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

48 KiB
Raw Permalink Blame History

子弹归航(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_addfloat 累加)→ _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;纯数据驱动;提交中文。specdocs_dev/specs/2026-07-30-bullet-homing-design.md关键既有事实:子弹 SoA BULLET_STRIDE=12x/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=1SpatialGrid.insert(entity_id, pos) / clear() / query_circle(center, radius)spawn_enemy 自动插网格。


Task 0: 建功能分支

  • Step 1: 从 master 切分支

当前在 master,CLAUDE.md 规定主分支上不做功能开发。

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)之后新增:

var homing_add:       float = 0.0   # MODIFIER 累加的归航角速度(弧度/秒,叠加到 ACTION 自带 homing)

reset()bounce_add = 0:27)之后新增:

	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)之后新增:

	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)之后新增:

			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 homingvar 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)之后新增:

	var b_hom:  float = s.homing_add

在还原段 s.bounce_add = b_bnc:652)之后新增:

	s.homing_add      = b_hom

把 :615 的注释「快照分支前 CastStats(9 字段,无分配)」改为「(10 字段,无分配)」。

  • Step 5: spells.json 加 modifier_homing

data/spells.jsonmodifier_bounce 行之后(同为 type 1 修饰器区)新增一行(注意逗号):

  "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.5mana_cost / shop_cost 不变)。原 3.0 经运动学核算实为「接近稳定命中」,配不上 8 蓝同族价;1.5 才兑现 spec §2.0 的「中度制导」。理由与定价决策见 spec §2.0 / §2.8 与 docs/design/numerical_design.md Tier 3 表下的 homing 行注。

  • Step 6: 校验语法 + 数据

Run: godot-mcp-pro validate_script on res://scripts/domain/spell_system/cast_stats.gdres://scripts/domain/spell_system/spell_evaluator.gd — 均无错误。

Run: godot-mcp-pro execute_editor_script

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=01.5 为 2026-07-30 平衡复核后的发版值,原 3.0)

  • Step 7: 提交
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 两行:

# 敌人位置快照(homing 共用,由 _physics_process 帧头建立)
var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array()

并删除 _physics_process:30-33)内的第 31 行 EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot),使其变为:

func _physics_process(delta: float) -> void:
	if not _cs_node:
		_gd_integrate(delta)

不要动文件头第 8 行的冷数据注释 —— 它列出的 homing 正是本期要实现的冷数据字段,删除的只是那份按槽位存位置、无人消费的快照。

enemy_manager.gd:删除 :345-350 整个函数(含其上的 ## 注释行):

## 建立敌人位置快照(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):

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)之后新增:

## 归航转向:以 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)之前插入:

					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.gdres://scripts/autoloads/enemy_manager.gd — 均无错误。

  • Step 6: 提交
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.gdMODIFIER schema :154 附近)

  • Step 1: MODIFIER schema 加两个字段

spell_tab.gdtype 1MODIFIER)分支,在 {"k": "pierce", "l": "穿透 pierce", "w": "i", "d": 0, "opt": true},:154)之后新增两行:

			{"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

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: 提交
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 折叠 → 冷数据」这条数据驱动链路,期望值必须等于发版值。

  • Step 1: 启动战斗场景

Run: godot-mcp-pro play_sceneres://scenes/main/combat_s2.tscn。确认运行、get_editor_errors count=0。

  • Step 2: 归航生效 + 角速度上限 + 速率守恒(验收①②③)

摆位:敌人在子弹侧前方,子弹沿 +x 直飞。归航子弹应逐帧朝敌人转向;对照组(无 homing 冷数据)方向恒定。 delta = 1/60strength = 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

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。反事实检验:删掉 clampfmax_step=0.4636 远超 cap=0.05,断言变红。

  • Step 2b: 归航「真的命中」端到端(验收① 补强)

Step 2 只断言了方向变化。补一条端到端:敌人侧偏 60 px(远超碰撞半径 22),归航弹须命中并造成伤害,对照弹须完全打空。

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)
  • 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.351clamp 成 +0.05,朝短边转。

两种实现给出符号相反的结果,故本步是 wrapf 的有效判据。(若把目标摆在正前方侧向,两种实现结果相同,测不出问题 —— 不要那样摆。)

Run: godot-mcp-pro execute_game_script

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,符号反转,断言变红。

  • 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

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 帧并补上两条状态判据。

  • Step 5: homing + bounce 协同(验收⑦)

相对原稿补强:预置一个远未来的 homing_retry_at,据此验证 bounce 路径的 cold.erase("homing_retry_at")(评审期修复的缺陷 —— 退避键残留会让子弹对着 bounce 刚给的有效目标拒绝转向)。原稿不写该键,测不到这条。

Run: godot-mcp-pro execute_game_script

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
  • 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

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.jsonmeta.homing(现为 1.5),两层折叠 = 3.0。若日后再调平衡,这条断言的期望值须同步更新 —— 它有意耦合发版值,测的正是「JSON → 折叠 → 冷数据」这条数据驱动链路。

  • 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

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={  }
  • 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),第 30–47 帧(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):

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 次后彻底放弃归航),属设计决策而非实现调优。

  • 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

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 证明这两个字段确实被这轮往返走到了,而不是「恰好没有法术用到它们」的空过。

  • Step 8b: validate_script + 数据驱动断言(验收⑩ / Task 1 Step 6 复核)
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_scriptbullet_manager.gd / enemy_manager.gd / spell_evaluator.gd / spell_tab.gdScript compiles successfullycast_stats.gd 唯一报错是已知假阴性 Class "CastStats" hides a global script class(带 class_name 的脚本必然如此)。get_editor_errors count=0。

  • 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。

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。