# 子弹连锁/弹跳（Chain / Bounce）设计

> **日期**：2026-07-24
> **Epic**：E1 战斗深度 · 子计划 ②（缺失功能路线图 `docs_dev/plans/2026-07-23-missing-features-roadmap.md`）
> **优先级**：P0 · 工作量 S~M
> **目标**：让子弹命中后可弹向最近的未命中敌人并伤害递减，激活 `bounce` 深度维度；配 `modifier_bounce` 词条。

---

## 1. 背景与现状（代码复核 2026-07-24）

- 子弹热数据 SoA（`BULLET_STRIDE=12`），冷数据在 `_bullet_contexts: Dictionary`（key=bullet_idx）。
- 现仅消费 `pierce_remaining`（`bullet_manager.gd:103-107`：穿透=保持方向继续飞）；**无** `bounce_remaining` / `visited_targets` / 伤害衰减。
- 修饰器管线**已完备且可复用**：`CastStats`（`cast_stats.gd`）累加字段 → `_apply_modifier`（`spell_evaluator.gd:510`）折叠 meta → `_push_projectile`（:429）`pierce = meta.get("pierce",0) + ctx.stats.pierce_add` → cold。bounce 完全镜像此路径。
- 伤害计算：`calc_damage = base_damage(slot+6) × damage_mult(slot+7) × (1−res) − armor`。子弹 spawn 时 `damage_mult` 传 `1.0`（实际伤害烘进 base_damage）→ **对 slot+7 逐次 ×衰减即可实现伤害递减**。

## 2. 设计（镜像 pierce，最小新增面）

### 2.1 冷数据字段（子弹 `_bullet_contexts[idx]`）
- `bounce_remaining: int` —— 剩余弹跳次数。
- `visited_targets: Array[int]` —— 已命中 entity_id，防 ping-pong 回跳。
- `bounce_decay: float`（默认 0.9）—— 每次弹跳伤害乘子。
- `bounce_range: float`（默认 250）—— 寻找下一目标的半径。

### 2.2 CastStats（`cast_stats.gd`）
- 加 `var bounce_add: int = 0`（紧邻 `pierce_add`）+ `reset()` 置 0。
- **CIRCUIT 分支快照/还原**（`spell_evaluator.gd:609-643`）加入 `bounce_add` 快照与还原（分支局部弹跳不外泄），注释「8 字段」→「9 字段」。
  > 注：现有快照块**未含** `pierce_add`（pre-existing 泄漏，本期不修，出范围）；`bounce_add` 按正确做法纳入。

### 2.3 修饰器折叠（`_apply_modifier`）
```gdscript
	if meta.has("bounce"):
		ctx.stats.bounce_add += int(meta["bounce"])
```

### 2.4 发射（`_push_projectile`，紧随现有 pierce 块）
```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))
```

### 2.5 命中弹跳（`bullet_manager._check_collision`）

**(a) 命中选择跳过已访问**（`for entity_id in hits` 循环顶，距离检查之后、施伤之前）：弹跳子弹停留在密集敌群时，若不跳过 `visited`，每帧命中选择可能重复命中同一敌人（bounce 恰在密集群最有价值）。故：
```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   # 弹跳已命中过此敌：跳过，避免重复命中
```

**(b) 弹跳重定向**：在冷数据分支内、**pierce 处理之前**插入（伤害/状态/荷载已执行）：
```gdscript
	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))   # 伤害递减（slot+7 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        # 射程内无未访问目标 → 回收
```
新增辅助：
```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
```

**优先级**：bounce 先于 pierce（一次命中要么弹跳要么穿透，不叠加）。`bounce_remaining==0` 时落到既有 pierce 逻辑。

### 2.6 修饰器数据（`data/spells.json`）
```json
"modifier_bounce": { "type": 1, "display_name": "Bounce +2", "description": "后续法术弹跳 +2（命中后弹向最近未命中敌人，伤害递减）。", "element_tags": [], "meta": { "bounce": 2, "mana_cost": 8, "shop_cost": 18 } }
```
`shop_cost>0` → 自动进商店池；经现有法术编辑器 MODIFIER 表单编辑（meta 逃生舱即可，无需新字段）。

## 3. 影响文件

| 文件 | 改动 |
| :-- | :-- |
| `scripts/domain/spell_system/cast_stats.gd` | `bounce_add` 字段 + reset |
| `scripts/domain/spell_system/spell_evaluator.gd` | `_apply_modifier` 折叠 + `_push_projectile` 写 cold + 分支快照/还原纳入 bounce_add |
| `scripts/autoloads/bullet_manager.gd` | `_check_collision` 弹跳分支 + `_find_nearest_unvisited` |
| `data/spells.json` | `modifier_bounce` 词条 |

## 4. 验收标准

1. bounce=2 子弹依次命中 **3 个不同**敌人（1 初击 + 2 弹跳），伤害递减 100→90→81（decay 0.9，MCP 数值实测）。
2. `visited_targets` 生效：不回跳已命中的敌人（3 敌摆位验证不 ping-pong）。
3. 射程内无未访问目标 → 子弹回收（`bounce_range` 外的孤立敌人不被弹到）。
4. bounce 优先于 pierce：同带 bounce+pierce 的子弹只弹跳、不穿透（验证行为单一）。
5. `modifier_bounce` 放入卡组后子弹获得弹跳（`bounce_add` 折叠生效）；上架商店可购。
6. `validate_script` 通过；纯数据驱动；无未命中目标时不崩、速率保持不变。

## 5. 非目标（YAGNI）

- bounce + pierce 组合叠加（bounce 优先，二选一）。**已明确语义**：`visited_targets` 在 bounce 耗尽后仍保留，命中循环的跳过守卫据此使「已被弹跳命中过的敌人」在后续 pierce 阶段**不再被重复命中**——即同一子弹对每个敌人至多命中一次（避免双重扣血）。此为刻意的合理行为，非 bug（纯 bounce 子弹末次命中后即回收，不受影响；纯 pierce / 普通子弹无 `visited_targets`，完全不受影响）。
- 归航 homing（子计划 ③）。
- 弹跳光束/连线 VFX（仅命中火花，沿用现有 `hit_spark`）。
- C# 热路径（GDScript 足够，500 敌 3.6% 预算）。
- 修复 `pierce_add` 的分支快照遗漏（pre-existing，出本期范围）。
