Files
spellforge/docs_dev/specs/2026-07-30-bullet-homing-design.md
T
joywayerandClaude Opus 5 3e45ef0059 docs: 计划回填实际执行的验收脚本 + 订正权威文档自相矛盾处 + 性能数字改区间
【计划 Task 4】原稿四个脚本有缺陷,照它复跑会得到假失败与空通过,现已替换为
实际执行并通过的版本,每处旁注「原脚本为何有缺陷」,并勾上完成的复选框:
- Step 2 敌人摆位 (400,200) 距原点 447px 已出 homing_range=400,实测 FAILS=1;
  这是测试摆位错误而非实现缺陷(精确距离过滤行为正确)。改到 (300,150)。
- Step 6 内联复刻了折叠逻辑与商店谓词——在测自己;改调真实 _apply_modifier
  与 ShopManager._available_pool(),另补 compile_wand→execute_compiled 端到端。
- Step 7 无墙钟起搏,40 帧全停在退避窗内、守卫沦为空断言;且几何让子弹直穿
  敌人簇,多计约 1.5ms 碰撞开销。改为 60fps 起搏 + spec §5.2 环形摆位,
  frame1 与稳态峰值分开报。
- Step 8 只对源码 grep 字符串,不是往返;改为真实实例化 spell_tab 对真实
  spells.json 做 8 条 type-1 的回读→写回比对。
回溯表补「观测方式」列,标出⑥是唯一一条状态推断而非直接观测。

【architecture_design.md】同一文档内自相矛盾:
- CastStats 块 homing_force(「归航强度,向最近敌人偏转」)与 §4.2 新写的
  「最大转向角速度 + 锁定式目标」冲突 → 订正为 homing_add(弧度/秒);
  顺带订正确证过的 pierce_add / bounce_add(原写 *_count)。该块其余字段
  仍有既有漂移,加 ⚠️ 注明未核对、以 cast_stats.gd 为准,不扩大改动范围。
- ProjectileDef 的 homing_force 删除——projectile_def.gd 无任何 homing 字段,
  归航冷数据由 _push_projectile 直接写入 BulletManager。
- §4.2 bounce 协同散文补上实际存在的 if cold.has("homing_strength") 前置守卫。
- get_nearest_pos 代码片段:_enemy_count → _active_count(前者不存在)、
  未命中返回值 Vector2.ZERO → origin、删除不存在的 _visible_flags 过滤。

【spec】
- §4 第⑥条措辞「不触发 query_circle」字面为假(_check_collision 每帧无条件
  发一次),改为「不因归航触发」,并写明这是唯一一条间接验证及其封闭性论证。
- §5.5 峰值改为区间表述(① 4~5ms / ② 5~7ms @1500),注明编辑器/调试构建、
  运行间离散(同场景三次得 5.61/5.09/7.29ms),判定回归看是否出现周期性复发
  尖峰与是否越过 16.67ms,勿拿单值比对;并记录几何对测量的影响。
- §5.4 勘误:「1493 个互异到期值」不可能成立(jitter 只能产出约 200 个整数值,
  同帧失败又共用同一 now),复测为跨度 223ms / 222 个互异值,结论不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:33:16 +08:00

316 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 子弹归航(Homing)设计
> **日期**2026-07-30
> **Epic**:E1 战斗深度 · 子计划 ③(缺失功能路线图 `docs_dev/plans/2026-07-23-missing-features-roadmap.md`
> **优先级**P0 · 工作量 S
> **目标**:让子弹在飞行途中以**受限角速度**转向锁定的敌人(中度制导),激活 `homing` 深度维度;配 `modifier_homing` 词条。
---
## 1. 背景与现状(代码复核 2026-07-30
- 子弹热数据 SoA`BULLET_STRIDE=12`),冷数据在 `_bullet_contexts: Dictionary`key=bullet_idx)。现已消费 `pierce_remaining` / `bounce_remaining` / `visited_targets`**无任何 homing 字段**。
- `bullet_manager.gd:22` 声明 `_enemy_pos_snapshot``:31` 每物理帧调 `EnemyManager.fill_pos_snapshot` 填充,但 `_gd_integrate` 从不读取 —— **零消费者的每帧 O(敌人数) 纯浪费**。全项目 grep 确认 `fill_pos_snapshot` 只此一个调用方。
- 修饰器管线已完备且被 pierce/bounce 两次验证:`CastStats` 累加字段 → `_apply_modifier` 折叠 meta → `_push_projectile` 写冷数据 → `bullet_manager` 消费。homing 完全镜像此路径,仅字段类型由 int 变 float。
- `EnemyManager.get_pos_by_id` 对已死/不存在实体返回哨兵 `(-9999,-9999)``bullet_manager.gd:69` 已在依赖此约定 —— 可直接用作「锁定目标已失效」的判据。
- `SpatialGrid.CELL_SIZE = 64``query_circle` 按半径覆盖的格子做三重循环,**且每次调用都新建 `PackedInt32Array`**`spatial_grid.gd:42-45` 注释:不可复用成员缓冲,嵌套调用会就地覆写,已实测复现)。半径 400 覆盖约 196 格,而现有碰撞查询半径 22 仅覆盖 1–2 格 —— **一次归航选目标 ≈ 一百多次碰撞查询**,故选目标必须是低频操作(见 §2.4)。
- GDScript 语义实测(编辑器内 lambda 验证,2026-07-30):`PackedVector2Array` 作函数参数是**引用语义**`resize()` 与元素写入均回传调用方。故 `fill_pos_snapshot` 本身工作正常,问题只是无人消费。
## 2. 设计
### 2.0 手感定位
**中度制导**:明显画弧追踪,但有最大转向角速度上限 —— 追不上贴脸急转的目标,会「甩尾」绕过去再回头。
反面:不做「轻度辅助瞄准」(玩家感知不到花了 8 蓝买了什么),也不做「每帧速度矢量直指目标」的强锁定(近乎必中,会让手瞄与闪避设计同时失效)。
转弯半径 = 速度 ÷ 角速度。主流子弹速度 ~350(`spark_bolt` 350 / `fire_bolt` 320 / `frost_bolt` 360):
| 角速度 | 350 速度下转弯半径 | 手感 |
| :-- | :-- | :-- |
| 2 rad/s | 175 px | 偏弱,大圆弧 |
| **3 rad/s(单层词条)** | **117 px** | **明显制导,急转的敌人能甩掉** |
| 6 rad/s(叠 2 层) | 58 px | 很难甩掉 |
| 12 rad/s(叠 4 层) | 29 px | 接近必中 |
不设叠加上限 —— 堆到必中是合法 build 收益,与 bounce 堆叠同理。
### 2.1 冷数据字段(子弹 `_bullet_contexts[idx]`
- `homing_strength: float` —— **最大转向角速度,单位弧度/秒**
- `homing_range: float`(默认 400.0)—— 选目标搜索半径。给得比 `bounce_range`(250) 大,因为归航是「飞行途中找目标」而非「命中时找下一个」;但保持有限,空旷处子弹走直线、不会永远不落空。
- `homing_target_id: int` —— 锁定的 entity_id`-1` = 未锁定/待重选。
SoA 布局(`BULLET_STRIDE=12`)**不变**。归航是稀疏特性,进冷数据符合既有分工。
### 2.2 CastStats`cast_stats.gd`
-`var homing_add: float = 0.0`(紧邻 `bounce_add`+ `reset()` 置 0.0。
- **CIRCUIT 分支快照/还原**`spell_evaluator.gd` `_run_branch_payload`)纳入 `homing_add`,注释「9 字段」→「10 字段」。
> 注:现有快照块**仍未含** `pierce_add`bounce 那期就记录的 pre-existing 泄漏,本期同样不修,出范围);`homing_add` 按正确做法纳入,与 `bounce_add` 一致。
### 2.3 修饰器折叠与发射
`_apply_modifier`
```gdscript
if meta.has("homing"):
ctx.stats.homing_add += float(meta["homing"])
```
`_push_projectile`(紧随现有 bounce 块):
```gdscript
var homing: float = float(meta.get("homing", 0.0)) + ctx.stats.homing_add
if homing > 0.0:
cold["homing_strength"] = homing
cold["homing_range"] = float(meta.get("homing_range", 400.0))
cold["homing_target_id"] = -1
```
**发射时不解析目标**`homing_target_id``-1`,第一帧在 `_gd_integrate` 惰性获取。这样 `spell_evaluator` 不必接触 `SpatialGrid`,选目标逻辑全项目只有一处。
### 2.4 目标获取与失效(低频查询策略)
```
目标有效(get_pos_by_id ≠ 哨兵)→ 直接用,零查询
目标失效或首帧(id < 0) → 场上无敌人则直行;否则 _find_nearest_unvisited(pos, homing_range, visited)
```
稳态下一颗子弹一生只查 1–2 次(首帧获取 + 目标死亡后重选),开销可忽略。
**空场守卫**:重选前先判 `EnemyManager.get_active_count() == 0` 直接跳过,挡掉「清场瞬间所有在飞子弹集体重选且都查不到」这一最常见的病态帧。
> **补充(实测后修订)**:上面「一生只查 1–2 次」只在**重选成功**时成立;重选**失败**时会每帧重试,空场守卫挡不住。实测已越过触发线,故 §5 的退避方案(`homing_retry_at`)已随本期落地 —— 失败路径退避 200 ms 后再试。详见 §5。
复用现成的 `_find_nearest_unvisited`(bounce 期引入)—— 它已经滤除哨兵与已访问目标。纯归航子弹传 `cold.get("visited_targets", [])`(无该键时为空数组)。
### 2.5 转向积分(`bullet_manager._gd_integrate`,循环体顶部、位置积分之前)
前置:`strength = float(cold["homing_strength"])``tpos = EnemyManager.get_pos_by_id(tid)``tid` 由 §2.4 解析。**`tid < 0`(重选失败/场上无敌人)时整段跳过,子弹按原速度直行。**
```gdscript
var vel := Vector2(_data[base + 2], _data[base + 3])
var desired := (tpos - Vector2(_data[base + 0], _data[base + 1])).angle()
var diff := wrapf(desired - vel.angle(), -PI, PI) # 取最短转向方向
var step := clampf(diff, -strength * delta, strength * delta)
var nv := vel.rotated(step)
_data[base + 2] = nv.x
_data[base + 3] = nv.y
```
三个要点:
1. `rotated()` **保持速率不变** —— 归航只改方向不改速度,与 bounce 重定向时用 `spd` 保持速率的处理一致。
2. `wrapf(..., -PI, PI)` 保证走最短转向方向,不会为了追一个偏 179° 的目标而绕远路。
3. `clampf``strength * delta` 即「最大角速度」,落实 §2.0 的中度制导。
放在位置积分**之前**,故子弹当帧即沿新方向前进。
**外层守卫**`if not _bullet_contexts.is_empty()` 后再逐弹 `has()`。纯普通子弹的额外成本是一次整数键哈希查找/帧。
### 2.6 与 bounce 协同
bounce 在命中瞬间把速度硬重定向到「最近未访问敌」(`bullet_manager.gd:116`),而 homing 每帧转向自己锁定的目标 —— 不协调的话**命中后下一帧 homing 就会覆盖 bounce 的重定向**bounce 直接失效。
**语义:bounce 负责选目标,homing 负责追上去。** 在 bounce 重定向块内追加一行,把 `homing_target_id` 改写为 bounce 刚选中的 `next_id`
```gdscript
if cold.has("homing_strength"):
cold["homing_target_id"] = next_id
```
bounce 现有行为一行不改(仍是命中瞬间硬重定向),homing 只接手其后的飞行段。两个词条叠加体感相乘而非互相抵消。
**死循环规避**homing 重选走 `_find_nearest_unvisited`,天然跳过 `visited_targets`。否则归航子弹会锁定一个已命中过的敌人,而命中循环的 visited 跳过守卫(`bullet_manager.gd:78`)让它永远打不中 —— 子弹绕着该敌人打转直到 lifetime 耗尽。
### 2.7 删除死代码
- `bullet_manager.gd`:删 `_enemy_pos_snapshot` 成员(:22)、`_physics_process` 中的 `fill_pos_snapshot` 调用(:31)、文件头注释里的 homing 快照说明(:21)。
- `enemy_manager.gd`:删 `fill_pos_snapshot` 函数本体(:345-350)—— grep 确认无其它调用方,保留即死代码。
净效果:**减少一份每帧 O(敌人数) 开销**。
> 与路线图的偏离(有意):路线图 E1-③ 原文写「`_gd_integrate` 消费 `_enemy_pos_snapshot`」。实际设计选择了**按 entity_id 锁定目标**(轨迹可读、目标死亡有明确判据),而快照是**按槽位存位置、不含 entity_id**,无法支撑锁定语义。故快照不是「接上消费者」而是「删除」。路线图对应条目在实现完成后一并订正。
### 2.8 修饰器数据(`data/spells.json`
```json
"modifier_homing": { "type": 1, "display_name": "Homing", "description": "后续法术获得归航(飞行中以受限角速度转向锁定的敌人)。", "element_tags": [], "meta": { "homing": 3.0, "mana_cost": 8, "shop_cost": 18 } }
```
`mana_cost` / `shop_cost``modifier_pierce_plus``modifier_bounce` 完全对齐。`shop_cost>0` → 自动进商店池。
**不新增自带归航的 ACTION 法术** —— 只出 `modifier_homing` 一个词条,与 bounce 当期只加修饰器的做法一致。
### 2.9 设计器字段(`addons/game_designer/spell_tab.gd`
MODIFIER`type 1`)的 meta schema 加:
```gdscript
{"k": "homing", "l": "归航 homing", "w": "f", "d": 0.0, "opt": true},
{"k": "bounce", "l": "弹跳 bounce", "w": "i", "d": 0, "opt": true},
```
`bounce` 是**顺手补上的遗漏** —— bounce 那期只加了运行时,没加设计器字段,导致 `bounce` 键至今掉进「其它(JSON)」逃生舱,违反 CLAUDE.md「字段化,禁止手写 JSON」。同一处 schema、两行,属于本次动到的代码的定向修正,不扩散到无关重构。
## 3. 影响文件
| 文件 | 改动 |
| :-- | :-- |
| `scripts/domain/spell_system/cast_stats.gd` | `homing_add` 字段 + reset |
| `scripts/domain/spell_system/spell_evaluator.gd` | `_apply_modifier` 折叠 + `_push_projectile` 写 cold + 分支快照/还原纳入 `homing_add` |
| `scripts/autoloads/bullet_manager.gd` | `_gd_integrate` 转向段 + bounce 块改写 `homing_target_id` + 删快照成员/调用 |
| `scripts/autoloads/enemy_manager.gd` | 删 `fill_pos_snapshot` |
| `data/spells.json` | `modifier_homing` 词条 |
| `addons/game_designer/spell_tab.gd` | MODIFIER schema 加 `homing` + 补 `bounce` |
## 4. 验收标准
全部经 Godot MCP 运行时实测,断言确定性数值而非目测:
1. **归航生效**`homing=3.0` 子弹朝偏移目标画弧并命中;`homing=0` 对照组直线飞过。断言速度矢量方向随帧变化(对照组不变)。
2. **角速度上限**:单帧转角 ≤ `homing_strength * delta`(浮点容差内)。
3. **速率守恒**:转向前后 `Vector2(vx,vy).length()` 不变。
4. **最短转向**:目标位于子弹正后方偏一侧时,转向方向为夹角较小的一侧(验证 `wrapf`)。
5. **目标失效重选**:锁定目标死亡后,下一帧 `homing_target_id` 变为另一存活敌人。
6. **空场直行**:场上无敌人时子弹保持直线,且**不因归航**触发 `query_circle`
> **措辞修正(2026-07-30 验收期)**:原写「不触发 `query_circle`」,字面为假 —— `_check_collision``bullet_manager.gd:69`)每帧对每颗子弹**无条件**发一次 `query_circle(pos, radius+16)`,与归航无关。被验证的命题是「`_apply_homing` 这条路径不发查询」。
>
> **⚠️ 本条是全部 10 条验收里唯一的间接验证**,值得留痕。无法给 autoload 的方法挂调用计数器,故改用状态判据:空场跑 6 帧后断言 `homing_target_id` 未变且冷数据无 `homing_retry_at` 键。其封闭性论证 ——
> 1. `_apply_homing` 内通往 `SpatialGrid` 的路径**有且仅有**一处 `_find_nearest_unvisited`,无其它 `SpatialGrid` 访问;
> 2. 该调用之后必然二选一:成功则写 `homing_target_id`(变为 ≥0 的新值),失败则写 `homing_retry_at`
> 3. 两者皆未发生 ⟹ 该调用未被执行 ⟹ 归航未发查询;
> 4. 零敌人时 `_check_collision` 在 `:71``hits.is_empty()`)即返回,bounce 路径不可达,不会与之混淆。
>
> 论证对被限定后的命题成立,但**它是状态推断而非对调用次数的直接观测** —— 如实标注,不宣称为直接测量。
7. **bounce 协同**`homing + bounce` 子弹弹跳后 `homing_target_id == ` bounce 选中的 `next_id`
8. **修饰器折叠**:两层 `modifier_homing``ctx.stats.homing_add == 6.0`
9. **商店与设计器**`modifier_homing` 可在商店买到;设计器 `homing`/`bounce` 字段回读往返一致(对真实 `data/spells.json`)。
10. `validate_script` 通过;纯数据驱动;无目标时不崩。
## 5. R-H1 选目标频率尖峰 —— 已实测触发并启用逃生方案
> **状态更新(2026-07-30Task 2 代码质量评审后)**:本节原写「方案 3 暂不实现」,等验收期实测再定。评审在 Task 2 上直接实测,**两种病态状态在 300 弹规模就已越过本节写死的 2.0 ms 触发线**,故方案 3 已随 Task 2 落地,不再等待。以下如实记录,含原始风险分析的不完整之处。
### 5.1 病态状态(其二为本次新发现)
重选**失败**时原实现不留任何记录,于是该子弹余生每帧都重跑 `_find_nearest_unvisited``query_circle(pos, 400)`(约 196 格 + 一次 `PackedInt32Array` 分配)。`get_active_count() == 0` 空场守卫只挡住「全场清空」—— 而这是本游戏里最罕见的状态,两种日常状态都没挡住:
1. **敌人在场但全在 `homing_range` 外**(玩家朝空处开火)—— 本节原文已预见。
2. **射程内敌人全在 `visited_targets` 里** —— **原风险分析漏掉了这一种,是本次发现,且比第 1 种更糟**bounce+homing 组合弹打空一个小簇后 `visited_targets` 只增不减,对那颗子弹是**永久状态**而非瞬态;同时 `visited` 的线性 `in` 扫描叠加在 `query_circle` 之上,单弹成本更高。
原分析把 R-H1 定性为「瞬态尖峰」是不准确的 —— 存在使其长期化的路径。
### 5.2 实测(启用前 vs 启用后)
测法:运行时直调 `BulletManager._gd_integrate(1/60)` 计时,20 帧取均值。30 个敌人置于环上(`far` 环半径 3000`visited` 环半径 300 —— 在 `homing_range` 400 内但在碰撞半径 20 外),子弹簇于原点附近,`homing_strength=3.0``homing_range=400`。对照基线为同规模无冷数据普通子弹。
| 场景 | 1500 弹(启用前) | 300 弹(启用前) | 1500 弹(启用后·摊薄) | 300 弹(启用后·摊薄) |
| :-- | --: | --: | --: | --: |
| 基线(普通子弹,无归航) | 1.630 ms | 0.324 ms | 1.642 ms | 0.324 ms |
| ① 敌人全在射程外 | **15.790 ms** | **3.135 ms** | 3.939 ms | 0.784 ms |
| ② 射程内全在 visited | **21.965 ms** | **4.341 ms** | 4.518 ms | 0.884 ms |
单弹成本:① 10.53 µs/帧、② 14.64 µs/帧。两种场景在 300 弹规模分别为 3.14 / 4.34 ms**均已越过本节 2.0 ms 触发线**,故触发条件成立。
「启用后」的摊薄值按 60fps 下 12 帧中 1 帧付全价计算 `(重选帧 + 11 × 退避帧) / 12`。分解:
| 场景 · 规模 | 退避帧(零查询) | 重选帧(全价) | 摊薄 |
| :-- | --: | --: | --: |
| ① 1500 弹 | 2.856 ms | 15.851 ms | 3.939 ms |
| ① 300 弹 | 0.567 ms | 3.167 ms | 0.784 ms |
| ② 1500 弹 | 2.915 ms | 22.150 ms | 4.518 ms |
| ② 300 弹 | 0.570 ms | 4.340 ms | 0.884 ms |
### 5.3 已启用的方案 3(退避 + 抖动)
重选失败时往冷数据写 `homing_retry_at`,窗口内子弹直行且不再查询,60fps 下把最坏情况摊薄约 **12×**。要点:
- **首次锁定仍是即时的** —— 无该键时 `get` 返回 0 必小于 `now`,退避只作用于失败路径,§2.0 的手感不回退(运行时已断言首帧即锁定且不写退避键)。
- 重选**成功**即 `erase("homing_retry_at")`,恢复 §2.4 的零查询稳态。
- 顺带同期落地:`homing_strength` 判别提到 `_gd_integrate` 调用方,带冷数据但不归航的子弹(纯 pierce/bounce/状态/荷载,真实 build 里占多数)不再付一次完整函数调用 —— 1500 纯 pierce 弹 2.107 → 1.812 ms。
### 5.4 二次发现:**朴素退避会同步,而非打散**(本次最值得记住的一条)
落地固定 200 ms 退避后按真实 60fps 起搏复测(每轮补足 16.67 ms 墙钟,只对 `_gd_integrate` 计时),发现**尖峰并未消失,只是变成周期性复发**。1500 弹 `visited` 场景逐帧耗时(ms):
```
抖动前: 21.9 2.8 2.9 2.9 2.9 2.9 2.9 2.9 2.9 2.9 2.8 3.1 | 21.8 2.8 ... | 21.0 2.9 ... | 20.9 3.7 3.9 3.8
↑第1帧 ↑第13帧 ↑第25帧 ↑第37帧
```
**恰好每 12 帧复发一次,永不衰减。** 根因:同一帧失败的所有子弹拿到同一个 `now`,到期时刻完全相同,一个退避周期后又整齐地一起重试。**退避把子弹锁进同相,而不是打散它们** —— 同波齐射本就天然同相,此后再无机会错开。设计退避/重试逻辑时若不显式加抖动,默认结果就是同步而非分散;这与「退避能摊薄尖峰」的直觉相反,摊薄后的均值数字恰恰会掩盖它。
**修法:抖动到期时刻**`_apply_homing` 失败路径):
```gdscript
var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count)
cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS
```
到期时刻均匀铺满 `[200, 400)` ms ≈ 恰好一个退避周期 ≈ 12 帧。**用「`bullet_idx` 在活跃弹数中的占比」而非 `bullet_idx % HOMING_RETRY_MS`**:后者在弹数少于窗口宽度时只能铺开 `_active_count` 毫秒(20 颗弹 → 20 ms ≈ 1.2 帧,等于没打散),占比式则不论弹数多少都铺满整窗。`HOMING_RETRY_MS` 一值两用且刻意耦合 —— 既是基础退避时长也是抖动窗口宽度,「铺满恰好一个退避周期」正是消峰所需的性质,拆成两个常量反而掩盖这层关系。
实测打散质量(2026-07-30 验收期复测):1500 颗同帧集体失败 → 到期时刻**跨度 223 ms**(≈ 恰好一个退避周期,即 13~14 帧)、**222 个互异毫秒值**,平均每毫秒约 6.8 颗、每帧约 110 颗(理想 500/12 = 125)。
> **勘误**:本节初稿写「1493 个互异值」,该数**不可能成立** —— `jitter = (bullet_idx * 200) / _active_count` 对 1500 颗弹只能产出 0~199 共约 200 个整数值,同帧失败的子弹又共用同一个 `now`,故互异到期时刻上限就是 ~200 个。复测得 222,多出的 22 来自「遍历这 1500 颗本身耗时约 23 ms,期间 `now` 在推进」(与 §5.6 记录的首帧 23 ms 吻合)。**打散结论不变**(跨度铺满一个完整退避周期即消峰所需性质),只是原先那个数字是错的。
### 5.5 抖动前后单帧峰值对比(60fps 起搏 · 40 帧)
| 场景 · 规模 | 抖动前·复发尖峰 | 抖动后·稳态峰值(区间) | 稳态超 16.67ms 帧数 |
| :-- | --: | --: | --: |
| ① 全射程外 · 1500 | 13.6 ~ 15.7 ms(每 12 帧复发) | **4 ~ 5 ms** | 0/39 |
| ① 全射程外 · 300 | 3.2 ms | **约 1 ms** | 0/39 |
| ② 全 visited · 1500 | 20.9 ~ 21.9 ms(每 12 帧复发) | **5 ~ 7 ms** | 0/39 |
| ② 全 visited · 300 | 4.6 ms | **1 ~ 2.5 ms** | 0/39 |
复发尖峰彻底消除,稳态全部回到帧预算内。
> **⚠️ 数字按区间读,不要按单值读**:以上均为**编辑器/调试构建**实测,运行间存在明显离散。同一场景 ②·1500 三次独立测量分别得 **5.61 / 5.09 / 7.29 ms**240 帧长跑收敛在 5–7 ms 带内、无发散。写成单值会显得比数据本身更精确 —— 结论(稳态远低于 16.67 ms 帧预算、无复发尖峰)不受此离散影响,但**不要引用单个数字做后续推算**,也不要因复跑得到带内的不同值就判定为回归。判定回归请看:是否出现周期性复发尖峰、是否有帧越过 16.67 ms。
>
> 另注:稳态峰值对**测试几何**敏感。若子弹航线穿过敌人簇,`_check_collision` 的 `query_circle` + `visited_targets` 线性扫描会额外贡献 1~1.5 ms,那部分**不是归航成本**(验收期曾因此误读一次)。上表摆位为「敌人置于环上、子弹簇于原点」,刻意让子弹不穿过敌人簇。
### 5.6 残余:初次锁定帧 —— **已决策:接受,不处理**
抖动后仍有**一个**高耗时帧 —— 第 1 帧,1500 弹 `far` 16.2 ms / `visited` 22.0 ms。它**不是**退避尖峰,而是「§2.4 首次锁定即时、不受退避约束」的直接结果:1500 颗归航子弹在同一帧被创建时,它们的首次 `query_circle` 必然落在同一帧。抖动按设计不作用于此(已断言首帧即时锁定且不写退避键,手感不回退)。
触发条件苛刻:需**同一帧**创建约 1500 颗归航子弹且首次选目标全部失败。典型局面下齐射规模远小于此,且失败还需叠加「全射程外」或「全 visited」。
**决策(2026-07-30):接受,本期不处理。** 理由三条:
1. **触发条件不是真实局面。** 1500 弹是全游戏子弹池的压力上限(`MAX_BULLETS=2048`),要求它们**全部**归航、**同帧**创建、且首次选目标**全部**失败 —— 这是三个独立苛刻条件的合取,属合成的最坏情况而非可达的玩法状态。项目实测基准是 500 敌 + 1500 弹时整个 `_physics_process` ≈ 0.6 ms(帧预算 3.6%)。
2. **它一次性、不复发。** 与被修掉的那个每 12 帧永久复发的尖峰性质不同 —— 后者是持续卡顿,前者最多是一次可忽略的掉帧。
3. **压下它的代价是手感回退或语义变更。** 三条可行路径(首帧也抖动 / 连续失败 N 次后彻底放弃归航 / 给 `visited_targets` 设上限)分别牺牲「首次锁定即时」、改变归航语义、改变 bounce 语义。按 CLAUDE.md「先测量后优化,不做无凭据的过早优化」,为一个合成场景付这个代价不划算。
**重新立项的触发条件(按弹数算,不要按场景猜)**:成本严格线性于「**同一帧**创建、且首次选目标**全部失败**的归航弹数」,约 **9.5 µs/颗**。据此可直接算出击穿点,无需揣摩「典型齐射规模」:
| 同帧首选失败的归航弹数 | 该帧额外耗时 | 判断 |
| --: | --: | :-- |
| 200 | ≈ 1.9 ms | 无感 |
| 500 | ≈ 4.8 ms | 可接受 |
| 1000 | ≈ 9.5 ms | 接近半帧,警戒 |
| **1750** | **≈ 16.7 ms** | **满帧预算,必然掉帧** |
即**约 1750 颗**是理论击穿点(`MAX_BULLETS = 2048`,故容量内可达)。注意实测 1500 颗 `visited` 场景为 22.0 ms,高于线性外推的 14.3 ms —— 差额来自 `visited_targets` 的线性 `in` 扫描,故 ② 类场景的实际击穿点更低。
**当同帧齐射的归航弹数逼近千级时重新立项**,按上述三条路径之一处理。
### 5.7 潜在项(当前休眠,**不修**):退避用墙钟,不免疫时间缩放
退避基于 `Time.get_ticks_msec()` —— **墙钟,不受 `Engine.time_scale` 影响**。而 `TimeManager` 正是为「慢动作 / 冻结时间 Core 效果」驱动 `Engine.time_scale` 的(`time_manager.gd:4,16`)。
**当前不是问题**grep 确认 `TimeManager.set_time_scale` 全项目**零调用方**(仅 `time_manager.gd:23` 定义),慢动作 Core 尚未落地。
**若日后落地**:设 0.2× 慢动作,200 ms 墙钟只跨约 2.4 个物理帧而非 12,每帧重选量涨约 5×,§5.4 的抖动摊薄基本失效 —— 且表现为**慢动作期间掉帧**,即最不该掉帧的时候掉。
**替代方案(触发时再实施,现在不动)**:改用物理帧计数而非墙钟 —— `HOMING_RETRY_FRAMES: int = 12`,抖动公式变成帧原生的 `(bullet_idx * HOMING_RETRY_FRAMES) / maxi(1, _active_count)`。天然免疫时间缩放、比 `Time.get_ticks_msec()` 略便宜,并消除 ms ↔ 帧的量纲错配(现在「200 ms ≈ 12 帧」只在 60fps 且 `time_scale == 1` 时成立)。本项目已有现成计数器 `TimeManager.game_tick``time_manager.gd:9,15`),其注释明写「不受 time_scale 影响」,正合用。
**触发条件**:慢动作 / 冻结时间 Core 落地时,即 `set_time_scale` 出现第一个调用方。
## 6. 非目标(YAGNI
- **轨迹修正 / 曲线弹道**(E1 子计划 ④,角速度常量而非寻的)—— 独立立项。
- **自带归航的 ACTION 法术** —— 本期只出修饰器。
- **归航拖尾 / 锁定指示 VFX** —— 沿用现有 `hit_spark`
- **敌方子弹归航**`EnemyBulletManager`)—— 本期仅玩家子弹。
- **C# 热路径** —— GDScript 足够(500 敌 3.6% 预算),且本期净减开销。
- **修复 `pierce_add` 的分支快照遗漏** —— pre-existing,出本期范围。
- **修复 `acceleration` 积分错误** —— `_gd_integrate:42-43` 对 vx/vy 各自加同一标量 `accel*delta`(应为沿速度方向加速)。当前无任何法术写入非零 `acceleration`,故为惰性死代码;会在 E1-④ 轨迹修正启用时暴露。出本期范围,已另行记录。