手感定位为中度制导:homing_strength 即最大转向角速度(rad/s), 单层词条 3.0(350 速度下转弯半径 117px),rotated() 保持速率不变。 目标策略为发射后锁定,仅在目标死亡(get_pos_by_id 返回哨兵)或首帧时 经 _find_nearest_unvisited 重选,配 get_active_count()==0 空场守卫, 避免 query_circle(r=400, 约196格且每次新建数组)进入每帧热路径。 与 bounce 协同:bounce 命中重定向时改写 homing_target_id, 由 bounce 选目标、homing 追上去;复用 visited 过滤规避"绕已命中敌打转"死循环。 偏离路线图原文:锁定语义需 entity_id,而 _enemy_pos_snapshot 按槽位存位置 不含 id,无法支撑,故快照连同 EnemyManager.fill_pos_snapshot 一并删除 (全项目唯一调用方),净减每帧 O(敌人数) 开销。 顺带补设计器 MODIFIER schema 遗漏的 bounce 字段(现掉在 JSON 逃生舱里)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 KiB
子弹归航(Homing)设计
日期:2026-07-30 Epic:E1 战斗深度 · 子计划 ③(缺失功能路线图
docs_dev/plans/2026-07-23-missing-features-roadmap.md) 优先级:P0 · 工作量 S 目标:让子弹在飞行途中以受限角速度转向锁定的敌人(中度制导),激活homing深度维度;配modifier_homing词条。
1. 背景与现状(代码复核 2026-07-30)
- 子弹热数据 SoA(
BULLET_STRIDE=12),冷数据在_bullet_contexts: Dictionary(key=bullet_idx)。现已消费pierce_remaining/bounce_remaining/visited_targets,无任何 homing 字段。 bullet_manager.gd:22声明_enemy_pos_snapshot,:31每物理帧调EnemyManager.fill_pos_snapshot填充,但_gd_integrate从不读取 —— 零消费者的每帧 O(敌人数) 纯浪费。全项目 grep 确认fill_pos_snapshot只此一个调用方。- 修饰器管线已完备且被 pierce/bounce 两次验证:
CastStats累加字段 →_apply_modifier折叠 meta →_push_projectile写冷数据 →bullet_manager消费。homing 完全镜像此路径,仅字段类型由 int 变 float。 EnemyManager.get_pos_by_id对已死/不存在实体返回哨兵(-9999,-9999),bullet_manager.gd:69已在依赖此约定 —— 可直接用作「锁定目标已失效」的判据。SpatialGrid.CELL_SIZE = 64;query_circle按半径覆盖的格子做三重循环,且每次调用都新建PackedInt32Array(spatial_grid.gd:42-45注释:不可复用成员缓冲,嵌套调用会就地覆写,已实测复现)。半径 400 覆盖约 196 格,而现有碰撞查询半径 22 仅覆盖 1–2 格 —— 一次归航选目标 ≈ 一百多次碰撞查询,故选目标必须是低频操作(见 §2.4)。- GDScript 语义实测(编辑器内 lambda 验证,2026-07-30):
PackedVector2Array作函数参数是引用语义,resize()与元素写入均回传调用方。故fill_pos_snapshot本身工作正常,问题只是无人消费。
2. 设计
2.0 手感定位
中度制导:明显画弧追踪,但有最大转向角速度上限 —— 追不上贴脸急转的目标,会「甩尾」绕过去再回头。 反面:不做「轻度辅助瞄准」(玩家感知不到花了 8 蓝买了什么),也不做「每帧速度矢量直指目标」的强锁定(近乎必中,会让手瞄与闪避设计同时失效)。
转弯半径 = 速度 ÷ 角速度。主流子弹速度 ~350(spark_bolt 350 / fire_bolt 320 / frost_bolt 360):
| 角速度 | 350 速度下转弯半径 | 手感 |
|---|---|---|
| 2 rad/s | 175 px | 偏弱,大圆弧 |
| 3 rad/s(单层词条) | 117 px | 明显制导,急转的敌人能甩掉 |
| 6 rad/s(叠 2 层) | 58 px | 很难甩掉 |
| 12 rad/s(叠 4 层) | 29 px | 接近必中 |
不设叠加上限 —— 堆到必中是合法 build 收益,与 bounce 堆叠同理。
2.1 冷数据字段(子弹 _bullet_contexts[idx])
homing_strength: float—— 最大转向角速度,单位弧度/秒。homing_range: float(默认 400.0)—— 选目标搜索半径。给得比bounce_range(250) 大,因为归航是「飞行途中找目标」而非「命中时找下一个」;但保持有限,空旷处子弹走直线、不会永远不落空。homing_target_id: int—— 锁定的 entity_id,-1= 未锁定/待重选。
SoA 布局(BULLET_STRIDE=12)不变。归航是稀疏特性,进冷数据符合既有分工。
2.2 CastStats(cast_stats.gd)
- 加
var homing_add: float = 0.0(紧邻bounce_add)+reset()置 0.0。 - CIRCUIT 分支快照/还原(
spell_evaluator.gd_run_branch_payload)纳入homing_add,注释「9 字段」→「10 字段」。注:现有快照块仍未含
pierce_add(bounce 那期就记录的 pre-existing 泄漏,本期同样不修,出范围);homing_add按正确做法纳入,与bounce_add一致。
2.3 修饰器折叠与发射
_apply_modifier:
if meta.has("homing"):
ctx.stats.homing_add += float(meta["homing"])
_push_projectile(紧随现有 bounce 块):
var homing: float = float(meta.get("homing", 0.0)) + ctx.stats.homing_add
if homing > 0.0:
cold["homing_strength"] = homing
cold["homing_range"] = float(meta.get("homing_range", 400.0))
cold["homing_target_id"] = -1
发射时不解析目标,homing_target_id 留 -1,第一帧在 _gd_integrate 惰性获取。这样 spell_evaluator 不必接触 SpatialGrid,选目标逻辑全项目只有一处。
2.4 目标获取与失效(低频查询策略)
目标有效(get_pos_by_id ≠ 哨兵)→ 直接用,零查询
目标失效或首帧(id < 0) → 场上无敌人则直行;否则 _find_nearest_unvisited(pos, homing_range, visited)
稳态下一颗子弹一生只查 1–2 次(首帧获取 + 目标死亡后重选),开销可忽略。
空场守卫:重选前先判 EnemyManager.get_active_count() == 0 直接跳过,挡掉「清场瞬间所有在飞子弹集体重选且都查不到」这一最常见的病态帧。
复用现成的 _find_nearest_unvisited(bounce 期引入)—— 它已经滤除哨兵与已访问目标。纯归航子弹传 cold.get("visited_targets", [])(无该键时为空数组)。
2.5 转向积分(bullet_manager._gd_integrate,循环体顶部、位置积分之前)
前置:strength = float(cold["homing_strength"]),tpos = EnemyManager.get_pos_by_id(tid),tid 由 §2.4 解析。tid < 0(重选失败/场上无敌人)时整段跳过,子弹按原速度直行。
var vel := Vector2(_data[base + 2], _data[base + 3])
var desired := (tpos - Vector2(_data[base + 0], _data[base + 1])).angle()
var diff := wrapf(desired - vel.angle(), -PI, PI) # 取最短转向方向
var step := clampf(diff, -strength * delta, strength * delta)
var nv := vel.rotated(step)
_data[base + 2] = nv.x
_data[base + 3] = nv.y
三个要点:
rotated()保持速率不变 —— 归航只改方向不改速度,与 bounce 重定向时用spd保持速率的处理一致。wrapf(..., -PI, PI)保证走最短转向方向,不会为了追一个偏 179° 的目标而绕远路。clampf到strength * delta即「最大角速度」,落实 §2.0 的中度制导。
放在位置积分之前,故子弹当帧即沿新方向前进。
外层守卫:if not _bullet_contexts.is_empty() 后再逐弹 has()。纯普通子弹的额外成本是一次整数键哈希查找/帧。
2.6 与 bounce 协同
bounce 在命中瞬间把速度硬重定向到「最近未访问敌」(bullet_manager.gd:116),而 homing 每帧转向自己锁定的目标 —— 不协调的话命中后下一帧 homing 就会覆盖 bounce 的重定向,bounce 直接失效。
语义:bounce 负责选目标,homing 负责追上去。 在 bounce 重定向块内追加一行,把 homing_target_id 改写为 bounce 刚选中的 next_id:
if cold.has("homing_strength"):
cold["homing_target_id"] = next_id
bounce 现有行为一行不改(仍是命中瞬间硬重定向),homing 只接手其后的飞行段。两个词条叠加体感相乘而非互相抵消。
死循环规避:homing 重选走 _find_nearest_unvisited,天然跳过 visited_targets。否则归航子弹会锁定一个已命中过的敌人,而命中循环的 visited 跳过守卫(bullet_manager.gd:78)让它永远打不中 —— 子弹绕着该敌人打转直到 lifetime 耗尽。
2.7 删除死代码
bullet_manager.gd:删_enemy_pos_snapshot成员(:22)、_physics_process中的fill_pos_snapshot调用(:31)、文件头注释里的 homing 快照说明(:21)。enemy_manager.gd:删fill_pos_snapshot函数本体(:345-350)—— grep 确认无其它调用方,保留即死代码。
净效果:减少一份每帧 O(敌人数) 开销。
与路线图的偏离(有意):路线图 E1-③ 原文写「
_gd_integrate消费_enemy_pos_snapshot」。实际设计选择了按 entity_id 锁定目标(轨迹可读、目标死亡有明确判据),而快照是按槽位存位置、不含 entity_id,无法支撑锁定语义。故快照不是「接上消费者」而是「删除」。路线图对应条目在实现完成后一并订正。
2.8 修饰器数据(data/spells.json)
"modifier_homing": { "type": 1, "display_name": "Homing", "description": "后续法术获得归航(飞行中以受限角速度转向锁定的敌人)。", "element_tags": [], "meta": { "homing": 3.0, "mana_cost": 8, "shop_cost": 18 } }
mana_cost / shop_cost 与 modifier_pierce_plus、modifier_bounce 完全对齐。shop_cost>0 → 自动进商店池。
不新增自带归航的 ACTION 法术 —— 只出 modifier_homing 一个词条,与 bounce 当期只加修饰器的做法一致。
2.9 设计器字段(addons/game_designer/spell_tab.gd)
MODIFIER(type 1)的 meta schema 加:
{"k": "homing", "l": "归航 homing", "w": "f", "d": 0.0, "opt": true},
{"k": "bounce", "l": "弹跳 bounce", "w": "i", "d": 0, "opt": true},
bounce 是顺手补上的遗漏 —— bounce 那期只加了运行时,没加设计器字段,导致 bounce 键至今掉进「其它(JSON)」逃生舱,违反 CLAUDE.md「字段化,禁止手写 JSON」。同一处 schema、两行,属于本次动到的代码的定向修正,不扩散到无关重构。
3. 影响文件
| 文件 | 改动 |
|---|---|
scripts/domain/spell_system/cast_stats.gd |
homing_add 字段 + reset |
scripts/domain/spell_system/spell_evaluator.gd |
_apply_modifier 折叠 + _push_projectile 写 cold + 分支快照/还原纳入 homing_add |
scripts/autoloads/bullet_manager.gd |
_gd_integrate 转向段 + bounce 块改写 homing_target_id + 删快照成员/调用 |
scripts/autoloads/enemy_manager.gd |
删 fill_pos_snapshot |
data/spells.json |
modifier_homing 词条 |
addons/game_designer/spell_tab.gd |
MODIFIER schema 加 homing + 补 bounce |
4. 验收标准
全部经 Godot MCP 运行时实测,断言确定性数值而非目测:
- 归航生效:
homing=3.0子弹朝偏移目标画弧并命中;homing=0对照组直线飞过。断言速度矢量方向随帧变化(对照组不变)。 - 角速度上限:单帧转角 ≤
homing_strength * delta(浮点容差内)。 - 速率守恒:转向前后
Vector2(vx,vy).length()不变。 - 最短转向:目标位于子弹正后方偏一侧时,转向方向为夹角较小的一侧(验证
wrapf)。 - 目标失效重选:锁定目标死亡后,下一帧
homing_target_id变为另一存活敌人。 - 空场直行:场上无敌人时子弹保持直线,且不触发
query_circle。 - bounce 协同:
homing + bounce子弹弹跳后homing_target_id ==bounce 选中的next_id。 - 修饰器折叠:两层
modifier_homing→ctx.stats.homing_add == 6.0。 - 商店与设计器:
modifier_homing可在商店买到;设计器homing/bounce字段回读往返一致(对真实data/spells.json)。 validate_script通过;纯数据驱动;无目标时不崩。
5. 记录在案的风险与逃生方案
R-H1 · 选目标频率尖峰:§2.4 只在目标失效时查询,但「敌人存在却都在 homing_range 外」时,每颗归航子弹每帧都会查一次 query_circle(约 196 格 + 一次数组分配)。空场守卫挡不住这种情况。
逃生方案(方案 3,暂不实现):重选失败时往冷数据写 homing_retry_at(Time.get_ticks_msec() 时间戳),0.2s 内不再重试,把最坏情况摊薄约 12 倍(60fps)。改动为冷数据加一个字段 + 重选前一处时间比较,约 5 行。
暂不实现的理由:CLAUDE.md 明定「先测量后优化,不做无凭据的过早优化」。当前 _physics_process 仅占帧预算 3.6%(500 敌 + 1500 弹 ≈ 0.6ms),方案 3 是在为尚未测到的瓶颈增加复杂度。触发条件:验收第 6 项附近若实测到「敌人在场但均超出射程」时出现帧耗时尖峰(_physics_process 超过 2ms),立即启用。
6. 非目标(YAGNI)
- 轨迹修正 / 曲线弹道(E1 子计划 ④,角速度常量而非寻的)—— 独立立项。
- 自带归航的 ACTION 法术 —— 本期只出修饰器。
- 归航拖尾 / 锁定指示 VFX —— 沿用现有
hit_spark。 - 敌方子弹归航(
EnemyBulletManager)—— 本期仅玩家子弹。 - C# 热路径 —— GDScript 足够(500 敌 3.6% 预算),且本期净减开销。
- 修复
pierce_add的分支快照遗漏 —— pre-existing,出本期范围。 - 修复
acceleration积分错误 ——_gd_integrate:42-43对 vx/vy 各自加同一标量accel*delta(应为沿速度方向加速)。当前无任何法术写入非零acceleration,故为惰性死代码;会在 E1-④ 轨迹修正启用时暴露。出本期范围,已另行记录。