Files
spellforge/docs/technical/implementation_plan.md
T

818 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 程序架构设计与实施计划 (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-10Steam 成就解锁(须幂等)
# 负载: { "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.1FSM 状态切换(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 界面)、PlayerManagerauto_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_backO(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_backO(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_backO(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,长度 4R1-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()` |
| 2002000 | **自定义 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 位 = faction0=敌方, 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_timeSpellEvaluator 用于判断蓄力时长奖励;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:若为 falseGPUParticles2D 持续循环发射,
# 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) # 挂载到 VFXManagerAutoload)节点下,跨场景持久存活
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 实现,确保选择的正确性。