diff --git a/docs/README.md b/docs/README.md index 54d67a3..4a08ef1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,7 +77,7 @@ docs_dev/ 开发过程 / 归档文档(工程根目录,见下 | 规范 ID | 位置 | 内容摘要 | | :--- | :--- | :--- | -| P6-N2 | architecture_design.md §4.2 | Homing 弹共用敌人位置快照 | +| P6-N2 | architecture_design.md §4.2 | ~~Homing 弹共用敌人位置快照~~ ⚠️ **已退役(2026-07-30)**:归航改为**按 entity_id 锁定目标**,目标有效时零查询、仅失效时重选,不再需要共用快照。见 spec [`2026-07-30-bullet-homing-design.md`](../docs_dev/specs/2026-07-30-bullet-homing-design.md) §2.4 / §2.7 | | P6-N3 | architecture_design.md §4.1 | SpellContext 池大小 = 32 | | P6-N5 | implementation_plan.md §2.2 | CompiledDeck 多项式滚动哈希(顺序敏感,种子含 grid_cols,防换位误判) | | P6-N6 | implementation_plan.md §2.1 | EventID 12 MINION_EXPIRED | @@ -96,7 +96,7 @@ docs_dev/ 开发过程 / 归档文档(工程根目录,见下 | P6-N22 | numerical_design.md §1.2 | XP 可行性验证:Level 20 为 20 波通关上限,Level 21+ 服务 Endless | | P6-N23 | numerical_design.md §1.2 | 商店刷新费用公式:20 + (本波刷新次数 × 10)G,波次结束重置 | | P6-N24 | architecture_design.md §4.2 | ProjectileDef(生成时模板)vs _bullet_contexts(运行时可变冷状态)职责分离 | -| P6-N25 | architecture_design.md §4.2 | _enemy_pos_snapshot 必须声明为类成员(预分配复用,禁止每帧 var 分配) | +| P6-N25 | architecture_design.md §4.2 | ~~_enemy_pos_snapshot 必须声明为类成员(预分配复用,禁止每帧 var 分配)~~ ⚠️ **已退役(2026-07-30)**:约束对象 `_enemy_pos_snapshot` 已删除 —— 归航改用**按 entity_id 锁定目标**,而该快照按槽位存位置、不含 entity_id,支撑不了锁定语义。见 spec [`2026-07-30-bullet-homing-design.md`](../docs_dev/specs/2026-07-30-bullet-homing-design.md) §2.7 | | P6-N26 | architecture_design.md §8 ADR-R5-N1 | ZoneManager ZONE_STRIDE=8(含 tick_interval+tick_accum),spawn_zone 需 tick_interval 参数 | | P6-N27 | implementation_plan.md §2.3.C | _visible_flags 类成员 PackedByteArray;has_point() 赋值须显式 int() 转换 | | P6-N28 | implementation_plan.md §2.1 | DamageContextPool Autoload 定义(acquire/get_context/release) | diff --git a/docs/technical/architecture_design.md b/docs/technical/architecture_design.md index 468c4bb..b64aa21 100644 --- a/docs/technical/architecture_design.md +++ b/docs/technical/architecture_design.md @@ -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_snapshot,O(M) - # 与 homing 子弹同一快照,无额外查询开销;仅在发射时调用,非每帧热路径 + # BulletManager.get_nearest_enemy_pos() 调用;直接线性扫描 SoA _data,O(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-01;C# 热路径 S1 填充后预计更低) | -| `EnemyManagerCs._PhysicsProcess` | C# | 2.0ms | **0.23ms** | 1000 敌人直线追踪 + fill_pos_snapshot(P-S0-02;Boid C# S1 填充) | +| `EnemyManagerCs._PhysicsProcess` | C# | 2.0ms | **0.23ms** | 1000 敌人直线追踪 + fill_pos_snapshot(P-S0-02;Boid C# S1 填充)
⚠️ 该实测取于 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 逐 tick(S4 验收后填入,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: