docs: 退役 P6-N2/P6-N25 并订正 §4.2 归航设计——删除快照的连带订正

E1-③ 归航实现删除了 _enemy_pos_snapshot 与 EnemyManager.fill_pos_snapshot
(锁定式目标需 entity_id,快照按槽位存位置不含 id),权威文档随之失准,一并订正:

- docs/README.md:P6-N25(快照须为类成员)与 P6-N2(Homing 弹共用快照)双双失去
  约束对象,改为删除线 + ⚠️ 已退役(2026-07-30) 并指向 spec §2.4/§2.7。不删条目,
  避免编号规范留下悬空引用。
- architecture_design.md §4.2:homing 伪代码整段改写为实际算法——冷数据
  homing_strength(弧度/秒)/homing_range/homing_target_id、发射不解析目标首帧惰性
  锁定、仅目标失效时经 _find_nearest_unvisited 重选、wrapf 最短转向 + clampf 限
  角速度 + rotated() 保速率、空场守卫、失败退避+抖动、bounce 协同改写目标并撤销
  退避键。性能数字一律指向 spec §5,不另起一套。
- 冷数据字段表 homing_force → homing_strength/homing_range/homing_target_id。
- EnemyManager API:fill_pos_snapshot 换成实际存在的 get_pos_by_id(含哨兵约定);
  get_nearest_pos 与自动瞄准改为「直接扫描 SoA」,原文称与 homing 快照共用属误述。
- S0 性能表 EnemyManagerCs 行标注该实测含已删除的 fill_pos_snapshot(保留历史数字)。
- MinionManager.fill_pos_snapshot 标注为未实现提案,并纠正其「供 homing 用」的注释。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 17:01:56 +08:00
co-authored by Claude Opus 5
parent d498097005
commit 14136b1223
2 changed files with 77 additions and 34 deletions
+75 -32
View File
@@ -607,8 +607,8 @@ func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool:
| +11 | `acceleration` | 速度加速度(像素/秒²,0=匀速) |
> **冷数据(非热路径)** 存储于 `_bullet_contexts: Dictionary`key=bullet_id),包含:
> `pierce_remaining`, `bounce_remaining`, `homing_force`, `on_hit_payload_id`,
> `on_expire_payload_id`, `on_kill_payload_id`, `proc_rate`, `visited_targets`。
> `pierce_remaining`, `bounce_remaining`, `homing_strength`, `homing_range`, `homing_target_id`,
> `on_hit_payload_id`, `on_expire_payload_id`, `on_kill_payload_id`, `proc_rate`, `visited_targets`。
> 正弦弹道等额外字段(`wave_frequency`, `wave_amplitude`)也存于此 Dictionary
> 不占用 SoA 热数组空间,仅在命中/特殊弹道帧查询。
>
@@ -618,36 +618,77 @@ func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool:
> `_bullet_contexts[id]` 随子弹销毁时一同移除(`_bullet_contexts.erase(bullet_id)`)。
* 在 `_physics_process(delta)` 中,直接遍历 Array 进行物理积分,速度比遍历 Node Tree 快一个数量级。
* **Dirty Sync**: 仅当物体在屏幕视口内,且逻辑坐标发生位移时,才去同步 `Node2D.position`。
* **Homing 批量查询优化**:若 `homing_force > 0` 的子弹数量为 N,每颗独立调用 `SpatialGrid.find_nearest` 将产生 N 次散列查询(N=500 时每帧 500 次)。**解决方案**:在 `_physics_process` 开头建立敌人位置快照,所有 homing 子弹共用
```gdscript
# ⚠️ _enemy_pos_snapshot 必须声明为 BulletManager 类成员(预分配复用),
# 禁止在 _physics_process 中用 var 声明(每帧分配新 PackedVector2Array,产生 GC 压力)。
# 类成员声明(BulletManager 顶部):
# var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array()
#
# BulletManager._physics_process 开头,O(M)M = 存活敌人数量
EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot)
# fill_pos_snapshot() 将所有存活敌人 [x, y] 写入传入的 PackedVector2Array(复用已分配内存,不触发 GC)
# 对比旧写法:var _enemy_pos_cache = EnemyManager.get_pos_snapshot() 每帧 new 一个新数组
* **Homing 受限角速度制导(2026-07-30 实现,权威 spec`docs_dev/specs/2026-07-30-bullet-homing-design.md`**
> **⚠️ 方案变更记录**:本节原方案为「每帧建立敌人位置快照 `_enemy_pos_snapshot`,所有 homing 子弹共用后线性扫描最近敌人」(对应已退役的 `P6-N2` / `P6-N25`)。实际实现改为**按 entity_id 锁定目标** —— 快照按槽位存位置、**不含 entity_id**,支撑不了锁定语义;且锁定后目标有效期内**零查询**,比每帧 O(M) 重扫更省。快照与 `EnemyManager.fill_pos_snapshot` 已一并删除。
# homing 计算:从快照线性扫描,比散列查询更 cache-friendly
for idx in _homing_indices: # _homing_indices: PackedInt32Array,本帧 homing 子弹列表
var bx: float = _data[idx * BULLET_STRIDE]; var by: float = _data[idx * BULLET_STRIDE + 1]
var nearest := _find_nearest_pos(bx, by, _enemy_pos_snapshot) # O(M) 线性扫描快照
# 计算转向力并更新 vx/vy…
目标策略为**发射后锁定**:`_push_projectile` 只写冷数据、不解析目标(`homing_target_id = -1`),首帧在 `_gd_integrate` 惰性获取;此后**仅在目标失效时**(`EnemyManager.get_pos_by_id` 返回哨兵 `(-9999,-9999)`,即目标死亡)才重选。稳态下一颗子弹一生只查 1–2 次。
```gdscript
# 冷数据字段(_bullet_contexts[bullet_idx]):
# homing_strength : float —— 最大转向角速度,单位 弧度/秒(3.0 ≈ 350 速度下转弯半径 117px)
# homing_range : float —— 选目标搜索半径,默认 400.0
# homing_target_id: int —— 锁定的 entity_id-1 = 未锁定/待重选
# homing_retry_at : int —— 重选失败后的退避到期时刻(ms 墙钟),成功即 erase
# bullet_manager._gd_integrate 循环体顶部,位置积分【之前】调用,使子弹当帧即沿新方向前进。
# 判别提到调用方:绝大多数冷数据子弹(纯 pierce/bounce/状态/荷载)不归航,
# 避免为它们付一次完整函数调用。
if has_cold and _bullet_contexts.has(i) and _bullet_contexts[i].has("homing_strength"):
_apply_homing(i, base, delta)
func _apply_homing(bullet_idx: int, base: int, delta: float) -> void:
var cold: Dictionary = _bullet_contexts[bullet_idx]
var strength: float = float(cold.get("homing_strength", 0.0))
if strength <= 0.0: return
var pos: Vector2 = Vector2(_data[base + 0], _data[base + 1])
var tid: int = int(cold.get("homing_target_id", -1))
var tpos := Vector2(-9999.0, -9999.0)
if tid >= 0:
tpos = EnemyManager.get_pos_by_id(tid)
if tpos == Vector2(-9999.0, -9999.0): # 目标失效或首帧:重选
if EnemyManager.get_active_count() == 0:
return # 空场守卫:挡掉清场瞬间全体子弹集体查询
var now: int = Time.get_ticks_msec()
if now < int(cold.get("homing_retry_at", 0)):
return # 退避中:本帧直行,零查询
# 复用 bounce 期引入的 _find_nearest_unvisited,天然滤除哨兵与 visited_targets
tid = _find_nearest_unvisited(pos, float(cold.get("homing_range", 400.0)),
cold.get("visited_targets", []))
cold["homing_target_id"] = tid
_bullet_contexts[bullet_idx] = cold
if tid < 0:
# 抖动「到期时刻」而非共用固定退避(见下方 ⚠️),落在 [200, 400) ms
var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count)
cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS
return
cold.erase("homing_retry_at") # 重选成功:恢复零查询稳态
tpos = EnemyManager.get_pos_by_id(tid)
if tpos == Vector2(-9999.0, -9999.0): return
var vel := Vector2(_data[base + 2], _data[base + 3])
var diff: float = wrapf((tpos - pos).angle() - vel.angle(), -PI, PI) # 最短转向方向
var nv: Vector2 = vel.rotated(clampf(diff, -strength * delta, strength * delta))
_data[base + 2] = nv.x # rotated() 保持速率不变,只改方向
_data[base + 3] = nv.y
```
M=1000 敌人、N=500 homing 子弹时,总计 500K 次向量运算,C# 热路径约 0.2ms/帧,可接受。
三个数值要点:`wrapf(..., -PI, PI)` 保证走最短转向(否则追一个偏 179° 的目标会绕远路);`clampf` 到 `strength * delta` 即最大角速度,落实「中度制导」(追不上贴脸急转的目标);`rotated()` **保持速率不变**,与 bounce 重定向用 `spd` 保速率一致。
> **⚠️ 失败退避必须带抖动**(spec §5.4,本次最值得记住的一条):重选**失败**时若不留记录,该子弹余生每帧都会重跑 `query_circle(r=400)`(半径 400 覆盖约 196 格 + 一次 `PackedInt32Array` 分配 ≈ 一百多次碰撞查询)。但**朴素固定退避会把子弹锁进同相、而非打散** —— 同帧失败的子弹拿到同一个 `now`,一个退避周期后又整齐地一起重试,尖峰每 12 帧永久复发。故抖动到期时刻,用「`bullet_idx` 在活跃弹数中的占比」铺满恰好一个退避周期(`HOMING_RETRY_MS` 一值两用:既是基础退避时长也是抖动窗口宽度)。实测数据见 spec §5.2 / §5.5,此处不复述以免两套数字分叉。
> **与 bounce 协同**:bounce 在命中瞬间硬重定向到「最近未访问敌」,若不协调,下一帧 homing 就会覆盖它、使 bounce 失效。语义定为**bounce 负责选目标、homing 负责追上去**:bounce 重定向块内改写 `cold["homing_target_id"] = next_id` 并 `cold.erase("homing_retry_at")`(新目标有效,须撤销过期退避)。
* **EnemyManager 对外查询接口**:以下函数供 BulletManager、DropManager、BossManager、AudioManager 调用,均为 GDScript 侧接口(轻量读操作,非热路径):
```gdscript
# enemy_manager.gd — 对外查询接口(热路径内不调用,仅事件驱动 / 轮询路径使用)
func fill_pos_snapshot(out: PackedVector2Array) -> void:
# BulletManager homing 共用快照;复用传入数组内存(P6-N25)
out.resize(_active_count)
for i in _active_count:
out[i] = Vector2(_data[i * ENEMY_STRIDE], _data[i * ENEMY_STRIDE + 1])
func get_pos_by_id(entity_id: int) -> Vector2:
# BulletManager 碰撞检测与 homing 转向查询目标位置;
# 未命中(已死亡 / 不存在)返回哨兵 Vector2(-9999, -9999)
# 此约定即 homing「锁定目标已失效」的判据,调用方须显式比对哨兵。
var idx: int = _entity_index_map.get(entity_id, -1)
if idx < 0: return Vector2(-9999.0, -9999.0)
return Vector2(_data[idx * ENEMY_STRIDE], _data[idx * ENEMY_STRIDE + 1])
func get_last_position(entity_id: int) -> Vector2:
# DropManager 在 ENEMY_KILLED 事件中查询死亡位置(EnemyManagerCs 死亡时缓存到字典)
@@ -670,8 +711,8 @@ func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool:
return _visible_count # EnemyManagerCs 的 LOD 循环维护此计数,O(1) 读取
func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
# BulletManager.get_nearest_enemy_pos() 调用;线性扫描 _enemy_pos_snapshotO(M)
# 与 homing 子弹同一快照,无额外查询开销;仅在发射时调用,非每帧热路径
# BulletManager.get_nearest_enemy_pos() 调用;直接线性扫描 SoA _dataO(M)
# 仅在发射时调用,非每帧热路径homing 走 get_pos_by_id 锁定目标,不经此函数)
var best_pos := Vector2.ZERO
var best_dist := max_dist * max_dist # 比较平方距离,避免 sqrt
for i in range(_enemy_count):
@@ -698,7 +739,7 @@ func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool:
* **自动瞄准目标选取算法**:法杖自动开火时,发射方向需要选取"目标敌人"。目标选取逻辑集中在 `PlayerManager.get_aim_target()` 中,优先级如下:
1. **手柄/鼠标显式瞄准**:若存在显式输入方向(`aim_vector.length() > 0.3`),直接使用该方向,不进行目标锁定。
2. **最近敌人(默认)**: `_enemy_pos_snapshot` 线性扫描,取欧氏距离最小的存活敌人位置,作为开火方向。时间复杂度 O(M),与 Homing 快照共用,无额外查询
2. **最近敌人(默认)**:委托 `EnemyManager.get_nearest_pos()` 直接线性扫描敌人 SoA `_data`,取欧氏距离最小的存活敌人位置,作为开火方向。时间复杂度 O(M);仅在发射时调用,非每帧热路径
3. **自定义优先级扩展(P2)**:商店购买"目标优先级"被动时(如"优先最低 HP"、"优先最近"),`PlayerManager.aim_priority` 属性切换选取算法,但当前 P0/P1 阶段固定为最近敌人。
```gdscript
# player_manager.gd
@@ -790,7 +831,7 @@ func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool:
| 系统 | 语言 | 预算上限 | S0 实测值 | 说明 |
| :--- | :--- | :--- | :--- | :--- |
| `BulletManagerCs._PhysicsProcess` | C# | 2.0ms | **0.37ms** | 2000 子弹 GDScript SoA 积分(P-S0-01C# 热路径 S1 填充后预计更低) |
| `EnemyManagerCs._PhysicsProcess` | C# | 2.0ms | **0.23ms** | 1000 敌人直线追踪 + fill_pos_snapshotP-S0-02Boid C# S1 填充) |
| `EnemyManagerCs._PhysicsProcess` | C# | 2.0ms | **0.23ms** | 1000 敌人直线追踪 + fill_pos_snapshotP-S0-02Boid C# S1 填充)<br>⚠️ 该实测取于 S0,其中 `fill_pos_snapshot` 已于 2026-07-30 随归航实现删除,当前循环不含此项 |
| `SpatialGridCs` 重建 + `query_circle` | C# | 1.5ms | **< 0.001ms** | GDScript stub(返回空数组);C# SpatialGridCs 实例化后 S1 回填实测 |
| `SpellEvaluatorCs.execute_compiled` | C# | 1.0ms | — | 高频施法内层 while 循环(S1 起填入)|
| `StatusManagerCs._PhysicsProcess` | C# | 1.5ms | — | 200+ DoT 逐 tickS4 验收后填入,P-S4-02;与 implementation_plan §2.4 统一)|
@@ -999,7 +1040,7 @@ WaveManager 不直接操作 EnemyManager 的 SoA 数据,而是调用高层接
func spawn(enemy_id: String, position: Vector2) -> int: # 返回 entity_id
func reset() -> void # 清空 SoA(由 GameCycleManager 调用)
func get_alive_count() -> int
func fill_pos_snapshot(out: PackedVector2Array) -> void # BulletManager homing 用
func get_pos_by_id(entity_id: int) -> Vector2 # BulletManager 碰撞/homing 用;未命中返回哨兵 (-9999,-9999)
```
---
@@ -2507,8 +2548,10 @@ func get_count(owner_id: int = -1) -> int:
return _minions.filter(func(m): return m.owner_id == owner_id).size()
func fill_pos_snapshot(out: PackedFloat32Array, out_ids: PackedInt32Array) -> int:
# BulletManager homing 查询友方召唤物(阵营标志:faction=1
# 返回写入数量;out 格式: [x0, y0, x1, y1, ...]
# ⚠️ 未实现(设计提案)。原注写「BulletManager homing 查询友方召唤物」,但 2026-07-30 落地的
# 归航只锁定敌人(经 _find_nearest_unvisited + SpatialGrid,不消费任何位置快照),
# 敌方/友方召唤物寻的尚未立项。此处保留仅为 MinionManager 自身的 SoA 导出提案。
# 返回写入数量;out 格式: [x0, y0, x1, y1, ...]out_ids 同步给出 entity_id
var n := 0
for m in _minions:
out[n * 2] = m.pos.x
@@ -2710,7 +2753,7 @@ func get_active_count() -> int: return _active_count
func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
# PlayerManager.get_aim_direction() 调用(自动瞄准默认模式)
# 委托 EnemyManager.fill_pos_snapshot 后线性扫描;非每帧热路径(仅发射时调用)
# 委托 EnemyManager.get_nearest_pos 直接线性扫描敌人 SoA;非每帧热路径(仅发射时调用)
return EnemyManager.get_nearest_pos(origin, max_dist)
func reset() -> void: