diff --git a/docs_dev/plans/2026-07-24-bullet-bounce.md b/docs_dev/plans/2026-07-24-bullet-bounce.md new file mode 100644 index 0000000..f2f1b37 --- /dev/null +++ b/docs_dev/plans/2026-07-24-bullet-bounce.md @@ -0,0 +1,293 @@ +# 子弹连锁/弹跳(Chain / Bounce)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:** 子弹命中后弹向最近的未命中敌人、伤害逐次递减,激活 bounce 深度维度;配 `modifier_bounce` 词条。 + +**Architecture:** 完全镜像现有 pierce 管线。`CastStats.bounce_add`(累加)→ `_apply_modifier` 折叠 meta → `_push_projectile` 写子弹冷数据 → `bullet_manager._check_collision` 命中时弹跳(转向最近未访问敌人 + `damage_mult×decay` 递减 + `visited` 防回跳,bounce 优先于 pierce)。纯 GDScript 回退路径。 + +**Tech Stack:** Godot 4.7.1 Mono / GDScript / 纯 JSON 数据驱动 / godot-mcp-pro 运行时实测(无 pytest;弹跳链经运行中战斗场景 `execute_game_script` 数值断言)。 + +> **规范**:所有 `.gd` 改动后 `validate_script`;纯数据驱动;提交中文;分支 `feat/bullet-bounce`(已建)。spec:`docs_dev/specs/2026-07-24-bullet-bounce-design.md`。子弹 SoA:base_damage=slot+6,damage_mult=slot+7,vx/vy=slot+2/+3。`get_pos_by_id` 未命中返回 `Vector2(-9999,-9999)`。SpellType.MODIFIER=1。 + +--- + +### Task 1: 法术 VM 侧 —— CastStats.bounce_add + 折叠 + 发射写冷数据 + 分支快照 + modifier_bounce 数据 + +**Files:** +- Modify: `scripts/domain/spell_system/cast_stats.gd`(字段 :14、reset :25) +- Modify: `scripts/domain/spell_system/spell_evaluator.gd`(`_push_projectile` :429-432、`_apply_modifier` :524-525、分支快照 :609-643) +- Modify: `data/spells.json`(加 `modifier_bounce`) + +- [ ] **Step 1: CastStats 加 bounce_add 字段** + +`cast_stats.gd`:在 `var pierce_add: int = 0 ...`(:14)之后新增: +```gdscript +var bounce_add: int = 0 # MODIFIER 累加的弹跳次数(叠加到 ACTION 自带 bounce) +``` +在 `reset()` 内 `pierce_add = 0`(:25)之后新增: +```gdscript + bounce_add = 0 +``` + +- [ ] **Step 2: `_apply_modifier` 折叠 bounce** + +`spell_evaluator.gd` 的 `_apply_modifier`,在 `if meta.has("pierce"): ctx.stats.pierce_add += int(meta["pierce"])`(:524-525)之后新增: +```gdscript + if meta.has("bounce"): + ctx.stats.bounce_add += int(meta["bounce"]) +``` + +- [ ] **Step 3: `_push_projectile` 写弹跳冷数据** + +`spell_evaluator.gd` 的 `_push_projectile`,在现有 pierce 冷数据块之后(`if pierce > 0: cold["pierce_remaining"] = pierce`,约 :431-432)新增: +```gdscript + var bounce: int = int(meta.get("bounce", 0)) + ctx.stats.bounce_add + if bounce > 0: + cold["bounce_remaining"] = bounce + cold["visited_targets"] = [] + cold["bounce_decay"] = float(meta.get("bounce_decay", 0.9)) + cold["bounce_range"] = float(meta.get("bounce_range", 250.0)) +``` +(注意缩进:与 `if pierce > 0:` 同层,在 `for i in spread:` 循环体内。) + +- [ ] **Step 4: CIRCUIT 分支快照/还原纳入 bounce_add** + +`spell_evaluator.gd` 分支快照块(`_run_branch_payload`,:609-643)。在快照段 `var b_crit: float = s.crit_chance`(:616)之后新增: +```gdscript + var b_bnc: int = s.bounce_add +``` +在还原段 `s.crit_chance = b_crit`(:642)之后新增: +```gdscript + s.bounce_add = b_bnc +``` +把该块注释「快照分支前 CastStats(8 字段…」改为「(9 字段…」。 + +- [ ] **Step 5: spells.json 加 modifier_bounce** + +在 `data/spells.json` 中 `modifier_pierce_plus` 行之后(同为 type 1 修饰器区)新增一行(注意逗号): +```json + "modifier_bounce": { "type": 1, "display_name": "Bounce +2", "description": "后续法术弹跳 +2(命中后弹向最近未命中敌人,伤害递减)。", "element_tags": [], "meta": { "bounce": 2, "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`(用 `_mcp_print`): +```gdscript +var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/spells.json")) +var ok: bool = d is Dictionary and d.has("modifier_bounce") and int(d["modifier_bounce"]["meta"].get("bounce", 0)) == 2 +_mcp_print("FAILS=%d" % (0 if ok else 1)) +``` +Expected: `FAILS=0` + +- [ ] **Step 7: 提交** + +```bash +git add scripts/domain/spell_system/cast_stats.gd scripts/domain/spell_system/spell_evaluator.gd data/spells.json +git commit -m "feat(bounce): CastStats.bounce_add + 修饰器折叠 + 发射写弹跳冷数据 + 分支快照; modifier_bounce 词条" +``` +(heredoc,body 末尾 `Co-Authored-By: Claude Opus 4.8 (1M context) `。) + +--- + +### Task 2: 子弹弹跳命中逻辑 —— bullet_manager + +**Files:** +- Modify: `scripts/autoloads/bullet_manager.gd`(`_check_collision` 命中循环 :67-75、pierce 块前 :102-108、新增辅助) + +- [ ] **Step 1: 命中选择跳过已访问敌人(防密集群重复命中)** + +`bullet_manager.gd` 的 `_check_collision`,在 `for entity_id in hits:` 循环内的距离检查(`if dist_sq > query_r * query_r: continue`,:74-75)之后、`# 命中:通过 DamageContextPool 发出伤害`(:76)之前插入: +```gdscript + if _bullet_contexts.has(bullet_idx): + var vt = _bullet_contexts[bullet_idx].get("visited_targets", null) + if vt != null and entity_id in vt: + continue # 弹跳已命中过此敌:跳过,避免密集群内重复命中 +``` +(缩进:与 `var dx`/`var ene_pos` 同层,都在 `for entity_id in hits:` 循环体内。) + +- [ ] **Step 2: 在命中处插入弹跳分支(pierce 之前)** + +`bullet_manager.gd` 的 `_check_collision`,在 `# pierce 处理` 注释(:102)之前、`# S3: 命中触发子荷载` 块之后插入: +```gdscript + # bounce 处理(优先于 pierce):命中后弹向最近未访问敌人,伤害递减 + var bounce: int = int(cold.get("bounce_remaining", 0)) + if bounce > 0: + var visited: Array = cold.get("visited_targets", []) + visited.append(entity_id) + var next_id: int = _find_nearest_unvisited(Vector2(bx, by), float(cold.get("bounce_range", 250.0)), visited) + if next_id >= 0: + var np: Vector2 = EnemyManager.get_pos_by_id(next_id) + var spd: float = Vector2(_data[base + 2], _data[base + 3]).length() + var dir: Vector2 = (np - Vector2(bx, by)).normalized() + _data[base + 2] = dir.x * spd + _data[base + 3] = dir.y * spd + _data[base + 7] *= float(cold.get("bounce_decay", 0.9)) # 伤害递减 (damage_mult) + cold["bounce_remaining"] = bounce - 1 + cold["visited_targets"] = visited + _bullet_contexts[bullet_idx] = cold + return false # 继续飞向新目标 + _swap_and_pop(bullet_idx) + return true # 射程内无未访问目标 → 回收 +``` +(缩进:与其上下的 `var status_id`/`# pierce 处理` 同层——都在 `var cold: Dictionary = _bullet_contexts[bullet_idx]` 之后的同一 for-循环体内。) + +- [ ] **Step 3: 新增 `_find_nearest_unvisited` 辅助** + +在 `bullet_manager.gd` 的 `get_nearest_enemy_pos`(:177)之前或 `_check_collision` 之后新增: +```gdscript +## 弹跳寻的:query_circle 半径内滤除已访问/哨兵,取最近 entity_id;无则 -1 +func _find_nearest_unvisited(origin: Vector2, radius: float, visited: Array) -> int: + var hits: PackedInt32Array = SpatialGrid.query_circle(origin, radius) + var best_id: int = -1 + var best_sq: float = radius * radius + for eid in hits: + if eid in visited: + continue + var p: Vector2 = EnemyManager.get_pos_by_id(eid) + if p == Vector2(-9999.0, -9999.0): + continue + var d: float = origin.distance_squared_to(p) + if d <= best_sq: + best_sq = d + best_id = eid + return best_id +``` + +- [ ] **Step 4: 校验语法** + +Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/bullet_manager.gd` — 无错误。 + +- [ ] **Step 5: 提交** + +```bash +git add scripts/autoloads/bullet_manager.gd +git commit -m "feat(bounce): bullet_manager 命中弹跳分支(优先于pierce)+跳过已访问+_find_nearest_unvisited 寻的" +``` + +--- + +### Task 3: 验收 —— 运行时集成实测(MCP)+ 收尾 + +**Files:** 无(运行时断言)+ 收尾文档 + +- [ ] **Step 1: 启动战斗场景** + +Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。确认运行、`get_editor_errors` count=0。 + +- [ ] **Step 2: 弹跳链 + 伤害递减 + visited(验收①②③)** + +> 摆位:3 个 Basic(type0, armor0) 敌人紧凑聚集(互相 < query_r≈22px),子弹直接经 `BulletManager.spawn_bullet` 生成于簇心,带 bounce 冷数据,低速。逐帧步进 `_physics_process`。bounce=2 → 初击 + 2 弹跳 = 3 个不同敌人,伤害 100/90/81(decay 0.9,作用于 damage_mult)。 + +Run: godot-mcp-pro `execute_game_script`: +```gdscript +BulletManager.reset() +EnemyManager.reset() +SpatialGrid.clear() +# 3 敌聚集:互相 <22px,均在簇心 query_r 内 +var e0 = EnemyManager.spawn_enemy(Vector2(300, 0), 1000.0, 0) +var e1 = EnemyManager.spawn_enemy(Vector2(310, 0), 1000.0, 0) +var e2 = EnemyManager.spawn_enemy(Vector2(320, 0), 1000.0, 0) +# 手动插入网格:spawn_enemy 不插网格(正常由 EnemyManager._physics_process 插入, +# 但本同步脚本内引擎帧未跑)。敌人在本循环内不移动 → 插入一次即可,无需 rebuild。 +SpatialGrid.insert(e0, Vector2(300, 0)) +SpatialGrid.insert(e1, Vector2(310, 0)) +SpatialGrid.insert(e2, Vector2(320, 0)) +# 子弹于簇心,低速,bounce=2,base_damage=100,mult=1(3 敌均在 query_r≈22 内) +var cold = {"bounce_remaining": 2, "visited_targets": [], "bounce_decay": 0.9, "bounce_range": 250.0} +BulletManager.spawn_bullet(Vector2(310, 0), Vector2(1, 0), 5.0, 6.0, 100.0, 1.0, 0, -1, 0, 0.0, cold) +# 步进物理若干帧让弹跳链完成(敌不动,网格无需重建) +for k in 10: + BulletManager._gd_integrate(1.0 / 60.0) +var drops = [] +for eid in [e0, e1, e2]: + drops.append(1000.0 - EnemyManager.get_hp_percent(eid) * 1000.0) +drops.sort() +drops.reverse() # 降序 +var fails := 0 +if abs(drops[0] - 100.0) > 1.0: fails += 1 # 初击满伤 +if abs(drops[1] - 90.0) > 1.0: fails += 1 # 弹跳1 ×0.9 +if abs(drops[2] - 81.0) > 1.0: fails += 1 # 弹跳2 ×0.81 +var hit_count := 0 +for dd in drops: + if dd > 1.0: hit_count += 1 +if hit_count != 3: fails += 1 # 恰 3 个不同敌人(visited 无回跳) +if BulletManager.get_active_count() != 0: fails += 1 # 弹跳耗尽后回收 +_mcp_print("FAILS=%d drops=%s active=%d" % [fails, str(drops), BulletManager.get_active_count()]) +``` +Expected: `FAILS=0 drops=[100, 90, 81] active=0`。 +> ⚠️ 适配注:`SpatialGrid` API 已确认——`insert(entity_id, pos)`(EnemyManager 每帧调)、`rebuild()` 无参、`query_circle(center, radius)`、`clear()`。`spawn_enemy` **不**自动插网格,故须手动 `insert`。若某断言意外失败,先 `_mcp_print` 中打印 `SpatialGrid.query_circle(Vector2(310,0), 22.0)` 确认 3 敌均在网格且在 query_r 内;必要时把 3 敌摆得更紧(如 305/310/315)。核心断言不变:3 个不同敌人掉血 {100,90,81}、子弹回收。 + +- [ ] **Step 3: bounce 优先于 pierce(验收④)** + +Run: godot-mcp-pro `execute_game_script`: +```gdscript +BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear() +var a = EnemyManager.spawn_enemy(Vector2(300, 0), 1000.0, 0) +var b = EnemyManager.spawn_enemy(Vector2(312, 0), 1000.0, 0) +SpatialGrid.insert(a, Vector2(300, 0)) +SpatialGrid.insert(b, Vector2(312, 0)) +# 同带 bounce=1 与 pierce=1:应弹跳(转向 b),而非穿透 +var cold = {"bounce_remaining": 1, "visited_targets": [], "bounce_decay": 0.9, "bounce_range": 250.0, "pierce_remaining": 1} +BulletManager.spawn_bullet(Vector2(306, 0), Vector2(1, 0), 5.0, 6.0, 100.0, 1.0, 0, -1, 0, 0.0, cold) +for k in 10: + BulletManager._gd_integrate(1.0 / 60.0) +var da = 1000.0 - EnemyManager.get_hp_percent(a) * 1000.0 +var db = 1000.0 - EnemyManager.get_hp_percent(b) * 1000.0 +var fails := 0 +if abs(da - 100.0) > 1.0: fails += 1 # a 初击满伤 +if abs(db - 90.0) > 1.0: fails += 1 # b 被弹跳命中(×0.9) → 证明走了 bounce 而非 pierce(穿透会是满伤且方向不变) +_mcp_print("FAILS=%d da=%.1f db=%.1f" % [fails, da, db]) +``` +Expected: `FAILS=0 da=100.0 db=90.0`(b 掉 90 证明经弹跳衰减,bounce 优先生效)。 + +- [ ] **Step 4: 修饰器折叠 modifier_bounce(验收⑤)** + +Run: godot-mcp-pro `execute_game_script`(直接验证折叠逻辑,避免构造整套 deck): +```gdscript +var reg_ok := SpellRegistry.get_spell("modifier_bounce") != null +var ctx = SpellContextPool.acquire() if SpellContextPool.has_method("acquire") else null +var fails := 0 +if not reg_ok: fails += 1 +# 折叠:造一个 meta.bounce=2 的 MODIFIER 节点,_apply_modifier 后 bounce_add 应=2 +var node = SpellNode.new() +node.type = SpellNode.SpellType.MODIFIER +node.meta = {"bounce": 2} +if ctx == null: + # 回退:直接用 CastStats 验证累加语义 + var cs = CastStats.new() + cs.bounce_add += int(node.meta["bounce"]) + if cs.bounce_add != 2: fails += 1 +else: + ctx.stats.reset() + SpellEvaluator._apply_modifier(node, ctx) + if ctx.stats.bounce_add != 2: fails += 1 + SpellContextPool.release(ctx) +_mcp_print("FAILS=%d reg_ok=%s" % [fails, reg_ok]) +``` +Expected: `FAILS=0 reg_ok=true`。 +> 注:若 `SpellContextPool.acquire`/`SpellNode.new`/`CastStats.new` 签名不符,先 `read_script` 确认;核心是断言 `modifier_bounce` 已注册且 `_apply_modifier` 把 `meta.bounce` 累加进 `bounce_add`。然后 `stop_scene`。 + +- [ ] **Step 5: 更新路线图 + memory + 提交** + +- `docs_dev/plans/2026-07-23-missing-features-roadmap.md`:E1「现状」连锁/弹跳行标记完成、切分②勾除。 +- memory `slice-progress-vs-plan.md`:记 E1-② 弹跳完成(分支/commit)+ MEMORY.md 索引。 + +```bash +git add docs_dev/plans/2026-07-23-missing-features-roadmap.md +git commit -m "docs(roadmap): E1-② 子弹弹跳完成,勾除现状项" +``` + +--- + +## 验收标准回溯(spec §4) + +| # | 验收标准 | 覆盖任务 | +| :- | :-- | :-- | +| 1 | bounce=2 命中 3 敌 + 递减 100/90/81 | Task 3 Step 2 | +| 2 | visited 防回跳(恰 3 个不同敌) | Task 3 Step 2 (hit_count) | +| 3 | 射程内无目标 → 回收 | Task 3 Step 2 (active=0) | +| 4 | bounce 优先于 pierce | Task 3 Step 3 | +| 5 | modifier_bounce 折叠生效 + 注册 | Task 3 Step 4 | +| 6 | validate 通过 / 无目标不崩 | 各 validate 步 + Task 3 |