Files
spellforge/docs_dev/plans/2026-07-30-bullet-homing.md
joywayerandClaude Opus 5 e651072b9b docs: 订正归航手感论证的运动学错误 + 记录定价越权与用户决策
【spec §2.0】原论证只算转弯半径就断言「急转的敌人能甩掉」,从未拿它对照敌人
实际速度 —— 明写此错,并补上缺的那一步运动学核算:跟踪横向速度 v、距离 d 的
目标所需角速度 ω≈v/d,故跟丢条件是 d < v/ω_max(近距离判据)。代入实际数值
(敌 45~140 px/s、弹 ~350 px/s) 重列强度表,标出发版值 1.5、给出各档对最快敌人
的跟丢距离。说明中远距离不是区分点(1.5 与 3.0 余量分别近 5 倍/10 倍,都能可靠
命中),真实失手方式是【近距离过冲】——目标坐进转弯圈内、几何上无法收敛,
而这正是 1.5 想要的手感。叠加表按 1.5 递增重列,堆到必中仍是合法 build 收益。

【spec §2.8】承认原「与 pierce/bounce 对齐」的论证绕过了权威:那两个词条本身
就不在定价表里,拿两个未定价条目互相对齐等于自建平行标准。而
numerical_design.md 确实给 homing 定过价(Tier 3 / Mana 40),且该表是活的权威——
其 Mana 列与 spells.json 每个已实现条目精确吻合,仅 homing 一行偏离。记录用户
决策及其取舍:这个词条能卖同族价,是因为它被调到了同族强度。

【numerical_design.md】homing 行按 P6-N2/N25 同样风格标注:原行删除线保留可
追溯(不抹掉「曾判定为 Tier 3」这个记录),表下补注说明 Homing Force 语义已废
(实现为最大转向角速度 rad/s,与 5.0 不同量纲不可比)、Mana 40 被同族标准取代、
实际发版 homing 1.5 / Mana 8 / 商店 18,并链接 spec。

【architecture_design.md】§4.2 冷数据注释的示例值 3.0 → 1.5(含转弯半径 233px)。

【plan】Task 1 Step 5/6 的 JSON 与断言期望值同步为 1.5;验收⑧(Step 6/6b)重跑
并更新为 folded=3.00 / e2e_2stack=3.000 / e2e_1stack=1.500;Step 8b 补设计器
往返与 SpinBox 格点确认输出。新增顶部醒目段落说明【机制测试①②③④⑦里的 3.0
是测试局部值、与发版值刻意解耦】——平衡调整不应导致机制测试失败,不要为了
数字一致去改它们;唯一应随发版值走的是⑧。Step 7 注明平衡调整不影响性能守卫
(强度只进转向数学,不参与决定 query_circle 频率的任何分支),未重跑。

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

818 lines
48 KiB
Markdown
Raw Permalink 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: 建功能分支
- [x] **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`
- [x] **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
```
- [x] **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"):` 同层,函数体一级缩进。)
- [x] **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[...]` 再深一级。)
- [x] **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 字段,无分配)」。
- [x] **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": 1.5, "mana_cost": 8, "shop_cost": 18 } },
```
> **`homing` 值于 2026-07-30 发版前平衡复核由 `3.0` 降为 `1.5`**`mana_cost` / `shop_cost` 不变)。原 3.0 经运动学核算实为「接近稳定命中」,配不上 8 蓝同族价;1.5 才兑现 spec §2.0 的「中度制导」。理由与定价决策见 spec §2.0 / §2.8 与 `docs/design/numerical_design.md` Tier 3 表下的 `homing` 行注。
- [x] **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)) - 1.5) < 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``1.5` 为 2026-07-30 平衡复核后的发版值,原 3.0)
- [x] **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
- [x] **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 确认全项目无其它调用方。)
- [x] **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
```
- [x] **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
```
- [x] **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:` 块内。)
- [x] **Step 5: 校验语法**
Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/bullet_manager.gd``res://scripts/autoloads/enemy_manager.gd` — 均无错误。
- [x] **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 附近)
- [x] **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」双语序。
- [x] **Step 2: 校验语法**
Run: godot-mcp-pro `validate_script` on `res://addons/game_designer/spell_tab.gd` — 无错误。
- [x] **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` 缺失 —— 可先跑一次确认它确实抓得到回归。)
- [x] **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:** 无(运行时断言)+ 收尾文档
> ### ⚠️ 关于脚本里的 `homing_strength: 3.0`(读 Step 2~5 之前先看这段)
>
> **机制测试(①②③④⑦,Step 2 / 2b / 3 / 4 / 5)里的 `3.0` 是测试局部值,与 `modifier_homing` 的发版值刻意解耦。** 这些脚本**直接构造冷数据字典**传给 `spawn_bullet`**不读 `data/spells.json`** —— 它们验证的是转向数学(角速度上限、速率守恒、最短转向、目标重选、bounce 协同),与词条卖多少钱、单层给多少强度无关。
>
> 选 `3.0` 是因为 `3.0 / 60 = 0.05` 是个干净的判据数,断言里 `cap=0.050000` 一眼可读。
>
> **2026-07-30 平衡调整把发版值从 3.0 降到 1.5(见 spec §2.0/§2.8),这些脚本刻意不跟着改。** 平衡调整**不应该**导致机制测试失败 —— 若把它们改成 1.5,单帧上限变成 0.025,判据数变难读,而且下次调平衡又得再改一轮。**这是有意的设计,不要为了「数字一致」去改。**
>
> 唯一**应当**跟随发版值的是 **⑧(Step 6 / 6b)** —— 它测的正是「JSON → `_apply_modifier` 折叠 → 冷数据」这条数据驱动链路,期望值必须等于发版值。
- [x] **Step 1: 启动战斗场景**
Run: godot-mcp-pro `play_scene``res://scenes/main/combat_s2.tscn`。确认运行、`get_editor_errors` count=0。
- [x] **Step 2: 归航生效 + 角速度上限 + 速率守恒(验收①②③)**
> 摆位:敌人在子弹**侧前方**,子弹沿 +x 直飞。归航子弹应逐帧朝敌人转向;对照组(无 homing 冷数据)方向恒定。
> `delta = 1/60``strength = 3.0` → 单帧最大转角 `0.05 rad`。
>
> ⚠️ **本脚本已按实际执行版本替换(2026-07-30 验收期)**。原稿把敌人放在 `(400, 200)` —— 距原点 **447.2 px,超出 `homing_range = 400`**`_find_nearest_unvisited` 的精确距离过滤(`best_sq = radius * radius`)会正确地拒绝它,于是归航完全不动、`FAILS=1`。这是**测试摆位错误,不是实现缺陷**(`query_circle` 按格子返回该敌,但精确距离判据把它滤掉了,行为正确)。已改到 `(300, 150)` = 335 px,落在射程内。**不要退回 `(400, 200)`。**
Run: godot-mcp-pro `execute_game_script`
```gdscript
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var ep := Vector2(300, 150) # dist=335 < homing_range 400(原稿 (400,200) 距 447 已出射程)
var e = EnemyManager.spawn_enemy(ep, 100000.0, 0) # 血厚防被打死
SpatialGrid.insert(e, ep)
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()
var angles := []
var speeds := []
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()
angles.append("%.5f" % v.angle())
speeds.append("%.4f" % v.length())
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=%.5f ac=%.5f max_step=%.6f cap=%.6f spd=%.4f->%.4f ctrl_spd=%.4f" %
[fails, vh.angle(), vc.angle(), max_step_seen, 3.0 * dt, speed0, vh.length(), vc.length()])
_mcp_print("angles=%s" % str(angles))
_mcp_print("speeds=%s tid=%d retry_key=%s" % [str(speeds),
int(BulletManager._bullet_contexts[0]["homing_target_id"]),
str(BulletManager._bullet_contexts[0].has("homing_retry_at"))])
```
实测输出:
```
FAILS=0 ah=0.25000 ac=0.00000 max_step=0.050000 cap=0.050000 spd=350.0000->350.0000 ctrl_spd=350.0000
angles=["0.05000", "0.10000", "0.15000", "0.20000", "0.25000"]
speeds=["350.0000","350.0000","350.0000","350.0000","350.0000"] tid=1 retry_key=false
```
`max_step` 恰等于 `cap`,逐帧速率恒定 350。反事实检验:删掉 `clampf``max_step=0.4636` 远超 `cap=0.05`,断言变红。
- [x] **Step 2b: 归航「真的命中」端到端(验收① 补强)**
> Step 2 只断言了方向变化。补一条端到端:敌人侧偏 60 px(远超碰撞半径 22),归航弹须命中并造成伤害,对照弹须完全打空。
```gdscript
var res := []
var fails := 0
for mode in 2:
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var ep := Vector2(300, 60)
var e = EnemyManager.spawn_enemy(ep, 1000.0, 0)
SpatialGrid.insert(e, ep)
var hp0 := EnemyManager.get_hp_percent(e)
if mode == 0:
BulletManager.spawn_bullet(Vector2(0, 0), Vector2(350, 0), 5.0, 6.0, 100.0, 1.0, 0, -1, 0, 0.0,
{"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1})
else:
BulletManager.spawn_bullet(Vector2(0, 0), Vector2(350, 0), 5.0, 6.0, 100.0, 1.0, 0, -1, 0, 0.0)
var hit_frame := -1
for k in 150:
BulletManager._gd_integrate(1.0 / 60.0)
if BulletManager.get_active_count() == 0:
hit_frame = k
break
var hp1 := EnemyManager.get_hp_percent(e)
if mode == 0 and (hit_frame < 0 or hp1 >= hp0): fails += 1 # 归航弹必须命中并造成伤害
if mode == 1 and (hit_frame >= 0 or hp1 < hp0): fails += 1 # 对照弹必须完全没打中
res.append("mode=%-7s hit_frame=%3d hp%% %.3f -> %.3f end_pos=(%.1f, %.1f)" %
["homing" if mode == 0 else "control", hit_frame, hp0, hp1,
BulletManager._data[0], BulletManager._data[1]])
_mcp_print("FAILS=%d" % fails)
for r in res: _mcp_print(r)
```
实测输出:
```
FAILS=0
mode=homing hit_frame= 48 hp% 1.000 -> 0.900 end_pos=(280.2, 55.9)
mode=control hit_frame= -1 hp% 1.000 -> 1.000 end_pos=(875.0, 0.0)
```
- [x] **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()
var tgt_ang := (Vector2(-300, -53) - Vector2(0, 0)).angle()
var raw_diff := tgt_ang - a0
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(%.1fdeg) tgt=%.4f(%.1fdeg) raw_diff=%.4f wrapped=%.4f delta=%.5f dist=%.1f" %
[fails, a0, rad_to_deg(a0), tgt_ang, rad_to_deg(tgt_ang), raw_diff,
wrapf(raw_diff, -PI, PI), delta_a, Vector2(-300, -53).length()])
# 自证本步非空断言:打印 |raw_diff| 是否真的跨过 ±π,以及无 wrapf 时 clamp 会得到什么
_mcp_print("crosses_pi=%s (|raw_diff|=%.4f > PI 说明必须 wrap;无 wrapf 时 clamp 结果为 %.4f)" %
[str(abs(raw_diff) > PI), abs(raw_diff), clampf(raw_diff, -0.05, 0.05)])
```
实测输出:
```
FAILS=0 a0=2.9671(170.0deg) tgt=-2.9667(-170.0deg) raw_diff=-5.9338 wrapped=0.3494 delta=0.05000 dist=304.6
crosses_pi=true (|raw_diff|=5.9338 > PI 说明必须 wrap;无 wrapf 时 clamp 结果为 -0.0500)
```
> **本步的空断言自检已内建**:脚本自己打印 `crosses_pi` 与「无 wrapf 时的 clamp 结果」,任何人复跑都能立刻看出摆位是否真的跨过 ±π。若把目标挪到子弹正前方侧向,`crosses_pi` 会变 `false`,此时本步退化为空断言 —— **看到 `crosses_pi=false` 即说明摆位被改坏了**。反事实检验:删掉 `wrapf` 后实测 `delta=-0.05000`,符号反转,断言变红。
- [x] **Step 4: 目标失效重选 + 空场直行(验收⑤⑥)**
> ⚠️ **「不触发 `query_circle`」是本次唯一的间接验证**。无法给 autoload 方法挂调用计数器,故用状态判据替代:`_apply_homing` 内通往 `SpatialGrid` 的路径**有且仅有**一处 `_find_nearest_unvisited`,其后必然二选一 —— 成功写 `homing_target_id`(变新值)、失败写 `homing_retry_at`;两者皆未发生 ⟹ 该路径未执行。零敌人时 `_check_collision` 在 `:71` 即返回,bounce 路径不可达,不会混淆。详见 spec §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 # 首帧应完成锁定
var no_retry_on_first: bool = not BulletManager._bullet_contexts[0].has("homing_retry_at")
if not no_retry_on_first: 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()
var angs := []
for k in 6:
BulletManager._gd_integrate(1.0 / 60.0)
angs.append(Vector2(BulletManager._data[2], BulletManager._data[3]).angle())
var a1: float = angs[0]
var a_last: float = angs[angs.size() - 1]
if abs(a_last - a1) > 0.0001: fails += 1 # ⑥ 空场直行
var tid_after: int = int(BulletManager._bullet_contexts[0]["homing_target_id"])
var no_retry: bool = not BulletManager._bullet_contexts[0].has("homing_retry_at")
if tid_after != relocked: fails += 1 # 成功路径未走(目标 id 未变)
if not no_retry: fails += 1 # 失败路径未走(无退避键)
_mcp_print("FAILS=%d locked=%d relocked=%d a_first=%.6f a_last=%.6f" % [fails, locked, relocked, a1, a_last])
_mcp_print("empty_field: tid_unchanged=%s(%d==%d) no_retry_key=%s angles=%s" %
[str(tid_after == relocked), tid_after, relocked, str(no_retry), str(angs)])
```
实测输出:
```
FAILS=0 locked=1 relocked=2 a_first=-0.000000 a_last=-0.000000
empty_field: tid_unchanged=true(2==2) no_retry_key=true angles=[-0.00000000544957, ×6 相同]
```
> 已确认 `despawn_enemy(id)` / `SpatialGrid.remove_entity(id)` 签名正确。原稿只跑 2 帧且未断言「无查询」,此处扩到 6 帧并补上两条状态判据。
- [x] **Step 5: homing + bounce 协同(验收⑦)**
> 相对原稿补强:预置一个**远未来的 `homing_retry_at`**,据此验证 bounce 路径的 `cold.erase("homing_retry_at")`(评审期修复的缺陷 —— 退避键残留会让子弹对着 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,
"homing_retry_at": Time.get_ticks_msec() + 999999}) # 预置过期退避键,验证 bounce 会撤销它
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 已消耗
if cold.has("homing_retry_at"): fails += 1 # bounce 给了有效目标,退避须被撤销
_mcp_print("FAILS=%d tgt=%d e1=%d e2=%d bounce_left=%d visited=%s retry_key=%s" %
[fails, int(cold.get("homing_target_id", -1)), e1, e2, int(cold.get("bounce_remaining", -1)),
str(cold.get("visited_targets", [])), str(cold.has("homing_retry_at"))])
_mcp_print("vel=(%.2f,%.2f) dmg_mult=%.3f" % [BulletManager._data[2], BulletManager._data[3], BulletManager._data[7]])
```
实测输出:
```
FAILS=0 tgt=2 e1=1 e2=2 bounce_left=0 visited=[1] retry_key=false
vel=(-6.66,199.89) dmg_mult=0.900
```
- [x] **Step 6: 修饰器折叠 + 商店可购(验收⑧⑨前半)**
> ⚠️ **本脚本已按实际执行版本替换(2026-07-30 验收期)**。原稿有两处**在测试自己而非测实现**:
> 1. 折叠部分把 `if meta.has("homing"): cs.homing_add += ...` **内联复刻**了一遍 —— 就算 `_apply_modifier` 里根本没写那两行,这段也照样 `folded=6.00` 通过。改为直接调真实的 `SpellEvaluator._apply_modifier(sp, ctx)`。
> 2. 商店部分**复刻**了 `shop_manager.gd:33` 的谓词 —— 若真实 `_available_pool()` 的过滤逻辑变了,此处测不出来。改为直接调 `ShopManager._available_pool()`。
>
> **不要退回内联复刻写法。**
Run: godot-mcp-pro `execute_game_script`
```gdscript
var fails := 0
var sp: SpellNode = SpellRegistry.get_spell("modifier_homing")
if sp == null: fails += 1
if not ("modifier_homing" in SpellRegistry.get_all_ids()): fails += 1
# ⑧ 走真实 _apply_modifier(而非内联复刻折叠逻辑)
var ctx := SpellContext.new()
ctx.stats = CastStats.new()
ctx.stats.reset()
SpellEvaluator._apply_modifier(sp, ctx)
SpellEvaluator._apply_modifier(sp, ctx)
var folded: float = ctx.stats.homing_add
if abs(folded - 3.0) > 0.001: fails += 1 # ⑧ 两层 → 3.0(= 2 × 发版值 1.5
ctx.stats.reset()
if abs(ctx.stats.homing_add) > 0.001: fails += 1 # reset 归零
# ⑨ 前半:走真实商店可抽池(而非复刻谓词)
var pool: Array = ShopManager._available_pool()
var in_pool: bool = "modifier_homing" in pool
if not in_pool: fails += 1
_mcp_print("FAILS=%d folded=%.2f after_reset=%.2f in_pool=%s pool_size=%d shop_cost=%d unlock_cost=%d mana_cost=%d" %
[fails, folded, ctx.stats.homing_add, str(in_pool), pool.size(),
int(sp.meta.get("shop_cost", 0)), int(sp.meta.get("unlock_cost", 0)), int(sp.meta.get("mana_cost", 0))])
```
实测输出(2026-07-30 平衡调整 `homing 3.0 → 1.5` 后重跑):
```
FAILS=0 per_stack=1.50 folded_2x=3.00 after_reset=0.00 in_pool=true shop_cost=18 unlock_cost=0 mana_cost=8
```
> **⑧ 的期望值随发版值走**:单层 = `spells.json` 的 `meta.homing`(现为 **1.5**),两层折叠 = 3.0。若日后再调平衡,这条断言的期望值须同步更新 —— 它**有意**耦合发版值,测的正是「JSON → 折叠 → 冷数据」这条数据驱动链路。
- [x] **Step 6b: 整条 VM 管线端到端(验收⑧ 补强)**
> Step 6 只验证了 `_apply_modifier` 这一环。补一条真实施法:`compile_wand([modifier_homing, modifier_homing, action_spark_bolt])` → `execute_compiled` → 断言落到子弹冷数据上的 `homing_strength == 6.0`,并用无修饰器的同一 ACTION 作对照(不应写任何 homing 键)。
> ⚠️ ACTION 的真实 id 是 **`action_spark_bolt`**,不是 `spark_bolt`。
```gdscript
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var core := CoreDefinition.new()
core.id = "test_core"; core.slot_count = 5; core.cpu_limit = 10; core.grid_cols = 5
var nodes := [SpellRegistry.get_spell("modifier_homing"),
SpellRegistry.get_spell("modifier_homing"),
SpellRegistry.get_spell("action_spark_bolt")]
var compiled = SpellEvaluator.compile_wand(core, nodes)
PlayerStats.mana = 999.0
SpellEvaluator.execute_compiled(compiled, -1, Vector2(0, 0))
var fails := 0
if BulletManager.get_active_count() < 1: fails += 1
var cold: Dictionary = BulletManager._bullet_contexts.get(0, {})
var hs: float = float(cold.get("homing_strength", -1.0))
if abs(hs - 3.0) > 0.001: fails += 1 # 2 × 发版值 1.5
if int(cold.get("homing_target_id", -99)) != -1: fails += 1 # 发射时不解析目标
if abs(float(cold.get("homing_range", -1.0)) - 400.0) > 0.001: fails += 1
# 对照:不带修饰器的同一 ACTION 不应写任何 homing 冷数据
BulletManager.reset()
var compiled2 = SpellEvaluator.compile_wand(core, [SpellRegistry.get_spell("action_spark_bolt")])
PlayerStats.mana = 999.0
SpellEvaluator.execute_compiled(compiled2, -1, Vector2(0, 0))
var cold2: Dictionary = BulletManager._bullet_contexts.get(0, {})
if cold2.has("homing_strength"): fails += 1
_mcp_print("FAILS=%d homing_strength=%.3f cold=%s" % [fails, hs, str(cold)])
_mcp_print("control_cold=%s" % str(cold2))
```
实测输出(2026-07-30 平衡调整后重跑,含单层对照):
```
FAILS=0 per_stack=1.50 folded_2x=3.00 e2e_2stack=3.000 e2e_1stack=1.500 mana=8 shop=18
cold_2stack={ "homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1 }
control_cold={ }
```
- [x] **Step 7: 性能回归守卫 —— R-H1 两种病态场景的单帧峰值**
> **2026-07-30 平衡调整(`homing 3.0 → 1.5`)不影响本步,未重跑。** 依据:`homing_strength` 只在 `_apply_homing` 末尾进入转向数学(`clampf(diff, ±strength*delta)`),**不参与**任何决定 `query_circle` 频率的分支 —— 查询频率只由「目标是否有效」与 `homing_retry_at` 退避窗决定,二者与强度无关(`strength <= 0.0` 的提前 return 是唯一例外,1.5 不触发)。且本步脚本用的是自己的测试局部值 3.0,字面上也未被改动。
> **本步性质已变更(2026-07-30,Task 2 代码质量评审后)**:原本这是「决策关卡」—— 实测后再定要不要启用 spec §5 的退避方案。评审在 Task 2 阶段就直接实测并**证实触发**(300 弹规模即越过 2.0 ms 线),故退避 + 抖动**已随 Task 2 落地**。本步因此降级为**回归守卫**:确认退避与抖动仍生效、峰值未回弹。
>
> **两种病态场景都要测**(原计划只写了第一种,漏了更糟的第二种):
> 1. **敌人在场但均超出 `homing_range`** —— 玩家朝空处/刚清空的方向开火。瞬态。
> 2. **射程内敌人全在 `visited_targets` 里** —— bounce+homing 组合弹打空一个小簇后。`visited` 只增不减,故这对那颗子弹是**永久状态**,比第一种更严重。这条是评审发现的,原 spec 风险分析漏掉了。
>
> **指标是单帧最大值,不是 60 帧均值。** 退避把成本摊薄成「1 个重选帧 + 11 个退避帧」,均值会掩盖重选帧的尖峰;而帧预算只有 16.67 ms,尖峰才是会被玩家看见的东西。抖动的作用正是打散这个尖峰。
> ⚠️ **本脚本已按实际执行版本替换(2026-07-30 验收期)**。原稿有两处会让守卫失真:
> 1. **没有墙钟起搏。** 退避基于 `Time.get_ticks_msec()`(墙钟)。连打 40 次 `_gd_integrate` 只跨几毫秒墙钟,40 帧**全部停在 `[200,400)` ms 退避窗内、一次重选都观测不到** —— 峰值看起来很低,但守卫**变成了空断言**。必须每帧补足到 16.67 ms(spec §5.5 即此方法)。
> 2. **几何让子弹直穿敌人簇。** 原稿子弹自 `(0,k)` 沿 +x 飞、敌人在 `(200+k, 100+k)`,第 3047 帧(x 175→274)正好穿过敌人簇,实测多出约 1.5 ms 的**碰撞**开销(`query_circle(r=22)` 命中 + `visited_targets` 线性扫描),那**不是归航成本**,验收期曾因此误读一次。改用 spec §5.2 的环形摆位:敌人置于环上(① 半径 3000 全出射程;② 半径 300 在射程 400 内、碰撞半径 22 外),子弹簇于原点附近。
>
> 另:**必须把 frame1 与稳态峰值分开报**。frame1 是 spec §5.6 已决策接受的「首次锁定尖峰」(不受退避约束,按设计如此),混进稳态峰值会掩盖真正要守的东西。
Run: godot-mcp-pro `execute_game_script`(一次跑完 6 组:基线/①/② × 300/1500):
```gdscript
var FRAMES := 40
var FRAME_US := 16670
var results := []
var configs := [[0, 300], [0, 1500], [1, 300], [1, 1500], [2, 300], [2, 1500]] # [场景, 弹数]
for cfg in configs:
var SCEN: int = cfg[0] # 0=基线(无冷数据) 1=全出射程 2=射程内全 visited
var N: int = cfg[1]
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var visited := []
if SCEN != 0:
var R: float = 3000.0 if SCEN == 1 else 300.0
for k in 30:
var a: float = TAU * k / 30.0
var p := Vector2(cos(a), sin(a)) * R
var eid = EnemyManager.spawn_enemy(p, 1000.0, 0)
SpatialGrid.insert(eid, p)
if SCEN == 2: visited.append(eid)
for k in N:
var sp0 := Vector2(0, k * 0.02) # 子弹簇于原点附近(30px 内),不穿过敌人环
if SCEN == 0:
BulletManager.spawn_bullet(sp0, Vector2(350, 0), 30.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0)
else:
var cold := {"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1}
if SCEN == 2: cold["visited_targets"] = visited.duplicate()
BulletManager.spawn_bullet(sp0, Vector2(350, 0), 30.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0, cold)
var pf := []
for k in FRAMES:
var t0 := Time.get_ticks_usec()
BulletManager._gd_integrate(1.0 / 60.0)
pf.append(Time.get_ticks_usec() - t0)
# 按真实 60fps 起搏:退避基于墙钟,不补足帧间隔则全程停在退避窗内,守卫会变成空断言
var rem := FRAME_US - (Time.get_ticks_usec() - t0)
if rem > 0: OS.delay_usec(rem)
var steady := 0
var over := 0
for k in range(1, FRAMES): # 跳过 frame1(§5.6 已接受的首次锁定尖峰)
if pf[k] > steady: steady = pf[k]
if pf[k] > 16670: over += 1
results.append("scen=%d n=%4d frame1=%7.3f 稳态峰值=%6.3f 超预算=%d/%d 末弹数=%d" %
[SCEN, N, pf[0]/1000.0, steady/1000.0, over, FRAMES-1, BulletManager.get_active_count()])
for r in results: _mcp_print(r)
```
实测输出:
```
scen=0 n= 300 frame1= 0.356 稳态峰值= 0.386 超预算=0/39 末弹数=300
scen=0 n=1500 frame1= 1.707 稳态峰值= 1.824 超预算=0/39 末弹数=1500
scen=1 n= 300 frame1= 3.600 稳态峰值= 0.936 超预算=0/39 末弹数=300
scen=1 n=1500 frame1= 17.118 稳态峰值= 4.662 超预算=0/39 末弹数=1500
scen=2 n= 300 frame1= 4.800 稳态峰值= 1.088 超预算=0/39 末弹数=300
scen=2 n=1500 frame1= 23.125 稳态峰值= 5.088 超预算=0/39 末弹数=1500
```
逐帧 trace 确认抖动仍生效 —— 无每 12 帧复发的尖峰,重选成本平铺在第 13–25 帧的 0.9–1.1 ms 带内。反事实检验:去掉抖动项后第 12/24 帧重现 22.6 / 23.0 ms 尖峰、`over16.67=3/39`
> **数字按区间读**:编辑器/调试构建,运行间离散明显(scen2@1500 三次独立测量 5.61 / 5.09 / 7.29 ms240 帧长跑收敛在 5–7 ms 带、无发散)。**判定回归看「是否出现周期性复发尖峰、是否有帧越过 16.67 ms」,不要拿单个数字比对。** 详见 spec §5.5。
> 若稳态出现 `超预算 > 0`,**停下来上报,不要自行加码** —— 那说明需要的是设计级方案(给 `visited_targets` 设上限、或让归航子弹连续失败 N 次后彻底放弃归航),属设计决策而非实现调优。
- [x] **Step 8: 设计器字段回读往返(验收⑨后半)**
> ⚠️ **本脚本已按实际执行版本替换(2026-07-30 验收期)**。原稿只是**对源码文本 grep** `'"k": "homing"'` —— 那只证明字符串出现在文件里,**不是往返**:schema 里写了键但 `_on_select` / `_read_meta` 任何一侧坏掉,grep 照样通过。改为按 CLAUDE.md 记录的绕缓存法(`GDScript.new()` + 从磁盘读 `source_code` + `reload()`)真实实例化 `spell_tab`,对**真实 `data/spells.json`** 的全部 type-1 法术做 `_select_id` → `_on_apply` → 键集合与逐键数值比对,并断言「其它(JSON)」逃生舱为空。**不要退回 grep 写法。**
Run: godot-mcp-pro `stop_scene`,然后 `execute_editor_script`
```gdscript
var gs := GDScript.new()
gs.source_code = FileAccess.get_file_as_string("res://addons/game_designer/spell_tab.gd")
var err := gs.reload() # 绕开 dock 里驻留的旧 @tool 类缓存
if err != OK:
_mcp_print("reload failed err=%d" % err)
return
var tab = gs.new()
EditorInterface.get_base_control().add_child(tab) # 触发 _ready(),其中会加载真实 spells.json
var truth = JSON.parse_string(FileAccess.get_file_as_string("res://data/spells.json"))
var fails := []
var checked := 0
var homing_seen := 0
var bounce_seen := 0
for sid in truth.keys():
if int(truth[sid].get("type", -1)) != 1:
continue
checked += 1
var orig: Dictionary = truth[sid].get("meta", {})
tab._select_id(sid) # 回读:JSON → 表单
tab._on_apply() # 写回:表单 → _data
var rt: Dictionary = tab._data[sid].get("meta", {})
if orig.has("homing"): homing_seen += 1
if orig.has("bounce"): bounce_seen += 1
for k in orig.keys(): # 往返语义等价:键集合相同 + 逐键数值相等
if not rt.has(k):
fails.append("%s: lost key %s" % [sid, k]); continue
if typeof(orig[k]) in [TYPE_INT, TYPE_FLOAT]:
if absf(float(orig[k]) - float(rt[k])) > 0.0001:
fails.append("%s.%s: %s -> %s" % [sid, k, str(orig[k]), str(rt[k])])
elif str(orig[k]) != str(rt[k]):
fails.append("%s.%s: %s -> %s" % [sid, k, str(orig[k]), str(rt[k])])
for k in rt.keys():
if not orig.has(k):
fails.append("%s: extra key %s=%s" % [sid, k, str(rt[k])])
if tab._extra.text.strip_edges() != "": # schema 已覆盖全部键,逃生舱应为空
fails.append("%s: extra-JSON escape hatch not empty = %s" % [sid, tab._extra.text.strip_edges()])
_mcp_print("FAILS=%d type1_checked=%d homing_fields=%d bounce_fields=%d" %
[fails.size(), checked, homing_seen, bounce_seen])
for f in fails: _mcp_print(" " + f)
tab.queue_free()
```
实测输出:
```
FAILS=0 type1_checked=8 homing_fields=1 bounce_fields=1
```
> `homing_fields=1 bounce_fields=1` 证明这两个字段**确实被这轮往返走到了**,而不是「恰好没有法术用到它们」的空过。
- [x] **Step 8b: `validate_script` + 数据驱动断言(验收⑩ / Task 1 Step 6 复核)**
```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])
var m = d.get("modifier_homing", {}).get("meta", {})
var ok: bool = d.has("modifier_homing") \
and absf(float(m.get("homing", 0.0)) - 1.5) < 0.001 \
and int(m.get("shop_cost", 0)) == 18 and int(m.get("mana_cost", 0)) == 8 \
and int(d["modifier_homing"].get("type", -1)) == 1
_mcp_print("schema_FAILS=%d missing=%s" % [missing.size(), str(missing)])
_mcp_print("json_FAILS=%d modifier_homing.meta=%s" % [(0 if ok else 1), str(m)])
```
实测输出(2026-07-30 平衡调整后重跑):
```
schema_FAILS=0 missing=[]
JSON_FAILS=0 spells_total=24 modifier_homing.meta={ "homing": 1.5, "mana_cost": 8.0, "shop_cost": 18.0 }
spinbox_grid: 1.5/0.05 = 30.000000 -> 整数格点=true
modifier_pierce_plus mana=8 shop=18
modifier_bounce mana=8 shop=18
modifier_homing mana=8 shop=18
ROUNDTRIP_FAILS=0 type1_checked=8
modifier_homing 往返后 meta={ "mana_cost": 8, "shop_cost": 18, "homing": 1.5 } 逃生舱=(空)
```
> 设计器 `homing` 字段的 `"d": 0.0` 是「未设置」默认,**不是发版值,不随平衡调整而改**。已确认 `1.5` 正落在 SpinBox `step=0.05` 的整数格点上(1.5/0.05 = 30),往返无损。
`validate_script``bullet_manager.gd` / `enemy_manager.gd` / `spell_evaluator.gd` / `spell_tab.gd``Script compiles successfully``cast_stats.gd` 唯一报错是已知假阴性 `Class "CastStats" hides a global script class`(带 `class_name` 的脚本必然如此)。`get_editor_errors` count=0。
- [x] **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
全部 ✅ 于 2026-07-30 实测通过(`FAILS=0`)。「观测方式」列区分**直接观测**与**状态推断**。
| # | 验收标准 | 覆盖任务 | 观测方式 | 结果 |
| :- | :-- | :-- | :-- | :-- |
| 1 | 归航生效(画弧)+ 对照组直线 | Step 2 (`ah=0.25` / `ac=0.00`)、Step 2b(真命中) | 直接 | ✅ |
| 2 | 单帧转角 ≤ `strength * delta` | Step 2 (`max_step=0.050000 == cap`) | 直接 | ✅ |
| 3 | 速率守恒 | Step 2 (逐帧 `350.0000`) | 直接 | ✅ |
| 4 | 最短转向方向(`wrapf` | Step 3 (`raw_diff=-5.93` 跨 ±π → `delta=+0.05`) | 直接 | ✅ |
| 5 | 目标失效后重选 | Step 4 (`locked=1 → relocked=2`) | 直接 | ✅ |
| 6 | 空场直行且**不因归航**查询 | Step 4 (方向 6 帧恒定;`tid` 未变 + 无 `homing_retry_at`) | **状态推断**(唯一一条非直接观测,论证见 spec §4⑥) | ✅ |
| 7 | bounce 协同改写 `homing_target_id` | Step 5 (`tgt == e2`,且过期退避键被 erase) | 直接 | ✅ |
| 8 | 两层修饰器 → `homing_add == 2 × 发版值`(现 `1.5×2 = 3.0` | Step 6(真实 `_apply_modifier`)、Step 6b(端到端施法) | 直接 | ✅ |
| 9 | 商店可购 + 设计器字段往返 | Step 6(真实 `_available_pool()`)/ Step 8(真实实例往返 8 条) | 直接 | ✅ |
| 10 | `validate_script` 通过 / 无目标不崩 | Task 1/2/3 各校验步 + Step 8b;无目标不崩由 Step 4 与 Step 7 数千次失败重选覆盖 | 直接 | ✅ |
| — | R-H1 退避+抖动的性能回归守卫(两种病态场景 × 两规模 × 单帧峰值,spec §5) | Step 7 | 直接 | ✅ 稳态 0/39 超预算 |
> **frame1 不计入稳态守卫**1500 弹场景 frame1 达 17.1 / 23.1 ms,这是 spec §5.6 **已决策接受**的「首次锁定尖峰」(同帧创建的归航弹首次 `query_circle` 必然落在同一帧,抖动按设计不作用于此)。它一次性、不复发,且触发条件是「≈1750 颗归航弹同帧创建且首选全部失败」这一合成最坏情况。**重新立项的判据按弹数算(≈9.5 µs/颗),不要按场景猜** —— 详见 spec §5.6。