# 子弹归航（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-30，Task 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-④ 轨迹修正启用时暴露。出本期范围，已另行记录。
