Files
spellforge/docs_dev/specs/2026-07-24-bullet-bounce-design.md
T

130 lines
7.1 KiB
Markdown
Raw 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 / 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) × (1res) 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→81decay 0.9MCP 数值实测)。
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,出本期范围)。