Files
spellforge/docs_dev/plans/2026-07-30-bullet-homing.md
T
joywayerandClaude Opus 5 364ebe4c62 docs(plan): E1-③ 子弹归航逐任务实现计划(5 任务 · 含 wrapf 跨 ±π 判据与 R-H1 性能守卫)
Task0 建分支 / Task1 法术 VM 侧 / Task2 bullet_manager 转向+删死快照 /
Task3 设计器字段(含补漏的 bounce)/ Task4 MCP 运行时验收。

验收脚本全部用确定性数值断言:单帧转角上限、速率守恒、跨 ±π 的最短转向
(该摆位下有无 wrapf 会给出符号相反的结果,是有效判据)、目标失效重选、
空场直行、bounce 协同改写 homing_target_id、两层折叠 6.0。

Task4 Step7 实测 spec R-H1 病态场景(50 敌全在射程外 + 300 归航弹),
超 2ms/帧则按 spec §5 启用失败退避逃生方案。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:51:18 +08:00

541 lines
27 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.
# 子弹归航(HomingImplementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 子弹在飞行途中以受限角速度转向锁定的敌人(中度制导),激活 homing 深度维度;配 `modifier_homing` 词条。
**Architecture:** 镜像现有 bounce 管线。`CastStats.homing_add`float 累加)→ `_apply_modifier` 折叠 meta → `_push_projectile` 写子弹冷数据 → `bullet_manager._gd_integrate` 每帧在位置积分**之前**转向。目标策略为「发射后锁定」:仅在目标死亡(`get_pos_by_id` 返回哨兵)或首帧时经 `_find_nearest_unvisited` 重选,配空场守卫,使 `query_circle` 不进入每帧热路径。同时删除无人消费的 `_enemy_pos_snapshot` 及其生产者。纯 GDScript 回退路径。
**Tech Stack:** Godot 4.7.1 Mono / GDScript / 纯 JSON 数据驱动 / godot-mcp-pro 运行时实测(无 pytest;转向行为经运行中战斗场景 `execute_game_script` 数值断言)。
> **规范**:所有 `.gd` 改动后 `validate_script`;纯数据驱动;提交中文。spec`docs_dev/specs/2026-07-30-bullet-homing-design.md`。
> **关键既有事实**:子弹 SoA `BULLET_STRIDE=12`x/y=slot+0/+1vx/vy=slot+2/+3lifetime=slot+4damage_mult=slot+7。`EnemyManager.get_pos_by_id` 未命中返回 `Vector2(-9999.0, -9999.0)`。`EnemyManager.get_active_count()` 存在(:313)。`_find_nearest_unvisited(origin, radius, visited) -> int` 已存在(`bullet_manager.gd:201`bounce 期引入,已滤哨兵与已访问)。`SpellType.MODIFIER=1`。`SpatialGrid.insert(entity_id, pos)` / `clear()` / `query_circle(center, radius)``spawn_enemy` **不**自动插网格。
---
### Task 0: 建功能分支
- [ ] **Step 1: 从 master 切分支**
当前在 `master`,CLAUDE.md 规定主分支上不做功能开发。
```bash
git checkout -b feat/bullet-homing
```
Expected: `Switched to a new branch 'feat/bullet-homing'`
---
### Task 1: 法术 VM 侧 —— CastStats.homing_add + 折叠 + 发射写冷数据 + 分支快照 + modifier_homing 数据
**Files:**
- Modify: `scripts/domain/spell_system/cast_stats.gd`(字段 :15 之后、reset :27 之后)
- Modify: `scripts/domain/spell_system/spell_evaluator.gd``_push_projectile` :438 之后、`_apply_modifier` :533 之后、分支快照 :615/:625/:652
- Modify: `data/spells.json`(加 `modifier_homing`
- [ ] **Step 1: CastStats 加 homing_add 字段**
`cast_stats.gd`:在 `var bounce_add: int = 0 ...`:15)之后新增:
```gdscript
var homing_add: float = 0.0 # MODIFIER 累加的归航角速度(弧度/秒,叠加到 ACTION 自带 homing)
```
`reset()``bounce_add = 0`:27)之后新增:
```gdscript
homing_add = 0.0
```
- [ ] **Step 2: `_apply_modifier` 折叠 homing**
`spell_evaluator.gd``_apply_modifier`,在 `if meta.has("bounce"): ctx.stats.bounce_add += int(meta["bounce"])`:532-533)之后新增:
```gdscript
if meta.has("homing"):
ctx.stats.homing_add += float(meta["homing"])
```
(缩进:与 `if meta.has("bounce"):` 同层,函数体一级缩进。)
- [ ] **Step 3: `_push_projectile` 写归航冷数据**
`spell_evaluator.gd``_push_projectile`,在现有 bounce 冷数据块最后一行 `cold["bounce_range"] = float(meta.get("bounce_range", 250.0))`:438)之后新增:
```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
```
(缩进:`var homing``var bounce`:433)同层,在 `for i in spread:` 循环体内;`cold[...]` 再深一级。)
- [ ] **Step 4: CIRCUIT 分支快照/还原纳入 homing_add**
`spell_evaluator.gd``_run_branch_payload`。在快照段 `var b_bnc: int = s.bounce_add`:625)之后新增:
```gdscript
var b_hom: float = s.homing_add
```
在还原段 `s.bounce_add = b_bnc`:652)之后新增:
```gdscript
s.homing_add = b_hom
```
把 :615 的注释「快照分支前 CastStats(9 字段,无分配)」改为「(10 字段,无分配)」。
- [ ] **Step 5: spells.json 加 modifier_homing**
`data/spells.json``modifier_bounce` 行之后(同为 type 1 修饰器区)新增一行(注意逗号):
```json
"modifier_homing": { "type": 1, "display_name": "Homing", "description": "后续法术获得归航(飞行中以受限角速度转向锁定的敌人)。", "element_tags": [], "meta": { "homing": 3.0, "mana_cost": 8, "shop_cost": 18 } },
```
- [ ] **Step 6: 校验语法 + 数据**
Run: godot-mcp-pro `validate_script` on `res://scripts/domain/spell_system/cast_stats.gd``res://scripts/domain/spell_system/spell_evaluator.gd` — 均无错误。
Run: godot-mcp-pro `execute_editor_script`
```gdscript
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/spells.json"))
var ok: bool = d is Dictionary and d.has("modifier_homing") \
and abs(float(d["modifier_homing"]["meta"].get("homing", 0.0)) - 3.0) < 0.001 \
and int(d["modifier_homing"]["meta"].get("shop_cost", 0)) == 18
_mcp_print("FAILS=%d" % (0 if ok else 1))
```
Expected: `FAILS=0`
- [ ] **Step 7: 提交**
```bash
git add scripts/domain/spell_system/cast_stats.gd scripts/domain/spell_system/spell_evaluator.gd data/spells.json
git commit -F- <<'EOF'
feat(homing): CastStats.homing_add + 修饰器折叠 + 发射写归航冷数据 + 分支快照; modifier_homing 词条
homing_add 为 float(角速度,弧度/秒),镜像 bounce_add 的 int 累加路径。
发射时不解析目标,homing_target_id 留 -1 由 bullet_manager 首帧惰性获取,
使 spell_evaluator 不必接触 SpatialGrid。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```
---
### Task 2: 子弹归航转向 —— bullet_manager + 删除死快照
**Files:**
- Modify: `scripts/autoloads/bullet_manager.gd`(文件头注释 :21、成员 :22、`_physics_process` :31、`_gd_integrate` :36-51、bounce 块 :119 之后、新增 `_apply_homing`
- Modify: `scripts/autoloads/enemy_manager.gd`(删 `fill_pos_snapshot` :345-350
- [ ] **Step 1: 删除无人消费的敌人位置快照**
`bullet_manager.gd`:删除第 21-22 两行:
```gdscript
# 敌人位置快照(homing 共用,由 _physics_process 帧头建立)
var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array()
```
并删除 `_physics_process`:30-33)内的第 31 行 `EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot)`,使其变为:
```gdscript
func _physics_process(delta: float) -> void:
if not _cs_node:
_gd_integrate(delta)
```
**不要动**文件头第 8 行的冷数据注释 —— 它列出的 `homing` 正是本期要实现的冷数据字段,删除的只是那份按槽位存位置、无人消费的快照。
`enemy_manager.gd`:删除 :345-350 整个函数(含其上的 `##` 注释行):
```gdscript
## 建立敌人位置快照(BulletManager homing 共用)
func fill_pos_snapshot(out_arr: PackedVector2Array) -> void:
out_arr.resize(_active_count)
for i in _active_count:
var base: int = i * ENEMY_STRIDE
out_arr[i] = Vector2(_data[base + 0], _data[base + 1])
```
(已 grep 确认全项目无其它调用方。)
- [ ] **Step 2: `_gd_integrate` 循环体顶部插入归航转向调用**
`bullet_manager.gd``_gd_integrate`,改为(新增第 2 行 `has_cold` 与循环体内的两行 if):
```gdscript
func _gd_integrate(delta: float) -> void:
var has_cold: bool = not _bullet_contexts.is_empty()
var i: int = 0
while i < _active_count:
var base: int = i * BULLET_STRIDE
if has_cold and _bullet_contexts.has(i):
_apply_homing(i, base, delta) # 转向须在位置积分之前,子弹当帧即沿新方向前进
_data[base + 0] += _data[base + 2] * delta # x += vx * dt
_data[base + 1] += _data[base + 3] * delta # y += vy * dt
_data[base + 2] += _data[base + 11] * delta # vx += accel * dt
_data[base + 3] += _data[base + 11] * delta # vy += accel * dt
_data[base + 4] -= delta # lifetime -= dt
if _data[base + 4] <= 0.0:
_swap_and_pop(i)
elif _check_collision(i, base):
# 命中处理已在 _check_collision 内完成,子弹被回收
pass # _active_count 已减少,i 不递增
else:
i += 1
```
- [ ] **Step 3: 新增 `_apply_homing` 辅助**
`bullet_manager.gd``_find_nearest_unvisited`(:201,删除快照两行后行号上移 2)之后新增:
```gdscript
## 归航转向:以 homing_strength(弧度/秒) 为上限把速度矢量转向锁定目标,速率不变
## 目标失效(死亡/未锁定)时重选一次;场上无敌人或射程内无目标则保持直行
func _apply_homing(bullet_idx: int, base: int, delta: float) -> void:
var cold: Dictionary = _bullet_contexts[bullet_idx]
var strength: float = float(cold.get("homing_strength", 0.0))
if strength <= 0.0:
return
var pos: Vector2 = Vector2(_data[base + 0], _data[base + 1])
var tid: int = int(cold.get("homing_target_id", -1))
var tpos: Vector2 = Vector2(-9999.0, -9999.0)
if tid >= 0:
tpos = EnemyManager.get_pos_by_id(tid)
if tpos == Vector2(-9999.0, -9999.0):
# 目标失效或首帧:重选。空场守卫避免清场瞬间全体子弹集体 query_circle
if EnemyManager.get_active_count() == 0:
return
tid = _find_nearest_unvisited(pos, float(cold.get("homing_range", 400.0)), cold.get("visited_targets", []))
cold["homing_target_id"] = tid
_bullet_contexts[bullet_idx] = cold
if tid < 0:
return
tpos = EnemyManager.get_pos_by_id(tid)
if tpos == Vector2(-9999.0, -9999.0):
return
var vel: Vector2 = Vector2(_data[base + 2], _data[base + 3])
var diff: float = wrapf((tpos - pos).angle() - vel.angle(), -PI, PI) # 最短转向方向
var max_step: float = strength * delta
var nv: Vector2 = vel.rotated(clampf(diff, -max_step, max_step)) # rotated 保持速率不变
_data[base + 2] = nv.x
_data[base + 3] = nv.y
```
- [ ] **Step 4: bounce 重定向时改写 homing 目标(协同)**
`bullet_manager.gd``_check_collision` 弹跳分支,在 `cold["bounce_remaining"] = bounce - 1`:119)之前插入:
```gdscript
if cold.has("homing_strength"):
cold["homing_target_id"] = next_id # bounce 选目标,homing 接手追上去
```
(缩进:与 `cold["bounce_remaining"] = bounce - 1` 同层,在 `if next_id >= 0:` 块内。)
- [ ] **Step 5: 校验语法**
Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/bullet_manager.gd``res://scripts/autoloads/enemy_manager.gd` — 均无错误。
- [ ] **Step 6: 提交**
```bash
git add scripts/autoloads/bullet_manager.gd scripts/autoloads/enemy_manager.gd
git commit -F- <<'EOF'
feat(homing): bullet_manager 受限角速度转向 + 锁定式目标重选 + bounce 协同; 删除无人消费的位置快照
_apply_homing 在位置积分前把速度矢量朝目标旋转,单帧转角上限 strength*delta
rotated() 保持速率不变;wrapf 取最短转向方向。
目标仅在失效或首帧时经 _find_nearest_unvisited 重选,配 get_active_count()==0
空场守卫,避免 query_circle(r=400) 进入每帧热路径。
bounce 命中重定向时改写 homing_target_id,由 bounce 选目标 homing 追上去。
删除 _enemy_pos_snapshot 及 EnemyManager.fill_pos_snapshot(全项目唯一调用方)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```
---
### Task 3: 设计器 MODIFIER 字段 —— 加 homing + 补漏掉的 bounce
**Files:**
- Modify: `addons/game_designer/spell_tab.gd`MODIFIER schema :154 附近)
- [ ] **Step 1: MODIFIER schema 加两个字段**
`spell_tab.gd``type 1`MODIFIER)分支,在 `{"k": "pierce", "l": "穿透 pierce", "w": "i", "d": 0, "opt": true},`:154)之后新增两行:
```gdscript
{"k": "bounce", "l": "弹跳 bounce", "w": "i", "d": 0, "opt": true},
{"k": "homing", "l": "归航 homing", "w": "f", "d": 0.0, "opt": true},
```
`bounce` 是补上期遗漏 —— 它至今掉在「其它(JSON)」逃生舱里(:81),违反 CLAUDE.md「字段化,禁止手写 JSON」。标签遵循「中文 English」双语序。
- [ ] **Step 2: 校验语法**
Run: godot-mcp-pro `validate_script` on `res://addons/game_designer/spell_tab.gd` — 无错误。
- [ ] **Step 3: 对真实数据验证 schema 覆盖**
> ⚠️ dock 里驻留的旧 `@tool` 类需 `EditorInterface.restart_editor(true)` 清缓存才测得到新代码(`reload_project`/`reload_plugin` **不**重编译已加载类)。本步绕开类实例化,直接断言「schema 声明的键集合已覆盖真实 JSON 里所有 type 1 法术的 meta 键」,不受缓存影响。
Run: godot-mcp-pro `execute_editor_script`
```gdscript
var src := FileAccess.get_file_as_string("res://addons/game_designer/spell_tab.gd")
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/spells.json"))
# 通用键由表单固定处理,不进 meta schema
var generic := {"mana_cost": true, "shop_cost": true}
var missing := []
for id in d.keys():
if int(d[id].get("type", -1)) != 1:
continue
for k in d[id].get("meta", {}).keys():
if generic.has(k):
continue
if not src.contains('"k": "%s"' % k):
missing.append("%s.%s" % [id, k])
_mcp_print("FAILS=%d missing=%s" % [missing.size(), str(missing)])
```
Expected: `FAILS=0 missing=[]`(改动前跑此脚本会报 `bounce` 缺失 —— 可先跑一次确认它确实抓得到回归。)
- [ ] **Step 4: 提交**
```bash
git add addons/game_designer/spell_tab.gd
git commit -F- <<'EOF'
feat(homing): 设计器 MODIFIER 表单加 homing 字段,并补上期遗漏的 bounce 字段
bounce 自上期起一直掉在「其它(JSON)」逃生舱,违反 CLAUDE.md 字段化规范,
与 homing 同处 schema,一并补上。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```
---
### Task 4: 验收 —— 运行时集成实测(MCP)+ 收尾
**Files:** 无(运行时断言)+ 收尾文档
- [ ] **Step 1: 启动战斗场景**
Run: godot-mcp-pro `play_scene``res://scenes/main/combat_s2.tscn`。确认运行、`get_editor_errors` count=0。
- [ ] **Step 2: 归航生效 + 角速度上限 + 速率守恒(验收①②③)**
> 摆位:敌人在子弹**侧前方**,子弹沿 +x 直飞。归航子弹应逐帧朝敌人转向;对照组(无 homing 冷数据)方向恒定。
> `delta = 1/60``strength = 3.0` → 单帧最大转角 `0.05 rad`。
Run: godot-mcp-pro `execute_game_script`
```gdscript
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var e = EnemyManager.spawn_enemy(Vector2(400, 200), 100000.0, 0) # 侧前方,血厚防被打死
SpatialGrid.insert(e, Vector2(400, 200))
var dt := 1.0 / 60.0
# 归航弹:起点远离敌人避免命中,沿 +x,速度 350
BulletManager.spawn_bullet(Vector2(0, 0), Vector2(350, 0), 5.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0,
{"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1})
# 对照弹:同起点同速度,无冷数据
BulletManager.spawn_bullet(Vector2(0, 0), Vector2(350, 0), 5.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0)
var fails := 0
var max_step_seen := 0.0
var prev_a := Vector2(BulletManager._data[2], BulletManager._data[3]).angle()
var speed0 := Vector2(BulletManager._data[2], BulletManager._data[3]).length()
for k in 5:
BulletManager._gd_integrate(dt)
var v := Vector2(BulletManager._data[2], BulletManager._data[3])
var step: float = abs(wrapf(v.angle() - prev_a, -PI, PI))
if step > max_step_seen: max_step_seen = step
prev_a = v.angle()
var vh := Vector2(BulletManager._data[2], BulletManager._data[3]) # 归航弹
var vc := Vector2(BulletManager._data[BulletManager.BULLET_STRIDE + 2],
BulletManager._data[BulletManager.BULLET_STRIDE + 3]) # 对照弹
if vh.angle() <= 0.001: fails += 1 # ① 已朝 +y 侧转向(敌在下方)
if abs(vc.angle()) > 0.001: fails += 1 # ① 对照组仍是 +x
if max_step_seen > 3.0 * dt + 0.0005: fails += 1 # ② 单帧转角 ≤ strength*delta
if abs(vh.length() - speed0) > 0.5: fails += 1 # ③ 速率守恒
_mcp_print("FAILS=%d ah=%.4f ac=%.4f max_step=%.4f cap=%.4f spd=%.2f%.2f" %
[fails, vh.angle(), vc.angle(), max_step_seen, 3.0 * dt, speed0, vh.length()])
```
Expected: `FAILS=0``ah` 明显 > 0(约 0.05×5 ≈ 0.25 rad,受实际几何略有出入但必为正),`ac ≈ 0``max_step ≤ 0.05`,速率两端均 ≈350。
- [ ] **Step 3: 最短转向方向(验收④)**
> **必须跨越 ±π 分界才测得到 `wrapf`**。子弹速度方向 ≈ **+170°**2.966 rad),目标方向 ≈ **170°**(−2.966 rad):
> - 原始差值 `2.966 2.966 = 5.932`,若**不**做 `wrapf` 会被 clamp 成 `0.05`,朝**反方向绕远路**
> - `wrapf(5.932, PI, PI) = +0.351`clamp 成 `+0.05`,朝**短边**转。
>
> 两种实现给出**符号相反**的结果,故本步是 `wrapf` 的有效判据。(若把目标摆在正前方侧向,两种实现结果相同,测不出问题 —— 不要那样摆。)
Run: godot-mcp-pro `execute_game_script`
```gdscript
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
# 目标方向 atan2(-53, -300) ≈ -2.966 rad(−170°)
var e = EnemyManager.spawn_enemy(Vector2(-300, -53), 100000.0, 0)
SpatialGrid.insert(e, Vector2(-300, -53))
# 子弹速度方向 atan2(61.7, -350) ≈ +2.966 rad+170°)
BulletManager.spawn_bullet(Vector2(0, 0), Vector2(-350, 61.7), 5.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0,
{"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1})
var a0 := Vector2(BulletManager._data[2], BulletManager._data[3]).angle()
BulletManager._gd_integrate(1.0 / 60.0)
var a1 := Vector2(BulletManager._data[2], BulletManager._data[3]).angle()
var delta_a: float = wrapf(a1 - a0, -PI, PI)
var fails := 0
if delta_a <= 0.0: fails += 1 # 必须为正(短边);无 wrapf 的实现会得到负值
if abs(abs(delta_a) - 0.05) > 0.005: fails += 1 # 且恰好吃满单帧上限 3.0/60
_mcp_print("FAILS=%d a0=%.4f a1=%.4f delta=%.4f" % [fails, a0, a1, delta_a])
```
Expected: `FAILS=0``delta ≈ +0.0500`(正值)。若实现漏了 `wrapf`,此处会得到 `delta ≈ -0.0500``FAILS=1`
- [ ] **Step 4: 目标失效重选 + 空场直行(验收⑤⑥)**
Run: godot-mcp-pro `execute_game_script`
```gdscript
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var e1 = EnemyManager.spawn_enemy(Vector2(200, 200), 100000.0, 0)
var e2 = EnemyManager.spawn_enemy(Vector2(200, -200), 100000.0, 0)
SpatialGrid.insert(e1, Vector2(200, 200))
SpatialGrid.insert(e2, Vector2(200, -200))
BulletManager.spawn_bullet(Vector2(0, 0), Vector2(350, 0), 5.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0,
{"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1})
BulletManager._gd_integrate(1.0 / 60.0)
var locked: int = int(BulletManager._bullet_contexts[0]["homing_target_id"])
var fails := 0
if locked < 0: fails += 1 # 首帧应完成锁定
# 杀掉被锁定的目标 → 下一帧应重选到另一个
EnemyManager.despawn_enemy(locked)
SpatialGrid.remove_entity(locked)
BulletManager._gd_integrate(1.0 / 60.0)
var relocked: int = int(BulletManager._bullet_contexts[0]["homing_target_id"])
if relocked < 0 or relocked == locked: fails += 1 # ⑤ 已重选到另一个存活敌人
# 清场 → 直行,方向不再变化
EnemyManager.reset(); SpatialGrid.clear()
BulletManager._gd_integrate(1.0 / 60.0)
var a1 := Vector2(BulletManager._data[2], BulletManager._data[3]).angle()
BulletManager._gd_integrate(1.0 / 60.0)
var a2 := Vector2(BulletManager._data[2], BulletManager._data[3]).angle()
if abs(a2 - a1) > 0.0001: fails += 1 # ⑥ 空场直行
_mcp_print("FAILS=%d locked=%d relocked=%d a1=%.4f a2=%.4f" % [fails, locked, relocked, a1, a2])
```
Expected: `FAILS=0``relocked` 为另一个 entity_id`a1 == a2`
> 适配注:若 `despawn_enemy` / `SpatialGrid.remove_entity` 签名不符,先 `read_script` 确认;核心断言是「锁定目标消失后 `homing_target_id` 变为另一存活敌人」与「无敌人时方向恒定」。
- [ ] **Step 5: homing + bounce 协同(验收⑦)**
Run: godot-mcp-pro `execute_game_script`
```gdscript
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var e1 = EnemyManager.spawn_enemy(Vector2(300, 0), 100000.0, 0)
var e2 = EnemyManager.spawn_enemy(Vector2(300, 100), 100000.0, 0) # 弹跳的下一目标
SpatialGrid.insert(e1, Vector2(300, 0))
SpatialGrid.insert(e2, Vector2(300, 100))
BulletManager.spawn_bullet(Vector2(300, 0), Vector2(200, 0), 5.0, 6.0, 100.0, 1.0, 0, -1, 0, 0.0,
{"bounce_remaining": 1, "visited_targets": [], "bounce_decay": 0.9, "bounce_range": 250.0,
"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1})
BulletManager._gd_integrate(1.0 / 60.0) # 命中 e1 → bounce 重定向到 e2 并改写 homing 目标
var cold: Dictionary = BulletManager._bullet_contexts[0]
var fails := 0
if int(cold.get("homing_target_id", -1)) != e2: fails += 1 # ⑦ homing 目标 == bounce 选中的敌人
if int(cold.get("bounce_remaining", -1)) != 0: fails += 1 # bounce 已消耗
_mcp_print("FAILS=%d tgt=%d e2=%d bounce_left=%d" %
[fails, int(cold.get("homing_target_id", -1)), e2, int(cold.get("bounce_remaining", -1))])
```
Expected: `FAILS=0``tgt == e2`
- [ ] **Step 6: 修饰器折叠 + 商店可购(验收⑧⑨前半)**
Run: godot-mcp-pro `execute_game_script`
```gdscript
var fails := 0
var sp = SpellRegistry.get_spell("modifier_homing")
if sp == null: fails += 1
if not ("modifier_homing" in SpellRegistry.get_all_ids()): fails += 1
var cs = CastStats.new()
var meta := {"homing": 3.0}
# 折叠两层:模拟 _apply_modifier 对同一 meta 调用两次
for k in 2:
if meta.has("homing"):
cs.homing_add += float(meta["homing"])
var folded := cs.homing_add
if abs(folded - 6.0) > 0.001: fails += 1 # ⑧ 两层 → 6.0
cs.reset()
if abs(cs.homing_add) > 0.001: fails += 1 # reset 归零
# 商店可抽池条件(shop_manager.gd:33 注释):shop_cost>0 且 unlock_cost==0 或已解锁
var in_pool: bool = sp != null \
and int(sp.meta.get("shop_cost", 0)) > 0 \
and int(sp.meta.get("unlock_cost", 0)) == 0
if not in_pool: fails += 1
_mcp_print("FAILS=%d folded=%.2f after_reset=%.2f in_pool=%s" %
[fails, folded, cs.homing_add, in_pool])
```
Expected: `FAILS=0 folded=6.00 after_reset=0.00 in_pool=true`
> API 已确认存在:`SpellRegistry.get_spell(id)`:36)、`get_all_ids()`:39)、`CastStats` 为 `class_name` 注册的 RefCounted(可 `.new()`)。
- [ ] **Step 7: 性能守卫 —— 确认未触发 R-H1 尖峰**
> spec §5 记录的风险:「敌人在场但均超出 `homing_range`」时每颗归航子弹每帧都会 `query_circle`。此步实测该病态场景,决定是否启用逃生方案(方案 3 失败退避)。
Run: godot-mcp-pro `execute_game_script`
```gdscript
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
# 敌人全部远在 homing_range(400) 之外 → 每帧重选且每帧失败
for k in 50:
var eid = EnemyManager.spawn_enemy(Vector2(3000 + k * 10, 0), 1000.0, 0)
SpatialGrid.insert(eid, Vector2(3000 + k * 10, 0))
for k in 300:
BulletManager.spawn_bullet(Vector2(0, k), Vector2(350, 0), 10.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0,
{"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1})
var t0 := Time.get_ticks_usec()
for k in 60:
BulletManager._gd_integrate(1.0 / 60.0)
var per_frame := float(Time.get_ticks_usec() - t0) / 60.0 / 1000.0 # ms/帧
_mcp_print("per_frame_ms=%.3f threshold=2.000 escape_needed=%s" %
[per_frame, str(per_frame > 2.0)])
```
Expected: `escape_needed=false``per_frame_ms` 应远低于 2.0)。
`escape_needed=true`**按 spec §5 启用方案 3**`_apply_homing` 重选失败时写 `cold["homing_retry_at"] = Time.get_ticks_msec() + 200`,重选前先判 `Time.get_ticks_msec() < int(cold.get("homing_retry_at", 0))` 则直接 return;重选成功时抹掉该键。然后重跑本步确认降到阈值以下,并在 spec §5 记录实测数据与启用结论。
- [ ] **Step 8: 设计器字段回读往返(验收⑨后半)**
Run: godot-mcp-pro `stop_scene`,然后 `execute_editor_script`(复用 Task 3 Step 3 的 schema 覆盖脚本,此时应含 `homing`):
```gdscript
var src := FileAccess.get_file_as_string("res://addons/game_designer/spell_tab.gd")
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/spells.json"))
var generic := {"mana_cost": true, "shop_cost": true}
var missing := []
for id in d.keys():
if int(d[id].get("type", -1)) != 1:
continue
for k in d[id].get("meta", {}).keys():
if generic.has(k):
continue
if not src.contains('"k": "%s"' % k):
missing.append("%s.%s" % [id, k])
_mcp_print("FAILS=%d missing=%s" % [missing.size(), str(missing)])
```
Expected: `FAILS=0 missing=[]`
- [ ] **Step 9: 更新路线图 + memory + 提交**
- `docs_dev/plans/2026-07-23-missing-features-roadmap.md`
- E1「现状」的「归航」行改为完成态,注明**偏离**:未消费 `_enemy_pos_snapshot` 而是将其删除(锁定语义需 entity_id,快照按槽位存位置不含 id)。
- 切分列表第 3 项「归航 homing」勾除(`~~**归航 homing**~~ ✅ **完成**`),格式对齐已完成的 ①②。
- memory `slice-progress-vs-plan.md` + `MEMORY.md` 索引:记 E1-③ 归航完成(分支/commit/关键实现与实测结论),并更新「next up」为 E1-④ 轨迹修正或 E3 经济与 build。
```bash
git add docs_dev/plans/2026-07-23-missing-features-roadmap.md
git commit -F- <<'EOF'
docs(roadmap): E1-③ 子弹归航完成,勾除现状项 + 记录快照删除这一有意偏离
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```
- [ ] **Step 10: 收尾 —— 合并决策**
实现完成、全部验收 `FAILS=0` 后,用 `superpowers:finishing-a-development-branch` 决定分支去向(合并回 `master` / 保留 / PR)。前三个 E1 子项(i-frames / 抗性 / 弹跳)均已合并进 `master` 且删除分支,本分支照同样处理即可。
---
## 验收标准回溯(spec §4
| # | 验收标准 | 覆盖任务 |
| :- | :-- | :-- |
| 1 | 归航生效(画弧)+ 对照组直线 | Task 4 Step 2 (`ah`>0 / `ac`≈0) |
| 2 | 单帧转角 ≤ `strength * delta` | Task 4 Step 2 (`max_step_seen`) |
| 3 | 速率守恒 | Task 4 Step 2 (`vh.length()`) |
| 4 | 最短转向方向(`wrapf` | Task 4 Step 3 |
| 5 | 目标失效后重选 | Task 4 Step 4 (`relocked`) |
| 6 | 空场直行且不查询 | Task 4 Step 4 (`a1==a2`) |
| 7 | bounce 协同改写 `homing_target_id` | Task 4 Step 5 |
| 8 | 两层修饰器 → `homing_add == 6.0` | Task 4 Step 6 |
| 9 | 商店可购 + 设计器字段往返 | Task 4 Step 6 / Step 8 |
| 10 | `validate_script` 通过 / 无目标不崩 | Task 1 Step 6、Task 2 Step 5、Task 3 Step 2、Task 4 Step 4 |
| — | R-H1 风险实测与逃生方案判定(spec §5 | Task 4 Step 7 |