diff --git a/docs_dev/plans/2026-07-30-bullet-homing.md b/docs_dev/plans/2026-07-30-bullet-homing.md new file mode 100644 index 0000000..e7e349a --- /dev/null +++ b/docs_dev/plans/2026-07-30-bullet-homing.md @@ -0,0 +1,540 @@ +# 子弹归航(Homing)Implementation 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/+1,vx/vy=slot+2/+3,lifetime=slot+4,damage_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 +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 +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 +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 +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 |