Files
spellforge/docs/technical/architecture_design.md
T
joywayerandClaude Opus 5 a119ec9ab1 docs(arch): 补删 ProjectileDef.reset() 里遗留的 homing_force 赋值
上一提交删了声明与注释、漏了 31 行后 reset() 内的赋值,
导致同一代码块自相矛盾(注释声明该字段不存在,正文仍在赋值)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:47:56 +08:00

3502 lines
191 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块化战术土豆 (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 功能专用
# ⚠️ 必须声明时初始化为长度 4PackedFloat32Array() 默认长度 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 不在此处清零:
# 持久 CorePERSISTENT_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 批量生成弹体
# 通过对象池复用,不触发 GCSpellContext.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类 SpellNodeACTION / 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 Ai < 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 ≤ 16O(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_degreeKahn 前快照,确保汇聚点检测正确
# 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 可选】
> ⚠️ **设计注意**:本功能仅通过特殊 Corepersistent_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`**,逻辑全部由中央 ManagerAutoload 单例)统一驱动。
* 也就是:`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` | 根源实体 IDfloat 存 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 _dataO(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 位 = faction0=敌方, 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-onlyNode 池档位已无意义,此简化为**代码更优**。详见 [审计报告](../../docs_dev/doc_code_audit_2026-07-20.md)。
| 同屏弹幕数 | 渲染方案 | 碰撞方案 | 说明 |
| :--- | :--- | :--- | :--- |
| < 200 | Node Pool (Node2D) | Area2D | 全功能,支持粒子特效、信号回调 |
| 2002000 | 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-01C# 热路径 S1 填充后预计更低) |
| `EnemyManagerCs._PhysicsProcess` | C# | 2.0ms | **0.23ms** | 1000 敌人直线追踪 + fill_pos_snapshotP-S0-02Boid C# S1 填充)<br>⚠️ 该实测取于 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 逐 tickS4 验收后填入,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 2060Godot 4.6.2 stable monoWindows 10。2000 子弹 + 1000 敌人同屏,GDScript fallback 运行(C# 热路径骨架未激活)。`physics_frame_time_msec`Godot Monitor= 0.05~0.13msFPS 稳定 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=DXT5Switch=ASTC4×4 |
| 音频资源(BGM 流式 + SFX 预加载)| 30MB | 80MB | BGM 流式播放,SFX 池全量预加载 |
| BulletManager SoA2000 子弹) | < 1MB | < 1MB | `BULLET_STRIDE=12` × 2000 × 4B ≈ 96KB |
| EnemyManager SoA1000 敌人) | < 1MB | < 1MB | `ENEMY_STRIDE=8` × 1000 × 4B ≈ 32KB |
| SpellContext 对象池(32 实例) | < 1MB | < 1MB | 每实例约 2KB(含 payloads Array |
| Node Pool(子弹节点 2000 个)| 20MB | 50MB | 每 Node2D 约 10KBMultiMesh 激活后降至 ~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 标签页实测 RSSPC < 512MBSwitch 目标 < 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 > 20Endless模式) | `SHOP` | wave_num++ShopManager.restock(wave_num) |
| `GAME_OVER` / `GAME_CLEARED` | 玩家点击"返回菜单" | `MAIN_MENU` | 卸载 BattleScene;重置所有 Autoload Manager |
| 任意战斗状态 | Input.is_action_just_pressed("pause") | `PAUSE` | 保存 _prev_stateEngine.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 = 0spawn 队列清空
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 让玩家决定装到哪个 Coretrue = 装 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 Reverbmax_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_processtick DoT 并倒计时 durationswap-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() KEYUI 显示用
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 对应的原始 SpellDeckcompile_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_COMPLETEkill_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 碰撞 AABBBulletManager 使用,用于超大碰撞体绕过 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.tscnBattleScene 挂载时创建)
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` | UpgradeChoiceUI3 张词条)| 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() KEYUI 显示用
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_valueMULTIPLY: *patch_valueOVERRIDE: =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 状态持久化、全局设置持久化
- **不负责**:游戏逻辑、排行榜 HMACEndlessRecordsManager 负责)
```gdscript
# profile_manager.gd (Autoload: ProfileManager)
# Run 存档:user://run_a.jsonA槽)+ user://run_b.jsonB槽)(权威来源: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(音量),EventBusSETTINGS_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 + PASSIVECONSUMABLE/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_typespark / 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<float>` 零拷贝 + struct 零 GCGDScript ≈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 次/帧 × 15µs = 2–10ms,直接耗尽帧预算。
**规则 2:热数据通过 `PackedFloat32Array.AsSpan()` 零拷贝共享**
GDScript Autoload 持有 `_data: PackedFloat32Array`C# 通过 `AsSpan()` 获取 `Span<float>` 原生指针,**无内存复制**
```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<int>` 收集当帧命中 ID`_PhysicsProcess` 末尾**一次性**通知 EventBus
```csharp
// 帧末单次跨语言调用(循环外)
// ⚠️ 禁止 _hitBulletIds.ToArray()——每帧 new int[] 产生 GC 分配。
// 改用可复用的类字段 Godot.Collections.Array<int> _hitBuffer_Ready() 中 new 一次):
// private readonly Godot.Collections.Array<int> _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();
}
```
**规则 4C# 组件挂为 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 对外 APIC# 热路径(_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_idSoA 槽位索引);def.lifetime 单位:秒
# SpellEvaluator、ZoneManager 调用
pass # 具体实现见 implementation_plan.md §2.2
func despawn_bullet(bullet_id: int) -> void:
# 提前回收:lifetime 置 0C# 下帧 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`121,下一空位 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
> **结论:采用独立的 ZoneManagerAutoload),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: PackedFloat32Arraystride = 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:
# 顶掉最旧的 Zoneindex 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-N2Core 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)。
商业 RogueliteHades / 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 激活层级 | 说明 |
| :--- | :--- | :--- |
| 04 | Layer 0(基础,仅鼓点 + 低音) | 常驻,即使 0 敌人 |
| 519 | Layer 1(主旋律 + 和弦) | EnemyManager.get_visible_count() ≥ 5 |
| ≥ 20 或 Boss 激活 | Layer 2(弦乐/合成铅声) | 最高战斗强度 |
| SHOP / MAIN_MENU 状态 | 独立 BGMBGM_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 indexAudioBusID 常量)的显式映射
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` 节点,最高优先级 |
| 玩家死亡时淡出所有 Bus0.5s 内 -40dB| `AudioManager.fade_out_all(duration=0.5)` |
---
## ADR-A4 — 敌人 AI 寻路方案决策 (Enemy Pathfinding)
> **结论**Wave 114 普通敌人维持当前 Boid 斥力向量(无寻路);Wave 15+ Elite 及 Boss 敌人按需引入 Godot `NavigationAgent2D`,并发上限 `MAX_PATHFINDING_ENEMIES=20`,超限降级 Boid。
>
> **S5 定案(2026-06-05P-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 规则 4C# 热路径作为对应 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) ← MultiMeshInstance2D2000+ 弹幕时动态启用)
│ ├── 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` | 无(纯常量,121|
| 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` | 无(时间缩放/GameTickimpl §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<T>("/root/XXX")` 延迟获取)。