# 子弹连锁/弹跳（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) <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/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`：
> **确定性判据**：命中后**速度是否重定向**。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 上沿 +x：bounce 优先 → 速度转向 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 |
