spec §5.6:残余的首帧尖峰(1500 弹同帧创建且首次选目标全失败时 16~22ms) 决策接受不处理——触发条件是三个苛刻条件的合取属合成最坏情况、一次性不复发、 压下它需牺牲「首次锁定即时」的手感或改变归航/bounce 语义。附重新立项触发条件。 plan Task4 Step7:原只测「敌人全在射程外」,补上评审发现的更严重场景 「射程内全在 visited」(visited 只增不减故为永久状态);指标由 60 帧均值 改为单帧峰值——退避把成本摊成 1 重选帧 + 11 退避帧,均值会掩盖尖峰, 而帧预算 16.67ms 下尖峰才是玩家可见的。性质由决策关卡降级为回归守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
29 KiB
子弹归航(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。 关键既有事实:子弹 SoABULLET_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 规定主分支上不做功能开发。
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 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)之后新增:
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.json 中 modifier_bounce 行之后(同为 type 1 修饰器区)新增一行(注意逗号):
"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:
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: 提交
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.gd 与 res://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.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)之后新增两行:
{"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: 无(运行时断言)+ 收尾文档
- 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:
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:
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:
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:
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:
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 两种病态场景的单帧峰值
本步性质已变更(2026-07-30,Task 2 代码质量评审后):原本这是「决策关卡」—— 实测后再定要不要启用 spec §5 的退避方案。评审在 Task 2 阶段就直接实测并证实触发(300 弹规模即越过 2.0 ms 线),故退避 + 抖动已随 Task 2 落地。本步因此降级为回归守卫:确认退避与抖动仍生效、峰值未回弹。
两种病态场景都要测(原计划只写了第一种,漏了更糟的第二种):
- 敌人在场但均超出
homing_range—— 玩家朝空处/刚清空的方向开火。瞬态。- 射程内敌人全在
visited_targets里 —— bounce+homing 组合弹打空一个小簇后。visited只增不减,故这对那颗子弹是永久状态,比第一种更严重。这条是评审发现的,原 spec 风险分析漏掉了。指标是单帧最大值,不是 60 帧均值。 退避把成本摊薄成「1 个重选帧 + 11 个退避帧」,均值会掩盖重选帧的尖峰;而帧预算只有 16.67 ms,尖峰才是会被玩家看见的东西。抖动的作用正是打散这个尖峰。
Run: godot-mcp-pro execute_game_script(两种场景各跑一次,改 SCENARIO 常量):
var SCENARIO := 1 # 1 = 敌人全在射程外;2 = 射程内全 visited
var N_BULLETS := 300 # 也跑一遍 1500 作为压力上限
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var visited := []
if SCENARIO == 1:
for k in 50: # 全部远在 homing_range(400) 之外
var eid = EnemyManager.spawn_enemy(Vector2(3000 + k * 10, 0), 1000.0, 0)
SpatialGrid.insert(eid, Vector2(3000 + k * 10, 0))
else:
for k in 30: # 在射程内,但全部预置进 visited → 重选必然失败
var eid = EnemyManager.spawn_enemy(Vector2(200 + k, 100 + k), 1000.0, 0)
SpatialGrid.insert(eid, Vector2(200 + k, 100 + k))
visited.append(eid)
for k in N_BULLETS:
var cold := {"homing_strength": 3.0, "homing_range": 400.0, "homing_target_id": -1}
if SCENARIO == 2:
cold["visited_targets"] = visited.duplicate()
BulletManager.spawn_bullet(Vector2(0, k), Vector2(350, 0), 30.0, 6.0, 1.0, 1.0, 0, -1, 0, 0.0, cold)
# 跑满一个退避周期以上,捕捉单帧峰值(含重选帧)
var peak_us := 0
var total_us := 0
for k in 40:
var t0 := Time.get_ticks_usec()
BulletManager._gd_integrate(1.0 / 60.0)
var dt_us := Time.get_ticks_usec() - t0
total_us += dt_us
if dt_us > peak_us: peak_us = dt_us
_mcp_print("scenario=%d bullets=%d peak_ms=%.3f mean_ms=%.3f budget=16.670 over_budget=%s" %
[SCENARIO, N_BULLETS, peak_us / 1000.0, total_us / 40.0 / 1000.0, str(peak_us / 1000.0 > 16.67)])
Expected:300 弹两种场景 peak_ms 均明显低于 2.0;1500 弹(压力上限)over_budget=false。
若
over_budget=true,停下来上报,不要自行加码 —— 那说明需要的是设计级方案(给visited_targets设上限、或让归航子弹连续失败 N 次后彻底放弃归航),属设计决策而非实现调优。 注:编辑器/调试构建的绝对值偏高于 release,看比例与是否越线而非绝对数字。基线(普通弹、无冷数据)约 1.6 ms @1500 / 0.32 ms @300,可先跑一次作参照。
- Step 8: 设计器字段回读往返(验收⑨后半)
Run: godot-mcp-pro stop_scene,然后 execute_editor_script(复用 Task 3 Step 3 的 schema 覆盖脚本,此时应含 homing):
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**~~ ✅ **完成**),格式对齐已完成的 ①②。
- E1「现状」的「归航」行改为完成态,注明偏离:未消费
-
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)
| # | 验收标准 | 覆盖任务 |
|---|---|---|
| 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 |