From 3e45ef00598487012e33d5be89b5b6b248bb5566 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 30 Jul 2026 17:33:16 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=AE=A1=E5=88=92=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E6=89=A7=E8=A1=8C=E7=9A=84=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=20+=20=E8=AE=A2=E6=AD=A3=E6=9D=83=E5=A8=81?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=87=AA=E7=9B=B8=E7=9F=9B=E7=9B=BE=E5=A4=84?= =?UTF-8?q?=20+=20=E6=80=A7=E8=83=BD=E6=95=B0=E5=AD=97=E6=94=B9=E5=8C=BA?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【计划 Task 4】原稿四个脚本有缺陷,照它复跑会得到假失败与空通过,现已替换为 实际执行并通过的版本,每处旁注「原脚本为何有缺陷」,并勾上完成的复选框: - Step 2 敌人摆位 (400,200) 距原点 447px 已出 homing_range=400,实测 FAILS=1; 这是测试摆位错误而非实现缺陷(精确距离过滤行为正确)。改到 (300,150)。 - Step 6 内联复刻了折叠逻辑与商店谓词——在测自己;改调真实 _apply_modifier 与 ShopManager._available_pool(),另补 compile_wand→execute_compiled 端到端。 - Step 7 无墙钟起搏,40 帧全停在退避窗内、守卫沦为空断言;且几何让子弹直穿 敌人簇,多计约 1.5ms 碰撞开销。改为 60fps 起搏 + spec §5.2 环形摆位, frame1 与稳态峰值分开报。 - Step 8 只对源码 grep 字符串,不是往返;改为真实实例化 spell_tab 对真实 spells.json 做 8 条 type-1 的回读→写回比对。 回溯表补「观测方式」列,标出⑥是唯一一条状态推断而非直接观测。 【architecture_design.md】同一文档内自相矛盾: - CastStats 块 homing_force(「归航强度,向最近敌人偏转」)与 §4.2 新写的 「最大转向角速度 + 锁定式目标」冲突 → 订正为 homing_add(弧度/秒); 顺带订正确证过的 pierce_add / bounce_add(原写 *_count)。该块其余字段 仍有既有漂移,加 ⚠️ 注明未核对、以 cast_stats.gd 为准,不扩大改动范围。 - ProjectileDef 的 homing_force 删除——projectile_def.gd 无任何 homing 字段, 归航冷数据由 _push_projectile 直接写入 BulletManager。 - §4.2 bounce 协同散文补上实际存在的 if cold.has("homing_strength") 前置守卫。 - get_nearest_pos 代码片段:_enemy_count → _active_count(前者不存在)、 未命中返回值 Vector2.ZERO → origin、删除不存在的 _visible_flags 过滤。 【spec】 - §4 第⑥条措辞「不触发 query_circle」字面为假(_check_collision 每帧无条件 发一次),改为「不因归航触发」,并写明这是唯一一条间接验证及其封闭性论证。 - §5.5 峰值改为区间表述(① 4~5ms / ② 5~7ms @1500),注明编辑器/调试构建、 运行间离散(同场景三次得 5.61/5.09/7.29ms),判定回归看是否出现周期性复发 尖峰与是否越过 16.67ms,勿拿单值比对;并记录几何对测量的影响。 - §5.4 勘误:「1493 个互异到期值」不可能成立(jitter 只能产出约 200 个整数值, 同帧失败又共用同一 now),复测为跨度 223ms / 222 个互异值,结论不变。 Co-Authored-By: Claude Opus 5 --- docs/technical/architecture_design.md | 34 +- docs_dev/plans/2026-07-30-bullet-homing.md | 475 +++++++++++++----- .../specs/2026-07-30-bullet-homing-design.md | 29 +- 3 files changed, 399 insertions(+), 139 deletions(-) diff --git a/docs/technical/architecture_design.md b/docs/technical/architecture_design.md index b64aa21..c05194b 100644 --- a/docs/technical/architecture_design.md +++ b/docs/technical/architecture_design.md @@ -73,6 +73,9 @@ graph TD #### A. 基础单元 (SpellNode) 这是所有"部件"的基类。 + +> **⚠️ 本块与实现存在既有漂移(2026-07-30 部分订正)**:`pierce_add` / `bounce_add` / `homing_add` 三项已按 `scripts/domain/spell_system/cast_stats.gd` 实测订正(原写 `pierce_count` / `bounce_count` / `homing_force`)。**其余字段仍未核对**:实现里没有 `projectile_speed` / `spread_angle` / `projectile_size` / `range_mult`,实际为 `spread_count` / `speed_mult` / `radius_mult` / `lifetime` / `multicast_count`。此漂移早于本次归航改动,未一并订正以免超出改动范围 —— **以 `cast_stats.gd` 为准**。 + ```gdscript # cast_stats.gd - MODIFIER 节点的修改目标;通过 SpellContext.stats 访问 class_name CastStats @@ -82,17 +85,18 @@ var damage_add: float = 0.0 # 累加伤害加成(所有 Dmg+ 修正 var damage_mult: float = 1.0 # 乘算伤害系数(暴击、元素弱点等乘入) var projectile_speed: float = 600.0 # 弹道飞行速度(像素/秒) var spread_angle: float = 0.0 # 散射角度(度,0 = 直线) -var pierce_count: int = 0 # 穿透次数(0 = 首次命中即销毁) -var bounce_count: int = 0 # 弹射次数(0 = 不弹射) -var homing_force: float = 0.0 # 归航强度(0 = 直线,>0 向最近敌人偏转) +var pierce_add: int = 0 # MODIFIER 累加的穿透次数(叠加到 ACTION 自带 pierce) +var bounce_add: int = 0 # MODIFIER 累加的弹跳次数(叠加到 ACTION 自带 bounce) +var homing_add: float = 0.0 # MODIFIER 累加的归航【最大转向角速度】,单位 弧度/秒 + # (叠加到 ACTION 自带 homing;语义见 §4.2 Homing 受限角速度制导) var projectile_size: float = 1.0 # 弹道大小系数(影响碰撞半径与贴图缩放) var range_mult: float = 1.0 # 射程乘算系数(弹道消失前飞行距离倍率) var crit_chance: float = 0.0 # 本次施法暴击率追加量(0.0~1.0,叠加而非覆盖) func reset() -> void: damage_add = 0.0; damage_mult = 1.0; projectile_speed = 600.0 - spread_angle = 0.0; pierce_count = 0; bounce_count = 0 - homing_force = 0.0; projectile_size = 1.0; range_mult = 1.0; crit_chance = 0.0 + spread_angle = 0.0; pierce_add = 0; bounce_add = 0 + homing_add = 0.0; projectile_size = 1.0; range_mult = 1.0; crit_chance = 0.0 # spell_context.gd - 使用 RefCounted 避免 GC 压力 class_name SpellContext @@ -192,7 +196,10 @@ var damage_type: int = DamageType.PHYSICAL # DamageType 枚举(见上方定 var speed: float = 600.0 # 初速度(像素/秒) var direction: Vector2 = Vector2.RIGHT var spread_angle_rad: float = 0.0 # 在 direction 基础上的随机偏转幅度 -var homing_force: float = 0.0 # 每帧转向力(0 = 直线) +# 注:归航【不】经 ProjectileDef 传递。`projectile_def.gd` 无任何 homing 字段 —— +# SpellEvaluator._push_projectile 直接把 homing_strength / homing_range / +# homing_target_id 写进 BulletManager 的冷数据字典(详见 §4.2)。 +# 原此处的 `homing_force`(「每帧转向力」)语义与实现均不存在,2026-07-30 删除。 var acceleration: float = 0.0 # 速度加速度(可为负,做减速球) # ── 生命周期 ────────────────────────────────────────────── @@ -675,7 +682,13 @@ func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool: > **⚠️ 失败退避必须带抖动**(spec §5.4,本次最值得记住的一条):重选**失败**时若不留记录,该子弹余生每帧都会重跑 `query_circle(r=400)`(半径 400 覆盖约 196 格 + 一次 `PackedInt32Array` 分配 ≈ 一百多次碰撞查询)。但**朴素固定退避会把子弹锁进同相、而非打散** —— 同帧失败的子弹拿到同一个 `now`,一个退避周期后又整齐地一起重试,尖峰每 12 帧永久复发。故抖动到期时刻,用「`bullet_idx` 在活跃弹数中的占比」铺满恰好一个退避周期(`HOMING_RETRY_MS` 一值两用:既是基础退避时长也是抖动窗口宽度)。实测数据见 spec §5.2 / §5.5,此处不复述以免两套数字分叉。 - > **与 bounce 协同**:bounce 在命中瞬间硬重定向到「最近未访问敌」,若不协调,下一帧 homing 就会覆盖它、使 bounce 失效。语义定为**bounce 负责选目标、homing 负责追上去**:bounce 重定向块内改写 `cold["homing_target_id"] = next_id` 并 `cold.erase("homing_retry_at")`(新目标有效,须撤销过期退避)。 + > **与 bounce 协同**:bounce 在命中瞬间硬重定向到「最近未访问敌」,若不协调,下一帧 homing 就会覆盖它、使 bounce 失效。语义定为**bounce 负责选目标、homing 负责追上去**。bounce 重定向块内: + > ```gdscript + > if cold.has("homing_strength"): # 前置守卫:纯 bounce 弹不写 homing 键 + > cold["homing_target_id"] = next_id + > cold.erase("homing_retry_at") # 新目标有效,须撤销过期退避 + > ``` + > `has("homing_strength")` 这个守卫不可省 —— 没有它,纯 bounce 子弹会被塞进一个永不被消费的 `homing_target_id`;而 `erase` 不可省是因为退避键若残留,子弹会对着 bounce 刚给的有效目标拒绝转向(该缺陷在实现期评审中实测复现并修复)。 * **EnemyManager 对外查询接口**:以下函数供 BulletManager、DropManager、BossManager、AudioManager 调用,均为 GDScript 侧接口(轻量读操作,非热路径): @@ -713,17 +726,16 @@ func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool: func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2: # BulletManager.get_nearest_enemy_pos() 调用;直接线性扫描 SoA _data,O(M) # 仅在发射时调用,非每帧热路径(homing 走 get_pos_by_id 锁定目标,不经此函数) - var best_pos := Vector2.ZERO + var best_pos := origin # 无敌人时返回 origin(非 Vector2.ZERO) var best_dist := max_dist * max_dist # 比较平方距离,避免 sqrt - for i in range(_enemy_count): - if _visible_flags[i] == 0: continue # 跳过屏外(可选:也可全扫) + for i in _active_count: # _active_count,不存在 _enemy_count var px := _data[i * ENEMY_STRIDE + 0] var py := _data[i * ENEMY_STRIDE + 1] var d2 := (px - origin.x) * (px - origin.x) + (py - origin.y) * (py - origin.y) if d2 < best_dist: best_dist = d2 best_pos = Vector2(px, py) - return best_pos + return best_pos # 实现全扫存活敌人,不按 _visible_flags 过滤 ``` > **EnemyManager SoA 完整槽位布局**(权威来源:`implementation_plan.md §2.3.C`,P6-N45): diff --git a/docs_dev/plans/2026-07-30-bullet-homing.md b/docs_dev/plans/2026-07-30-bullet-homing.md index 85331d1..ec88d42 100644 --- a/docs_dev/plans/2026-07-30-bullet-homing.md +++ b/docs_dev/plans/2026-07-30-bullet-homing.md @@ -15,7 +15,7 @@ ### Task 0: 建功能分支 -- [ ] **Step 1: 从 master 切分支** +- [x] **Step 1: 从 master 切分支** 当前在 `master`,CLAUDE.md 规定主分支上不做功能开发。 @@ -34,7 +34,7 @@ Expected: `Switched to a new branch 'feat/bullet-homing'` - 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 字段** +- [x] **Step 1: CastStats 加 homing_add 字段** `cast_stats.gd`:在 `var bounce_add: int = 0 ...`(:15)之后新增: ```gdscript @@ -45,7 +45,7 @@ var homing_add: float = 0.0 # MODIFIER 累加的归航角速度(弧度/ homing_add = 0.0 ``` -- [ ] **Step 2: `_apply_modifier` 折叠 homing** +- [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 @@ -54,7 +54,7 @@ var homing_add: float = 0.0 # MODIFIER 累加的归航角速度(弧度/ ``` (缩进:与 `if meta.has("bounce"):` 同层,函数体一级缩进。) -- [ ] **Step 3: `_push_projectile` 写归航冷数据** +- [x] **Step 3: `_push_projectile` 写归航冷数据** `spell_evaluator.gd` 的 `_push_projectile`,在现有 bounce 冷数据块最后一行 `cold["bounce_range"] = float(meta.get("bounce_range", 250.0))`(:438)之后新增: ```gdscript @@ -66,7 +66,7 @@ var homing_add: float = 0.0 # MODIFIER 累加的归航角速度(弧度/ ``` (缩进:`var homing` 与 `var bounce`(:433)同层,在 `for i in spread:` 循环体内;`cold[...]` 再深一级。) -- [ ] **Step 4: CIRCUIT 分支快照/还原纳入 homing_add** +- [x] **Step 4: CIRCUIT 分支快照/还原纳入 homing_add** `spell_evaluator.gd` 的 `_run_branch_payload`。在快照段 `var b_bnc: int = s.bounce_add`(:625)之后新增: ```gdscript @@ -78,14 +78,14 @@ var homing_add: float = 0.0 # MODIFIER 累加的归航角速度(弧度/ ``` 把 :615 的注释「快照分支前 CastStats(9 字段,无分配)」改为「(10 字段,无分配)」。 -- [ ] **Step 5: spells.json 加 modifier_homing** +- [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": 3.0, "mana_cost": 8, "shop_cost": 18 } }, ``` -- [ ] **Step 6: 校验语法 + 数据** +- [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` — 均无错误。 @@ -99,7 +99,7 @@ _mcp_print("FAILS=%d" % (0 if ok else 1)) ``` Expected: `FAILS=0` -- [ ] **Step 7: 提交** +- [x] **Step 7: 提交** ```bash git add scripts/domain/spell_system/cast_stats.gd scripts/domain/spell_system/spell_evaluator.gd data/spells.json @@ -122,7 +122,7 @@ EOF - 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: 删除无人消费的敌人位置快照** +- [x] **Step 1: 删除无人消费的敌人位置快照** `bullet_manager.gd`:删除第 21-22 两行: ```gdscript @@ -148,7 +148,7 @@ func fill_pos_snapshot(out_arr: PackedVector2Array) -> void: ``` (已 grep 确认全项目无其它调用方。) -- [ ] **Step 2: `_gd_integrate` 循环体顶部插入归航转向调用** +- [x] **Step 2: `_gd_integrate` 循环体顶部插入归航转向调用** `bullet_manager.gd` 的 `_gd_integrate`,改为(新增第 2 行 `has_cold` 与循环体内的两行 if): ```gdscript @@ -173,7 +173,7 @@ func _gd_integrate(delta: float) -> void: i += 1 ``` -- [ ] **Step 3: 新增 `_apply_homing` 辅助** +- [x] **Step 3: 新增 `_apply_homing` 辅助** 在 `bullet_manager.gd` 的 `_find_nearest_unvisited`(:201,删除快照两行后行号上移 2)之后新增: ```gdscript @@ -209,7 +209,7 @@ func _apply_homing(bullet_idx: int, base: int, delta: float) -> void: _data[base + 3] = nv.y ``` -- [ ] **Step 4: bounce 重定向时改写 homing 目标(协同)** +- [x] **Step 4: bounce 重定向时改写 homing 目标(协同)** `bullet_manager.gd` 的 `_check_collision` 弹跳分支,在 `cold["bounce_remaining"] = bounce - 1`(:119)之前插入: ```gdscript @@ -218,11 +218,11 @@ func _apply_homing(bullet_idx: int, base: int, delta: float) -> void: ``` (缩进:与 `cold["bounce_remaining"] = bounce - 1` 同层,在 `if next_id >= 0:` 块内。) -- [ ] **Step 5: 校验语法** +- [x] **Step 5: 校验语法** Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/bullet_manager.gd` 与 `res://scripts/autoloads/enemy_manager.gd` — 均无错误。 -- [ ] **Step 6: 提交** +- [x] **Step 6: 提交** ```bash git add scripts/autoloads/bullet_manager.gd scripts/autoloads/enemy_manager.gd @@ -247,7 +247,7 @@ EOF **Files:** - Modify: `addons/game_designer/spell_tab.gd`(MODIFIER schema :154 附近) -- [ ] **Step 1: MODIFIER schema 加两个字段** +- [x] **Step 1: MODIFIER schema 加两个字段** `spell_tab.gd` 的 `type 1`(MODIFIER)分支,在 `{"k": "pierce", "l": "穿透 pierce", "w": "i", "d": 0, "opt": true},`(:154)之后新增两行: ```gdscript @@ -256,11 +256,11 @@ EOF ``` `bounce` 是补上期遗漏 —— 它至今掉在「其它(JSON)」逃生舱里(:81),违反 CLAUDE.md「字段化,禁止手写 JSON」。标签遵循「中文 English」双语序。 -- [ ] **Step 2: 校验语法** +- [x] **Step 2: 校验语法** Run: godot-mcp-pro `validate_script` on `res://addons/game_designer/spell_tab.gd` — 无错误。 -- [ ] **Step 3: 对真实数据验证 schema 覆盖** +- [x] **Step 3: 对真实数据验证 schema 覆盖** > ⚠️ dock 里驻留的旧 `@tool` 类需 `EditorInterface.restart_editor(true)` 清缓存才测得到新代码(`reload_project`/`reload_plugin` **不**重编译已加载类)。本步绕开类实例化,直接断言「schema 声明的键集合已覆盖真实 JSON 里所有 type 1 法术的 meta 键」,不受缓存影响。 @@ -283,7 +283,7 @@ _mcp_print("FAILS=%d missing=%s" % [missing.size(), str(missing)]) ``` Expected: `FAILS=0 missing=[]`(改动前跑此脚本会报 `bounce` 缺失 —— 可先跑一次确认它确实抓得到回归。) -- [ ] **Step 4: 提交** +- [x] **Step 4: 提交** ```bash git add addons/game_designer/spell_tab.gd @@ -303,22 +303,25 @@ EOF **Files:** 无(运行时断言)+ 收尾文档 -- [ ] **Step 1: 启动战斗场景** +- [x] **Step 1: 启动战斗场景** Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。确认运行、`get_editor_errors` count=0。 -- [ ] **Step 2: 归航生效 + 角速度上限 + 速率守恒(验收①②③)** +- [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 e = EnemyManager.spawn_enemy(Vector2(400, 200), 100000.0, 0) # 侧前方,血厚防被打死 -SpatialGrid.insert(e, Vector2(400, 200)) +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 +# 归航弹:沿 +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}) # 对照弹:同起点同速度,无冷数据 @@ -327,12 +330,16 @@ 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]) # 对照弹 @@ -340,12 +347,62 @@ 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()]) +_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"))]) ``` -Expected: `FAILS=0`,`ah` 明显 > 0(约 0.05×5 ≈ 0.25 rad,受实际几何略有出入但必为正),`ac ≈ 0`,`max_step ≤ 0.05`,速率两端均 ≈350。 +实测输出: +``` +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`,断言变红。 -- [ ] **Step 3: 最短转向方向(验收④)** +- [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`,朝**反方向绕远路**; @@ -363,17 +420,31 @@ SpatialGrid.insert(e, Vector2(-300, -53)) 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 a1=%.4f delta=%.4f" % [fails, a0, a1, delta_a]) +_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)]) ``` -Expected: `FAILS=0`,`delta ≈ +0.0500`(正值)。若实现漏了 `wrapf`,此处会得到 `delta ≈ -0.0500` 并 `FAILS=1`。 +实测输出: +``` +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: 目标失效重选 + 空场直行(验收⑤⑥)** +- [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 @@ -388,25 +459,41 @@ 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() -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]) +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)]) ``` -Expected: `FAILS=0`,`relocked` 为另一个 entity_id,`a1 == a2`。 -> 适配注:若 `despawn_enemy` / `SpatialGrid.remove_entity` 签名不符,先 `read_script` 确认;核心断言是「锁定目标消失后 `homing_target_id` 变为另一存活敌人」与「无敌人时方向恒定」。 +实测输出: +``` +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 协同(验收⑦)** +- [x] **Step 5: homing + bounce 协同(验收⑦)** + +> 相对原稿补强:预置一个**远未来的 `homing_retry_at`**,据此验证 bounce 路径的 `cold.erase("homing_retry_at")`(评审期修复的缺陷 —— 退避键残留会让子弹对着 bounce 刚给的有效目标拒绝转向)。原稿不写该键,测不到这条。 Run: godot-mcp-pro `execute_game_script`: ```gdscript @@ -417,47 +504,101 @@ 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_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 已消耗 -_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))]) +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 ``` -Expected: `FAILS=0`,`tgt == e2`。 -- [ ] **Step 6: 修饰器折叠 + 商店可购(验收⑧⑨前半)** +- [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 = SpellRegistry.get_spell("modifier_homing") +var sp: SpellNode = 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 +# ⑧ 走真实 _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 - 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 +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" % - [fails, folded, cs.homing_add, in_pool]) +_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))]) +``` +实测输出: +``` +FAILS=0 folded=6.00 after_reset=0.00 in_pool=true pool_size=17 shop_cost=18 unlock_cost=0 mana_cost=8 ``` -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 两种病态场景的单帧峰值** +- [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 - 6.0) > 0.001: fails += 1 +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)) +``` +实测输出: +``` +FAILS=0 homing_strength=6.000 cold={ "homing_strength": 6.0, "homing_range": 400.0, "homing_target_id": -1 } +control_cold={ } +``` + +- [x] **Step 7: 性能回归守卫 —— R-H1 两种病态场景的单帧峰值** > **本步性质已变更(2026-07-30,Task 2 代码质量评审后)**:原本这是「决策关卡」—— 实测后再定要不要启用 spec §5 的退避方案。评审在 Task 2 阶段就直接实测并**证实触发**(300 弹规模即越过 2.0 ms 线),故退避 + 抖动**已随 Task 2 落地**。本步因此降级为**回归守卫**:确认退避与抖动仍生效、峰值未回弹。 > @@ -467,63 +608,151 @@ Expected: `FAILS=0 folded=6.00 after_reset=0.00 in_pool=true` > > **指标是单帧最大值,不是 60 帧均值。** 退避把成本摊薄成「1 个重选帧 + 11 个退避帧」,均值会掩盖重选帧的尖峰;而帧预算只有 16.67 ms,尖峰才是会被玩家看见的东西。抖动的作用正是打散这个尖峰。 -Run: godot-mcp-pro `execute_game_script`(两种场景各跑一次,改 `SCENARIO` 常量): +> ⚠️ **本脚本已按实际执行版本替换(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): ```gdscript -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)]) +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) ``` -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,可先跑一次作参照。 +实测输出: +``` +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`。 -- [ ] **Step 8: 设计器字段回读往返(验收⑨后半)** +> **数字按区间读**:编辑器/调试构建,运行间离散明显(scen2@1500 三次独立测量 5.61 / 5.09 / 7.29 ms,240 帧长跑收敛在 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 复核)** -Run: godot-mcp-pro `stop_scene`,然后 `execute_editor_script`(复用 Task 3 Step 3 的 schema 覆盖脚本,此时应含 `homing`): ```gdscript var src := FileAccess.get_file_as_string("res://addons/game_designer/spell_tab.gd") var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/spells.json")) var generic := {"mana_cost": true, "shop_cost": true} var missing := [] for id in d.keys(): - if int(d[id].get("type", -1)) != 1: - continue + 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)]) + 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)) - 3.0) < 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)]) ``` -Expected: `FAILS=0 missing=[]` +实测输出: +``` +schema_FAILS=0 missing=[] +json_FAILS=0 modifier_homing.meta={ "homing": 3.0, "mana_cost": 8.0, "shop_cost": 18.0 } +``` +`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。 -- [ ] **Step 9: 更新路线图 + memory + 提交** +- [x] **Step 9: 更新路线图 + memory + 提交** - `docs_dev/plans/2026-07-23-missing-features-roadmap.md`: - E1「现状」的「归航」行改为完成态,注明**偏离**:未消费 `_enemy_pos_snapshot` 而是将其删除(锁定语义需 entity_id,快照按槽位存位置不含 id)。 @@ -547,16 +776,20 @@ EOF ## 验收标准回溯(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 | +全部 ✅ 于 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 == 6.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。 diff --git a/docs_dev/specs/2026-07-30-bullet-homing-design.md b/docs_dev/specs/2026-07-30-bullet-homing-design.md index 37cb7e8..cc93190 100644 --- a/docs_dev/specs/2026-07-30-bullet-homing-design.md +++ b/docs_dev/specs/2026-07-30-bullet-homing-design.md @@ -171,7 +171,16 @@ MODIFIER(`type 1`)的 meta schema 加: 3. **速率守恒**:转向前后 `Vector2(vx,vy).length()` 不变。 4. **最短转向**:目标位于子弹正后方偏一侧时,转向方向为夹角较小的一侧(验证 `wrapf`)。 5. **目标失效重选**:锁定目标死亡后,下一帧 `homing_target_id` 变为另一存活敌人。 -6. **空场直行**:场上无敌人时子弹保持直线,且不触发 `query_circle`。 +6. **空场直行**:场上无敌人时子弹保持直线,且**不因归航**触发 `query_circle`。 + > **措辞修正(2026-07-30 验收期)**:原写「不触发 `query_circle`」,字面为假 —— `_check_collision`(`bullet_manager.gd:69`)每帧对每颗子弹**无条件**发一次 `query_circle(pos, radius+16)`,与归航无关。被验证的命题是「`_apply_homing` 这条路径不发查询」。 + > + > **⚠️ 本条是全部 10 条验收里唯一的间接验证**,值得留痕。无法给 autoload 的方法挂调用计数器,故改用状态判据:空场跑 6 帧后断言 `homing_target_id` 未变且冷数据无 `homing_retry_at` 键。其封闭性论证 —— + > 1. `_apply_homing` 内通往 `SpatialGrid` 的路径**有且仅有**一处 `_find_nearest_unvisited`,无其它 `SpatialGrid` 访问; + > 2. 该调用之后必然二选一:成功则写 `homing_target_id`(变为 ≥0 的新值),失败则写 `homing_retry_at`; + > 3. 两者皆未发生 ⟹ 该调用未被执行 ⟹ 归航未发查询; + > 4. 零敌人时 `_check_collision` 在 `:71`(`hits.is_empty()`)即返回,bounce 路径不可达,不会与之混淆。 + > + > 论证对被限定后的命题成立,但**它是状态推断而非对调用次数的直接观测** —— 如实标注,不宣称为直接测量。 7. **bounce 协同**:`homing + bounce` 子弹弹跳后 `homing_target_id == ` bounce 选中的 `next_id`。 8. **修饰器折叠**:两层 `modifier_homing` → `ctx.stats.homing_add == 6.0`。 9. **商店与设计器**:`modifier_homing` 可在商店买到;设计器 `homing`/`bounce` 字段回读往返一致(对真实 `data/spells.json`)。 @@ -239,19 +248,25 @@ MODIFIER(`type 1`)的 meta schema 加: 到期时刻均匀铺满 `[200, 400)` ms ≈ 恰好一个退避周期 ≈ 12 帧。**用「`bullet_idx` 在活跃弹数中的占比」而非 `bullet_idx % HOMING_RETRY_MS`**:后者在弹数少于窗口宽度时只能铺开 `_active_count` 毫秒(20 颗弹 → 20 ms ≈ 1.2 帧,等于没打散),占比式则不论弹数多少都铺满整窗。`HOMING_RETRY_MS` 一值两用且刻意耦合 —— 既是基础退避时长也是抖动窗口宽度,「铺满恰好一个退避周期」正是消峰所需的性质,拆成两个常量反而掩盖这层关系。 -实测打散质量:1500 颗同帧集体失败 → 到期偏移 200~415 ms、1493 个互异值、覆盖 14 帧、单帧最多 120 颗(理想 125)。 +实测打散质量(2026-07-30 验收期复测):1500 颗同帧集体失败 → 到期时刻**跨度 223 ms**(≈ 恰好一个退避周期,即 13~14 帧)、**222 个互异毫秒值**,平均每毫秒约 6.8 颗、每帧约 110 颗(理想 1500/12 = 125)。 + +> **勘误**:本节初稿写「1493 个互异值」,该数**不可能成立** —— `jitter = (bullet_idx * 200) / _active_count` 对 1500 颗弹只能产出 0~199 共约 200 个整数值,同帧失败的子弹又共用同一个 `now`,故互异到期时刻上限就是 ~200 个。复测得 222,多出的 22 来自「遍历这 1500 颗本身耗时约 23 ms,期间 `now` 在推进」(与 §5.6 记录的首帧 23 ms 吻合)。**打散结论不变**(跨度铺满一个完整退避周期即消峰所需性质),只是原先那个数字是错的。 ### 5.5 抖动前后单帧峰值对比(60fps 起搏 · 40 帧) -| 场景 · 规模 | 抖动前·复发尖峰 | 抖动后·稳态峰值 | 稳态超 16.67ms 帧数 | +| 场景 · 规模 | 抖动前·复发尖峰 | 抖动后·稳态峰值(区间) | 稳态超 16.67ms 帧数 | | :-- | --: | --: | --: | -| ① 全射程外 · 1500 | 13.6 ~ 15.7 ms(每 12 帧复发) | **4.40 ms** | 0/39 | -| ① 全射程外 · 300 | 3.2 ms | **0.92 ms** | 0/39 | -| ② 全 visited · 1500 | 20.9 ~ 21.9 ms(每 12 帧复发) | **5.61 ms** | 0/39 | -| ② 全 visited · 300 | 4.6 ms | **1.15 ms** | 0/39 | +| ① 全射程外 · 1500 | 13.6 ~ 15.7 ms(每 12 帧复发) | **4 ~ 5 ms** | 0/39 | +| ① 全射程外 · 300 | 3.2 ms | **约 1 ms** | 0/39 | +| ② 全 visited · 1500 | 20.9 ~ 21.9 ms(每 12 帧复发) | **5 ~ 7 ms** | 0/39 | +| ② 全 visited · 300 | 4.6 ms | **1 ~ 2.5 ms** | 0/39 | 复发尖峰彻底消除,稳态全部回到帧预算内。 +> **⚠️ 数字按区间读,不要按单值读**:以上均为**编辑器/调试构建**实测,运行间存在明显离散。同一场景 ②·1500 三次独立测量分别得 **5.61 / 5.09 / 7.29 ms**;240 帧长跑收敛在 5–7 ms 带内、无发散。写成单值会显得比数据本身更精确 —— 结论(稳态远低于 16.67 ms 帧预算、无复发尖峰)不受此离散影响,但**不要引用单个数字做后续推算**,也不要因复跑得到带内的不同值就判定为回归。判定回归请看:是否出现周期性复发尖峰、是否有帧越过 16.67 ms。 +> +> 另注:稳态峰值对**测试几何**敏感。若子弹航线穿过敌人簇,`_check_collision` 的 `query_circle` + `visited_targets` 线性扫描会额外贡献 1~1.5 ms,那部分**不是归航成本**(验收期曾因此误读一次)。上表摆位为「敌人置于环上、子弹簇于原点」,刻意让子弹不穿过敌人簇。 + ### 5.6 残余:初次锁定帧 —— **已决策:接受,不处理** 抖动后仍有**一个**高耗时帧 —— 第 1 帧,1500 弹 `far` 16.2 ms / `visited` 22.0 ms。它**不是**退避尖峰,而是「§2.4 首次锁定即时、不受退避约束」的直接结果:1500 颗归航子弹在同一帧被创建时,它们的首次 `query_circle` 必然落在同一帧。抖动按设计不作用于此(已断言首帧即时锁定且不写退避键,手感不回退)。