如实记录: - 原风险分析只预见「敌人全在射程外」,漏掉「射程内敌人全在 visited_targets 里」—— 后者经 bounce+homing 组合是该子弹的永久状态而非瞬态,且成本更高,是本次发现; 原文把 R-H1 定性为瞬态尖峰不准确。 - 实测两种场景在 300 弹规模已达 3.14 / 4.34 ms,越过本节自定的 2.0 ms 触发线, 故方案 3(0.2s 退避)随 Task 2 落地,摊薄约 12×。 - 残余未收敛项另立 §5.4:1500 弹摊薄后仍 3.9 / 4.5 ms,且退避会使同波齐射的 子弹同步重试、留下 15.9 / 22.2 ms 单帧尖峰;错帧方案待决策,本次未擅自扩大范围。 §2.4 补一句指向 §5,避免「一生只查 1–2 次」与失败路径重试自相矛盾。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
239 lines
17 KiB
Markdown
239 lines
17 KiB
Markdown
# 子弹归航(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-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 = Time.get_ticks_msec() + HOMING_RETRY_MS`(**200 ms**),窗口内子弹直行且不再查询,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 未收敛的残余(待定,需决策)
|
||
|
||
摊薄后 300 弹规模已回到预算内(0.78 / 0.88 ms),但 **1500 弹规模的摊薄值 3.9 / 4.5 ms 仍高于 2.0 ms**,且更值得注意的是**重选帧本身仍是 15.9 / 22.2 ms 的单帧尖峰**。
|
||
|
||
原因是**退避会同步而非打散**:同一帧失败的所有子弹拿到同一个 `now`,退避期一致,12 帧后又整齐地一起重试。同一波齐射的子弹天然同相,此后不会自行错开。
|
||
|
||
候选解(评审提出,**尚未实现,待决策**):按 `(Engine.get_physics_frames() + bullet_idx) % 12` 错帧重选,把重试分散到 12 帧上消除尖峰;或更小改动 —— 给退避时长加与 `bullet_idx` 相关的抖动。1500 颗同时归航且同时重选失败属压力上限而非典型局面,故未擅自扩大本次改动范围。
|
||
|
||
## 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-④ 轨迹修正启用时暴露。出本期范围,已另行记录。
|