Files
spellforge/docs_dev/specs/2026-07-30-bullet-homing-design.md
joywayerandClaude Opus 5 e651072b9b docs: 订正归航手感论证的运动学错误 + 记录定价越权与用户决策
【spec §2.0】原论证只算转弯半径就断言「急转的敌人能甩掉」,从未拿它对照敌人
实际速度 —— 明写此错,并补上缺的那一步运动学核算:跟踪横向速度 v、距离 d 的
目标所需角速度 ω≈v/d,故跟丢条件是 d < v/ω_max(近距离判据)。代入实际数值
(敌 45~140 px/s、弹 ~350 px/s) 重列强度表,标出发版值 1.5、给出各档对最快敌人
的跟丢距离。说明中远距离不是区分点(1.5 与 3.0 余量分别近 5 倍/10 倍,都能可靠
命中),真实失手方式是【近距离过冲】——目标坐进转弯圈内、几何上无法收敛,
而这正是 1.5 想要的手感。叠加表按 1.5 递增重列,堆到必中仍是合法 build 收益。

【spec §2.8】承认原「与 pierce/bounce 对齐」的论证绕过了权威:那两个词条本身
就不在定价表里,拿两个未定价条目互相对齐等于自建平行标准。而
numerical_design.md 确实给 homing 定过价(Tier 3 / Mana 40),且该表是活的权威——
其 Mana 列与 spells.json 每个已实现条目精确吻合,仅 homing 一行偏离。记录用户
决策及其取舍:这个词条能卖同族价,是因为它被调到了同族强度。

【numerical_design.md】homing 行按 P6-N2/N25 同样风格标注:原行删除线保留可
追溯(不抹掉「曾判定为 Tier 3」这个记录),表下补注说明 Homing Force 语义已废
(实现为最大转向角速度 rad/s,与 5.0 不同量纲不可比)、Mana 40 被同族标准取代、
实际发版 homing 1.5 / Mana 8 / 商店 18,并链接 spec。

【architecture_design.md】§4.2 冷数据注释的示例值 3.0 → 1.5(含转弯半径 233px)。

【plan】Task 1 Step 5/6 的 JSON 与断言期望值同步为 1.5;验收⑧(Step 6/6b)重跑
并更新为 folded=3.00 / e2e_2stack=3.000 / e2e_1stack=1.500;Step 8b 补设计器
往返与 SpinBox 格点确认输出。新增顶部醒目段落说明【机制测试①②③④⑦里的 3.0
是测试局部值、与发版值刻意解耦】——平衡调整不应导致机制测试失败,不要为了
数字一致去改它们;唯一应随发版值走的是⑧。Step 7 注明平衡调整不影响性能守卫
(强度只进转向数学,不参与决定 query_circle 频率的任何分支),未重跑。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:13:58 +08:00

339 lines
30 KiB
Markdown
Raw Permalink 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 蓝买了什么),也不做「每帧速度矢量直指目标」的强锁定(近乎必中,会让手瞄与闪避设计同时失效)。
> **⚠️ 原论证有错,已订正(2026-07-30,发版前平衡复核)**:本节初稿只算了转弯半径,就断言 3.0 rad/s「急转的敌人能甩掉」—— **算了转弯半径,却从未拿它对照敌人的实际速度**。补做运动学核算后结论反转:3.0 rad/s 实际接近「稳定命中」。单层词条值因此由 **3.0 降为 1.5 rad/s**,详见下方核算与 §2.8 定价说明。
### 运动学核算(原论证缺的那一步)
跟踪一个相对子弹横向速度为 `v`、距离为 `d` 的目标,所需角速度约为 **`ω ≈ v / d`**。子弹能提供的上限是 `homing_strength`,故**跟丢的条件是 `d < v / ω_max`** —— 注意这是个**近距离**判据:距离越近,同样的横向速度要求越高的角速度。
代入本工程实际数值(`data/enemies.json` 敌人速度 **45 ~ 140 px/s**;主流子弹速度 **~350 px/s**`spark_bolt` 350 / `fire_bolt` 320 / `frost_bolt` 360):
| 角速度 | 转弯半径(350 速度) | 对最快敌人(140 px/s)的跟丢距离 `v/ω` | 手感 |
| :-- | --: | --: | :-- |
| 1.0 rad/s | 350 px | 140 px | 偏弱,大圆弧 |
| **1.5 rad/s(单层词条·发版值)** | **233 px** | **93 px** | **中度制导:中远距可靠修正,贴脸急转能甩掉** |
| 3.0 rad/s(叠 2 层) | 117 px | 47 px | 很难甩掉,接近稳定命中 |
| 6.0 rad/s(叠 4 层) | 58 px | 23 px | 已在碰撞半径量级,接近必中 |
**远距离拦截不是区分点。** 400 px 接近过程耗时约 `400/350 ≈ 1.14 s`,其间最快敌人横移约 160 px,所需航向修正仅约 `atan(160/400) ≈ 0.38 rad`;而 1.5 rad/s 在同样时间内可转 **1.71 rad**,余量近 5 倍。3.0 rad/s 的余量则接近 10 倍 —— 两者在中远距**都**能可靠命中,差别根本体现不出来。这正是原论证失效的地方:它比较的是两个都远超需求的数字。
**真实的失手方式是近距离过冲**,不是「被甩掉」。目标进入子弹的转弯圈(半径 233 px)以内时,子弹的最小转弯半径大于它到目标的距离,几何上无法收敛 —— 会从目标旁掠过、绕一个大弧再回头。1.5 rad/s 把这个「过冲区」放大到有意义的尺度(93 px 内对最快敌人必然跟丢,233 px 转弯圈使贴身缠斗时反复过冲),**这正是本节想要的手感**:远处放心开火,近身仍需走位与手瞄。3.0 rad/s 下过冲区只有 47 px,已小到玩家感知不到。
不设叠加上限 —— 堆到必中是合法 build 收益,与 bounce 堆叠同理;叠 2 层即回到 3.0、叠 4 层 6.0,正是玩家用词条位换来的强度。
### 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": 1.5, "mana_cost": 8, "shop_cost": 18 } }
```
`mana_cost` / `shop_cost``modifier_pierce_plus``modifier_bounce` 完全对齐。`shop_cost>0` → 自动进商店池。
> **⚠️ 定价论证的订正与用户决策(2026-07-30,发版前平衡复核)**
>
> **原论证绕过了权威。** 本节初稿的理由是「与 pierce/bounce 对齐」,但那两个词条**根本不在定价权威表里** —— 拿两个同样未被该表定过价的条目互相对齐,等于自建了一套平行标准。而 `docs/design/numerical_design.md` 的法术数据库**确实给 homing 定过价****Tier 3(史诗/质变)· Mana 40**,备注「只有这一个修正就够改变玩法」。
>
> 该表是**活的定价权威**,不是过时草案 —— 复核确认它的 Mana 列与 `spells.json` 里**每一个**已实现条目精确吻合(`double_cast` 2 / `spread_mod` 0 / `damage_plus` 15 / `trigger_hit` 10 / `heavy_cost` 0 / `chain_bolt` 60 / `energy_orb` 20),**只有 homing 这一行对不上**。所以这是一处真实的越权定价,不是表本身失准。
>
> **用户决策:降强度到 1.5 rad/s,保持 8 蓝 / 18 金。** 三个选项中(① 抬价到 40 蓝 Tier 3;② 保持 8 蓝但承认越权;③ 降强度使 8 蓝名副其实),选 ③。理由:它是唯一**同时**兑现 §2.0 原批准的设计意图(中度制导、可被甩掉)又**不需要连带复核蓝耗曲线**的选项 —— 抬到 40 蓝会牵动整条 Tier 3 定价与玩家蓝池预算,属另一次立项。
>
> 换言之:**这个词条之所以能卖同族价,是因为它被调到了同族强度**,而不是因为「和 pierce/bounce 对齐」这条论证本身成立。§2.0 的运动学核算证明 3.0 rad/s 确实值 Tier 3 的评价(接近稳定命中),原表的判断是对的。
>
> `numerical_design.md` 对应行已在同日标注:原 `Homing Force: 5.0` / Mana 40 保留可追溯,并注明实际发版为 `homing 1.5` / Mana 8。
**不新增自带归航的 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=0` 对照组直线飞过。断言速度矢量方向随帧变化(对照组不变)。
> 机制类验收(①②③④⑦)的测试脚本直接构造冷数据、**不读 `spells.json`**,固定用**测试局部值 `homing_strength = 3.0`**`3.0/60 = 0.05` 是个干净的单帧判据数)。该值与 `modifier_homing` 的**发版值(现 1.5,见 §2.0)刻意解耦** —— 平衡调整不应导致机制测试失败。唯一随发版值走的是 ⑧。
2. **角速度上限**:单帧转角 ≤ `homing_strength * delta`(浮点容差内)。
3. **速率守恒**:转向前后 `Vector2(vx,vy).length()` 不变。
4. **最短转向**:目标位于子弹正后方偏一侧时,转向方向为夹角较小的一侧(验证 `wrapf`)。
5. **目标失效重选**:锁定目标死亡后,下一帧 `homing_target_id` 变为另一存活敌人。
6. **空场直行**:场上无敌人时子弹保持直线,且**不因归航**触发 `query_circle`
> **措辞修正(2026-07-30 验收期)**:原写「不触发 `query_circle`」,字面为假 —— `_check_collision``bullet_manager.gd:69`)每帧对每颗子弹**无条件**发一次 `query_circle(pos, radius+16)`,与归航无关。被验证的命题是「`_apply_homing` 这条路径不发查询」。
>
> **⚠️ 本条是全部 10 条验收里唯一的间接验证**,值得留痕。无法给 autoload 的方法挂调用计数器,故改用状态判据:空场跑 6 帧后断言 `homing_target_id` 未变且冷数据无 `homing_retry_at` 键。其封闭性论证 ——
> 1. `_apply_homing` 内通往 `SpatialGrid` 的路径**有且仅有**一处 `_find_nearest_unvisited`,无其它 `SpatialGrid` 访问;
> 2. 该调用之后必然二选一:成功则写 `homing_target_id`(变为 ≥0 的新值),失败则写 `homing_retry_at`
> 3. 两者皆未发生 ⟹ 该调用未被执行 ⟹ 归航未发查询;
> 4. 零敌人时 `_check_collision` 在 `:71``hits.is_empty()`)即返回,bounce 路径不可达,不会与之混淆。
>
> 论证对被限定后的命题成立,但**它是状态推断而非对调用次数的直接观测** —— 如实标注,不宣称为直接测量。
7. **bounce 协同**`homing + bounce` 子弹弹跳后 `homing_target_id == ` bounce 选中的 `next_id`
8. **修饰器折叠**:两层 `modifier_homing``ctx.stats.homing_add == 2 ×` 发版值(现 `1.5 × 2 = 3.0`)。**本条有意耦合发版值**,测的正是「JSON → 折叠 → 冷数据」这条数据驱动链路,平衡调整时须同步更新期望值。
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` 一值两用且刻意耦合 —— 既是基础退避时长也是抖动窗口宽度,「铺满恰好一个退避周期」正是消峰所需的性质,拆成两个常量反而掩盖这层关系。
实测打散质量(2026-07-30 验收期复测):1500 颗同帧集体失败 → 到期时刻**跨度 223 ms**(≈ 恰好一个退避周期,即 13~14 帧)、**222 个互异毫秒值**,平均每毫秒约 6.8 颗、每帧约 110 颗(理想 500/12 = 125)。
> **勘误**:本节初稿写「1493 个互异值」,该数**不可能成立** —— `jitter = (bullet_idx * 200) / _active_count` 对 1500 颗弹只能产出 0~199 共约 200 个整数值,同帧失败的子弹又共用同一个 `now`,故互异到期时刻上限就是 ~200 个。复测得 222,多出的 22 来自「遍历这 1500 颗本身耗时约 23 ms,期间 `now` 在推进」(与 §5.6 记录的首帧 23 ms 吻合)。**打散结论不变**(跨度铺满一个完整退避周期即消峰所需性质),只是原先那个数字是错的。
### 5.5 抖动前后单帧峰值对比(60fps 起搏 · 40 帧)
| 场景 · 规模 | 抖动前·复发尖峰 | 抖动后·稳态峰值(区间) | 稳态超 16.67ms 帧数 |
| :-- | --: | --: | --: |
| ① 全射程外 · 1500 | 13.6 ~ 15.7 ms(每 12 帧复发) | **4 ~ 5 ms** | 0/39 |
| ① 全射程外 · 300 | 3.2 ms | **约 1 ms** | 0/39 |
| ② 全 visited · 1500 | 20.9 ~ 21.9 ms(每 12 帧复发) | **5 ~ 7 ms** | 0/39 |
| ② 全 visited · 300 | 4.6 ms | **1 ~ 2.5 ms** | 0/39 |
复发尖峰彻底消除,稳态全部回到帧预算内。
> **⚠️ 数字按区间读,不要按单值读**:以上均为**编辑器/调试构建**实测,运行间存在明显离散。同一场景 ②·1500 三次独立测量分别得 **5.61 / 5.09 / 7.29 ms**240 帧长跑收敛在 5–7 ms 带内、无发散。写成单值会显得比数据本身更精确 —— 结论(稳态远低于 16.67 ms 帧预算、无复发尖峰)不受此离散影响,但**不要引用单个数字做后续推算**,也不要因复跑得到带内的不同值就判定为回归。判定回归请看:是否出现周期性复发尖峰、是否有帧越过 16.67 ms。
>
> 另注:稳态峰值对**测试几何**敏感。若子弹航线穿过敌人簇,`_check_collision` 的 `query_circle` + `visited_targets` 线性扫描会额外贡献 1~1.5 ms,那部分**不是归航成本**(验收期曾因此误读一次)。上表摆位为「敌人置于环上、子弹簇于原点」,刻意让子弹不穿过敌人簇。
### 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「先测量后优化,不做无凭据的过早优化」,为一个合成场景付这个代价不划算。
**重新立项的触发条件(按弹数算,不要按场景猜)**:成本严格线性于「**同一帧**创建、且首次选目标**全部失败**的归航弹数」,约 **9.5 µs/颗**。据此可直接算出击穿点,无需揣摩「典型齐射规模」:
| 同帧首选失败的归航弹数 | 该帧额外耗时 | 判断 |
| --: | --: | :-- |
| 200 | ≈ 1.9 ms | 无感 |
| 500 | ≈ 4.8 ms | 可接受 |
| 1000 | ≈ 9.5 ms | 接近半帧,警戒 |
| **1750** | **≈ 16.7 ms** | **满帧预算,必然掉帧** |
即**约 1750 颗**是理论击穿点(`MAX_BULLETS = 2048`,故容量内可达)。注意实测 1500 颗 `visited` 场景为 22.0 ms,高于线性外推的 14.3 ms —— 差额来自 `visited_targets` 的线性 `in` 扫描,故 ② 类场景的实际击穿点更低。
**当同帧齐射的归航弹数逼近千级时重新立项**,按上述三条路径之一处理。
### 5.7 潜在项(当前休眠,**不修**):退避用墙钟,不免疫时间缩放
退避基于 `Time.get_ticks_msec()` —— **墙钟,不受 `Engine.time_scale` 影响**。而 `TimeManager` 正是为「慢动作 / 冻结时间 Core 效果」驱动 `Engine.time_scale` 的(`time_manager.gd:4,16`)。
**当前不是问题**grep 确认 `TimeManager.set_time_scale` 全项目**零调用方**(仅 `time_manager.gd:23` 定义),慢动作 Core 尚未落地。
**若日后落地**:设 0.2× 慢动作,200 ms 墙钟只跨约 2.4 个物理帧而非 12,每帧重选量涨约 5×,§5.4 的抖动摊薄基本失效 —— 且表现为**慢动作期间掉帧**,即最不该掉帧的时候掉。
**替代方案(触发时再实施,现在不动)**:改用物理帧计数而非墙钟 —— `HOMING_RETRY_FRAMES: int = 12`,抖动公式变成帧原生的 `(bullet_idx * HOMING_RETRY_FRAMES) / maxi(1, _active_count)`。天然免疫时间缩放、比 `Time.get_ticks_msec()` 略便宜,并消除 ms ↔ 帧的量纲错配(现在「200 ms ≈ 12 帧」只在 60fps 且 `time_scale == 1` 时成立)。本项目已有现成计数器 `TimeManager.game_tick``time_manager.gd:9,15`),其注释明写「不受 time_scale 影响」,正合用。
**触发条件**:慢动作 / 冻结时间 Core 落地时,即 `set_time_scale` 出现第一个调用方。
## 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-④ 轨迹修正启用时暴露。出本期范围,已另行记录。