Files
spellforge/docs_dev/plans/2026-07-24-bullet-bounce.md

298 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 子弹连锁/弹跳(Chain / BounceImplementation 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`。子弹 SoAbase_damage=slot+6damage_mult=slot+7vx/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 词条"
```
heredocbody 末尾 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`。)
---
### 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/81decay 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=2base_damage=100mult=13 敌均在 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`
> **确定性判据**:命中后**速度是否重定向**。bounce 会把速度转向下一目标;pierce 保持原方向。把 e2 摆在**垂直**方向即可干净区分(伤害对照会被慢速子弹在密集群内的 pierce 滞留重复命中干扰——见下注,故改测速度)。spec §2.5(b) 明确 bounce 耗尽后落到 pierce,故「bounce+pierce 子弹先弹后穿」是**符合规范**的;本步只验证「有 bounce 时该次命中走 bounce 而非 pierce」。
```gdscript
BulletManager.reset(); EnemyManager.reset(); SpatialGrid.clear()
var e1 = EnemyManager.spawn_enemy(Vector2(300, 0), 1000.0, 0)
var e2 = EnemyManager.spawn_enemy(Vector2(300, 100), 1000.0, 0) # 垂直方向,偏离 +x 直线路径
SpatialGrid.insert(e1, Vector2(300, 0))
SpatialGrid.insert(e2, Vector2(300, 100))
# bounce=1 + pierce=1,子弹在 e1 上沿 +xbounce 优先 → 速度转向 e2(+y);若走 pierce → 速度保持 +x
var cold = {"bounce_remaining": 1, "visited_targets": [], "bounce_decay": 0.9, "bounce_range": 250.0, "pierce_remaining": 1}
BulletManager.spawn_bullet(Vector2(300, 0), Vector2(200, 0), 5.0, 6.0, 100.0, 1.0, 0, -1, 0, 0.0, cold)
BulletManager._gd_integrate(1.0 / 60.0) # 单帧:命中 e1,bounce 应重定向朝 e2
var vx = BulletManager._data[2] # index0 vx
var vy = BulletManager._data[3] # index0 vy
var de1 = 1000.0 - EnemyManager.get_hp_percent(e1) * 1000.0
var fails := 0
if vy < 100.0: fails += 1 # 转向 e2(+y) ≈200
if abs(vx) > 50.0: fails += 1 # +x 分量消失(pierce 会保持 ≈200)
if abs(de1 - 100.0) > 1.0: fails += 1
_mcp_print("FAILS=%d vx=%.1f vy=%.1f de1=%.1f" % [fails, vx, vy, de1])
```
Expected: `FAILS=0`vx≈0 / vy≈200 / de1≈100(速度转向证明 bounce 优先,pierce 会保持 +x)。
> 注(实测已确认 vx=-6.7, vy=199.9, de1=100):早前用共线慢速子弹 + 伤害对照的写法会因 pierce 耗尽后子弹滞留在 query_r 内重复命中同一敌(既有 pierce 特性,非本 feature bug),故改用垂直摆位 + 速度判据。
- [ ] **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 |