# 程序架构设计与实施计划 (Implementation Plan & Technical Architecture)

## 1. 核心设计原则 (Core Design Principles)

### 1.1 性能优先 (Performance First)
鉴于已确定的"海量弹幕+同屏千人"需求，本项目的核心代码将偏离标准的 Godot 节点组件模式，采用更底层的 **Data-Oriented (面向数据)** 思想。
*   **原则 A**: 战斗循环中尽量避免 `new`（GDScript 实例化）。所有高频对象（Context, Bullet, Enemy）必须池化。
*   **原则 B**: 逻辑与渲染分离。位置更新在 Autoload Manager 的 `_physics_process` 中批量处理，节点的 `_process` 仅负责同步 View。
*   **原则 C**: 避免匿名函数与 Lambda。解释器核心循环使用 `match` 和 `for` 循环，减少函数调用开销。

### 1.2 极度模块化 (Extreme Modularity)
法术系统的每个功能点（如：追踪、分裂）都必须封装为独立的 `Strategy` 类，通过 ID 动态加载。核心系统不能直接依赖具体法术。

**法术注册表 (Spell Registry)** 技术方案：
```gdscript
# spell_registry.gd (Autoload)
# 启动时自动扫描 res://resources/spells/ 目录，加载所有 .tres SpellNode Resource
var _registry: Dictionary = {}  # spell_id (String) -> SpellNode

func _ready() -> void:
    # ⚠️ F13 注意：当前使用同步 load()，主线程阻塞直到所有法术资源加载完毕。
    # 法术数量较少（< 50）时可接受；若未来资源增多，应改用异步预加载：
    #   ResourceLoader.load_threaded_request(path) → 在 Loading 界面 _process 中轮询
    #   load_threaded_get_status()，加载完成后发出信号，不阻塞主线程。
    # P2 路线图：引入 SpellRegistry.preload_async(on_complete: Callable) 接口。
    var dir := DirAccess.open("res://resources/spells/")
    if dir == null:
        push_error("SpellRegistry: 无法打开 res://resources/spells/ 目录")
        return
    dir.list_dir_begin()
    var file := dir.get_next()
    while file != "":
        if file.ends_with(".tres"):
            var res: SpellNode = load("res://resources/spells/" + file)
            if res != null:
                _registry[res.id] = res
            else:
                push_warning("SpellRegistry: 加载失败 - %s" % file)
        file = dir.get_next()
    dir.list_dir_end()  # ⚠️ 必须调用，否则目录句柄不会释放

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

func all_ids() -> Array:
    return _registry.keys()  # ⚠️ keys() 返回无类型 Array，不可声明为 Array[String]
                              # GDScript 4.x 中 Dictionary.keys() 的静态返回类型为 Array
```
*   新增法术只需在 `res://resources/spells/` 中放入一个 `.tres` 文件，无需修改任何代码。
*   `SpellNode.id` 字段必须全局唯一，推荐使用 `category_name` 格式（如 `modifier_damage_plus`）。

### 1.3 零 GC 与内存高效架构 (Zero-GC & Memory Efficiency)
GDScript 的垃圾回收虽不如 JavaScript 激进，但频繁实例化 RefCounted 对象同样会带来性能抖动。架构上强制执行以下模式：
*   **Struct-like Classes**: 核心算子类（如 `Vector2`）优先使用 GDScript 内置值类型（Value Type），它们在栈上分配，无 GC 压力。自定义数据类继承 `RefCounted` 并通过对象池复用。
*   **Ring Buffers**: 音频、粒子、伤害数字使用环形缓冲区，覆盖旧数据而非销毁。
*   **Integer IDs > Objects**: 在 `EventBus` 和 ECS 中，传递整数 ID (entity_id) 而非传递整个对象引用，减少引用计数复杂度。

---

## 2. 模块详细设计 (Detailed Module Design)

### 2.1 核心库层 (Layer 0: Core Libs)

| 模块 | 类名/文件 | 职责说明 | 性能关键点 |
| :--- | :--- | :--- | :--- |
| **Object Pool** | `ObjectPool` (Autoload) | 通用对象池，支持 `reset()` 接口。 | 使用 `Array` 做栈，`append`/`pop_back` 操作。 |
| **Event Bus** | `EventSystem` (Autoload) | 类型安全的全局事件总线。 | 使用整数 ID 代替字符串 Key，减少哈希查找开销；也可直接使用 Godot 原生 Signal。 |
| **Spatial Hash** | `SpatialGrid` | 2D 空间网格，替代 Quadtree。 | 使用 `PackedInt32Array` 一维数组模拟二维网格，直接索引访问 `grid[x + y * width]`。 |
| **Timer** | `TimeManager` (Autoload) | 缩放时间、帧率节流控制。 | 支持 `GameTick` (physics) 与 `RenderFrame` (process) 分离。 |

#### EventBus 事件目录 (Event Catalog)

所有跨系统通信必须通过此表对齐，不得私自定义事件 ID。

> **E-N1 规范**：事件 ID 必须使用以下具名常量（`EventID` 枚举或 Autoload 常量），禁止在业务代码中直接写裸整数，防止撞号和可读性问题。
```gdscript
# event_ids.gd (Autoload: EventID)
const APPLY_DAMAGE:       int = 1
const ENEMY_KILLED:       int = 2
const SPELL_CAST_BEGIN:   int = 3
const BULLET_HIT:         int = 4
const PLAYER_DAMAGED:     int = 5
const PLAYER_DIED:        int = 6
const WAVE_COMPLETE:      int = 7
const STATUS_APPLIED:     int = 8
const CHARGE_STATE_CHANGED: int = 9  # 停步蓄力进度变化 → UIManager
const ON_PLAYER_HURT:       int = 10 # 玩家受到伤害时触发（用于受击释放法术/反弹尖刺等 OnHurt 钩子）
const MINION_SPAWNED:        int = 11 # 召唤物创建 → UIManager 更新召唤物计数显示
const MINION_EXPIRED:        int = 12 # 召唤物消亡 → UIManager 更新召唤物计数显示
const DASH_TRIGGERED:        int = 13 # 玩家执行冲刺动作时触发（dash 输入按下瞬间）→ SpellEvaluator(OnDash 钩子，dash-wave Core 附加法术), UIManager(清除蓄力进度 UI)
                                       # 负载: { "caster_id": int, "stationary_time": float }（冲刺前已积累的静止时长，供 dash-wave Core 读取）
                                       # ⚠️ 与 CHARGE_FIRED(14) 的区别：DASH_TRIGGERED 由冲刺操作触发；CHARGE_FIRED 由停步静止时长首次超阈值触发（无冲刺）。
const CHARGE_FIRED:          int = 14 # 停步蓄力首次达到阈值时发出（仅一次，非每帧）→ SpellEvaluator(执行超载版法术)
                                       # 区别于 CHARGE_STATE_CHANGED（每帧进度，仅供 UI）：
                                       # CHARGE_STATE_CHANGED 持续更新进度环动画；
                                       # CHARGE_FIRED 是一次性触发信号，SpellEvaluator 只订阅此事件来实际释放法术。
                                       # 负载: { "caster_id": int }
const BOSS_PHASE_CHANGED:    int = 15 # ⚠️ 早期草案编号，未落地；实际实现为 29（见下表末尾 / event_ids.gd）
const BOSS_KILLED:           int = 16 # ⚠️ 早期草案编号，未落地；实际实现为 30（见下表末尾 / event_ids.gd）
const GAME_CLEARED:          int = 17 # ⚠️ 早期草案编号，未实现（Boss 通关演出为范围外）
const ACHIEVEMENT_UNLOCKED:  int = 18 # certification_checklist.md ST-10：Steam 成就解锁（须幂等）
                                       # 负载: { "achievement_id": String }
const SETTINGS_CHANGED:      int = 19 # architecture_design.md ADR-C1 / SettingsManager：字体缩放、高对比度等
                                       # 负载: { "key": String }（例：`font_scale`、`high_contrast`）
const GAME_STATE_CHANGED:    int = 20 # architecture_design.md §5.1：FSM 状态切换（GameCycleManager 发出）
                                       # 负载: { "prev": GameState, "next": GameState }
                                       # 订阅方：UIManager（路由 UI 切换）、AudioManager（切换 BGM/SFX）
const SPELL_DROP_PICKUP:     int = 21 # architecture_design.md §5.3：法术掉落拾取弹 UI 事件
                                       # 负载: { "spell_id": String, "auto_equip": bool }
                                       # 发出方：DropManager（掉落拾取）、ShopManager.offer_free_spell()
                                       # 订阅方：UIManager（弹背包选 Core 界面）、PlayerManager（auto_equip=true 时直接装备）
# 新增事件从 22 起递增，并向下表登记
```

| 事件 ID | 具名常量 | 名称 | 负载内容 | 发出方 | 订阅方 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `1` | `EventID.APPLY_DAMAGE` | `APPLY_DAMAGE` | `damage_context_id: int` | `SpellEvaluator` / `StatusManager` | `EnemyManager` |
| `2` | `EventID.ENEMY_KILLED` | `ENEMY_KILLED` | `enemy_id: int, killer_id: int` | `EnemyManager` | `PlayerStats`, `DropManager`, `MissionTracker` |
| `3` | `EventID.SPELL_CAST_BEGIN` | `SPELL_CAST_BEGIN` | `caster_id: int, deck_id: int` | `SpellEvaluator` | `VFXManager`, `AudioManager` |
| `4` | `EventID.BULLET_HIT` | `BULLET_HIT` | `bullet_id: int, target_id: int` | `BulletManager` | `SpellEvaluator`(proc), `VFXManager` |
| `5` | `EventID.PLAYER_DAMAGED` | `PLAYER_DAMAGED` | `amount: float, source_id: int` | `PlayerManager` | `UIManager`, `AudioManager` |
| `6` | `EventID.PLAYER_DIED` | `PLAYER_DIED` | 无 | `PlayerManager` | `GameCycleManager` |
| `7` | `EventID.WAVE_COMPLETE` | `WAVE_COMPLETE` | `wave_num: int` | `WaveManager` | `ShopManager`, `GameCycleManager` |
| `8` | `EventID.STATUS_APPLIED` | `STATUS_APPLIED` | `target_id: int, status_type: int, stacks: int` | `StatusManager` | `VFXManager`, `EnemyManager` |
| `9` | `EventID.CHARGE_STATE_CHANGED` | `CHARGE_STATE_CHANGED` | `caster_id: int, progress: float` | `PlayerManager` | `UIManager` |
| `10` | `EventID.ON_PLAYER_HURT` | `ON_PLAYER_HURT` | `amount: float, source_id: int` | `PlayerManager` | `SpellEvaluator`(OnHurt 钩子), `VFXManager`, `AudioManager` |
| `11` | `EventID.MINION_SPAWNED` | `MINION_SPAWNED` | `minion_id: int, owner_id: int, current_count: int` | `MinionManager` | `UIManager`(召唤物计数显示) |
| `12` | `EventID.MINION_EXPIRED` | `MINION_EXPIRED` | `minion_id: int, owner_id: int, current_count: int` | `MinionManager` | `UIManager`(召唤物计数显示) |
| `13` | `EventID.DASH_TRIGGERED` | `DASH_TRIGGERED` | `caster_id: int, stationary_time: float` | `PlayerManager` | `SpellEvaluator`(OnDash钩子，dash-wave Core 附加法术), `UIManager`(蓄力UI清除) |
| `14` | `EventID.CHARGE_FIRED` | `CHARGE_FIRED` | `caster_id: int` | `PlayerManager` | `SpellEvaluator`(执行超载版法术) |
| ~~`15`~~ | ~~`EventID.BOSS_PHASE_CHANGED`~~ | ~~`BOSS_PHASE_CHANGED`~~ | ⚠️ 早期草案编号，未落地；**实际见末尾 id 29** | — | — |
| ~~`16`~~ | ~~`EventID.BOSS_KILLED`~~ | ~~`BOSS_KILLED`~~ | ⚠️ 早期草案编号，未落地；**实际见末尾 id 30** | — | — |
| ~~`17`~~ | ~~`EventID.GAME_CLEARED`~~ | ~~`GAME_CLEARED`~~ | ⚠️ 早期草案编号，未实现（范围外） | — | — |
| `18` | `EventID.ACHIEVEMENT_UNLOCKED` | `ACHIEVEMENT_UNLOCKED` | `achievement_id: String` | 各游戏系统 | `AchievementManager` → Steam |
| `19` | `EventID.SETTINGS_CHANGED` | `SETTINGS_CHANGED` | `key: String` | `SettingsManager` | `UIManager`（全局主题/字体重载） |
| `20` | `EventID.GAME_STATE_CHANGED` | `GAME_STATE_CHANGED` | `prev: int, next: int` | `GameCycleManager` | `UIManager`, `AudioManager` |
| `21` | `EventID.SPELL_DROP_PICKUP` | `SPELL_DROP_PICKUP` | `spell_id: String, auto_equip: bool` | `DropManager`, `ShopManager` | `UIManager`, `PlayerManager` |
| `29` | `EventID.BOSS_PHASE_CHANGED` | `BOSS_PHASE_CHANGED` | `boss_id, boss_type, phase` | `BossManager` | `UIManager`（HUD 阶段提示） |
| `30` | `EventID.BOSS_KILLED` | `BOSS_KILLED` | `boss_id, boss_type, wave` | `BossManager` | `GameCycleManager`（结算）, `AchievementManager`（成就） |
> 新增事件从 **22** 起递增，并向下表登记。规则：编号永不重用（可标 DEPRECATED 但不删除）。

> **E-N2 跨语言边界协议（GDScript ↔ C#）**：`architecture_design.md §6` 规定跨语言只传基本类型（int/float/Vector2/Godot.Array）。`DamageContext`（RefCounted）**不得直接穿越语言边界**。约定如下：
> - `EnemyManager` 遵循与 `BulletManager` 相同的 **ADR-L1 规则 4** 模式：`enemy_manager.gd`（GDScript Autoload）持有 `_data: PackedFloat32Array` 并对外暴露接口；`EnemyManagerCs.cs`（C# 子节点）处理 `_PhysicsProcess` 热路径（Boid 分离力、SoA 积分），预期帧时间 GDScript ≈6ms → C# ≈0.6ms（见 §6.1 语言分工表）。
> - `APPLY_DAMAGE` 事件的处理逻辑位于 GDScript Autoload 层，负载 `damage_context_id: int` 是 `DamageContextPool` 的槽位索引，GDScript 层调用 `DamageContextPool.get_context(id)` 取得对象后处理伤害，处理完毕立即 `release(id)`。`DamageContext` 不穿越 GDScript ↔ C# 语言边界。

> **DamageContextPool 定义**：E-N2 中引用的 `DamageContextPool` 为伤害上下文对象池 Autoload，定义如下：
> ```gdscript
> # ── damage_context.gd ──────────────────────────────────────────────────────────
> # DamageContext 数据类必须单独放在 damage_context.gd 中，
> # 禁止将 class_name DamageContext 写在 damage_context_pool.gd 里（否则 Autoload 实例自身就是
> # DamageContext 类型，导致 _pool: Array[DamageContext] 存入了具有池管理方法的对象，语义错误）。
> class_name DamageContext
> extends RefCounted
> var base_damage: float = 0.0
> var mult:        float = 1.0
> var damage_type: int   = DamageType.PHYSICAL  # 使用枚举常量，禁止裸整数
> var owner_id:    int   = -1
> var source_tags: int   = 0
> var is_crit:     bool  = false    # 影响暴击字体/特效（见 combat_mechanics_depth.md §4）
> var pierce_rate: float = 0.0      # 护甲穿透率 (0.0~1.0)，EnemyManager.apply_damage() 中使用
>
> # ── damage_context_pool.gd (Autoload: DamageContextPool) ──────────────────────
> # 本文件不声明 class_name；由 Godot Project Settings 注册为 Autoload "DamageContextPool"。
> # 采用固定大小槽位数组 + PackedInt32Array 空闲 ID 栈：O(1) 申请/归还，零动态分配，零 GC。
> const POOL_SIZE: int = 64
> var _slots:    Array[DamageContext]   # 固定槽位，索引即 ID（预分配，_ready() 填充）
> var _free_ids: PackedInt32Array       # 空闲 ID 栈（append/pop_back，O(1)，零 GC）
> var _in_use:   PackedByteArray        # 占用标记：1=已 acquire；防重复 release
>
> func _ready() -> void:
>     _slots.resize(POOL_SIZE)
>     _free_ids.resize(POOL_SIZE)
>     _in_use.resize(POOL_SIZE)
>     for i in POOL_SIZE:
>         _slots[i]    = DamageContext.new()
>         _free_ids[i] = POOL_SIZE - 1 - i  # 初始化：index 0 最先被弹出
>         _in_use[i]   = 0
>
> # ⚠️ 必须先调用 ctx.reset()，防止从池中取出的旧实例残留 source_tags 等脏数据。
> func acquire(base_dmg: float, m: float, dtype: int, owner: int,
>              crit: bool = false, pierce: float = 0.0) -> int:
>     if _free_ids.is_empty():
>         push_warning("DamageContextPool: 池已耗尽，动态扩展（POOL_SIZE=%d 建议上调）" % POOL_SIZE)
>         var new_id: int = _slots.size()
>         _slots.append(DamageContext.new()); _in_use.append(0)
>         return _fill_slot(new_id, base_dmg, m, dtype, owner, crit, pierce)
>     var id: int = _free_ids[_free_ids.size() - 1]
>     _free_ids.resize(_free_ids.size() - 1)  # pop_back，O(1)
>     return _fill_slot(id, base_dmg, m, dtype, owner, crit, pierce)
>
> func _fill_slot(id: int, base_dmg: float, m: float, dtype: int, owner: int,
>                 crit: bool, pierce: float) -> int:
>     var ctx: DamageContext = _slots[id]
>     ctx.reset()  # 清除所有残留字段后再覆写，保证每次 acquire 的上下文状态干净
>     ctx.base_damage = base_dmg; ctx.mult = m
>     ctx.damage_type = dtype;    ctx.owner_id = owner
>     ctx.is_crit = crit;         ctx.pierce_rate = pierce
>     _in_use[id] = 1
>     return id
>
> func get_context(id: int) -> DamageContext:
>     if id < 0 or id >= _slots.size() or _in_use[id] == 0: return null
>     return _slots[id]
>
> func release(id: int) -> void:
>     if id < 0 or id >= _slots.size() or _in_use[id] == 0:
>         push_warning("DamageContextPool.release: 无效或重复归还 ID=%d" % id)
>         return
>     _in_use[id] = 0
>     _free_ids.append(id)   # push_back，O(1)，不触发 GC
>
> func reset() -> void:
>     # GameCycleManager._reset_all_managers() 在波次/Run 重置时调用。
>     # 将所有 in-use 槽位强制归还（防止跨波次泄漏），清空空闲 ID 栈后重建。
>     _free_ids.clear()
>     for i in _slots.size():
>         _in_use[i] = 0
>         _free_ids.append(i)
> ```
> **使用约定**：`SpellEvaluator` 发出 `APPLY_DAMAGE` 前调用 `DamageContextPool.acquire(...)` 获取整数 ID；
> `EnemyManager` 收到事件后调用 `get_context(id)` 处理伤害，处理完毕立即调用 `release(id)` 归还，
> 不得持有 DamageContext 跨帧引用。

### 2.2 法术解释器层 (Layer 1: Spell Virtual Machine)

这是本项目的"CPU"，负责执行 `SpellNode` 指令。

#### 数据结构

**SpellDeck** — 运行时执行游标
```gdscript
class_name SpellDeck
# _nodes: Array[SpellNode]，预编译后不再修改
# _cursor: int，当前执行位置
#
# Scope 边界标记：预编译时每个 SCOPE_OPEN 节点（LOGIC_IF_*、TRIGGER 后续体）
#   在 _nodes 中紧跟虚拟节点 SpellNode{type=SCOPE_CLOSE, id="_scope_end"}。
#   skip_until_scope_end() 推进 cursor 直到遇到第一个 SCOPE_CLOSE（深度计数器处理嵌套）。
#   consume_until_scope_end() 同理，返回中间节点序列用于打包 SubPayload。
#   O(N) 线性扫描定位边界，N ≤ MAX_OPS_PER_CPU × cpu_limit。
func has_next() -> bool: return _cursor < _nodes.size()
func pop() -> SpellNode:
    # 完整实现须包含 _consumed 掩码跳过（见 architecture_design.md §3.4.C）
    if _cursor >= _nodes.size(): return null
    var node := _nodes[_cursor]
    _cursor += 1           # GDScript 不支持后置 ++ 运算符
    return node
func skip_until_scope_end() -> void: ...
func consume_until_scope_end() -> Array[SpellNode]: ...
```

**CompiledDeck** — 预编译产物，关卡期间只读
```gdscript
class_name CompiledDeck
# compile_wand(core: CoreDefinition, raw_deck: SpellDeck) -> CompiledDeck
# 由 SpellEvaluator 在背包关闭时调用
#
# nodes: Array[SpellNode]      拓扑扁平化 + SCOPE 虚节点插入后的线性序列
# label_table: Dictionary      { label_id: int -> node_index: int }（LOGIC_LABEL 专用）
# sub_payload_ids: Array[int]  本 Deck 预编译时注册到 SubPayloadRegistry 的所有 ID
# topology_type: int           0=LINEAR 1=MATRIX 2=CIRCUIT（影响 _flatten_* 选择）
# checksum: int                插槽内容哈希，用于判断是否需要重新编译
#   算法：多项式滚动哈希（对换位敏感，避免 [A,B] 与 [B,A] 产生相同 checksum）：
#     checksum = topology_type ^ slot_count ^ (grid_cols << 8) ^ hash(core.id)
#     // grid_cols 防止不同列宽的 MATRIX Core 碰撞（2×4 与 4×2 slot_count 相同）
#     // hash(core.id) 防止不同 Core ID 但拓扑相同时 checksum 碰撞导致跳过重编译
#     for i in range(nodes.size()):
#         checksum = checksum * 31 ^ hash(nodes[i].id)
#
# 运行时 SpellEvaluator 从 CompiledDeck 构造一个 SpellDeck（拷贝 nodes 并重置 cursor）
var nodes: Array[SpellNode]
var label_table: Dictionary
var sub_payload_ids: Array[int]
var topology_type: int = 0
var checksum: int = 0
```

**CastState** — 单次 execute 调用的临时状态
```gdscript
# trigger_depth: int，当前 execute_sub 嵌套深度（防止 TRIGGER 无限递归）
# registers: PackedFloat32Array，长度 4，R1-R4
# ⚠️ 重要区分：
#   CastState.registers  → 单次 execute() 内的临时工作寄存器，每次 execute() 开始时重置，不跨调用保留
#   SpellContext.registers → 跨帧持久寄存器（见 architecture_design.md §3.2）
# LOGIC_EVERY_N_SHOTS【必须】使用 SpellContext.registers（否则计数器每帧重置，永远等效为"每次都触发"）。
# _execute_logic() 收到 LOGIC_EVERY_N_SHOTS 时通过 spell_ctx 参数访问 spell_ctx.registers[0]。
```

#### 执行流程 (`SpellEvaluator.execute`)
```gdscript
# MAX_OPS_PER_CPU = 40：每点玩家属性 cpu_limit 换算为 40 步执行额度
# 运行时：_max_ops = PlayerStats.cpu_limit × MAX_OPS_PER_CPU（默认 cpu_limit=5 → MAX_OPS=200）
# 超过限制立即中断，防止图灵完备构建死循环
const MAX_OPS_PER_CPU: int = 40
const MAX_TRIGGER_DEPTH: int = 3  # execute_sub 嵌套最大深度；超过 3 层时 TRIGGER 不再触发新的 SubPayload
                                  # 分工：MAX_OPS 管单次施法内指令总量；MAX_TRIGGER_DEPTH 管跨弹体调用深度
                                  # ⚠️ 超限时不得静默失效：当 execute_sub 被 TRIGGER_DEPTH 隔断时，
                                  # EventBus 发出 SPELL_CAST_BEGIN 事件并附带 flag "depth_exceeded=true"，
                                  # UIManager 订阅该 flag，在 HUD 显示短暂黄色闪烁（"[法术链太深，部分触发已忽略]"）。
                                  # 开发期保留 push_warning；正式版将 push_warning 改为发出事件。
var _max_ops: int = 200  # 战斗开始时更新：_max_ops = PlayerStats.cpu_limit * MAX_OPS_PER_CPU

while deck.has_next() and ops_count < _max_ops:
    ops_count += 1
    var node: SpellNode = deck.pop()
    match node.type:
        SpellType.ACTION:
            _push_projectile(node, context)
        SpellType.MODIFIER:
            node.apply(context)  # 直接修改 context.stats
            # Multicast 边缘规则：若 Deck 剩余法术数 < draw_count，以"尽力而为"策略执行
            # （弹出现有法术直至 Deck 耗尽，不崩溃），法杖编辑 UI 对此显示黄色警告。
        SpellType.TRIGGER:
            # 实现方案：预编译时打包 SubPayload，运行时查表执行
            # 1. 将 TRIGGER 后续的 SpellNode 序列打包为 SubPayload，注册到全局表，得到整数 ID
            # 2. 子弹在 PackedFloat32Array 中仅携带 on_hit_payload_id (int)
            # 3. 命中时 BulletManager 查表并调用 SpellEvaluator.execute_sub(payload_id, hit_pos, owner_id)
            # 这样子弹逻辑与 PackedFloat32Array 热数据完全解耦
            var payload_id: int = SubPayloadRegistry.register(deck.consume_until_scope_end())
            context.current_payload.on_hit_payload_id = payload_id
        SpellType.LOGIC:
            # 最小指令集（详细行为见 architecture_design.md §3.4.B）：
            # LOGIC_IF_HP_BELOW     → 读 caster HP%，条件不满足则 deck.skip_until_scope_end()
            # LOGIC_IF_ENEMY_NEARBY → SpatialGrid 查询 context.range 内是否有敌方实体
            # LOGIC_EVERY_N_SHOTS   → 读写 spell_ctx.registers[0..3]（SpellContext，跨帧持久！）
            #                         ⚠️ 严禁使用 CastState.registers（每帧重置 → 计数器永远不累积）
            # LOGIC_LOOP(count, body_size)
            #   → 将紧随其后的 body_size 个节点重复执行 count 次（count ≤ 8，超限裁剪）
            #   → 每次循环消耗 body_size 个 ops；总耗费 = count × body_size，计入 ops_count
            #   → 与 MAX_OPS 交互：LOOP(5, 3) = 15 ops；若 ops_count + 15 > _max_ops 则提前截断
            #   → game_design.md §3.2.D 中「循环执行后续 3 个法术 5 次」即为 LOGIC_LOOP(5, 3)
            # LOGIC_LABEL / LOGIC_JUMP_IF → 仅限 persistent_memory Core；预编译时建立 _label_table
            # LOGIC_FORK  → CIRCUIT 拓扑分叉专用（由 _flatten_circuit 注入，玩家不可购买）
            #   node.id = "LOGIC_FORK"；node.get_meta("fork_branch_ids") = Array[int] SubPayload ID 列表
            #   _execute_logic 识别 LOGIC_FORK 时对每个 fork_branch_id 调用 execute_sub()
            #   消耗 fork_branch_ids.size() 个 ops（每个分支算 1 步），计入 ops_count
            _execute_logic(node, context, deck)
# ops_count 达到上限时：① 开发期 push_warning；② 通过 EventBus 向玩家 HUD 发出反馈
# 截断不得静默，玩家须知构建已被截断（与 MAX_TRIGGER_DEPTH "depth_exceeded" 事件相同规范）。
# UIManager 订阅 SPELL_CAST_BEGIN 中 "ops_exceeded: true" 标志，HUD 显示短暂黄色闪烁提示。
if ops_count >= _max_ops:
    push_warning("SpellEvaluator: MAX_OPS reached, build may be too complex")
    EventBus.emit(EventID.SPELL_CAST_BEGIN, {
        "caster_id": context.caster_id, "deck_id": -1, "ops_exceeded": true
    })
```

> **LOGIC 指令集扩展路径**：当前 `_execute_logic` 使用中央 `match node.id` 硬编码所有 LOGIC 指令，新增 LOGIC 类型需修改核心（OCP 限制）。
> **P0/P1**：保留 match 实现，LOGIC 指令集固定不超过 8 条，可接受。
> **P2 路线图**：若 LOGIC 指令集需扩展超过 10 条，引入 `LogicHandler` 接口：
> ```gdscript
> # logic_handler.gd
> class_name LogicHandler
> extends RefCounted
> func handle(node: SpellNode, context: SpellContext, deck: SpellDeck) -> void: pass
>
> # 在 SpellRegistry 中按 node.id 注册：
> # SpellRegistry.register_logic_handler("LOGIC_MY_NEW", MyNewHandler.new())
> # _execute_logic 改为：
> # SpellRegistry.get_logic_handler(node.id).handle(node, context, deck)
> ```
> 迁移前无需改变 P0/P1 行为，仅 P2 新增 LOGIC 法术卡采用新接口注册。

#### SubPayloadRegistry 生命周期
*   **创建时机**：玩家关闭背包（预编译）时，`SpellEvaluator.compile_wand(core, raw_deck)` 清空并重建整个 Registry。
*   **清空时机**：关卡切换、游戏结束时全量清除。滚动最终销毁所有未使用的 SubPayload 对象还入对象池。
*   **并发处理**：多弹一帧内同时命中时，`execute_sub` 可并行执行同一 `payload_id`， Registry 不发生冲突（只读）。
*   **竞争条件安全保证**：Registry 重建发生在玩家**关闭**背包时，而非打开时。
    因此飞行中的弹体在玩家进入商店编辑阶段时，其 `on_hit_payload_id` 仍指向当前 Registry 中的有效条目——
    战斗波次结束后所有弹体自然清空，Registry 重建（下次关闭背包）必定早于下波战斗开始；
    不存在"弹体持有旧 payload_id、Registry 已重建为新 ID 映射"的竞争窗口。
    > **禁止行为**：不得在波次**进行中**允许玩家修改 Deck（如暂停菜单热插拔法术），否则将违反此不变量。
    > 如未来需支持"战斗中暂停换牌"，必须先清空所有飞行中的弹体，再执行重编译，最后恢复生成。

### 2.3 战斗实体层 (Layer 2: Combat ECS-Lite)

#### A. 物理与碰撞 (Physics & Collision)

碰撞系统采用**分档策略**，不同弹幕密度区间使用不同方案；`BulletManager._collision_mode` 标志控制当前路径（详见 `architecture_design.md §4.3`）：

| 同屏弹幕数 | 碰撞方案 | 说明 |
| :--- | :--- | :--- |
| < 200 | **Area2D + CircleShape2D** | Godot 内置碰撞；Physics Layer/Mask 过滤阵营；`body_entered` 信号回调 `_on_bullet_hit()` |
| 200–2000 | **自定义 SpatialGrid（主力）** | BulletManager 每帧 dirty-list 重建网格，手动 `query_circle()`；Area2D 实例全部禁用 |
| 2000+ | **SpatialGrid + MultiMesh** | 碰撞继续走 SpatialGrid；渲染切换 MultiMeshInstance2D |

> **⚠️ S0 验证目标（风险 R-04）**：S0 期间在 500+ 弹幕时实测 Area2D 帧时间（预期 > 8ms）。验证后**永久锁定 SpatialGrid 为 200+ 弹幕的主力碰撞方案**，Area2D 路径不再作为运行时"降级回退"保留。

*   **Physics Layer & Mask 配置**（< 200 弹幕 Area2D 路径）：Layer 1 = 玩家，Layer 2 = 子弹，Layer 3 = 敌人；子弹 Layer 2 只检测 Layer 3；`body_entered` → `_on_bullet_hit(bullet_id, enemy_id)`。
*   **SpatialGrid 主路径**（200+）：两路径最终均调用相同的 `_on_bullet_hit(bullet_id, enemy_id)` 处理函数，后处理逻辑完全共用；切换入口仅在 `_collision_mode` 标志处。

#### B. 子弹系统 (`BulletManager`)
*   **混合渲染模式**:
    *   **逻辑层**: 依然使用 `PackedFloat32Array` 管理子弹的生命周期和属性（速度/伤害），SoA 布局保证缓存友好。
    *   **表现层**: 利用 Node Pool 复用 `Node2D` 节点；对于极大量同类子弹，改用 `MultiMeshInstance2D` 直接 GPU 实例化渲染，大幅降低 Draw Call。
    *   **同步优化**: 只有当子弹位置发生显著变化或处于屏幕视口内时，才设置 `Node2D.position`，利用 Godot 的脏标记机制避免无效渲染树更新。

#### C. 敌人系统 (`EnemyManager`)

**EnemyManager 热数据 SoA 布局（权威定义）**

敌人数量可达 1000+，同 BulletManager 一样需要 PackedFloat32Array SoA 结构：

```
const ENEMY_STRIDE: int = 8

stride = ENEMY_STRIDE 个 float
[ x, y, vx, vy, hp, hp_max, faction_and_type, status_bits ]
  0   1   2   3   4    5          6                 7
```
- `faction_and_type`：高 16 位 = faction（0=敌方, 1=友方/召唤物），低 16 位 = enemy_type_id
- `status_bits`：燃烧/冰冻/中毒等状态的位掩码（各类 DoT 的精确数值存冷数据）
- 复杂数据（AI 状态、DoT 计时器、combo_tracker）存入 `_enemy_contexts: Dictionary`（key = entity_id）

**SpatialGrid 每帧清空策略（P5）**

`SpatialGrid` 每 `_physics_process` 帧重建一次，采用 **Dirty-List 方案**而非全量 memset：
- `_dirty_cells: PackedInt32Array` 记录本帧有写入的 Cell 索引。
- 帧末遍历 `_dirty_cells`，仅清空这些 Cell，跳过空 Cell。
- 当 `_active_count > 1500` 时切换为全量 fill(0)（此时脏格子数量接近总数，遍历反而更慢）。

*   **AI 状态机**：AI 逻辑**不使用 AnimationTree 驱动**，而是以轻量级枚举存于 `_enemy_contexts[entity_id]["ai_state"]`（0=IDLE / 1=CHASE / 2=ATTACK / 3=RETREAT）。`EnemyManager._physics_process` 在 Manager 内部批量完成所有状态转换，不经过 Godot 场景树。
    *   **单向驱动**：帧末对**屏内**敌人（`_visible_flags[i] == 1`）将 `ai_state` 映射为 AnimationTree 参数（`anim_tree.set("parameters/conditions/is_running", ...)`），AnimationTree **仅接收结果，不反向影响逻辑**；屏外敌人完全跳过 AnimationTree 更新。
    *   **⚠️ 禁止**：不得用 AnimationTree StateMachine 驱动 AI 逻辑转换；不得在 `_physics_process` 热路径内对屏外敌人调用 `anim_tree.set()`（大量屏外时 1000× 调用/帧严重浪费）。
*   **LOD（屏外 AI 剔除）**：❌ **不使用** `VisibilityNotifier2D` per-enemy 方案（1000+ 敌人各挂一个节点 = 1000+ 额外信号监听，节点开销显著）。改为 **Manager 侧 Camera Rect AABB 批量剔除**：
    ```gdscript
    # ⚠️ _visible_flags 必须声明为 EnemyManager 的类成员（PackedByteArray），
    # 禁止在 _physics_process 内用 var 声明（否则每帧分配新数组，触发 GC 抖动）。
    # 类成员声明（EnemyManager 顶部）：
    #   var _visible_flags: PackedByteArray = PackedByteArray()
    # 每当 _enemy_count 扩容时同步 _visible_flags.resize(_enemy_count)
    #
    # EnemyManager._physics_process 每帧执行（O(N) 单次矩形测试）：
    var cam_rect: Rect2 = _get_camera_rect_expanded(128.0)  # 扩大 128px 防闪烁
    for i in range(_enemy_count):
        var pos := Vector2(_data[i * ENEMY_STRIDE + 0], _data[i * ENEMY_STRIDE + 1])
        _visible_flags[i] = int(cam_rect.has_point(pos))  # ⚠️ 必须显式 int() 转换
                                                           # has_point() 返回 bool，
                                                           # 直接赋给 PackedByteArray[i] 会静默写入错误值
    # _physics_process 遍历时跳过 _visible_flags[i] == 0 的实体 AI 计算
    ```
    *   相比 VisibilityNotifier2D，此方案无额外节点、无信号开销，N=1000 时约节省 ~1000 次信号 dispatch/帧，适合 ECS-Lite 架构。

> **屏外 Boid 分离力低频保留**：完全跳过屏外敌人 AI 会导致 Boid 分离斥力缺失——大量屏外实体相互挤叠，进入视野瞬间爆发弹射（视觉闪烁）。
> **解决方案**：`_visible_flags[i] == 0` 的屏外敌人**不跳过分离力更新**，改为以低频单独运行：
> ```gdscript
> const OFFSCREEN_SEPARATION_INTERVAL: int = 10  # 每 10 帧更新一次屏外分离力
>
> for i in range(_enemy_count):
>     if _visible_flags[i]:
>         _update_ai_full(i)            # 全量 AI：索敌 + 分离力 + 动画参数
>     elif Engine.get_physics_frames() % OFFSCREEN_SEPARATION_INTERVAL == i % OFFSCREEN_SEPARATION_INTERVAL:
>         _update_separation_only(i)    # 仅分离斥力：查 SpatialGrid 最近 8 格，O(1)
> ```
> - `_update_separation_only` 无 AI 状态机、无动画更新，每帧参与计算的屏外实体数 ≤ N/10。
> - 10 帧低频下最坏分离响应时延 ≈ 166ms（60 Hz），不可感知；可消除进视野瞬间弹射。

### 2.4 状态效果系统 (`StatusManager`)

#### 核心常量

```gdscript
# status_manager.gd (Autoload: StatusManager)
# _physics_process 热路径见 csharp/autoloads/StatusManagerCs.cs (ADR-L1)

## 每个实体允许同时存在的最大状态类型数量（STATUS_BATCH_LIMIT）
const STATUS_BATCH_LIMIT: int = 8

## 同一状态类型的最大叠加层数上限
const STATUS_MAX_STACKS: int = 16
```

**设计依据**：单实体最多 8 种状态类型。`_physics_process` 最坏情况 = 1000 敌人 × 8 状态 = 8000 次 tick 检查/帧，仍在帧预算内（ADR-L1 规定 StatusManagerCs < 1.5ms）。

#### 超限淘汰策略（Eviction）

当某实体活跃状态数 = `STATUS_BATCH_LIMIT` 且接收到新状态时，按以下顺序决策：

| 优先级 | 规则 |
| :--- | :--- |
| 1（最高）| 同类型 → 刷新 duration，按叠加规则增减 stacks，**不占新槽** |
| 2 | 同组升级（如 CHILL→FREEZE）→ 替换，保留剩余 duration，**不增加槽数** |
| 3 | LRU 淘汰 → 踢出 `last_applied_time` 最早的状态，空槽后写入新状态 |
| 4（最低）| 新状态优先级 < 所有现存状态 → 静默丢弃，`push_warning` 记录 |

```gdscript
## 状态分组（同组内高等级覆盖低等级）
const STATUS_GROUP: Dictionary = {
    StatusType.BURN:    "fire_dot",   StatusType.SCORCH:  "fire_dot",
    StatusType.POISON:  "toxic_dot",  StatusType.CORRODE: "toxic_dot",
    StatusType.CHILL:   "cold",       StatusType.FREEZE:  "cold",
    StatusType.SLOW:    "cc",         StatusType.STUN:    "cc",
}
```

#### DoT Tick 规则

```gdscript
// StatusManagerCs.cs —— _physics_process 热路径（C#）
// tick_timer 使用 while 模式，与 ZoneManager 规范一致：
// while (tickTimer >= TICK_INTERVAL) { tickTimer -= TICK_INTERVAL; ApplyDot(entityId, status); }
// 最坏情况：STATUS_BATCH_LIMIT = 8 种 × 1000 敌人 = 8000 次检查/帧
```

#### 与 ZoneManager 的交互规则

- ZoneManager 通过 `StatusManager.apply(entity_id, type, stacks, duration=0, source_id=zone_id)` 施加持续性区域 DoT。
- `duration=0` 表示"持续存在直到来源主动移除"；区域销毁时调用 `StatusManager.remove_source(zone_id)` 批量清除。
- **同一来源的相同状态不占新槽**，直接刷新 timer（防止多个同类型区域叠满 8 槽）。

### 2.5 基础服务框架 (Layer 3: System Services)
使用 Godot 4 的 **InputMap** 系统作为底层驱动，在上层封装一层 **"Action-Based"** 的抽象。
*   **抽象层**: 游戏逻辑通过 `Input.get_vector("move_left", "move_right", "move_up", "move_down")` 获取移动向量，不关心具体设备。
*   **设备支持**:
    *   **键盘/鼠标**: `WASD` 在 InputMap 中映射为 `move_*` Action，鼠标位置通过 `get_global_mouse_position()` 获取瞄准方向。
    *   **虚拟摇杆**: 监听 UI 层触摸事件，转换为 (-1, 1) 的向量，映射到同名 Action。
    *   **手柄 (Gamepad)**: 利用 Godot 内置的 `Input.get_joy_axis()` 和 `Input.is_joy_button_pressed()`，InputMap 已支持手柄轴自动绑定。
        *   **自动适配**: 识别 `Input.get_joy_name(device_id)` 来区分 Xbox / PS 的按键图标显示。
        *   **死区处理**: 在 InputMap 中直接配置 `Deadzone` 参数，或在 Manager 层统一处理防止摇杆漂移。

> **PlayerManager 移动状态追踪**：`game_design.md §5` 描述了移动敏感型法术
> （移动增益型、冲刺波、停步蓄力型），这些功能要求 `PlayerManager` 持续追踪玩家运动状态：
> ```gdscript
> # player_manager.gd（相关字段，非完整类定义）
> var _is_moving: bool = false             # 当前帧是否在移动（move_vector.length() > MOVE_THRESHOLD_NORM）
> var _stationary_time: float = 0.0       # 连续静止时长（秒）；移动时立即清零
> var _charge_triggered: bool = false     # 停步蓄力已触发标志；边沿检测，防止每帧重复发出 CHARGE_FIRED
> var _last_dash_time: float = -999.0     # 上次冲刺时刻（用于冲刺波冷却）
> # MOVE_THRESHOLD_NORM：归一化输入轴幅度（0.0~1.0），非像素/秒速度
> # 0.067 = 约 6.7% 摇杆偏移即判定为移动（死区 + 轻微触碰阈值）
> const MOVE_THRESHOLD_NORM: float = 0.067  # 对应约 20px/s @ 300px/s 最大速度
> const CHARGE_TRIGGER_TIME: float = 1.5  # 停步蓄力法术所需静止时长（秒）
>
> func _physics_process(delta: float) -> void:
>     var move_vec := Input.get_vector("move_left", "move_right", "move_up", "move_down")
>     _is_moving = move_vec.length() > MOVE_THRESHOLD_NORM
>     if _is_moving:
>         if _stationary_time > 0.0:
>             _stationary_time = 0.0
>             _charge_triggered = false   # 移动后重置标志，允许下次停步重新触发
>             EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": player_id, "progress": 0.0})
>     else:
>         _stationary_time += delta
>         if _stationary_time >= 0.3:   # 0.3 秒后开始显示蓄力进度（game_design.md §5）
>             var prog := clampf(_stationary_time / CHARGE_TRIGGER_TIME, 0.0, 1.0)
>             EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": player_id, "progress": prog})
>         # 边沿检测：仅首次越过阈值时发出一次 CHARGE_FIRED；持续静止不会重复发送
>         # SpellEvaluator 只订阅 CHARGE_FIRED 以执行停步蓄力法术（非 CHARGE_STATE_CHANGED）。
>         if _stationary_time >= CHARGE_TRIGGER_TIME and not _charge_triggered:
>             _charge_triggered = true
>             EventBus.emit(EventID.CHARGE_FIRED, {"caster_id": player_id})
>
>     # 冲刺输入检测（冲刺波 Core 专属）
>     if Input.is_action_just_pressed("dash"):
>         _last_dash_time = Time.get_ticks_msec() / 1000.0
>         # 附带 stationary_time：SpellEvaluator 用于判断蓄力时长奖励；UIManager 用于清除蓄力 UI
>         EventBus.emit(EventID.DASH_TRIGGERED, {
>             "caster_id": player_id,
>             "stationary_time": _stationary_time   # 冲刺前已积累的静止时长（秒）
>         })
>         _stationary_time = 0.0  # 冲刺后立即清零
>         _charge_triggered = false  # 冲刺打断蓄力，重置触发标志允许下次重新蓄力
>         # SpellEvaluator 订阅 DASH_TRIGGERED，触发 dash-wave Core 的附加法术释放
> ```
> **设计约束**：`CHARGE_STATE_CHANGED` 事件仅由 `UIManager` 订阅以驱动蓄力进度环动画；
> `SpellEvaluator` 订阅 `CHARGE_FIRED`（一次性边沿触发信号）以实际释放停步蓄力法术。
> 两者均通过 EventBus 解耦，`PlayerManager` 不直接持有 UI 引用或 SpellEvaluator 引用。
> ⚠️ 禁止让 `SpellEvaluator` 订阅 `CHARGE_STATE_CHANGED` 并在 `progress >= 1.0` 时触发法术——
> 该事件每帧发送（60Hz），会导致蓄力法术每帧重复释放，违反"首次越阈值只触发一次"的设计原则。

#### B. 音频系统 (`AudioManager`)
针对"割草"游戏的高并发特性进行封装。
*   **Audio Pool**: 预先实例化 32 个 `AudioStreamPlayer2D` 节点，循环使用。
*   **Throttling (节流)**: 维护一个 `last_played_time` 字典。
    *   *规则*: 如果 `explosion_sound` 在 0.1s 内已经播放过，且新的请求音量没有更大，则丢弃该请求。这避免了 50 个敌人同时炸裂时的爆音。
*   **Spatial (空间感)**: 使用 `AudioStreamPlayer2D` 的内置衰减曲线，或根据摄像机位置动态调整 `volume_db` 和 `panning_strength`。

#### C. 资源管理 (`ResourceManager`)
*   **后台加载**: 使用 `ResourceLoader.load_threaded_request(path)` 异步加载法术图标、特效场景等，避免卡顿。
*   **分组管理**: 利用 Godot 的 `ResourceGroup` 或按文件夹路径约定管理资源（如 `res://resources/spells/`、`res://scenes/enemies/`）。
*   **引用计数**: Godot 的 `Resource` 本身基于引用计数，场景卸载时未被引用的资源自动释放。对于手动预加载的资源，切换场景时显式 `queue_free()` 对应节点以触发释放。

#### D. 特效管理 (`VFXManager`)

`VFXManager` 是 Autoload 单例，所有视觉特效（命中闪光、爆炸、DoT 气泡等）必须通过此管理器播放，**禁止在逻辑层直接操作 Node**。

```gdscript
# vfx_manager.gd (Autoload)
# 职责：接收逻辑事件并以数据驱动方式触发对应特效，逻辑层与表现层完全解耦

const MAX_ACTIVE_VFX: int = 200   # 同屏最大活跃特效数量上限
var _vfx_pool: Dictionary = {}    # { "effect_id": Array[GPUParticles2D] } — 按类型分池
# VFX 场景预加载表；_ready() 中按配置填充，play() 通过此表实例化节点
# 例: _vfx_scenes["hit_spark"] = preload("res://scenes/vfx/hit_spark.tscn")
var _vfx_scenes: Dictionary = {}  # { "effect_id": String -> PackedScene }
# 维护活跃特效计数器（O(1) 计数，不在 play() 中遍历 pool 统计）
var _active_count: int = 0        # 当前正在 emitting 的特效节点总数（O(1) 计数，避免遍历 pool 统计）

# EventBus 订阅列表（在 _ready() 中注册）：
# EventID.BULLET_HIT      → play("hit_spark",   hit_position)
# EventID.ENEMY_KILLED    → play("death_burst",  enemy_position)
# EventID.STATUS_APPLIED  → play("status_" + status_name, target_position)
# EventID.SPELL_CAST_BEGIN→ play("cast_flash",   caster_position)

func play(effect_id: String, world_pos: Vector2, override_scale: float = 1.0) -> void:
    if _active_count >= MAX_ACTIVE_VFX:  # O(1) 检查，达上限时静默丢弃
        return
    var node := _pool_pop(effect_id)
    node.position = world_pos
    node.scale = Vector2.ONE * override_scale
    node.emitting = true
    _active_count += 1
    node.finished.connect(_pool_return.bind(effect_id, node), CONNECT_ONE_SHOT)

func _pool_pop(effect_id: String) -> GPUParticles2D:
    if not _vfx_pool.has(effect_id) or _vfx_pool[effect_id].is_empty():
        return _instantiate_vfx(effect_id)  # 按需实例化并加入场景树
    return _vfx_pool[effect_id].pop_back()

func _pool_return(effect_id: String, node: GPUParticles2D) -> void:
    node.emitting = false
    _active_count -= 1  # CONNECT_ONE_SHOT 保证每个节点只调用一次
    # 首次归还时 _vfx_pool 中可能尚无此 effect_id 键，需先初始化空列表再 append
    if not _vfx_pool.has(effect_id):
        _vfx_pool[effect_id] = []
    _vfx_pool[effect_id].append(node)

# _pool_pop() 中对应 effect_id 的池为空时，按需创建新 GPUParticles2D 节点。
# ⚠️ node.one_shot 必须为 true：若为 false，GPUParticles2D 持续循环发射，
#   finished 信号永不触发，_pool_return 不被调用，_active_count 只增不减，
#   触达 MAX_ACTIVE_VFX 后所有后续特效全部被静默丢弃（完全失效）。
# 父节点：VFXManager 自身（Autoload 单例），跨场景持久存活，确保池化节点复用。
func _instantiate_vfx(effect_id: String) -> GPUParticles2D:
    if not _vfx_scenes.has(effect_id):
        # 未注册的 effect_id：返回空占位节点（不崩溃），开发期通过 push_warning 提示漏配
        push_warning("VFXManager: 未知特效 ID='%s'，请检查 _ready() 中的预加载配置" % effect_id)
        var placeholder := GPUParticles2D.new()
        placeholder.one_shot = true   # 占位节点同样需要 one_shot，确保 finished 可触发
        add_child(placeholder)
        return placeholder
    var node: GPUParticles2D = _vfx_scenes[effect_id].instantiate()
    node.one_shot = true      # 必须为 true，否则 finished 永不触发，池化失效
    node.emitting = false     # 初始不发射，等待 play() 设置 position/scale 后再启动
    add_child(node)           # 挂载到 VFXManager（Autoload）节点下，跨场景持久存活
    return node
```

> **性能约束（V-N1）**：`MAX_ACTIVE_VFX = 200` 超出时静默丢弃，避免大量爆炸帧暴涨。
> 伤害数字（DamageNumber）由 `UIManager` 而非 `VFXManager` 管理，两者隔离（参见 `architecture_design.md §4.4` Throttling）。

#### E. 元数据管理 (`ProfileManager`)
*   **Save/Load**: 使用 Godot 的 `FileAccess` 读写 JSON 字符串，或使用 `ConfigFile` 存储键值对数据，存储路径为 `user://save_data.json`。
*   **Schema**: 定义 `SaveData` 字典结构，包含 `unlocked_spells: Array[String]`、`achievements: Array[int]` 等字段。
*   **版本字段**: `SaveData` 根层必须包含 `schema_version: int`（当前版本 = 1）。
*   **加载时版本检查**:
    1.  读取 `schema_version`，若字段不存在则视为 `v0`。
    2.  对每个低版本执行迁移函数（`_migrate_v0_to_v1(data)`），补全缺失字段为默认值。
    3.  若 `schema_version > 当前版本`（由更新版本写入），提示版本不匹配并回退到初始状态，避免崩溃。
*   **参考**: `docs/design/core_wand_design.md` 中的 SaveData JSON 序列化示例。

#### E.1 存档损坏 UX 规范（C-GAP-3 修复）

`load_run()` 和 `EndlessRecordsManager.load()` **不得静默降级**——返回空数据时必须通过 UI 告知玩家，禁止无声失败。

| 失败场景 | 触发条件 | UI 行为 |
| :--- | :--- | :--- |
| `load_run()` 解析失败 | JSON 格式错误 / `schema_version` 缺失 / 两槽均损坏 | `UIManager.show_data_corrupted_dialog("run")` → 对话框：「检测到存档异常，当前游戏进度无法读取。是否开始新游戏？」；确认后清除 run 文件并进入主菜单新游戏流程 |
| `EndlessRecordsManager.load()` 签名失败 | HMAC 不匹配（文件被篡改或损坏） | `UIManager.show_data_corrupted_dialog("leaderboard")` → Toast 提示：「排行榜分数验证失败，本地记录已重置」；不影响游戏进程，直接以空记录继续 |
| `load_run()` 返回 `{}` 但存档文件存在 | 两槽均损坏 | 同 run 对话框，额外追加一条 `crash_log.txt` 记录，便于复现调试 |

```gdscript
# profile_manager.gd — load_run() 调用方规范（游戏启动时）
var run_data := load_run()
if run_data.is_empty() and (
        FileAccess.file_exists(_SLOT_A) or FileAccess.file_exists(_SLOT_B)):
    # 文件存在但解析为空 → 损坏，不静默降级
    UIManager.show_data_corrupted_dialog("run")
    return  # 由对话框回调决定后续流程（新游戏 or 退出）
```

> **`UIManager.show_data_corrupted_dialog(type: String)` 接口规范**：  
> - `type = "run"`：模态对话框，两个按钮：「开始新游戏」（清除损坏 Run 文件）/ 「返回主菜单」。  
> - `type = "leaderboard"`：非模态 Toast（3 秒自动消失），不阻断游戏流程。  
> - 对话框文案须通过 `tr()` 包裹（L10n 支持）：键名 `"DIALOG_SAVE_CORRUPTED_RUN"` / `"TOAST_LEADERBOARD_RESET"`。


> `game_design.md §8.1` 要求死亡时自动截图保存构建快照。实现规范如下：
>
> **DPS 计算**：`CombatManager`（或 `ProfileManager`）维护滚动窗口：
> ```gdscript
> # 环形缓冲区：O(1) 写入/覆盖，无内存分配，零 GC 压力（替代 Array + pop_front() 的 O(N) 方案）
> # _recalc_window() 懒加载：仅在 get_dps() 查询时计算（不在每次 record_damage() 触发）
> # 理由：record_damage() 每帧可能被数百次 AoE 命中调用；UIManager 仅每 0.5 秒轮询 DPS
> const DPS_WINDOW_SEC: float = 3.0
> const _RB_SIZE: int = 256                      # 环形缓冲区槽数（上限：3秒内最多 256 次伤害事件）
> var _rb_time: PackedFloat64Array               # 各槽时间戳
> var _rb_dmg:  PackedFloat32Array               # 各槽伤害值
> var _rb_head: int = 0                          # 下一次写入位置（覆盖最旧记录）
> var _rb_count: int = 0                         # 有效记录数（最大 = _RB_SIZE）
> var _accumulated_dmg: float = 0.0             # 当前窗口内伤害累计（主动维护，O(1) 读取）
>
> func _ready() -> void:
>     _rb_time = PackedFloat64Array(); _rb_time.resize(_RB_SIZE)
>     _rb_dmg  = PackedFloat32Array(); _rb_dmg.resize(_RB_SIZE)
>
> func record_damage(amount: float) -> void:
>     var now := Time.get_ticks_msec() / 1000.0
>     _rb_time[_rb_head] = now
>     _rb_dmg[_rb_head]  = amount
>     _rb_head = (_rb_head + 1) % _RB_SIZE
>     if _rb_count < _RB_SIZE:
>         _rb_count += 1
>     _accumulated_dmg += amount   # 乐观累加；get_dps() 时统一裁剪过期值
>
> func _recalc_window(now: float) -> void:
>     var sum: float = 0.0
>     var tail := (_rb_head - _rb_count + _RB_SIZE) % _RB_SIZE
>     var valid: int = 0
>     for k in _rb_count:
>         var idx := (tail + k) % _RB_SIZE
>         if (now - _rb_time[idx]) <= DPS_WINDOW_SEC:
>             sum += _rb_dmg[idx]
>             valid += 1
>     _accumulated_dmg = sum
>     _rb_count = valid
>
> func get_dps() -> float:
>     _recalc_window(Time.get_ticks_msec() / 1000.0)  # 懒加载裁剪
>     return _accumulated_dmg / DPS_WINDOW_SEC   # 平均 DPS（过去 3 秒）
> ```
> `UIManager` 每 0.5 秒轮询 `CombatManager.get_dps()`，更新 HUD DPS 标签；靶场模式下同步更新 DPS 面板。
>
> **Run 截图**：死亡时 `GameCycleManager` 调用：
> ```gdscript
> func _save_run_screenshot() -> void:
>     var img: Image = get_viewport().get_texture().get_image()
>     var path := "user://runs/%s_run_%d.png" % [
>         Time.get_datetime_string_from_system().substr(0, 10),
>         ProfileManager.get_run_count()
>     ]
>     img.save_png(path)
> ```
> 截图在主线程调用（`get_image()` 是同步操作），建议在死亡动画第 1 帧完成后、统计屏显示前执行，
> 避免截到黑屏。

## 3. 开发阶段规划 (Development Stages)

### Phase 1: 核心验证 (The Engine)
*   [x] 定义 `SpellNode` 基类 (已完成)。
*   [ ] 实现 `SpellEvaluator` (纯逻辑，无 UI)。
*   [ ] 实现 `SpatialGrid` 碰撞检测。
*   [ ] 编写单元测试：构建一个简单的"散弹枪"逻辑，验证输出数据是否符合预期。

### Phase 2: 视觉化 (The Visuals)
*   [ ] 搭建 Godot 场景，实现玩家移动 (键盘/手柄)。
*   [ ] 接入 `BulletManager`，将逻辑坐标同步到 Sprite2D。
*   [ ] 实现简单的敌人 AI (追着玩家跑)。

### Phase 3: 构建系统 (The Builder)
*   [ ] 实现各种具体的 `SpellNode` (Actions, Modifiers)。
*   [ ] UI 开发：背包拖拽系统。
*   [ ] 存档与解析系统 (JSON <-> SpellDeck)，含 Schema 版本字段。

#### SpellDeck JSON 序列化格式 (E2 权威格式)

```json
{
  "schema_version": 1,
  "core_id": "linear_core_t2",
  "slots": [
    { "spell_id": "modifier_damage_plus", "position": 0 },
    { "spell_id": "action_spark_bolt",    "position": 1 },
    { "spell_id": "trigger_on_hit",       "position": 2 },
    { "spell_id": "action_chain_bolt",    "position": 3 }
  ]
}
```
*   `core_id`：核心 ID，决定插槽拓扑结构。
*   `slots` 按 `position` 排列，`position` 为核心插槽座标（线性核心就是序数，网格核心为 `{x, y}` 对）。
*   `spell_id` 对应 `SpellRegistry` 中的全局唯一 ID。

### Phase 4: 循环与内容 (The Game)
*   [ ] 商店逻辑。
*   [ ] 波次管理器。
*   [ ] 数值接入。
*   [ ] ZoneManager（地面效果系统）。
*   [ ] VFXManager 特效池。
*   [ ] Endless 排行榜本地存储。

---

## 4. 关键技术难点预案

### Q1: GDScript 如何避免 GC 抖动？
*   **预案**: 所有的 `ProjectilePayload`、`SpellContext` 在游戏启动时预分配并放入 `Array` 池。使用时 `pool.pop_back()` 取出，用完调用 `reset()` 后 `pool.append()` 归还，不依赖 GDScript 的自动引用计数销毁。

### Q2: 复杂的法术链如何调试？
*   **预案**: 开发一个 Godot 编辑器插件（`EditorPlugin`）作为可视化的 **"Debug Graph"** 工具。在编辑器模式下，可以单步执行 `SpellEvaluator`，看到当前的 Stack 和 Deck 状态，并在 2D 视图中高亮对应节点。

### Q3: 物理碰撞性能不够怎么办？
*   **预案**: 
    1. 确保 `CollisionShape2D` 使用圆形（最快），并合理配置 Physics Layer/Mask 减少检测对数。
    2. 将碰撞检测分帧（Time-Slicing），这一帧算一半子弹，下一帧算另一半。对于高速割草游戏，延迟一帧通常不可感知。
    3. 终极方案：完全放弃 Area2D，改用自定义 `SpatialGrid`，在 `_physics_process` 中手动执行圆形重叠测试。

### Q4: `PackedFloat32Array` 删除策略？
*   **问题**：直接调用 `PackedFloat32Array.resize()` 删除元素是 O(n)，对 2000+ 弹幕每帧有明显性能开销。
*   **预案：末位交换 (Swap-and-Pop)**：
    *   子弹 “死亡” 时，将数组最后一个元素的数据复制到当前位置，然后缩减数组长度 1 个 stride。
    *   复杂度 O(1)，但子弹顺序不再保证（对融剆这个游戏类型无影响）。
    *   **实际实现**：`BulletManager` 内部维护 `_active_count: int`，子弹数据帮存在 `[0, _active_count)` 区间内，不依赖 resize()。

### Q5: GDScript 与 C# 如何协同工作？
*   **热路径 C# 文件目录**：`scripts/core_csharp/BulletManagerCore.cs`、`SpellEvaluatorCore.cs`。
*   **调用方式**：在 Godot 4 中，GDScript 可通过 `var evaluator = preload("res://scripts/SpellEvaluatorCore.cs").new()` 直接实例化 C# 类，调用公开方法无需特殊处理。
*   **跨语言传递类型**：只传递 GDScript / C# 双方都支持的类型：`int`、`float`、`Vector2`、`Godot.Array`、`Godot.Dictionary`。禁止将 GDScript `RefCounted` 自定义类型直接传入 C#：应将复杂数据序列化为 `PackedFloat32Array` 或 `int` ID 后传递。
*   **原则**：只有当 Profiler 显示具体函数是确认瓶颈时，才将其迁移到 C#；默认先用 GDScript 实现，确保选择的正确性。
