# 模块化战术土豆 (Modular Tactical Potato) - 架构设计文档 ## 1. 概述 (Overview) 本项目旨在开发一款基于 **Godot 4.x** 的 Roguelite 动作射击游戏。 核心体验结合了 **Brotato (土豆兄弟)** 的快节奏割草体验与 **Noita** 的深度法术构建系统。 架构设计的首要目标是 **高性能**(支持同屏大量单位与弹幕)与 **极高的可扩展性**(特别是武器系统的模块化)。 ### 1.1 设计目标 1. **超模块化武器系统 (Hyper-Modular Weapon System)**:超越 Noita 的线性构建,引入更灵活的管道流与事件钩子机制。 2. **高性能战斗引擎**:支持同屏 500+ 敌人,2000+ 弹幕,60FPS 稳定运行。 3. **数据驱动 (Data-Driven)**:所有游戏内容(法术、属性、波次)完全配表化/JSON化。 --- ## 2. 系统分层架构 (Layered Architecture) 采用能够严格分离数据与表现的架构模式。虽然 Godot 是节点/组件式的,但在核心战斗层我们将采用 **Manager + Data** 的方式来规避逐节点更新带来的开销。 ```mermaid graph TD Layer1[表现层 (Presentation Layer)] --> Layer2[逻辑层 (Domain/Logic Layer)] Layer2 --> Layer3[数据层 (Data Layer)] Layer2 --> Layer4[核心库 (Core Library)] subgraph Layer1 ViewComponents[Godot Nodes (Sprite2D, AnimationPlayer)] UIManagers[UI System (Control/CanvasLayer)] Effects[VFXManager + GPUParticles2D Pool] end subgraph Layer2 CombatMgr[Combat Manager (Main Loop)] SpellEvaluator[Spell Interpreter (The "CPU")] EnemyAI[Boid AI System] GameCycle[Wave & Shop Cycle] MinionMgr[MinionManager (友军召唤)] ZoneMgr[ZoneManager (地面效果)] StatusMgr[StatusManager (状态效果)] end subgraph Layer3 ConfigMgr[JSON Config Loader] SaveSystem[Persistent Storage] Inventory[Player State & Inventory] end subgraph Layer4 Pool[Object Pool System] SpatialHash[Spatial Hashing (Collision)] EventSystem[Global Event Bus] CoreFeatureTagConst[CoreFeatureTag (特性常量)] end ``` --- ## 3. 核心子系统:超模块化法术系统 (HMWS) 这是本项目的技术核心。我们将 Noita 的“魔杖”概念抽象为 **“法术管道 (Spell Pipeline)”**。 ### 3.1 核心概念差异 | 特性 | Noita 原版 | HMWS (本项目) | 改进目的 | | :--- | :--- | :--- | :--- | | **执行流** | 线性 (Deck -> Hand -> Discard) | **树状/图状结构 + 事件驱动** | 支持“子母弹”、“条件触发”、“击中后分裂逻辑”的无限嵌套。 | | **属性计算** | 累加式 (Cast Delay += 0.1) | **管线式 (Pipeline)** | 允许中间件对属性进行乘算、覆写或逻辑重定向。 | | **载体** | 法杖 (Wand) | **构建核心 (Core)** | 核心决定了插槽拓扑结构(不仅仅是线性数组,可能是矩阵或特定触发槽)。 | ### 3.2 数据结构设计 (GDScript) #### A. 基础单元 (SpellNode) 这是所有"部件"的基类。 ```gdscript # cast_stats.gd - MODIFIER 节点的修改目标;通过 SpellContext.stats 访问 class_name CastStats extends RefCounted var damage_add: float = 0.0 # 累加伤害加成(所有 Dmg+ 修正器的总和) var damage_mult: float = 1.0 # 乘算伤害系数(暴击、元素弱点等乘入) var projectile_speed: float = 600.0 # 弹道飞行速度(像素/秒) var spread_angle: float = 0.0 # 散射角度(度,0 = 直线) var pierce_count: int = 0 # 穿透次数(0 = 首次命中即销毁) var bounce_count: int = 0 # 弹射次数(0 = 不弹射) var homing_force: float = 0.0 # 归航强度(0 = 直线,>0 向最近敌人偏转) var projectile_size: float = 1.0 # 弹道大小系数(影响碰撞半径与贴图缩放) var range_mult: float = 1.0 # 射程乘算系数(弹道消失前飞行距离倍率) var crit_chance: float = 0.0 # 本次施法暴击率追加量(0.0~1.0,叠加而非覆盖) func reset() -> void: damage_add = 0.0; damage_mult = 1.0; projectile_speed = 600.0 spread_angle = 0.0; pierce_count = 0; bounce_count = 0 homing_force = 0.0; projectile_size = 1.0; range_mult = 1.0; crit_chance = 0.0 # spell_context.gd - 使用 RefCounted 避免 GC 压力 class_name SpellContext extends RefCounted # ⚠️ 注意:禁止持有 Node2D 引用——逻辑层与表现层必须解耦 # 施法者位置通过 EntityManager.get_position(caster_id) 实时查询 var caster_id: int = -1 # 施法者实体 ID var target: Vector2 # 目标点 var stats: CastStats = CastStats.new() # 必须在声明时初始化;reset() 调用 stats.reset() 清零字段,不重新 new var payloads: Array[ProjectileDef] = [] # 待发射的弹头定义队列 var registers: PackedFloat32Array = PackedFloat32Array([0.0, 0.0, 0.0, 0.0]) # R1, R2, R3, R4 — LOGIC_P2 功能专用 # ⚠️ 必须声明时初始化为长度 4;PackedFloat32Array() 默认长度 0, # 直接访问 registers[0] 会触发 out-of-bounds 崩溃。 # 生命周期:携带 CoreFeatureTag.PERSISTENT_MEMORY 的 Core 跨帧保留此值; # 非持久 Core 下,帧结束后由 SpellEvaluator 调用 registers.fill(0.0) 清零。 # 与 CastState.registers 的区别: # SpellContext.registers → 跨帧持久寄存器(属于 SpellContext 对象) # CastState.registers → 单次 execute() 内临时工作寄存器,每次重置 # core_wand_design.md §6 将此字段称为 "memory_bank"(同一字段) var current_payload: ProjectileDef # 当前正在构建的弹头定义 # 池化归还时必须调用 reset(),防止脏数据污染下次施法 func reset() -> void: caster_id = -1 target = Vector2.ZERO stats.reset() # CastStats 内部清零 payloads.clear() # 清空弹头队列(不销毁元素,由 ProjectileDef 池负责回收) current_payload = null # ⚠️ registers 不在此处清零: # 持久 Core(PERSISTENT_MEMORY)由 SpellEvaluator 决定是否保留; # 非持久 Core 由 SpellEvaluator 在帧结束后调用 registers.fill(0.0)。 # spell_node.gd - 基类,子类重写 execute() # 所有系统通过 SpellType 枚举访问节点类型,禁止在业务代码中直接写裸整数(0/1/2/3)。 enum SpellType { ACTION = 0, # 产生实际飞行物或即时效果(如 spark_bolt、nuke) MODIFIER = 1, # 修改下一个 ACTION 的属性(如 damage_plus、homing) TRIGGER = 2, # 将后续法术打包为 SubPayload,在弹体命中时执行(子母弹核心) LOGIC = 3, # 条件跳转、循环、寄存器读写(IF_HP_LOW、LOOP 等) SCOPE_CLOSE = 4 # 虚节点,预编译时自动插入,标记 TRIGGER/LOGIC 作用域边界 # 不出现在 SpellRegistry 中,玩家不可购买 } class_name SpellNode extends RefCounted var id: String var type: SpellType = SpellType.ACTION # 业务代码赋值时须使用 SpellType.XXX,禁止写裸整数 var element_tags: Array[String] = [] # 元素亲和性标签,供共鸣系统(§3.4.C Resonance)进行模式匹配 # 内容示例:["tag:water"]、["tag:lightning"]、[] # resonance_recipes.json 中 "pattern" 字段的每个元素 # 对应本字段中的一个字符串;预编译时 _check_resonance() # 遍历 deck.nodes,比对 element_tags 中是否包含目标 tag # 数据来源:SpellRegistry 从 res://resources/spells/*.tres 加载 var shape_icon: String = "" # ADR-C1: 无障碍形状标识,与颜色配合供色盲玩家区分元素。 # 取值约定(UIAtlas 中对应图标键): # "triangle" → 火系 (Fire) # "diamond" → 冰系 (Ice) # "circle" → 水系 (Water) # "square" → 土系 (Earth) # "star" → 雷系 (Lightning) # "cross" → 暗系 (Dark) # "" → 无元素(纯伤害类) # 渲染规则:UI 显示元素标签时必须同时绘制颜色背景 + shape_icon; # VFXManager 在命中 VFX 上叠加 1.5× 字号的 shape_icon TextureRect。 # 禁止仅用颜色区分元素(ADR-C1 强制要求)。 # 核心执行函数:修改 Context 或产生行为,子类必须重写 func execute(ctx: SpellContext, deck: SpellDeck) -> void: pass # projectile_def.gd - ACTION 节点执行时填充,最终传给 BulletManager 批量生成弹体 # 通过对象池复用,不触发 GC;SpellContext.payloads 以 Array[ProjectileDef] 形式积累 class_name ProjectileDef extends RefCounted # 业务代码必须通过 DamageType.PHYSICAL 等常量引用,禁止直接写 0/1/2/3/4。 # 此枚举也是 BulletManager SoA 中 damage_type 字段、StatusManager DoT 分类的统一来源。 enum DamageType { PHYSICAL = 0, # 物理伤害:受护甲(Armor)平铺减免,不受元素抗性影响 FIRE = 1, # 火焰伤害:受 attunement_fire 加成;可触发点燃/爆燃状态 ICE = 2, # 冰霜伤害:受 attunement_ice 加成;可触发冻结/减速状态 LIGHTNING = 3, # 雷电伤害:受 attunement_lightning 加成;可触发麻痹/连锁导电 POISON = 4 # 毒素伤害:受 attunement_poison 加成;施加 DoT,叠层上限受精通影响 } # ───────────────────────────────────────────────────────────────────────────── # ── 伤害 ────────────────────────────────────────────────── var base_damage: float = 5.0 # 计算前基础伤害值(已含 CastStats.damage_add 累积) var damage_mult: float = 1.0 # 乘算系数(暴击/元素弱点已合入) var damage_type: int = DamageType.PHYSICAL # DamageType 枚举(见上方定义) # ── 运动 ────────────────────────────────────────────────── var speed: float = 600.0 # 初速度(像素/秒) var direction: Vector2 = Vector2.RIGHT var spread_angle_rad: float = 0.0 # 在 direction 基础上的随机偏转幅度 var homing_force: float = 0.0 # 每帧转向力(0 = 直线) var acceleration: float = 0.0 # 速度加速度(可为负,做减速球) # ── 生命周期 ────────────────────────────────────────────── var lifetime: float = 3.0 # 最大存活时间(秒) var pierce_remaining: int = 0 # 剩余穿透次数(0 = 首次命中销毁) var bounce_remaining: int = 0 # 剩余弹射次数 # ── 碰撞体积 ────────────────────────────────────────────── var radius: float = 6.0 # 碰撞圆半径(像素) var size_mult: float = 1.0 # 外观与碰撞同步缩放系数 # ── 触发器 ──────────────────────────────────────────────── var on_hit_payload_id: int = -1 # SubPayloadRegistry ID;-1 = 无 TRIGGER var on_expire_payload_id: int = -1 # 到期/撞墙触发;-1 = 无 var on_kill_payload_id: int = -1 # 击杀触发;-1 = 无 # ── 归属 ────────────────────────────────────────────────── var owner_id: int = -1 # 根源实体 ID(归击杀、吸血计算) var source_tags: int = 0 # 位掩码: PRIMARY=1, TRIGGERED=2, SUMMON=4, REFLECTED=8 var spawn_position: Vector2 # 发射原点(填充时从 caster 位置获取) # ── 触发系数 ─────────────────────────────────────────────── var proc_rate: float = 1.0 # Proc 系数(0.0~1.0)。高频攻击(加特林)应设置为 0.1~0.3, # 防止高攻速过度触发 on_hit 特效/技能。 # 命中时实际触发概率 = SpellNode.proc_chance × proc_rate。 # 默认 1.0(不削减),Tier 1 Action 通常无需修改。 func reset() -> void: base_damage = 5.0; damage_mult = 1.0; damage_type = DamageType.PHYSICAL speed = 600.0; direction = Vector2.RIGHT; spread_angle_rad = 0.0 homing_force = 0.0; acceleration = 0.0; lifetime = 3.0 pierce_remaining = 0; bounce_remaining = 0 radius = 6.0; size_mult = 1.0 on_hit_payload_id = -1; on_expire_payload_id = -1; on_kill_payload_id = -1 owner_id = -1; source_tags = 0; proc_rate = 1.0 spawn_position = Vector2.ZERO # 必须重置,否则池化复用时携带上帧发射原点 ``` #### B. 法术解析器 (The Evaluator) 为了高性能,解析器必须 **低 GC 压力 (Low-GC)**。在施法计算帧,尽量避免分配新对象。 * 使用预分配的 `SpellContext` 对象池(`Array` 作为栈管理空闲对象)。 * 使用 `Array`(作为栈)模拟递归,防止深层递归爆栈。 #### C. 高级特性:动态插槽与逻辑门 * **Logic Spells (逻辑法术)**:引入 `IfHPBelow`, `OnKillAction`, `EveryNbShot` 等逻辑块,让玩家实现“如果血量低于30%,则发射吸血导弹”的构建。 * **Variable Storage (变量存储)**:允许法术在法杖上写入/读取临时变量(例如:记录连击数)。 ### 3.3 扩展性设计 所有法术行为通过 **Strategy Pattern (策略模式)** 实现。 新增一个法术只需: 1. 在 JSON(或 Godot `.tres` Resource)中定义 ID 和贴图路径。 2. 实现一个继承自 `SpellNode` 的 GDScript 类。 3. 在注册表中注册。 ### 3.4 进阶构建机制 (Advanced Mechanics) - 玩法增强 为了超越“线性堆砌”的枯燥感,架构支持以下三种深度玩法机制。 > **MVP 功能边界说明**: > - **P0(必须实现)**:4类 SpellNode(ACTION / MODIFIER / TRIGGER / LOGIC)+ LINEAR Core。 > - **P1(迭代加入)**:拓扑插槽系统(MATRIX/CIRCUIT Core)、共鸣系统。 > - **P2(可选高级内容)**:状态寄存器 + 条件跳转——此功能面向极硬核玩家,仅通过特殊 Core 解锁,**不在新手 UI 中暴露**。 #### A. 拓扑插槽系统 (Topology Slots)【P1】 核心(Core)不再仅仅是一个列表,它可以是一个 **2D 网格** 或 **电路板**。 * **adjacency_bonus (邻接加成)**:某些插槽有物理连接。例如,将 [火元素] 放在 [高压槽] 旁边,会自动获得 +20% 范围。 * **Circuit Logic (电路逻辑)**:法术流不再只是从左到右。核心板可以有分叉路口,玩家需要用 [分流器法术] 将能量流引导到不同的分支。 * 详见 [docs/design/core_wand_design.md](../design/core_wand_design.md)。 **拓扑适配层 (Topology Adapter)【A1 解决方案】** `SpellEvaluator` 的 while 循环只能处理线性指令流。为此,在 `SpellEvaluator.compile_wand(core, raw_deck)` 预编译阶段引入 **拓扑扁平化 (Topology Flattening)**,将不同 Core 的空间拓扑提前转换为线性指令序列,运行时 while 循环无需感知拓扑: | Core 类型 | 扁平化策略 | | :--- | :--- | | **LINEAR** | 无需处理,直接输出原始 SpellNode 序列 | | **MATRIX_2X4** | 扫描邻接槽对(slot[i] 与 slot[i + cols]),若满足 `adjacency_bonus` 条件,在对应 ACTION 前插入隐式 MODIFIER 节点;最终序列仍按行→列顺序线性排列 | | **CIRCUIT** | 对有向图做 **拓扑排序**,每个分叉点展开为独立 SubPayload 并注册到 SubPayloadRegistry,分叉处插入 `LOGIC_FORK` 指令批量触发;主链保持线性 | ```gdscript # spell_evaluator.gd(预编译入口 + 运行时配置) # compile_wand:拓扑扁平化策略入口,仅在玩家关闭背包/装备 Core 时调用,非热路径。 var _max_ops: int = 40 # 每次施法的法术节点执行上限(= MAX_OPS_PER_CPU × cpu_limit) func update_max_ops(cpu_limit: int) -> void: # UpgradeSystem.apply_choice() 在被动词条变更 cpu_limit 后调用 # MAX_OPS_PER_CPU = 8(基准值,SpellEvaluator 常量) const MAX_OPS_PER_CPU: int = 8 _max_ops = clamp(MAX_OPS_PER_CPU * cpu_limit, 8, 200) func compile_wand(core: CoreDefinition, raw_deck: SpellDeck) -> CompiledDeck: match core.topology: CoreDefinition.LINEAR: return CompiledDeck.new(raw_deck.nodes) CoreDefinition.MATRIX_2X4: return _flatten_matrix(raw_deck, core) CoreDefinition.CIRCUIT: return _flatten_circuit(raw_deck, core) return CompiledDeck.new([]) # 未知拓扑降级为空 Deck,防止 match 无 default 返回 null func _flatten_matrix(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck: # 仅遍历 Row A(i < grid_cols);Row B 槽位法术不作为独立执行节点,仅用于邻接加成注入。 var out: Array[SpellNode] = [] var row_a_count: int = core.grid_cols # Row A 的槽数 = grid_cols(如 2×4 矩阵中 = 4) for i in row_a_count: var adj_idx: int = i + core.grid_cols # Row B 中与 slot[i] 垂直对齐的槽索引 # 空槽守卫:deck.nodes[i] 可能为 null(槽为空) var row_a_node: SpellNode = deck.nodes[i] if i < deck.nodes.size() else null var row_b_node: SpellNode = deck.nodes[adj_idx] if adj_idx < deck.nodes.size() else null if row_a_node == null: continue # 空槽:Row A 无法触发,跳过(Row B 对应槽的邻接加成也无效) if row_b_node != null and _has_adjacency_bonus(row_a_node, row_b_node): # 注入隐式 MODIFIER 节点,效果见 core_wand_design.md §2.2 邻接加成效果表 out.append(_make_adjacency_mod(row_a_node, row_b_node)) out.append(row_a_node) # 仅追加 Row A 节点到执行序列 # Row B 节点(row_b_node)不追加 → 不独立执行,仅作为邻接加成来源 return CompiledDeck.new(out) # 对 CIRCUIT 拓扑的有向图做 Kahn 算法拓扑排序,主链保持线性; # 分叉节点展开为独立 SubPayload 并注册到 SubPayloadRegistry。 # edges 读自 core.edges(顶层字段);CoreDefinition 须声明 @export var edges: Array = [](见 core_wand_design.md §1) # 性能说明:仅在玩家关闭背包时触发(非 _physics_process 热路径),slot_count ≤ 16,O(V+E) < 0.1ms。 func _flatten_circuit(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck: # 1. 从 core.edges 读取顶层有向边列表;若为空,降级为 LINEAR 处理(见 core_wand_design.md §2.3) var edges: Array = core.edges if edges.is_empty(): push_warning("_flatten_circuit: core.edges 为空,降级为 LINEAR 处理(Core=%s)" % core.id) return CompiledDeck.new(deck.nodes) # 2. Kahn 算法:BFS 拓扑排序(保证无环时的确定性线性化顺序) var in_degree: Array[int] = [] var adj: Array = [] # adj[i] = Array[int] 出边目标 in_degree.resize(core.slot_count) adj.resize(core.slot_count) for i in core.slot_count: in_degree[i] = 0 adj[i] = [] for e in edges: adj[e["from"]].append(e["to"]) in_degree[e["to"]] += 1 # ⚠️ Kahn 循环结束后 in_degree 全归零;_collect_branch_path() 需要原始入度来判断汇聚点 # (orig_in_degree[nxt] > 1 表示多入边)。在 Kahn 循环前保存副本供分支收集函数使用。 var orig_in_degree: Array[int] = in_degree.duplicate() # Kahn 前原始入度快照 var queue: Array[int] = [] for i in core.slot_count: if in_degree[i] == 0: queue.append(i) var topo_order: Array[int] = [] while not queue.is_empty(): var cur: int = queue.pop_front() topo_order.append(cur) for nxt: int in adj[cur]: in_degree[nxt] -= 1 # 修改工作副本(orig_in_degree 保留原始值供汇聚点检测) if in_degree[nxt] == 0: queue.append(nxt) if topo_order.size() != core.slot_count: push_error("_flatten_circuit: 检测到环路,无法线性化!Core=%s" % core.id) return CompiledDeck.new([]) # 返回空 Deck,游戏不崩溃但该法杖无法施法 # 3. 按拓扑顺序输出节点,出度 > 1 的槽注入 LOGIC_FORK 指令 var out: Array[SpellNode] = [] # in_branch_payload 记录已纳入某 SubPayload 的槽索引,防止主链循环将分支节点重复追加执行 var in_branch_payload: Dictionary = {} for slot_idx in topo_order: # 跳过已被分支 SubPayload 收纳的节点,防止双重执行 if in_branch_payload.has(slot_idx): continue var node: SpellNode = deck.nodes[slot_idx] if slot_idx < deck.nodes.size() else null if node == null: continue # 空槽跳过 if adj[slot_idx].size() > 1: # 分叉检测基于出度(出边数 > 1);"splitter" tag 保留为 UI/编辑器标注用途,不参与执行判断 # 分叉槽自身的法术节点先行追加(分叉前执行,通常为空但允许 MODIFIER/ACTION) out.append(node) # 为每条出边收集完整分支路径(含多节点分支,_collect_branch_path 沿单出边延伸直到叶/汇聚点) var branch_payload_ids: Array[int] = [] for neighbor_idx in adj[slot_idx]: var branch_path: Array[SpellNode] = _collect_branch_path( neighbor_idx, adj, orig_in_degree, deck, in_branch_payload) # orig_in_degree:Kahn 前快照,确保汇聚点检测正确 # in_branch_payload:共享引用,函数内登记已访问槽,主链循环可跳过 if not branch_path.is_empty(): branch_payload_ids.append(SubPayloadRegistry.register(branch_path)) if not branch_payload_ids.is_empty(): var fork_node: SpellNode = SpellNode.new() fork_node.type = SpellType.LOGIC fork_node.id = "LOGIC_FORK" fork_node.set_meta("fork_branch_ids", branch_payload_ids) out.append(fork_node) else: out.append(node) return CompiledDeck.new(out) # 从 start_idx 沿单出边路径收集节点序列,直到: # 叶节点(出度 0)、汇聚点(orig_in_degree > 1,属于主链或另一分支)、嵌套分叉(出度 > 1,递归处理) # orig_in_degree:必须传 Kahn 前的原始入度副本,用于汇聚点判断 # in_branch_payload:共享字典,函数内每访问一个槽即写入,消除主链双重执行 func _collect_branch_path(start_idx: int, adj: Array, orig_in_degree: Array, deck: SpellDeck, in_branch_payload: Dictionary) -> Array[SpellNode]: var path: Array[SpellNode] = [] var cur: int = start_idx var visited: Dictionary = {} while cur >= 0 and not visited.has(cur): visited[cur] = true in_branch_payload[cur] = true # 登记此槽已纳入 SubPayload,主链循环将跳过 if cur < deck.nodes.size() and deck.nodes[cur] != null: path.append(deck.nodes[cur]) if adj[cur].size() > 1: # 嵌套分叉:为每条子出边递归收集完整路径并注册 SubPayload, # 然后将嵌套 LOGIC_FORK 节点插入当前 path。 var nested_branch_ids: Array[int] = [] for nxt_idx: int in adj[cur]: var nested_path: Array[SpellNode] = _collect_branch_path( nxt_idx, adj, orig_in_degree, deck, in_branch_payload) if not nested_path.is_empty(): nested_branch_ids.append(SubPayloadRegistry.register(nested_path)) if not nested_branch_ids.is_empty(): var nested_fork: SpellNode = SpellNode.new() nested_fork.type = SpellType.LOGIC nested_fork.id = "LOGIC_FORK" nested_fork.set_meta("fork_branch_ids", nested_branch_ids) path.append(nested_fork) break # 嵌套分叉后路径在各子 SubPayload 中独立延伸,本路径就此结束 elif adj[cur].size() == 1: var nxt: int = adj[cur][0] if orig_in_degree[nxt] <= 1: # 单入边,继续延伸 cur = nxt else: break # 汇聚点(多入边),停止;该节点由主链处理 else: break # 叶节点(出度 0) return path ``` > **E3 解决方案**:MATRIX 邻接加成通过 `_flatten_matrix` 在预编译阶段注入隐式 MODIFIER 节点,SpellEvaluator 运行时不感知 Core 拓扑,邻接触发与普通 MODIFIER 执行路径完全一致。 #### B. 状态寄存器与图灵完备 (State Registers & Turing Completeness)【P2 可选】 > ⚠️ **设计注意**:本功能仅通过特殊 Core(persistent_memory / circuit_fork)解锁,新手不会接触寄存器和跳转指令。 为了支持硬核玩家实现真正的“图灵完备”构建,架构预留对**状态存储**、**条件跳转** 和 **循环**的支持。 1. **Registers (寄存器)**: * 在 `SpellContext` 中引入 `MemoryBank`,提供 4 个 Float 寄存器 (`R1`, `R2`, `R3`, `R4`)。 * 寄存器在同一帧内所有法术间共享,甚至可以跨帧持久化(如果法杖配置了 Persistent Memory 核心)。 2. **Instruction Set (指令集法术)**: * **OPS**: `Add R1, 1` (加法), `Set R2, HP_Percent` (赋值). * **JUMP**: `JumpIf R1 > 10, Label_A` (条件跳转到标签A). * **LABEL**: `Label_A` (标记跳转点). 3. **Recursion Control (递归控制)**: * 为了防止死循环 (`While(true)`), 解释器引入 `MaxOpLimit` (最大操作数限制,例如 100 ops/frame)。超过限制强制中断并在此帧失效。 4. **实战应用**: * **计数器**: 每射击 3 次,第 4 次发射强力火球。 * **动态模式切换**: 根据敌人距离 (`R1 = EnemyDistance`),如果近则跳转到 [霰弹逻辑],如果远则跳转到 [狙击逻辑]。 #### C. 共鸣系统 (Resonance System)【P1】 在**预编译阶段 (Pre-compile Phase)** 进行模式匹配。 * 如果检测到 [水] 和 [电] 法术在执行链中紧邻,架构自动插入一个隐藏的 [导电反应] 中间件。 * 这允许设计隐藏配方(Hidden Recipes),鼓励玩家探索特定组合。 * **发现机制**:法术卡片上显示元素亲和性标签(如 ⚡ 雷、💧 水),引导玩家尝试组合,而非完全盲猜。 * **缓存失效**:玩家在商店修改法术顺序时,自动触发重新预编译,确保共鸣结果与当前 Deck 始终一致。 * **共鸣配方存储 (Recipe Schema)**:配方存储于 `res://resources/resonance_recipes.json`,结构如下: ```json [ { "id": "plasma_storm", "pattern": ["tag:water", "tag:lightning"], "match": "adjacent", "result_spell_id": "plasma_storm", "consume_inputs": true, "vfx": "resonance_plasma" } ] ``` - `match` 取值:`"adjacent"`(紧邻)/ `"anywhere_in_deck"`(Deck 内任意位置)。 - `consume_inputs: true` 表示触发后移除原两张法术,节省插槽。 - 新增配方只需编辑 JSON,无需修改 SpellEvaluator 代码。 **`_check_resonance()` 精确算法**: "相邻"的定义:在预编译后的线性执行序列 `CompiledDeck.nodes` 中,两个法术节点的 **index 差 ≤ 2**(允许中间最多夹一个 MODIFIER 节点,因为 MODIFIER 不改变元素属性流向)。 ```gdscript # spell_evaluator.gd(在 compile_wand 完成拓扑扁平化之后调用) func _check_resonance(compiled: CompiledDeck) -> CompiledDeck: var recipes: Array = _resonance_recipes # 启动时从 JSON 加载,按 priority 排序(高优先级先匹配) var nodes: Array[SpellNode] = compiled.nodes.duplicate() var consumed: PackedByteArray = PackedByteArray() consumed.resize(nodes.size()) # 全 0 初始化 # 先处理所有 adjacent 配方 for recipe in recipes.filter(func(r): return r.get("match") == "adjacent"): var pattern: Array = recipe.get("pattern", []) # 如 ["tag:water", "tag:lightning"] if pattern.size() < 2: continue for i in nodes.size(): if consumed[i] != 0: continue if not _node_has_tag(nodes[i], pattern[0]): continue # 向右扫描 index i+1 至 i+2(跳过 MODIFIER),寻找 pattern[1] for j in range(i + 1, min(i + 3, nodes.size())): if consumed[j] != 0: continue if nodes[j].type == SpellType.MODIFIER: continue # MODIFIER 不参与匹配,但不中断扫描 if _node_has_tag(nodes[j], pattern[1]): # 命中:在 i 位置注入共鸣结果节点(替换 nodes[i] 位置),标记双方为已消费 var result_node: SpellNode = SpellRegistry.get(recipe.get("result_spell_id", "")) if result_node == null: break nodes.insert(i, result_node) # 在 i 位置插入共鸣结果 consumed.insert(i, 0) # 同步扩展 consumed 数组 if recipe.get("consume_inputs", false): consumed[i + 1] = 1 # 原 pattern[0] 节点(现 i+1)标记为消费 consumed[j + 1] = 1 # 原 pattern[1] 节点(现 j+1)标记为消费 break # 每个 i 只匹配一次,防止多配方叠加在同一节点 else: break # 遇到非 MODIFIER 非目标节点,中断内层扫描(不跨过 ACTION/TRIGGER) # 再处理所有 anywhere_in_deck 配方(O(N²),P1 仅限稀有度 ≥ 3 的传说配方) for recipe in recipes.filter(func(r): return r.get("match") == "anywhere_in_deck"): ... # 类似逻辑,但 i、j 不需要相邻约束 return CompiledDeck.new(nodes, compiled.label_table, compiled.sub_payload_ids, compiled.topology_type, compiled.checksum, consumed) func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool: # tag_pattern 格式为 "tag:water",匹配 node.element_tags 中含 "tag:water" 的节点 if tag_pattern.begins_with("tag:"): return tag_pattern in node.element_tags return node.id == tag_pattern # 直接 ID 匹配(精确配方,如 nuke+ignite) ``` > **⚠️ `consume_inputs` 运行时实现**:`CompiledDeck.nodes` 在预编译后为**只读**线性序列, > 不可在运行时直接删除元素(会破坏共鸣检测缓存,影响其他帧的重编译判断)。 > `consume_inputs: true` 配方的运行时"移除"通过 **消费掩码(Consumed Mask)** 实现: > ```gdscript > # spell_deck.gd(运行时执行游标) > # _consumed: PackedByteArray,长度 = nodes.size(),初始全 0 > # 预编译阶段在共鸣注入时,将被消费的槽位 index 写入 _consumed_indices 列表。 > # 运行时 pop() 跳过 _consumed[cursor] != 0 的槽位: > func pop() -> SpellNode: > while _cursor < _nodes.size() and _consumed[_cursor] != 0: > _cursor += 1 # 跳过已消费槽位 > if _cursor >= _nodes.size(): > return null > var node := _nodes[_cursor] > _cursor += 1 > return node > ``` > **消费掩码初始化**:`SpellEvaluator.compile_wand()` 完成共鸣注入后,对每个 `consume_inputs: true` 配方, > 将参与配方的两个原始节点的 index 写入 `_consumed`(值为 1),同时在对应位置插入共鸣结果节点。 > `_consumed` 是 `SpellDeck`(运行时对象)的字段,`CompiledDeck`(只读预编译缓存)不含此字段, > 每次从 CompiledDeck 构造 SpellDeck 时重新初始化(避免消费状态跨轮次残留)。 > **R4-C1 规范(触发范围权威定义)**: > - `"adjacent"`:**标准触发范围**,绝大多数配方使用此选项(如"水+雷=等离子风暴")。 > `game_design.md §3.3.C` 所述"执行链中直接相邻"即对应此选项。 > - `"anywhere_in_deck"`:**跨距触发**,仅用于极少数传说级全局共鸣配方, > 预编译时对整个 CompiledDeck 做 O(N²) 全局扫描,且**自动标记为稀有以上配方**。 > 新增此类配方时需在 JSON 中追加 `"rarity": 3`(传说)以防止轻易触发。 > - 两者不冲突,同一配方文件中可同时存在不同 `match` 值的条目,SpellEvaluator 在预编译阶段 > 先处理所有 `adjacent` 配方,再统一处理 `anywhere_in_deck` 配方。 --- ## 4. 高性能战斗架构 (High-Performance Combat Architecture) 为了实现“同屏 2000+ 弹幕”和“复杂逻辑构建”的双重目标,本架构采用 **Data-Oriented (面向数据)** 与 **Hybrid-ECS** 相结合的策略,最大化 CPU 缓存命中率并消除 GC 压力。 ### 4.1 核心原则:低 GC (Low-GC Principle) 在核心战斗循环 (Game Loop) 中,尽量避免频繁实例化新对象。 * **Context Pooling**: `SpellContext` 等高频对象在关卡加载时预分配并放入 `Array` 池,使用时复用,用完调用 `reset()` 方法归还。 * **预分配大小**:`SPELL_CONTEXT_POOL_SIZE = 32`。依据:单帧最坏情况 ≈ MAX_BULLETS(2000) × 平均 proc_rate(0.05) × 命中概率(0.3) ≈ 30 次 execute_sub 并发触发,32 个实例可覆盖此场景且无 GC 分配。若 `pool.pop_back()` 返回 null(池耗尽),额外实例化 1 个并发出 `push_warning("SpellContextPool: exhausted")`,不崩溃。**⚠️ 溢出实例归还规范**:溢出实例使用完毕后须调用 `pool.push_back(ctx)` 归还;若归还时 `pool.size() >= SPELL_CONTEXT_POOL_SIZE`,直接丢弃(GC 处理权交还 Godot),不超量囤积。禁止遗弃,避免 Endless 模式长局游离实例积累导致 GC 压力持续上升。 * **Static Temporaries**: GDScript 可在模块顶层声明静态变量 (`static var _tmp_vec2: Vector2`),避免向量计算产生中间对象。 * **PackedFloat32Array (SoA)**: 子弹的热数据(位置、速度)存储在 `PackedFloat32Array` 中,保证内存连续性,提升缓存命中率。 ### 4.2 实体管理:ECS-Lite 虽然 Godot 是基于节点/组件的,但在海量单位管理上,我们将剥离节点的逐帧逻辑。 * **Manager-Based Logic**: 子弹和敌人的 `Node2D` 节点**不运行自身的 `_process`**,逻辑全部由中央 Manager(Autoload 单例)统一驱动。 * 也就是:`Node2D` 仅仅作为渲染容器,负责同步 `position` 和播放动画。 * **Centralized Loop (中央循环)**: * `BulletManager` 维护一个紧凑的 `PackedFloat32Array`(SoA 布局)。 每颗子弹占用 **BULLET_STRIDE = 12** 个 float,在数组中偏移量 = `bullet_id × BULLET_STRIDE`: | 偏移 | 字段 | 说明 | | :--- | :--- | :--- | | +0 | `x` | 世界坐标 X(像素) | | +1 | `y` | 世界坐标 Y(像素) | | +2 | `vx` | 速度 X(像素/秒) | | +3 | `vy` | 速度 Y(像素/秒) | | +4 | `lifetime` | 剩余存活时间(秒),归零时销毁 | | +5 | `radius` | 碰撞圆半径(像素) | | +6 | `base_damage` | 命中基础伤害值 | | +7 | `damage_mult` | 伤害乘算系数 | | +8 | `damage_type` | DamageType 整数(DamageType 枚举,0=PHYSICAL…4=POISON) | | +9 | `owner_id` | 根源实体 ID(float 存 int,精度足够 24-bit ID) | | +10 | `source_tags` | 位掩码(PRIMARY/TRIGGERED/SUMMON/REFLECTED) | | +11 | `acceleration` | 速度加速度(像素/秒²,0=匀速) | > **冷数据(非热路径)** 存储于 `_bullet_contexts: Dictionary`(key=bullet_id),包含: > `pierce_remaining`, `bounce_remaining`, `homing_force`, `on_hit_payload_id`, > `on_expire_payload_id`, `on_kill_payload_id`, `proc_rate`, `visited_targets`。 > 正弦弹道等额外字段(`wave_frequency`, `wave_amplitude`)也存于此 Dictionary, > 不占用 SoA 热数组空间,仅在命中/特殊弹道帧查询。 > > **职责分离**:`ProjectileDef` 是**生成时配置模板**,在 `SpellEvaluator.execute` 阶段由 ACTION 节点填充; > `_bullet_contexts[bullet_id]` 是**运行时可变冷状态**,由 `BulletManager.spawn(def)` 从 `ProjectileDef` 拷贝创建。 > 两者字段名相同但生命周期不同:`ProjectileDef` 可安全复用(对象池 reset); > `_bullet_contexts[id]` 随子弹销毁时一同移除(`_bullet_contexts.erase(bullet_id)`)。 * 在 `_physics_process(delta)` 中,直接遍历 Array 进行物理积分,速度比遍历 Node Tree 快一个数量级。 * **Dirty Sync**: 仅当物体在屏幕视口内,且逻辑坐标发生位移时,才去同步 `Node2D.position`。 * **Homing 批量查询优化**:若 `homing_force > 0` 的子弹数量为 N,每颗独立调用 `SpatialGrid.find_nearest` 将产生 N 次散列查询(N=500 时每帧 500 次)。**解决方案**:在 `_physics_process` 开头建立敌人位置快照,所有 homing 子弹共用: ```gdscript # ⚠️ _enemy_pos_snapshot 必须声明为 BulletManager 类成员(预分配复用), # 禁止在 _physics_process 中用 var 声明(每帧分配新 PackedVector2Array,产生 GC 压力)。 # 类成员声明(BulletManager 顶部): # var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array() # # BulletManager._physics_process 开头,O(M),M = 存活敌人数量 EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot) # fill_pos_snapshot() 将所有存活敌人 [x, y] 写入传入的 PackedVector2Array(复用已分配内存,不触发 GC) # 对比旧写法:var _enemy_pos_cache = EnemyManager.get_pos_snapshot() 每帧 new 一个新数组 # homing 计算:从快照线性扫描,比散列查询更 cache-friendly for idx in _homing_indices: # _homing_indices: PackedInt32Array,本帧 homing 子弹列表 var bx: float = _data[idx * BULLET_STRIDE]; var by: float = _data[idx * BULLET_STRIDE + 1] var nearest := _find_nearest_pos(bx, by, _enemy_pos_snapshot) # O(M) 线性扫描快照 # 计算转向力并更新 vx/vy… ``` M=1000 敌人、N=500 homing 子弹时,总计 500K 次向量运算,C# 热路径约 0.2ms/帧,可接受。 * **EnemyManager 对外查询接口**:以下函数供 BulletManager、DropManager、BossManager、AudioManager 调用,均为 GDScript 侧接口(轻量读操作,非热路径): ```gdscript # enemy_manager.gd — 对外查询接口(热路径内不调用,仅事件驱动 / 轮询路径使用) func fill_pos_snapshot(out: PackedVector2Array) -> void: # BulletManager homing 共用快照;复用传入数组内存(P6-N25) out.resize(_active_count) for i in _active_count: out[i] = Vector2(_data[i * ENEMY_STRIDE], _data[i * ENEMY_STRIDE + 1]) func get_last_position(entity_id: int) -> Vector2: # DropManager 在 ENEMY_KILLED 事件中查询死亡位置(EnemyManagerCs 死亡时缓存到字典) return _death_position_cache.get(entity_id, Vector2.ZERO) func get_type(entity_id: int) -> String: # DropManager / BossManager 查询敌人类型 ID(对应掉落表 key / boss_config key) return _entity_type_map.get(entity_id, "") # { entity_id: String },spawn 时写入 func get_hp_percent(entity_id: int) -> float: # BossManager 多阶段 HP 切换使用;entity_id 存在则返回 [0.0, 1.0],否则返回 0.0 var idx: int = _entity_index_map.get(entity_id, -1) if idx < 0: return 0.0 var hp := _data[idx * ENEMY_STRIDE + 4] # slot +4: hp(权威来源 implementation_plan §2.3.C) var hp_max := _data[idx * ENEMY_STRIDE + 5] # slot +5: hp_max return hp / max(hp_max, 0.001) func get_visible_count() -> int: # AudioManager 动态音乐层轮询;返回当前在 Camera AABB 内的存活敌人数 return _visible_count # EnemyManagerCs 的 LOD 循环维护此计数,O(1) 读取 func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2: # BulletManager.get_nearest_enemy_pos() 调用;线性扫描 _enemy_pos_snapshot,O(M) # 与 homing 子弹同一快照,无额外查询开销;仅在发射时调用,非每帧热路径 var best_pos := Vector2.ZERO var best_dist := max_dist * max_dist # 比较平方距离,避免 sqrt for i in range(_enemy_count): if _visible_flags[i] == 0: continue # 跳过屏外(可选:也可全扫) var px := _data[i * ENEMY_STRIDE + 0] var py := _data[i * ENEMY_STRIDE + 1] var d2 := (px - origin.x) * (px - origin.x) + (py - origin.y) * (py - origin.y) if d2 < best_dist: best_dist = d2 best_pos = Vector2(px, py) return best_pos ``` > **EnemyManager SoA 完整槽位布局**(权威来源:`implementation_plan.md §2.3.C`,P6-N45): > ``` > ENEMY_STRIDE = 8 > [ x, y, vx, vy, hp, hp_max, faction_and_type, status_bits ] > 0 1 2 3 4 5 6 7 > ``` > - `+4 hp`:当前生命值;`+5 hp_max`:最大生命值(BossManager 的 `get_hp_percent` 读取此两槽) > - `+6 faction_and_type`:高 16 位 = faction(0=敌方, 1=友方);低 16 位 = enemy_type_id > - `+7 status_bits`:燃烧/冰冻/中毒等位掩码(精确 DoT 计时存 `_enemy_contexts` 冷数据) > - `_death_position_cache`、`_entity_type_map`、`_entity_index_map` 为类成员 `Dictionary`,spawn/kill 时 GDScript 维护。 * **自动瞄准目标选取算法**:法杖自动开火时,发射方向需要选取"目标敌人"。目标选取逻辑集中在 `PlayerManager.get_aim_target()` 中,优先级如下: 1. **手柄/鼠标显式瞄准**:若存在显式输入方向(`aim_vector.length() > 0.3`),直接使用该方向,不进行目标锁定。 2. **最近敌人(默认)**:从 `_enemy_pos_snapshot` 线性扫描,取欧氏距离最小的存活敌人位置,作为开火方向。时间复杂度 O(M),与 Homing 快照共用,无额外查询。 3. **自定义优先级扩展(P2)**:商店购买"目标优先级"被动时(如"优先最低 HP"、"优先最近"),`PlayerManager.aim_priority` 属性切换选取算法,但当前 P0/P1 阶段固定为最近敌人。 ```gdscript # player_manager.gd func get_aim_direction() -> Vector2: var aim := Input.get_vector("aim_left", "aim_right", "aim_up", "aim_down") if aim.length() > 0.3: return aim.normalized() # 手柄/鼠标显式瞄准优先 # 自动瞄准:查最近敌人快照 var nearest_pos := BulletManager.get_nearest_enemy_pos(get_position()) if nearest_pos == Vector2.INF: return Vector2.RIGHT # 无敌人时向右发射(避免零向量) return (nearest_pos - get_position()).normalized() ``` ### 4.3 物理与碰撞机制 * **Area2D (优先方案)**: 子弹使用 `Area2D` + `CollisionShape2D`,通过 `body_entered` 信号回调处理命中。Godot 的 Area2D 属于轻量级重叠检测,无刚体动力学开销。 * 利用 **Physics Layer & Mask** 系统过滤阵营(子弹层只与敌人层交互),避免无效检测。 * **Spatial Hashing Fallback**: 若 Area2D 在 2000+ 弹幕下仍有压力,回退到定制的 **Spatial Grid** (一维数组网格),只计算临近 Grid 的实体碰撞,确保碰撞检测复杂度维持在 O(N)。 * **Separation Logic**: 怪物挤压不使用刚体求解,而是施加简单的轻量级斥力向量。 * **Cell Size 定义**: 默认格子边长 = `max_collider_radius × 2`(如最大子弹半径 20px → Cell = 40px)。格子过小增加哈希桶压力,格子过大降低空间剪裁效果。推荐随关卡配置可调。 * **超大弹体豁免策略**:`nuke` 等特殊弹体半径可达 300px,若将 Cell Size 设为 600px 会使整个 SpatialGrid 退化为单格全量扫描。 解决方案:SpatialGrid 的 `max_collider_radius` 仅取**标准弹体**上限(如 24px → Cell = 48px); `radius > LARGE_PROJECTILE_THRESHOLD`(默认 = 64px)的弹体**绕过 SpatialGrid**,改用 `EnemyManager.query_aabb(bullet_aabb)` 进行全量矩形测试—— 此类超大弹体数量通常极少(≤ 5 颗/帧),全量扫描代价可接受;同时 SpatialGrid 精度不受破坏。 `BulletManager` 在 spawn 时按 radius 自动路由到正确的碰撞路径,不需要业务层感知区别。 * **运行时切换策略**: `BulletManager` 维护 `_collision_mode: int`(`0` = Area2D,`1` = SpatialGrid)。 * 上穿阈值(`_active_count > 2000 and _collision_mode == 0`):**分帧渐进禁用** Area2D(每帧最多关闭 `BATCH_DISABLE_PER_FRAME = 100` 个节点,约需 20 帧完成 2000 节点的切换,避免单帧卡顿);过渡期内新生成子弹直接走 SpatialGrid 路径;所有 Area2D 禁用完毕后正式激活 MultiMesh 渲染。 * 下穿回滞(`_active_count < 1500 and _collision_mode == 1`):反向切回,避免阈值附近频繁震荡。**⚠️ 反向切换 Jitter 风险**:从 SpatialGrid 切回 Area2D 时,约 1500 颗子弹需要重新启用 Node(`process_mode = INHERIT`)。若全部在单帧完成,将产生约 1500 次节点状态更改(额外 1~2ms,可见微卡顿)。**缓解策略**:反向切换同样须分帧渐进(`BATCH_ENABLE_PER_FRAME = 100`,约 15 帧完成),过渡期内存活子弹继续走 SpatialGrid 路径,碰撞正确性不受影响。 * 两段路径(信号回调 vs 手动查询)最终均调用同一 `_on_bullet_hit(bullet_id, enemy_id)` 函数,后处理逻辑完全共用。 ### 4.4 渲染优化 子弹渲染策略依据同屏数量分三档,**Area2D 与 MultiMeshInstance2D 不共存**——MultiMesh 绕过节点树,意味着子弹无法挂载 Area2D。因此两者适用于不同的弹幕规模区间: | 同屏弹幕数 | 渲染方案 | 碰撞方案 | 说明 | | :--- | :--- | :--- | :--- | | < 200 | Node Pool (Node2D) | Area2D | 全功能,支持粒子特效、信号回调 | | 200–2000 | Node Pool + Dirty Sync | Area2D | 仅视口内节点同步 position | | 2000+ | MultiMeshInstance2D | 自定义 SpatialGrid | 完全绕过节点树,牺牲特效换取帧率 | * **Node Pooling**: 严格的节点池管理(使用 Array 存储空闲节点,Node.process_mode = DISABLED 代替真实销毁)。 * **MultiMeshInstance2D**: 仅在同屏弹幕超过 2000 且切换为 SpatialGrid 碰撞时启用。启用后 BulletManager 不再为每颗子弹维护 Node 实例,改为直接更新 `MultiMesh.transform_array`。 **SoA 索引 ↔ MultiMesh transform_array 索引映射规则**: BulletManager 的 `_data: PackedFloat32Array` 使用 **紧凑活跃列表(Compact Active List)** 布局:索引 0 到 `_active_count - 1` 始终是存活子弹,dead-and-removed 子弹用 swap-and-pop 填入空位。MultiMesh 的 `instance_count` 始终等于 `_active_count`,两者**共享同一索引**——SoA 中第 i 个子弹对应 MultiMesh 第 i 个实例变换。 ```gdscript # BulletManagerCs.cs — _PhysicsProcess 末尾(完成 SoA 积分后) # 将所有活跃子弹的位置同步到 MultiMesh.transform_array(批量上传) private void SyncMultiMesh() { // _multiMesh.InstanceCount 由 GDScript 在 _active_count 变化时更新 // 这里只做 transform_array 的批量写入,不触发单实例 set_instance_transform_2d() var transforms = new float[_activeCount * 8]; // Transform2D = 6 float + 2 位置 = Godot 内部格式 8 float var span = _data.AsSpan(); for (int i = 0; i < _activeCount; i++) { int src = i * BulletStride; int dst = i * 8; // Transform2D(1,0, 0,1, px, py) — 纯平移,无旋转(子弹朝向由 Sprite 贴图决定) transforms[dst] = 1f; transforms[dst + 1] = 0f; // col0 transforms[dst + 2] = 0f; transforms[dst + 3] = 1f; // col1 transforms[dst + 4] = span[src]; // px transforms[dst + 5] = span[src + 1]; // py // Godot MultiMesh 格式:8 floats per instance(含 custom_data,后 2 float 可复用为 type/size) transforms[dst + 6] = span[src + 10]; // bullet_type(用于 Shader 切换 UV atlas 区域) transforms[dst + 7] = span[src + 9]; // size_mult(用于 Shader 缩放实例) } _multiMesh.SetBuffer(transforms); // 单次批量上传,1 次 Draw Call } ``` **子弹类型与 MultiMesh Shader**:MultiMesh 使用单一材质 + Atlas Texture,`transform_array[i * 8 + 6]`(bullet_type 整数)作为 Shader 参数,在 Canvas Shader 中做 `UV = atlas_uv_for_type(bullet_type)`,支持不同外观子弹共用一次 Draw Call。 * **Throttling (分帧降频)**: * 伤害数字:每帧最多弹出 10 个,多余的合并或延迟显示。 * AI 索敌:不需要每帧执行 FindNearest,可分散到 10~20 帧内轮询一次(利用 Engine.get_physics_frames() % 15 == entity_id % 15 错峰轮询)。 ### 4.5 帧预算分配表 (Frame Budget Allocation) > **目标**:60fps → 单帧总预算 **16.67ms**。以下为 S0 性能验收的基准分配;S0 Profiler 实测后需将"S0 实测值"列回填并提交 git,作为后续切片的性能基线快照。 | 系统 | 语言 | 预算上限 | S0 实测值 | 说明 | | :--- | :--- | :--- | :--- | :--- | | `BulletManagerCs._PhysicsProcess` | C# | 2.0ms | **0.37ms** | 2000 子弹 GDScript SoA 积分(P-S0-01;C# 热路径 S1 填充后预计更低) | | `EnemyManagerCs._PhysicsProcess` | C# | 2.0ms | **0.23ms** | 1000 敌人直线追踪 + fill_pos_snapshot(P-S0-02;Boid C# S1 填充) | | `SpatialGridCs` 重建 + `query_circle` | C# | 1.5ms | **< 0.001ms** | GDScript stub(返回空数组);C# SpatialGridCs 实例化后 S1 回填实测 | | `SpellEvaluatorCs.execute_compiled` | C# | 1.0ms | — | 高频施法内层 while 循环(S1 起填入)| | `StatusManagerCs._PhysicsProcess` | C# | 1.5ms | — | 200+ DoT 逐 tick(S4 验收后填入,P-S4-02;与 implementation_plan §2.4 统一)| | `ZoneManagerCs._PhysicsProcess` | C# | 1.0ms | — | 64 zones × SpatialGrid 查询(S5 验收后填入)| | GDScript 游戏循环(WaveManager / EventBus dispatch)| GDScript | 1.5ms | — | 事件分发 + 波次状态机 | | MultiMesh transform 上传 CPU→GPU | Godot 渲染器 | 1.5ms | — | 2000+ 子弹时(S6 验收后填入)| | Godot 渲染器 Draw Call 基线 | 引擎 | 2.0ms | — | 背景 + 角色 + HUD(S1 场景建立后填入)| | **保留余量**(突发帧:GC、资源加载)| — | **1.17ms** | — | — | | **合计** | | **16.67ms** | **≈ 0.60ms**(S0 GDScript 基线,2000弹+1000敌;FPS 180)| | > **S0 实测摘要(2026-06-04)**:机器 RTX 2060;Godot 4.6.2 stable mono;Windows 10。2000 子弹 + 1000 敌人同屏,GDScript fallback 运行(C# 热路径骨架未激活)。`physics_frame_time_msec`(Godot Monitor)= 0.05~0.13ms,FPS 稳定 145~180,远超 60fps 目标。R-01/R-03 风险**已消除**。SpatialGrid C# 实例化与 AsSpan() 零拷贝基准在 S1 阶段随 BulletManagerCs 真实热路径一同验收(P-S0-06/P-S0-07 S1 延续项)。 **验收规则**: - **S0 联合基准**:`BulletManagerCs` + `EnemyManagerCs` + `SpatialGridCs` 三项合计实测 **< 6ms**(覆盖 P-S0-01/02/04)。 - **超标触发流程**:若任一系统实测超出预算上限,**该切片不得进入下一切片**;立即检查是否违反 ADR-L1 规则 1(内层循环 `Call()`)或规则 2(未使用 `AsSpan()` 零拷贝)。 - **回填规范**:每个新切片的验收标准中对该切片新增系统补充帧时间目标;S6 发布前所有"—"必须替换为实测值。 --- ### 4.6 内存预算表 (Memory Budget) > **目标**:确保在目标平台(PC 和未来 Nintendo Switch)的内存占用在可接受范围内。Switch 主内存总量 4GB,系统 + 驱动常驻约 1.5GB,游戏可用约 **2.5GB**;PC 目标 RSS < **512MB**。 | 内存类别 | PC 预算 | Switch 预算 | 说明 | | :--- | :--- | :--- | :--- | | 代码 + GDScript VM + C# CLR | 80MB | 200MB | Mono 运行时常驻较大 | | 纹理资源(Atlas + VFX + UI) | 150MB | 400MB | 压缩格式:PC=DXT5,Switch=ASTC4×4 | | 音频资源(BGM 流式 + SFX 预加载)| 30MB | 80MB | BGM 流式播放,SFX 池全量预加载 | | BulletManager SoA(2000 子弹) | < 1MB | < 1MB | `BULLET_STRIDE=12` × 2000 × 4B ≈ 96KB | | EnemyManager SoA(1000 敌人) | < 1MB | < 1MB | `ENEMY_STRIDE=8` × 1000 × 4B ≈ 32KB | | SpellContext 对象池(32 实例) | < 1MB | < 1MB | 每实例约 2KB(含 payloads Array) | | Node Pool(子弹节点 2000 个)| 20MB | 50MB | 每 Node2D 约 10KB;MultiMesh 激活后降至 ~0 | | VFX 粒子节点池(200 个)| 5MB | 10MB | MAX_ACTIVE_VFX=200 | | DPS 环形缓冲区 + 其他运行时 | < 1MB | < 1MB | 各 PackedFloat 数组合计 | | 存档文件(user://) | < 1MB | < 1MB | JSON 明文 < 100KB;含 HMAC 签名 | | **合计(估算)** | **~290MB** | **~745MB** | Switch 远低于 2.5GB 限制 | **验收规则**: - **S6 P0 验收 P-S6-07**:Godot Profiler → Memory 标签页实测 RSS:PC < 512MB,Switch 目标 < 2.5GB。 - **热场景峰值**:Wave 20 Boss 战(最大同屏实体数)测量内存峰值,作为 S6 发布基线。 - **纹理内存监控**:`RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TEXTURE_MEM_USED)` 不超过 PC 预算的 150MB。 --- ## 5. 游戏循环与系统集成 (Game Loop & System Integration) ### 5.1 GameCycleManager 状态机 (FSM) `GameCycleManager` 是游戏最顶层的协调者,持有全局状态机并驱动各 Manager 的生命周期。 ```gdscript # game_cycle_manager.gd (Autoload: GameCycleManager) enum GameState { MAIN_MENU = 0, # 主菜单(初始/死亡后/通关后回到此状态) LOADING = 1, # 场景加载过渡(ResourceLoader 异步加载战斗场景) SHOP = 2, # 商店/背包阶段(波次间) WAVE_INTRO = 3, # 波次开场动画(约 1.5s,Boss 波有特殊演出) WAVE = 4, # 战斗进行中 WAVE_RESULT = 5, # 本波结算(经验弹算、升级选择 UI) GAME_OVER = 6, # 死亡结算界面 GAME_CLEARED = 7, # 通关结算(Wave 20 Boss 击杀后) PAUSE = 8, # 暂停(可从 WAVE/SHOP 进入,恢复后返回原状态) } var _state: GameState = GameState.MAIN_MENU var _prev_state: GameState = GameState.MAIN_MENU # 用于 PAUSE 恢复 var wave_num: int = 0 # 当前波次(UIManager / WaveManager 均读取) var _rng: RandomNumberGenerator = RandomNumberGenerator.new() # _rng 在每次 Run 开始时重置:_rng.seed = hash(Time.get_ticks_msec()) # UpgradeSystem.build_choices(wave_num, _rng) 与 ShopManager 独立使用各自 seed,互不干扰 func transition_to(next: GameState) -> void: _on_exit(_state) _prev_state = _state _state = next _on_enter(next) func get_state() -> GameState: return _state ``` #### 状态转换表 | 当前状态 | 触发条件 | 目标状态 | 关键副作用 | | :--- | :--- | :--- | :--- | | `MAIN_MENU` | 玩家点击"开始游戏" | `LOADING` | 异步加载 BattleScene.tscn | | `LOADING` | 场景加载完成信号 | `SHOP` | wave_num=1;调用 ShopManager.open_shop(wave_num) | | `SHOP` | 玩家点击"出发" | `WAVE_INTRO` | PlayerManager.compile_all_wands();WaveManager.prepare_wave(wave_num) | | `WAVE_INTRO` | 开场动画播放完毕(1.5s Timer) | `WAVE` | WaveManager.start_wave();BulletManager.reset();EnemyManager.reset() | | `WAVE` | `EventID.WAVE_COMPLETE` 收到 | `WAVE_RESULT` | 经验结算;存档(ADR-A2 §2 波次结束自动存档) | | `WAVE` | `EventID.PLAYER_DIED` 收到 | `GAME_OVER` | 统计数据收集;EndlessRecordsManager 不更新 | | `WAVE_RESULT` | wave_num < 20,玩家确认升级 | `SHOP` | wave_num++;ShopManager.restock(wave_num) | | `WAVE_RESULT` | wave_num == 20 且 Boss 已击杀 | `GAME_CLEARED` | EndlessRecordsManager 更新;Steam成就触发 | | `WAVE_RESULT` | wave_num > 20(Endless模式) | `SHOP` | wave_num++;ShopManager.restock(wave_num) | | `GAME_OVER` / `GAME_CLEARED` | 玩家点击"返回菜单" | `MAIN_MENU` | 卸载 BattleScene;重置所有 Autoload Manager | | 任意战斗状态 | Input.is_action_just_pressed("pause") | `PAUSE` | 保存 _prev_state;Engine.time_scale = 0 | | `PAUSE` | 再次按暂停键 | `_prev_state` | Engine.time_scale = 1 | #### Manager Reset 协议 每次从 `GAME_OVER`/`GAME_CLEARED` 返回 `MAIN_MENU` 时,GameCycleManager 负责按顺序重置所有 Autoload: ```gdscript func _reset_all_managers() -> void: # 顺序重要:先停止所有计算,再清理数据,最后重置 UI BulletManager.reset() # 清空所有子弹 SoA 数据 EnemyManager.reset() # 清空所有敌人 SoA 数据 MinionManager.reset() # 清空所有召唤物 ZoneManager.reset() # 清空所有地面效果 StatusManager.reset() # 清空所有状态效果 SpellEvaluator.reset() # 清空 SubPayloadRegistry DamageContextPool.reset() # 归还所有在途 context(防止下局 context 泄漏) PlayerManager.reset() # 重置血量/位置/蓄力状态 WaveManager.reset() # wave_num = 0;spawn 队列清空 ShopManager.reset() # 商品列表清空 ProfileManager.clear_run() # 清除本局存档 ``` --- ### 5.2 WaveManager 框架设计 `WaveManager` 负责读取波次配置、按时序 spawn 敌人、检测清场条件、向 GameCycleManager 上报结果。 #### 波次配置 JSON 格式 ```json // res://resources/waves/wave_01.json { "wave_num": 1, "duration_sec": 30, // 波次持续时间上限(超时也算通过) "is_boss_wave": false, // true 时触发 BOSS_PHASE_CHANGED 事件和特殊 BGM "spawn_groups": [ { "enemy_id": "slime_basic", "count": 20, "spawn_mode": "perimeter", // "perimeter" = 屏幕外缘随机, "cluster" = 聚集点, "instant" = 全量即时 "delay_sec": 0.0, // 从波次开始到此批出现的延迟 "interval_sec": 0.5 // 同批内每只敌人的间隔(0 = 即时全量) }, { "enemy_id": "bat_fast", "count": 10, "spawn_mode": "perimeter", "delay_sec": 8.0, "interval_sec": 0.3 } ], "clear_condition": "kill_all" // "kill_all" | "survive_duration" | "kill_boss" } ``` #### WaveManager 状态与接口 ```gdscript # wave_manager.gd (Autoload: WaveManager) var wave_num: int = 0 var _elapsed: float = 0.0 var _alive_count: int = 0 # 存活敌人数 var _total_spawned: int = 0 # 本波已 spawn 敌人总数 var _config: Dictionary = {} # 当前波次 JSON 解析结果 # 由 GameCycleManager 在 SHOP→WAVE_INTRO 时调用 func prepare_wave(num: int) -> void: wave_num = num _elapsed = 0.0; _alive_count = 0; _total_spawned = 0 var path := "res://resources/waves/wave_%02d.json" % num _config = JSON.parse_string(FileAccess.open(path, FileAccess.READ).get_as_text()) # Boss 波:提前通知 AudioManager 切换 BGM 分层(淡入 Boss 主题) if _config.get("is_boss_wave", false): EventBus.emit(EventID.BOSS_PHASE_CHANGED, {"boss_id": _config.get("boss_id",""), "phase": 0, "bgm_layer": "boss"}) # 由 GameCycleManager 在 WAVE_INTRO 完成后调用 func start_wave() -> void: _schedule_spawns() func _physics_process(delta: float) -> void: if GameCycleManager.get_state() != GameCycleManager.GameState.WAVE: return _elapsed += delta _process_spawn_queue(delta) _check_clear_condition() func _check_clear_condition() -> void: match _config.get("clear_condition", "kill_all"): "kill_all": if _total_spawned >= _total_required() and _alive_count <= 0: EventBus.emit(EventID.WAVE_COMPLETE, {"wave_num": wave_num}) "survive_duration": if _elapsed >= _config.get("duration_sec", 30.0): EventBus.emit(EventID.WAVE_COMPLETE, {"wave_num": wave_num}) "kill_boss": pass # 由 BossManager 在 Boss 死亡时发出 WAVE_COMPLETE # 订阅 ENEMY_KILLED:更新存活计数 func _on_enemy_killed(_enemy_id: int, _killer_id: int) -> void: _alive_count = max(0, _alive_count - 1) ``` #### 敌人 spawn 与 EnemyManager 协议 WaveManager 不直接操作 EnemyManager 的 SoA 数据,而是调用高层接口: ```gdscript # EnemyManager 对外接口(GDScript Autoload 层) func spawn(enemy_id: String, position: Vector2) -> int: # 返回 entity_id func reset() -> void # 清空 SoA(由 GameCycleManager 调用) func get_alive_count() -> int func fill_pos_snapshot(out: PackedVector2Array) -> void # BulletManager homing 用 ``` --- ### 5.3 ShopManager 框架设计 `ShopManager` 负责商品池管理、商店刷新、购买流程。与 `PlayerManager`(货币/库存)解耦通过 EventBus 或直接调用接口。 #### 商品池与刷新算法 ```gdscript # shop_manager.gd (Autoload: ShopManager) # 商品类型(可出现在商店的物品分类) enum ShopItemType { SPELL = 0, PASSIVE = 1, CONSUMABLE = 2, CORE = 3 } # 商店槽位数:固定 6 格(3 法术 + 2 被动 + 1 消耗品) const SLOT_COUNT: int = 6 const REROLL_BASE_COST: int = 20 # 首次刷新费用 const REROLL_COST_STEP: int = 10 # 每次刷新追加费用 var _slots: Array[Dictionary] = [] # 当前商店展示的商品 var _reroll_count_this_wave: int = 0 # 本波刷新次数(结算后重置) var _rng: RandomNumberGenerator = RandomNumberGenerator.new() # 由 GameCycleManager 调用 func open_shop(wave_num: int) -> void: _reroll_count_this_wave = 0 _rng.seed = hash(wave_num) ^ ProfileManager.get_run_seed() # 确定性种子,防退出重开刷商店 _fill_slots(wave_num) func restock(wave_num: int) -> void: open_shop(wave_num) func get_reroll_cost() -> int: return REROLL_BASE_COST + _reroll_count_this_wave * REROLL_COST_STEP func reroll(wave_num: int) -> bool: var cost := get_reroll_cost() if not PlayerManager.spend_gold(cost): return false _reroll_count_this_wave += 1 _fill_slots(wave_num) return true func _fill_slots(wave_num: int) -> void: _slots.clear() # 权重池:随波次调整稀有度分布(越后期出现高稀有度概率越高) var pool := _build_weighted_pool(wave_num) for i in SLOT_COUNT: _slots.append(_pick_from_pool(pool)) func _build_weighted_pool(wave_num: int) -> Array: # ── 稀有度权重(与 UpgradeSystem._rarity_weights 保持相同曲线)──────── var common_w := max(5.0, 60.0 - wave_num * 2.5) var uncommon_w := min(50.0, 30.0 + wave_num * 1.5) var rare_w := min(30.0, max(5.0, wave_num * 1.2 - 5.0)) var legendary_w := min(10.0, max(0.0, wave_num * 0.5 - 8.0)) var rarity_weights := [common_w, uncommon_w, rare_w, legendary_w] # ── 候选池:SPELL + PASSIVE(按稀有度归组)──────────────────────────── var candidates: Array[Array] = [[], [], [], []] # index = rarity-1 for item in SpellRegistry.all(): candidates[clamp(item.rarity - 1, 0, 3)].append({ "type": ShopItemType.SPELL, "id": item.spell_id, "price": 4 + (item.rarity - 1) * 4 # 价格公式:4/8/12/16 金币 }) for item in PassiveRegistry.all(): candidates[clamp(item.rarity - 1, 0, 3)].append({ "type": ShopItemType.PASSIVE, "id": item.passive_id, "price": 6 + (item.rarity - 1) * 5 # 被动略贵:6/11/16/21 金币 }) # ── 构建带权重的扁平池 ───────────────────────────────────────────── var pool: Array = [] for rarity_idx in 4: var w: float = rarity_weights[rarity_idx] for entry in candidates[rarity_idx]: pool.append({"entry": entry, "weight": w}) return pool func _pick_from_pool(pool: Array) -> Dictionary: # 使用确定性 _rng(防退出重开刷商店,与 open_shop seed 一致) if pool.is_empty(): return {} var total := 0.0 for item in pool: total += item["weight"] var roll := _rng.randf() * total for item in pool: roll -= item["weight"] if roll <= 0.0: return item["entry"] return pool[-1]["entry"] ``` #### 购买流程与 PlayerManager 接口 ```gdscript # 购买接口(UIManager 点击购买时调用) func purchase(slot_idx: int) -> bool: if slot_idx < 0 or slot_idx >= _slots.size(): return false var item: Dictionary = _slots[slot_idx] if item.is_empty(): return false var cost: int = item.get("price", 0) if not PlayerManager.spend_gold(cost): return false # 按类型分发给对应 Manager match item.get("type", ShopItemType.SPELL): ShopItemType.SPELL: PlayerManager.add_spell_to_inventory(item.get("id", "")) ShopItemType.PASSIVE: PlayerManager.add_passive(item.get("id", "")) ShopItemType.CORE: PlayerManager.replace_core(item.get("slot", 0), item.get("id", "")) ShopItemType.CONSUMABLE: PlayerManager.use_consumable(item.get("id", "")) _slots[slot_idx] = {} # 标记为已售 return true func get_slots() -> Array[Dictionary]: # UIManager._refresh_shop_ui() 读取当前商品展示列表(只读副本) return _slots.duplicate() func reset() -> void: _slots.clear(); _reroll_count_this_wave = 0 func offer_free_spell(spell_id: String) -> void: # DropManager 拾取法术卡掉落时调用。 # 弹出"拾取法术"交互 UI,玩家确认后才实际装备(防止强制打断操作)。 # UIManager 订阅此函数弹出的事件;若 UIManager 未处理则直接给予背包。 if not SpellRegistry.has(spell_id): return EventBus.emit(EventID.SPELL_DROP_PICKUP, { "spell_id": spell_id, "auto_equip": false # false = 弹 UI 让玩家决定装到哪个 Core;true = 装 active_core }) ``` --- ### 5.4 AudioManager 框架设计 `AudioManager` 管理 AudioBus 层级、动态音乐分层、音效池和空间音频。 > **权威定义说明**:本节为 AudioBus 结构的唯一权威来源。`ADR-A3`(§文档末尾)提供补充规则(空间音效衰减参数、节流规则、音量持久化细节),不重新定义 Bus 结构。两者合并阅读。 #### AudioBus 层级(权威) ``` Master ├── BGM (背景音乐总线,-6dB 预衰减) │ ├── BGM_Base (基础旋律层,常驻播放,AudioStreamPlayer 独占) │ └── BGM_Layer (动态叠加层:战斗强度 / Boss 阶段驱动;3 轨并行,按强度 fade-in) ├── SFX (战斗音效总线,-3dB) │ ├── SFX_Combat (子弹/爆炸/伤害,来自 BulletManager/EnemyManager) │ └── SFX_Spatial(2D 空间音效,挂 AudioEffect2DPan + 2D Reverb;max_distance=1200px) ├── UI (UI 音效,不受空间衰减,-3dB) ├── Ambience (循环环境音,-9dB) └── Voice (语音 / Boss 台词,最高优先级,不受 SFX 池限制) ``` **AudioBusID 常量 Autoload**(代码中所有 Bus 名称必须通过此 Autoload 引用,禁止硬编码字符串): ```gdscript # scripts/autoloads/audio_bus_id.gd (Autoload: AudioBusID) const MASTER : StringName = &"Master" const BGM : StringName = &"BGM" const BGM_BASE : StringName = &"BGM_Base" const BGM_LAYER : StringName = &"BGM_Layer" const SFX : StringName = &"SFX" const SFX_COMBAT : StringName = &"SFX_Combat" const SFX_SPATIAL : StringName = &"SFX_Spatial" const UI : StringName = &"UI" const AMBIENCE : StringName = &"Ambience" const VOICE : StringName = &"Voice" ``` **Bus 配置文件**:`res://audio/bus_layout.tres`(Godot 内置 AudioBusLayout Resource,在 Project Settings > Audio 中指定)。 #### AudioManager 完整数据模型 ```gdscript # audio_manager.gd (Autoload: AudioManager) # ── 常量 ─────────────────────────────────────────────────────── const MAX_CONCURRENT_SFX: int = 32 # 同时最多播放 32 个 SFX 实例(SFX_Combat + SFX_Spatial 共享上限) const MAX_SAME_SFX_PER_FRAME: int = 3 # 同一音效每帧最多触发 3 次(防弹幕爆炸噪音堆叠) const BGM_FADE_OUT_SEC: float = 0.8 # BGM 淡出时长(秒) const LAYER_FADE_SPEED: float = 2.0 # 动态音乐层淡变速率(dB/秒) const SFX_COOLDOWN_SEC: float = 0.1 # 同一音效最小间隔(防 0.1s 内重复播放) # ── BGM 三步交叉淡变状态机 ──────────────────────────────────────── enum BgmPhase { IDLE, FADING_OUT, SWITCHING, FADING_IN } var _bgm_phase: BgmPhase = BgmPhase.IDLE var _current_bgm_id: String = "" var _pending_bgm_id: String = "" var _pending_fade_in: float = 1.5 var _fade_timer: float = 0.0 # ── BGM 播放器节点(_ready() 中创建并 add_child)─────────────────── var _bgm_base_player: AudioStreamPlayer = null # BGM_Base Bus 专属播放器 var _music_layers: Array[AudioStreamPlayer] = [] # BGM_Layer Bus 上的 3 个动态层播放器(index 0/1/2) var _layer_target: int = 0 # 当前目标动态层级(0=基础/1=战斗/2=高强度) # ── SFX 池 ────────────────────────────────────────────────────── var _sfx_pool: Array[AudioStreamPlayer2D] = [] # 预分配节点池(MAX_CONCURRENT_SFX 个) var _sfx_active_count: int = 0 # O(1) 可用池数量追踪(P6-N60 规范) var _sfx_frame_count: Dictionary = {} # { sfx_id: int } 本帧计数(_process() 帧末清零) var _sfx_last_play_time: Dictionary = {} # { sfx_id: float } 同 ID 节流计时 func _ready() -> void: # 创建 BGM_Base 播放器 _bgm_base_player = AudioStreamPlayer.new() _bgm_base_player.bus = AudioBusID.BGM_BASE add_child(_bgm_base_player) # 创建 3 个动态层播放器(挂 BGM_Layer Bus) for i in 3: var lp := AudioStreamPlayer.new() lp.bus = AudioBusID.BGM_LAYER lp.volume_db = -80.0 # 初始静音 add_child(lp) _music_layers.append(lp) # 预分配 SFX 池 for i in MAX_CONCURRENT_SFX: var sp := AudioStreamPlayer2D.new() sp.bus = AudioBusID.SFX_COMBAT # 默认 Combat 总线,play_sfx() 可按类型切换 sp.finished.connect(_on_sfx_finished.bind(sp)) add_child(sp) _sfx_pool.append(sp) EventBus.subscribe(EventID.BOSS_PHASE_CHANGED, _on_boss_phase_changed) EventBus.subscribe(EventID.BOSS_KILLED, _on_boss_killed) EventBus.subscribe(EventID.PLAYER_DIED, _on_player_died) ``` #### 核心接口 ```gdscript # ── SFX 接口 ───────────────────────────────────────────────────── func play_sfx(sfx_id: String, position: Vector2, volume_db: float = 0.0, bus: StringName = AudioBusID.SFX_COMBAT) -> void: # 同帧频率限制 _sfx_frame_count[sfx_id] = _sfx_frame_count.get(sfx_id, 0) + 1 if _sfx_frame_count[sfx_id] > MAX_SAME_SFX_PER_FRAME: return # 同 ID 0.1s 节流 var now := Time.get_ticks_msec() / 1000.0 if now - _sfx_last_play_time.get(sfx_id, 0.0) < SFX_COOLDOWN_SEC: return _sfx_last_play_time[sfx_id] = now var player := _acquire_sfx_player() if player == null: return # 池耗尽,静默丢弃(非崩溃) player.stream = SfxLibrary.get(sfx_id) player.bus = bus player.position = position player.volume_db = volume_db player.play() func play_sfx_ui(sfx_id: String) -> void: # UI 音效:不需要位置,直接挂 UI Bus var player := _acquire_sfx_player() if player == null: return player.stream = SfxLibrary.get(sfx_id) player.bus = AudioBusID.UI player.position = Vector2.ZERO player.volume_db = 0.0 player.play() func _acquire_sfx_player() -> AudioStreamPlayer2D: if _sfx_active_count >= MAX_CONCURRENT_SFX: return null for p in _sfx_pool: if not p.playing: _sfx_active_count += 1 return p return null # 全部占用 func _on_sfx_finished(player: AudioStreamPlayer2D) -> void: _sfx_active_count = max(0, _sfx_active_count - 1) # ── BGM 接口 ───────────────────────────────────────────────────── func play_bgm(bgm_id: String, fade_in_sec: float = 1.5) -> void: if _current_bgm_id == bgm_id: return _pending_bgm_id = bgm_id _pending_fade_in = fade_in_sec _bgm_phase = BgmPhase.FADING_OUT func fade_out_all(duration: float = 0.5) -> void: # 玩家死亡时调用:所有 Bus 在 duration 秒内淡出至 -40dB var tween := create_tween() for bus_name in [AudioBusID.BGM, AudioBusID.SFX, AudioBusID.UI, AudioBusID.AMBIENCE]: var idx := AudioServer.get_bus_index(bus_name) tween.parallel().tween_method( func(v): AudioServer.set_bus_volume_db(idx, v), AudioServer.get_bus_volume_db(idx), -40.0, duration) # ── BGM 三步交叉淡变(_process() 每帧驱动)──────────────────────── func _process_bgm_fade(delta: float) -> void: match _bgm_phase: BgmPhase.FADING_OUT: _fade_timer += delta _bgm_base_player.volume_db = lerp(0.0, -80.0, _fade_timer / BGM_FADE_OUT_SEC) if _fade_timer >= BGM_FADE_OUT_SEC: _bgm_base_player.stop() _fade_timer = 0.0 _bgm_phase = BgmPhase.SWITCHING BgmPhase.SWITCHING: _bgm_base_player.stream = BgmLibrary.get(_pending_bgm_id) _bgm_base_player.volume_db = -80.0 _bgm_base_player.play() _current_bgm_id = _pending_bgm_id _bgm_phase = BgmPhase.FADING_IN BgmPhase.FADING_IN: _fade_timer += delta _bgm_base_player.volume_db = lerp(-80.0, 0.0, _fade_timer / _pending_fade_in) if _fade_timer >= _pending_fade_in: _bgm_base_player.volume_db = 0.0 _fade_timer = 0.0 _bgm_phase = BgmPhase.IDLE # ── 动态音乐层(每 0.5s 在 _process() 中轮询,不在 _physics_process)── func _update_music_layers(enemy_count: int, boss_active: bool) -> void: _layer_target = 0 if boss_active or enemy_count >= 20: _layer_target = 2 elif enemy_count >= 5: _layer_target = 1 for i in _music_layers.size(): var target_db: float = 0.0 if i <= _layer_target else -80.0 _music_layers[i].volume_db = move_toward( _music_layers[i].volume_db, target_db, LAYER_FADE_SPEED * get_process_delta_time()) func _on_boss_phase_changed(payload: Dictionary) -> void: var bgm_layer: String = payload.get("bgm_layer", "") if bgm_layer.begins_with("boss"): play_bgm("boss_phase_0") func _on_boss_killed(_payload: Dictionary) -> void: play_bgm("wave_normal") func _on_player_died(_payload: Dictionary) -> void: fade_out_all(0.5) func reset() -> void: _bgm_base_player.stop() for lp in _music_layers: lp.stop(); lp.volume_db = -80.0 _current_bgm_id = ""; _bgm_phase = BgmPhase.IDLE; _fade_timer = 0.0 _sfx_frame_count.clear(); _sfx_last_play_time.clear(); _sfx_active_count = 0 ``` #### SfxLibrary / BgmLibrary 音频资源索引 `SfxLibrary` 和 `BgmLibrary` 是纯静态字典,在 `AudioManager._ready()` 时由预加载填充,供 `play_sfx` / `play_bgm` 通过字符串 ID 查找 `AudioStream`。 ```gdscript # sfx_library.gd(非 Autoload,挂在 AudioManager 子节点或直接合并到 audio_manager.gd) const _SFX_MAP: Dictionary = { # 战斗音效 ───────────────────────────── "bullet_hit_flesh" : preload("res://audio/sfx/combat/bullet_hit_flesh.ogg"), "bullet_hit_shield" : preload("res://audio/sfx/combat/bullet_hit_shield.ogg"), "explosion_small" : preload("res://audio/sfx/combat/explosion_small.ogg"), "explosion_large" : preload("res://audio/sfx/combat/explosion_large.ogg"), "player_hurt" : preload("res://audio/sfx/combat/player_hurt.ogg"), # 法术施放 ───────────────────────────── "spell_cast_generic" : preload("res://audio/sfx/spell/cast_generic.ogg"), "spell_cast_fire" : preload("res://audio/sfx/spell/cast_fire.ogg"), "spell_cast_ice" : preload("res://audio/sfx/spell/cast_ice.ogg"), # UI 音效 ────────────────────────────── "ui_click" : preload("res://audio/sfx/ui/click.ogg"), "ui_purchase" : preload("res://audio/sfx/ui/purchase.ogg"), "ui_reroll" : preload("res://audio/sfx/ui/reroll.ogg"), "ui_level_up" : preload("res://audio/sfx/ui/level_up.ogg"), } static func get(sfx_id: String) -> AudioStream: return _SFX_MAP.get(sfx_id, null) # bgm_library.gd(同上,流式资源使用 load() 而非 preload 避免启动时全量入内存) const _BGM_MAP: Dictionary = { "main_menu" : "res://audio/bgm/main_menu.ogg", "shop" : "res://audio/bgm/shop.ogg", "wave_normal" : "res://audio/bgm/wave_normal.ogg", "boss_phase_0" : "res://audio/bgm/boss_phase0.ogg", "boss_phase_1" : "res://audio/bgm/boss_phase1.ogg", "boss_phase_2" : "res://audio/bgm/boss_phase2.ogg", "game_over" : "res://audio/bgm/game_over.ogg", "game_cleared" : "res://audio/bgm/game_cleared.ogg", } static func get(bgm_id: String) -> AudioStream: var path: String = _BGM_MAP.get(bgm_id, "") if path == "": return null return load(path) as AudioStream # 流式加载,释放旧引用时 Godot 自动 GC ``` > **资源命名规范**:音效文件统一使用 `.ogg`(Vorbis,内存效率最优);BGM 为 `.ogg` 流式(`loop: true` 在 Import 设置中启用)。所有路径以 `res://audio/` 为根,子目录 `sfx/` 和 `bgm/` 对应上方结构。 --- ### 5.5 StatusManager 架构设计 `StatusManager` 集中管理所有实体的状态效果(DoT、减速、易伤等),消除各 Manager 自行处理状态的耦合。 #### 核心数据结构 ```gdscript # status_manager.gd (Autoload: StatusManager) # 所有状态 ID 常量见 status_id.gd (Autoload: StatusID) # 所有状态定义(效果、叠加规则)以 StatusTypeDef .tres 资源存储,由 StatusRegistry 加载 # SoA 布局(PackedFloat32Array):每个状态实例占 STATUS_STRIDE=5 个浮点槽 # slot 0: entity_id (float 转 int 存储) # slot 1: status_type_id (float 转 int) # slot 2: remaining_duration # slot 3: tick_accumulator # slot 4: stack_count (float 转 int) const STATUS_STRIDE: int = 5 const MAX_STATUS_INSTANCES: int = 512 # 最多同时存在 512 个状态实例(32KB) var _data: PackedFloat32Array = PackedFloat32Array() var _active_count: int = 0 ``` #### 接口与生命周期 ```gdscript # 施加状态(由 SpellEvaluator / ZoneManager / BulletManager 调用) # 权威签名(三处调用方统一使用此签名): # - SpellEvaluator: apply(target_id, status_type_id, intensity, owner_id=caster_id) # - ZoneManager: apply(target_id, status_type_id, owner_id=zone_owner_id)(intensity=1.0 默认) # - impl §2.4: apply(entity_id, status_type_id, stacks, duration, source_id) — # duration/stacks 由 StatusTypeDef 驱动,不在此签名中暴露(内部细节) func apply(entity_id: int, status_type_id: int, intensity: float = 1.0, owner_id: int = -1) -> void: # owner_id 用于 remove_source()(实体死亡时移除其施加的所有状态);-1 = 无主(区域/无主状态) var existing := _find(entity_id, status_type_id) var typedef: StatusTypeDef = StatusRegistry.get(status_type_id) if existing >= 0: match typedef.stack_mode: "refresh": _data[existing * STATUS_STRIDE + 2] = typedef.duration # 刷新时长 "stack": _data[existing * STATUS_STRIDE + 4] += 1.0 # 叠加层数 "ignore": pass # 已有则忽略 else: _add_new(entity_id, status_type_id, typedef.duration, intensity, owner_id) EventBus.emit(EventID.STATUS_APPLIED, {"target_id": entity_id, "status_type": status_type_id, "stacks": int(_data[existing * STATUS_STRIDE + 4]) if existing >= 0 else 1}) func remove_source(owner_id: int) -> void: # 移除 owner_id 施加的所有状态(召唤物/Boss 死亡时调用,防止游魂状态残留) # 详见 implementation_plan.md §2.4 remove_source 实现 var i := 0 while i < _active_count: if int(_data[i * STATUS_STRIDE + 5]) == owner_id: # slot +5: owner_id # swap-and-pop if i < _active_count - 1: for k in STATUS_STRIDE: _data[i * STATUS_STRIDE + k] = _data[(_active_count - 1) * STATUS_STRIDE + k] _active_count -= 1 else: i += 1 # 查询(EnemyManager.apply_damage() 读取易伤系数) func get_stacks(entity_id: int, status_type_id: int) -> int: var idx := _find(entity_id, status_type_id) return int(_data[idx * STATUS_STRIDE + 4]) if idx >= 0 else 0 func has_status(entity_id: int, status_type_id: int) -> bool: return _find(entity_id, status_type_id) >= 0 # _physics_process:tick DoT 并倒计时 duration,swap-and-pop 移除过期实例 func _physics_process(delta: float) -> void: var i := 0 while i < _active_count: var base := i * STATUS_STRIDE _data[base + 2] -= delta # remaining_duration 倒计时 _data[base + 3] += delta # tick_accumulator 累积 var typedef: StatusTypeDef = StatusRegistry.get(int(_data[base + 1])) while _data[base + 3] >= typedef.tick_interval: _data[base + 3] -= typedef.tick_interval _apply_dot_tick(i, typedef) # 触发 DoT 伤害 if _data[base + 2] <= 0.0: _swap_and_pop(i) # O(1) 移除,顺序可乱 else: i += 1 func reset() -> void: _active_count = 0 # 不需要清零数据,_active_count 控制有效范围 ``` #### StatusTypeDef 资源格式 ```gdscript # status_type_def.gd - Resource 子类 class_name StatusTypeDef extends Resource var display_name: String = "" # tr() KEY,UI 显示用 var duration: float = 3.0 # 默认持续时长(秒) var tick_interval: float = 1.0 # DoT 跳字间隔(非 DoT 状态设 999.0 避免误触发) var stack_mode: String = "refresh" # "refresh" | "stack" | "ignore" var max_stacks: int = 99 # 叠层上限(stack 模式) var dot_damage_per_tick: float = 0.0 # 每 tick 造成的基础伤害(0 = 无 DoT) var dot_damage_type: int = DamageType.PHYSICAL var can_catalyze: Array[int] = [] # 可参与催化的状态 ID 列表(空 = 不参与元素反应) var is_combo_tracker: bool = false # true = 计数追踪状态(tick 逻辑由调用方控制,不走 DoT 路径) var vfx_id: String = "" # 状态持续时的粒子特效 ID(空 = 无) var icon_key: String = "" # UI 状态图标的 UIAtlas 键 ``` --- ### 5.6 PlayerManager 架构设计 `PlayerManager` 是玩家实体的单一数据权威,持有 HP / 金币 / XP / 等级 / 三个 Core / 被动列表。所有系统通过 `PlayerManager` 读写玩家状态,禁止直接操作其内部字段。 #### 玩家状态数据模型 ```gdscript # player_manager.gd (Autoload: PlayerManager) # ── 生命值 ──────────────────────────────────────────────────── var hp: float = 100.0 var hp_max: float = 100.0 var hp_regen: float = 0.0 # 每秒自动回血(被动加成) # ── 经济 ────────────────────────────────────────────────────── var gold: int = 0 var xp: int = 0 var level: int = 1 # XP 升级阈值:⌊10 × 1.4^(level-1)⌋(见 numerical_design.md §1.2) # level 20 = 最终等级(Wave 20 通关上限);level 21+ 服务 Endless 模式 # ── 属性词条(被动叠加结果)──────────────────────────────────── var stats: PlayerStats = PlayerStats.new() # 伤害/攻速/移速等合并后的最终值 # ── 法杖 Core 系统(三 Core 机制)────────────────────────────── const MAX_CORES: int = 3 var cores: Array[CoreDefinition] = [] # 当前装备的 Core,长度 ≤ MAX_CORES var active_core_idx: int = 0 # 当前激活 Core 的下标 var _raw_decks: Array[SpellDeck] = [] # 每个 Core 对应的原始 SpellDeck(compile_all_wands 的输入) var compiled_decks: Array[CompiledDeck] = [] # 每个 Core 的预编译产物(compile_all_wands 的输出) # ── 消耗品临时持有(当回合商店购买的消耗品,在 WAVE 开始前使用)───── var _pending_consumables: Array[String] = [] # consumable_id 列表 # ── 被动列表 ────────────────────────────────────────────────── var passives: Array[String] = [] # 被动 ID 列表(passive_id: String,对应 .tres 文件名) # ── 位置(View 层只读) ──────────────────────────────────────── var _position: Vector2 = Vector2.ZERO # 由 CharacterBody2D 每帧同步;其他系统通过 get_position() 读取 func get_position() -> Vector2: return _position ``` #### PlayerStats 合并数据类 ```gdscript # player_stats.gd - 所有被动合并后的最终属性快照(MODIFIER 节点的基准值来源) class_name PlayerStats extends RefCounted var damage_mult: float = 1.0 # 全局伤害乘算(累乘所有 damage% 被动) var attack_speed_mult: float = 1.0 # 攻速(影响 cast_delay 分母) var move_speed: float = 200.0 # 像素/秒 var hp_max_bonus: float = 0.0 # HP 上限加成 var hp_regen_bonus: float = 0.0 # 回血加成 var cpu_limit: int = 5 # SpellEvaluator MAX_OPS 的基础乘数 var pickup_radius: float = 60.0 # 拾取半径(DropManager 用) var crit_chance_bonus: float = 0.0 # 全局暴击率追加(叠加到 CastStats.crit_chance) func recalculate(passives: Array[String]) -> void: # 重置为默认值后依次应用每个被动的 stats_patch _reset_defaults() for pid in passives: var def: PassiveDef = PassiveRegistry.get(pid) if def: def.apply_to(self) ``` #### 核心接口 ```gdscript # ── 经济接口 ────────────────────────────────────────────────── func spend_gold(amount: int) -> bool: if gold < amount: return false gold -= amount return true func add_gold(amount: int) -> void: gold += amount func add_xp(amount: int) -> void: xp += amount _check_level_up() # 达到阈值则 level++,触发 WAVE_RESULT 升级选择流程 func _check_level_up() -> void: var threshold: int = int(10.0 * pow(1.4, level - 1)) while xp >= threshold and level < 21: # level 21 以上 Endless 不再升级 xp -= threshold level += 1 threshold = int(10.0 * pow(1.4, level - 1)) # ── 生命值接口 ──────────────────────────────────────────────── func take_damage(amount: float) -> void: hp = max(0.0, hp - amount) EventBus.emit(EventID.PLAYER_DAMAGED, {"amount": amount, "source_id": -1}) if hp <= 0.0: EventBus.emit(EventID.PLAYER_DIED, {}) func heal(amount: float) -> void: hp = min(hp_max, hp + amount) # ── Core / Deck 管理 ────────────────────────────────────────── func add_core(core: CoreDefinition, slot: int = -1) -> void: if slot < 0: slot = cores.size() slot = clamp(slot, 0, MAX_CORES - 1) if slot < cores.size(): cores[slot] = core else: cores.append(core) _raw_decks.append(SpellDeck.new()) # 同步扩容,保持 cores 与 _raw_decks 等长 compiled_decks.resize(cores.size()) func replace_core(slot: int, core_id: String) -> void: # ShopManager 购买 CORE 类商品时调用;以新 Core 替换指定槽,清空该槽的原始 Deck if slot < 0 or slot >= MAX_CORES: return var core: CoreDefinition = CoreRegistry.get(core_id) if core == null: return if slot < cores.size(): cores[slot] = core _raw_decks[slot] = SpellDeck.new() # 换 Core 后重置对应 Deck else: add_core(core, slot) compiled_decks.resize(cores.size()) func add_spell_to_inventory(spell_id: String, target_core_idx: int = -1) -> void: var idx := target_core_idx if target_core_idx >= 0 else active_core_idx if idx >= cores.size(): return _raw_decks[idx].push(SpellRegistry.get(spell_id)) func add_passive(passive_id: String) -> void: passives.append(passive_id) stats.recalculate(passives) # 被动变更后立即重算 PlayerStats func use_consumable(consumable_id: String) -> void: # ShopManager 购买 CONSUMABLE 类商品时调用。 # 消耗品效果立即生效(不延迟到 WAVE);若需要战斗中生效,加入 _pending_consumables 并在 WAVE_INTRO 处理。 var def: ConsumableDef = ConsumableRegistry.get(consumable_id) if def == null: return def.apply(self) # ConsumableDef.apply(player: PlayerManager) 直接修改玩家状态 # ── 法杖预编译入口(GameCycleManager 在 SHOP→WAVE_INTRO 时调用)──── func compile_all_wands() -> void: compiled_decks.resize(cores.size()) for i in cores.size(): compiled_decks[i] = SpellEvaluator.compile_wand(cores[i], _raw_decks[i]) func get_compiled_deck(core_idx: int) -> CompiledDeck: if core_idx < 0 or core_idx >= compiled_decks.size(): return null return compiled_decks[core_idx] func get_raw_deck(core_idx: int) -> SpellDeck: if core_idx < 0 or core_idx >= _raw_decks.size(): return null return _raw_decks[core_idx] func reset() -> void: hp = hp_max; gold = 0; xp = 0; level = 1 cores.clear(); _raw_decks.clear(); compiled_decks.clear(); passives.clear() _pending_consumables.clear() stats.recalculate([]) _position = Vector2.ZERO ``` > **compile_all_wands() 调用时机**:GameCycleManager 的 `SHOP → WAVE_INTRO` 转换回调中调用 `PlayerManager.compile_all_wands()`,而非直接调用 `SpellEvaluator`。这样 PlayerManager 保持对三个 Core 的迭代责任,SpellEvaluator 只负责单个 `compile_wand()` 逻辑。 --- ### 5.7 DropManager 框架设计 `DropManager` 订阅 `EventID.ENEMY_KILLED`,负责在敌人位置生成掉落物并处理玩家拾取。 #### 掉落表配置格式 ```json // res://resources/drops/enemy_drops.json { "slime_basic": { "xp": { "min": 2, "max": 4 }, "gold": { "min": 0, "max": 2, "chance": 0.3 }, "spell_drop": { "chance": 0.02, "rarity_max": 1 } }, "bat_fast": { "xp": { "min": 3, "max": 5 }, "gold": { "min": 1, "max": 3, "chance": 0.4 }, "spell_drop": { "chance": 0.03, "rarity_max": 2 } }, "elite": { "xp": { "min": 15, "max": 25 }, "gold": { "min": 8, "max": 15, "chance": 1.0 }, "spell_drop": { "chance": 0.20, "rarity_max": 3 } }, "_default": { "xp": { "min": 2, "max": 3 }, "gold": { "min": 0, "max": 1, "chance": 0.2 }, "spell_drop": { "chance": 0.01, "rarity_max": 1 } } } ``` #### DropManager 核心逻辑 ```gdscript # drop_manager.gd (Autoload: DropManager) # 掉落物在世界空间以 Dictionary 形式存储(无 Node 开销,轻量化) # { "type": "xp"|"gold"|"spell", "value": Variant, "position": Vector2, "lifetime": float } var _drops: Array[Dictionary] = [] var _drop_table: Dictionary = {} # 启动时从 JSON 加载 func _ready() -> void: var raw: String = FileAccess.open("res://resources/drops/enemy_drops.json", FileAccess.READ).get_as_text() _drop_table = JSON.parse_string(raw) EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) func _on_enemy_killed(payload: Dictionary) -> void: var enemy_id: int = payload.get("enemy_id", -1) var pos: Vector2 = EnemyManager.get_last_position(enemy_id) # 死亡时缓存的位置 var enemy_type: String = EnemyManager.get_type(enemy_id) var table: Dictionary = _drop_table.get(enemy_type, _drop_table.get("_default", {})) _spawn_drops(pos, table) func _spawn_drops(pos: Vector2, table: Dictionary) -> void: # XP(必定掉落) var xp_cfg: Dictionary = table.get("xp", {}) var xp_val: int = randi_range(xp_cfg.get("min", 1), xp_cfg.get("max", 2)) _drops.append({"type": "xp", "value": xp_val, "position": pos, "lifetime": 8.0}) # 金币(概率) var gold_cfg: Dictionary = table.get("gold", {}) if randf() < gold_cfg.get("chance", 0.0): var g: int = randi_range(gold_cfg.get("min", 1), gold_cfg.get("max", 1)) _drops.append({"type": "gold", "value": g, "position": pos, "lifetime": 10.0}) # 法术卡掉落(低概率) var spell_cfg: Dictionary = table.get("spell_drop", {}) if randf() < spell_cfg.get("chance", 0.0): var max_rarity: int = spell_cfg.get("rarity_max", 1) var spell_id: String = SpellRegistry.random_by_rarity(max_rarity) if spell_id != "": _drops.append({"type": "spell", "value": spell_id, "position": pos, "lifetime": 15.0}) func _physics_process(delta: float) -> void: if GameCycleManager.get_state() != GameCycleManager.GameState.WAVE: return var player_pos := PlayerManager.get_position() var pickup_r := PlayerManager.stats.pickup_radius var i := 0 while i < _drops.size(): var drop := _drops[i] drop["lifetime"] -= delta # 拾取检测:圆形距离 if (player_pos - drop["position"]).length_squared() <= pickup_r * pickup_r: _collect(drop) _drops.remove_at(i) elif drop["lifetime"] <= 0.0: _drops.remove_at(i) # 超时消失(swap-and-pop 可改,当前掉落数量极少,remove_at 可接受) else: i += 1 func _collect(drop: Dictionary) -> void: match drop["type"]: "xp": PlayerManager.add_xp(drop["value"]) "gold": PlayerManager.add_gold(drop["value"]) "spell": ShopManager.offer_free_spell(drop["value"]) # 弹出"拾取法术"UI提示 ``` --- ### 5.8 UpgradeSystem 框架设计 每波结算后,玩家从随机 3 张升级词条中选择 1 张。`UpgradeSystem` 管理词条池构建、展示、应用。 #### 升级词条资源格式(UpgradeDef .tres) ```gdscript # upgrade_def.gd - Resource 子类 class_name UpgradeDef extends Resource var upgrade_id: String = "" # 全局唯一,格式:category_effect(如 "offense_damage_plus") var display_name: String = "" # tr() KEY var description: String = "" # tr() KEY,支持 {value} 占位符 var icon_key: String = "" # UIAtlas 图标键 var rarity: int = 1 # 1=Common, 2=Uncommon, 3=Rare, 4=Legendary var max_stack: int = 1 # 同一词条最多叠取次数(1=不可重复,99=无限) var value: float = 0.0 # 效果数值(供 description {value} 和 apply() 使用) # 效果函数:直接修改 PlayerStats 或 PlayerManager 状态 func apply(player: PlayerManager) -> void: pass # 子类重写,例:player.stats.damage_mult *= 1.1 ``` #### UpgradeSystem 核心逻辑 ```gdscript # upgrade_system.gd (Autoload: UpgradeSystem) const CHOICES_COUNT: int = 3 # 每次展示 3 张词条 var _upgrade_pool: Array[UpgradeDef] = [] # 启动时从 res://resources/upgrades/ 扫描加载 var _taken_counts: Dictionary = {} # { upgrade_id: int } 本次 Run 已取次数(reset 时清零) func _ready() -> void: _scan_upgrades() func _scan_upgrades() -> void: _upgrade_pool.clear() var dir := DirAccess.open("res://resources/upgrades/") if dir: dir.list_dir_begin() var fname := dir.get_next() while fname != "": if fname.ends_with(".tres"): var def: UpgradeDef = load("res://resources/upgrades/" + fname) if def and def.upgrade_id != "": _upgrade_pool.append(def) fname = dir.get_next() func build_choices(wave_num: int, rng: RandomNumberGenerator) -> Array[UpgradeDef]: var weights := _rarity_weights(wave_num) var available := _pool_filtered() # 过滤已达 max_stack 的词条 var result: Array[UpgradeDef] = [] var seen_ids: Array[String] = [] for _i in CHOICES_COUNT: var pick := _weighted_pick(available, weights, rng, seen_ids) if pick: result.append(pick) seen_ids.append(pick.upgrade_id) return result func apply_choice(upgrade: UpgradeDef) -> void: upgrade.apply(PlayerManager) _taken_counts[upgrade.upgrade_id] = _taken_counts.get(upgrade.upgrade_id, 0) + 1 SpellEvaluator.update_max_ops(PlayerManager.stats.cpu_limit) func _pool_filtered() -> Array[UpgradeDef]: # 过滤已达 max_stack 的词条(不再可选) var result: Array[UpgradeDef] = [] for def in _upgrade_pool: if _taken_counts.get(def.upgrade_id, 0) < def.max_stack: result.append(def) return result func _weighted_pick(pool: Array[UpgradeDef], weights: Array[float], rng: RandomNumberGenerator, exclude: Array[String]) -> UpgradeDef: # 按稀有度权重从 pool 中加权随机选一条词条(排除 exclude 中的 ID) var total := 0.0 for def in pool: if def.upgrade_id in exclude: continue total += weights[clamp(def.rarity - 1, 0, 3)] if total <= 0.0: return null var roll := rng.randf() * total for def in pool: if def.upgrade_id in exclude: continue roll -= weights[clamp(def.rarity - 1, 0, 3)] if roll <= 0.0: return def return pool[-1] if not pool.is_empty() else null func _rarity_weights(wave_num: int) -> Array[float]: var common_w := max(5.0, 60.0 - wave_num * 2.5) var uncommon_w := min(50.0, 30.0 + wave_num * 1.5) var rare_w := min(30.0, max(5.0, wave_num * 1.2 - 5.0)) var legendary_w := min(10.0, max(0.0, wave_num * 0.5 - 8.0)) return [common_w, uncommon_w, rare_w, legendary_w] func reset() -> void: _taken_counts.clear() ``` > **GameCycleManager 集成**:`WAVE_RESULT` 状态的 `_on_enter()` 中调用 `UpgradeSystem.build_choices(wave_num, _rng)` 获取词条列表,UIManager 展示 3 张升级卡;玩家选择后调用 `UpgradeSystem.apply_choice(selected)` → 直接返回 `GameCycleManager.transition_to(SHOP)`。 --- ### 5.9 BossManager 架构设计 Boss 是具有多阶段 HP 和特殊行为的特殊敌人。`BossManager` 是 `EnemyManager` 的协调层,不持有独立的 SoA 数据,而是通过 EnemyManager 的接口操作 Boss 实体并监听阶段切换。 #### BossManager 与 EnemyManager 的关系 | 职责 | 归属 | 理由 | | :--- | :--- | :--- | | Boss 物理位置 / Boid 运动 | `EnemyManager` SoA | Boss 也是"敌人",共享同一物理系统 | | Boss HP 存储 | `EnemyManager._enemy_data` | EnemyManager 统一管理存活/死亡状态 | | 阶段切换逻辑 / 特殊能力触发 | `BossManager` | 阶段是 Boss 专有行为,不属于通用敌人 | | Boss 碰撞体(多区域) | `BossManager` 管理多个 Area2D / 自定义 AABB | Boss 半径可达 200px,超过 SpatialGrid 标准弹体阈值,走 `EnemyManager.query_aabb(boss_aabb)` | #### BossManager 核心设计 ```gdscript # boss_manager.gd (Autoload: BossManager) # Boss 阶段定义(每个 Boss 的阶段配置在其对应 JSON 中定义) # boss_design.md §6 详细描述各 Boss 行为,BossManager 仅处理通用框架 var _boss_entity_id: int = -1 # 当前 Boss 在 EnemyManager 中的 entity_id(-1 = 无 Boss) var _current_phase: int = 0 var _phase_thresholds: Array[float] = [] # HP 百分比阈值(降序),如 [1.0, 0.6, 0.3] var _is_active: bool = false var _boss_size_cache: Dictionary = {} # spawn_boss 时缓存的 Boss 配置(含 collision_radius) # WaveManager 在 is_boss_wave=true 时调用 func spawn_boss(boss_id: String, position: Vector2) -> void: _boss_entity_id = EnemyManager.spawn(boss_id, position) var cfg: Dictionary = _load_boss_config(boss_id) _boss_size_cache = cfg _phase_thresholds = cfg.get("phase_hp_thresholds", [1.0, 0.6, 0.3]) _current_phase = 0 _is_active = true EventBus.emit(EventID.BOSS_PHASE_CHANGED, { "boss_id": boss_id, "phase": 0, "bgm_layer": "boss" }) func _physics_process(_delta: float) -> void: if not _is_active or _boss_entity_id < 0: return var hp_pct: float = EnemyManager.get_hp_percent(_boss_entity_id) # 检测阶段切换(阈值降序,phase 0→1→2) var next_phase := _current_phase for i in range(_current_phase + 1, _phase_thresholds.size()): if hp_pct <= _phase_thresholds[i]: next_phase = i if next_phase != _current_phase: _current_phase = next_phase _on_phase_changed(_current_phase) func _on_phase_changed(phase: int) -> void: EventBus.emit(EventID.BOSS_PHASE_CHANGED, { "boss_id": EnemyManager.get_type(_boss_entity_id), "phase": phase, "bgm_layer": "boss_phase_%d" % phase }) # 触发特殊技能(如护盾、新 spawn pattern、移速变化) # 具体行为由 boss_design.md §6 的各 Boss 脚本重写 _on_phase_enter(phase) func _on_enemy_killed(payload: Dictionary) -> void: if payload.get("enemy_id", -1) != _boss_entity_id: return _is_active = false _boss_entity_id = -1 EventBus.emit(EventID.BOSS_KILLED, { "boss_id": EnemyManager.get_type(payload["enemy_id"]), "wave_num": WaveManager.wave_num }) # WaveManager 订阅 BOSS_KILLED 后发出 WAVE_COMPLETE(kill_boss clear_condition) func reset() -> void: _boss_entity_id = -1; _current_phase = 0; _is_active = false _phase_thresholds.clear() # ── 只读查询接口(BulletManager 碰撞检测 + UIManager Boss 血条)──── func is_active() -> bool: return _is_active func get_boss_entity_id() -> int: return _boss_entity_id func get_collision_rect() -> Rect2: # Boss 碰撞 AABB(BulletManager 使用,用于超大碰撞体绕过 SpatialGrid) if not _is_active: return Rect2() var pos := EnemyManager.get_last_position(_boss_entity_id) # Boss 仍存活时 get_last_position 返回当前位置 var cfg: Dictionary = _boss_size_cache # spawn_boss 时从 _load_boss_config 读取 "collision_radius" var r: float = cfg.get("collision_radius", 80.0) return Rect2(pos.x - r, pos.y - r, r * 2, r * 2) func _load_boss_config(boss_id: String) -> Dictionary: # 从 res://resources/bosses/{boss_id}.json 加载 Boss 配置(同步加载,spawn 时调用一次) var path := "res://resources/bosses/%s.json" % boss_id if not FileAccess.file_exists(path): push_warning("BossManager: boss config not found: %s" % path) return {} var raw: String = FileAccess.open(path, FileAccess.READ).get_as_text() return JSON.parse_string(raw) # 配置字段(规范): # { "phase_hp_thresholds": [1.0, 0.6, 0.3], # "collision_radius": 80.0, # "spawn_pattern": "center", # "phase_actions": { "1": "spawn_minions", "2": "enrage" } } ``` #### Boss 碰撞策略 Boss 半径通常 > 64px(超过 `LARGE_PROJECTILE_THRESHOLD`),使用与"超大弹体豁免"对称的策略: ```gdscript # BulletManager 子弹命中检测(_physics_process 中,SpatialGrid 路径) # 超大碰撞体(Boss)同样绕过 SpatialGrid,改用 Boss 自定义 AABB 全量测试 if BossManager.is_active(): var boss_aabb: Rect2 = BossManager.get_collision_rect() for i in _active_count: var bx := _data[i * BULLET_STRIDE]; var by := _data[i * BULLET_STRIDE + 1] if boss_aabb.has_point(Vector2(bx, by)): _on_bullet_hit(i, BossManager.get_boss_entity_id()) ``` --- ### 5.10 UIManager 架构设计 `UIManager` 是 UI 系统的唯一协调者,订阅 `GameCycleManager` 的状态变化,驱动各 UI 场景的显示/隐藏。业务逻辑(金币计算、法术编译)由各业务 Manager 负责;UIManager 只负责**路由**(什么状态显示什么界面)和 **View 刷新**(将数据渲染到 Control 节点)。 #### 职责边界 | 职责 | 归属 | 理由 | | :--- | :--- | :--- | | 游戏状态判断(什么时候进入商店)| `GameCycleManager` | 状态机唯一持有状态 | | UI 场景切换(show/hide CanvasLayer 子场景)| `UIManager` | UI 协调者唯一职责 | | 数据计算(价格/稀有度/词条效果)| `ShopManager` / `UpgradeSystem` | 与 UI 无关,可独立测试 | | Control 节点刷新(Label.text / ProgressBar.value)| UIManager 各子函数 | 表现层 | | 玩家操作确认(点击购买/选择词条)| UIManager → 调用对应 Manager 接口 | 控制流 | #### UIManager 数据模型 ```gdscript # ui_manager.gd (Autoload: UIManager) # ── 各 UI 场景实例(懒加载,首次显示时实例化)──────────────────── var _shop_ui: Control = null # res://scenes/ui/ShopUI.tscn var _inventory_ui: Control = null # res://scenes/ui/InventoryUI.tscn var _upgrade_ui: Control = null # res://scenes/ui/UpgradeChoiceUI.tscn var _hud: Control = null # res://scenes/ui/HUD.tscn(BattleScene 挂载时创建) var _main_menu: Control = null # res://scenes/ui/MainMenuUI.tscn var _game_over: Control = null # res://scenes/ui/GameOverUI.tscn var _pause_menu: Control = null # res://scenes/ui/PauseMenuUI.tscn # ── RNG(用于 UpgradeSystem.build_choices 的确定性种子)────────── var _rng: RandomNumberGenerator = RandomNumberGenerator.new() # _rng.seed 在每次进入 WAVE_RESULT 状态时重置(保持波次间确定性) # ── 伤害数字池 ────────────────────────────────────────────────── const MAX_DAMAGE_NUMBERS: int = 30 var _dmg_num_pool: Array[Label] = [] # 飘字 Label 池(重复利用,减少节点创建) var _dmg_num_active: int = 0 func _ready() -> void: EventBus.subscribe(EventID.GAME_STATE_CHANGED, _on_state_changed) EventBus.subscribe(EventID.PLAYER_DAMAGED, _on_player_damaged) EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed) EventBus.subscribe(EventID.SPELL_DROP_PICKUP, _on_spell_drop_pickup) # 预分配伤害数字池 for i in MAX_DAMAGE_NUMBERS: var lbl := Label.new() lbl.visible = false add_child(lbl) _dmg_num_pool.append(lbl) ``` #### 状态→UI 路由表 `GameCycleManager` 在每次 `transition_to(next)` 末尾发送 `EventID.GAME_STATE_CHANGED`(payload: `{prev, next}`),UIManager 的 `_on_state_changed` 根据下表切换: | GameState | 显示的 UI | 隐藏的 UI | 附加操作 | | :--- | :--- | :--- | :--- | | `MAIN_MENU` | MainMenuUI | 其他全部 | 停止 HUD | | `LOADING` | 无(过渡遮罩由 GameCycleManager 驱动)| 其他 | — | | `SHOP` | ShopUI | HUD, InventoryUI | `_refresh_shop_ui()` | | `WAVE_INTRO` | HUD(波次预告)| ShopUI, UpgradeUI | `_show_wave_intro_banner()` | | `WAVE` | HUD | 其他 | 激活伤害数字池 | | `WAVE_RESULT` | UpgradeChoiceUI(3 张词条)| HUD | `_refresh_upgrade_choices()` | | `GAME_OVER` | GameOverUI | 其他 | 显示分数/波次 | | `GAME_CLEARED` | GameOverUI(通关变体)| 其他 | 显示 Endless 入口 | | `PAUSE` | PauseMenuUI(叠加当前 UI)| — | `get_tree().paused = true` | ```gdscript func _on_state_changed(payload: Dictionary) -> void: var next: int = payload.get("next", -1) _hide_all_ui() match next: GameCycleManager.GameState.MAIN_MENU: _show(_main_menu, "res://scenes/ui/MainMenuUI.tscn") GameCycleManager.GameState.SHOP: _show(_shop_ui, "res://scenes/ui/ShopUI.tscn") _refresh_shop_ui() GameCycleManager.GameState.WAVE_INTRO: _show(_hud, "res://scenes/ui/HUD.tscn"); _show_wave_intro_banner() GameCycleManager.GameState.WAVE: _show(_hud, "res://scenes/ui/HUD.tscn") GameCycleManager.GameState.WAVE_RESULT: _show(_upgrade_ui, "res://scenes/ui/UpgradeChoiceUI.tscn") _refresh_upgrade_choices() GameCycleManager.GameState.GAME_OVER,\ GameCycleManager.GameState.GAME_CLEARED: _show(_game_over, "res://scenes/ui/GameOverUI.tscn") GameCycleManager.GameState.PAUSE: _show(_pause_menu, "res://scenes/ui/PauseMenuUI.tscn") get_tree().paused = true func _show(ref: Control, scene_path: String) -> Control: if ref == null: ref = load(scene_path).instantiate() add_child(ref) # UIManager Autoload 自身作为父节点(跨场景持久) ref.visible = true return ref func _hide_all_ui() -> void: for ui in [_shop_ui, _inventory_ui, _upgrade_ui, _hud, _main_menu, _game_over, _pause_menu]: if ui: ui.visible = false get_tree().paused = false # 确保 PAUSE 状态退出时解除暂停 ``` #### 伤害数字池接口 ```gdscript func show_damage_number(amount: float, position: Vector2, is_crit: bool = false) -> void: # 每帧最多弹出 10 个(帧节流由调用方 BulletManager 批处理) var lbl := _acquire_dmg_label() if lbl == null: return lbl.text = str(int(amount)) + ("!" if is_crit else "") lbl.modulate = Color.RED if is_crit else Color.WHITE lbl.global_position = position lbl.visible = true # 0.6s 飘字动画后归还池(使用 Tween,避免 Timer 节点堆叠) var tween := create_tween() tween.tween_property(lbl, "global_position", position + Vector2(0, -40), 0.6) tween.parallel().tween_property(lbl, "modulate:a", 0.0, 0.6) tween.tween_callback(func(): lbl.visible = false; _dmg_num_active -= 1) func _acquire_dmg_label() -> Label: if _dmg_num_active >= MAX_DAMAGE_NUMBERS: return null for lbl in _dmg_num_pool: if not lbl.visible: _dmg_num_active += 1 return lbl return null ``` #### ShopUI 刷新协议 ```gdscript func _refresh_shop_ui() -> void: # ShopManager.get_slots() 返回当前 SLOT_COUNT=6 个商品 Dictionary var slots: Array = ShopManager.get_slots() _shop_ui.refresh(slots, PlayerManager.gold, WaveManager.wave_num) # ShopUI.refresh() 是 Control 层函数,仅做 Label/Icon 更新,无业务逻辑 func _refresh_upgrade_choices() -> void: var choices: Array[UpgradeDef] = UpgradeSystem.build_choices(WaveManager.wave_num, _rng) _upgrade_ui.show_choices(choices) # UpgradeChoiceUI 展示 3 张卡 func _on_upgrade_selected(upgrade: UpgradeDef) -> void: # UpgradeChoiceUI 发送信号,UIManager 接收后调用业务层 UpgradeSystem.apply_choice(upgrade) GameCycleManager.transition_to(GameCycleManager.GameState.SHOP) func _on_spell_drop_pickup(payload: Dictionary) -> void: # DropManager 拾取法术卡后通过 EventBus 通知,UIManager 弹出提示 var spell_id: String = payload.get("spell_id", "") var auto_equip: bool = payload.get("auto_equip", false) if auto_equip: PlayerManager.add_spell_to_inventory(spell_id) else: # 弹出"装备到哪个Core?"选择框 _show_spell_pickup_dialog(spell_id) ``` --- ### 5.11 PassiveDef + PassiveRegistry 设计 被动词条是 Roguelite 构建深度的核心扩展机制。`PassiveRegistry` 与 `SpellRegistry` 采用对称设计,启动时从资源目录扫描加载。 #### PassiveDef Resource 格式 ```gdscript # passive_def.gd - Resource 子类 class_name PassiveDef extends Resource var passive_id: String = "" # 全局唯一,格式:category_stat(如 "offense_damage_mult") var display_name: String = "" # tr() KEY,UI 显示用 var description: String = "" # tr() KEY,支持 {value} 占位符(如 "+{value}% 伤害") var icon_key: String = "" # UIAtlas 图标键(ADR-C1:必须有对应形状标识) var rarity: int = 1 # 1=Common, 2=Uncommon, 3=Rare, 4=Legendary var max_stack: int = 1 # 同一被动最多叠取次数(1=不可重复购买,99=无限) var value: float = 0.0 # 效果数值(description {value} 占位符的填充值) # ── 效果:修改 PlayerStats 的规则 ─────────────────────────────── # 支持三种效果模式(enum 选一): enum StatPatchMode { ADD, MULTIPLY, OVERRIDE } var stat_field: String = "" # PlayerStats 中被修改的字段名(如 "damage_mult") var patch_mode: StatPatchMode = StatPatchMode.MULTIPLY var patch_value: float = 1.0 # ADD: +patch_value;MULTIPLY: *patch_value;OVERRIDE: =patch_value func apply_to(stats: PlayerStats) -> void: if stat_field == "": return var current: float = stats.get(stat_field) match patch_mode: StatPatchMode.ADD: stats.set(stat_field, current + patch_value) StatPatchMode.MULTIPLY: stats.set(stat_field, current * patch_value) StatPatchMode.OVERRIDE: stats.set(stat_field, patch_value) ``` > **设计约束**:`apply_to()` 只修改 `PlayerStats` 中的 float 字段,不直接修改 `PlayerManager` 的 HP/Gold/XP(防止被动产生经济副作用)。需要修改资源上限(如 `hp_max_bonus`)的被动,通过 `stats.hp_max_bonus` 间接影响,`PlayerManager` 在 `recalculate()` 后重算 `hp_max = 100.0 + stats.hp_max_bonus`。 #### PassiveRegistry Autoload ```gdscript # passive_registry.gd (Autoload: PassiveRegistry) var _registry: Dictionary = {} # { passive_id: PassiveDef } var _by_rarity: Array[Array] = [[], [], [], []] # 与 SpellRegistry 对称 func _ready() -> void: var dir := DirAccess.open("res://resources/passives/") if dir: dir.list_dir_begin() var fname := dir.get_next() while fname != "": if fname.ends_with(".tres"): var def: PassiveDef = load("res://resources/passives/" + fname) if def and def.passive_id != "": _registry[def.passive_id] = def _by_rarity[clamp(def.rarity - 1, 0, 3)].append(def.passive_id) fname = dir.get_next() func get(passive_id: String) -> PassiveDef: return _registry.get(passive_id, null) func has(passive_id: String) -> bool: return _registry.has(passive_id) func all() -> Array: return _registry.values() # Array[PassiveDef](ShopManager 构建权重池用) ``` #### 被动词条设计示例(资源文件) ```gdscript # res://resources/passives/offense_damage_mult_10pct.tres passive_id = "offense_damage_mult_10pct" display_name = "PASSIVE_DAMAGE_MULT_NAME" # tr() 键 description = "PASSIVE_DAMAGE_MULT_DESC" # tr() → "全局伤害 +{value}%" icon_key = "icon_sword_up" rarity = 1 # Common max_stack = 5 # 最多叠取 5 次(最终 damage_mult = 1.1^5 ≈ 1.61) value = 10.0 # 描述占位符:+10% stat_field = "damage_mult" patch_mode = StatPatchMode.MULTIPLY patch_value = 1.1 # 每次叠取:damage_mult *= 1.1 ``` --- ### 5.11.A SpellRegistry 接口定义 `SpellRegistry` 是纯只读 Autoload,启动时扫描 `res://resources/spells/` 并加载所有 `SpellNode .tres` 文件,之后提供 O(1) / O(稀有度桶) 查询。 ```gdscript # spell_registry.gd (Autoload: SpellRegistry) var _registry: Dictionary = {} # { spell_id: SpellNode } var _by_rarity: Array[Array] = [[], [], [], []] # index = rarity-1,每组为 Array[String](spell_id列表) func _ready() -> void: var dir := DirAccess.open("res://resources/spells/") if dir: dir.list_dir_begin() var fname := dir.get_next() while fname != "": if fname.ends_with(".tres"): var node: SpellNode = load("res://resources/spells/" + fname) if node and node.spell_id != "": _registry[node.spell_id] = node var ri := clamp(node.rarity - 1, 0, 3) _by_rarity[ri].append(node.spell_id) fname = dir.get_next() func get(spell_id: String) -> SpellNode: return _registry.get(spell_id, null) func has(spell_id: String) -> bool: return _registry.has(spell_id) func all() -> Array: return _registry.values() # Array[SpellNode](用于 ShopManager 构建权重池) func random_by_rarity(max_rarity: int, rng: RandomNumberGenerator = null) -> String: # DropManager 使用:从稀有度 1~max_rarity 的所有法术中随机选一个 ID # rng 为 null 时使用全局随机(掉落不需要确定性种子) var pool: Array[String] = [] for r in range(min(max_rarity, 4)): pool.append_array(_by_rarity[r]) if pool.is_empty(): return "" if rng: return pool[rng.randi() % pool.size()] return pool[randi() % pool.size()] ``` --- ### 5.14 ProfileManager / SaveSystem 框架设计 `ProfileManager` 是持久化层的统一入口,负责 **Run 运行时存档** 和 **全局设置/统计** 的读写。权威实现详见 `implementation_plan.md §2.5.E` 和 `ADR-A2`,本节为接口契约。 #### 职责边界 - **负责**:JSON 文件读写(A/B 双槽写、CRC 校验)、schema 版本迁移、Run 状态持久化、全局设置持久化 - **不负责**:游戏逻辑、排行榜 HMAC(EndlessRecordsManager 负责) ```gdscript # profile_manager.gd (Autoload: ProfileManager) # Run 存档:user://run_a.json(A槽)+ user://run_b.json(B槽)(权威来源:ADR-A2、certification_checklist.md ST-51) # 全局设置:user://save_data.json(音量/无障碍等,非 Run 数据) # schema_version 必须与 ADR-A2 中的 _migrate 链同步 const SCHEMA_VERSION: int = 1 # 每次 save 结构变更时递增 # ── 全局持久化(设置/统计,非 Run)──────────────────────────────── func get_float(key: String, default_val: float = 0.0) -> float: return _global.get(key, default_val) func set_float(key: String, value: float) -> void: _global[key] = value; _dirty = true func get_int(key: String, default_val: int = 0) -> int: return int(_global.get(key, default_val)) func set_int(key: String, value: int) -> void: _global[key] = value; _dirty = true # ── Run 状态存档(ADR-A2 §2 波次结束自动存档)──────────────────── func save_run(run_data: Dictionary) -> void: # A/B 双槽交替写入(ADR-A2 防断电损坏协议) run_data["schema_version"] = SCHEMA_VERSION var which := get_int("run_write_slot", 0) var path_a := "user://run_a.json"; var path_b := "user://run_b.json" _write_json(path_a if which == 0 else path_b, run_data) set_int("run_write_slot", 1 - which) func load_run() -> Dictionary: # 优先读 A 槽;A 槽损坏则降级读 B 槽;均损坏则返回 {}(GameCycleManager 展示损坏提示) var data := _read_json("user://run_a.json") if data.is_empty(): data = _read_json("user://run_b.json") if not data.is_empty(): data = _migrate(data) return data func has_run() -> bool: # GameCycleManager 启动时查询是否有未完成的 Run return not load_run().is_empty() func clear_run() -> void: # Run 结束(通关/死亡)后调用,删除 Run 存档(保留全局设置/统计) _write_json("user://run_a.json", {}) _write_json("user://run_b.json", {}) func get_run_seed() -> int: # ShopManager 确定性种子(新 Run 开始时写入,存档后恢复) return get_int("run_seed", 0) func get_run_count() -> int: return get_int("run_count", 0) func flush() -> void: # 保存全局设置(音量、无障碍等)→ user://save_data.json(与 Run 存档 run_a/b.json 分离) # 由 SettingsManager.save_audio_setting 触发或 App 退出时调用 if _dirty: _write_json("user://save_data.json", _global); _dirty = false func _migrate(data: Dictionary) -> Dictionary: # ADR-A2 §3:链式迁移,v0→v1→v2... var v: int = data.get("schema_version", 0) if v < 1: data = _migrate_v0_to_v1(data) return data ``` --- ### 5.15 SettingsManager 框架设计 `SettingsManager` 是游戏设置的运行时接口,持久化委托给 `ProfileManager.flush()`。 ```gdscript # settings_manager.gd (Autoload: SettingsManager) # 依赖:ProfileManager(持久化)、AudioBusID(音量),EventBus(SETTINGS_CHANGED 通知) func _ready() -> void: load_audio_settings() # 恢复持久化音量 _apply_accessibility_settings() # 高对比度、字体缩放、减少闪烁 # ── 音量(ADR-A3.4)──────────────────────────────────────────────── # 见 ADR-A3.4 伪代码(load_audio_settings / save_audio_setting) # ── 无障碍功能(ADR-C1)─────────────────────────────────────────── func set_colorblind_mode(mode: String) -> void: # mode: "normal" / "protanopia" / "deuteranopia"(见 ADR-C1.1) ProfileManager.set_int("colorblind_mode", ["normal","protanopia","deuteranopia"].find(mode)) EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "colorblind_mode" }) func set_font_scale(scale: float) -> void: ProfileManager.set_float("font_scale", clampf(scale, 0.8, 1.5)) _apply_font_scale() EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" }) func set_reduce_flash(enabled: bool) -> void: ProfileManager.set_int("reduce_flash", int(enabled)) EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "reduce_flash" }) func set_high_contrast(enabled: bool) -> void: ProfileManager.set_int("high_contrast", int(enabled)) _apply_high_contrast() EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "high_contrast" }) func apply_settings() -> void: # UIManager 在 SettingsUI 打开时调用,刷新所有设置为持久化值 load_audio_settings() _apply_accessibility_settings() func get_volume(save_key: String) -> float: # UIManager 音量滑块初始化时调用 return ProfileManager.get_float(save_key, 0.0) func _apply_accessibility_settings() -> void: _apply_font_scale() _apply_high_contrast() func _apply_font_scale() -> void: var scale: float = ProfileManager.get_float("font_scale", 1.0) # 遍历所有 Label / RichTextLabel 节点并设置 theme_override_font_sizes pass # 见 ADR-C1.3 func _apply_high_contrast() -> void: pass # 切换 CanvasItem 材质 Shader uniform;见 ADR-C1.4 ``` --- ### 5.11.B CoreRegistry + ConsumableRegistry + ConsumableDef `CoreRegistry` 和 `ConsumableRegistry` 与 `SpellRegistry` / `PassiveRegistry` 采用对称模式,补全 `PlayerManager.replace_core()` 和 `use_consumable()` 的依赖链。 ```gdscript # ── core_registry.gd (Autoload: CoreRegistry) ────────────────────────────── # 扫描 res://resources/cores/ 目录,加载所有 CoreDefinition .tres 文件 var _registry: Dictionary = {} # { core_id: CoreDefinition } func _ready() -> void: var dir := DirAccess.open("res://resources/cores/") if dir: dir.list_dir_begin() var fname := dir.get_next() while fname != "": if fname.ends_with(".tres"): var def: CoreDefinition = load("res://resources/cores/" + fname) if def and def.core_id != "": _registry[def.core_id] = def fname = dir.get_next() func get(core_id: String) -> CoreDefinition: return _registry.get(core_id, null) func has(core_id: String) -> bool: return _registry.has(core_id) func all() -> Array: return _registry.values() # ── consumable_def.gd ─────────────────────────────────────────────────────── class_name ConsumableDef extends Resource var consumable_id: String = "" # 全局唯一,格式:consumable_effect(如 "hp_potion_50") var display_name: String = "" # tr() KEY var description: String = "" # tr() KEY,支持 {value} 占位符 var icon_key: String = "" # UIAtlas 图标键 var price: int = 8 # 商店默认价格(金币) var value: float = 0.0 # 效果数值 func apply(player: PlayerManager) -> void: pass # 子类重写,或通过 effect_type + value 驱动(参考 PassiveDef.apply_to 模式) # 示例子类:HP 药水 → player.heal(value);速度水 → player.stats.move_speed += value(本回合) # ── consumable_registry.gd (Autoload: ConsumableRegistry) ────────────────── var _registry: Dictionary = {} # { consumable_id: ConsumableDef } func _ready() -> void: var dir := DirAccess.open("res://resources/consumables/") if dir: dir.list_dir_begin() var fname := dir.get_next() while fname != "": if fname.ends_with(".tres"): var def: ConsumableDef = load("res://resources/consumables/" + fname) if def and def.consumable_id != "": _registry[def.consumable_id] = def fname = dir.get_next() func get(consumable_id: String) -> ConsumableDef: return _registry.get(consumable_id, null) func all() -> Array: return _registry.values() ``` > **ShopManager 商品池补充**:`ShopItemType.CONSUMABLE` 与 `ShopItemType.CORE` 商品由特定波次固定投放(不进入随机权重池),即 Boss 波前固定刷出 Core 槽位、每波随机出现 1 消耗品。`_build_weighted_pool` 仅负责 SPELL + PASSIVE;CONSUMABLE/CORE 槽位由 `_fill_fixed_slots()` 在 `_fill_slots()` 中单独填充。 --- ### 5.12 MinionManager 架构设计 `MinionManager` 管理玩家召唤物(友方单位)的生命周期,与 `EnemyManager` 共享 `SpatialGrid` 但 faction 位不同(`+6 高16位 = 1`)。召唤物数量上限较小(通常 ≤ 16),不需要 C# 热路径。 #### 职责边界 - **不处理**:战斗逻辑(委托 SpellEvaluator / StatusManager)、敌人碰撞(委托 BulletManager) - **负责**:召唤物生命周期(spawn/expire/recall)、最大上限控制、事件通知(MINION_SPAWNED/MINION_EXPIRED) ```gdscript # minion_manager.gd (Autoload: MinionManager) const MAX_MINIONS: int = 16 # 全局最大召唤物数量 const DEFAULT_LIFETIME: float = 30.0 # 默认存活秒数(可被 MinionDef 覆盖) var _minions: Array[Dictionary] = [] # 活跃召唤物列表,每项:{ id, owner_id, def, lifetime_rem, node } var _next_id: int = 0 func spawn_minion(def: MinionDef, owner_id: int, pos: Vector2) -> int: # 返回 minion_id;若已达 MAX_MINIONS,先 expire 最旧的(先进先出策略) # 发出 EventID.MINION_SPAWNED if _minions.size() >= MAX_MINIONS: _expire(_minions[0].id) var id := _next_id _next_id = (_next_id + 1) % 100000 var entry := { "id": id, "owner_id": owner_id, "def": def, "lifetime_rem": def.lifetime if def.lifetime > 0 else DEFAULT_LIFETIME, "pos": pos } _minions.append(entry) EventBus.emit(EventID.MINION_SPAWNED, { "minion_id": id, "owner_id": owner_id, "current_count": _minions.size() }) return id func recall_all(owner_id: int) -> void: # 立即移除 owner_id 的所有召唤物(玩家死亡 / Run 结束时调用) var to_expire := _minions.filter(func(m): return m.owner_id == owner_id) for m in to_expire: _expire(m.id) func get_count(owner_id: int = -1) -> int: # owner_id = -1 时返回所有召唤物总数 if owner_id < 0: return _minions.size() return _minions.filter(func(m): return m.owner_id == owner_id).size() func fill_pos_snapshot(out: PackedFloat32Array, out_ids: PackedInt32Array) -> int: # BulletManager homing 查询友方召唤物(阵营标志:faction=1) # 返回写入数量;out 格式: [x0, y0, x1, y1, ...] var n := 0 for m in _minions: out[n * 2] = m.pos.x out[n * 2 + 1] = m.pos.y out_ids[n] = m.id n += 1 return n func _physics_process(delta: float) -> void: var i := 0 while i < _minions.size(): _minions[i].lifetime_rem -= delta if _minions[i].lifetime_rem <= 0.0: _expire(_minions[i].id) else: i += 1 func _expire(minion_id: int) -> void: for i in _minions.size(): if _minions[i].id == minion_id: var owner := _minions[i].owner_id _minions.remove_at(i) EventBus.emit(EventID.MINION_EXPIRED, { "minion_id": minion_id, "owner_id": owner, "current_count": _minions.size() }) return func reset() -> void: # GameCycleManager._reset_all_managers() 调用 _minions.clear(); _next_id = 0 ``` > **MinionDef Resource**:`res://resources/minions/{id}.tres`,字段:`minion_id: String`、`lifetime: float`(≤0 使用默认值)、`move_speed: float`、`damage_mult: float`、`spell_deck_id: String`(召唤物使用的法术)。 --- ### 5.13 VFXManager 框架设计 `VFXManager` 管理战斗特效的播放与回收,基于对象池避免运行时节点创建。权威实现参见 `implementation_plan.md §2.5.D`,本节为 §5 级接口契约(供其他 Manager 调用)。 #### 职责边界 - **不处理**:音效(AudioManager 负责)、伤害数字(UIManager 负责) - **负责**:粒子/精灵特效生命周期(play/stop/reset)、命中火花(spawn_hit_vfx)、区域特效(play_zone) ```gdscript # vfx_manager.gd (Autoload: VFXManager) # C# 热路径:无(特效数量有限,GDScript 对象池足够) const MAX_ACTIVE_VFX: int = 64 # 同时活跃特效上限(超出时丢弃最旧) # 订阅事件(_ready() 注册): # EventID.BULLET_HIT → spawn_hit_vfx("hit_spark", world_pos) # EventID.ENEMY_KILLED → play("death_burst", enemy_pos) # EventID.STATUS_APPLIED → play("status_" + type_name, target_pos) # EventID.SPELL_CAST_BEGIN→ play("cast_flash", caster_pos) # EventID.BOSS_PHASE_CHANGED → play("boss_phase_" + phase, boss_pos) func play(effect_id: String, world_pos: Vector2, scale: float = 1.0) -> int: # 从池中取出一个特效节点并播放;返回 vfx_handle(用于 stop) # effect_id 对应 res://scenes/vfx/{effect_id}.tscn pass # 具体实现见 implementation_plan.md §2.5.D func stop(vfx_handle: int) -> void: # 立即停止并回收特效节点(用于持续特效提前结束,如召唤物消失) pass func play_zone(zone_type_id: int, world_pos: Vector2, radius: float, duration: float) -> int: # 为 ZoneManager 显示区域特效(持续型,duration 秒后自动停止) # zone_type_id 对应 ZoneManager 的 zone_type_id,映射到 "zone_{type}" 特效 return play("zone_%d" % zone_type_id, world_pos, radius / 64.0) func spawn_hit_vfx(hit_type: String, world_pos: Vector2) -> void: # BulletManager BULLET_HIT 事件处理;hit_type:spark / elemental_X / critical play("hit_" + hit_type, world_pos, 1.0) func reset() -> void: # GameCycleManager._reset_all_managers() 调用,停止并归还所有活跃特效节点 pass # 具体实现见 implementation_plan.md §2.5.D(遍历 _active_pool,调用 stop/归还) ``` > **与 implementation_plan.md 的关系**:`impl §2.5.D` 为完整实现(含池管理细节),本节 §5.13 为接口契约层(供其他 Manager 查阅可调用的 API)。两者互为补充,实现时以 impl §2.5.D 为基础,函数签名以本节为准。 --- ## 6. 技术栈选型总结 | 模块 | 方案 | 理由 | | :--- | :--- | :--- | | **引擎** | Godot 4.x | 开源免费,2D 性能强,内置物理/动画系统完善 | | **语言** | GDScript + C#(静态职责划分,见 §6.1) | 非主备关系:GDScript 负责快迭代域(UI / 事件 / 配置 / 游戏循环 / 法术预编译),C# 负责计算密集热路径(`BulletManager` / `EnemyManager` / `SpatialGrid` / `SpellEvaluator` 内层循环);热数据通过 `PackedFloat32Array.AsSpan()` 零拷贝共享;详见 §6.1 | | **ECS框架** | Custom Lite (Autoload Manager-based) | 利用 Godot Autoload 实现 Manager 单例,针对本项目定制,避免引入第三方 ECS 库 | | **物理** | Custom SpatialGrid(主力)+ Godot Area2D(低密度启动路径) | < 200 弹幕:Area2D 信号回调;200+ 弹幕:SpatialGrid dirty-list **主力碰撞**;详见 §4.3 | | **渲染批次** | MultiMeshInstance2D | 极大量同类子弹用 MultiMesh 渲染,最小化 Draw Call | | **UI** | Godot Control + 自定义虚拟列表 | 背包道具可能很多,需要虚拟列表优化 | | **配置** | JSON + GDScript 类型注解(或 .tres Resource) | JSON 灵活易热更;Resource 文件可享受 Godot 编辑器集成 | ### 6.1 语言职责分工 (Language Partition) GDScript 与 C# **不是主备(备用)关系**,而是按职责**静态划分**,从 S0 起并行建立: | 职责域 | 语言 | 核心原因 | | :--- | :--- | :--- | | 表现层(UI / VFX / 音频 / 动画) | **GDScript** | 节点操作频繁;Inspector 可视化调整;帧预算宽松(< 1ms) | | 游戏循环(WaveManager / ShopManager / CombatManager 状态机) | **GDScript** | 非高频热路径;快速迭代优先 | | 数据定义(CoreDefinition / SpellNode / StatusTypeDef) | **GDScript `.tres`** | Godot 编辑器原生 Inspector 支持;数值调整零编译 | | 配置与存档(ConfigMgr / SpellRegistry / ProfileManager) | **GDScript** | JSON / Resource 读取一次性,非热路径 | | 法术预编译(`compile_wand`) | **GDScript** | 仅在换牌时触发,非 `_physics_process`;Dictionary 操作 GDScript 更高效 | | 事件总线(EventBus / EventID) | **GDScript** | 保持单语言,避免跨语言信号绑定复杂性 | | **`BulletManager._physics_process`** | **C#** | 2000 颗/帧 SoA 积分;`Span` 零拷贝 + struct 零 GC;GDScript ≈8ms → C# ≈0.8ms | | **`EnemyManager._physics_process`** | **C#** | 1000 敌人 Boid 分离力;向量运算密集;GDScript ≈6ms → C# ≈0.6ms | | **`SpatialGrid`(重建 + 查询)** | **C#** | 每帧重建 + M×N `query_circle`;纯计算密集,受益于 JIT 内联优化 | | **`SpellEvaluator.execute_compiled`** | **C#** | 内层 while 循环(MAX_OPS_PER_CPU=40 × 高频施法);`switch` 跳表 JIT 优化 | | **`StatusManager._physics_process`** | **C#** | 200+ 状态实例逐 tick 计算;swap-and-pop 顺序内存访问 C# 受益更大 | | **`ZoneManager._physics_process`** | **C#** | Zone tick + SpatialGrid 批量查询组合 | #### 跨语言边界规则(ADR-L1) 每次 GDScript ↔ C# `Call()` / `Set()` 约有 1–5µs 开销,以下规则确保该开销不进入任何热路径: **规则 1:禁止在 C# 内层循环体内调用 GDScript 方法** `BulletManagerCs._PhysicsProcess` 的 `for` 循环体内不得出现 `GodotObject.Call()` / `.Set()`。 2000 次/帧 × 1–5µs = 2–10ms,直接耗尽帧预算。 **规则 2:热数据通过 `PackedFloat32Array.AsSpan()` 零拷贝共享** GDScript Autoload 持有 `_data: PackedFloat32Array`;C# 通过 `AsSpan()` 获取 `Span` 原生指针,**无内存复制**: ```csharp // BulletManagerCs.cs — _PhysicsProcess 热路径 public override void _PhysicsProcess(double delta) { float f = (float)delta; using var span = _data.AsSpan(); // 零拷贝:指向 PackedFloat32Array 底层内存 int end = _activeCount * BulletStride; // const int BulletStride = 12 for (int i = 0; i < end; i += BulletStride) { span[i] += span[i + 2] * f; // px += vx * dt span[i + 1] += span[i + 3] * f; // py += vy * dt span[i + 4] -= f; // lifetime -= dt } } ``` **规则 3:帧末批量通知 GDScript,不在循环内逐条回调** C# 内部用 `List` 收集当帧命中 ID,`_PhysicsProcess` 末尾**一次性**通知 EventBus: ```csharp // 帧末单次跨语言调用(循环外) // ⚠️ 禁止 _hitBulletIds.ToArray()——每帧 new int[] 产生 GC 分配。 // 改用可复用的类字段 Godot.Collections.Array _hitBuffer(_Ready() 中 new 一次): // private readonly Godot.Collections.Array _hitBuffer = new(); if (_hitBulletIds.Count > 0) { _hitBuffer.Clear(); foreach (var id in _hitBulletIds) _hitBuffer.Add(id); _eventBus.Call("emit_batch", (int)EventId.BulletHit, _hitBuffer); _hitBulletIds.Clear(); } ``` **规则 4:C# 组件挂为 GDScript Autoload 的子节点** ``` (Autoload) bullet_manager.gd ← GDScript:_data PackedFloat32Array + 对外接口(spawn/despawn) └─ BulletManagerCs.cs ← C# Node:_Ready() 缓存父节点引用;_PhysicsProcess 执行热路径 ``` GDScript 对外接口(`spawn_bullet` / `despawn_bullet`)保持不变,其他 GDScript 系统无感知 C# 的存在。 C# `_Ready()` 中获取父节点并缓存 `_data` 引用,之后每帧直接操作,无跨语言调用。 **BulletManager GDScript 对外接口(权威签名)** ```gdscript # bullet_manager.gd (Autoload: BulletManager) # 以下为 GDScript 对外 API;C# 热路径(_physics_process 积分)不在此列出。 const BULLET_STRIDE: int = 12 # SoA 完整布局(权威:arch §4.2): # [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ] # 0 1 2 3 4 5 6 7 8 9 10 11 # 冷数据(pierce/bounce/homing/payload_id)→ _bullet_contexts: Dictionary(非热路径) var _data: PackedFloat32Array = PackedFloat32Array() var _active_count: int = 0 var _bullet_contexts: Dictionary = {} # { bullet_id: int → Dictionary(冷数据)} func spawn_bullet(def: ProjectileDef, pos: Vector2, vel: Vector2) -> int: # 返回 bullet_id(SoA 槽位索引);def.lifetime 单位:秒 # SpellEvaluator、ZoneManager 调用 pass # 具体实现见 implementation_plan.md §2.2 func despawn_bullet(bullet_id: int) -> void: # 提前回收:lifetime 置 0;C# 下帧 SoA 清理 if bullet_id < 0 or bullet_id >= _active_count: return _data[bullet_id * BULLET_STRIDE + 4] = 0.0 # slot +4: lifetime func get_active_count() -> int: return _active_count func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2: # PlayerManager.get_aim_direction() 调用(自动瞄准默认模式) # 委托 EnemyManager.fill_pos_snapshot 后线性扫描;非每帧热路径(仅发射时调用) return EnemyManager.get_nearest_pos(origin, max_dist) func reset() -> void: _active_count = 0 # 不需要清零数据,_active_count 控制有效范围 _bullet_defs.clear() ``` > **命名统一说明**:文档早期混用 `spawn()` / `spawn_bullet()`,以此处 `spawn_bullet(def, pos, vel)` 为唯一权威签名;`despawn_bullet(id)` 同理。ADR-L1 中提到的"GDScript 对外接口"均指此两个函数。 **规则 5:`compile_wand` 保留在 GDScript,不迁移** 法术预编译仅在换牌时触发(非 `_physics_process`),主要为 `Dictionary` / `Array` 构建操作, GDScript 开发效率更高;迁移 C# 无性能收益且增加维护成本。 --- ### 6.2 EventBus Autoload 接口定义 `EventBus`(`event_bus.gd`)是全局事件总线,**第 6 个 Autoload 初始化**(见 §9.2),所有 Manager 均依赖它。采用整数 ID 替代字符串,避免运行时 String 比较。 ```gdscript # event_bus.gd (Autoload: EventBus) # 核心约定: # - 所有事件 ID 均在 event_ids.gd (Autoload: EventID) 中声明,不接受魔术数字 # - C# 热路径通过 emit_batch 一帧内批量发送,降低跨语言调用次数 # - 订阅回调在 GDScript 侧统一处理,不在 C# 侧订阅(ADR-L1 规则 2) var _listeners: Dictionary = {} # { event_id: int → Array[Callable] } func subscribe(event_id: int, callback: Callable) -> void: if not _listeners.has(event_id): _listeners[event_id] = [] var arr: Array = _listeners[event_id] if not arr.has(callback): arr.append(callback) func unsubscribe(event_id: int, callback: Callable) -> void: if _listeners.has(event_id): _listeners[event_id].erase(callback) func emit(event_id: int, payload: Dictionary = {}) -> void: if not _listeners.has(event_id): return for cb: Callable in _listeners[event_id].duplicate(): # duplicate() 防止回调内修改订阅表 cb.call(payload) func emit_batch(events: Array) -> void: # C# BulletManager / EnemyManager 在 _physics_process 末尾调用(ADR-L1 规则 3) # events 格式:[ [event_id: int, payload: Dictionary], ... ] # 在主线程 GDScript 帧末执行,保证监听者在下一个 _process 前收到通知 for ev in events: if ev.size() >= 2: emit(int(ev[0]), ev[1]) elif ev.size() == 1: emit(int(ev[0])) func reset() -> void: # GameCycleManager 在 Run 结束时调用,清除所有动态订阅(防跨 Run 事件泄漏) # ⚠️ 仅清除非常驻订阅(GameCycleManager、UIManager 等 Autoload 在 _ready() 中重新订阅) _listeners.clear() ``` > **使用约定**: > - `subscribe` / `unsubscribe` 必须成对调用;Autoload 在 `_ready()` 中订阅,节点在 `_exit_tree()` 中取消。 > - `emit_batch` 仅供 C# Manager 使用,格式见注释;普通 GDScript Manager 直接调用 `emit`。 > - EventID 目录权威来源:`implementation_plan.md §2.1`(1–21,下一空位 22)。 --- ## 7. 目录规范建议 ```text project.godot scripts/ autoloads/ # Godot Autoload 单例 (EventBus, BulletManager, EnemyManager) core/ # 核心架构 (ObjectPool, SpatialGrid, BaseClasses) systems/ # 独立系统 (InputManager, AudioManager, ResourceManager) domain/ # 游戏业务逻辑 spell_system/ # 法术解释器, SpellNode 定义, SpellDeck combat/ # 伤害计算, 弹道管理 (BulletManager) enemy/ # AI 行为, EnemyManager ui/ # UI 控制脚本, 特效表现 config/ # JSON 配置加载器与类型定义 csharp/ autoloads/ # C# 热路径内核 (BulletManagerCs, EnemyManagerCs) systems/ # C# 计算模块 (SpellEvaluatorCs, SpatialGridCs, StatusManagerCs, ZoneManagerCs) scenes/ main/ # 主场景, 战斗场景 ui/ # UI 场景 (背包, 商店, HUD) entities/ # 预制体场景 (子弹, 敌人, 特效) resources/ spells/ # 法术数据 (.tres 或 .json) enemies/ # 怪物数据 ``` --- ## 8. 架构决策记录 (Architecture Decision Records) ### ADR-R4-N2:属性快照方案(Snapshot vs Dynamic) > **结论:采用 Snapshot(快照)方案。** DoT(持续伤害)、召唤物(Minion)、持续性法术的属性在**施加/发射瞬间**锁定, 不跟随玩家后续属性变化。即: - DoT 的每 tick 伤害 = 施加瞬间的 `attunement_bonus` × `base_damage`(固定值),后续装备变化不影响已施加的 DoT。 - Minion 的各项属性(速度/伤害/血量)= 召唤瞬间的玩家属性×配方系数,召唤后独立计算,不随玩家属性波动。 - 实现方式:`ProjectileDef` / `MinionSpawnParams` 在填充时立刻将计算完毕的数值写入, BulletManager / MinionManager 直接使用存储值,不保存对玩家属性表的引用。 **设计原因**:Dynamic 方案(实时跟随)会导致属性换装瞬间大量已存在实体同步刷新, 产生不可控的计算尖峰,且与 SoA 热数组布局不兼容。Snapshot 方案计算开销集中在"施加时刻", 运行时热路径无额外查询,更符合 Low-GC 原则。 详见 `docs/mechanics/combat_mechanics_extensions_v2.md §4.2`。 --- ### ADR-R4-N4:召唤物/友军管理方案(MinionManager,方案 B) > **结论:采用独立的 MinionManager(方案 B),不在 EnemyManager 中混管召唤物。** `MinionManager` 为独立的 Autoload 单例,管理玩家召唤的炮台/傀儡/宠物类实体: - **与 EnemyManager 隔离**:EnemyManager 的 SoA 中 `faction_and_type` 字段不扩展到友军, 避免碰撞 Layer 混乱和阵营判断逻辑膨胀。 - **Minion SoA stride**:`[x, y, vx, vy, hp, hp_max, owner_id, behavior_state]`(stride=8), 与 EnemyManager 的 stride=8 同构,便于复用 SpatialGrid 查询逻辑。 - **行为驱动**:Minion 的 AI 行为(巡逻/护卫/定点炮击)由 `behavior_state` 枚举驱动, 每帧在 `MinionManager._physics_process` 中批处理;不使用独立 Node `_process`。 - **生命周期**:Minion 属性在召唤瞬间 Snapshot(见 ADR-R4-N2),由 `owner_id` 关联到施法者; 施法者死亡时,对应的所有 Minion 同帧标记为"消退状态"(延迟 1 秒淡出销毁)。 - **上限保护**:单个玩家同场最多 `MAX_MINIONS = 20` 个 Minion,超出时最早召唤的自动消退。 详见 `docs/mechanics/advanced_mechanics_summons_and_environment.md §3.1`(方案 B 推荐)。 --- ### ADR-R5-N1:地面效果系统架构(ZoneManager) > **结论:采用独立的 ZoneManager(Autoload),Zone 实体基于 SpatialGrid 空间索引,不使用 Area2D。** `ZoneManager` 管理战场中的持久化地面效果(毒液池、岩浆区、冻结地面等): - **数据结构**:`_zones: Array[ZoneData]`,ZoneData 字段:`[cx, cy, radius, status_type_id, duration, tick_interval, tick_accum, root_owner_id]`(stride=8;`tick_interval` 与 `tick_accum` 分开存储,支持每个 Zone 配置独立应用频率)。 - **碰撞检测**:每 `_physics_process` 帧调用 `SpatialGrid.query_circle(zone_center, zone_radius)`,对返回的敌人实体批量施加状态;不使用 Area2D,不产生 Node 开销。 - **上限保护**:`MAX_ZONES = 64`(超出时最早创建的 Zone 被顶掉)。 - **渲染**:Zone 的地面贴花由 `VFXManager.play_zone("poison_pool", center, radius)` 驱动,不在逻辑层操作节点。 - **生命周期**:Zone 创建时 duration 快照,每帧 duration -= delta;降至 ≤ 0 时用 swap-and-pop 移除,同步通知 VFXManager 淡出。 - **与 SpellSystem 的接口**:`ActionSplashPoison` 等 ACTION 节点执行时调用 `ZoneManager.spawn_zone(...)` 而非直接操作场景。 ```gdscript # zone_data.gd (inline struct, not a class_name Resource to avoid GC) # 存储于 ZoneManager._zone_data: PackedFloat32Array,stride = 8 # [0]=cx [1]=cy [2]=radius [3]=status_type_id [4]=duration [5]=tick_interval [6]=tick_accum [7]=owner_id # tick_interval:两次状态应用之间的最小间隔(秒,由 spawn_zone 调用方指定) # tick_accum:当前累积时间(运行时变量),>= tick_interval 时触发一次 APPLY_DAMAGE 并减去 tick_interval # zone_manager.gd (Autoload) const ZONE_STRIDE: int = 8 const MAX_ZONES: int = 64 var _zone_data: PackedFloat32Array # SoA 热数据 var _active_zones: int = 0 func spawn_zone(cx: float, cy: float, radius: float, status_id: int, duration: float, tick_interval: float, # Zone 每隔多少秒对范围内敌人应用一次状态 owner_id: int) -> void: if _active_zones >= MAX_ZONES: # 顶掉最旧的 Zone(index 0),整体前移(O(N),N≤64 可接受) # PackedFloat32Array 无 remove_at(),用手动循环前移实现首元素删除 for j in range(ZONE_STRIDE, _active_zones * ZONE_STRIDE): _zone_data[j - ZONE_STRIDE] = _zone_data[j] _active_zones -= 1 var base: int = _active_zones * ZONE_STRIDE _zone_data.resize((_active_zones + 1) * ZONE_STRIDE) _zone_data[base + 0] = cx; _zone_data[base + 1] = cy _zone_data[base + 2] = radius; _zone_data[base + 3] = status_id _zone_data[base + 4] = duration; _zone_data[base + 5] = tick_interval _zone_data[base + 6] = 0.0 # tick_accum 初始为 0 _zone_data[base + 7] = owner_id _active_zones += 1 func _physics_process(delta: float) -> void: var i: int = 0 while i < _active_zones: var base: int = i * ZONE_STRIDE _zone_data[base + 4] -= delta # duration 倒计时 _zone_data[base + 6] += delta # tick_accum 累积 # 速率限制:仅当 tick_accum >= tick_interval 时才触发状态应用,防止 64 个 Zone 每帧 # 对 1000 个敌人各触发,产生最多 64,000 APPLY_DAMAGE 事件/帧。 # 外层 if 守卫 + 内层 while + -= tick_interval:保留余量精度,且支持大帧多次跳字。 # SpatialGrid.query_circle 仅在 if 守卫内调用一次,不在 while 内重复查询。 if _zone_data[base + 6] >= _zone_data[base + 5]: var cx: float = _zone_data[base + 0]; var cy: float = _zone_data[base + 1] var rad: float = _zone_data[base + 2]; var sid: int = int(_zone_data[base + 3]) var targets := SpatialGrid.query_circle(Vector2(cx, cy), rad) while _zone_data[base + 6] >= _zone_data[base + 5]: _zone_data[base + 6] -= _zone_data[base + 5] # -= tick_interval 保留余量精度 for t_id in targets: StatusManager.apply(t_id, sid, 1.0, int(_zone_data[base + 7])) # intensity=1.0, owner_id=zone_owner if _zone_data[base + 4] <= 0.0: # duration 耗尽,移除此 Zone var cx_exp: float = _zone_data[base + 0]; var cy_exp: float = _zone_data[base + 1] var rad_exp: float = _zone_data[base + 2] VFXManager.play("zone_expire", Vector2(cx_exp, cy_exp), rad_exp / 64.0) # swap-and-pop 移除(O(1),顺序可乱) if i < _active_zones - 1: for k in ZONE_STRIDE: _zone_data[base + k] = _zone_data[(_active_zones - 1) * ZONE_STRIDE + k] _active_zones -= 1 else: i += 1 func reset() -> void: # GameCycleManager._reset_all_managers() 调用,清空所有活跃区域特效 _active_zones = 0 # SoA 数据不清零,_active_zones 控制有效范围(惰性清理) ``` > **与 BulletManager 的区别**:BulletManager 管理短生命周期(秒级)的投射物;ZoneManager 管理中等生命周期(数秒~数十秒)的区域场。两者共享同一个 `SpatialGrid` 实例,但查询入口不同(BulletManager 查敌人,ZoneManager 也查敌人但传入 zone_center/radius)。 --- ### ADR-R5-N2:Core Feature Tags 字符串常量规范 > **问题**:`if "persistent_memory" not in core.feature_tags` 使用裸字符串比较,拼写错误时静默失效,且重构不安全。 > **结论**:所有 Feature Tag 字符串必须通过 `CoreFeatureTag` 常量访问,禁止在业务代码中写裸字符串。 ```gdscript # core_feature_tag.gd (Autoload: CoreFeatureTag) ## Core 特性标签常量表 — 所有系统通过此处引用,禁止裸字符串 const PERSISTENT_MEMORY: String = "persistent_memory" const DUAL_STREAM: String = "dual_stream" const SHUFFLE_DECK: String = "shuffle_deck" const INFINITE_SPELLS: String = "infinite_spells" const ALWAYS_CAST_LAST: String = "always_cast_last" ## 使用示例(正确): ## if CoreFeatureTag.PERSISTENT_MEMORY in core.feature_tags: ... ## ## 禁止的写法: ## if "persistent_memory" in core.feature_tags: ... # ❌ 裸字符串 ``` 新增 Feature Tag 流程: 1. 在 `CoreFeatureTag` 中追加常量(命名规则:全大写下划线分隔)。 2. 在 `core_wand_design.md §3` Feature Tags 表格中登记效果与稀有度。 3. 在 `SpellEvaluator` 或对应 Manager 中实现逻辑,通过 `CoreFeatureTag.XXX` 引用。 --- ### ADR-A1:本地化架构(L10n Infrastructure) > **结论:从 S1 起强制所有玩家可见字符串通过 `tr()` 包裹;本地化是开发规范而非后期工程。** 商业 Steam 发行需支持至少简体中文 / 繁体中文 / 英语 / 日语四语。延后本地化会导致数千条裸字符串的批量替换,历史代价极高。 **规范约束**: * 所有 UI 标签、法术名称、状态名称、Boss 名称、提示文本字符串字面量**必须**用 `tr("KEY")` 包裹。 * 禁止在业务代码中写裸中文字符串(仅 `push_warning` / `push_error` 调试输出除外)。 * 使用 Godot 内置 `.po` 文件(兼容 Gettext 工具链),路径 `res://translations/zh_CN.po` / `en.po`。 **键名规范**:`<模块>_<实体>_<含义>`,全大写下划线,例: | 键 | 对应文本 | | :--- | :--- | | `SPELL_SPARK_BOLT_NAME` | "电花弹" | | `SPELL_SPARK_BOLT_DESC` | "发射一枚快速电花弹,命中造成闪电伤害" | | `STATUS_BURN_NAME` | "燃烧" | | `UI_SHOP_REROLL_TOOLTIP` | "花费 {gold} 金币刷新商店" | **实施时机**:S1 随 ConfigMgr 一并建立翻译加载流程(`ProjectSettings/locale` + `TranslationServer.add_translation()`);S1 起所有新增文本直接按规范写键,不留技术债。 --- ### ADR-A2:局内存档 / 断点续玩(Run State Persistence) > **结论:S2 实现波次边界自动存档,采用 A/B 双槽写入防崩溃损坏,支持断点续玩。** 商业 Roguelite(Hades / Slay the Spire / Balatro)均支持中途退出后恢复到当前波次起始状态。 **存档时机**: 1. 每波**战斗结束**(进入商店阶段前):序列化 `PlayerState + ActiveCore + WaveNum`。 2. 每次**商店关闭**(进入下一波前):追加序列化 `InventoryState + UpgradePicks`。 3. 游戏**异常退出**:通过 `_notification(NOTIFICATION_WM_CLOSE_REQUEST)` 触发最后一次存档。 **序列化字段**(`run_state_v1.json`): | 字段 | 内容 | | :--- | :--- | | `schema_version` | 当前 = 1;迁移时递增 | | `wave_num` | 当前波次(读档后从此波重新开始) | | `elapsed_sec` | 本次 Run 总计时(无尽模式得分依据) | | `player_hp` | 当前血量 | | `player_stats` | 所有属性词条快照(Dictionary) | | `cores` | 3 个 Core 的完整 SpellDeck JSON(含 `core_id` + `slots`,格式见 §3 E2) | | `active_core_idx` | 当前激活 Core 索引(0/1/2) | | `unlocked_upgrades` | 已选升级 ID 数组 | | `shop_seed` | 随机种子(保证重进后商店展示相同选项,防刷新重开骗局) | **A/B 双槽写入(防崩溃数据损坏)**: ```gdscript # game_cycle_manager.gd(或 ProfileManager) const _SLOT_A := "user://run_a.json" const _SLOT_B := "user://run_b.json" func save_run(data: Dictionary) -> void: var which := ProfileManager.get_int("run_write_slot", 0) # 0=A, 1=B var path := _SLOT_A if which == 0 else _SLOT_B var f := FileAccess.open(path, FileAccess.WRITE) if f: f.store_string(JSON.stringify(data)) ProfileManager.set_int("run_write_slot", 1 - which) # 下次写另一槽 func load_run() -> Dictionary: for path in [_SLOT_A, _SLOT_B]: if not FileAccess.file_exists(path): continue var text := FileAccess.open(path, FileAccess.READ).get_as_text() var parsed: Variant = JSON.parse_string(text) if parsed is Dictionary and parsed.has("schema_version"): return parsed return {} # 无有效存档,从头开始新 Run ``` 写完 A 槽后下次写 B 槽,两槽交替覆写;任意一次写入崩溃只损坏一个槽,另一槽保留上次完整状态。 **存档版本迁移框架(Schema Migration)**:新增字段或修改存档结构时,递增 `schema_version` 并在 `_migrate_run()` 中追加对应迁移函数;禁止在业务代码中对新字段直接 `.get()` 而绕过迁移。 ```gdscript # profile_manager.gd — 存档迁移分发入口 # 调用时机:load_run() 成功解析 JSON 后、return 前调用 _migrate_run(data) func _migrate_run(data: Dictionary) -> Dictionary: var ver: int = data.get("schema_version", 0) if ver < 1: data = _migrate_v0_to_v1(data) # if ver < 2: # data = _migrate_v1_to_v2(data) # 未来版本在此追加,保持链式顺序迁移 return data # v0 → v1:为无 schema_version 的旧存档补全 S2 新增字段的默认值 func _migrate_v0_to_v1(data: Dictionary) -> Dictionary: data["schema_version"] = 1 if not data.has("elapsed_sec"): data["elapsed_sec"] = 0.0 if not data.has("shop_seed"): data["shop_seed"] = randi() # 随机补种,防刷新重开 if not data.has("unlocked_upgrades"): data["unlocked_upgrades"] = [] if not data.has("active_core_idx"): data["active_core_idx"] = 0 return data ``` **版本变更记录**(每次 `schema_version` 递增后在此登记): | schema_version | 变更内容 | 对应切片 | | :--- | :--- | :--- | | 1 | 初始版本:`wave_num` / `elapsed_sec` / `player_hp` / `player_stats` / `cores` / `active_core_idx` / `unlocked_upgrades` / `shop_seed` | S2 | --- ## ADR-R6 — Endless 排行榜数据完整性 (Leaderboard Integrity) > **问题**:`user://endless_records.json` 是纯文本 JSON,玩家可直接用文本编辑器修改分数。 > 本 ADR 定义基于 HMAC-SHA256 的本地签名方案,对抗普通玩家随意手改,同时不影响离线可玩性。 ### R6.1 威胁模型 | 攻击向量 | 对抗手段 | 是否覆盖 | | :--- | :--- | :--- | | 直接编辑 JSON 文件 | HMAC 签名验证失败 → 拒绝载入 | ✅ | | 复制高分存档文件 | 签名与内容绑定,复制后验证通过(允许,属于本地副本) | 允许 | | 逆向提取 HMAC Key 后重签 | 超出本方案防护范围;如需强防护则依赖服务器排行榜 | ❌ | | 网络传输篡改(Steam 排行榜提交)| Steam SDK 侧验证,非本地文件问题 | ❌(Steam 负责)| ### R6.2 实现规范 ```gdscript # scripts/domain/endless_records_manager.gd class_name EndlessRecordsManager extends RefCounted # HMAC Key:32 字节随机密钥,S5 开发前生成并硬编码到此处 # ⚠️ 安全注意:此 Key 仅防普通手改,不能防逆向;不含敏感用户数据,可接受。 # 生成方式:python3 -c "import secrets; print(list(secrets.token_bytes(32)))" const _HMAC_KEY: PackedByteArray = [ 0x3F, 0x7A, 0x12, 0xB8, 0x5C, 0xE4, 0x91, 0x2D, 0x6F, 0x04, 0xAA, 0x73, 0xC9, 0x18, 0x55, 0xE7, 0x8B, 0x31, 0xF6, 0x4E, 0x0D, 0x90, 0x26, 0x7C, 0xD2, 0x47, 0xBE, 0x5A, 0x83, 0x1F, 0xCA, 0x69 ] # 实际集成时替换为项目专属 Key const RECORDS_PATH: String = "user://endless_records.json" func save(records: Array) -> void: var data_str := JSON.stringify(records) var sig := _sign(data_str) var envelope := { "data": records, "sig": sig, "ver": 1 } var f := FileAccess.open(RECORDS_PATH, FileAccess.WRITE) if f: f.store_string(JSON.stringify(envelope)) func load() -> Array: if not FileAccess.file_exists(RECORDS_PATH): return [] var raw: Variant = JSON.parse_string( FileAccess.open(RECORDS_PATH, FileAccess.READ).get_as_text()) if not raw is Dictionary: return [] var records: Variant = raw.get("data", []) var expected := _sign(JSON.stringify(records)) if raw.get("sig", "") != expected: push_warning("EndlessRecords: 签名验证失败,疑似被篡改,分数已重置") return [] # 拒绝载入,不崩溃;UI 显示"分数不可用" return records func _sign(data: String) -> String: var crypto := Crypto.new() return crypto.hmac_digest( HashingContext.HASH_SHA256, _HMAC_KEY, data.to_utf8_buffer() ).hex_encode() ``` ### R6.3 分数提交 Steam 排行榜前的验证 ```gdscript # game_cycle_manager.gd — Endless 结算时 func _submit_endless_score(wave: int, elapsed_sec: float) -> void: var score: int = wave * 100000 + (86400 - int(elapsed_sec)) # wave 优先,elapsed_sec 作为同波次决胜分 var records: Array = EndlessRecordsManager.new().load() if records.is_empty() and FileAccess.file_exists( EndlessRecordsManager.RECORDS_PATH): # 文件存在但 load() 返回空 → 被篡改,不提交 Steam 排行榜 UIManager.show_toast("分数验证失败,本次成绩不上传排行榜") return # 验证通过后提交(Steam SDK 调用) if SteamIntegration.is_available(): SteamIntegration.upload_leaderboard_score(score) ``` > **决策背景**:3A 商业标准要求游戏对色盲玩家(约占男性玩家 8%)可玩。仅用颜色区分元素 > 会导致此类玩家无法识别元素类型,影响共鸣系统核心玩法。 ### C1.1 颜色盲支持(必须实现) **规则 ADR-C1-R1**:UI 中所有元素标识必须同时提供颜色 + 形状标识,禁止仅用颜色区分。 **形状图标映射表**(对应 `SpellNode.shape_icon` 字段): | 元素 | 颜色(正常视觉) | `shape_icon` 键 | 图标形状描述 | | :--- | :--- | :--- | :--- | | 火系 Fire | 橙红 `#FF4500` | `"triangle"` | 向上三角形 | | 冰系 Ice | 冰蓝 `#7EC8E3` | `"diamond"` | 菱形 | | 水系 Water | 深蓝 `#1E90FF` | `"circle"` | 圆形 | | 土系 Earth | 棕绿 `#8B7355` | `"square"` | 方形 | | 雷系 Lightning | 黄色 `#FFD700` | `"star"` | 五角星 | | 暗系 Dark | 紫黑 `#4B0082` | `"cross"` | X 形 | **渲染规则**: ```gdscript # ui_spell_card.gd — 法术卡渲染示例 func _render_element(spell_node: SpellNode) -> void: # 1. 颜色背景(正常视觉) $ElementBadge/Background.color = ElementColors.get(spell_node.element_tags) # 2. 形状图标叠加(色盲支持,ADR-C1-R1) if spell_node.shape_icon != "": $ElementBadge/ShapeIcon.texture = UIAtlas.get_icon(spell_node.shape_icon) $ElementBadge/ShapeIcon.visible = true else: $ElementBadge/ShapeIcon.visible = false ``` **VFX 叠加规则**(ADR-C1-R1 延伸): - 命中 VFX 粒子颜色使用对应元素颜色。 - 命中时在命中点叠加 1 帧(0.1s)的 `shape_icon` TextureRect,字号 1.5× 基准大小。 - `VFXManager` 在 `spawn_hit_vfx(pos, element_tag)` 中从 UIAtlas 读取 `shape_icon` 并生成叠加节点。 ### C1.2 字体缩放(S4 实现) ```gdscript # settings_manager.gd 新增常量 + 接口 const FONT_SCALE_KEY: String = "font_scale" const FONT_SCALE_MIN: float = 0.5 const FONT_SCALE_MAX: float = 1.5 const FONT_SCALE_DEF: float = 1.0 static func get_font_scale() -> float: return ProfileManager.get_float(FONT_SCALE_KEY, FONT_SCALE_DEF) .clamp(FONT_SCALE_MIN, FONT_SCALE_MAX) static func apply_font_scale(root: Node) -> void: # 遍历 UILayer 下所有 Label 节点,按 scale 覆写 custom_font_size for label in root.find_children("*", "Label", true, false): label.add_theme_font_size_override( "font_size", int(label.get_theme_font_size("font_size") * get_font_scale())) ``` UI 初始化时调用 `SettingsManager.apply_font_scale(ui_root)`;字体缩放变更时发送 `EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" })` 触发全局重渲染(`EventID` **19**,见 `implementation_plan.md` §2.1)。 ### C1.3 减少闪光(S6 实现) 设置项 `reduce_flash`(bool,默认 `false`): - 开启时:VFXManager 将爆炸 / 共鸣特效的帧率限制为 12fps,白闪持续时间从 0.2s → 0.05s。 - `EventID.GAME_CLEARED`(GameCycleManager 进入 GAME_CLEARED 状态时发出)的全屏白闪在该模式下完全跳过。 ### C1.4 高对比度模式(S6 实现) 设置项 `high_contrast`(bool,默认 `false`): - 开启时:UI 背景强制黑色 `#000000`,所有 Label 强制白色 `#FFFFFF`。 - 实现:`SettingsManager.apply_high_contrast(ui_root)` 覆写主题颜色变量,不修改资源文件。 --- ## ADR-A3 — 音频架构补充细节 (Audio Architecture - Supplementary) > **注意**:AudioBus 层级结构、AudioBusID 常量、`_bgm_base_player`/`_music_layers` 字段声明、`play_bgm()`/`play_sfx()` 接口均已在 **§5.4** 完整定义,本 ADR 仅补充 §5.4 未涉及的细节(2D 空间音效衰减参数、音量持久化、SFX 节流规则)。 ### A3.1 AudioBus 结构参考 > ⚠️ 此处为索引引用,**不重新定义** Bus 结构,权威定义见 §5.4。 Bus 树:`Master > BGM (BGM_Base, BGM_Layer) > SFX (SFX_Combat, SFX_Spatial) > UI > Ambience > Voice` AudioBusID Autoload 常量:`MASTER / BGM / BGM_BASE / BGM_LAYER / SFX / SFX_COMBAT / SFX_SPATIAL / UI / AMBIENCE / VOICE` ### A3.2 动态音乐分层触发规则(补充) | 屏内敌人数 / 状态 | BGM_Layer 激活层级 | 说明 | | :--- | :--- | :--- | | 0–4 | Layer 0(基础,仅鼓点 + 低音) | 常驻,即使 0 敌人 | | 5–19 | Layer 1(主旋律 + 和弦) | EnemyManager.get_visible_count() ≥ 5 | | ≥ 20 或 Boss 激活 | Layer 2(弦乐/合成铅声) | 最高战斗强度 | | SHOP / MAIN_MENU 状态 | 独立 BGM(BGM_Base 直接切换)| BGM_Layer 全部静音 | `AudioManager._process()` 每 0.5s 轮询一次 `EnemyManager.get_visible_count()`;Boss 激活/死亡通过 EventBus 订阅(`BOSS_PHASE_CHANGED` / `BOSS_KILLED`)实时响应,无需轮询。 ### A3.3 2D 空间音效衰减 - SFX_Spatial Bus 下的 `AudioStreamPlayer2D` 使用 Godot 内置 `attenuation_filter_db` 曲线。 - **衰减曲线参数**(`max_distance=1200px`,`attenuation=1.8`): 0px → 0dB, 400px → -6dB, 800px → -18dB, 1200px → -40dB(截止)。 - 超出 `max_distance` 的音源直接 `stop()`,不参与 0.1s 节流(已无声)。 ### A3.4 音量持久化 ```gdscript # settings_manager.gd — 音量持久化 # AudioBusID 是 Autoload,其常量为整数(bus index);不支持字符串下标访问,使用显式映射表 # bus_key(保存键)→ AudioServer bus index(AudioBusID 常量)的显式映射 const _VOLUME_BUS_MAP: Dictionary = { "vol_master": AudioBusID.MASTER, "vol_bgm": AudioBusID.BGM, "vol_sfx": AudioBusID.SFX, "vol_ui": AudioBusID.UI, } func load_audio_settings() -> void: # SettingsManager._ready() 调用;将持久化音量值应用到 AudioServer for save_key in _VOLUME_BUS_MAP: var db: float = ProfileManager.get_float(save_key, 0.0) # 0.0 dB = 100% AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db) func save_audio_setting(save_key: String, db: float) -> void: # UIManager 音量滑块 on_value_changed → SettingsManager.save_audio_setting("vol_bgm", db) if not _VOLUME_BUS_MAP.has(save_key): push_warning("SettingsManager: unknown audio save_key: %s" % save_key) return AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db) ProfileManager.set_float(save_key, db) EventBus.emit(EventID.SETTINGS_CHANGED, { "key": save_key }) ``` **存储位置**:`user://save_data.json`(由 `ProfileManager` 管理,非 Run 存档)。 **范围约定**:音量滑块线性映射到 `-40dB ~ 0dB`;mute 状态用 `-80dB` 替代 `set_bus_mute()` 以避免 Godot 的 mute 标志在存档恢复时的边界问题。 ### A3.5 SFX 池节流规则补充 > 以下补充 `implementation_plan.md §2.5.B` 中已有的 32 池规则: | 规则 | 说明 | | :--- | :--- | | 同一音效 0.1s 内只播放 1 次 | `_last_play_time[sfx_id]` 字典节流 | | 同帧相同位置的碰撞音效合并为 1 次 | BulletManager 碰撞处理后批量调用 `AudioManager.play_sfx_batch()` | | Boss 语音(Voice Bus)不受 SFX 池限制 | 独立 `AudioStreamPlayer` 节点,最高优先级 | | 玩家死亡时淡出所有 Bus(0.5s 内 -40dB)| `AudioManager.fade_out_all(duration=0.5)` | --- ## ADR-A4 — 敌人 AI 寻路方案决策 (Enemy Pathfinding) > **结论**:Wave 1–14 普通敌人维持当前 Boid 斥力向量(无寻路);Wave 15+ Elite 及 Boss 敌人按需引入 Godot `NavigationAgent2D`,并发上限 `MAX_PATHFINDING_ENEMIES=20`,超限降级 Boid。 > > **S5 定案(2026-06-05,P-S5-AI-01 实测)**:采用 **`NavigationAgent2D` 方案**,**不**切换 FlowField。20 个 Elite 同时寻路的 `_update_pathfinding_movement` 实测 **≈0.0496ms/帧**,远低于 0.5ms 预算(约 10× 余量)。实现于 `enemy_manager.gd`(GDScript 回退路径):程序化单凸矩形 `NavigationRegion2D` + 每精英 `Node2D` host 挂 `NavigationAgent2D`(`avoidance_enabled=false`,与 SoA Boid 分离力共存);无精英时主循环零额外开销。 ### 背景 当前架构中,所有敌人的移动逻辑为 Boid 斥力向量(EnemyManagerCs 内 SoA 批量计算),无完整寻路。 评估发现 W15+ 精英敌人在面对角落障碍物时,因无寻路只能在角落堆叠,导致玩家可"站角落无脑输出"打过高难度波次,严重削弱 W15+ 的挑战性。 ### 方案对比 | 方案 | 优点 | 缺点 | 适用范围 | | :--- | :--- | :--- | :--- | | Boid 斥力(当前) | 零额外 CPU,SoA 批量计算,1000 敌人帧时间已知 | 敌人无法绕过角落障碍物 | W1-14 杂鱼(堆叠可接受) | | Godot `NavigationAgent2D` | 引擎内置,实现简单;支持动态障碍物 | 1000 个 Agent 同时 `get_next_path_position()` 帧时间未知,需 S5 基准测试 | Elite / Boss(≤ 20 个并发寻路) | | FlowField 预烘焙 | O(1) 每敌人查询,适合大量敌人共享目标 | 实现复杂;场景动态障碍物需触发重烘焙(延迟约 1 帧) | 若 NavigationAgent2D 20 个精英时帧时间增量 > 0.5ms,则以此方案替代 | ### 实施规则 - `MAX_PATHFINDING_ENEMIES = 20`:同时具有完整寻路能力的敌人数上限。超出上限的额外 Elite 降级为 Boid 模式(按距玩家最近优先保留寻路资格)。 - W1–14 普通敌人:**永远** 使用 Boid 模式,不分配 NavigationAgent2D,避免节点数量爆炸。 - W15+ Elite / Boss:入场时在 `EnemyManagerCs` 中标记 `has_pathfinding = true`,GDScript 侧为其创建 `NavigationAgent2D` 子节点。 - **S5 性能验收(新增 P-S5-AI-01)**:`NavigationAgent2D` 20 个 Elite 同时寻路,`_physics_process` 帧时间增量 < **0.5ms**;若超标,切换 FlowField 方案并在本 ADR 更新决策结论。 ### 过渡策略 S1–S4 期间:所有敌人继续使用 Boid 模式,不实现寻路(维持已知性能基线)。 S5 实现 Elite 敌人时:同步实现 `NavigationAgent2D` 方案并运行 P-S5-AI-01 基准测试,若达标则定案为 NavigationAgent2D;否则切换 FlowField。 --- ## 9. Godot 项目结构 (Project Structure) ### 9.1 场景树结构 (Scene Tree) #### 主场景(Main.tscn) ``` Main (Node) ← res://scenes/Main.tscn,程序入口 ├── UILayer (CanvasLayer, layer=10) ← 主菜单 / 过渡 / 全局遮罩 │ └── MainMenuUI (Control) ├── GameRoot (Node2D) ← 战斗场景的挂载点(动态 add_child / remove_child) └── [C# Manager 子节点] ← ADR-L1 规则 4:C# 热路径作为对应 Autoload 的子节点 ├── BulletManagerCs (Node) ← 挂在 BulletManager Autoload 下 ├── EnemyManagerCs (Node) ← 挂在 EnemyManager Autoload 下 ├── SpatialGridCs (Node) ← 挂在 SpatialGrid Autoload 下(或独立 Autoload) ├── SpellEvaluatorCs (Node) ← 挂在 SpellEvaluator Autoload 下 ├── StatusManagerCs (Node) ← 挂在 StatusManager Autoload 下 └── ZoneManagerCs (Node) ← 挂在 ZoneManager Autoload 下 ``` > **注意**:C# 子节点由对应的 GDScript Autoload 在 `_ready()` 中通过 `add_child(BulletManagerCs.new())` 创建,不出现在 .tscn 文件中(避免场景文件依赖 C# 程序集,保持热重载友好)。 #### 战斗场景(BattleScene.tscn) ``` BattleScene (Node2D) ← res://scenes/BattleScene.tscn ├── World (Node2D) ← 世界坐标系(地图 TileMap 挂这里) │ ├── TileMap (TileMap) ← 地图地形(S1 起实现) │ ├── BulletRenderRoot (Node2D) ← 子弹 Node2D 池的挂载根(< 2000 弹幕时) │ ├── MultiMeshRoot (Node2D) ← MultiMeshInstance2D(2000+ 弹幕时动态启用) │ ├── EnemyRenderRoot (Node2D) ← 敌人 Sprite2D 节点池 │ ├── VFXRoot (Node2D) ← VFXManager 的粒子节点池(跨场景挂 Autoload 自身) │ └── ZoneVFXRoot (Node2D) ← 地面效果贴图/Shader 渲染根 ├── Player (CharacterBody2D) ← 玩家节点(PlayerManager 的 View 层) │ ├── Sprite2D │ ├── AnimationPlayer │ └── CollisionShape2D └── HUDLayer (CanvasLayer, layer=5) ← 战斗 HUD(血条、法术槽、波次信息) ├── HPBar (ProgressBar) ├── WaveLabel (Label) ├── SpellSlotPanel (HBoxContainer) ├── DamageNumberPool (Node) ← 伤害数字节点池(弹出后自动归还) └── MiniMap (Control) ← S3 实现 ``` #### CanvasLayer 分层规则 | Layer | 用途 | 挂载场景 | | :--- | :--- | :--- | | 0 | 世界空间(无 CanvasLayer) | World 节点树 | | 5 | 战斗 HUD(跟随 Viewport) | BattleScene > HUDLayer | | 8 | 商店 / 背包 UI(模态)| ShopManager 动态创建 | | 10 | 全局 UI(主菜单 / 过渡动画 / 暂停菜单)| Main > UILayer | | 15 | 调试叠加层(仅 Debug 构建) | Main > DebugLayer | --- ### 9.2 Autoload 初始化顺序(project.godot 注册顺序) Godot 按 `project.godot [autoload]` 中的声明顺序依次调用 `_ready()`。下表定义了强制顺序及原因: | 顺序 | Autoload 名 | 文件 | 依赖(_ready() 中调用的其他 Autoload) | | :--- | :--- | :--- | :--- | | 1 | `CrashReporter` | `crash_reporter.gd` | 无(最先,捕获所有后续初始化崩溃) | | 2 | `EventID` | `event_ids.gd` | 无(纯常量,1–21)| | 3 | `DamageType` | `damage_type.gd` | 无(纯常量) | | 4 | `StatusID` | `status_id.gd` | 无(纯常量) | | 5 | `CoreFeatureTag` | `core_feature_tag.gd` | 无(纯常量) | | 6 | `AudioBusID` | `audio_bus_id.gd` | 无(纯常量,Bus index 整数)| | 7 | `ObjectPool` | `object_pool.gd` | 无(通用对象池,S0 起全局使用,impl §2.1 Layer-0)| | 8 | `TimeManager` | `time_manager.gd` | 无(时间缩放/GameTick,impl §2.1 Layer-0)| | 9 | `EventBus` | `event_bus.gd` | 无(事件总线基础设施,其他 Manager 订阅事件需要它先就绪) | | 10 | `DamageContextPool` | `damage_context_pool.gd` | 无(独立池,预分配 64 个槽) | | 11 | `SubPayloadRegistry` | `sub_payload_registry.gd` | 无 | | 12 | `SpatialGrid` | `spatial_grid.gd` + `SpatialGridCs.cs` | 无(C# 子节点在其 _ready() 中 add_child) | | 13 | `SpellRegistry` | `spell_registry.gd` | 无(同步扫描加载 .tres) | | 14 | `PassiveRegistry` | `passive_registry.gd` | 无(同步扫描加载 PassiveDef .tres) | | 15 | `StatusRegistry` | `status_registry.gd` | 无(同步扫描加载 StatusTypeDef .tres) | | 16 | `ProfileManager` | `profile_manager.gd` | 无(存档读写;Run存档:run_a/b.json,设置:save_data.json)| | 17 | `SettingsManager` | `settings_manager.gd` | `ProfileManager`(读取用户音量/辅助功能设置) | | 18 | `BulletManager` | `bullet_manager.gd` + `BulletManagerCs.cs` | `SpatialGrid`(spawn 时写入格子) | | 19 | `EnemyManager` | `enemy_manager.gd` + `EnemyManagerCs.cs` | `SpatialGrid`(敌人位置写入格子), `DamageContextPool` | | 20 | `MinionManager` | `minion_manager.gd` | `SpatialGrid`, `EnemyManager`(友伤判断), `EventBus` | | 21 | `StatusManager` | `status_manager.gd` + `StatusManagerCs.cs` | `StatusRegistry`, `DamageContextPool`, `EventBus` | | 22 | `ZoneManager` | `zone_manager.gd` + `ZoneManagerCs.cs` | `SpatialGrid`, `StatusManager` | | 23 | `SpellEvaluator` | `spell_evaluator.gd` + `SpellEvaluatorCs.cs` | `SubPayloadRegistry`, `BulletManager`, `DamageContextPool`, `EventBus` | | 24 | `PlayerManager` | `player_manager.gd` | `SpellEvaluator`, `PassiveRegistry`, `EventBus`, `ProfileManager` | | 25 | `VFXManager` | `vfx_manager.gd` | `EventBus` | | 26 | `AudioManager` | `audio_manager.gd` | `SettingsManager`(初始音量), `EventBus` | | 27 | `UIManager` | `ui_manager.gd` | `EventBus`(订阅 GAME_STATE_CHANGED 等)| | 28 | `CoreRegistry` | `core_registry.gd` | 无(纯只读注册表,扫描 res://resources/cores/)| | 29 | `ConsumableRegistry` | `consumable_registry.gd` | 无(纯只读注册表,扫描 res://resources/consumables/)| | 30 | `BossManager` | `boss_manager.gd` | `EnemyManager`, `EventBus` | | 31 | `WaveManager` | `wave_manager.gd` | `EnemyManager`, `BossManager`, `EventBus` | | 32 | `DropManager` | `drop_manager.gd` | `EnemyManager`, `PlayerManager`, `SpellRegistry`, `EventBus` | | 33 | `UpgradeSystem` | `upgrade_system.gd` | `PlayerManager`, `SpellEvaluator`, `EventBus` | | 34 | `ShopManager` | `shop_manager.gd` | `PlayerManager`, `SpellRegistry`, `PassiveRegistry`, `CoreRegistry`, `ConsumableRegistry`, `EventBus` | | 35 | `GameCycleManager` | `game_cycle_manager.gd` | **所有以上 Manager**(协调者,最后初始化) | > **初始化安全规则**:编号 N 的 Autoload 的 `_ready()` 中只能调用编号 < N 的 Autoload。若发现需要访问编号 ≥ N 的 Autoload,必须延迟到 `_ready()` 后的第一个 `_process()` 帧,或通过 `EventBus` 信号触发。 > > **C# 子节点时机**:C# 子节点(`BulletManagerCs` 等)在其 GDScript Autoload 的 `_ready()` 中通过 `add_child()` 挂载。C# 子节点的 `_Ready()` 在 `add_child()` 返回前同步调用(Godot 规范),因此 C# 子节点可以安全访问其 GDScript 父节点,但不得直接访问尚未初始化的其他 Autoload(通过 `GetNode("/root/XXX")` 延迟获取)。