# 程序架构设计与实施计划 (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 # boss_design.md §6:Boss 阶段切换 → HUD、AudioManager(BGM 分层) const BOSS_KILLED: int = 16 # boss_design.md §6:Boss 死亡 → WaveManager、AchievementManager const GAME_CLEARED: int = 17 # boss_design.md §6:通关 → GameCycleManager、排行榜 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` | `boss_id, phase, bgm_layer` | `BossManager` | `UIManager`, `AudioManager` | | `16` | `EventID.BOSS_KILLED` | `BOSS_KILLED` | `boss_id, wave_num` | `BossManager` | `WaveManager`, `AchievementManager` | | `17` | `EventID.GAME_CLEARED` | `GAME_CLEARED` | `elapsed_sec, wave_num` | `BossManager` / `GameCycleManager` | `GameCycleManager`, 排行榜 | | `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` | > 新增事件从 **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 实现,确保选择的正确性。