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