Files
spellforge/docs_dev/specs/2026-07-30-bullet-homing-design.md
T
joywayerandClaude Opus 5 049d1dda1b docs(spec): E1-③ 子弹归航设计——受限角速度中度制导 + 锁定式目标 + 删除无人消费的位置快照
手感定位为中度制导: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>
2026-07-30 13:36:42 +08:00

195 lines
13 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.
# 子弹归航(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`
```gdscript
if meta.has("homing"):
ctx.stats.homing_add += float(meta["homing"])
```
`_push_projectile`(紧随现有 bounce 块):
```gdscript
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`(重选失败/场上无敌人)时整段跳过,子弹按原速度直行。**
```gdscript
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
```
三个要点:
1. `rotated()` **保持速率不变** —— 归航只改方向不改速度,与 bounce 重定向时用 `spd` 保持速率的处理一致。
2. `wrapf(..., -PI, PI)` 保证走最短转向方向,不会为了追一个偏 179° 的目标而绕远路。
3. `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`
```gdscript
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`
```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 加:
```gdscript
{"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 运行时实测,断言确定性数值而非目测:
1. **归航生效**`homing=3.0` 子弹朝偏移目标画弧并命中;`homing=0` 对照组直线飞过。断言速度矢量方向随帧变化(对照组不变)。
2. **角速度上限**:单帧转角 ≤ `homing_strength * delta`(浮点容差内)。
3. **速率守恒**:转向前后 `Vector2(vx,vy).length()` 不变。
4. **最短转向**:目标位于子弹正后方偏一侧时,转向方向为夹角较小的一侧(验证 `wrapf`)。
5. **目标失效重选**:锁定目标死亡后,下一帧 `homing_target_id` 变为另一存活敌人。
6. **空场直行**:场上无敌人时子弹保持直线,且不触发 `query_circle`
7. **bounce 协同**`homing + bounce` 子弹弹跳后 `homing_target_id == ` bounce 选中的 `next_id`
8. **修饰器折叠**:两层 `modifier_homing``ctx.stats.homing_add == 6.0`
9. **商店与设计器**`modifier_homing` 可在商店买到;设计器 `homing`/`bounce` 字段回读往返一致(对真实 `data/spells.json`)。
10. `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-④ 轨迹修正启用时暴露。出本期范围,已另行记录。