Files
spellforge/docs_dev/specs/2026-07-30-bullet-homing-design.md
T
joywayerandClaude Opus 5 4ef8012b6e docs: 首帧锁定尖峰决策为「接受」+ Task4 Step7 改为两场景单帧峰值回归守卫
spec §5.6:残余的首帧尖峰(1500 弹同帧创建且首次选目标全失败时 16~22ms)
决策接受不处理——触发条件是三个苛刻条件的合取属合成最坏情况、一次性不复发、
压下它需牺牲「首次锁定即时」的手感或改变归航/bounce 语义。附重新立项触发条件。

plan Task4 Step7:原只测「敌人全在射程外」,补上评审发现的更严重场景
「射程内全在 visited」(visited 只增不减故为永久状态);指标由 60 帧均值
改为单帧峰值——退避把成本摊成 1 重选帧 + 11 退避帧,均值会掩盖尖峰,
而帧预算 16.67ms 下尖峰才是玩家可见的。性质由决策关卡降级为回归守卫。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:15:01 +08:00

278 lines
20 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` 直接跳过,挡掉「清场瞬间所有在飞子弹集体重选且都查不到」这一最常见的病态帧。
> **补充(实测后修订)**:上面「一生只查 1–2 次」只在**重选成功**时成立;重选**失败**时会每帧重试,空场守卫挡不住。实测已越过触发线,故 §5 的退避方案(`homing_retry_at`)已随本期落地 —— 失败路径退避 200 ms 后再试。详见 §5。
复用现成的 `_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 选目标频率尖峰 —— 已实测触发并启用逃生方案
> **状态更新(2026-07-30Task 2 代码质量评审后)**:本节原写「方案 3 暂不实现」,等验收期实测再定。评审在 Task 2 上直接实测,**两种病态状态在 300 弹规模就已越过本节写死的 2.0 ms 触发线**,故方案 3 已随 Task 2 落地,不再等待。以下如实记录,含原始风险分析的不完整之处。
### 5.1 病态状态(其二为本次新发现)
重选**失败**时原实现不留任何记录,于是该子弹余生每帧都重跑 `_find_nearest_unvisited``query_circle(pos, 400)`(约 196 格 + 一次 `PackedInt32Array` 分配)。`get_active_count() == 0` 空场守卫只挡住「全场清空」—— 而这是本游戏里最罕见的状态,两种日常状态都没挡住:
1. **敌人在场但全在 `homing_range` 外**(玩家朝空处开火)—— 本节原文已预见。
2. **射程内敌人全在 `visited_targets` 里** —— **原风险分析漏掉了这一种,是本次发现,且比第 1 种更糟**bounce+homing 组合弹打空一个小簇后 `visited_targets` 只增不减,对那颗子弹是**永久状态**而非瞬态;同时 `visited` 的线性 `in` 扫描叠加在 `query_circle` 之上,单弹成本更高。
原分析把 R-H1 定性为「瞬态尖峰」是不准确的 —— 存在使其长期化的路径。
### 5.2 实测(启用前 vs 启用后)
测法:运行时直调 `BulletManager._gd_integrate(1/60)` 计时,20 帧取均值。30 个敌人置于环上(`far` 环半径 3000`visited` 环半径 300 —— 在 `homing_range` 400 内但在碰撞半径 20 外),子弹簇于原点附近,`homing_strength=3.0``homing_range=400`。对照基线为同规模无冷数据普通子弹。
| 场景 | 1500 弹(启用前) | 300 弹(启用前) | 1500 弹(启用后·摊薄) | 300 弹(启用后·摊薄) |
| :-- | --: | --: | --: | --: |
| 基线(普通子弹,无归航) | 1.630 ms | 0.324 ms | 1.642 ms | 0.324 ms |
| ① 敌人全在射程外 | **15.790 ms** | **3.135 ms** | 3.939 ms | 0.784 ms |
| ② 射程内全在 visited | **21.965 ms** | **4.341 ms** | 4.518 ms | 0.884 ms |
单弹成本:① 10.53 µs/帧、② 14.64 µs/帧。两种场景在 300 弹规模分别为 3.14 / 4.34 ms**均已越过本节 2.0 ms 触发线**,故触发条件成立。
「启用后」的摊薄值按 60fps 下 12 帧中 1 帧付全价计算 `(重选帧 + 11 × 退避帧) / 12`。分解:
| 场景 · 规模 | 退避帧(零查询) | 重选帧(全价) | 摊薄 |
| :-- | --: | --: | --: |
| ① 1500 弹 | 2.856 ms | 15.851 ms | 3.939 ms |
| ① 300 弹 | 0.567 ms | 3.167 ms | 0.784 ms |
| ② 1500 弹 | 2.915 ms | 22.150 ms | 4.518 ms |
| ② 300 弹 | 0.570 ms | 4.340 ms | 0.884 ms |
### 5.3 已启用的方案 3(退避 + 抖动)
重选失败时往冷数据写 `homing_retry_at`,窗口内子弹直行且不再查询,60fps 下把最坏情况摊薄约 **12×**。要点:
- **首次锁定仍是即时的** —— 无该键时 `get` 返回 0 必小于 `now`,退避只作用于失败路径,§2.0 的手感不回退(运行时已断言首帧即锁定且不写退避键)。
- 重选**成功**即 `erase("homing_retry_at")`,恢复 §2.4 的零查询稳态。
- 顺带同期落地:`homing_strength` 判别提到 `_gd_integrate` 调用方,带冷数据但不归航的子弹(纯 pierce/bounce/状态/荷载,真实 build 里占多数)不再付一次完整函数调用 —— 1500 纯 pierce 弹 2.107 → 1.812 ms。
### 5.4 二次发现:**朴素退避会同步,而非打散**(本次最值得记住的一条)
落地固定 200 ms 退避后按真实 60fps 起搏复测(每轮补足 16.67 ms 墙钟,只对 `_gd_integrate` 计时),发现**尖峰并未消失,只是变成周期性复发**。1500 弹 `visited` 场景逐帧耗时(ms):
```
抖动前: 21.9 2.8 2.9 2.9 2.9 2.9 2.9 2.9 2.9 2.9 2.8 3.1 | 21.8 2.8 ... | 21.0 2.9 ... | 20.9 3.7 3.9 3.8
↑第1帧 ↑第13帧 ↑第25帧 ↑第37帧
```
**恰好每 12 帧复发一次,永不衰减。** 根因:同一帧失败的所有子弹拿到同一个 `now`,到期时刻完全相同,一个退避周期后又整齐地一起重试。**退避把子弹锁进同相,而不是打散它们** —— 同波齐射本就天然同相,此后再无机会错开。设计退避/重试逻辑时若不显式加抖动,默认结果就是同步而非分散;这与「退避能摊薄尖峰」的直觉相反,摊薄后的均值数字恰恰会掩盖它。
**修法:抖动到期时刻**`_apply_homing` 失败路径):
```gdscript
var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count)
cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS
```
到期时刻均匀铺满 `[200, 400)` ms ≈ 恰好一个退避周期 ≈ 12 帧。**用「`bullet_idx` 在活跃弹数中的占比」而非 `bullet_idx % HOMING_RETRY_MS`**:后者在弹数少于窗口宽度时只能铺开 `_active_count` 毫秒(20 颗弹 → 20 ms ≈ 1.2 帧,等于没打散),占比式则不论弹数多少都铺满整窗。`HOMING_RETRY_MS` 一值两用且刻意耦合 —— 既是基础退避时长也是抖动窗口宽度,「铺满恰好一个退避周期」正是消峰所需的性质,拆成两个常量反而掩盖这层关系。
实测打散质量:1500 颗同帧集体失败 → 到期偏移 200~415 ms、1493 个互异值、覆盖 14 帧、单帧最多 120 颗(理想 125)。
### 5.5 抖动前后单帧峰值对比(60fps 起搏 · 40 帧)
| 场景 · 规模 | 抖动前·复发尖峰 | 抖动后·稳态峰值 | 稳态超 16.67ms 帧数 |
| :-- | --: | --: | --: |
| ① 全射程外 · 1500 | 13.6 ~ 15.7 ms(每 12 帧复发) | **4.40 ms** | 0/39 |
| ① 全射程外 · 300 | 3.2 ms | **0.92 ms** | 0/39 |
| ② 全 visited · 1500 | 20.9 ~ 21.9 ms(每 12 帧复发) | **5.61 ms** | 0/39 |
| ② 全 visited · 300 | 4.6 ms | **1.15 ms** | 0/39 |
复发尖峰彻底消除,稳态全部回到帧预算内。
### 5.6 残余:初次锁定帧 —— **已决策:接受,不处理**
抖动后仍有**一个**高耗时帧 —— 第 1 帧,1500 弹 `far` 16.2 ms / `visited` 22.0 ms。它**不是**退避尖峰,而是「§2.4 首次锁定即时、不受退避约束」的直接结果:1500 颗归航子弹在同一帧被创建时,它们的首次 `query_circle` 必然落在同一帧。抖动按设计不作用于此(已断言首帧即时锁定且不写退避键,手感不回退)。
触发条件苛刻:需**同一帧**创建约 1500 颗归航子弹且首次选目标全部失败。典型局面下齐射规模远小于此,且失败还需叠加「全射程外」或「全 visited」。
**决策(2026-07-30):接受,本期不处理。** 理由三条:
1. **触发条件不是真实局面。** 1500 弹是全游戏子弹池的压力上限(`MAX_BULLETS=2048`),要求它们**全部**归航、**同帧**创建、且首次选目标**全部**失败 —— 这是三个独立苛刻条件的合取,属合成的最坏情况而非可达的玩法状态。项目实测基准是 500 敌 + 1500 弹时整个 `_physics_process` ≈ 0.6 ms(帧预算 3.6%)。
2. **它一次性、不复发。** 与被修掉的那个每 12 帧永久复发的尖峰性质不同 —— 后者是持续卡顿,前者最多是一次可忽略的掉帧。
3. **压下它的代价是手感回退或语义变更。** 三条可行路径(首帧也抖动 / 连续失败 N 次后彻底放弃归航 / 给 `visited_targets` 设上限)分别牺牲「首次锁定即时」、改变归航语义、改变 bounce 语义。按 CLAUDE.md「先测量后优化,不做无凭据的过早优化」,为一个合成场景付这个代价不划算。
**重新立项的触发条件**:若日后出现「单帧创建数百颗以上归航子弹」的真实玩法(如某个 Endless 全局词条或 Boss Rush 构筑),且实测首帧超 16.67 ms,则按上述三条路径之一立项处理。
## 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-④ 轨迹修正启用时暴露。出本期范围,已另行记录。