# 模块化战术土豆 (Modular Tactical Potato) - 架构设计文档

## 1. 概述 (Overview)

本项目旨在开发一款基于 **Godot 4.x** 的 Roguelite 动作射击游戏。
核心体验结合了 **Brotato (土豆兄弟)** 的快节奏割草体验与 **Noita** 的深度法术构建系统。
架构设计的首要目标是 **高性能**（支持同屏大量单位与弹幕）与 **极高的可扩展性**（特别是武器系统的模块化）。

### 1.1 设计目标
1.  **超模块化武器系统 (Hyper-Modular Weapon System)**：超越 Noita 的线性构建，引入更灵活的管道流与事件钩子机制。
2.  **高性能战斗引擎**：支持同屏 500+ 敌人，2000+ 弹幕，60FPS 稳定运行。
3.  **数据驱动 (Data-Driven)**：所有游戏内容（法术、属性、波次）完全配表化/JSON化。

---

## 2. 系统分层架构 (Layered Architecture)

采用能够严格分离数据与表现的架构模式。虽然 Godot 是节点/组件式的，但在核心战斗层我们将采用 **Manager + Data** 的方式来规避逐节点更新带来的开销。

```mermaid
graph TD
    Layer1[表现层 (Presentation Layer)] --> Layer2[逻辑层 (Domain/Logic Layer)]
    Layer2 --> Layer3[数据层 (Data Layer)]
    Layer2 --> Layer4[核心库 (Core Library)]

    subgraph Layer1
        ViewComponents[Godot Nodes (Sprite2D, AnimationPlayer)]
        UIManagers[UI System (Control/CanvasLayer)]
        Effects[VFXManager + GPUParticles2D Pool]
    end

    subgraph Layer2
        CombatMgr[Combat Manager (Main Loop)]
        SpellEvaluator[Spell Interpreter (The "CPU")]
        EnemyAI[Boid AI System]
        GameCycle[Wave & Shop Cycle]
        MinionMgr[MinionManager (友军召唤)]
        ZoneMgr[ZoneManager (地面效果)]
        StatusMgr[StatusManager (状态效果)]
    end

    subgraph Layer3
        ConfigMgr[JSON Config Loader]
        SaveSystem[Persistent Storage]
        Inventory[Player State & Inventory]
    end

    subgraph Layer4
        Pool[Object Pool System]
        SpatialHash[Spatial Hashing (Collision)]
        EventSystem[Global Event Bus]
        CoreFeatureTagConst[CoreFeatureTag (特性常量)]
    end
```

> **⚠️ 实现现状 (2026-07-20 审计)**：数据层实际为**纯 `data/*.json`**（7 个文件：spells / cores / enemies / waves / resonance / status_effects / balance），各 Manager 自行 `FileAccess` 直读；全项目**零 `.tres` 数据资源**（仅 `assets/ui/ui_theme.tres`），无 `resources/` 目录。图中 `ConfigMgr` autoload 虽已注册（`project.godot`），但**从未被任何调用方引用，是死代码**。文中后续所有 `res://resources/**/*.tres` 路径均为设计蓝图，与实际 `data/*.json` 布局不符。详见 [`docs_dev/doc_code_audit_2026-07-20.md`](../../docs_dev/doc_code_audit_2026-07-20.md)。

---

## 3. 核心子系统：超模块化法术系统 (HMWS)

这是本项目的技术核心。我们将 Noita 的“魔杖”概念抽象为 **“法术管道 (Spell Pipeline)”**。

### 3.1 核心概念差异

| 特性 | Noita 原版 | HMWS (本项目) | 改进目的 |
| :--- | :--- | :--- | :--- |
| **执行流** | 线性 (Deck -> Hand -> Discard) | **树状/图状结构 + 事件驱动** | 支持“子母弹”、“条件触发”、“击中后分裂逻辑”的无限嵌套。 |
| **属性计算** | 累加式 (Cast Delay += 0.1) | **管线式 (Pipeline)** | 允许中间件对属性进行乘算、覆写或逻辑重定向。 |
| **载体** | 法杖 (Wand) | **构建核心 (Core)** | 核心决定了插槽拓扑结构（不仅仅是线性数组，可能是矩阵或特定触发槽）。 |

### 3.2 数据结构设计 (GDScript)

#### A. 基础单元 (SpellNode)
这是所有"部件"的基类。

> **⚠️ 本块与实现存在既有漂移（2026-07-30 部分订正）**：`pierce_add` / `bounce_add` / `homing_add` 三项已按 `scripts/domain/spell_system/cast_stats.gd` 实测订正（原写 `pierce_count` / `bounce_count` / `homing_force`）。**其余字段仍未核对**：实现里没有 `projectile_speed` / `spread_angle` / `projectile_size` / `range_mult`，实际为 `spread_count` / `speed_mult` / `radius_mult` / `lifetime` / `multicast_count`。此漂移早于本次归航改动，未一并订正以免超出改动范围 —— **以 `cast_stats.gd` 为准**。

```gdscript
# cast_stats.gd - MODIFIER 节点的修改目标；通过 SpellContext.stats 访问
class_name CastStats
extends RefCounted

var damage_add: float = 0.0          # 累加伤害加成（所有 Dmg+ 修正器的总和）
var damage_mult: float = 1.0         # 乘算伤害系数（暴击、元素弱点等乘入）
var projectile_speed: float = 600.0  # 弹道飞行速度（像素/秒）
var spread_angle: float = 0.0        # 散射角度（度，0 = 直线）
var pierce_add: int = 0              # MODIFIER 累加的穿透次数（叠加到 ACTION 自带 pierce）
var bounce_add: int = 0              # MODIFIER 累加的弹跳次数（叠加到 ACTION 自带 bounce）
var homing_add: float = 0.0          # MODIFIER 累加的归航【最大转向角速度】，单位 弧度/秒
                                     # （叠加到 ACTION 自带 homing；语义见 §4.2 Homing 受限角速度制导）
var projectile_size: float = 1.0     # 弹道大小系数（影响碰撞半径与贴图缩放）
var range_mult: float = 1.0          # 射程乘算系数（弹道消失前飞行距离倍率）
var crit_chance: float = 0.0         # 本次施法暴击率追加量（0.0~1.0，叠加而非覆盖）

func reset() -> void:
    damage_add = 0.0; damage_mult = 1.0; projectile_speed = 600.0
    spread_angle = 0.0; pierce_add = 0; bounce_add = 0
    homing_add = 0.0; projectile_size = 1.0; range_mult = 1.0; crit_chance = 0.0

# spell_context.gd - 使用 RefCounted 避免 GC 压力
class_name SpellContext
extends RefCounted

# ⚠️ 注意：禁止持有 Node2D 引用——逻辑层与表现层必须解耦
# 施法者位置通过 EntityManager.get_position(caster_id) 实时查询
var caster_id: int = -1  # 施法者实体 ID
var target: Vector2      # 目标点
var stats: CastStats = CastStats.new()  # 必须在声明时初始化；reset() 调用 stats.reset() 清零字段，不重新 new
var payloads: Array[ProjectileDef] = []  # 待发射的弹头定义队列
var registers: PackedFloat32Array = PackedFloat32Array([0.0, 0.0, 0.0, 0.0])
                                   # R1, R2, R3, R4 — LOGIC_P2 功能专用
                                   # ⚠️ 必须声明时初始化为长度 4；PackedFloat32Array() 默认长度 0，
                                   # 直接访问 registers[0] 会触发 out-of-bounds 崩溃。
                                   # 生命周期：携带 CoreFeatureTag.PERSISTENT_MEMORY 的 Core 跨帧保留此值；
                                   # 非持久 Core 下，帧结束后由 SpellEvaluator 调用 registers.fill(0.0) 清零。
                                   # 与 CastState.registers 的区别：
                                   #   SpellContext.registers → 跨帧持久寄存器（属于 SpellContext 对象）
                                   #   CastState.registers    → 单次 execute() 内临时工作寄存器，每次重置
                                   # core_wand_design.md §6 将此字段称为 "memory_bank"（同一字段）
var current_payload: ProjectileDef  # 当前正在构建的弹头定义

# 池化归还时必须调用 reset()，防止脏数据污染下次施法
func reset() -> void:
    caster_id = -1
    target = Vector2.ZERO
    stats.reset()          # CastStats 内部清零
    payloads.clear()       # 清空弹头队列（不销毁元素，由 ProjectileDef 池负责回收）
    current_payload = null
    # ⚠️ registers 不在此处清零：
    # 持久 Core（PERSISTENT_MEMORY）由 SpellEvaluator 决定是否保留；
    # 非持久 Core 由 SpellEvaluator 在帧结束后调用 registers.fill(0.0)。

# spell_node.gd - 基类，子类重写 execute()
# 所有系统通过 SpellType 枚举访问节点类型，禁止在业务代码中直接写裸整数（0/1/2/3）。
enum SpellType {
    ACTION   = 0,   # 产生实际飞行物或即时效果（如 spark_bolt、nuke）
    MODIFIER = 1,   # 修改下一个 ACTION 的属性（如 damage_plus、homing）
    TRIGGER  = 2,   # 将后续法术打包为 SubPayload，在弹体命中时执行（子母弹核心）
    LOGIC    = 3,   # 条件跳转、循环、寄存器读写（IF_HP_LOW、LOOP 等）
    SCOPE_CLOSE = 4 # 虚节点，预编译时自动插入，标记 TRIGGER/LOGIC 作用域边界
                    # 不出现在 SpellRegistry 中，玩家不可购买
}

class_name SpellNode
extends RefCounted

var id: String
var type: SpellType = SpellType.ACTION  # 业务代码赋值时须使用 SpellType.XXX，禁止写裸整数
var element_tags: Array[String] = []   # 元素亲和性标签，供共鸣系统（§3.4.C Resonance）进行模式匹配
                                        # 内容示例：["tag:water"]、["tag:lightning"]、[]
                                        # resonance_recipes.json 中 "pattern" 字段的每个元素
                                        # 对应本字段中的一个字符串；预编译时 _check_resonance()
                                        # 遍历 deck.nodes，比对 element_tags 中是否包含目标 tag
                                        # 数据来源：SpellRegistry 从 res://resources/spells/*.tres 加载

var shape_icon: String = ""            # ADR-C1: 无障碍形状标识，与颜色配合供色盲玩家区分元素。
                                        # 取值约定（UIAtlas 中对应图标键）：
                                        #   "triangle"  → 火系 (Fire)
                                        #   "diamond"   → 冰系 (Ice)
                                        #   "circle"    → 水系 (Water)
                                        #   "square"    → 土系 (Earth)
                                        #   "star"      → 雷系 (Lightning)
                                        #   "cross"     → 暗系 (Dark)
                                        #   ""          → 无元素（纯伤害类）
                                        # 渲染规则：UI 显示元素标签时必须同时绘制颜色背景 + shape_icon；
                                        # VFXManager 在命中 VFX 上叠加 1.5× 字号的 shape_icon TextureRect。
                                        # 禁止仅用颜色区分元素（ADR-C1 强制要求）。

# 核心执行函数：修改 Context 或产生行为，子类必须重写
func execute(ctx: SpellContext, deck: SpellDeck) -> void:
    pass

# projectile_def.gd - ACTION 节点执行时填充，最终传给 BulletManager 批量生成弹体
# 通过对象池复用，不触发 GC；SpellContext.payloads 以 Array[ProjectileDef] 形式积累
class_name ProjectileDef
extends RefCounted

# 业务代码必须通过 DamageType.PHYSICAL 等常量引用，禁止直接写 0/1/2/3/4。
# 此枚举也是 BulletManager SoA 中 damage_type 字段、StatusManager DoT 分类的统一来源。
enum DamageType {
    PHYSICAL  = 0,   # 物理伤害：受护甲（Armor）平铺减免，不受元素抗性影响
    FIRE      = 1,   # 火焰伤害：受 attunement_fire 加成；可触发点燃/爆燃状态
    ICE       = 2,   # 冰霜伤害：受 attunement_ice 加成；可触发冻结/减速状态
    LIGHTNING = 3,   # 雷电伤害：受 attunement_lightning 加成；可触发麻痹/连锁导电
    POISON    = 4    # 毒素伤害：受 attunement_poison 加成；施加 DoT，叠层上限受精通影响
}
# ─────────────────────────────────────────────────────────────────────────────

# ── 伤害 ──────────────────────────────────────────────────
var base_damage: float = 5.0         # 计算前基础伤害值（已含 CastStats.damage_add 累积）
var damage_mult: float = 1.0         # 乘算系数（暴击/元素弱点已合入）
var damage_type: int = DamageType.PHYSICAL  # DamageType 枚举（见上方定义）

# ── 运动 ──────────────────────────────────────────────────
var speed: float = 600.0             # 初速度（像素/秒）
var direction: Vector2 = Vector2.RIGHT
var spread_angle_rad: float = 0.0    # 在 direction 基础上的随机偏转幅度
# 注：归航【不】经 ProjectileDef 传递。`projectile_def.gd` 无任何 homing 字段 ——
#     SpellEvaluator._push_projectile 直接把 homing_strength / homing_range /
#     homing_target_id 写进 BulletManager 的冷数据字典（详见 §4.2）。
#     原此处的 `homing_force`（「每帧转向力」）语义与实现均不存在，2026-07-30 删除。
var acceleration: float = 0.0        # 速度加速度（可为负，做减速球）

# ── 生命周期 ──────────────────────────────────────────────
var lifetime: float = 3.0            # 最大存活时间（秒）
var pierce_remaining: int = 0        # 剩余穿透次数（0 = 首次命中销毁）
var bounce_remaining: int = 0        # 剩余弹射次数

# ── 碰撞体积 ──────────────────────────────────────────────
var radius: float = 6.0              # 碰撞圆半径（像素）
var size_mult: float = 1.0           # 外观与碰撞同步缩放系数

# ── 触发器 ────────────────────────────────────────────────
var on_hit_payload_id: int = -1      # SubPayloadRegistry ID；-1 = 无 TRIGGER
var on_expire_payload_id: int = -1   # 到期/撞墙触发；-1 = 无
var on_kill_payload_id: int = -1     # 击杀触发；-1 = 无

# ── 归属 ──────────────────────────────────────────────────
var owner_id: int = -1               # 根源实体 ID（归击杀、吸血计算）
var source_tags: int = 0             # 位掩码: PRIMARY=1, TRIGGERED=2, SUMMON=4, REFLECTED=8
var spawn_position: Vector2          # 发射原点（填充时从 caster 位置获取）

# ── 触发系数 ───────────────────────────────────────────────
var proc_rate: float = 1.0           # Proc 系数（0.0~1.0）。高频攻击（加特林）应设置为 0.1~0.3，
                                     # 防止高攻速过度触发 on_hit 特效/技能。
                                     # 命中时实际触发概率 = SpellNode.proc_chance × proc_rate。
                                     # 默认 1.0（不削减），Tier 1 Action 通常无需修改。

func reset() -> void:
    base_damage = 5.0; damage_mult = 1.0; damage_type = DamageType.PHYSICAL
    speed = 600.0; direction = Vector2.RIGHT; spread_angle_rad = 0.0
    acceleration = 0.0; lifetime = 3.0
    pierce_remaining = 0; bounce_remaining = 0
    radius = 6.0; size_mult = 1.0
    on_hit_payload_id = -1; on_expire_payload_id = -1; on_kill_payload_id = -1
    owner_id = -1; source_tags = 0; proc_rate = 1.0
    spawn_position = Vector2.ZERO  # 必须重置，否则池化复用时携带上帧发射原点
```

#### B. 法术解析器 (The Evaluator)
为了高性能，解析器必须 **低 GC 压力 (Low-GC)**。在施法计算帧，尽量避免分配新对象。
*   使用预分配的 `SpellContext` 对象池（`Array` 作为栈管理空闲对象）。
*   使用 `Array`（作为栈）模拟递归，防止深层递归爆栈。

#### C. 高级特性：动态插槽与逻辑门
*   **Logic Spells (逻辑法术)**：引入 `IfHPBelow`, `OnKillAction`, `EveryNbShot` 等逻辑块，让玩家实现“如果血量低于30%，则发射吸血导弹”的构建。
*   **Variable Storage (变量存储)**：允许法术在法杖上写入/读取临时变量（例如：记录连击数）。

### 3.3 扩展性设计
所有法术行为通过 **Strategy Pattern (策略模式)** 实现。
新增一个法术只需：
1.  在 JSON（或 Godot `.tres` Resource）中定义 ID 和贴图路径。
2.  实现一个继承自 `SpellNode` 的 GDScript 类。
3.  在注册表中注册。

### 3.4 进阶构建机制 (Advanced Mechanics) - 玩法增强
为了超越“线性堆砌”的枯燥感，架构支持以下三种深度玩法机制。

> **MVP 功能边界说明**：
> - **P0（必须实现）**：4类 SpellNode（ACTION / MODIFIER / TRIGGER / LOGIC）+ LINEAR Core。
> - **P1（迭代加入）**：拓扑插槽系统（MATRIX/CIRCUIT Core）、共鸣系统。
> - **P2（可选高级内容）**：状态寄存器 + 条件跳转——此功能面向极硬核玩家，仅通过特殊 Core 解锁，**不在新手 UI 中暴露**。

#### A. 拓扑插槽系统 (Topology Slots)【P1】
核心（Core）不再仅仅是一个列表，它可以是一个 **2D 网格** 或 **电路板**。
*   **adjacency_bonus (邻接加成)**：某些插槽有物理连接。例如，将 [火元素] 放在 [高压槽] 旁边，会自动获得 +20% 范围。
*   **Circuit Logic (电路逻辑)**：法术流不再只是从左到右。核心板可以有分叉路口，玩家需要用 [分流器法术] 将能量流引导到不同的分支。
*   详见 [docs/design/core_wand_design.md](../design/core_wand_design.md)。

**拓扑适配层 (Topology Adapter)【A1 解决方案】**

`SpellEvaluator` 的 while 循环只能处理线性指令流。为此，在 `SpellEvaluator.compile_wand(core, raw_deck)` 预编译阶段引入 **拓扑扁平化 (Topology Flattening)**，将不同 Core 的空间拓扑提前转换为线性指令序列，运行时 while 循环无需感知拓扑：

> **⚠️ 实现现状 (2026-07-20 审计)**：真实签名与本节伪代码不同：
> - `compile_wand(core, raw_nodes: Array)` —— 第 2 参是 `Array` 而非 `SpellDeck`（`spell_evaluator.gd:36`）。
> - `execute_compiled(compiled, caster_id: int, spawn_pos: Vector2)` —— 执行期不传 `ctx`/`core`，`feature_tags` 从 `CompiledDeck` 编译期快照读取（`spell_evaluator.gd:321`）。
> 详见 [`docs_dev/doc_code_audit_2026-07-20.md`](../../docs_dev/doc_code_audit_2026-07-20.md)。

| Core 类型 | 扁平化策略 |
| :--- | :--- |
| **LINEAR** | 无需处理，直接输出原始 SpellNode 序列 |
| **MATRIX_2X4** | 扫描邻接槽对（slot[i] 与 slot[i + cols]），若满足 `adjacency_bonus` 条件，在对应 ACTION 前插入隐式 MODIFIER 节点；最终序列仍按行→列顺序线性排列 |
| **CIRCUIT** | 对有向图做 **拓扑排序**，每个分叉点展开为独立 SubPayload 并注册到 SubPayloadRegistry，分叉处插入 `LOGIC_FORK` 指令批量触发；主链保持线性 |

```gdscript
# spell_evaluator.gd（预编译入口 + 运行时配置）
# compile_wand：拓扑扁平化策略入口，仅在玩家关闭背包/装备 Core 时调用，非热路径。
var _max_ops: int = 40   # 每次施法的法术节点执行上限（= MAX_OPS_PER_CPU × cpu_limit）

func update_max_ops(cpu_limit: int) -> void:
    # UpgradeSystem.apply_choice() 在被动词条变更 cpu_limit 后调用
    # MAX_OPS_PER_CPU = 8（基准值，SpellEvaluator 常量）
    const MAX_OPS_PER_CPU: int = 8
    _max_ops = clamp(MAX_OPS_PER_CPU * cpu_limit, 8, 200)
    # ⚠️ 实现现状(2026-07-20)：SpellEvaluator 从不调用本函数、从不读 cpu_limit；
    #    实际硬编码 max_ops = MAX_OPS_PER_CPU * 5（代码常量为 40，本处写 8，文档内部亦不一致）。
    #    结果：升级 CPU 词条对施法预算无效——本设计更优，代码待修。见审计报告 D 节。

func compile_wand(core: CoreDefinition, raw_deck: SpellDeck) -> CompiledDeck:
    match core.topology:
        CoreDefinition.LINEAR:
            return CompiledDeck.new(raw_deck.nodes)
        CoreDefinition.MATRIX_2X4:
            return _flatten_matrix(raw_deck, core)
        CoreDefinition.CIRCUIT:
            return _flatten_circuit(raw_deck, core)
    return CompiledDeck.new([])  # 未知拓扑降级为空 Deck，防止 match 无 default 返回 null

func _flatten_matrix(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck:
    # 仅遍历 Row A（i < grid_cols）；Row B 槽位法术不作为独立执行节点，仅用于邻接加成注入。
    var out: Array[SpellNode] = []
    var row_a_count: int = core.grid_cols  # Row A 的槽数 = grid_cols（如 2×4 矩阵中 = 4）
    for i in row_a_count:
        var adj_idx: int = i + core.grid_cols  # Row B 中与 slot[i] 垂直对齐的槽索引
        # 空槽守卫：deck.nodes[i] 可能为 null（槽为空）
        var row_a_node: SpellNode = deck.nodes[i] if i < deck.nodes.size() else null
        var row_b_node: SpellNode = deck.nodes[adj_idx] if adj_idx < deck.nodes.size() else null
        if row_a_node == null:
            continue  # 空槽：Row A 无法触发，跳过（Row B 对应槽的邻接加成也无效）
        if row_b_node != null and _has_adjacency_bonus(row_a_node, row_b_node):
            # 注入隐式 MODIFIER 节点，效果见 core_wand_design.md §2.2 邻接加成效果表
            out.append(_make_adjacency_mod(row_a_node, row_b_node))
        out.append(row_a_node)  # 仅追加 Row A 节点到执行序列
        # Row B 节点（row_b_node）不追加 → 不独立执行，仅作为邻接加成来源
    return CompiledDeck.new(out)

# 对 CIRCUIT 拓扑的有向图做 Kahn 算法拓扑排序，主链保持线性；
# 分叉节点展开为独立 SubPayload 并注册到 SubPayloadRegistry。
# edges 读自 core.edges（顶层字段）；CoreDefinition 须声明 @export var edges: Array = []（见 core_wand_design.md §1）
# 性能说明：仅在玩家关闭背包时触发（非 _physics_process 热路径），slot_count ≤ 16，O(V+E) < 0.1ms。
func _flatten_circuit(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck:
    # 1. 从 core.edges 读取顶层有向边列表；若为空，降级为 LINEAR 处理（见 core_wand_design.md §2.3）
    var edges: Array = core.edges
    if edges.is_empty():
        push_warning("_flatten_circuit: core.edges 为空，降级为 LINEAR 处理（Core=%s）" % core.id)
        return CompiledDeck.new(deck.nodes)

    # 2. Kahn 算法：BFS 拓扑排序（保证无环时的确定性线性化顺序）
    var in_degree: Array[int] = []
    var adj: Array         = []     # adj[i] = Array[int] 出边目标
    in_degree.resize(core.slot_count)
    adj.resize(core.slot_count)
    for i in core.slot_count:
        in_degree[i] = 0
        adj[i] = []
    for e in edges:
        adj[e["from"]].append(e["to"])
        in_degree[e["to"]] += 1

    # ⚠️ Kahn 循环结束后 in_degree 全归零；_collect_branch_path() 需要原始入度来判断汇聚点
    # （orig_in_degree[nxt] > 1 表示多入边）。在 Kahn 循环前保存副本供分支收集函数使用。
    var orig_in_degree: Array[int] = in_degree.duplicate()  # Kahn 前原始入度快照

    var queue: Array[int] = []
    for i in core.slot_count:
        if in_degree[i] == 0:
            queue.append(i)

    var topo_order: Array[int] = []
    while not queue.is_empty():
        var cur: int = queue.pop_front()
        topo_order.append(cur)
        for nxt: int in adj[cur]:
            in_degree[nxt] -= 1        # 修改工作副本（orig_in_degree 保留原始值供汇聚点检测）
            if in_degree[nxt] == 0:
                queue.append(nxt)

    if topo_order.size() != core.slot_count:
        push_error("_flatten_circuit: 检测到环路，无法线性化！Core=%s" % core.id)
        return CompiledDeck.new([])  # 返回空 Deck，游戏不崩溃但该法杖无法施法

    # 3. 按拓扑顺序输出节点，出度 > 1 的槽注入 LOGIC_FORK 指令
    var out: Array[SpellNode] = []
    # in_branch_payload 记录已纳入某 SubPayload 的槽索引，防止主链循环将分支节点重复追加执行
    var in_branch_payload: Dictionary = {}
    for slot_idx in topo_order:
        # 跳过已被分支 SubPayload 收纳的节点，防止双重执行
        if in_branch_payload.has(slot_idx):
            continue
        var node: SpellNode = deck.nodes[slot_idx] if slot_idx < deck.nodes.size() else null
        if node == null:
            continue  # 空槽跳过
        if adj[slot_idx].size() > 1:
            # 分叉检测基于出度（出边数 > 1）；"splitter" tag 保留为 UI/编辑器标注用途，不参与执行判断

            # 分叉槽自身的法术节点先行追加（分叉前执行，通常为空但允许 MODIFIER/ACTION）
            out.append(node)

            # 为每条出边收集完整分支路径（含多节点分支，_collect_branch_path 沿单出边延伸直到叶/汇聚点）
            var branch_payload_ids: Array[int] = []
            for neighbor_idx in adj[slot_idx]:
                var branch_path: Array[SpellNode] = _collect_branch_path(
                    neighbor_idx, adj, orig_in_degree, deck, in_branch_payload)
                    # orig_in_degree：Kahn 前快照，确保汇聚点检测正确
                    # in_branch_payload：共享引用，函数内登记已访问槽，主链循环可跳过
                if not branch_path.is_empty():
                    branch_payload_ids.append(SubPayloadRegistry.register(branch_path))
            if not branch_payload_ids.is_empty():
                var fork_node: SpellNode = SpellNode.new()
                fork_node.type = SpellType.LOGIC
                fork_node.id = "LOGIC_FORK"
                fork_node.set_meta("fork_branch_ids", branch_payload_ids)
                out.append(fork_node)
        else:
            out.append(node)
    return CompiledDeck.new(out)

# 从 start_idx 沿单出边路径收集节点序列，直到：
#   叶节点（出度 0）、汇聚点（orig_in_degree > 1，属于主链或另一分支）、嵌套分叉（出度 > 1，递归处理）
# orig_in_degree：必须传 Kahn 前的原始入度副本，用于汇聚点判断
# in_branch_payload：共享字典，函数内每访问一个槽即写入，消除主链双重执行
func _collect_branch_path(start_idx: int, adj: Array, orig_in_degree: Array,
                          deck: SpellDeck,
                          in_branch_payload: Dictionary) -> Array[SpellNode]:
    var path: Array[SpellNode] = []
    var cur: int = start_idx
    var visited: Dictionary = {}
    while cur >= 0 and not visited.has(cur):
        visited[cur] = true
        in_branch_payload[cur] = true  # 登记此槽已纳入 SubPayload，主链循环将跳过
        if cur < deck.nodes.size() and deck.nodes[cur] != null:
            path.append(deck.nodes[cur])

        if adj[cur].size() > 1:
            # 嵌套分叉：为每条子出边递归收集完整路径并注册 SubPayload，
            # 然后将嵌套 LOGIC_FORK 节点插入当前 path。
            var nested_branch_ids: Array[int] = []
            for nxt_idx: int in adj[cur]:
                var nested_path: Array[SpellNode] = _collect_branch_path(
                    nxt_idx, adj, orig_in_degree, deck, in_branch_payload)
                if not nested_path.is_empty():
                    nested_branch_ids.append(SubPayloadRegistry.register(nested_path))
            if not nested_branch_ids.is_empty():
                var nested_fork: SpellNode = SpellNode.new()
                nested_fork.type = SpellType.LOGIC
                nested_fork.id = "LOGIC_FORK"
                nested_fork.set_meta("fork_branch_ids", nested_branch_ids)
                path.append(nested_fork)
            break  # 嵌套分叉后路径在各子 SubPayload 中独立延伸，本路径就此结束

        elif adj[cur].size() == 1:
            var nxt: int = adj[cur][0]
            if orig_in_degree[nxt] <= 1:  # 单入边，继续延伸
                cur = nxt
            else:
                break           # 汇聚点（多入边），停止；该节点由主链处理

        else:
            break               # 叶节点（出度 0）
    return path
```

> **E3 解决方案**：MATRIX 邻接加成通过 `_flatten_matrix` 在预编译阶段注入隐式 MODIFIER 节点，SpellEvaluator 运行时不感知 Core 拓扑，邻接触发与普通 MODIFIER 执行路径完全一致。

#### B. 状态寄存器与图灵完备 (State Registers & Turing Completeness)【P2 可选】
> ⚠️ **设计注意**：本功能仅通过特殊 Core（persistent_memory / circuit_fork）解锁，新手不会接触寄存器和跳转指令。

为了支持硬核玩家实现真正的“图灵完备”构建，架构预留对**状态存储**、**条件跳转** 和 **循环**的支持。

1.  **Registers (寄存器)**:
    *   在 `SpellContext` 中引入 `MemoryBank`，提供 4 个 Float 寄存器 (`R1`, `R2`, `R3`, `R4`)。
    *   寄存器在同一帧内所有法术间共享，甚至可以跨帧持久化（如果法杖配置了 Persistent Memory 核心）。

2.  **Instruction Set (指令集法术)**:
    *   **OPS**: `Add R1, 1` (加法), `Set R2, HP_Percent` (赋值).
    *   **JUMP**: `JumpIf R1 > 10, Label_A` (条件跳转到标签A).
    *   **LABEL**: `Label_A` (标记跳转点).

3.  **Recursion Control (递归控制)**:
    *   为了防止死循环 (`While(true)`), 解释器引入 `MaxOpLimit` (最大操作数限制，例如 100 ops/frame)。超过限制强制中断并在此帧失效。

4.  **实战应用**:
    *   **计数器**: 每射击 3 次，第 4 次发射强力火球。
    *   **动态模式切换**: 根据敌人距离 (`R1 = EnemyDistance`)，如果近则跳转到 [霰弹逻辑]，如果远则跳转到 [狙击逻辑]。

#### C. 共鸣系统 (Resonance System)【P1】
在**预编译阶段 (Pre-compile Phase)** 进行模式匹配。
*   如果检测到 [水] 和 [电] 法术在执行链中紧邻，架构自动插入一个隐藏的 [导电反应] 中间件。
*   这允许设计隐藏配方（Hidden Recipes），鼓励玩家探索特定组合。
*   **发现机制**：法术卡片上显示元素亲和性标签（如 ⚡ 雷、💧 水），引导玩家尝试组合，而非完全盲猜。
*   **缓存失效**：玩家在商店修改法术顺序时，自动触发重新预编译，确保共鸣结果与当前 Deck 始终一致。
*   **共鸣配方存储 (Recipe Schema)**：配方存储于 `res://resources/resonance_recipes.json`，结构如下：
    ```json
    [
      {
        "id": "plasma_storm",
        "pattern": ["tag:water", "tag:lightning"],
        "match": "adjacent",
        "result_spell_id": "plasma_storm",
        "consume_inputs": true,
        "vfx": "resonance_plasma"
      }
    ]
    ```
    - `match` 取值：`"adjacent"`（紧邻）/ `"anywhere_in_deck"`（Deck 内任意位置）。
    - `consume_inputs: true` 表示触发后移除原两张法术，节省插槽。
    - 新增配方只需编辑 JSON，无需修改 SpellEvaluator 代码。

**`_check_resonance()` 精确算法**：

"相邻"的定义：在预编译后的线性执行序列 `CompiledDeck.nodes` 中，两个法术节点的 **index 差 ≤ 2**（允许中间最多夹一个 MODIFIER 节点，因为 MODIFIER 不改变元素属性流向）。

```gdscript
# spell_evaluator.gd（在 compile_wand 完成拓扑扁平化之后调用）
func _check_resonance(compiled: CompiledDeck) -> CompiledDeck:
    var recipes: Array = _resonance_recipes  # 启动时从 JSON 加载，按 priority 排序（高优先级先匹配）
    var nodes: Array[SpellNode] = compiled.nodes.duplicate()
    var consumed: PackedByteArray = PackedByteArray()
    consumed.resize(nodes.size())  # 全 0 初始化

    # 先处理所有 adjacent 配方
    for recipe in recipes.filter(func(r): return r.get("match") == "adjacent"):
        var pattern: Array = recipe.get("pattern", [])  # 如 ["tag:water", "tag:lightning"]
        if pattern.size() < 2: continue
        for i in nodes.size():
            if consumed[i] != 0: continue
            if not _node_has_tag(nodes[i], pattern[0]): continue
            # 向右扫描 index i+1 至 i+2（跳过 MODIFIER），寻找 pattern[1]
            for j in range(i + 1, min(i + 3, nodes.size())):
                if consumed[j] != 0: continue
                if nodes[j].type == SpellType.MODIFIER: continue  # MODIFIER 不参与匹配，但不中断扫描
                if _node_has_tag(nodes[j], pattern[1]):
                    # 命中：在 i 位置注入共鸣结果节点（替换 nodes[i] 位置），标记双方为已消费
                    var result_node: SpellNode = SpellRegistry.get(recipe.get("result_spell_id", ""))
                    if result_node == null: break
                    nodes.insert(i, result_node)          # 在 i 位置插入共鸣结果
                    consumed.insert(i, 0)                  # 同步扩展 consumed 数组
                    if recipe.get("consume_inputs", false):
                        consumed[i + 1] = 1  # 原 pattern[0] 节点（现 i+1）标记为消费
                        consumed[j + 1] = 1  # 原 pattern[1] 节点（现 j+1）标记为消费
                    break                    # 每个 i 只匹配一次，防止多配方叠加在同一节点
                else:
                    break  # 遇到非 MODIFIER 非目标节点，中断内层扫描（不跨过 ACTION/TRIGGER）
    # 再处理所有 anywhere_in_deck 配方（O(N²)，P1 仅限稀有度 ≥ 3 的传说配方）
    for recipe in recipes.filter(func(r): return r.get("match") == "anywhere_in_deck"):
        ...  # 类似逻辑，但 i、j 不需要相邻约束

    return CompiledDeck.new(nodes, compiled.label_table, compiled.sub_payload_ids,
                            compiled.topology_type, compiled.checksum, consumed)

func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool:
    # tag_pattern 格式为 "tag:water"，匹配 node.element_tags 中含 "tag:water" 的节点
    if tag_pattern.begins_with("tag:"):
        return tag_pattern in node.element_tags
    return node.id == tag_pattern  # 直接 ID 匹配（精确配方，如 nuke+ignite）
```

> **⚠️ `consume_inputs` 运行时实现**：`CompiledDeck.nodes` 在预编译后为**只读**线性序列，
> 不可在运行时直接删除元素（会破坏共鸣检测缓存，影响其他帧的重编译判断）。
> `consume_inputs: true` 配方的运行时"移除"通过 **消费掩码（Consumed Mask）** 实现：
> ```gdscript
> # spell_deck.gd（运行时执行游标）
> # _consumed: PackedByteArray，长度 = nodes.size()，初始全 0
> # 预编译阶段在共鸣注入时，将被消费的槽位 index 写入 _consumed_indices 列表。
> # 运行时 pop() 跳过 _consumed[cursor] != 0 的槽位：
> func pop() -> SpellNode:
>     while _cursor < _nodes.size() and _consumed[_cursor] != 0:
>         _cursor += 1   # 跳过已消费槽位
>     if _cursor >= _nodes.size():
>         return null
>     var node := _nodes[_cursor]
>     _cursor += 1
>     return node
> ```
> **消费掩码初始化**：`SpellEvaluator.compile_wand()` 完成共鸣注入后，对每个 `consume_inputs: true` 配方，
> 将参与配方的两个原始节点的 index 写入 `_consumed`（值为 1），同时在对应位置插入共鸣结果节点。
> `_consumed` 是 `SpellDeck`（运行时对象）的字段，`CompiledDeck`（只读预编译缓存）不含此字段，
> 每次从 CompiledDeck 构造 SpellDeck 时重新初始化（避免消费状态跨轮次残留）。

> **R4-C1 规范（触发范围权威定义）**：
> - `"adjacent"`：**标准触发范围**，绝大多数配方使用此选项（如"水+雷=等离子风暴"）。
>   `game_design.md §3.3.C` 所述"执行链中直接相邻"即对应此选项。
> - `"anywhere_in_deck"`：**跨距触发**，仅用于极少数传说级全局共鸣配方，
>   预编译时对整个 CompiledDeck 做 O(N²) 全局扫描，且**自动标记为稀有以上配方**。
>   新增此类配方时需在 JSON 中追加 `"rarity": 3`（传说）以防止轻易触发。
> - 两者不冲突，同一配方文件中可同时存在不同 `match` 值的条目，SpellEvaluator 在预编译阶段
>   先处理所有 `adjacent` 配方，再统一处理 `anywhere_in_deck` 配方。

---

## 4. 高性能战斗架构 (High-Performance Combat Architecture)

为了实现“同屏 2000+ 弹幕”和“复杂逻辑构建”的双重目标，本架构采用 **Data-Oriented (面向数据)** 与 **Hybrid-ECS** 相结合的策略，最大化 CPU 缓存命中率并消除 GC 压力。

### 4.1 核心原则：低 GC (Low-GC Principle)
在核心战斗循环 (Game Loop) 中，尽量避免频繁实例化新对象。
*   **Context Pooling**: `SpellContext` 等高频对象在关卡加载时预分配并放入 `Array` 池，使用时复用，用完调用 `reset()` 方法归还。
    *   **预分配大小**：`SPELL_CONTEXT_POOL_SIZE = 32`。依据：单帧最坏情况 ≈ MAX_BULLETS(2000) × 平均 proc_rate(0.05) × 命中概率(0.3) ≈ 30 次 execute_sub 并发触发，32 个实例可覆盖此场景且无 GC 分配。若 `pool.pop_back()` 返回 null（池耗尽），额外实例化 1 个并发出 `push_warning("SpellContextPool: exhausted")`，不崩溃。**⚠️ 溢出实例归还规范**：溢出实例使用完毕后须调用 `pool.push_back(ctx)` 归还；若归还时 `pool.size() >= SPELL_CONTEXT_POOL_SIZE`，直接丢弃（GC 处理权交还 Godot），不超量囤积。禁止遗弃，避免 Endless 模式长局游离实例积累导致 GC 压力持续上升。
*   **Static Temporaries**: GDScript 可在模块顶层声明静态变量 (`static var _tmp_vec2: Vector2`)，避免向量计算产生中间对象。
*   **PackedFloat32Array (SoA)**: 子弹的热数据（位置、速度）存储在 `PackedFloat32Array` 中，保证内存连续性，提升缓存命中率。

### 4.2 实体管理：ECS-Lite
虽然 Godot 是基于节点/组件的，但在海量单位管理上，我们将剥离节点的逐帧逻辑。
*   **Manager-Based Logic**: 子弹和敌人的 `Node2D` 节点**不运行自身的 `_process`**，逻辑全部由中央 Manager（Autoload 单例）统一驱动。
    *   也就是：`Node2D` 仅仅作为渲染容器，负责同步 `position` 和播放动画。
*   **Centralized Loop (中央循环)**:
    *   `BulletManager` 维护一个紧凑的 `PackedFloat32Array`（SoA 布局）。
        每颗子弹占用 **BULLET_STRIDE = 12** 个 float，在数组中偏移量 = `bullet_id × BULLET_STRIDE`：

        | 偏移 | 字段 | 说明 |
        | :--- | :--- | :--- |
        | +0 | `x` | 世界坐标 X（像素） |
        | +1 | `y` | 世界坐标 Y（像素） |
        | +2 | `vx` | 速度 X（像素/秒） |
        | +3 | `vy` | 速度 Y（像素/秒） |
        | +4 | `lifetime` | 剩余存活时间（秒），归零时销毁 |
        | +5 | `radius` | 碰撞圆半径（像素） |
        | +6 | `base_damage` | 命中基础伤害值 |
        | +7 | `damage_mult` | 伤害乘算系数 |
        | +8 | `damage_type` | DamageType 整数（DamageType 枚举，0=PHYSICAL…4=POISON） |
        | +9 | `owner_id` | 根源实体 ID（float 存 int，精度足够 24-bit ID） |
        | +10 | `source_tags` | 位掩码（PRIMARY/TRIGGERED/SUMMON/REFLECTED） |
        | +11 | `acceleration` | 速度加速度（像素/秒²，0=匀速） |

        > **冷数据（非热路径）** 存储于 `_bullet_contexts: Dictionary`（key=bullet_id），包含：
        > `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 热数组空间，仅在命中/特殊弹道帧查询。
        >
        > **职责分离**：`ProjectileDef` 是**生成时配置模板**，在 `SpellEvaluator.execute` 阶段由 ACTION 节点填充；
        > `_bullet_contexts[bullet_id]` 是**运行时可变冷状态**，由 `BulletManager.spawn(def)` 从 `ProjectileDef` 拷贝创建。
        > 两者字段名相同但生命周期不同：`ProjectileDef` 可安全复用（对象池 reset）；
        > `_bullet_contexts[id]` 随子弹销毁时一同移除（`_bullet_contexts.erase(bullet_id)`）。
    *   在 `_physics_process(delta)` 中，直接遍历 Array 进行物理积分，速度比遍历 Node Tree 快一个数量级。
    *   **Dirty Sync**: 仅当物体在屏幕视口内，且逻辑坐标发生位移时，才去同步 `Node2D.position`。
    *   **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` 已一并删除。

        目标策略为**发射后锁定**：`_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 —— 最大转向角速度，单位 弧度/秒
        #                              （单层词条发版值 1.5 ≈ 350 速度下转弯半径 233px）
        #   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
        ```

        三个数值要点：`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 重定向块内：
        > ```gdscript
        > if cold.has("homing_strength"):        # 前置守卫：纯 bounce 弹不写 homing 键
        >     cold["homing_target_id"] = next_id
        >     cold.erase("homing_retry_at")      # 新目标有效，须撤销过期退避
        > ```
        > `has("homing_strength")` 这个守卫不可省 —— 没有它，纯 bounce 子弹会被塞进一个永不被消费的 `homing_target_id`；而 `erase` 不可省是因为退避键若残留，子弹会对着 bounce 刚给的有效目标拒绝转向（该缺陷在实现期评审中实测复现并修复）。

    *   **EnemyManager 对外查询接口**：以下函数供 BulletManager、DropManager、BossManager、AudioManager 调用，均为 GDScript 侧接口（轻量读操作，非热路径）：

        ```gdscript
        # enemy_manager.gd — 对外查询接口（热路径内不调用，仅事件驱动 / 轮询路径使用）

        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 死亡时缓存到字典）
            return _death_position_cache.get(entity_id, Vector2.ZERO)

        func get_type(entity_id: int) -> String:
            # DropManager / BossManager 查询敌人类型 ID（对应掉落表 key / boss_config key）
            return _entity_type_map.get(entity_id, "")  # { entity_id: String }，spawn 时写入

        func get_hp_percent(entity_id: int) -> float:
            # BossManager 多阶段 HP 切换使用；entity_id 存在则返回 [0.0, 1.0]，否则返回 0.0
            var idx: int = _entity_index_map.get(entity_id, -1)
            if idx < 0: return 0.0
            var hp     := _data[idx * ENEMY_STRIDE + 4]   # slot +4: hp（权威来源 implementation_plan §2.3.C）
            var hp_max := _data[idx * ENEMY_STRIDE + 5]   # slot +5: hp_max
            return hp / max(hp_max, 0.001)

        func get_visible_count() -> int:
            # AudioManager 动态音乐层轮询；返回当前在 Camera AABB 内的存活敌人数
            return _visible_count  # EnemyManagerCs 的 LOD 循环维护此计数，O(1) 读取

        func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
            # BulletManager.get_nearest_enemy_pos() 调用；直接线性扫描 SoA _data，O(M)
            # 仅在发射时调用，非每帧热路径（homing 走 get_pos_by_id 锁定目标，不经此函数）
            var best_pos  := origin              # 无敌人时返回 origin（非 Vector2.ZERO）
            var best_dist := max_dist * max_dist  # 比较平方距离，避免 sqrt
            for i in _active_count:               # _active_count，不存在 _enemy_count
                var px := _data[i * ENEMY_STRIDE + 0]
                var py := _data[i * ENEMY_STRIDE + 1]
                var d2 := (px - origin.x) * (px - origin.x) + (py - origin.y) * (py - origin.y)
                if d2 < best_dist:
                    best_dist = d2
                    best_pos  = Vector2(px, py)
            return best_pos                       # 实现全扫存活敌人，不按 _visible_flags 过滤
        ```

        > **EnemyManager SoA 完整槽位布局**（权威来源：`implementation_plan.md §2.3.C`，P6-N45）：
        > ```
        > ENEMY_STRIDE = 8
        > [ x,  y,  vx,  vy,  hp,  hp_max,  faction_and_type,  status_bits ]
        >   0   1    2    3    4      5             6                  7
        > ```
        > - `+4 hp`：当前生命值；`+5 hp_max`：最大生命值（BossManager 的 `get_hp_percent` 读取此两槽）
        > - `+6 faction_and_type`：高 16 位 = faction（0=敌方, 1=友方）；低 16 位 = enemy_type_id
        > - `+7 status_bits`：燃烧/冰冻/中毒等位掩码（精确 DoT 计时存 `_enemy_contexts` 冷数据）
        > - `_death_position_cache`、`_entity_type_map`、`_entity_index_map` 为类成员 `Dictionary`，spawn/kill 时 GDScript 维护。

    *   **自动瞄准目标选取算法**：法杖自动开火时，发射方向需要选取"目标敌人"。目标选取逻辑集中在 `PlayerManager.get_aim_target()` 中，优先级如下：
        1.  **手柄/鼠标显式瞄准**：若存在显式输入方向（`aim_vector.length() > 0.3`），直接使用该方向，不进行目标锁定。
        2.  **最近敌人（默认）**：委托 `EnemyManager.get_nearest_pos()` 直接线性扫描敌人 SoA `_data`，取欧氏距离最小的存活敌人位置，作为开火方向。时间复杂度 O(M)；仅在发射时调用，非每帧热路径。
        3.  **自定义优先级扩展（P2）**：商店购买"目标优先级"被动时（如"优先最低 HP"、"优先最近"），`PlayerManager.aim_priority` 属性切换选取算法，但当前 P0/P1 阶段固定为最近敌人。
        ```gdscript
        # player_manager.gd
        func get_aim_direction() -> Vector2:
            var aim := Input.get_vector("aim_left", "aim_right", "aim_up", "aim_down")
            if aim.length() > 0.3:
                return aim.normalized()  # 手柄/鼠标显式瞄准优先
            # 自动瞄准：查最近敌人快照
            var nearest_pos := BulletManager.get_nearest_enemy_pos(get_position())
            if nearest_pos == Vector2.INF:
                return Vector2.RIGHT  # 无敌人时向右发射（避免零向量）
            return (nearest_pos - get_position()).normalized()
        ```

### 4.3 物理与碰撞机制
*   **Area2D (优先方案)**: 子弹使用 `Area2D` + `CollisionShape2D`，通过 `body_entered` 信号回调处理命中。Godot 的 Area2D 属于轻量级重叠检测，无刚体动力学开销。
    *   利用 **Physics Layer & Mask** 系统过滤阵营（子弹层只与敌人层交互），避免无效检测。
*   **Spatial Hashing Fallback**: 若 Area2D 在 2000+ 弹幕下仍有压力，回退到定制的 **Spatial Grid** (一维数组网格)，只计算临近 Grid 的实体碰撞，确保碰撞检测复杂度维持在 O(N)。
    *   **Separation Logic**: 怪物挤压不使用刚体求解，而是施加简单的轻量级斥力向量。
    *   **Cell Size 定义**: 默认格子边长 = `max_collider_radius × 2`（如最大子弹半径 20px → Cell = 40px）。格子过小增加哈希桶压力，格子过大降低空间剪裁效果。推荐随关卡配置可调。
    *   **超大弹体豁免策略**：`nuke` 等特殊弹体半径可达 300px，若将 Cell Size 设为 600px 会使整个 SpatialGrid 退化为单格全量扫描。
        解决方案：SpatialGrid 的 `max_collider_radius` 仅取**标准弹体**上限（如 24px → Cell = 48px）；
        `radius > LARGE_PROJECTILE_THRESHOLD`（默认 = 64px）的弹体**绕过 SpatialGrid**，改用 `EnemyManager.query_aabb(bullet_aabb)` 进行全量矩形测试——
        此类超大弹体数量通常极少（≤ 5 颗/帧），全量扫描代价可接受；同时 SpatialGrid 精度不受破坏。
        `BulletManager` 在 spawn 时按 radius 自动路由到正确的碰撞路径，不需要业务层感知区别。
    *   **运行时切换策略**: `BulletManager` 维护 `_collision_mode: int`（`0` = Area2D，`1` = SpatialGrid）。
        *   上穿阈值（`_active_count > 2000 and _collision_mode == 0`）：**分帧渐进禁用** Area2D（每帧最多关闭 `BATCH_DISABLE_PER_FRAME = 100` 个节点，约需 20 帧完成 2000 节点的切换，避免单帧卡顿）；过渡期内新生成子弹直接走 SpatialGrid 路径；所有 Area2D 禁用完毕后正式激活 MultiMesh 渲染。
        *   下穿回滞（`_active_count < 1500 and _collision_mode == 1`）：反向切回，避免阈值附近频繁震荡。**⚠️ 反向切换 Jitter 风险**：从 SpatialGrid 切回 Area2D 时，约 1500 颗子弹需要重新启用 Node（`process_mode = INHERIT`）。若全部在单帧完成，将产生约 1500 次节点状态更改（额外 1~2ms，可见微卡顿）。**缓解策略**：反向切换同样须分帧渐进（`BATCH_ENABLE_PER_FRAME = 100`，约 15 帧完成），过渡期内存活子弹继续走 SpatialGrid 路径，碰撞正确性不受影响。
        *   两段路径（信号回调 vs 手动查询）最终均调用同一 `_on_bullet_hit(bullet_id, enemy_id)` 函数，后处理逻辑完全共用。

> **⚠️ 实现现状 (2026-07-20 审计)**：上述 Area2D 优先 + `_collision_mode` 三档运行时切换**从未实现**。现状是 **SpatialGrid-only**：`BulletManager._check_collision` 无条件走 `SpatialGrid.query_circle`，代码中不存在 Area2D / CircleShape2D / `body_entered` / `_collision_mode` / `BATCH_DISABLE_PER_FRAME`。这一简化与 `implementation_plan.md` §S0 的 **R-04 决议**（永久锁定 SpatialGrid 为 200+ 主力、不保留 Area2D 回退）一致——本节 §4.3/§4.4 属尚未同步的旧设计。详见 [审计报告](../../docs_dev/doc_code_audit_2026-07-20.md)。

### 4.4 渲染优化

子弹渲染策略依据同屏数量分三档，**Area2D 与 MultiMeshInstance2D 不共存**——MultiMesh 绕过节点树，意味着子弹无法挂载 Area2D。因此两者适用于不同的弹幕规模区间：

> **⚠️ 实现现状 (2026-07-20 审计)**：下表三档**未实现**。现状：`MultiMeshInstance2D` **从第 1 帧起单档常开**（无 Node2D 池、无 <200/200-2000 档），子弹与敌人均走 MultiMesh。同步是 **GDScript 逐实例 `set_instance_transform_2d` 循环**（非文档的 C# `SetBuffer` 批量上传）；子弹无按型颜色（统一 `modulate`），敌人有 per-instance color。既然碰撞已 SpatialGrid-only，Node 池档位已无意义，此简化为**代码更优**。详见 [审计报告](../../docs_dev/doc_code_audit_2026-07-20.md)。

| 同屏弹幕数 | 渲染方案 | 碰撞方案 | 说明 |
| :--- | :--- | :--- | :--- |
| < 200 | Node Pool (Node2D) | Area2D | 全功能，支持粒子特效、信号回调 |
| 200–2000 | Node Pool + Dirty Sync | Area2D | 仅视口内节点同步 position |
| 2000+ | MultiMeshInstance2D | 自定义 SpatialGrid | 完全绕过节点树，牺牲特效换取帧率 |

*   **Node Pooling**: 严格的节点池管理（使用 Array 存储空闲节点，Node.process_mode = DISABLED 代替真实销毁）。
*   **MultiMeshInstance2D**: 仅在同屏弹幕超过 2000 且切换为 SpatialGrid 碰撞时启用。启用后 BulletManager 不再为每颗子弹维护 Node 实例，改为直接更新 `MultiMesh.transform_array`。

    **SoA 索引 ↔ MultiMesh transform_array 索引映射规则**：

    BulletManager 的 `_data: PackedFloat32Array` 使用 **紧凑活跃列表（Compact Active List）** 布局：索引 0 到 `_active_count - 1` 始终是存活子弹，dead-and-removed 子弹用 swap-and-pop 填入空位。MultiMesh 的 `instance_count` 始终等于 `_active_count`，两者**共享同一索引**——SoA 中第 i 个子弹对应 MultiMesh 第 i 个实例变换。

    ```gdscript
    # BulletManagerCs.cs — _PhysicsProcess 末尾（完成 SoA 积分后）
    # 将所有活跃子弹的位置同步到 MultiMesh.transform_array（批量上传）
    private void SyncMultiMesh()
    {
        // _multiMesh.InstanceCount 由 GDScript 在 _active_count 变化时更新
        // 这里只做 transform_array 的批量写入，不触发单实例 set_instance_transform_2d()
        var transforms = new float[_activeCount * 8]; // Transform2D = 6 float + 2 位置 = Godot 内部格式 8 float
        var span = _data.AsSpan();
        for (int i = 0; i < _activeCount; i++)
        {
            int src = i * BulletStride;
            int dst = i * 8;
            // Transform2D(1,0, 0,1, px, py) — 纯平移，无旋转（子弹朝向由 Sprite 贴图决定）
            transforms[dst]     = 1f; transforms[dst + 1] = 0f;  // col0
            transforms[dst + 2] = 0f; transforms[dst + 3] = 1f;  // col1
            transforms[dst + 4] = span[src];     // px
            transforms[dst + 5] = span[src + 1]; // py
            // Godot MultiMesh 格式：8 floats per instance（含 custom_data，后 2 float 可复用为 type/size）
            transforms[dst + 6] = span[src + 10]; // bullet_type（用于 Shader 切换 UV atlas 区域）
            transforms[dst + 7] = span[src + 9];  // size_mult（用于 Shader 缩放实例）
        }
        _multiMesh.SetBuffer(transforms); // 单次批量上传，1 次 Draw Call
    }
    ```

    **子弹类型与 MultiMesh Shader**：MultiMesh 使用单一材质 + Atlas Texture，`transform_array[i * 8 + 6]`（bullet_type 整数）作为 Shader 参数，在 Canvas Shader 中做 `UV = atlas_uv_for_type(bullet_type)`，支持不同外观子弹共用一次 Draw Call。
*   **Throttling (分帧降频)**:
    *   伤害数字：每帧最多弹出 10 个，多余的合并或延迟显示。
    *   AI 索敌：不需要每帧执行 FindNearest，可分散到 10~20 帧内轮询一次（利用 Engine.get_physics_frames() % 15 == entity_id % 15 错峰轮询）。

### 4.5 帧预算分配表 (Frame Budget Allocation)

> **目标**：60fps → 单帧总预算 **16.67ms**。以下为 S0 性能验收的基准分配；S0 Profiler 实测后需将"S0 实测值"列回填并提交 git，作为后续切片的性能基线快照。

> **⚠️ 实现现状 (2026-07-20 审计)**：下表"语言"列标注 C#（`BulletManagerCs`/`EnemyManagerCs`/`SpatialGridCs`/`SpellEvaluatorCs`）的热路径**均未接线**——三个 C# 内核从未实例化、从未进场景树，战斗逻辑 **100% 由 GDScript 回退路径承载**（R-08 顺延）。C# `AsSpan()` 零拷贝在代码中不存在。表中"S0 实测值"确为 GDScript 回退实测（见 :795 摘要）。详见 [审计报告](../../docs_dev/doc_code_audit_2026-07-20.md)。

| 系统 | 语言 | 预算上限 | 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 填充）<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 逐 tick（S4 验收后填入，P-S4-02；与 implementation_plan §2.4 统一）|
| `ZoneManagerCs._PhysicsProcess` | C# | 1.0ms | — | 64 zones × SpatialGrid 查询（S5 验收后填入）|
| GDScript 游戏循环（WaveManager / EventBus dispatch）| GDScript | 1.5ms | — | 事件分发 + 波次状态机 |
| MultiMesh transform 上传 CPU→GPU | Godot 渲染器 | 1.5ms | — | 2000+ 子弹时（S6 验收后填入）|
| Godot 渲染器 Draw Call 基线 | 引擎 | 2.0ms | — | 背景 + 角色 + HUD（S1 场景建立后填入）|
| **保留余量**（突发帧：GC、资源加载）| — | **1.17ms** | — | — |
| **合计** | | **16.67ms** | **≈ 0.60ms**（S0 GDScript 基线，2000弹+1000敌；FPS 180）| |

> **S0 实测摘要（2026-06-04）**：机器 RTX 2060；Godot 4.6.2 stable mono；Windows 10。2000 子弹 + 1000 敌人同屏，GDScript fallback 运行（C# 热路径骨架未激活）。`physics_frame_time_msec`（Godot Monitor）= 0.05~0.13ms，FPS 稳定 145~180，远超 60fps 目标。R-01/R-03 风险**已消除**。SpatialGrid C# 实例化与 AsSpan() 零拷贝基准在 S1 阶段随 BulletManagerCs 真实热路径一同验收（P-S0-06/P-S0-07 S1 延续项）。

**验收规则**：
- **S0 联合基准**：`BulletManagerCs` + `EnemyManagerCs` + `SpatialGridCs` 三项合计实测 **< 6ms**（覆盖 P-S0-01/02/04）。
- **超标触发流程**：若任一系统实测超出预算上限，**该切片不得进入下一切片**；立即检查是否违反 ADR-L1 规则 1（内层循环 `Call()`）或规则 2（未使用 `AsSpan()` 零拷贝）。
- **回填规范**：每个新切片的验收标准中对该切片新增系统补充帧时间目标；S6 发布前所有"—"必须替换为实测值。

---

### 4.6 内存预算表 (Memory Budget)

> **目标**：确保在目标平台（PC 和未来 Nintendo Switch）的内存占用在可接受范围内。Switch 主内存总量 4GB，系统 + 驱动常驻约 1.5GB，游戏可用约 **2.5GB**；PC 目标 RSS < **512MB**。

| 内存类别 | PC 预算 | Switch 预算 | 说明 |
| :--- | :--- | :--- | :--- |
| 代码 + GDScript VM + C# CLR | 80MB | 200MB | Mono 运行时常驻较大 |
| 纹理资源（Atlas + VFX + UI） | 150MB | 400MB | 压缩格式：PC=DXT5，Switch=ASTC4×4 |
| 音频资源（BGM 流式 + SFX 预加载）| 30MB | 80MB | BGM 流式播放，SFX 池全量预加载 |
| BulletManager SoA（2000 子弹） | < 1MB | < 1MB | `BULLET_STRIDE=12` × 2000 × 4B ≈ 96KB |
| EnemyManager SoA（1000 敌人） | < 1MB | < 1MB | `ENEMY_STRIDE=8` × 1000 × 4B ≈ 32KB |
| SpellContext 对象池（32 实例） | < 1MB | < 1MB | 每实例约 2KB（含 payloads Array） |
| Node Pool（子弹节点 2000 个）| 20MB | 50MB | 每 Node2D 约 10KB；MultiMesh 激活后降至 ~0 |
| VFX 粒子节点池（200 个）| 5MB | 10MB | MAX_ACTIVE_VFX=200 |
| DPS 环形缓冲区 + 其他运行时 | < 1MB | < 1MB | 各 PackedFloat 数组合计 |
| 存档文件（user://） | < 1MB | < 1MB | JSON 明文 < 100KB；含 HMAC 签名 |
| **合计（估算）** | **~290MB** | **~745MB** | Switch 远低于 2.5GB 限制 |

**验收规则**：
- **S6 P0 验收 P-S6-07**：Godot Profiler → Memory 标签页实测 RSS：PC < 512MB，Switch 目标 < 2.5GB。
- **热场景峰值**：Wave 20 Boss 战（最大同屏实体数）测量内存峰值，作为 S6 发布基线。
- **纹理内存监控**：`RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TEXTURE_MEM_USED)` 不超过 PC 预算的 150MB。

---

## 5. 游戏循环与系统集成 (Game Loop & System Integration)

### 5.1 GameCycleManager 状态机 (FSM)

> **⚠️ 实现现状 (2026-07-20 审计)**：`GameCycleManager` Autoload **不存在**。顶层状态机现内嵌在场景子节点 `combat_manager.gd`，仅 **5 态**（INIT/BATTLE/SETTLEMENT/SHOP/GAME_OVER），缺文档的 WAVE_INTRO 开场、WAVE_RESULT 升级卡结算、GAME_CLEARED 通关、PAUSE 独立态（暂停改由 `get_tree().paused` 处理）。`GAME_STATE_CHANGED` payload 用字符串态名而非枚举。同理 §5.10 的 `UIManager` Autoload 也不存在——全部 UI 在 `combat_s2.gd` 程序化构建，无 `HUD.tscn`/`shop.tscn` 等场景，`DamageNumber` 飘字未实现。下文 GameCycleManager/UIManager 相关设计保留作目标蓝图。详见 [审计报告](../../docs_dev/doc_code_audit_2026-07-20.md)。

`GameCycleManager` 是游戏最顶层的协调者，持有全局状态机并驱动各 Manager 的生命周期。

```gdscript
# game_cycle_manager.gd (Autoload: GameCycleManager)
enum GameState {
    MAIN_MENU    = 0,  # 主菜单（初始/死亡后/通关后回到此状态）
    LOADING      = 1,  # 场景加载过渡（ResourceLoader 异步加载战斗场景）
    SHOP         = 2,  # 商店/背包阶段（波次间）
    WAVE_INTRO   = 3,  # 波次开场动画（约 1.5s，Boss 波有特殊演出）
    WAVE         = 4,  # 战斗进行中
    WAVE_RESULT  = 5,  # 本波结算（经验弹算、升级选择 UI）
    GAME_OVER    = 6,  # 死亡结算界面
    GAME_CLEARED = 7,  # 通关结算（Wave 20 Boss 击杀后）
    PAUSE        = 8,  # 暂停（可从 WAVE/SHOP 进入，恢复后返回原状态）
}

var _state: GameState = GameState.MAIN_MENU
var _prev_state: GameState = GameState.MAIN_MENU  # 用于 PAUSE 恢复
var wave_num: int = 0                              # 当前波次（UIManager / WaveManager 均读取）
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
# _rng 在每次 Run 开始时重置：_rng.seed = hash(Time.get_ticks_msec())
# UpgradeSystem.build_choices(wave_num, _rng) 与 ShopManager 独立使用各自 seed，互不干扰

func transition_to(next: GameState) -> void:
    _on_exit(_state)
    _prev_state = _state
    _state = next
    _on_enter(next)

func get_state() -> GameState: return _state
```

#### 状态转换表

| 当前状态 | 触发条件 | 目标状态 | 关键副作用 |
| :--- | :--- | :--- | :--- |
| `MAIN_MENU` | 玩家点击"开始游戏" | `LOADING` | 异步加载 BattleScene.tscn |
| `LOADING` | 场景加载完成信号 | `SHOP` | wave_num=1；调用 ShopManager.open_shop(wave_num) |
| `SHOP` | 玩家点击"出发" | `WAVE_INTRO` | PlayerManager.compile_all_wands()；WaveManager.prepare_wave(wave_num) |
| `WAVE_INTRO` | 开场动画播放完毕（1.5s Timer） | `WAVE` | WaveManager.start_wave()；BulletManager.reset()；EnemyManager.reset() |
| `WAVE` | `EventID.WAVE_COMPLETE` 收到 | `WAVE_RESULT` | 经验结算；存档（ADR-A2 §2 波次结束自动存档） |
| `WAVE` | `EventID.PLAYER_DIED` 收到 | `GAME_OVER` | 统计数据收集；EndlessRecordsManager 不更新 |
| `WAVE_RESULT` | wave_num < 20，玩家确认升级 | `SHOP` | wave_num++；ShopManager.restock(wave_num) |
| `WAVE_RESULT` | wave_num == 20 且 Boss 已击杀 | `GAME_CLEARED` | EndlessRecordsManager 更新；Steam成就触发 |
| `WAVE_RESULT` | wave_num > 20（Endless模式） | `SHOP` | wave_num++；ShopManager.restock(wave_num) |
| `GAME_OVER` / `GAME_CLEARED` | 玩家点击"返回菜单" | `MAIN_MENU` | 卸载 BattleScene；重置所有 Autoload Manager |
| 任意战斗状态 | Input.is_action_just_pressed("pause") | `PAUSE` | 保存 _prev_state；Engine.time_scale = 0 |
| `PAUSE` | 再次按暂停键 | `_prev_state` | Engine.time_scale = 1 |

#### Manager Reset 协议

每次从 `GAME_OVER`/`GAME_CLEARED` 返回 `MAIN_MENU` 时，GameCycleManager 负责按顺序重置所有 Autoload：

```gdscript
func _reset_all_managers() -> void:
    # 顺序重要：先停止所有计算，再清理数据，最后重置 UI
    BulletManager.reset()        # 清空所有子弹 SoA 数据
    EnemyManager.reset()         # 清空所有敌人 SoA 数据
    MinionManager.reset()        # 清空所有召唤物
    ZoneManager.reset()          # 清空所有地面效果
    StatusManager.reset()        # 清空所有状态效果
    SpellEvaluator.reset()       # 清空 SubPayloadRegistry
    DamageContextPool.reset()    # 归还所有在途 context（防止下局 context 泄漏）
    PlayerManager.reset()        # 重置血量/位置/蓄力状态
    WaveManager.reset()          # wave_num = 0；spawn 队列清空
    ShopManager.reset()          # 商品列表清空
    ProfileManager.clear_run()   # 清除本局存档
```

---

### 5.2 WaveManager 框架设计

`WaveManager` 负责读取波次配置、按时序 spawn 敌人、检测清场条件、向 GameCycleManager 上报结果。

#### 波次配置 JSON 格式

```json
// res://resources/waves/wave_01.json
{
  "wave_num": 1,
  "duration_sec": 30,       // 波次持续时间上限（超时也算通过）
  "is_boss_wave": false,    // true 时触发 BOSS_PHASE_CHANGED 事件和特殊 BGM
  "spawn_groups": [
    {
      "enemy_id": "slime_basic",
      "count": 20,
      "spawn_mode": "perimeter",  // "perimeter" = 屏幕外缘随机, "cluster" = 聚集点, "instant" = 全量即时
      "delay_sec": 0.0,           // 从波次开始到此批出现的延迟
      "interval_sec": 0.5         // 同批内每只敌人的间隔（0 = 即时全量）
    },
    {
      "enemy_id": "bat_fast",
      "count": 10,
      "spawn_mode": "perimeter",
      "delay_sec": 8.0,
      "interval_sec": 0.3
    }
  ],
  "clear_condition": "kill_all"   // "kill_all" | "survive_duration" | "kill_boss"
}
```

#### WaveManager 状态与接口

```gdscript
# wave_manager.gd (Autoload: WaveManager)
var wave_num: int = 0
var _elapsed: float = 0.0
var _alive_count: int = 0           # 存活敌人数
var _total_spawned: int = 0         # 本波已 spawn 敌人总数
var _config: Dictionary = {}        # 当前波次 JSON 解析结果

# 由 GameCycleManager 在 SHOP→WAVE_INTRO 时调用
func prepare_wave(num: int) -> void:
    wave_num = num
    _elapsed = 0.0; _alive_count = 0; _total_spawned = 0
    var path := "res://resources/waves/wave_%02d.json" % num
    _config = JSON.parse_string(FileAccess.open(path, FileAccess.READ).get_as_text())
    # Boss 波：提前通知 AudioManager 切换 BGM 分层（淡入 Boss 主题）
    if _config.get("is_boss_wave", false):
        EventBus.emit(EventID.BOSS_PHASE_CHANGED, {"boss_id": _config.get("boss_id",""), "phase": 0, "bgm_layer": "boss"})

# 由 GameCycleManager 在 WAVE_INTRO 完成后调用
func start_wave() -> void:
    _schedule_spawns()

func _physics_process(delta: float) -> void:
    if GameCycleManager.get_state() != GameCycleManager.GameState.WAVE: return
    _elapsed += delta
    _process_spawn_queue(delta)
    _check_clear_condition()

func _check_clear_condition() -> void:
    match _config.get("clear_condition", "kill_all"):
        "kill_all":
            if _total_spawned >= _total_required() and _alive_count <= 0:
                EventBus.emit(EventID.WAVE_COMPLETE, {"wave_num": wave_num})
        "survive_duration":
            if _elapsed >= _config.get("duration_sec", 30.0):
                EventBus.emit(EventID.WAVE_COMPLETE, {"wave_num": wave_num})
        "kill_boss":
            pass  # 由 BossManager 在 Boss 死亡时发出 WAVE_COMPLETE

# 订阅 ENEMY_KILLED：更新存活计数
func _on_enemy_killed(_enemy_id: int, _killer_id: int) -> void:
    _alive_count = max(0, _alive_count - 1)
```

#### 敌人 spawn 与 EnemyManager 协议

WaveManager 不直接操作 EnemyManager 的 SoA 数据，而是调用高层接口：

```gdscript
# EnemyManager 对外接口（GDScript Autoload 层）
func spawn(enemy_id: String, position: Vector2) -> int:  # 返回 entity_id
func reset() -> void                                     # 清空 SoA（由 GameCycleManager 调用）
func get_alive_count() -> int
func get_pos_by_id(entity_id: int) -> Vector2            # BulletManager 碰撞/homing 用；未命中返回哨兵 (-9999,-9999)
```

---

### 5.3 ShopManager 框架设计

`ShopManager` 负责商品池管理、商店刷新、购买流程。与 `PlayerManager`（货币/库存）解耦通过 EventBus 或直接调用接口。

#### 商品池与刷新算法

```gdscript
# shop_manager.gd (Autoload: ShopManager)

# 商品类型（可出现在商店的物品分类）
enum ShopItemType { SPELL = 0, PASSIVE = 1, CONSUMABLE = 2, CORE = 3 }

# 商店槽位数：固定 6 格（3 法术 + 2 被动 + 1 消耗品）
const SLOT_COUNT: int = 6
const REROLL_BASE_COST: int = 20   # 首次刷新费用
const REROLL_COST_STEP: int = 10   # 每次刷新追加费用

var _slots: Array[Dictionary] = []       # 当前商店展示的商品
var _reroll_count_this_wave: int = 0    # 本波刷新次数（结算后重置）
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()

# 由 GameCycleManager 调用
func open_shop(wave_num: int) -> void:
    _reroll_count_this_wave = 0
    _rng.seed = hash(wave_num) ^ ProfileManager.get_run_seed()  # 确定性种子，防退出重开刷商店
    _fill_slots(wave_num)

func restock(wave_num: int) -> void:
    open_shop(wave_num)

func get_reroll_cost() -> int:
    return REROLL_BASE_COST + _reroll_count_this_wave * REROLL_COST_STEP

func reroll(wave_num: int) -> bool:
    var cost := get_reroll_cost()
    if not PlayerManager.spend_gold(cost): return false
    _reroll_count_this_wave += 1
    _fill_slots(wave_num)
    return true

func _fill_slots(wave_num: int) -> void:
    _slots.clear()
    # 权重池：随波次调整稀有度分布（越后期出现高稀有度概率越高）
    var pool := _build_weighted_pool(wave_num)
    for i in SLOT_COUNT:
        _slots.append(_pick_from_pool(pool))

func _build_weighted_pool(wave_num: int) -> Array:
    # ── 稀有度权重（与 UpgradeSystem._rarity_weights 保持相同曲线）────────
    var common_w    := max(5.0,  60.0 - wave_num * 2.5)
    var uncommon_w  := min(50.0, 30.0 + wave_num * 1.5)
    var rare_w      := min(30.0, max(5.0, wave_num * 1.2 - 5.0))
    var legendary_w := min(10.0, max(0.0, wave_num * 0.5 - 8.0))
    var rarity_weights := [common_w, uncommon_w, rare_w, legendary_w]
    # ── 候选池：SPELL + PASSIVE（按稀有度归组）────────────────────────────
    var candidates: Array[Array] = [[], [], [], []]  # index = rarity-1
    for item in SpellRegistry.all():
        candidates[clamp(item.rarity - 1, 0, 3)].append({
            "type": ShopItemType.SPELL, "id": item.spell_id,
            "price": 4 + (item.rarity - 1) * 4   # 价格公式：4/8/12/16 金币
        })
    for item in PassiveRegistry.all():
        candidates[clamp(item.rarity - 1, 0, 3)].append({
            "type": ShopItemType.PASSIVE, "id": item.passive_id,
            "price": 6 + (item.rarity - 1) * 5   # 被动略贵：6/11/16/21 金币
        })
    # ── 构建带权重的扁平池 ─────────────────────────────────────────────
    var pool: Array = []
    for rarity_idx in 4:
        var w: float = rarity_weights[rarity_idx]
        for entry in candidates[rarity_idx]:
            pool.append({"entry": entry, "weight": w})
    return pool

func _pick_from_pool(pool: Array) -> Dictionary:
    # 使用确定性 _rng（防退出重开刷商店，与 open_shop seed 一致）
    if pool.is_empty(): return {}
    var total := 0.0
    for item in pool: total += item["weight"]
    var roll := _rng.randf() * total
    for item in pool:
        roll -= item["weight"]
        if roll <= 0.0: return item["entry"]
    return pool[-1]["entry"]
```

#### 购买流程与 PlayerManager 接口

```gdscript
# 购买接口（UIManager 点击购买时调用）
func purchase(slot_idx: int) -> bool:
    if slot_idx < 0 or slot_idx >= _slots.size(): return false
    var item: Dictionary = _slots[slot_idx]
    if item.is_empty(): return false
    var cost: int = item.get("price", 0)
    if not PlayerManager.spend_gold(cost): return false
    # 按类型分发给对应 Manager
    match item.get("type", ShopItemType.SPELL):
        ShopItemType.SPELL:
            PlayerManager.add_spell_to_inventory(item.get("id", ""))
        ShopItemType.PASSIVE:
            PlayerManager.add_passive(item.get("id", ""))
        ShopItemType.CORE:
            PlayerManager.replace_core(item.get("slot", 0), item.get("id", ""))
        ShopItemType.CONSUMABLE:
            PlayerManager.use_consumable(item.get("id", ""))
    _slots[slot_idx] = {}  # 标记为已售
    return true

func get_slots() -> Array[Dictionary]:
    # UIManager._refresh_shop_ui() 读取当前商品展示列表（只读副本）
    return _slots.duplicate()

func reset() -> void:
    _slots.clear(); _reroll_count_this_wave = 0

func offer_free_spell(spell_id: String) -> void:
    # DropManager 拾取法术卡掉落时调用。
    # 弹出"拾取法术"交互 UI，玩家确认后才实际装备（防止强制打断操作）。
    # UIManager 订阅此函数弹出的事件；若 UIManager 未处理则直接给予背包。
    if not SpellRegistry.has(spell_id): return
    EventBus.emit(EventID.SPELL_DROP_PICKUP, {
        "spell_id": spell_id,
        "auto_equip": false     # false = 弹 UI 让玩家决定装到哪个 Core；true = 装 active_core
    })
```

---

### 5.4 AudioManager 框架设计

`AudioManager` 管理 AudioBus 层级、动态音乐分层、音效池和空间音频。

> **权威定义说明**：本节为 AudioBus 结构的唯一权威来源。`ADR-A3`（§文档末尾）提供补充规则（空间音效衰减参数、节流规则、音量持久化细节），不重新定义 Bus 结构。两者合并阅读。

#### AudioBus 层级（权威）

```
Master
├── BGM            (背景音乐总线，-6dB 预衰减)
│   ├── BGM_Base   (基础旋律层，常驻播放，AudioStreamPlayer 独占)
│   └── BGM_Layer  (动态叠加层：战斗强度 / Boss 阶段驱动；3 轨并行，按强度 fade-in)
├── SFX            (战斗音效总线，-3dB)
│   ├── SFX_Combat (子弹/爆炸/伤害，来自 BulletManager/EnemyManager)
│   └── SFX_Spatial(2D 空间音效，挂 AudioEffect2DPan + 2D Reverb；max_distance=1200px)
├── UI             (UI 音效，不受空间衰减，-3dB)
├── Ambience       (循环环境音，-9dB)
└── Voice          (语音 / Boss 台词，最高优先级，不受 SFX 池限制)
```

**AudioBusID 常量 Autoload**（代码中所有 Bus 名称必须通过此 Autoload 引用，禁止硬编码字符串）：

```gdscript
# scripts/autoloads/audio_bus_id.gd (Autoload: AudioBusID)
const MASTER      : StringName = &"Master"
const BGM         : StringName = &"BGM"
const BGM_BASE    : StringName = &"BGM_Base"
const BGM_LAYER   : StringName = &"BGM_Layer"
const SFX         : StringName = &"SFX"
const SFX_COMBAT  : StringName = &"SFX_Combat"
const SFX_SPATIAL : StringName = &"SFX_Spatial"
const UI          : StringName = &"UI"
const AMBIENCE    : StringName = &"Ambience"
const VOICE       : StringName = &"Voice"
```

**Bus 配置文件**：`res://audio/bus_layout.tres`（Godot 内置 AudioBusLayout Resource，在 Project Settings > Audio 中指定）。

#### AudioManager 完整数据模型

```gdscript
# audio_manager.gd (Autoload: AudioManager)

# ── 常量 ───────────────────────────────────────────────────────
const MAX_CONCURRENT_SFX: int = 32    # 同时最多播放 32 个 SFX 实例（SFX_Combat + SFX_Spatial 共享上限）
const MAX_SAME_SFX_PER_FRAME: int = 3 # 同一音效每帧最多触发 3 次（防弹幕爆炸噪音堆叠）
const BGM_FADE_OUT_SEC: float = 0.8   # BGM 淡出时长（秒）
const LAYER_FADE_SPEED: float = 2.0   # 动态音乐层淡变速率（dB/秒）
const SFX_COOLDOWN_SEC: float = 0.1   # 同一音效最小间隔（防 0.1s 内重复播放）

# ── BGM 三步交叉淡变状态机 ────────────────────────────────────────
enum BgmPhase { IDLE, FADING_OUT, SWITCHING, FADING_IN }
var _bgm_phase: BgmPhase = BgmPhase.IDLE
var _current_bgm_id: String = ""
var _pending_bgm_id: String = ""
var _pending_fade_in: float = 1.5
var _fade_timer: float = 0.0

# ── BGM 播放器节点（_ready() 中创建并 add_child）───────────────────
var _bgm_base_player: AudioStreamPlayer = null   # BGM_Base Bus 专属播放器
var _music_layers: Array[AudioStreamPlayer] = [] # BGM_Layer Bus 上的 3 个动态层播放器（index 0/1/2）
var _layer_target: int = 0                        # 当前目标动态层级（0=基础/1=战斗/2=高强度）

# ── SFX 池 ──────────────────────────────────────────────────────
var _sfx_pool: Array[AudioStreamPlayer2D] = []   # 预分配节点池（MAX_CONCURRENT_SFX 个）
var _sfx_active_count: int = 0                    # O(1) 可用池数量追踪（P6-N60 规范）
var _sfx_frame_count: Dictionary = {}            # { sfx_id: int } 本帧计数（_process() 帧末清零）
var _sfx_last_play_time: Dictionary = {}         # { sfx_id: float } 同 ID 节流计时

func _ready() -> void:
    # 创建 BGM_Base 播放器
    _bgm_base_player = AudioStreamPlayer.new()
    _bgm_base_player.bus = AudioBusID.BGM_BASE
    add_child(_bgm_base_player)
    # 创建 3 个动态层播放器（挂 BGM_Layer Bus）
    for i in 3:
        var lp := AudioStreamPlayer.new()
        lp.bus = AudioBusID.BGM_LAYER
        lp.volume_db = -80.0  # 初始静音
        add_child(lp)
        _music_layers.append(lp)
    # 预分配 SFX 池
    for i in MAX_CONCURRENT_SFX:
        var sp := AudioStreamPlayer2D.new()
        sp.bus = AudioBusID.SFX_COMBAT  # 默认 Combat 总线，play_sfx() 可按类型切换
        sp.finished.connect(_on_sfx_finished.bind(sp))
        add_child(sp)
        _sfx_pool.append(sp)
    EventBus.subscribe(EventID.BOSS_PHASE_CHANGED, _on_boss_phase_changed)
    EventBus.subscribe(EventID.BOSS_KILLED, _on_boss_killed)
    EventBus.subscribe(EventID.PLAYER_DIED, _on_player_died)
```

#### 核心接口

```gdscript
# ── SFX 接口 ─────────────────────────────────────────────────────
func play_sfx(sfx_id: String, position: Vector2, volume_db: float = 0.0,
              bus: StringName = AudioBusID.SFX_COMBAT) -> void:
    # 同帧频率限制
    _sfx_frame_count[sfx_id] = _sfx_frame_count.get(sfx_id, 0) + 1
    if _sfx_frame_count[sfx_id] > MAX_SAME_SFX_PER_FRAME: return
    # 同 ID 0.1s 节流
    var now := Time.get_ticks_msec() / 1000.0
    if now - _sfx_last_play_time.get(sfx_id, 0.0) < SFX_COOLDOWN_SEC: return
    _sfx_last_play_time[sfx_id] = now
    var player := _acquire_sfx_player()
    if player == null: return  # 池耗尽，静默丢弃（非崩溃）
    player.stream = SfxLibrary.get(sfx_id)
    player.bus = bus
    player.position = position
    player.volume_db = volume_db
    player.play()

func play_sfx_ui(sfx_id: String) -> void:
    # UI 音效：不需要位置，直接挂 UI Bus
    var player := _acquire_sfx_player()
    if player == null: return
    player.stream = SfxLibrary.get(sfx_id)
    player.bus = AudioBusID.UI
    player.position = Vector2.ZERO
    player.volume_db = 0.0
    player.play()

func _acquire_sfx_player() -> AudioStreamPlayer2D:
    if _sfx_active_count >= MAX_CONCURRENT_SFX: return null
    for p in _sfx_pool:
        if not p.playing:
            _sfx_active_count += 1
            return p
    return null  # 全部占用

func _on_sfx_finished(player: AudioStreamPlayer2D) -> void:
    _sfx_active_count = max(0, _sfx_active_count - 1)

# ── BGM 接口 ─────────────────────────────────────────────────────
func play_bgm(bgm_id: String, fade_in_sec: float = 1.5) -> void:
    if _current_bgm_id == bgm_id: return
    _pending_bgm_id  = bgm_id
    _pending_fade_in = fade_in_sec
    _bgm_phase       = BgmPhase.FADING_OUT

func fade_out_all(duration: float = 0.5) -> void:
    # 玩家死亡时调用：所有 Bus 在 duration 秒内淡出至 -40dB
    var tween := create_tween()
    for bus_name in [AudioBusID.BGM, AudioBusID.SFX, AudioBusID.UI, AudioBusID.AMBIENCE]:
        var idx := AudioServer.get_bus_index(bus_name)
        tween.parallel().tween_method(
            func(v): AudioServer.set_bus_volume_db(idx, v),
            AudioServer.get_bus_volume_db(idx), -40.0, duration)

# ── BGM 三步交叉淡变（_process() 每帧驱动）────────────────────────
func _process_bgm_fade(delta: float) -> void:
    match _bgm_phase:
        BgmPhase.FADING_OUT:
            _fade_timer += delta
            _bgm_base_player.volume_db = lerp(0.0, -80.0, _fade_timer / BGM_FADE_OUT_SEC)
            if _fade_timer >= BGM_FADE_OUT_SEC:
                _bgm_base_player.stop()
                _fade_timer = 0.0
                _bgm_phase = BgmPhase.SWITCHING
        BgmPhase.SWITCHING:
            _bgm_base_player.stream = BgmLibrary.get(_pending_bgm_id)
            _bgm_base_player.volume_db = -80.0
            _bgm_base_player.play()
            _current_bgm_id = _pending_bgm_id
            _bgm_phase = BgmPhase.FADING_IN
        BgmPhase.FADING_IN:
            _fade_timer += delta
            _bgm_base_player.volume_db = lerp(-80.0, 0.0, _fade_timer / _pending_fade_in)
            if _fade_timer >= _pending_fade_in:
                _bgm_base_player.volume_db = 0.0
                _fade_timer = 0.0
                _bgm_phase = BgmPhase.IDLE

# ── 动态音乐层（每 0.5s 在 _process() 中轮询，不在 _physics_process）──
func _update_music_layers(enemy_count: int, boss_active: bool) -> void:
    _layer_target = 0
    if boss_active or enemy_count >= 20: _layer_target = 2
    elif enemy_count >= 5:               _layer_target = 1
    for i in _music_layers.size():
        var target_db: float = 0.0 if i <= _layer_target else -80.0
        _music_layers[i].volume_db = move_toward(
            _music_layers[i].volume_db, target_db,
            LAYER_FADE_SPEED * get_process_delta_time())

func _on_boss_phase_changed(payload: Dictionary) -> void:
    var bgm_layer: String = payload.get("bgm_layer", "")
    if bgm_layer.begins_with("boss"): play_bgm("boss_phase_0")

func _on_boss_killed(_payload: Dictionary) -> void:
    play_bgm("wave_normal")

func _on_player_died(_payload: Dictionary) -> void:
    fade_out_all(0.5)

func reset() -> void:
    _bgm_base_player.stop()
    for lp in _music_layers: lp.stop(); lp.volume_db = -80.0
    _current_bgm_id = ""; _bgm_phase = BgmPhase.IDLE; _fade_timer = 0.0
    _sfx_frame_count.clear(); _sfx_last_play_time.clear(); _sfx_active_count = 0
```

#### SfxLibrary / BgmLibrary 音频资源索引

`SfxLibrary` 和 `BgmLibrary` 是纯静态字典，在 `AudioManager._ready()` 时由预加载填充，供 `play_sfx` / `play_bgm` 通过字符串 ID 查找 `AudioStream`。

```gdscript
# sfx_library.gd（非 Autoload，挂在 AudioManager 子节点或直接合并到 audio_manager.gd）
const _SFX_MAP: Dictionary = {
    # 战斗音效 ─────────────────────────────
    "bullet_hit_flesh"    : preload("res://audio/sfx/combat/bullet_hit_flesh.ogg"),
    "bullet_hit_shield"   : preload("res://audio/sfx/combat/bullet_hit_shield.ogg"),
    "explosion_small"     : preload("res://audio/sfx/combat/explosion_small.ogg"),
    "explosion_large"     : preload("res://audio/sfx/combat/explosion_large.ogg"),
    "player_hurt"         : preload("res://audio/sfx/combat/player_hurt.ogg"),
    # 法术施放 ─────────────────────────────
    "spell_cast_generic"  : preload("res://audio/sfx/spell/cast_generic.ogg"),
    "spell_cast_fire"     : preload("res://audio/sfx/spell/cast_fire.ogg"),
    "spell_cast_ice"      : preload("res://audio/sfx/spell/cast_ice.ogg"),
    # UI 音效 ──────────────────────────────
    "ui_click"            : preload("res://audio/sfx/ui/click.ogg"),
    "ui_purchase"         : preload("res://audio/sfx/ui/purchase.ogg"),
    "ui_reroll"           : preload("res://audio/sfx/ui/reroll.ogg"),
    "ui_level_up"         : preload("res://audio/sfx/ui/level_up.ogg"),
}

static func get(sfx_id: String) -> AudioStream:
    return _SFX_MAP.get(sfx_id, null)

# bgm_library.gd（同上，流式资源使用 load() 而非 preload 避免启动时全量入内存）
const _BGM_MAP: Dictionary = {
    "main_menu"           : "res://audio/bgm/main_menu.ogg",
    "shop"                : "res://audio/bgm/shop.ogg",
    "wave_normal"         : "res://audio/bgm/wave_normal.ogg",
    "boss_phase_0"        : "res://audio/bgm/boss_phase0.ogg",
    "boss_phase_1"        : "res://audio/bgm/boss_phase1.ogg",
    "boss_phase_2"        : "res://audio/bgm/boss_phase2.ogg",
    "game_over"           : "res://audio/bgm/game_over.ogg",
    "game_cleared"        : "res://audio/bgm/game_cleared.ogg",
}

static func get(bgm_id: String) -> AudioStream:
    var path: String = _BGM_MAP.get(bgm_id, "")
    if path == "": return null
    return load(path) as AudioStream  # 流式加载，释放旧引用时 Godot 自动 GC
```

> **资源命名规范**：音效文件统一使用 `.ogg`（Vorbis，内存效率最优）；BGM 为 `.ogg` 流式（`loop: true` 在 Import 设置中启用）。所有路径以 `res://audio/` 为根，子目录 `sfx/` 和 `bgm/` 对应上方结构。

---

### 5.5 StatusManager 架构设计

`StatusManager` 集中管理所有实体的状态效果（DoT、减速、易伤等），消除各 Manager 自行处理状态的耦合。

#### 核心数据结构

```gdscript
# status_manager.gd (Autoload: StatusManager)
# 所有状态 ID 常量见 status_id.gd (Autoload: StatusID)
# 所有状态定义（效果、叠加规则）以 StatusTypeDef .tres 资源存储，由 StatusRegistry 加载

# SoA 布局（PackedFloat32Array）：每个状态实例占 STATUS_STRIDE=5 个浮点槽
# slot 0: entity_id (float 转 int 存储)
# slot 1: status_type_id (float 转 int)
# slot 2: remaining_duration
# slot 3: tick_accumulator
# slot 4: stack_count (float 转 int)
const STATUS_STRIDE: int = 5
const MAX_STATUS_INSTANCES: int = 512  # 最多同时存在 512 个状态实例（32KB）

var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
```

#### 接口与生命周期

```gdscript
# 施加状态（由 SpellEvaluator / ZoneManager / BulletManager 调用）
# 权威签名（三处调用方统一使用此签名）：
#   - SpellEvaluator: apply(target_id, status_type_id, intensity, owner_id=caster_id)
#   - ZoneManager:    apply(target_id, status_type_id, owner_id=zone_owner_id)（intensity=1.0 默认）
#   - impl §2.4:      apply(entity_id, status_type_id, stacks, duration, source_id) —
#                     duration/stacks 由 StatusTypeDef 驱动，不在此签名中暴露（内部细节）
func apply(entity_id: int, status_type_id: int, intensity: float = 1.0,
           owner_id: int = -1) -> void:
    # owner_id 用于 remove_source()（实体死亡时移除其施加的所有状态）；-1 = 无主（区域/无主状态）
    var existing := _find(entity_id, status_type_id)
    var typedef: StatusTypeDef = StatusRegistry.get(status_type_id)
    if existing >= 0:
        match typedef.stack_mode:
            "refresh":  _data[existing * STATUS_STRIDE + 2] = typedef.duration  # 刷新时长
            "stack":    _data[existing * STATUS_STRIDE + 4] += 1.0               # 叠加层数
            "ignore":   pass                                                       # 已有则忽略
    else:
        _add_new(entity_id, status_type_id, typedef.duration, intensity, owner_id)
    EventBus.emit(EventID.STATUS_APPLIED, {"target_id": entity_id, "status_type": status_type_id,
                                           "stacks": int(_data[existing * STATUS_STRIDE + 4]) if existing >= 0 else 1})

func remove_source(owner_id: int) -> void:
    # 移除 owner_id 施加的所有状态（召唤物/Boss 死亡时调用，防止游魂状态残留）
    # 详见 implementation_plan.md §2.4 remove_source 实现
    var i := 0
    while i < _active_count:
        if int(_data[i * STATUS_STRIDE + 5]) == owner_id:  # slot +5: owner_id
            # swap-and-pop
            if i < _active_count - 1:
                for k in STATUS_STRIDE:
                    _data[i * STATUS_STRIDE + k] = _data[(_active_count - 1) * STATUS_STRIDE + k]
            _active_count -= 1
        else:
            i += 1

# 查询（EnemyManager.apply_damage() 读取易伤系数）
func get_stacks(entity_id: int, status_type_id: int) -> int:
    var idx := _find(entity_id, status_type_id)
    return int(_data[idx * STATUS_STRIDE + 4]) if idx >= 0 else 0

func has_status(entity_id: int, status_type_id: int) -> bool:
    return _find(entity_id, status_type_id) >= 0

# _physics_process：tick DoT 并倒计时 duration，swap-and-pop 移除过期实例
func _physics_process(delta: float) -> void:
    var i := 0
    while i < _active_count:
        var base := i * STATUS_STRIDE
        _data[base + 2] -= delta              # remaining_duration 倒计时
        _data[base + 3] += delta              # tick_accumulator 累积
        var typedef: StatusTypeDef = StatusRegistry.get(int(_data[base + 1]))
        while _data[base + 3] >= typedef.tick_interval:
            _data[base + 3] -= typedef.tick_interval
            _apply_dot_tick(i, typedef)       # 触发 DoT 伤害
        if _data[base + 2] <= 0.0:
            _swap_and_pop(i)                  # O(1) 移除，顺序可乱
        else:
            i += 1

func reset() -> void:
    _active_count = 0  # 不需要清零数据，_active_count 控制有效范围
```

#### StatusTypeDef 资源格式

```gdscript
# status_type_def.gd - Resource 子类
class_name StatusTypeDef
extends Resource

var display_name: String = ""          # tr() KEY，UI 显示用
var duration: float = 3.0              # 默认持续时长（秒）
var tick_interval: float = 1.0         # DoT 跳字间隔（非 DoT 状态设 999.0 避免误触发）
var stack_mode: String = "refresh"     # "refresh" | "stack" | "ignore"
var max_stacks: int = 99               # 叠层上限（stack 模式）
var dot_damage_per_tick: float = 0.0   # 每 tick 造成的基础伤害（0 = 无 DoT）
var dot_damage_type: int = DamageType.PHYSICAL
var can_catalyze: Array[int] = []      # 可参与催化的状态 ID 列表（空 = 不参与元素反应）
var is_combo_tracker: bool = false     # true = 计数追踪状态（tick 逻辑由调用方控制，不走 DoT 路径）
var vfx_id: String = ""                # 状态持续时的粒子特效 ID（空 = 无）
var icon_key: String = ""              # UI 状态图标的 UIAtlas 键
```

---

### 5.6 PlayerManager 架构设计

`PlayerManager` 是玩家实体的单一数据权威，持有 HP / 金币 / XP / 等级 / 三个 Core / 被动列表。所有系统通过 `PlayerManager` 读写玩家状态，禁止直接操作其内部字段。

#### 玩家状态数据模型

```gdscript
# player_manager.gd (Autoload: PlayerManager)

# ── 生命值 ────────────────────────────────────────────────────
var hp: float = 100.0
var hp_max: float = 100.0
var hp_regen: float = 0.0         # 每秒自动回血（被动加成）

# ── 经济 ──────────────────────────────────────────────────────
var gold: int = 0
var xp: int = 0
var level: int = 1
# XP 升级阈值：⌊10 × 1.4^(level-1)⌋（见 numerical_design.md §1.2）
# level 20 = 最终等级（Wave 20 通关上限）；level 21+ 服务 Endless 模式

# ── 属性词条（被动叠加结果）────────────────────────────────────
var stats: PlayerStats = PlayerStats.new()  # 伤害/攻速/移速等合并后的最终值

# ── 法杖 Core 系统（三 Core 机制）──────────────────────────────
const MAX_CORES: int = 3
var cores: Array[CoreDefinition] = []        # 当前装备的 Core，长度 ≤ MAX_CORES
var active_core_idx: int = 0                # 当前激活 Core 的下标
var _raw_decks: Array[SpellDeck] = []       # 每个 Core 对应的原始 SpellDeck（compile_all_wands 的输入）
var compiled_decks: Array[CompiledDeck] = [] # 每个 Core 的预编译产物（compile_all_wands 的输出）

# ── 消耗品临时持有（当回合商店购买的消耗品，在 WAVE 开始前使用）─────
var _pending_consumables: Array[String] = []  # consumable_id 列表

# ── 被动列表 ──────────────────────────────────────────────────
var passives: Array[String] = []             # 被动 ID 列表（passive_id: String，对应 .tres 文件名）

# ── 位置（View 层只读） ────────────────────────────────────────
var _position: Vector2 = Vector2.ZERO       # 由 CharacterBody2D 每帧同步；其他系统通过 get_position() 读取
func get_position() -> Vector2: return _position
```

#### PlayerStats 合并数据类

```gdscript
# player_stats.gd - 所有被动合并后的最终属性快照（MODIFIER 节点的基准值来源）
class_name PlayerStats
extends RefCounted

var damage_mult: float = 1.0          # 全局伤害乘算（累乘所有 damage% 被动）
var attack_speed_mult: float = 1.0    # 攻速（影响 cast_delay 分母）
var move_speed: float = 200.0         # 像素/秒
var hp_max_bonus: float = 0.0         # HP 上限加成
var hp_regen_bonus: float = 0.0       # 回血加成
var cpu_limit: int = 5                # SpellEvaluator MAX_OPS 的基础乘数
var pickup_radius: float = 60.0       # 拾取半径（DropManager 用）
var crit_chance_bonus: float = 0.0    # 全局暴击率追加（叠加到 CastStats.crit_chance）

func recalculate(passives: Array[String]) -> void:
    # 重置为默认值后依次应用每个被动的 stats_patch
    _reset_defaults()
    for pid in passives:
        var def: PassiveDef = PassiveRegistry.get(pid)
        if def: def.apply_to(self)
```

#### 核心接口

```gdscript
# ── 经济接口 ──────────────────────────────────────────────────
func spend_gold(amount: int) -> bool:
    if gold < amount: return false
    gold -= amount
    return true

func add_gold(amount: int) -> void:
    gold += amount

func add_xp(amount: int) -> void:
    xp += amount
    _check_level_up()   # 达到阈值则 level++，触发 WAVE_RESULT 升级选择流程

func _check_level_up() -> void:
    var threshold: int = int(10.0 * pow(1.4, level - 1))
    while xp >= threshold and level < 21:  # level 21 以上 Endless 不再升级
        xp -= threshold
        level += 1
        threshold = int(10.0 * pow(1.4, level - 1))

# ── 生命值接口 ────────────────────────────────────────────────
func take_damage(amount: float) -> void:
    hp = max(0.0, hp - amount)
    EventBus.emit(EventID.PLAYER_DAMAGED, {"amount": amount, "source_id": -1})
    if hp <= 0.0:
        EventBus.emit(EventID.PLAYER_DIED, {})

func heal(amount: float) -> void:
    hp = min(hp_max, hp + amount)

# ── Core / Deck 管理 ──────────────────────────────────────────
func add_core(core: CoreDefinition, slot: int = -1) -> void:
    if slot < 0: slot = cores.size()
    slot = clamp(slot, 0, MAX_CORES - 1)
    if slot < cores.size():
        cores[slot] = core
    else:
        cores.append(core)
        _raw_decks.append(SpellDeck.new())   # 同步扩容，保持 cores 与 _raw_decks 等长
    compiled_decks.resize(cores.size())

func replace_core(slot: int, core_id: String) -> void:
    # ShopManager 购买 CORE 类商品时调用；以新 Core 替换指定槽，清空该槽的原始 Deck
    if slot < 0 or slot >= MAX_CORES: return
    var core: CoreDefinition = CoreRegistry.get(core_id)
    if core == null: return
    if slot < cores.size():
        cores[slot] = core
        _raw_decks[slot] = SpellDeck.new()   # 换 Core 后重置对应 Deck
    else:
        add_core(core, slot)
    compiled_decks.resize(cores.size())

func add_spell_to_inventory(spell_id: String, target_core_idx: int = -1) -> void:
    var idx := target_core_idx if target_core_idx >= 0 else active_core_idx
    if idx >= cores.size(): return
    _raw_decks[idx].push(SpellRegistry.get(spell_id))

func add_passive(passive_id: String) -> void:
    passives.append(passive_id)
    stats.recalculate(passives)  # 被动变更后立即重算 PlayerStats

func use_consumable(consumable_id: String) -> void:
    # ShopManager 购买 CONSUMABLE 类商品时调用。
    # 消耗品效果立即生效（不延迟到 WAVE）；若需要战斗中生效，加入 _pending_consumables 并在 WAVE_INTRO 处理。
    var def: ConsumableDef = ConsumableRegistry.get(consumable_id)
    if def == null: return
    def.apply(self)  # ConsumableDef.apply(player: PlayerManager) 直接修改玩家状态

# ── 法杖预编译入口（GameCycleManager 在 SHOP→WAVE_INTRO 时调用）────
func compile_all_wands() -> void:
    compiled_decks.resize(cores.size())
    for i in cores.size():
        compiled_decks[i] = SpellEvaluator.compile_wand(cores[i], _raw_decks[i])

func get_compiled_deck(core_idx: int) -> CompiledDeck:
    if core_idx < 0 or core_idx >= compiled_decks.size(): return null
    return compiled_decks[core_idx]

func get_raw_deck(core_idx: int) -> SpellDeck:
    if core_idx < 0 or core_idx >= _raw_decks.size(): return null
    return _raw_decks[core_idx]

func reset() -> void:
    hp = hp_max; gold = 0; xp = 0; level = 1
    cores.clear(); _raw_decks.clear(); compiled_decks.clear(); passives.clear()
    _pending_consumables.clear()
    stats.recalculate([])
    _position = Vector2.ZERO
```

> **compile_all_wands() 调用时机**：GameCycleManager 的 `SHOP → WAVE_INTRO` 转换回调中调用 `PlayerManager.compile_all_wands()`，而非直接调用 `SpellEvaluator`。这样 PlayerManager 保持对三个 Core 的迭代责任，SpellEvaluator 只负责单个 `compile_wand()` 逻辑。

---

### 5.7 DropManager 框架设计

`DropManager` 订阅 `EventID.ENEMY_KILLED`，负责在敌人位置生成掉落物并处理玩家拾取。

#### 掉落表配置格式

```json
// res://resources/drops/enemy_drops.json
{
  "slime_basic": {
    "xp": { "min": 2, "max": 4 },
    "gold": { "min": 0, "max": 2, "chance": 0.3 },
    "spell_drop": { "chance": 0.02, "rarity_max": 1 }
  },
  "bat_fast": {
    "xp": { "min": 3, "max": 5 },
    "gold": { "min": 1, "max": 3, "chance": 0.4 },
    "spell_drop": { "chance": 0.03, "rarity_max": 2 }
  },
  "elite": {
    "xp": { "min": 15, "max": 25 },
    "gold": { "min": 8, "max": 15, "chance": 1.0 },
    "spell_drop": { "chance": 0.20, "rarity_max": 3 }
  },
  "_default": {
    "xp": { "min": 2, "max": 3 },
    "gold": { "min": 0, "max": 1, "chance": 0.2 },
    "spell_drop": { "chance": 0.01, "rarity_max": 1 }
  }
}
```

#### DropManager 核心逻辑

```gdscript
# drop_manager.gd (Autoload: DropManager)

# 掉落物在世界空间以 Dictionary 形式存储（无 Node 开销，轻量化）
# { "type": "xp"|"gold"|"spell", "value": Variant, "position": Vector2, "lifetime": float }
var _drops: Array[Dictionary] = []
var _drop_table: Dictionary = {}  # 启动时从 JSON 加载

func _ready() -> void:
    var raw: String = FileAccess.open("res://resources/drops/enemy_drops.json", FileAccess.READ).get_as_text()
    _drop_table = JSON.parse_string(raw)
    EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)

func _on_enemy_killed(payload: Dictionary) -> void:
    var enemy_id: int = payload.get("enemy_id", -1)
    var pos: Vector2 = EnemyManager.get_last_position(enemy_id)  # 死亡时缓存的位置
    var enemy_type: String = EnemyManager.get_type(enemy_id)
    var table: Dictionary = _drop_table.get(enemy_type, _drop_table.get("_default", {}))
    _spawn_drops(pos, table)

func _spawn_drops(pos: Vector2, table: Dictionary) -> void:
    # XP（必定掉落）
    var xp_cfg: Dictionary = table.get("xp", {})
    var xp_val: int = randi_range(xp_cfg.get("min", 1), xp_cfg.get("max", 2))
    _drops.append({"type": "xp", "value": xp_val, "position": pos, "lifetime": 8.0})

    # 金币（概率）
    var gold_cfg: Dictionary = table.get("gold", {})
    if randf() < gold_cfg.get("chance", 0.0):
        var g: int = randi_range(gold_cfg.get("min", 1), gold_cfg.get("max", 1))
        _drops.append({"type": "gold", "value": g, "position": pos, "lifetime": 10.0})

    # 法术卡掉落（低概率）
    var spell_cfg: Dictionary = table.get("spell_drop", {})
    if randf() < spell_cfg.get("chance", 0.0):
        var max_rarity: int = spell_cfg.get("rarity_max", 1)
        var spell_id: String = SpellRegistry.random_by_rarity(max_rarity)
        if spell_id != "":
            _drops.append({"type": "spell", "value": spell_id, "position": pos, "lifetime": 15.0})

func _physics_process(delta: float) -> void:
    if GameCycleManager.get_state() != GameCycleManager.GameState.WAVE: return
    var player_pos := PlayerManager.get_position()
    var pickup_r := PlayerManager.stats.pickup_radius
    var i := 0
    while i < _drops.size():
        var drop := _drops[i]
        drop["lifetime"] -= delta
        # 拾取检测：圆形距离
        if (player_pos - drop["position"]).length_squared() <= pickup_r * pickup_r:
            _collect(drop)
            _drops.remove_at(i)
        elif drop["lifetime"] <= 0.0:
            _drops.remove_at(i)  # 超时消失（swap-and-pop 可改，当前掉落数量极少，remove_at 可接受）
        else:
            i += 1

func _collect(drop: Dictionary) -> void:
    match drop["type"]:
        "xp":    PlayerManager.add_xp(drop["value"])
        "gold":  PlayerManager.add_gold(drop["value"])
        "spell": ShopManager.offer_free_spell(drop["value"])  # 弹出"拾取法术"UI提示
```

---

### 5.8 UpgradeSystem 框架设计

每波结算后，玩家从随机 3 张升级词条中选择 1 张。`UpgradeSystem` 管理词条池构建、展示、应用。

#### 升级词条资源格式（UpgradeDef .tres）

```gdscript
# upgrade_def.gd - Resource 子类
class_name UpgradeDef
extends Resource

var upgrade_id: String = ""           # 全局唯一，格式：category_effect（如 "offense_damage_plus"）
var display_name: String = ""         # tr() KEY
var description: String = ""         # tr() KEY，支持 {value} 占位符
var icon_key: String = ""             # UIAtlas 图标键
var rarity: int = 1                   # 1=Common, 2=Uncommon, 3=Rare, 4=Legendary
var max_stack: int = 1                # 同一词条最多叠取次数（1=不可重复，99=无限）
var value: float = 0.0                # 效果数值（供 description {value} 和 apply() 使用）

# 效果函数：直接修改 PlayerStats 或 PlayerManager 状态
func apply(player: PlayerManager) -> void:
    pass  # 子类重写，例：player.stats.damage_mult *= 1.1
```

#### UpgradeSystem 核心逻辑

```gdscript
# upgrade_system.gd (Autoload: UpgradeSystem)

const CHOICES_COUNT: int = 3           # 每次展示 3 张词条

var _upgrade_pool: Array[UpgradeDef] = []     # 启动时从 res://resources/upgrades/ 扫描加载
var _taken_counts: Dictionary = {}            # { upgrade_id: int } 本次 Run 已取次数（reset 时清零）

func _ready() -> void:
    _scan_upgrades()

func _scan_upgrades() -> void:
    _upgrade_pool.clear()
    var dir := DirAccess.open("res://resources/upgrades/")
    if dir:
        dir.list_dir_begin()
        var fname := dir.get_next()
        while fname != "":
            if fname.ends_with(".tres"):
                var def: UpgradeDef = load("res://resources/upgrades/" + fname)
                if def and def.upgrade_id != "": _upgrade_pool.append(def)
            fname = dir.get_next()

func build_choices(wave_num: int, rng: RandomNumberGenerator) -> Array[UpgradeDef]:
    var weights := _rarity_weights(wave_num)
    var available := _pool_filtered()   # 过滤已达 max_stack 的词条
    var result: Array[UpgradeDef] = []
    var seen_ids: Array[String] = []
    for _i in CHOICES_COUNT:
        var pick := _weighted_pick(available, weights, rng, seen_ids)
        if pick:
            result.append(pick)
            seen_ids.append(pick.upgrade_id)
    return result

func apply_choice(upgrade: UpgradeDef) -> void:
    upgrade.apply(PlayerManager)
    _taken_counts[upgrade.upgrade_id] = _taken_counts.get(upgrade.upgrade_id, 0) + 1
    SpellEvaluator.update_max_ops(PlayerManager.stats.cpu_limit)

func _pool_filtered() -> Array[UpgradeDef]:
    # 过滤已达 max_stack 的词条（不再可选）
    var result: Array[UpgradeDef] = []
    for def in _upgrade_pool:
        if _taken_counts.get(def.upgrade_id, 0) < def.max_stack:
            result.append(def)
    return result

func _weighted_pick(pool: Array[UpgradeDef], weights: Array[float],
                    rng: RandomNumberGenerator, exclude: Array[String]) -> UpgradeDef:
    # 按稀有度权重从 pool 中加权随机选一条词条（排除 exclude 中的 ID）
    var total := 0.0
    for def in pool:
        if def.upgrade_id in exclude: continue
        total += weights[clamp(def.rarity - 1, 0, 3)]
    if total <= 0.0: return null
    var roll := rng.randf() * total
    for def in pool:
        if def.upgrade_id in exclude: continue
        roll -= weights[clamp(def.rarity - 1, 0, 3)]
        if roll <= 0.0: return def
    return pool[-1] if not pool.is_empty() else null

func _rarity_weights(wave_num: int) -> Array[float]:
    var common_w    := max(5.0,  60.0 - wave_num * 2.5)
    var uncommon_w  := min(50.0, 30.0 + wave_num * 1.5)
    var rare_w      := min(30.0, max(5.0, wave_num * 1.2 - 5.0))
    var legendary_w := min(10.0, max(0.0, wave_num * 0.5 - 8.0))
    return [common_w, uncommon_w, rare_w, legendary_w]

func reset() -> void:
    _taken_counts.clear()
```

> **GameCycleManager 集成**：`WAVE_RESULT` 状态的 `_on_enter()` 中调用 `UpgradeSystem.build_choices(wave_num, _rng)` 获取词条列表，UIManager 展示 3 张升级卡；玩家选择后调用 `UpgradeSystem.apply_choice(selected)` → 直接返回 `GameCycleManager.transition_to(SHOP)`。

---

### 5.9 BossManager 架构设计

Boss 是具有多阶段 HP 和特殊行为的特殊敌人。`BossManager` 是 `EnemyManager` 的协调层，不持有独立的 SoA 数据，而是通过 EnemyManager 的接口操作 Boss 实体并监听阶段切换。

#### BossManager 与 EnemyManager 的关系

| 职责 | 归属 | 理由 |
| :--- | :--- | :--- |
| Boss 物理位置 / Boid 运动 | `EnemyManager` SoA | Boss 也是"敌人"，共享同一物理系统 |
| Boss HP 存储 | `EnemyManager._enemy_data` | EnemyManager 统一管理存活/死亡状态 |
| 阶段切换逻辑 / 特殊能力触发 | `BossManager` | 阶段是 Boss 专有行为，不属于通用敌人 |
| Boss 碰撞体（多区域） | `BossManager` 管理多个 Area2D / 自定义 AABB | Boss 半径可达 200px，超过 SpatialGrid 标准弹体阈值，走 `EnemyManager.query_aabb(boss_aabb)` |

#### BossManager 核心设计

```gdscript
# boss_manager.gd (Autoload: BossManager)

# Boss 阶段定义（每个 Boss 的阶段配置在其对应 JSON 中定义）
# boss_design.md §6 详细描述各 Boss 行为，BossManager 仅处理通用框架
var _boss_entity_id: int = -1            # 当前 Boss 在 EnemyManager 中的 entity_id（-1 = 无 Boss）
var _current_phase: int = 0
var _phase_thresholds: Array[float] = [] # HP 百分比阈值（降序），如 [1.0, 0.6, 0.3]
var _is_active: bool = false
var _boss_size_cache: Dictionary = {}    # spawn_boss 时缓存的 Boss 配置（含 collision_radius）

# WaveManager 在 is_boss_wave=true 时调用
func spawn_boss(boss_id: String, position: Vector2) -> void:
    _boss_entity_id = EnemyManager.spawn(boss_id, position)
    var cfg: Dictionary = _load_boss_config(boss_id)
    _boss_size_cache = cfg
    _phase_thresholds = cfg.get("phase_hp_thresholds", [1.0, 0.6, 0.3])
    _current_phase = 0
    _is_active = true
    EventBus.emit(EventID.BOSS_PHASE_CHANGED, {
        "boss_id": boss_id, "phase": 0, "bgm_layer": "boss"
    })

func _physics_process(_delta: float) -> void:
    if not _is_active or _boss_entity_id < 0: return
    var hp_pct: float = EnemyManager.get_hp_percent(_boss_entity_id)
    # 检测阶段切换（阈值降序，phase 0→1→2）
    var next_phase := _current_phase
    for i in range(_current_phase + 1, _phase_thresholds.size()):
        if hp_pct <= _phase_thresholds[i]:
            next_phase = i
    if next_phase != _current_phase:
        _current_phase = next_phase
        _on_phase_changed(_current_phase)

func _on_phase_changed(phase: int) -> void:
    EventBus.emit(EventID.BOSS_PHASE_CHANGED, {
        "boss_id": EnemyManager.get_type(_boss_entity_id),
        "phase": phase, "bgm_layer": "boss_phase_%d" % phase
    })
    # 触发特殊技能（如护盾、新 spawn pattern、移速变化）
    # 具体行为由 boss_design.md §6 的各 Boss 脚本重写 _on_phase_enter(phase)

func _on_enemy_killed(payload: Dictionary) -> void:
    if payload.get("enemy_id", -1) != _boss_entity_id: return
    _is_active = false
    _boss_entity_id = -1
    EventBus.emit(EventID.BOSS_KILLED, {
        "boss_id": EnemyManager.get_type(payload["enemy_id"]),
        "wave_num": WaveManager.wave_num
    })
    # WaveManager 订阅 BOSS_KILLED 后发出 WAVE_COMPLETE（kill_boss clear_condition）

func reset() -> void:
    _boss_entity_id = -1; _current_phase = 0; _is_active = false
    _phase_thresholds.clear()

# ── 只读查询接口（BulletManager 碰撞检测 + UIManager Boss 血条）────
func is_active() -> bool: return _is_active
func get_boss_entity_id() -> int: return _boss_entity_id

func get_collision_rect() -> Rect2:
    # Boss 碰撞 AABB（BulletManager 使用，用于超大碰撞体绕过 SpatialGrid）
    if not _is_active: return Rect2()
    var pos := EnemyManager.get_last_position(_boss_entity_id)  # Boss 仍存活时 get_last_position 返回当前位置
    var cfg: Dictionary = _boss_size_cache    # spawn_boss 时从 _load_boss_config 读取 "collision_radius"
    var r: float = cfg.get("collision_radius", 80.0)
    return Rect2(pos.x - r, pos.y - r, r * 2, r * 2)

func _load_boss_config(boss_id: String) -> Dictionary:
    # 从 res://resources/bosses/{boss_id}.json 加载 Boss 配置（同步加载，spawn 时调用一次）
    var path := "res://resources/bosses/%s.json" % boss_id
    if not FileAccess.file_exists(path):
        push_warning("BossManager: boss config not found: %s" % path)
        return {}
    var raw: String = FileAccess.open(path, FileAccess.READ).get_as_text()
    return JSON.parse_string(raw)
    # 配置字段（规范）：
    # { "phase_hp_thresholds": [1.0, 0.6, 0.3],
    #   "collision_radius": 80.0,
    #   "spawn_pattern": "center",
    #   "phase_actions": { "1": "spawn_minions", "2": "enrage" } }
```

#### Boss 碰撞策略

Boss 半径通常 > 64px（超过 `LARGE_PROJECTILE_THRESHOLD`），使用与"超大弹体豁免"对称的策略：

```gdscript
# BulletManager 子弹命中检测（_physics_process 中，SpatialGrid 路径）
# 超大碰撞体（Boss）同样绕过 SpatialGrid，改用 Boss 自定义 AABB 全量测试
if BossManager.is_active():
    var boss_aabb: Rect2 = BossManager.get_collision_rect()
    for i in _active_count:
        var bx := _data[i * BULLET_STRIDE]; var by := _data[i * BULLET_STRIDE + 1]
        if boss_aabb.has_point(Vector2(bx, by)):
            _on_bullet_hit(i, BossManager.get_boss_entity_id())
```

---

### 5.10 UIManager 架构设计

`UIManager` 是 UI 系统的唯一协调者，订阅 `GameCycleManager` 的状态变化，驱动各 UI 场景的显示/隐藏。业务逻辑（金币计算、法术编译）由各业务 Manager 负责；UIManager 只负责**路由**（什么状态显示什么界面）和 **View 刷新**（将数据渲染到 Control 节点）。

#### 职责边界

| 职责 | 归属 | 理由 |
| :--- | :--- | :--- |
| 游戏状态判断（什么时候进入商店）| `GameCycleManager` | 状态机唯一持有状态 |
| UI 场景切换（show/hide CanvasLayer 子场景）| `UIManager` | UI 协调者唯一职责 |
| 数据计算（价格/稀有度/词条效果）| `ShopManager` / `UpgradeSystem` | 与 UI 无关，可独立测试 |
| Control 节点刷新（Label.text / ProgressBar.value）| UIManager 各子函数 | 表现层 |
| 玩家操作确认（点击购买/选择词条）| UIManager → 调用对应 Manager 接口 | 控制流 |

#### UIManager 数据模型

```gdscript
# ui_manager.gd (Autoload: UIManager)

# ── 各 UI 场景实例（懒加载，首次显示时实例化）────────────────────
var _shop_ui: Control = null          # res://scenes/ui/ShopUI.tscn
var _inventory_ui: Control = null     # res://scenes/ui/InventoryUI.tscn
var _upgrade_ui: Control = null       # res://scenes/ui/UpgradeChoiceUI.tscn
var _hud: Control = null              # res://scenes/ui/HUD.tscn（BattleScene 挂载时创建）
var _main_menu: Control = null        # res://scenes/ui/MainMenuUI.tscn
var _game_over: Control = null        # res://scenes/ui/GameOverUI.tscn
var _pause_menu: Control = null       # res://scenes/ui/PauseMenuUI.tscn

# ── RNG（用于 UpgradeSystem.build_choices 的确定性种子）──────────
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
# _rng.seed 在每次进入 WAVE_RESULT 状态时重置（保持波次间确定性）

# ── 伤害数字池 ──────────────────────────────────────────────────
const MAX_DAMAGE_NUMBERS: int = 30
var _dmg_num_pool: Array[Label] = []  # 飘字 Label 池（重复利用，减少节点创建）
var _dmg_num_active: int = 0

func _ready() -> void:
    EventBus.subscribe(EventID.GAME_STATE_CHANGED, _on_state_changed)
    EventBus.subscribe(EventID.PLAYER_DAMAGED,     _on_player_damaged)
    EventBus.subscribe(EventID.ENEMY_KILLED,       _on_enemy_killed)
    EventBus.subscribe(EventID.SPELL_DROP_PICKUP,  _on_spell_drop_pickup)
    # 预分配伤害数字池
    for i in MAX_DAMAGE_NUMBERS:
        var lbl := Label.new()
        lbl.visible = false
        add_child(lbl)
        _dmg_num_pool.append(lbl)
```

#### 状态→UI 路由表

`GameCycleManager` 在每次 `transition_to(next)` 末尾发送 `EventID.GAME_STATE_CHANGED`（payload: `{prev, next}`），UIManager 的 `_on_state_changed` 根据下表切换：

| GameState | 显示的 UI | 隐藏的 UI | 附加操作 |
| :--- | :--- | :--- | :--- |
| `MAIN_MENU` | MainMenuUI | 其他全部 | 停止 HUD |
| `LOADING` | 无（过渡遮罩由 GameCycleManager 驱动）| 其他 | — |
| `SHOP` | ShopUI | HUD, InventoryUI | `_refresh_shop_ui()` |
| `WAVE_INTRO` | HUD（波次预告）| ShopUI, UpgradeUI | `_show_wave_intro_banner()` |
| `WAVE` | HUD | 其他 | 激活伤害数字池 |
| `WAVE_RESULT` | UpgradeChoiceUI（3 张词条）| HUD | `_refresh_upgrade_choices()` |
| `GAME_OVER` | GameOverUI | 其他 | 显示分数/波次 |
| `GAME_CLEARED` | GameOverUI（通关变体）| 其他 | 显示 Endless 入口 |
| `PAUSE` | PauseMenuUI（叠加当前 UI）| — | `get_tree().paused = true` |

```gdscript
func _on_state_changed(payload: Dictionary) -> void:
    var next: int = payload.get("next", -1)
    _hide_all_ui()
    match next:
        GameCycleManager.GameState.MAIN_MENU:   _show(_main_menu,  "res://scenes/ui/MainMenuUI.tscn")
        GameCycleManager.GameState.SHOP:
            _show(_shop_ui, "res://scenes/ui/ShopUI.tscn")
            _refresh_shop_ui()
        GameCycleManager.GameState.WAVE_INTRO:  _show(_hud, "res://scenes/ui/HUD.tscn"); _show_wave_intro_banner()
        GameCycleManager.GameState.WAVE:        _show(_hud, "res://scenes/ui/HUD.tscn")
        GameCycleManager.GameState.WAVE_RESULT:
            _show(_upgrade_ui, "res://scenes/ui/UpgradeChoiceUI.tscn")
            _refresh_upgrade_choices()
        GameCycleManager.GameState.GAME_OVER,\
        GameCycleManager.GameState.GAME_CLEARED:
            _show(_game_over, "res://scenes/ui/GameOverUI.tscn")
        GameCycleManager.GameState.PAUSE:
            _show(_pause_menu, "res://scenes/ui/PauseMenuUI.tscn")
            get_tree().paused = true

func _show(ref: Control, scene_path: String) -> Control:
    if ref == null:
        ref = load(scene_path).instantiate()
        add_child(ref)   # UIManager Autoload 自身作为父节点（跨场景持久）
    ref.visible = true
    return ref

func _hide_all_ui() -> void:
    for ui in [_shop_ui, _inventory_ui, _upgrade_ui, _hud, _main_menu, _game_over, _pause_menu]:
        if ui: ui.visible = false
    get_tree().paused = false  # 确保 PAUSE 状态退出时解除暂停
```

#### 伤害数字池接口

```gdscript
func show_damage_number(amount: float, position: Vector2, is_crit: bool = false) -> void:
    # 每帧最多弹出 10 个（帧节流由调用方 BulletManager 批处理）
    var lbl := _acquire_dmg_label()
    if lbl == null: return
    lbl.text = str(int(amount)) + ("!" if is_crit else "")
    lbl.modulate = Color.RED if is_crit else Color.WHITE
    lbl.global_position = position
    lbl.visible = true
    # 0.6s 飘字动画后归还池（使用 Tween，避免 Timer 节点堆叠）
    var tween := create_tween()
    tween.tween_property(lbl, "global_position", position + Vector2(0, -40), 0.6)
    tween.parallel().tween_property(lbl, "modulate:a", 0.0, 0.6)
    tween.tween_callback(func(): lbl.visible = false; _dmg_num_active -= 1)

func _acquire_dmg_label() -> Label:
    if _dmg_num_active >= MAX_DAMAGE_NUMBERS: return null
    for lbl in _dmg_num_pool:
        if not lbl.visible:
            _dmg_num_active += 1
            return lbl
    return null
```

#### ShopUI 刷新协议

```gdscript
func _refresh_shop_ui() -> void:
    # ShopManager.get_slots() 返回当前 SLOT_COUNT=6 个商品 Dictionary
    var slots: Array = ShopManager.get_slots()
    _shop_ui.refresh(slots, PlayerManager.gold, WaveManager.wave_num)
    # ShopUI.refresh() 是 Control 层函数，仅做 Label/Icon 更新，无业务逻辑

func _refresh_upgrade_choices() -> void:
    var choices: Array[UpgradeDef] = UpgradeSystem.build_choices(WaveManager.wave_num, _rng)
    _upgrade_ui.show_choices(choices)  # UpgradeChoiceUI 展示 3 张卡

func _on_upgrade_selected(upgrade: UpgradeDef) -> void:
    # UpgradeChoiceUI 发送信号，UIManager 接收后调用业务层
    UpgradeSystem.apply_choice(upgrade)
    GameCycleManager.transition_to(GameCycleManager.GameState.SHOP)

func _on_spell_drop_pickup(payload: Dictionary) -> void:
    # DropManager 拾取法术卡后通过 EventBus 通知，UIManager 弹出提示
    var spell_id: String = payload.get("spell_id", "")
    var auto_equip: bool = payload.get("auto_equip", false)
    if auto_equip:
        PlayerManager.add_spell_to_inventory(spell_id)
    else:
        # 弹出"装备到哪个Core？"选择框
        _show_spell_pickup_dialog(spell_id)
```

---

### 5.11 PassiveDef + PassiveRegistry 设计

被动词条是 Roguelite 构建深度的核心扩展机制。`PassiveRegistry` 与 `SpellRegistry` 采用对称设计，启动时从资源目录扫描加载。

#### PassiveDef Resource 格式

```gdscript
# passive_def.gd - Resource 子类
class_name PassiveDef
extends Resource

var passive_id: String = ""           # 全局唯一，格式：category_stat（如 "offense_damage_mult"）
var display_name: String = ""         # tr() KEY，UI 显示用
var description: String = ""         # tr() KEY，支持 {value} 占位符（如 "+{value}% 伤害"）
var icon_key: String = ""             # UIAtlas 图标键（ADR-C1：必须有对应形状标识）
var rarity: int = 1                   # 1=Common, 2=Uncommon, 3=Rare, 4=Legendary
var max_stack: int = 1                # 同一被动最多叠取次数（1=不可重复购买，99=无限）
var value: float = 0.0                # 效果数值（description {value} 占位符的填充值）

# ── 效果：修改 PlayerStats 的规则 ───────────────────────────────
# 支持三种效果模式（enum 选一）：
enum StatPatchMode { ADD, MULTIPLY, OVERRIDE }

var stat_field: String = ""           # PlayerStats 中被修改的字段名（如 "damage_mult"）
var patch_mode: StatPatchMode = StatPatchMode.MULTIPLY
var patch_value: float = 1.0          # ADD: +patch_value；MULTIPLY: *patch_value；OVERRIDE: =patch_value

func apply_to(stats: PlayerStats) -> void:
    if stat_field == "": return
    var current: float = stats.get(stat_field)
    match patch_mode:
        StatPatchMode.ADD:      stats.set(stat_field, current + patch_value)
        StatPatchMode.MULTIPLY: stats.set(stat_field, current * patch_value)
        StatPatchMode.OVERRIDE: stats.set(stat_field, patch_value)
```

> **设计约束**：`apply_to()` 只修改 `PlayerStats` 中的 float 字段，不直接修改 `PlayerManager` 的 HP/Gold/XP（防止被动产生经济副作用）。需要修改资源上限（如 `hp_max_bonus`）的被动，通过 `stats.hp_max_bonus` 间接影响，`PlayerManager` 在 `recalculate()` 后重算 `hp_max = 100.0 + stats.hp_max_bonus`。

#### PassiveRegistry Autoload

```gdscript
# passive_registry.gd (Autoload: PassiveRegistry)
var _registry: Dictionary = {}               # { passive_id: PassiveDef }
var _by_rarity: Array[Array] = [[], [], [], []]  # 与 SpellRegistry 对称

func _ready() -> void:
    var dir := DirAccess.open("res://resources/passives/")
    if dir:
        dir.list_dir_begin()
        var fname := dir.get_next()
        while fname != "":
            if fname.ends_with(".tres"):
                var def: PassiveDef = load("res://resources/passives/" + fname)
                if def and def.passive_id != "":
                    _registry[def.passive_id] = def
                    _by_rarity[clamp(def.rarity - 1, 0, 3)].append(def.passive_id)
            fname = dir.get_next()

func get(passive_id: String) -> PassiveDef:
    return _registry.get(passive_id, null)

func has(passive_id: String) -> bool:
    return _registry.has(passive_id)

func all() -> Array:
    return _registry.values()  # Array[PassiveDef]（ShopManager 构建权重池用）
```

#### 被动词条设计示例（资源文件）

```gdscript
# res://resources/passives/offense_damage_mult_10pct.tres
passive_id   = "offense_damage_mult_10pct"
display_name = "PASSIVE_DAMAGE_MULT_NAME"       # tr() 键
description  = "PASSIVE_DAMAGE_MULT_DESC"       # tr() → "全局伤害 +{value}%"
icon_key     = "icon_sword_up"
rarity       = 1                                # Common
max_stack    = 5                                # 最多叠取 5 次（最终 damage_mult = 1.1^5 ≈ 1.61）
value        = 10.0                             # 描述占位符：+10%
stat_field   = "damage_mult"
patch_mode   = StatPatchMode.MULTIPLY
patch_value  = 1.1                              # 每次叠取：damage_mult *= 1.1
```

---

### 5.11.A SpellRegistry 接口定义

`SpellRegistry` 是纯只读 Autoload，启动时扫描 `res://resources/spells/` 并加载所有 `SpellNode .tres` 文件，之后提供 O(1) / O(稀有度桶) 查询。

```gdscript
# spell_registry.gd (Autoload: SpellRegistry)
var _registry: Dictionary = {}              # { spell_id: SpellNode }
var _by_rarity: Array[Array] = [[], [], [], []]  # index = rarity-1，每组为 Array[String](spell_id列表)

func _ready() -> void:
    var dir := DirAccess.open("res://resources/spells/")
    if dir:
        dir.list_dir_begin()
        var fname := dir.get_next()
        while fname != "":
            if fname.ends_with(".tres"):
                var node: SpellNode = load("res://resources/spells/" + fname)
                if node and node.spell_id != "":
                    _registry[node.spell_id] = node
                    var ri := clamp(node.rarity - 1, 0, 3)
                    _by_rarity[ri].append(node.spell_id)
            fname = dir.get_next()

func get(spell_id: String) -> SpellNode:
    return _registry.get(spell_id, null)

func has(spell_id: String) -> bool:
    return _registry.has(spell_id)

func all() -> Array:
    return _registry.values()   # Array[SpellNode]（用于 ShopManager 构建权重池）

func random_by_rarity(max_rarity: int, rng: RandomNumberGenerator = null) -> String:
    # DropManager 使用：从稀有度 1~max_rarity 的所有法术中随机选一个 ID
    # rng 为 null 时使用全局随机（掉落不需要确定性种子）
    var pool: Array[String] = []
    for r in range(min(max_rarity, 4)):
        pool.append_array(_by_rarity[r])
    if pool.is_empty(): return ""
    if rng: return pool[rng.randi() % pool.size()]
    return pool[randi() % pool.size()]
```

---

### 5.14 ProfileManager / SaveSystem 框架设计

`ProfileManager` 是持久化层的统一入口，负责 **Run 运行时存档** 和 **全局设置/统计** 的读写。权威实现详见 `implementation_plan.md §2.5.E` 和 `ADR-A2`，本节为接口契约。

#### 职责边界
- **负责**：JSON 文件读写（A/B 双槽写、CRC 校验）、schema 版本迁移、Run 状态持久化、全局设置持久化
- **不负责**：游戏逻辑、排行榜 HMAC（EndlessRecordsManager 负责）

```gdscript
# profile_manager.gd (Autoload: ProfileManager)
# Run 存档：user://run_a.json（A槽）+ user://run_b.json（B槽）（权威来源：ADR-A2、certification_checklist.md ST-51）
# 全局设置：user://save_data.json（音量/无障碍等，非 Run 数据）
# schema_version 必须与 ADR-A2 中的 _migrate 链同步

const SCHEMA_VERSION: int = 1  # 每次 save 结构变更时递增

# ── 全局持久化（设置/统计，非 Run）────────────────────────────────
func get_float(key: String, default_val: float = 0.0) -> float:
    return _global.get(key, default_val)
func set_float(key: String, value: float) -> void:
    _global[key] = value; _dirty = true
func get_int(key: String, default_val: int = 0) -> int:
    return int(_global.get(key, default_val))
func set_int(key: String, value: int) -> void:
    _global[key] = value; _dirty = true

# ── Run 状态存档（ADR-A2 §2 波次结束自动存档）────────────────────
func save_run(run_data: Dictionary) -> void:
    # A/B 双槽交替写入（ADR-A2 防断电损坏协议）
    run_data["schema_version"] = SCHEMA_VERSION
    var which := get_int("run_write_slot", 0)
    var path_a := "user://run_a.json"; var path_b := "user://run_b.json"
    _write_json(path_a if which == 0 else path_b, run_data)
    set_int("run_write_slot", 1 - which)

func load_run() -> Dictionary:
    # 优先读 A 槽；A 槽损坏则降级读 B 槽；均损坏则返回 {}（GameCycleManager 展示损坏提示）
    var data := _read_json("user://run_a.json")
    if data.is_empty():
        data = _read_json("user://run_b.json")
    if not data.is_empty():
        data = _migrate(data)
    return data

func has_run() -> bool:
    # GameCycleManager 启动时查询是否有未完成的 Run
    return not load_run().is_empty()

func clear_run() -> void:
    # Run 结束（通关/死亡）后调用，删除 Run 存档（保留全局设置/统计）
    _write_json("user://run_a.json", {})
    _write_json("user://run_b.json", {})

func get_run_seed() -> int:
    # ShopManager 确定性种子（新 Run 开始时写入，存档后恢复）
    return get_int("run_seed", 0)

func get_run_count() -> int:
    return get_int("run_count", 0)

func flush() -> void:
    # 保存全局设置（音量、无障碍等）→ user://save_data.json（与 Run 存档 run_a/b.json 分离）
    # 由 SettingsManager.save_audio_setting 触发或 App 退出时调用
    if _dirty: _write_json("user://save_data.json", _global); _dirty = false

func _migrate(data: Dictionary) -> Dictionary:
    # ADR-A2 §3：链式迁移，v0→v1→v2...
    var v: int = data.get("schema_version", 0)
    if v < 1: data = _migrate_v0_to_v1(data)
    return data
```

---

### 5.15 SettingsManager 框架设计

`SettingsManager` 是游戏设置的运行时接口，持久化委托给 `ProfileManager.flush()`。

```gdscript
# settings_manager.gd (Autoload: SettingsManager)
# 依赖：ProfileManager（持久化）、AudioBusID（音量），EventBus（SETTINGS_CHANGED 通知）

func _ready() -> void:
    load_audio_settings()         # 恢复持久化音量
    _apply_accessibility_settings() # 高对比度、字体缩放、减少闪烁

# ── 音量（ADR-A3.4）────────────────────────────────────────────────
# 见 ADR-A3.4 伪代码（load_audio_settings / save_audio_setting）

# ── 无障碍功能（ADR-C1）───────────────────────────────────────────
func set_colorblind_mode(mode: String) -> void:
    # mode: "normal" / "protanopia" / "deuteranopia"（见 ADR-C1.1）
    ProfileManager.set_int("colorblind_mode", ["normal","protanopia","deuteranopia"].find(mode))
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "colorblind_mode" })

func set_font_scale(scale: float) -> void:
    ProfileManager.set_float("font_scale", clampf(scale, 0.8, 1.5))
    _apply_font_scale()
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" })

func set_reduce_flash(enabled: bool) -> void:
    ProfileManager.set_int("reduce_flash", int(enabled))
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "reduce_flash" })

func set_high_contrast(enabled: bool) -> void:
    ProfileManager.set_int("high_contrast", int(enabled))
    _apply_high_contrast()
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "high_contrast" })

func apply_settings() -> void:
    # UIManager 在 SettingsUI 打开时调用，刷新所有设置为持久化值
    load_audio_settings()
    _apply_accessibility_settings()

func get_volume(save_key: String) -> float:
    # UIManager 音量滑块初始化时调用
    return ProfileManager.get_float(save_key, 0.0)

func _apply_accessibility_settings() -> void:
    _apply_font_scale()
    _apply_high_contrast()

func _apply_font_scale() -> void:
    var scale: float = ProfileManager.get_float("font_scale", 1.0)
    # 遍历所有 Label / RichTextLabel 节点并设置 theme_override_font_sizes
    pass  # 见 ADR-C1.3

func _apply_high_contrast() -> void:
    pass  # 切换 CanvasItem 材质 Shader uniform；见 ADR-C1.4
```

---

### 5.11.B CoreRegistry + ConsumableRegistry + ConsumableDef

`CoreRegistry` 和 `ConsumableRegistry` 与 `SpellRegistry` / `PassiveRegistry` 采用对称模式，补全 `PlayerManager.replace_core()` 和 `use_consumable()` 的依赖链。

```gdscript
# ── core_registry.gd (Autoload: CoreRegistry) ──────────────────────────────
# 扫描 res://resources/cores/ 目录，加载所有 CoreDefinition .tres 文件
var _registry: Dictionary = {}  # { core_id: CoreDefinition }

func _ready() -> void:
    var dir := DirAccess.open("res://resources/cores/")
    if dir:
        dir.list_dir_begin()
        var fname := dir.get_next()
        while fname != "":
            if fname.ends_with(".tres"):
                var def: CoreDefinition = load("res://resources/cores/" + fname)
                if def and def.core_id != "": _registry[def.core_id] = def
            fname = dir.get_next()

func get(core_id: String) -> CoreDefinition: return _registry.get(core_id, null)
func has(core_id: String) -> bool:           return _registry.has(core_id)
func all() -> Array:                         return _registry.values()

# ── consumable_def.gd ───────────────────────────────────────────────────────
class_name ConsumableDef
extends Resource

var consumable_id: String = ""     # 全局唯一，格式：consumable_effect（如 "hp_potion_50"）
var display_name:  String = ""     # tr() KEY
var description:   String = ""     # tr() KEY，支持 {value} 占位符
var icon_key:      String = ""     # UIAtlas 图标键
var price:         int    = 8      # 商店默认价格（金币）
var value:         float  = 0.0    # 效果数值

func apply(player: PlayerManager) -> void:
    pass   # 子类重写，或通过 effect_type + value 驱动（参考 PassiveDef.apply_to 模式）
    # 示例子类：HP 药水 → player.heal(value)；速度水 → player.stats.move_speed += value（本回合）

# ── consumable_registry.gd (Autoload: ConsumableRegistry) ──────────────────
var _registry: Dictionary = {}  # { consumable_id: ConsumableDef }

func _ready() -> void:
    var dir := DirAccess.open("res://resources/consumables/")
    if dir:
        dir.list_dir_begin()
        var fname := dir.get_next()
        while fname != "":
            if fname.ends_with(".tres"):
                var def: ConsumableDef = load("res://resources/consumables/" + fname)
                if def and def.consumable_id != "": _registry[def.consumable_id] = def
            fname = dir.get_next()

func get(consumable_id: String) -> ConsumableDef: return _registry.get(consumable_id, null)
func all() -> Array:                               return _registry.values()
```

> **ShopManager 商品池补充**：`ShopItemType.CONSUMABLE` 与 `ShopItemType.CORE` 商品由特定波次固定投放（不进入随机权重池），即 Boss 波前固定刷出 Core 槽位、每波随机出现 1 消耗品。`_build_weighted_pool` 仅负责 SPELL + PASSIVE；CONSUMABLE/CORE 槽位由 `_fill_fixed_slots()` 在 `_fill_slots()` 中单独填充。

---

### 5.12 MinionManager 架构设计

`MinionManager` 管理玩家召唤物（友方单位）的生命周期，与 `EnemyManager` 共享 `SpatialGrid` 但 faction 位不同（`+6 高16位 = 1`）。召唤物数量上限较小（通常 ≤ 16），不需要 C# 热路径。

#### 职责边界
- **不处理**：战斗逻辑（委托 SpellEvaluator / StatusManager）、敌人碰撞（委托 BulletManager）
- **负责**：召唤物生命周期（spawn/expire/recall）、最大上限控制、事件通知（MINION_SPAWNED/MINION_EXPIRED）

```gdscript
# minion_manager.gd (Autoload: MinionManager)

const MAX_MINIONS: int = 16          # 全局最大召唤物数量
const DEFAULT_LIFETIME: float = 30.0 # 默认存活秒数（可被 MinionDef 覆盖）

var _minions: Array[Dictionary] = []  # 活跃召唤物列表，每项：{ id, owner_id, def, lifetime_rem, node }
var _next_id: int = 0

func spawn_minion(def: MinionDef, owner_id: int, pos: Vector2) -> int:
    # 返回 minion_id；若已达 MAX_MINIONS，先 expire 最旧的（先进先出策略）
    # 发出 EventID.MINION_SPAWNED
    if _minions.size() >= MAX_MINIONS:
        _expire(_minions[0].id)
    var id := _next_id
    _next_id = (_next_id + 1) % 100000
    var entry := { "id": id, "owner_id": owner_id, "def": def,
                   "lifetime_rem": def.lifetime if def.lifetime > 0 else DEFAULT_LIFETIME,
                   "pos": pos }
    _minions.append(entry)
    EventBus.emit(EventID.MINION_SPAWNED, { "minion_id": id, "owner_id": owner_id,
                                             "current_count": _minions.size() })
    return id

func recall_all(owner_id: int) -> void:
    # 立即移除 owner_id 的所有召唤物（玩家死亡 / Run 结束时调用）
    var to_expire := _minions.filter(func(m): return m.owner_id == owner_id)
    for m in to_expire: _expire(m.id)

func get_count(owner_id: int = -1) -> int:
    # owner_id = -1 时返回所有召唤物总数
    if owner_id < 0: return _minions.size()
    return _minions.filter(func(m): return m.owner_id == owner_id).size()

func fill_pos_snapshot(out: PackedFloat32Array, out_ids: PackedInt32Array) -> int:
    # ⚠️ 未实现（设计提案）。原注写「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
        out[n * 2 + 1] = m.pos.y
        out_ids[n]     = m.id
        n += 1
    return n

func _physics_process(delta: float) -> void:
    var i := 0
    while i < _minions.size():
        _minions[i].lifetime_rem -= delta
        if _minions[i].lifetime_rem <= 0.0:
            _expire(_minions[i].id)
        else:
            i += 1

func _expire(minion_id: int) -> void:
    for i in _minions.size():
        if _minions[i].id == minion_id:
            var owner := _minions[i].owner_id
            _minions.remove_at(i)
            EventBus.emit(EventID.MINION_EXPIRED, { "minion_id": minion_id, "owner_id": owner,
                                                    "current_count": _minions.size() })
            return

func reset() -> void:
    # GameCycleManager._reset_all_managers() 调用
    _minions.clear(); _next_id = 0
```

> **MinionDef Resource**：`res://resources/minions/{id}.tres`，字段：`minion_id: String`、`lifetime: float`（≤0 使用默认值）、`move_speed: float`、`damage_mult: float`、`spell_deck_id: String`（召唤物使用的法术）。

---

### 5.13 VFXManager 框架设计

`VFXManager` 管理战斗特效的播放与回收，基于对象池避免运行时节点创建。权威实现参见 `implementation_plan.md §2.5.D`，本节为 §5 级接口契约（供其他 Manager 调用）。

#### 职责边界
- **不处理**：音效（AudioManager 负责）、伤害数字（UIManager 负责）
- **负责**：粒子/精灵特效生命周期（play/stop/reset）、命中火花（spawn_hit_vfx）、区域特效（play_zone）

```gdscript
# vfx_manager.gd (Autoload: VFXManager)
# C# 热路径：无（特效数量有限，GDScript 对象池足够）

const MAX_ACTIVE_VFX: int = 64      # 同时活跃特效上限（超出时丢弃最旧）

# 订阅事件（_ready() 注册）：
# EventID.BULLET_HIT      → spawn_hit_vfx("hit_spark",  world_pos)
# EventID.ENEMY_KILLED    → play("death_burst",           enemy_pos)
# EventID.STATUS_APPLIED  → play("status_" + type_name,  target_pos)
# EventID.SPELL_CAST_BEGIN→ play("cast_flash",            caster_pos)
# EventID.BOSS_PHASE_CHANGED → play("boss_phase_" + phase, boss_pos)

func play(effect_id: String, world_pos: Vector2, scale: float = 1.0) -> int:
    # 从池中取出一个特效节点并播放；返回 vfx_handle（用于 stop）
    # effect_id 对应 res://scenes/vfx/{effect_id}.tscn
    pass  # 具体实现见 implementation_plan.md §2.5.D

func stop(vfx_handle: int) -> void:
    # 立即停止并回收特效节点（用于持续特效提前结束，如召唤物消失）
    pass

func play_zone(zone_type_id: int, world_pos: Vector2, radius: float,
               duration: float) -> int:
    # 为 ZoneManager 显示区域特效（持续型，duration 秒后自动停止）
    # zone_type_id 对应 ZoneManager 的 zone_type_id，映射到 "zone_{type}" 特效
    return play("zone_%d" % zone_type_id, world_pos, radius / 64.0)

func spawn_hit_vfx(hit_type: String, world_pos: Vector2) -> void:
    # BulletManager BULLET_HIT 事件处理；hit_type：spark / elemental_X / critical
    play("hit_" + hit_type, world_pos, 1.0)

func reset() -> void:
    # GameCycleManager._reset_all_managers() 调用，停止并归还所有活跃特效节点
    pass  # 具体实现见 implementation_plan.md §2.5.D（遍历 _active_pool，调用 stop/归还）
```

> **与 implementation_plan.md 的关系**：`impl §2.5.D` 为完整实现（含池管理细节），本节 §5.13 为接口契约层（供其他 Manager 查阅可调用的 API）。两者互为补充，实现时以 impl §2.5.D 为基础，函数签名以本节为准。

---

## 6. 技术栈选型总结

| 模块 | 方案 | 理由 |
| :--- | :--- | :--- |
| **引擎** | Godot 4.x | 开源免费，2D 性能强，内置物理/动画系统完善 |
| **语言** | GDScript + C#（静态职责划分，见 §6.1） | 非主备关系：GDScript 负责快迭代域（UI / 事件 / 配置 / 游戏循环 / 法术预编译），C# 负责计算密集热路径（`BulletManager` / `EnemyManager` / `SpatialGrid` / `SpellEvaluator` 内层循环）；热数据通过 `PackedFloat32Array.AsSpan()` 零拷贝共享；详见 §6.1 |
| **ECS框架** | Custom Lite (Autoload Manager-based) | 利用 Godot Autoload 实现 Manager 单例，针对本项目定制，避免引入第三方 ECS 库 |
| **物理** | Custom SpatialGrid（主力）+ Godot Area2D（低密度启动路径） | < 200 弹幕：Area2D 信号回调；200+ 弹幕：SpatialGrid dirty-list **主力碰撞**；详见 §4.3 |
| **渲染批次** | MultiMeshInstance2D | 极大量同类子弹用 MultiMesh 渲染，最小化 Draw Call |
| **UI** | Godot Control + 自定义虚拟列表 | 背包道具可能很多，需要虚拟列表优化 |
| **配置** | JSON + GDScript 类型注解（或 .tres Resource） | JSON 灵活易热更；Resource 文件可享受 Godot 编辑器集成 |

### 6.1 语言职责分工 (Language Partition)

GDScript 与 C# **不是主备（备用）关系**，而是按职责**静态划分**，从 S0 起并行建立：

| 职责域 | 语言 | 核心原因 |
| :--- | :--- | :--- |
| 表现层（UI / VFX / 音频 / 动画） | **GDScript** | 节点操作频繁；Inspector 可视化调整；帧预算宽松（< 1ms） |
| 游戏循环（WaveManager / ShopManager / CombatManager 状态机） | **GDScript** | 非高频热路径；快速迭代优先 |
| 数据定义（CoreDefinition / SpellNode / StatusTypeDef） | **GDScript `.tres`** | Godot 编辑器原生 Inspector 支持；数值调整零编译 |
| 配置与存档（ConfigMgr / SpellRegistry / ProfileManager） | **GDScript** | JSON / Resource 读取一次性，非热路径 |
| 法术预编译（`compile_wand`） | **GDScript** | 仅在换牌时触发，非 `_physics_process`；Dictionary 操作 GDScript 更高效 |
| 事件总线（EventBus / EventID） | **GDScript** | 保持单语言，避免跨语言信号绑定复杂性 |
| **`BulletManager._physics_process`** | **C#** | 2000 颗/帧 SoA 积分；`Span<float>` 零拷贝 + struct 零 GC；GDScript ≈8ms → C# ≈0.8ms |
| **`EnemyManager._physics_process`** | **C#** | 1000 敌人 Boid 分离力；向量运算密集；GDScript ≈6ms → C# ≈0.6ms |
| **`SpatialGrid`（重建 + 查询）** | **C#** | 每帧重建 + M×N `query_circle`；纯计算密集，受益于 JIT 内联优化 |
| **`SpellEvaluator.execute_compiled`** | **C#** | 内层 while 循环（MAX_OPS_PER_CPU=40 × 高频施法）；`switch` 跳表 JIT 优化 |
| **`StatusManager._physics_process`** | **C#** | 200+ 状态实例逐 tick 计算；swap-and-pop 顺序内存访问 C# 受益更大 |
| **`ZoneManager._physics_process`** | **C#** | Zone tick + SpatialGrid 批量查询组合 |

#### 跨语言边界规则（ADR-L1）

每次 GDScript ↔ C# `Call()` / `Set()` 约有 1–5µs 开销，以下规则确保该开销不进入任何热路径：

**规则 1：禁止在 C# 内层循环体内调用 GDScript 方法**

`BulletManagerCs._PhysicsProcess` 的 `for` 循环体内不得出现 `GodotObject.Call()` / `.Set()`。
2000 次/帧 × 1–5µs = 2–10ms，直接耗尽帧预算。

**规则 2：热数据通过 `PackedFloat32Array.AsSpan()` 零拷贝共享**

GDScript Autoload 持有 `_data: PackedFloat32Array`；C# 通过 `AsSpan()` 获取 `Span<float>` 原生指针，**无内存复制**：

```csharp
// BulletManagerCs.cs — _PhysicsProcess 热路径
public override void _PhysicsProcess(double delta)
{
    float f = (float)delta;
    using var span = _data.AsSpan();          // 零拷贝：指向 PackedFloat32Array 底层内存
    int end = _activeCount * BulletStride;    // const int BulletStride = 12
    for (int i = 0; i < end; i += BulletStride)
    {
        span[i]     += span[i + 2] * f;      // px += vx * dt
        span[i + 1] += span[i + 3] * f;      // py += vy * dt
        span[i + 4] -= f;                    // lifetime -= dt
    }
}
```

**规则 3：帧末批量通知 GDScript，不在循环内逐条回调**

C# 内部用 `List<int>` 收集当帧命中 ID，`_PhysicsProcess` 末尾**一次性**通知 EventBus：

```csharp
// 帧末单次跨语言调用（循环外）
// ⚠️ 禁止 _hitBulletIds.ToArray()——每帧 new int[] 产生 GC 分配。
// 改用可复用的类字段 Godot.Collections.Array<int> _hitBuffer（_Ready() 中 new 一次）：
//   private readonly Godot.Collections.Array<int> _hitBuffer = new();
if (_hitBulletIds.Count > 0)
{
    _hitBuffer.Clear();
    foreach (var id in _hitBulletIds) _hitBuffer.Add(id);
    _eventBus.Call("emit_batch", (int)EventId.BulletHit, _hitBuffer);
    _hitBulletIds.Clear();
}
```

**规则 4：C# 组件挂为 GDScript Autoload 的子节点**

```
(Autoload) bullet_manager.gd   ← GDScript：_data PackedFloat32Array + 对外接口（spawn/despawn）
    └─ BulletManagerCs.cs      ← C# Node：_Ready() 缓存父节点引用；_PhysicsProcess 执行热路径
```

GDScript 对外接口（`spawn_bullet` / `despawn_bullet`）保持不变，其他 GDScript 系统无感知 C# 的存在。
C# `_Ready()` 中获取父节点并缓存 `_data` 引用，之后每帧直接操作，无跨语言调用。

**BulletManager GDScript 对外接口（权威签名）**

```gdscript
# bullet_manager.gd (Autoload: BulletManager)
# 以下为 GDScript 对外 API；C# 热路径（_physics_process 积分）不在此列出。

const BULLET_STRIDE: int = 12  # SoA 完整布局（权威：arch §4.2）：
# [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ]
#   0  1   2   3      4       5          6              7           8          9          10           11
# 冷数据（pierce/bounce/homing/payload_id）→ _bullet_contexts: Dictionary（非热路径）

var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
var _bullet_contexts: Dictionary = {}  # { bullet_id: int → Dictionary（冷数据）}

func spawn_bullet(def: ProjectileDef, pos: Vector2, vel: Vector2) -> int:
    # 返回 bullet_id（SoA 槽位索引）；def.lifetime 单位：秒
    # SpellEvaluator、ZoneManager 调用
    pass  # 具体实现见 implementation_plan.md §2.2

func despawn_bullet(bullet_id: int) -> void:
    # 提前回收：lifetime 置 0；C# 下帧 SoA 清理
    if bullet_id < 0 or bullet_id >= _active_count: return
    _data[bullet_id * BULLET_STRIDE + 4] = 0.0  # slot +4: lifetime

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.get_nearest_pos 直接线性扫描敌人 SoA；非每帧热路径（仅发射时调用）
    return EnemyManager.get_nearest_pos(origin, max_dist)

func reset() -> void:
    _active_count = 0  # 不需要清零数据，_active_count 控制有效范围
    _bullet_defs.clear()
```

> **命名统一说明**：文档早期混用 `spawn()` / `spawn_bullet()`，以此处 `spawn_bullet(def, pos, vel)` 为唯一权威签名；`despawn_bullet(id)` 同理。ADR-L1 中提到的"GDScript 对外接口"均指此两个函数。

**规则 5：`compile_wand` 保留在 GDScript，不迁移**

法术预编译仅在换牌时触发（非 `_physics_process`），主要为 `Dictionary` / `Array` 构建操作，
GDScript 开发效率更高；迁移 C# 无性能收益且增加维护成本。

---

### 6.2 EventBus Autoload 接口定义

`EventBus`（`event_bus.gd`）是全局事件总线，**第 6 个 Autoload 初始化**（见 §9.2），所有 Manager 均依赖它。采用整数 ID 替代字符串，避免运行时 String 比较。

```gdscript
# event_bus.gd (Autoload: EventBus)
# 核心约定：
#   - 所有事件 ID 均在 event_ids.gd (Autoload: EventID) 中声明，不接受魔术数字
#   - C# 热路径通过 emit_batch 一帧内批量发送，降低跨语言调用次数
#   - 订阅回调在 GDScript 侧统一处理，不在 C# 侧订阅（ADR-L1 规则 2）

var _listeners: Dictionary = {}  # { event_id: int → Array[Callable] }

func subscribe(event_id: int, callback: Callable) -> void:
    if not _listeners.has(event_id):
        _listeners[event_id] = []
    var arr: Array = _listeners[event_id]
    if not arr.has(callback):
        arr.append(callback)

func unsubscribe(event_id: int, callback: Callable) -> void:
    if _listeners.has(event_id):
        _listeners[event_id].erase(callback)

func emit(event_id: int, payload: Dictionary = {}) -> void:
    if not _listeners.has(event_id): return
    for cb: Callable in _listeners[event_id].duplicate():  # duplicate() 防止回调内修改订阅表
        cb.call(payload)

func emit_batch(events: Array) -> void:
    # C# BulletManager / EnemyManager 在 _physics_process 末尾调用（ADR-L1 规则 3）
    # events 格式：[ [event_id: int, payload: Dictionary], ... ]
    # 在主线程 GDScript 帧末执行，保证监听者在下一个 _process 前收到通知
    for ev in events:
        if ev.size() >= 2:
            emit(int(ev[0]), ev[1])
        elif ev.size() == 1:
            emit(int(ev[0]))

func reset() -> void:
    # GameCycleManager 在 Run 结束时调用，清除所有动态订阅（防跨 Run 事件泄漏）
    # ⚠️ 仅清除非常驻订阅（GameCycleManager、UIManager 等 Autoload 在 _ready() 中重新订阅）
    _listeners.clear()
```

> **使用约定**：
> - `subscribe` / `unsubscribe` 必须成对调用；Autoload 在 `_ready()` 中订阅，节点在 `_exit_tree()` 中取消。
> - `emit_batch` 仅供 C# Manager 使用，格式见注释；普通 GDScript Manager 直接调用 `emit`。
> - EventID 目录权威来源：`implementation_plan.md §2.1`（1–21，下一空位 22）。

---

## 7. 目录规范建议

```text
project.godot
scripts/
  autoloads/        # Godot Autoload 单例 (EventBus, BulletManager, EnemyManager)
  core/             # 核心架构 (ObjectPool, SpatialGrid, BaseClasses)
  systems/          # 独立系统 (InputManager, AudioManager, ResourceManager)
  domain/           # 游戏业务逻辑
    spell_system/   # 法术解释器, SpellNode 定义, SpellDeck
    combat/         # 伤害计算, 弹道管理 (BulletManager)
    enemy/          # AI 行为, EnemyManager
  ui/               # UI 控制脚本, 特效表现
  config/           # JSON 配置加载器与类型定义
csharp/
  autoloads/        # C# 热路径内核 (BulletManagerCs, EnemyManagerCs)
  systems/          # C# 计算模块 (SpellEvaluatorCs, SpatialGridCs, StatusManagerCs, ZoneManagerCs)
scenes/
  main/             # 主场景, 战斗场景
  ui/               # UI 场景 (背包, 商店, HUD)
  entities/         # 预制体场景 (子弹, 敌人, 特效)
resources/
  spells/           # 法术数据 (.tres 或 .json)
  enemies/          # 怪物数据
```

---

## 8. 架构决策记录 (Architecture Decision Records)

### ADR-R4-N2：属性快照方案（Snapshot vs Dynamic）

> **结论：采用 Snapshot（快照）方案。**

DoT（持续伤害）、召唤物（Minion）、持续性法术的属性在**施加/发射瞬间**锁定，
不跟随玩家后续属性变化。即：

- DoT 的每 tick 伤害 = 施加瞬间的 `attunement_bonus` × `base_damage`（固定值），后续装备变化不影响已施加的 DoT。
- Minion 的各项属性（速度/伤害/血量）= 召唤瞬间的玩家属性×配方系数，召唤后独立计算，不随玩家属性波动。
- 实现方式：`ProjectileDef` / `MinionSpawnParams` 在填充时立刻将计算完毕的数值写入，
  BulletManager / MinionManager 直接使用存储值，不保存对玩家属性表的引用。

**设计原因**：Dynamic 方案（实时跟随）会导致属性换装瞬间大量已存在实体同步刷新，
产生不可控的计算尖峰，且与 SoA 热数组布局不兼容。Snapshot 方案计算开销集中在"施加时刻"，
运行时热路径无额外查询，更符合 Low-GC 原则。

详见 `docs/mechanics/combat_mechanics_extensions_v2.md §4.2`。

---

### ADR-R4-N4：召唤物/友军管理方案（MinionManager，方案 B）

> **结论：采用独立的 MinionManager（方案 B），不在 EnemyManager 中混管召唤物。**

> **⚠️ 实现现状 (2026-07-20 审计)**：MinionManager 存在，但数据结构用的是 **`Array[Dictionary]`（随 §5.12）而非本节的 SoA `stride=8`**（两处文档矛盾，代码选了 Array）；`behavior_state` 枚举未实现。召唤物**只实现了 Stationary 炮台一种**，随从/卫星/镜像（含 hp/碰撞体积/移动）均未落地。`MAX_MINIONS=20` FIFO 顶替已实现。详见 [审计报告](../../docs_dev/doc_code_audit_2026-07-20.md)。

`MinionManager` 为独立的 Autoload 单例，管理玩家召唤的炮台/傀儡/宠物类实体：

- **与 EnemyManager 隔离**：EnemyManager 的 SoA 中 `faction_and_type` 字段不扩展到友军，
  避免碰撞 Layer 混乱和阵营判断逻辑膨胀。
- **Minion SoA stride**：`[x, y, vx, vy, hp, hp_max, owner_id, behavior_state]`（stride=8），
  与 EnemyManager 的 stride=8 同构，便于复用 SpatialGrid 查询逻辑。
- **行为驱动**：Minion 的 AI 行为（巡逻/护卫/定点炮击）由 `behavior_state` 枚举驱动，
  每帧在 `MinionManager._physics_process` 中批处理；不使用独立 Node `_process`。
- **生命周期**：Minion 属性在召唤瞬间 Snapshot（见 ADR-R4-N2），由 `owner_id` 关联到施法者；
  施法者死亡时，对应的所有 Minion 同帧标记为"消退状态"（延迟 1 秒淡出销毁）。
- **上限保护**：单个玩家同场最多 `MAX_MINIONS = 20` 个 Minion，超出时最早召唤的自动消退。

详见 `docs/mechanics/advanced_mechanics_summons_and_environment.md §3.1`（方案 B 推荐）。

---

### ADR-R5-N1：地面效果系统架构（ZoneManager）

> **结论：采用独立的 ZoneManager（Autoload），Zone 实体基于 SpatialGrid 空间索引，不使用 Area2D。**

`ZoneManager` 管理战场中的持久化地面效果（毒液池、岩浆区、冻结地面等）：

- **数据结构**：`_zones: Array[ZoneData]`，ZoneData 字段：`[cx, cy, radius, status_type_id, duration, tick_interval, tick_accum, root_owner_id]`（stride=8；`tick_interval` 与 `tick_accum` 分开存储，支持每个 Zone 配置独立应用频率）。
- **碰撞检测**：每 `_physics_process` 帧调用 `SpatialGrid.query_circle(zone_center, zone_radius)`，对返回的敌人实体批量施加状态；不使用 Area2D，不产生 Node 开销。
- **上限保护**：`MAX_ZONES = 64`（超出时最早创建的 Zone 被顶掉）。
- **渲染**：Zone 的地面贴花由 `VFXManager.play_zone("poison_pool", center, radius)` 驱动，不在逻辑层操作节点。
- **生命周期**：Zone 创建时 duration 快照，每帧 duration -= delta；降至 ≤ 0 时用 swap-and-pop 移除，同步通知 VFXManager 淡出。
- **与 SpellSystem 的接口**：`ActionSplashPoison` 等 ACTION 节点执行时调用 `ZoneManager.spawn_zone(...)` 而非直接操作场景。

```gdscript
# zone_data.gd (inline struct, not a class_name Resource to avoid GC)
# 存储于 ZoneManager._zone_data: PackedFloat32Array，stride = 8
# [0]=cx [1]=cy [2]=radius [3]=status_type_id [4]=duration [5]=tick_interval [6]=tick_accum [7]=owner_id
# tick_interval：两次状态应用之间的最小间隔（秒，由 spawn_zone 调用方指定）
# tick_accum：当前累积时间（运行时变量），>= tick_interval 时触发一次 APPLY_DAMAGE 并减去 tick_interval

# zone_manager.gd (Autoload)
const ZONE_STRIDE: int = 8
const MAX_ZONES: int = 64
var _zone_data: PackedFloat32Array   # SoA 热数据
var _active_zones: int = 0

func spawn_zone(cx: float, cy: float, radius: float,
                status_id: int, duration: float,
                tick_interval: float,   # Zone 每隔多少秒对范围内敌人应用一次状态
                owner_id: int) -> void:
    if _active_zones >= MAX_ZONES:
        # 顶掉最旧的 Zone（index 0），整体前移（O(N)，N≤64 可接受）
        # PackedFloat32Array 无 remove_at()，用手动循环前移实现首元素删除
        for j in range(ZONE_STRIDE, _active_zones * ZONE_STRIDE):
            _zone_data[j - ZONE_STRIDE] = _zone_data[j]
        _active_zones -= 1
    var base: int = _active_zones * ZONE_STRIDE
    _zone_data.resize((_active_zones + 1) * ZONE_STRIDE)
    _zone_data[base + 0] = cx;             _zone_data[base + 1] = cy
    _zone_data[base + 2] = radius;         _zone_data[base + 3] = status_id
    _zone_data[base + 4] = duration;       _zone_data[base + 5] = tick_interval
    _zone_data[base + 6] = 0.0            # tick_accum 初始为 0
    _zone_data[base + 7] = owner_id
    _active_zones += 1

func _physics_process(delta: float) -> void:
    var i: int = 0
    while i < _active_zones:
        var base: int = i * ZONE_STRIDE
        _zone_data[base + 4] -= delta           # duration 倒计时
        _zone_data[base + 6] += delta           # tick_accum 累积

        # 速率限制：仅当 tick_accum >= tick_interval 时才触发状态应用，防止 64 个 Zone 每帧
        # 对 1000 个敌人各触发，产生最多 64,000 APPLY_DAMAGE 事件/帧。
        # 外层 if 守卫 + 内层 while + -= tick_interval：保留余量精度，且支持大帧多次跳字。
        # SpatialGrid.query_circle 仅在 if 守卫内调用一次，不在 while 内重复查询。
        if _zone_data[base + 6] >= _zone_data[base + 5]:
            var cx: float = _zone_data[base + 0]; var cy: float = _zone_data[base + 1]
            var rad: float = _zone_data[base + 2]; var sid: int = int(_zone_data[base + 3])
            var targets := SpatialGrid.query_circle(Vector2(cx, cy), rad)
            while _zone_data[base + 6] >= _zone_data[base + 5]:
                _zone_data[base + 6] -= _zone_data[base + 5]  # -= tick_interval 保留余量精度
                for t_id in targets:
                    # ⚠️ 修正(2026-07-20)：真实签名 apply(entity_id, status_type_id, stacks:int=1, duration:float=-1.0, owner_id:int=-1)——5 参
                    StatusManager.apply(t_id, sid, 1, -1.0, int(_zone_data[base + 7]))  # stacks=1, duration=default, owner_id=zone_owner（现实 zone_manager.gd:60 即如此调用）

        if _zone_data[base + 4] <= 0.0:        # duration 耗尽，移除此 Zone
            var cx_exp: float = _zone_data[base + 0]; var cy_exp: float = _zone_data[base + 1]
            var rad_exp: float = _zone_data[base + 2]
            VFXManager.play("zone_expire", Vector2(cx_exp, cy_exp), rad_exp / 64.0)
            # swap-and-pop 移除（O(1)，顺序可乱）
            if i < _active_zones - 1:
                for k in ZONE_STRIDE:
                    _zone_data[base + k] = _zone_data[(_active_zones - 1) * ZONE_STRIDE + k]
            _active_zones -= 1
        else:
            i += 1

func reset() -> void:
    # GameCycleManager._reset_all_managers() 调用，清空所有活跃区域特效
    _active_zones = 0  # SoA 数据不清零，_active_zones 控制有效范围（惰性清理）
```

> **与 BulletManager 的区别**：BulletManager 管理短生命周期（秒级）的投射物；ZoneManager 管理中等生命周期（数秒～数十秒）的区域场。两者共享同一个 `SpatialGrid` 实例，但查询入口不同（BulletManager 查敌人，ZoneManager 也查敌人但传入 zone_center/radius）。

---

### ADR-R5-N2：Core Feature Tags 字符串常量规范

> **问题**：`if "persistent_memory" not in core.feature_tags` 使用裸字符串比较，拼写错误时静默失效，且重构不安全。

> **结论**：所有 Feature Tag 字符串必须通过 `CoreFeatureTag` 常量访问，禁止在业务代码中写裸字符串。

> **⚠️ 实现现状 (2026-07-20 审计)**：代码**未按本节的 `String` 常量 + `in` 判定实现**。`core_feature_tag.gd` 实为 **`int` 常量**（PERSISTENT_MEMORY=1, DUAL_STREAM=2, ALWAYS_CAST_LAST=3, SHUFFLE_DECK=4, INFINITE_SPELLS=5），`core.feature_tags` 为 `int` 位掩码。**feature_tags 实为单选枚举**（游戏设计器 `core_tab.gd` 以 OptionButton 下标 0..5 写入，下标即等于常量值）。原代码用位与 `&` 判定，因常量非 2 的幂会串扰（`3 & 1 = 1` 误判持久内存）。✅ **已修复** (`fix/audit-latent-bugs` dc05eea)：判定改为 `==`，常量保持 1..5（未改位标志以免与设计器 desync）。详见 [审计报告 D 节](../../docs_dev/doc_code_audit_2026-07-20.md)。

```gdscript
# core_feature_tag.gd (Autoload: CoreFeatureTag)
## Core 特性标签常量表 — 所有系统通过此处引用，禁止裸字符串
const PERSISTENT_MEMORY: String = "persistent_memory"
const DUAL_STREAM:        String = "dual_stream"
const SHUFFLE_DECK:       String = "shuffle_deck"
const INFINITE_SPELLS:    String = "infinite_spells"
const ALWAYS_CAST_LAST:   String = "always_cast_last"

## 使用示例（正确）：
## if CoreFeatureTag.PERSISTENT_MEMORY in core.feature_tags: ...
##
## 禁止的写法：
## if "persistent_memory" in core.feature_tags: ...  # ❌ 裸字符串
```

新增 Feature Tag 流程：
1. 在 `CoreFeatureTag` 中追加常量（命名规则：全大写下划线分隔）。
2. 在 `core_wand_design.md §3` Feature Tags 表格中登记效果与稀有度。
3. 在 `SpellEvaluator` 或对应 Manager 中实现逻辑，通过 `CoreFeatureTag.XXX` 引用。

---

### ADR-A1：本地化架构（L10n Infrastructure）

> **结论：从 S1 起强制所有玩家可见字符串通过 `tr()` 包裹；本地化是开发规范而非后期工程。**

商业 Steam 发行需支持至少简体中文 / 繁体中文 / 英语 / 日语四语。延后本地化会导致数千条裸字符串的批量替换，历史代价极高。

**规范约束**：
*   所有 UI 标签、法术名称、状态名称、Boss 名称、提示文本字符串字面量**必须**用 `tr("KEY")` 包裹。
*   禁止在业务代码中写裸中文字符串（仅 `push_warning` / `push_error` 调试输出除外）。
*   使用 Godot 内置 `.po` 文件（兼容 Gettext 工具链），路径 `res://translations/zh_CN.po` / `en.po`。

**键名规范**：`<模块>_<实体>_<含义>`，全大写下划线，例：

| 键 | 对应文本 |
| :--- | :--- |
| `SPELL_SPARK_BOLT_NAME` | "电花弹" |
| `SPELL_SPARK_BOLT_DESC` | "发射一枚快速电花弹，命中造成闪电伤害" |
| `STATUS_BURN_NAME` | "燃烧" |
| `UI_SHOP_REROLL_TOOLTIP` | "花费 {gold} 金币刷新商店" |

**实施时机**：S1 随 ConfigMgr 一并建立翻译加载流程（`ProjectSettings/locale` + `TranslationServer.add_translation()`）；S1 起所有新增文本直接按规范写键，不留技术债。

---

### ADR-A2：局内存档 / 断点续玩（Run State Persistence）

> **结论：S2 实现波次边界自动存档，采用 A/B 双槽写入防崩溃损坏，支持断点续玩。**

> **⚠️ 实现现状 (2026-07-20 审计)**：A/B 双槽 + 波次自动存档 + `WM_CLOSE` 兜底 + 链式迁移已实现。差异：① 选槽逻辑现按 `saved_at` **时间戳选最新未损坏槽**（比下文伪代码"固定顺序取第一个有效"更健壮，**代码更优**）；② `SCHEMA_VERSION` 已到 **2**（新增 `wand` 键），本节仅记 v1；③ 实存字段为 `wave_num/shop_seed/player_stats/wand`，**缺** `elapsed_sec`（影响无尽续玩计时）、`active_core_idx`、`unlocked_upgrades`、多 Core 数组（待补）。详见 [审计报告](../../docs_dev/doc_code_audit_2026-07-20.md)。

商业 Roguelite（Hades / Slay the Spire / Balatro）均支持中途退出后恢复到当前波次起始状态。

**存档时机**：
1. 每波**战斗结束**（进入商店阶段前）：序列化 `PlayerState + ActiveCore + WaveNum`。
2. 每次**商店关闭**（进入下一波前）：追加序列化 `InventoryState + UpgradePicks`。
3. 游戏**异常退出**：通过 `_notification(NOTIFICATION_WM_CLOSE_REQUEST)` 触发最后一次存档。

**序列化字段**（`run_state_v1.json`）：

| 字段 | 内容 |
| :--- | :--- |
| `schema_version` | 当前 = 1；迁移时递增 |
| `wave_num` | 当前波次（读档后从此波重新开始） |
| `elapsed_sec` | 本次 Run 总计时（无尽模式得分依据） |
| `player_hp` | 当前血量 |
| `player_stats` | 所有属性词条快照（Dictionary） |
| `cores` | 3 个 Core 的完整 SpellDeck JSON（含 `core_id` + `slots`，格式见 §3 E2） |
| `active_core_idx` | 当前激活 Core 索引（0/1/2） |
| `unlocked_upgrades` | 已选升级 ID 数组 |
| `shop_seed` | 随机种子（保证重进后商店展示相同选项，防刷新重开骗局） |

**A/B 双槽写入（防崩溃数据损坏）**：

```gdscript
# game_cycle_manager.gd（或 ProfileManager）
const _SLOT_A := "user://run_a.json"
const _SLOT_B := "user://run_b.json"

func save_run(data: Dictionary) -> void:
    var which := ProfileManager.get_int("run_write_slot", 0)  # 0=A, 1=B
    var path  := _SLOT_A if which == 0 else _SLOT_B
    var f := FileAccess.open(path, FileAccess.WRITE)
    if f: f.store_string(JSON.stringify(data))
    ProfileManager.set_int("run_write_slot", 1 - which)  # 下次写另一槽

func load_run() -> Dictionary:
    for path in [_SLOT_A, _SLOT_B]:
        if not FileAccess.file_exists(path): continue
        var text := FileAccess.open(path, FileAccess.READ).get_as_text()
        var parsed: Variant = JSON.parse_string(text)
        if parsed is Dictionary and parsed.has("schema_version"):
            return parsed
    return {}   # 无有效存档，从头开始新 Run
```

写完 A 槽后下次写 B 槽，两槽交替覆写；任意一次写入崩溃只损坏一个槽，另一槽保留上次完整状态。

**存档版本迁移框架（Schema Migration）**：新增字段或修改存档结构时，递增 `schema_version` 并在 `_migrate_run()` 中追加对应迁移函数；禁止在业务代码中对新字段直接 `.get()` 而绕过迁移。

```gdscript
# profile_manager.gd — 存档迁移分发入口
# 调用时机：load_run() 成功解析 JSON 后、return 前调用 _migrate_run(data)
func _migrate_run(data: Dictionary) -> Dictionary:
    var ver: int = data.get("schema_version", 0)
    if ver < 1:
        data = _migrate_v0_to_v1(data)
    # if ver < 2:
    #     data = _migrate_v1_to_v2(data)  # 未来版本在此追加，保持链式顺序迁移
    return data

# v0 → v1：为无 schema_version 的旧存档补全 S2 新增字段的默认值
func _migrate_v0_to_v1(data: Dictionary) -> Dictionary:
    data["schema_version"] = 1
    if not data.has("elapsed_sec"):       data["elapsed_sec"]       = 0.0
    if not data.has("shop_seed"):         data["shop_seed"]         = randi()  # 随机补种，防刷新重开
    if not data.has("unlocked_upgrades"): data["unlocked_upgrades"] = []
    if not data.has("active_core_idx"):   data["active_core_idx"]   = 0
    return data
```

**版本变更记录**（每次 `schema_version` 递增后在此登记）：

| schema_version | 变更内容 | 对应切片 |
| :--- | :--- | :--- |
| 1 | 初始版本：`wave_num` / `elapsed_sec` / `player_hp` / `player_stats` / `cores` / `active_core_idx` / `unlocked_upgrades` / `shop_seed` | S2 |

---

## ADR-R6 — Endless 排行榜数据完整性 (Leaderboard Integrity)

> **问题**：`user://endless_records.json` 是纯文本 JSON，玩家可直接用文本编辑器修改分数。
> 本 ADR 定义基于 HMAC-SHA256 的本地签名方案，对抗普通玩家随意手改，同时不影响离线可玩性。

### R6.1 威胁模型

| 攻击向量 | 对抗手段 | 是否覆盖 |
| :--- | :--- | :--- |
| 直接编辑 JSON 文件 | HMAC 签名验证失败 → 拒绝载入 | ✅ |
| 复制高分存档文件 | 签名与内容绑定，复制后验证通过（允许，属于本地副本） | 允许 |
| 逆向提取 HMAC Key 后重签 | 超出本方案防护范围；如需强防护则依赖服务器排行榜 | ❌ |
| 网络传输篡改（Steam 排行榜提交）| Steam SDK 侧验证，非本地文件问题 | ❌（Steam 负责）|

### R6.2 实现规范

```gdscript
# scripts/domain/endless_records_manager.gd
class_name EndlessRecordsManager
extends RefCounted

# HMAC Key：32 字节随机密钥，S5 开发前生成并硬编码到此处
# ⚠️ 安全注意：此 Key 仅防普通手改，不能防逆向；不含敏感用户数据，可接受。
# 生成方式：python3 -c "import secrets; print(list(secrets.token_bytes(32)))"
const _HMAC_KEY: PackedByteArray = [
    0x3F, 0x7A, 0x12, 0xB8, 0x5C, 0xE4, 0x91, 0x2D,
    0x6F, 0x04, 0xAA, 0x73, 0xC9, 0x18, 0x55, 0xE7,
    0x8B, 0x31, 0xF6, 0x4E, 0x0D, 0x90, 0x26, 0x7C,
    0xD2, 0x47, 0xBE, 0x5A, 0x83, 0x1F, 0xCA, 0x69
]  # 实际集成时替换为项目专属 Key

const RECORDS_PATH: String = "user://endless_records.json"

func save(records: Array) -> void:
    var data_str := JSON.stringify(records)
    var sig := _sign(data_str)
    var envelope := { "data": records, "sig": sig, "ver": 1 }
    var f := FileAccess.open(RECORDS_PATH, FileAccess.WRITE)
    if f:
        f.store_string(JSON.stringify(envelope))

func load() -> Array:
    if not FileAccess.file_exists(RECORDS_PATH): return []
    var raw: Variant = JSON.parse_string(
        FileAccess.open(RECORDS_PATH, FileAccess.READ).get_as_text())
    if not raw is Dictionary: return []
    var records: Variant = raw.get("data", [])
    var expected := _sign(JSON.stringify(records))
    if raw.get("sig", "") != expected:
        push_warning("EndlessRecords: 签名验证失败，疑似被篡改，分数已重置")
        return []   # 拒绝载入，不崩溃；UI 显示"分数不可用"
    return records

func _sign(data: String) -> String:
    var crypto := Crypto.new()
    return crypto.hmac_digest(
        HashingContext.HASH_SHA256,
        _HMAC_KEY,
        data.to_utf8_buffer()
    ).hex_encode()
```

### R6.3 分数提交 Steam 排行榜前的验证

```gdscript
# game_cycle_manager.gd — Endless 结算时
func _submit_endless_score(wave: int, elapsed_sec: float) -> void:
    var score: int = wave * 100000 + (86400 - int(elapsed_sec))  # wave 优先，elapsed_sec 作为同波次决胜分
    var records: Array = EndlessRecordsManager.new().load()
    if records.is_empty() and FileAccess.file_exists(
            EndlessRecordsManager.RECORDS_PATH):
        # 文件存在但 load() 返回空 → 被篡改，不提交 Steam 排行榜
        UIManager.show_toast("分数验证失败，本次成绩不上传排行榜")
        return
    # 验证通过后提交（Steam SDK 调用）
    if SteamIntegration.is_available():
        SteamIntegration.upload_leaderboard_score(score)
```

> **决策背景**：3A 商业标准要求游戏对色盲玩家（约占男性玩家 8%）可玩。仅用颜色区分元素
> 会导致此类玩家无法识别元素类型，影响共鸣系统核心玩法。

### C1.1 颜色盲支持（必须实现）

**规则 ADR-C1-R1**：UI 中所有元素标识必须同时提供颜色 + 形状标识，禁止仅用颜色区分。

**形状图标映射表**（对应 `SpellNode.shape_icon` 字段）：

| 元素 | 颜色（正常视觉） | `shape_icon` 键 | 图标形状描述 |
| :--- | :--- | :--- | :--- |
| 火系 Fire | 橙红 `#FF4500` | `"triangle"` | 向上三角形 |
| 冰系 Ice | 冰蓝 `#7EC8E3` | `"diamond"` | 菱形 |
| 水系 Water | 深蓝 `#1E90FF` | `"circle"` | 圆形 |
| 土系 Earth | 棕绿 `#8B7355` | `"square"` | 方形 |
| 雷系 Lightning | 黄色 `#FFD700` | `"star"` | 五角星 |
| 暗系 Dark | 紫黑 `#4B0082` | `"cross"` | X 形 |

**渲染规则**：
```gdscript
# ui_spell_card.gd — 法术卡渲染示例
func _render_element(spell_node: SpellNode) -> void:
    # 1. 颜色背景（正常视觉）
    $ElementBadge/Background.color = ElementColors.get(spell_node.element_tags)
    # 2. 形状图标叠加（色盲支持，ADR-C1-R1）
    if spell_node.shape_icon != "":
        $ElementBadge/ShapeIcon.texture = UIAtlas.get_icon(spell_node.shape_icon)
        $ElementBadge/ShapeIcon.visible = true
    else:
        $ElementBadge/ShapeIcon.visible = false
```

**VFX 叠加规则**（ADR-C1-R1 延伸）：
- 命中 VFX 粒子颜色使用对应元素颜色。
- 命中时在命中点叠加 1 帧（0.1s）的 `shape_icon` TextureRect，字号 1.5× 基准大小。
- `VFXManager` 在 `spawn_hit_vfx(pos, element_tag)` 中从 UIAtlas 读取 `shape_icon` 并生成叠加节点。

### C1.2 字体缩放（S4 实现）

```gdscript
# settings_manager.gd 新增常量 + 接口
const FONT_SCALE_KEY: String = "font_scale"
const FONT_SCALE_MIN: float  = 0.5
const FONT_SCALE_MAX: float  = 1.5
const FONT_SCALE_DEF: float  = 1.0

static func get_font_scale() -> float:
    return ProfileManager.get_float(FONT_SCALE_KEY, FONT_SCALE_DEF)
    .clamp(FONT_SCALE_MIN, FONT_SCALE_MAX)

static func apply_font_scale(root: Node) -> void:
    # 遍历 UILayer 下所有 Label 节点，按 scale 覆写 custom_font_size
    for label in root.find_children("*", "Label", true, false):
        label.add_theme_font_size_override(
            "font_size", int(label.get_theme_font_size("font_size") * get_font_scale()))
```

UI 初始化时调用 `SettingsManager.apply_font_scale(ui_root)`；字体缩放变更时发送
`EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" })` 触发全局重渲染（`EventID` **19**，见 `implementation_plan.md` §2.1）。

### C1.3 减少闪光（S6 实现）

设置项 `reduce_flash`（bool，默认 `false`）：
- 开启时：VFXManager 将爆炸 / 共鸣特效的帧率限制为 12fps，白闪持续时间从 0.2s → 0.05s。
- `EventID.GAME_CLEARED`（GameCycleManager 进入 GAME_CLEARED 状态时发出）的全屏白闪在该模式下完全跳过。

### C1.4 高对比度模式（S6 实现）

设置项 `high_contrast`（bool，默认 `false`）：
- 开启时：UI 背景强制黑色 `#000000`，所有 Label 强制白色 `#FFFFFF`。
- 实现：`SettingsManager.apply_high_contrast(ui_root)` 覆写主题颜色变量，不修改资源文件。

---

## ADR-A3 — 音频架构补充细节 (Audio Architecture - Supplementary)

> **注意**：AudioBus 层级结构、AudioBusID 常量、`_bgm_base_player`/`_music_layers` 字段声明、`play_bgm()`/`play_sfx()` 接口均已在 **§5.4** 完整定义，本 ADR 仅补充 §5.4 未涉及的细节（2D 空间音效衰减参数、音量持久化、SFX 节流规则）。

### A3.1 AudioBus 结构参考

> ⚠️ 此处为索引引用，**不重新定义** Bus 结构，权威定义见 §5.4。

Bus 树：`Master > BGM (BGM_Base, BGM_Layer) > SFX (SFX_Combat, SFX_Spatial) > UI > Ambience > Voice`

AudioBusID Autoload 常量：`MASTER / BGM / BGM_BASE / BGM_LAYER / SFX / SFX_COMBAT / SFX_SPATIAL / UI / AMBIENCE / VOICE`

### A3.2 动态音乐分层触发规则（补充）

| 屏内敌人数 / 状态 | BGM_Layer 激活层级 | 说明 |
| :--- | :--- | :--- |
| 0–4 | Layer 0（基础，仅鼓点 + 低音） | 常驻，即使 0 敌人 |
| 5–19 | Layer 1（主旋律 + 和弦） | EnemyManager.get_visible_count() ≥ 5 |
| ≥ 20 或 Boss 激活 | Layer 2（弦乐/合成铅声） | 最高战斗强度 |
| SHOP / MAIN_MENU 状态 | 独立 BGM（BGM_Base 直接切换）| BGM_Layer 全部静音 |

`AudioManager._process()` 每 0.5s 轮询一次 `EnemyManager.get_visible_count()`；Boss 激活/死亡通过 EventBus 订阅（`BOSS_PHASE_CHANGED` / `BOSS_KILLED`）实时响应，无需轮询。

### A3.3 2D 空间音效衰减

- SFX_Spatial Bus 下的 `AudioStreamPlayer2D` 使用 Godot 内置 `attenuation_filter_db` 曲线。
- **衰减曲线参数**（`max_distance=1200px`，`attenuation=1.8`）：  
  0px → 0dB, 400px → -6dB, 800px → -18dB, 1200px → -40dB（截止）。
- 超出 `max_distance` 的音源直接 `stop()`，不参与 0.1s 节流（已无声）。

### A3.4 音量持久化

```gdscript
# settings_manager.gd — 音量持久化
# AudioBusID 是 Autoload，其常量为整数（bus index）；不支持字符串下标访问，使用显式映射表

# bus_key（保存键）→ AudioServer bus index（AudioBusID 常量）的显式映射
const _VOLUME_BUS_MAP: Dictionary = {
    "vol_master": AudioBusID.MASTER,
    "vol_bgm":    AudioBusID.BGM,
    "vol_sfx":    AudioBusID.SFX,
    "vol_ui":     AudioBusID.UI,
}

func load_audio_settings() -> void:
    # SettingsManager._ready() 调用；将持久化音量值应用到 AudioServer
    for save_key in _VOLUME_BUS_MAP:
        var db: float = ProfileManager.get_float(save_key, 0.0)  # 0.0 dB = 100%
        AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db)

func save_audio_setting(save_key: String, db: float) -> void:
    # UIManager 音量滑块 on_value_changed → SettingsManager.save_audio_setting("vol_bgm", db)
    if not _VOLUME_BUS_MAP.has(save_key):
        push_warning("SettingsManager: unknown audio save_key: %s" % save_key)
        return
    AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db)
    ProfileManager.set_float(save_key, db)
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": save_key })
```

**存储位置**：`user://save_data.json`（由 `ProfileManager` 管理，非 Run 存档）。  
**范围约定**：音量滑块线性映射到 `-40dB ~ 0dB`；mute 状态用 `-80dB` 替代 `set_bus_mute()`
以避免 Godot 的 mute 标志在存档恢复时的边界问题。

### A3.5 SFX 池节流规则补充

> 以下补充 `implementation_plan.md §2.5.B` 中已有的 32 池规则：

| 规则 | 说明 |
| :--- | :--- |
| 同一音效 0.1s 内只播放 1 次 | `_last_play_time[sfx_id]` 字典节流 |
| 同帧相同位置的碰撞音效合并为 1 次 | BulletManager 碰撞处理后批量调用 `AudioManager.play_sfx_batch()` |
| Boss 语音（Voice Bus）不受 SFX 池限制 | 独立 `AudioStreamPlayer` 节点，最高优先级 |
| 玩家死亡时淡出所有 Bus（0.5s 内 -40dB）| `AudioManager.fade_out_all(duration=0.5)` |

---

## ADR-A4 — 敌人 AI 寻路方案决策 (Enemy Pathfinding)

> **结论**：Wave 1–14 普通敌人维持当前 Boid 斥力向量（无寻路）；Wave 15+ Elite 及 Boss 敌人按需引入 Godot `NavigationAgent2D`，并发上限 `MAX_PATHFINDING_ENEMIES=20`，超限降级 Boid。
>
> **S5 定案（2026-06-05，P-S5-AI-01 实测）**：采用 **`NavigationAgent2D` 方案**，**不**切换 FlowField。20 个 Elite 同时寻路的 `_update_pathfinding_movement` 实测 **≈0.0496ms/帧**，远低于 0.5ms 预算（约 10× 余量）。实现于 `enemy_manager.gd`（GDScript 回退路径）：程序化单凸矩形 `NavigationRegion2D` + 每精英 `Node2D` host 挂 `NavigationAgent2D`（`avoidance_enabled=false`，与 SoA Boid 分离力共存）；无精英时主循环零额外开销。

### 背景

当前架构中，所有敌人的移动逻辑为 Boid 斥力向量（EnemyManagerCs 内 SoA 批量计算），无完整寻路。  
评估发现 W15+ 精英敌人在面对角落障碍物时，因无寻路只能在角落堆叠，导致玩家可"站角落无脑输出"打过高难度波次，严重削弱 W15+ 的挑战性。

### 方案对比

| 方案 | 优点 | 缺点 | 适用范围 |
| :--- | :--- | :--- | :--- |
| Boid 斥力（当前） | 零额外 CPU，SoA 批量计算，1000 敌人帧时间已知 | 敌人无法绕过角落障碍物 | W1-14 杂鱼（堆叠可接受） |
| Godot `NavigationAgent2D` | 引擎内置，实现简单；支持动态障碍物 | 1000 个 Agent 同时 `get_next_path_position()` 帧时间未知，需 S5 基准测试 | Elite / Boss（≤ 20 个并发寻路） |
| FlowField 预烘焙 | O(1) 每敌人查询，适合大量敌人共享目标 | 实现复杂；场景动态障碍物需触发重烘焙（延迟约 1 帧） | 若 NavigationAgent2D 20 个精英时帧时间增量 > 0.5ms，则以此方案替代 |

### 实施规则

- `MAX_PATHFINDING_ENEMIES = 20`：同时具有完整寻路能力的敌人数上限。超出上限的额外 Elite 降级为 Boid 模式（按距玩家最近优先保留寻路资格）。
- W1–14 普通敌人：**永远** 使用 Boid 模式，不分配 NavigationAgent2D，避免节点数量爆炸。
- W15+ Elite / Boss：入场时在 `EnemyManagerCs` 中标记 `has_pathfinding = true`，GDScript 侧为其创建 `NavigationAgent2D` 子节点。
- **S5 性能验收（新增 P-S5-AI-01）**：`NavigationAgent2D` 20 个 Elite 同时寻路，`_physics_process` 帧时间增量 < **0.5ms**；若超标，切换 FlowField 方案并在本 ADR 更新决策结论。

### 过渡策略

S1–S4 期间：所有敌人继续使用 Boid 模式，不实现寻路（维持已知性能基线）。  
S5 实现 Elite 敌人时：同步实现 `NavigationAgent2D` 方案并运行 P-S5-AI-01 基准测试，若达标则定案为 NavigationAgent2D；否则切换 FlowField。

---

## 9. Godot 项目结构 (Project Structure)

### 9.1 场景树结构 (Scene Tree)

#### 主场景（Main.tscn）

```
Main (Node)                          ← res://scenes/Main.tscn，程序入口
├── UILayer (CanvasLayer, layer=10)  ← 主菜单 / 过渡 / 全局遮罩
│   └── MainMenuUI (Control)
├── GameRoot (Node2D)                ← 战斗场景的挂载点（动态 add_child / remove_child）
└── [C# Manager 子节点]              ← ADR-L1 规则 4：C# 热路径作为对应 Autoload 的子节点
    ├── BulletManagerCs (Node)       ← 挂在 BulletManager Autoload 下
    ├── EnemyManagerCs (Node)        ← 挂在 EnemyManager Autoload 下
    ├── SpatialGridCs (Node)         ← 挂在 SpatialGrid Autoload 下（或独立 Autoload）
    ├── SpellEvaluatorCs (Node)      ← 挂在 SpellEvaluator Autoload 下
    ├── StatusManagerCs (Node)       ← 挂在 StatusManager Autoload 下
    └── ZoneManagerCs (Node)         ← 挂在 ZoneManager Autoload 下
```

> **注意**：C# 子节点由对应的 GDScript Autoload 在 `_ready()` 中通过 `add_child(BulletManagerCs.new())` 创建，不出现在 .tscn 文件中（避免场景文件依赖 C# 程序集，保持热重载友好）。

#### 战斗场景（BattleScene.tscn）

```
BattleScene (Node2D)                 ← res://scenes/BattleScene.tscn
├── World (Node2D)                   ← 世界坐标系（地图 TileMap 挂这里）
│   ├── TileMap (TileMap)            ← 地图地形（S1 起实现）
│   ├── BulletRenderRoot (Node2D)    ← 子弹 Node2D 池的挂载根（< 2000 弹幕时）
│   ├── MultiMeshRoot (Node2D)       ← MultiMeshInstance2D（2000+ 弹幕时动态启用）
│   ├── EnemyRenderRoot (Node2D)     ← 敌人 Sprite2D 节点池
│   ├── VFXRoot (Node2D)             ← VFXManager 的粒子节点池（跨场景挂 Autoload 自身）
│   └── ZoneVFXRoot (Node2D)         ← 地面效果贴图/Shader 渲染根
├── Player (CharacterBody2D)         ← 玩家节点（PlayerManager 的 View 层）
│   ├── Sprite2D
│   ├── AnimationPlayer
│   └── CollisionShape2D
└── HUDLayer (CanvasLayer, layer=5)  ← 战斗 HUD（血条、法术槽、波次信息）
    ├── HPBar (ProgressBar)
    ├── WaveLabel (Label)
    ├── SpellSlotPanel (HBoxContainer)
    ├── DamageNumberPool (Node)      ← 伤害数字节点池（弹出后自动归还）
    └── MiniMap (Control)            ← S3 实现
```

#### CanvasLayer 分层规则

| Layer | 用途 | 挂载场景 |
| :--- | :--- | :--- |
| 0 | 世界空间（无 CanvasLayer） | World 节点树 |
| 5 | 战斗 HUD（跟随 Viewport） | BattleScene > HUDLayer |
| 8 | 商店 / 背包 UI（模态）| ShopManager 动态创建 |
| 10 | 全局 UI（主菜单 / 过渡动画 / 暂停菜单）| Main > UILayer |
| 15 | 调试叠加层（仅 Debug 构建） | Main > DebugLayer |

---

### 9.2 Autoload 初始化顺序（project.godot 注册顺序）

Godot 按 `project.godot [autoload]` 中的声明顺序依次调用 `_ready()`。下表定义了强制顺序及原因：

| 顺序 | Autoload 名 | 文件 | 依赖（_ready() 中调用的其他 Autoload） |
| :--- | :--- | :--- | :--- |
| 1 | `CrashReporter` | `crash_reporter.gd` | 无（最先，捕获所有后续初始化崩溃） |
| 2 | `EventID` | `event_ids.gd` | 无（纯常量，1–21）|
| 3 | `DamageType` | `damage_type.gd` | 无（纯常量） |
| 4 | `StatusID` | `status_id.gd` | 无（纯常量） |
| 5 | `CoreFeatureTag` | `core_feature_tag.gd` | 无（纯常量） |
| 6 | `AudioBusID` | `audio_bus_id.gd` | 无（纯常量，Bus index 整数）|
| 7 | `ObjectPool` | `object_pool.gd` | 无（通用对象池，S0 起全局使用，impl §2.1 Layer-0）|
| 8 | `TimeManager` | `time_manager.gd` | 无（时间缩放/GameTick，impl §2.1 Layer-0）|
| 9 | `EventBus` | `event_bus.gd` | 无（事件总线基础设施，其他 Manager 订阅事件需要它先就绪） |
| 10 | `DamageContextPool` | `damage_context_pool.gd` | 无（独立池，预分配 64 个槽） |
| 11 | `SubPayloadRegistry` | `sub_payload_registry.gd` | 无 |
| 12 | `SpatialGrid` | `spatial_grid.gd` + `SpatialGridCs.cs` | 无（C# 子节点在其 _ready() 中 add_child） |
| 13 | `SpellRegistry` | `spell_registry.gd` | 无（同步扫描加载 .tres） |
| 14 | `PassiveRegistry` | `passive_registry.gd` | 无（同步扫描加载 PassiveDef .tres） |
| 15 | `StatusRegistry` | `status_registry.gd` | 无（同步扫描加载 StatusTypeDef .tres） |
| 16 | `ProfileManager` | `profile_manager.gd` | 无（存档读写；Run存档:run_a/b.json，设置:save_data.json）|
| 17 | `SettingsManager` | `settings_manager.gd` | `ProfileManager`（读取用户音量/辅助功能设置） |
| 18 | `BulletManager` | `bullet_manager.gd` + `BulletManagerCs.cs` | `SpatialGrid`（spawn 时写入格子） |
| 19 | `EnemyManager` | `enemy_manager.gd` + `EnemyManagerCs.cs` | `SpatialGrid`（敌人位置写入格子）, `DamageContextPool` |
| 20 | `MinionManager` | `minion_manager.gd` | `SpatialGrid`, `EnemyManager`（友伤判断）, `EventBus` |
| 21 | `StatusManager` | `status_manager.gd` + `StatusManagerCs.cs` | `StatusRegistry`, `DamageContextPool`, `EventBus` |
| 22 | `ZoneManager` | `zone_manager.gd` + `ZoneManagerCs.cs` | `SpatialGrid`, `StatusManager` |
| 23 | `SpellEvaluator` | `spell_evaluator.gd` + `SpellEvaluatorCs.cs` | `SubPayloadRegistry`, `BulletManager`, `DamageContextPool`, `EventBus` |
| 24 | `PlayerManager` | `player_manager.gd` | `SpellEvaluator`, `PassiveRegistry`, `EventBus`, `ProfileManager` |
| 25 | `VFXManager` | `vfx_manager.gd` | `EventBus` |
| 26 | `AudioManager` | `audio_manager.gd` | `SettingsManager`（初始音量）, `EventBus` |
| 27 | `UIManager` | `ui_manager.gd` | `EventBus`（订阅 GAME_STATE_CHANGED 等）|
| 28 | `CoreRegistry` | `core_registry.gd` | 无（纯只读注册表，扫描 res://resources/cores/）|
| 29 | `ConsumableRegistry` | `consumable_registry.gd` | 无（纯只读注册表，扫描 res://resources/consumables/）|
| 30 | `BossManager` | `boss_manager.gd` | `EnemyManager`, `EventBus` |
| 31 | `WaveManager` | `wave_manager.gd` | `EnemyManager`, `BossManager`, `EventBus` |
| 32 | `DropManager` | `drop_manager.gd` | `EnemyManager`, `PlayerManager`, `SpellRegistry`, `EventBus` |
| 33 | `UpgradeSystem` | `upgrade_system.gd` | `PlayerManager`, `SpellEvaluator`, `EventBus` |
| 34 | `ShopManager` | `shop_manager.gd` | `PlayerManager`, `SpellRegistry`, `PassiveRegistry`, `CoreRegistry`, `ConsumableRegistry`, `EventBus` |
| 35 | `GameCycleManager` | `game_cycle_manager.gd` | **所有以上 Manager**（协调者，最后初始化） |

> **初始化安全规则**：编号 N 的 Autoload 的 `_ready()` 中只能调用编号 < N 的 Autoload。若发现需要访问编号 ≥ N 的 Autoload，必须延迟到 `_ready()` 后的第一个 `_process()` 帧，或通过 `EventBus` 信号触发。
>
> **C# 子节点时机**：C# 子节点（`BulletManagerCs` 等）在其 GDScript Autoload 的 `_ready()` 中通过 `add_child()` 挂载。C# 子节点的 `_Ready()` 在 `add_child()` 返回前同步调用（Godot 规范），因此 C# 子节点可以安全访问其 GDScript 父节点，但不得直接访问尚未初始化的其他 Autoload（通过 `GetNode<T>("/root/XXX")` 延迟获取）。


