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