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

14 KiB
Raw Blame History

子弹连锁/弹跳(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(已建)。specdocs_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)之后新增:

var bounce_add:       int   = 0     # MODIFIER 累加的弹跳次数(叠加到 ACTION 自带 bounce

reset()pierce_add = 0:25)之后新增:

	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)之后新增:

	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)新增:

			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)之后新增:

	var b_bnc:  int   = s.bounce_add

在还原段 s.crit_chance = b_crit:642)之后新增:

	s.bounce_add      = b_bnc

把该块注释「快照分支前 CastStats(8 字段…」改为「(9 字段…」。

  • Step 5: spells.json 加 modifier_bounce

data/spells.jsonmodifier_pierce_plus 行之后(同为 type 1 修饰器区)新增一行(注意逗号):

  "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.gdres://scripts/domain/spell_system/spell_evaluator.gd — 均无错误。 Run: godot-mcp-pro execute_editor_script(用 _mcp_print):

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: 提交
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)之前插入:

		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: 命中触发子荷载 块之后插入:

			# 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.gdget_nearest_enemy_pos:177)之前或 _check_collision 之后新增:

## 弹跳寻的: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: 提交
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_sceneres://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

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

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):

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_modifiermeta.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 索引。

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