- 审计报告 D 节 + G.2 表标注 D1/D2/D5 已修复(待运行时验收),修正 D1 修复方案为「单选枚举 + ==」(复核发现设计器 core_tab 以下标写入 feature_tags, 不宜改 2 的幂位标志) - architecture_design ADR-R5-N2 与 core_wand §1 的现状 callout 同步更新为已修复 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 KiB
模块化战术土豆 (Modular Tactical Potato) - 架构设计文档
1. 概述 (Overview)
本项目旨在开发一款基于 Godot 4.x 的 Roguelite 动作射击游戏。 核心体验结合了 Brotato (土豆兄弟) 的快节奏割草体验与 Noita 的深度法术构建系统。 架构设计的首要目标是 高性能(支持同屏大量单位与弹幕)与 极高的可扩展性(特别是武器系统的模块化)。
1.1 设计目标
- 超模块化武器系统 (Hyper-Modular Weapon System):超越 Noita 的线性构建,引入更灵活的管道流与事件钩子机制。
- 高性能战斗引擎:支持同屏 500+ 敌人,2000+ 弹幕,60FPS 稳定运行。
- 数据驱动 (Data-Driven):所有游戏内容(法术、属性、波次)完全配表化/JSON化。
2. 系统分层架构 (Layered Architecture)
采用能够严格分离数据与表现的架构模式。虽然 Godot 是节点/组件式的,但在核心战斗层我们将采用 Manager + Data 的方式来规避逐节点更新带来的开销。
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/目录。图中ConfigMgrautoload 虽已注册(project.godot),但从未被任何调用方引用,是死代码。文中后续所有res://resources/**/*.tres路径均为设计蓝图,与实际data/*.json布局不符。详见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)
这是所有"部件"的基类。
# cast_stats.gd - MODIFIER 节点的修改目标;通过 SpellContext.stats 访问
class_name CastStats
extends RefCounted
var damage_add: float = 0.0 # 累加伤害加成(所有 Dmg+ 修正器的总和)
var damage_mult: float = 1.0 # 乘算伤害系数(暴击、元素弱点等乘入)
var projectile_speed: float = 600.0 # 弹道飞行速度(像素/秒)
var spread_angle: float = 0.0 # 散射角度(度,0 = 直线)
var pierce_count: int = 0 # 穿透次数(0 = 首次命中即销毁)
var bounce_count: int = 0 # 弹射次数(0 = 不弹射)
var homing_force: float = 0.0 # 归航强度(0 = 直线,>0 向最近敌人偏转)
var projectile_size: float = 1.0 # 弹道大小系数(影响碰撞半径与贴图缩放)
var range_mult: float = 1.0 # 射程乘算系数(弹道消失前飞行距离倍率)
var crit_chance: float = 0.0 # 本次施法暴击率追加量(0.0~1.0,叠加而非覆盖)
func reset() -> void:
damage_add = 0.0; damage_mult = 1.0; projectile_speed = 600.0
spread_angle = 0.0; pierce_count = 0; bounce_count = 0
homing_force = 0.0; projectile_size = 1.0; range_mult = 1.0; crit_chance = 0.0
# spell_context.gd - 使用 RefCounted 避免 GC 压力
class_name SpellContext
extends RefCounted
# ⚠️ 注意:禁止持有 Node2D 引用——逻辑层与表现层必须解耦
# 施法者位置通过 EntityManager.get_position(caster_id) 实时查询
var caster_id: int = -1 # 施法者实体 ID
var target: Vector2 # 目标点
var stats: CastStats = CastStats.new() # 必须在声明时初始化;reset() 调用 stats.reset() 清零字段,不重新 new
var payloads: Array[ProjectileDef] = [] # 待发射的弹头定义队列
var registers: PackedFloat32Array = PackedFloat32Array([0.0, 0.0, 0.0, 0.0])
# R1, R2, R3, R4 — LOGIC_P2 功能专用
# ⚠️ 必须声明时初始化为长度 4;PackedFloat32Array() 默认长度 0,
# 直接访问 registers[0] 会触发 out-of-bounds 崩溃。
# 生命周期:携带 CoreFeatureTag.PERSISTENT_MEMORY 的 Core 跨帧保留此值;
# 非持久 Core 下,帧结束后由 SpellEvaluator 调用 registers.fill(0.0) 清零。
# 与 CastState.registers 的区别:
# SpellContext.registers → 跨帧持久寄存器(属于 SpellContext 对象)
# CastState.registers → 单次 execute() 内临时工作寄存器,每次重置
# core_wand_design.md §6 将此字段称为 "memory_bank"(同一字段)
var current_payload: ProjectileDef # 当前正在构建的弹头定义
# 池化归还时必须调用 reset(),防止脏数据污染下次施法
func reset() -> void:
caster_id = -1
target = Vector2.ZERO
stats.reset() # CastStats 内部清零
payloads.clear() # 清空弹头队列(不销毁元素,由 ProjectileDef 池负责回收)
current_payload = null
# ⚠️ registers 不在此处清零:
# 持久 Core(PERSISTENT_MEMORY)由 SpellEvaluator 决定是否保留;
# 非持久 Core 由 SpellEvaluator 在帧结束后调用 registers.fill(0.0)。
# spell_node.gd - 基类,子类重写 execute()
# 所有系统通过 SpellType 枚举访问节点类型,禁止在业务代码中直接写裸整数(0/1/2/3)。
enum SpellType {
ACTION = 0, # 产生实际飞行物或即时效果(如 spark_bolt、nuke)
MODIFIER = 1, # 修改下一个 ACTION 的属性(如 damage_plus、homing)
TRIGGER = 2, # 将后续法术打包为 SubPayload,在弹体命中时执行(子母弹核心)
LOGIC = 3, # 条件跳转、循环、寄存器读写(IF_HP_LOW、LOOP 等)
SCOPE_CLOSE = 4 # 虚节点,预编译时自动插入,标记 TRIGGER/LOGIC 作用域边界
# 不出现在 SpellRegistry 中,玩家不可购买
}
class_name SpellNode
extends RefCounted
var id: String
var type: SpellType = SpellType.ACTION # 业务代码赋值时须使用 SpellType.XXX,禁止写裸整数
var element_tags: Array[String] = [] # 元素亲和性标签,供共鸣系统(§3.4.C Resonance)进行模式匹配
# 内容示例:["tag:water"]、["tag:lightning"]、[]
# resonance_recipes.json 中 "pattern" 字段的每个元素
# 对应本字段中的一个字符串;预编译时 _check_resonance()
# 遍历 deck.nodes,比对 element_tags 中是否包含目标 tag
# 数据来源:SpellRegistry 从 res://resources/spells/*.tres 加载
var shape_icon: String = "" # ADR-C1: 无障碍形状标识,与颜色配合供色盲玩家区分元素。
# 取值约定(UIAtlas 中对应图标键):
# "triangle" → 火系 (Fire)
# "diamond" → 冰系 (Ice)
# "circle" → 水系 (Water)
# "square" → 土系 (Earth)
# "star" → 雷系 (Lightning)
# "cross" → 暗系 (Dark)
# "" → 无元素(纯伤害类)
# 渲染规则:UI 显示元素标签时必须同时绘制颜色背景 + shape_icon;
# VFXManager 在命中 VFX 上叠加 1.5× 字号的 shape_icon TextureRect。
# 禁止仅用颜色区分元素(ADR-C1 强制要求)。
# 核心执行函数:修改 Context 或产生行为,子类必须重写
func execute(ctx: SpellContext, deck: SpellDeck) -> void:
pass
# projectile_def.gd - ACTION 节点执行时填充,最终传给 BulletManager 批量生成弹体
# 通过对象池复用,不触发 GC;SpellContext.payloads 以 Array[ProjectileDef] 形式积累
class_name ProjectileDef
extends RefCounted
# 业务代码必须通过 DamageType.PHYSICAL 等常量引用,禁止直接写 0/1/2/3/4。
# 此枚举也是 BulletManager SoA 中 damage_type 字段、StatusManager DoT 分类的统一来源。
enum DamageType {
PHYSICAL = 0, # 物理伤害:受护甲(Armor)平铺减免,不受元素抗性影响
FIRE = 1, # 火焰伤害:受 attunement_fire 加成;可触发点燃/爆燃状态
ICE = 2, # 冰霜伤害:受 attunement_ice 加成;可触发冻结/减速状态
LIGHTNING = 3, # 雷电伤害:受 attunement_lightning 加成;可触发麻痹/连锁导电
POISON = 4 # 毒素伤害:受 attunement_poison 加成;施加 DoT,叠层上限受精通影响
}
# ─────────────────────────────────────────────────────────────────────────────
# ── 伤害 ──────────────────────────────────────────────────
var base_damage: float = 5.0 # 计算前基础伤害值(已含 CastStats.damage_add 累积)
var damage_mult: float = 1.0 # 乘算系数(暴击/元素弱点已合入)
var damage_type: int = DamageType.PHYSICAL # DamageType 枚举(见上方定义)
# ── 运动 ──────────────────────────────────────────────────
var speed: float = 600.0 # 初速度(像素/秒)
var direction: Vector2 = Vector2.RIGHT
var spread_angle_rad: float = 0.0 # 在 direction 基础上的随机偏转幅度
var homing_force: float = 0.0 # 每帧转向力(0 = 直线)
var acceleration: float = 0.0 # 速度加速度(可为负,做减速球)
# ── 生命周期 ──────────────────────────────────────────────
var lifetime: float = 3.0 # 最大存活时间(秒)
var pierce_remaining: int = 0 # 剩余穿透次数(0 = 首次命中销毁)
var bounce_remaining: int = 0 # 剩余弹射次数
# ── 碰撞体积 ──────────────────────────────────────────────
var radius: float = 6.0 # 碰撞圆半径(像素)
var size_mult: float = 1.0 # 外观与碰撞同步缩放系数
# ── 触发器 ────────────────────────────────────────────────
var on_hit_payload_id: int = -1 # SubPayloadRegistry ID;-1 = 无 TRIGGER
var on_expire_payload_id: int = -1 # 到期/撞墙触发;-1 = 无
var on_kill_payload_id: int = -1 # 击杀触发;-1 = 无
# ── 归属 ──────────────────────────────────────────────────
var owner_id: int = -1 # 根源实体 ID(归击杀、吸血计算)
var source_tags: int = 0 # 位掩码: PRIMARY=1, TRIGGERED=2, SUMMON=4, REFLECTED=8
var spawn_position: Vector2 # 发射原点(填充时从 caster 位置获取)
# ── 触发系数 ───────────────────────────────────────────────
var proc_rate: float = 1.0 # Proc 系数(0.0~1.0)。高频攻击(加特林)应设置为 0.1~0.3,
# 防止高攻速过度触发 on_hit 特效/技能。
# 命中时实际触发概率 = SpellNode.proc_chance × proc_rate。
# 默认 1.0(不削减),Tier 1 Action 通常无需修改。
func reset() -> void:
base_damage = 5.0; damage_mult = 1.0; damage_type = DamageType.PHYSICAL
speed = 600.0; direction = Vector2.RIGHT; spread_angle_rad = 0.0
homing_force = 0.0; acceleration = 0.0; lifetime = 3.0
pierce_remaining = 0; bounce_remaining = 0
radius = 6.0; size_mult = 1.0
on_hit_payload_id = -1; on_expire_payload_id = -1; on_kill_payload_id = -1
owner_id = -1; source_tags = 0; proc_rate = 1.0
spawn_position = Vector2.ZERO # 必须重置,否则池化复用时携带上帧发射原点
B. 法术解析器 (The Evaluator)
为了高性能,解析器必须 低 GC 压力 (Low-GC)。在施法计算帧,尽量避免分配新对象。
- 使用预分配的
SpellContext对象池(Array作为栈管理空闲对象)。 - 使用
Array(作为栈)模拟递归,防止深层递归爆栈。
C. 高级特性:动态插槽与逻辑门
- Logic Spells (逻辑法术):引入
IfHPBelow,OnKillAction,EveryNbShot等逻辑块,让玩家实现“如果血量低于30%,则发射吸血导弹”的构建。 - Variable Storage (变量存储):允许法术在法杖上写入/读取临时变量(例如:记录连击数)。
3.3 扩展性设计
所有法术行为通过 Strategy Pattern (策略模式) 实现。 新增一个法术只需:
- 在 JSON(或 Godot
.tresResource)中定义 ID 和贴图路径。 - 实现一个继承自
SpellNode的 GDScript 类。 - 在注册表中注册。
3.4 进阶构建机制 (Advanced Mechanics) - 玩法增强
为了超越“线性堆砌”的枯燥感,架构支持以下三种深度玩法机制。
MVP 功能边界说明:
- P0(必须实现):4类 SpellNode(ACTION / MODIFIER / TRIGGER / LOGIC)+ LINEAR Core。
- P1(迭代加入):拓扑插槽系统(MATRIX/CIRCUIT Core)、共鸣系统。
- P2(可选高级内容):状态寄存器 + 条件跳转——此功能面向极硬核玩家,仅通过特殊 Core 解锁,不在新手 UI 中暴露。
A. 拓扑插槽系统 (Topology Slots)【P1】
核心(Core)不再仅仅是一个列表,它可以是一个 2D 网格 或 电路板。
- adjacency_bonus (邻接加成):某些插槽有物理连接。例如,将 [火元素] 放在 [高压槽] 旁边,会自动获得 +20% 范围。
- Circuit Logic (电路逻辑):法术流不再只是从左到右。核心板可以有分叉路口,玩家需要用 [分流器法术] 将能量流引导到不同的分支。
- 详见 docs/design/core_wand_design.md。
拓扑适配层 (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。
| Core 类型 | 扁平化策略 |
|---|---|
| LINEAR | 无需处理,直接输出原始 SpellNode 序列 |
| MATRIX_2X4 | 扫描邻接槽对(slot[i] 与 slot[i + cols]),若满足 adjacency_bonus 条件,在对应 ACTION 前插入隐式 MODIFIER 节点;最终序列仍按行→列顺序线性排列 |
| CIRCUIT | 对有向图做 拓扑排序,每个分叉点展开为独立 SubPayload 并注册到 SubPayloadRegistry,分叉处插入 LOGIC_FORK 指令批量触发;主链保持线性 |
# spell_evaluator.gd(预编译入口 + 运行时配置)
# compile_wand:拓扑扁平化策略入口,仅在玩家关闭背包/装备 Core 时调用,非热路径。
var _max_ops: int = 40 # 每次施法的法术节点执行上限(= MAX_OPS_PER_CPU × cpu_limit)
func update_max_ops(cpu_limit: int) -> void:
# UpgradeSystem.apply_choice() 在被动词条变更 cpu_limit 后调用
# MAX_OPS_PER_CPU = 8(基准值,SpellEvaluator 常量)
const MAX_OPS_PER_CPU: int = 8
_max_ops = clamp(MAX_OPS_PER_CPU * cpu_limit, 8, 200)
# ⚠️ 实现现状(2026-07-20):SpellEvaluator 从不调用本函数、从不读 cpu_limit;
# 实际硬编码 max_ops = MAX_OPS_PER_CPU * 5(代码常量为 40,本处写 8,文档内部亦不一致)。
# 结果:升级 CPU 词条对施法预算无效——本设计更优,代码待修。见审计报告 D 节。
func compile_wand(core: CoreDefinition, raw_deck: SpellDeck) -> CompiledDeck:
match core.topology:
CoreDefinition.LINEAR:
return CompiledDeck.new(raw_deck.nodes)
CoreDefinition.MATRIX_2X4:
return _flatten_matrix(raw_deck, core)
CoreDefinition.CIRCUIT:
return _flatten_circuit(raw_deck, core)
return CompiledDeck.new([]) # 未知拓扑降级为空 Deck,防止 match 无 default 返回 null
func _flatten_matrix(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck:
# 仅遍历 Row A(i < grid_cols);Row B 槽位法术不作为独立执行节点,仅用于邻接加成注入。
var out: Array[SpellNode] = []
var row_a_count: int = core.grid_cols # Row A 的槽数 = grid_cols(如 2×4 矩阵中 = 4)
for i in row_a_count:
var adj_idx: int = i + core.grid_cols # Row B 中与 slot[i] 垂直对齐的槽索引
# 空槽守卫:deck.nodes[i] 可能为 null(槽为空)
var row_a_node: SpellNode = deck.nodes[i] if i < deck.nodes.size() else null
var row_b_node: SpellNode = deck.nodes[adj_idx] if adj_idx < deck.nodes.size() else null
if row_a_node == null:
continue # 空槽:Row A 无法触发,跳过(Row B 对应槽的邻接加成也无效)
if row_b_node != null and _has_adjacency_bonus(row_a_node, row_b_node):
# 注入隐式 MODIFIER 节点,效果见 core_wand_design.md §2.2 邻接加成效果表
out.append(_make_adjacency_mod(row_a_node, row_b_node))
out.append(row_a_node) # 仅追加 Row A 节点到执行序列
# Row B 节点(row_b_node)不追加 → 不独立执行,仅作为邻接加成来源
return CompiledDeck.new(out)
# 对 CIRCUIT 拓扑的有向图做 Kahn 算法拓扑排序,主链保持线性;
# 分叉节点展开为独立 SubPayload 并注册到 SubPayloadRegistry。
# edges 读自 core.edges(顶层字段);CoreDefinition 须声明 @export var edges: Array = [](见 core_wand_design.md §1)
# 性能说明:仅在玩家关闭背包时触发(非 _physics_process 热路径),slot_count ≤ 16,O(V+E) < 0.1ms。
func _flatten_circuit(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck:
# 1. 从 core.edges 读取顶层有向边列表;若为空,降级为 LINEAR 处理(见 core_wand_design.md §2.3)
var edges: Array = core.edges
if edges.is_empty():
push_warning("_flatten_circuit: core.edges 为空,降级为 LINEAR 处理(Core=%s)" % core.id)
return CompiledDeck.new(deck.nodes)
# 2. Kahn 算法:BFS 拓扑排序(保证无环时的确定性线性化顺序)
var in_degree: Array[int] = []
var adj: Array = [] # adj[i] = Array[int] 出边目标
in_degree.resize(core.slot_count)
adj.resize(core.slot_count)
for i in core.slot_count:
in_degree[i] = 0
adj[i] = []
for e in edges:
adj[e["from"]].append(e["to"])
in_degree[e["to"]] += 1
# ⚠️ Kahn 循环结束后 in_degree 全归零;_collect_branch_path() 需要原始入度来判断汇聚点
# (orig_in_degree[nxt] > 1 表示多入边)。在 Kahn 循环前保存副本供分支收集函数使用。
var orig_in_degree: Array[int] = in_degree.duplicate() # Kahn 前原始入度快照
var queue: Array[int] = []
for i in core.slot_count:
if in_degree[i] == 0:
queue.append(i)
var topo_order: Array[int] = []
while not queue.is_empty():
var cur: int = queue.pop_front()
topo_order.append(cur)
for nxt: int in adj[cur]:
in_degree[nxt] -= 1 # 修改工作副本(orig_in_degree 保留原始值供汇聚点检测)
if in_degree[nxt] == 0:
queue.append(nxt)
if topo_order.size() != core.slot_count:
push_error("_flatten_circuit: 检测到环路,无法线性化!Core=%s" % core.id)
return CompiledDeck.new([]) # 返回空 Deck,游戏不崩溃但该法杖无法施法
# 3. 按拓扑顺序输出节点,出度 > 1 的槽注入 LOGIC_FORK 指令
var out: Array[SpellNode] = []
# in_branch_payload 记录已纳入某 SubPayload 的槽索引,防止主链循环将分支节点重复追加执行
var in_branch_payload: Dictionary = {}
for slot_idx in topo_order:
# 跳过已被分支 SubPayload 收纳的节点,防止双重执行
if in_branch_payload.has(slot_idx):
continue
var node: SpellNode = deck.nodes[slot_idx] if slot_idx < deck.nodes.size() else null
if node == null:
continue # 空槽跳过
if adj[slot_idx].size() > 1:
# 分叉检测基于出度(出边数 > 1);"splitter" tag 保留为 UI/编辑器标注用途,不参与执行判断
# 分叉槽自身的法术节点先行追加(分叉前执行,通常为空但允许 MODIFIER/ACTION)
out.append(node)
# 为每条出边收集完整分支路径(含多节点分支,_collect_branch_path 沿单出边延伸直到叶/汇聚点)
var branch_payload_ids: Array[int] = []
for neighbor_idx in adj[slot_idx]:
var branch_path: Array[SpellNode] = _collect_branch_path(
neighbor_idx, adj, orig_in_degree, deck, in_branch_payload)
# orig_in_degree:Kahn 前快照,确保汇聚点检测正确
# in_branch_payload:共享引用,函数内登记已访问槽,主链循环可跳过
if not branch_path.is_empty():
branch_payload_ids.append(SubPayloadRegistry.register(branch_path))
if not branch_payload_ids.is_empty():
var fork_node: SpellNode = SpellNode.new()
fork_node.type = SpellType.LOGIC
fork_node.id = "LOGIC_FORK"
fork_node.set_meta("fork_branch_ids", branch_payload_ids)
out.append(fork_node)
else:
out.append(node)
return CompiledDeck.new(out)
# 从 start_idx 沿单出边路径收集节点序列,直到:
# 叶节点(出度 0)、汇聚点(orig_in_degree > 1,属于主链或另一分支)、嵌套分叉(出度 > 1,递归处理)
# orig_in_degree:必须传 Kahn 前的原始入度副本,用于汇聚点判断
# in_branch_payload:共享字典,函数内每访问一个槽即写入,消除主链双重执行
func _collect_branch_path(start_idx: int, adj: Array, orig_in_degree: Array,
deck: SpellDeck,
in_branch_payload: Dictionary) -> Array[SpellNode]:
var path: Array[SpellNode] = []
var cur: int = start_idx
var visited: Dictionary = {}
while cur >= 0 and not visited.has(cur):
visited[cur] = true
in_branch_payload[cur] = true # 登记此槽已纳入 SubPayload,主链循环将跳过
if cur < deck.nodes.size() and deck.nodes[cur] != null:
path.append(deck.nodes[cur])
if adj[cur].size() > 1:
# 嵌套分叉:为每条子出边递归收集完整路径并注册 SubPayload,
# 然后将嵌套 LOGIC_FORK 节点插入当前 path。
var nested_branch_ids: Array[int] = []
for nxt_idx: int in adj[cur]:
var nested_path: Array[SpellNode] = _collect_branch_path(
nxt_idx, adj, orig_in_degree, deck, in_branch_payload)
if not nested_path.is_empty():
nested_branch_ids.append(SubPayloadRegistry.register(nested_path))
if not nested_branch_ids.is_empty():
var nested_fork: SpellNode = SpellNode.new()
nested_fork.type = SpellType.LOGIC
nested_fork.id = "LOGIC_FORK"
nested_fork.set_meta("fork_branch_ids", nested_branch_ids)
path.append(nested_fork)
break # 嵌套分叉后路径在各子 SubPayload 中独立延伸,本路径就此结束
elif adj[cur].size() == 1:
var nxt: int = adj[cur][0]
if orig_in_degree[nxt] <= 1: # 单入边,继续延伸
cur = nxt
else:
break # 汇聚点(多入边),停止;该节点由主链处理
else:
break # 叶节点(出度 0)
return path
E3 解决方案:MATRIX 邻接加成通过
_flatten_matrix在预编译阶段注入隐式 MODIFIER 节点,SpellEvaluator 运行时不感知 Core 拓扑,邻接触发与普通 MODIFIER 执行路径完全一致。
B. 状态寄存器与图灵完备 (State Registers & Turing Completeness)【P2 可选】
⚠️ 设计注意:本功能仅通过特殊 Core(persistent_memory / circuit_fork)解锁,新手不会接触寄存器和跳转指令。
为了支持硬核玩家实现真正的“图灵完备”构建,架构预留对状态存储、条件跳转 和 循环的支持。
-
Registers (寄存器):
- 在
SpellContext中引入MemoryBank,提供 4 个 Float 寄存器 (R1,R2,R3,R4)。 - 寄存器在同一帧内所有法术间共享,甚至可以跨帧持久化(如果法杖配置了 Persistent Memory 核心)。
- 在
-
Instruction Set (指令集法术):
- OPS:
Add R1, 1(加法),Set R2, HP_Percent(赋值). - JUMP:
JumpIf R1 > 10, Label_A(条件跳转到标签A). - LABEL:
Label_A(标记跳转点).
- OPS:
-
Recursion Control (递归控制):
- 为了防止死循环 (
While(true)), 解释器引入MaxOpLimit(最大操作数限制,例如 100 ops/frame)。超过限制强制中断并在此帧失效。
- 为了防止死循环 (
-
实战应用:
- 计数器: 每射击 3 次,第 4 次发射强力火球。
- 动态模式切换: 根据敌人距离 (
R1 = EnemyDistance),如果近则跳转到 [霰弹逻辑],如果远则跳转到 [狙击逻辑]。
C. 共鸣系统 (Resonance System)【P1】
在预编译阶段 (Pre-compile Phase) 进行模式匹配。
- 如果检测到 [水] 和 [电] 法术在执行链中紧邻,架构自动插入一个隐藏的 [导电反应] 中间件。
- 这允许设计隐藏配方(Hidden Recipes),鼓励玩家探索特定组合。
- 发现机制:法术卡片上显示元素亲和性标签(如 ⚡ 雷、💧 水),引导玩家尝试组合,而非完全盲猜。
- 缓存失效:玩家在商店修改法术顺序时,自动触发重新预编译,确保共鸣结果与当前 Deck 始终一致。
- 共鸣配方存储 (Recipe Schema):配方存储于
res://resources/resonance_recipes.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 不改变元素属性流向)。
# 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) 实现:# spell_deck.gd(运行时执行游标) # _consumed: PackedByteArray,长度 = nodes.size(),初始全 0 # 预编译阶段在共鸣注入时,将被消费的槽位 index 写入 _consumed_indices 列表。 # 运行时 pop() 跳过 _consumed[cursor] != 0 的槽位: func pop() -> SpellNode: while _cursor < _nodes.size() and _consumed[_cursor] != 0: _cursor += 1 # 跳过已消费槽位 if _cursor >= _nodes.size(): return null var node := _nodes[_cursor] _cursor += 1 return node消费掩码初始化:
SpellEvaluator.compile_wand()完成共鸣注入后,对每个consume_inputs: true配方, 将参与配方的两个原始节点的 index 写入_consumed(值为 1),同时在对应位置插入共鸣结果节点。_consumed是SpellDeck(运行时对象)的字段,CompiledDeck(只读预编译缓存)不含此字段, 每次从 CompiledDeck 构造 SpellDeck 时重新初始化(避免消费状态跨轮次残留)。
R4-C1 规范(触发范围权威定义):
"adjacent":标准触发范围,绝大多数配方使用此选项(如"水+雷=等离子风暴")。game_design.md §3.3.C所述"执行链中直接相邻"即对应此选项。"anywhere_in_deck":跨距触发,仅用于极少数传说级全局共鸣配方, 预编译时对整个 CompiledDeck 做 O(N²) 全局扫描,且自动标记为稀有以上配方。 新增此类配方时需在 JSON 中追加"rarity": 3(传说)以防止轻易触发。- 两者不冲突,同一配方文件中可同时存在不同
match值的条目,SpellEvaluator 在预编译阶段 先处理所有adjacent配方,再统一处理anywhere_in_deck配方。
4. 高性能战斗架构 (High-Performance Combat Architecture)
为了实现“同屏 2000+ 弹幕”和“复杂逻辑构建”的双重目标,本架构采用 Data-Oriented (面向数据) 与 Hybrid-ECS 相结合的策略,最大化 CPU 缓存命中率并消除 GC 压力。
4.1 核心原则:低 GC (Low-GC Principle)
在核心战斗循环 (Game Loop) 中,尽量避免频繁实例化新对象。
- Context Pooling:
SpellContext等高频对象在关卡加载时预分配并放入Array池,使用时复用,用完调用reset()方法归还。- 预分配大小:
SPELL_CONTEXT_POOL_SIZE = 32。依据:单帧最坏情况 ≈ MAX_BULLETS(2000) × 平均 proc_rate(0.05) × 命中概率(0.3) ≈ 30 次 execute_sub 并发触发,32 个实例可覆盖此场景且无 GC 分配。若pool.pop_back()返回 null(池耗尽),额外实例化 1 个并发出push_warning("SpellContextPool: exhausted"),不崩溃。⚠️ 溢出实例归还规范:溢出实例使用完毕后须调用pool.push_back(ctx)归还;若归还时pool.size() >= SPELL_CONTEXT_POOL_SIZE,直接丢弃(GC 处理权交还 Godot),不超量囤积。禁止遗弃,避免 Endless 模式长局游离实例积累导致 GC 压力持续上升。
- 预分配大小:
- Static Temporaries: GDScript 可在模块顶层声明静态变量 (
static var _tmp_vec2: Vector2),避免向量计算产生中间对象。 - PackedFloat32Array (SoA): 子弹的热数据(位置、速度)存储在
PackedFloat32Array中,保证内存连续性,提升缓存命中率。
4.2 实体管理:ECS-Lite
虽然 Godot 是基于节点/组件的,但在海量单位管理上,我们将剥离节点的逐帧逻辑。
- Manager-Based Logic: 子弹和敌人的
Node2D节点不运行自身的_process,逻辑全部由中央 Manager(Autoload 单例)统一驱动。- 也就是:
Node2D仅仅作为渲染容器,负责同步position和播放动画。
- 也就是:
- Centralized Loop (中央循环):
-
BulletManager维护一个紧凑的PackedFloat32Array(SoA 布局)。 每颗子弹占用 BULLET_STRIDE = 12 个 float,在数组中偏移量 =bullet_id × BULLET_STRIDE:偏移 字段 说明 +0 x世界坐标 X(像素) +1 y世界坐标 Y(像素) +2 vx速度 X(像素/秒) +3 vy速度 Y(像素/秒) +4 lifetime剩余存活时间(秒),归零时销毁 +5 radius碰撞圆半径(像素) +6 base_damage命中基础伤害值 +7 damage_mult伤害乘算系数 +8 damage_typeDamageType 整数(DamageType 枚举,0=PHYSICAL…4=POISON) +9 owner_id根源实体 ID(float 存 int,精度足够 24-bit ID) +10 source_tags位掩码(PRIMARY/TRIGGERED/SUMMON/REFLECTED) +11 acceleration速度加速度(像素/秒²,0=匀速) 冷数据(非热路径) 存储于
_bullet_contexts: Dictionary(key=bullet_id),包含:pierce_remaining,bounce_remaining,homing_force,on_hit_payload_id,on_expire_payload_id,on_kill_payload_id,proc_rate,visited_targets。 正弦弹道等额外字段(wave_frequency,wave_amplitude)也存于此 Dictionary, 不占用 SoA 热数组空间,仅在命中/特殊弹道帧查询。职责分离:
ProjectileDef是生成时配置模板,在SpellEvaluator.execute阶段由 ACTION 节点填充;_bullet_contexts[bullet_id]是运行时可变冷状态,由BulletManager.spawn(def)从ProjectileDef拷贝创建。 两者字段名相同但生命周期不同:ProjectileDef可安全复用(对象池 reset);_bullet_contexts[id]随子弹销毁时一同移除(_bullet_contexts.erase(bullet_id))。 -
在
_physics_process(delta)中,直接遍历 Array 进行物理积分,速度比遍历 Node Tree 快一个数量级。 -
Dirty Sync: 仅当物体在屏幕视口内,且逻辑坐标发生位移时,才去同步
Node2D.position。 -
Homing 批量查询优化:若
homing_force > 0的子弹数量为 N,每颗独立调用SpatialGrid.find_nearest将产生 N 次散列查询(N=500 时每帧 500 次)。解决方案:在_physics_process开头建立敌人位置快照,所有 homing 子弹共用:# ⚠️ _enemy_pos_snapshot 必须声明为 BulletManager 类成员(预分配复用), # 禁止在 _physics_process 中用 var 声明(每帧分配新 PackedVector2Array,产生 GC 压力)。 # 类成员声明(BulletManager 顶部): # var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array() # # BulletManager._physics_process 开头,O(M),M = 存活敌人数量 EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot) # fill_pos_snapshot() 将所有存活敌人 [x, y] 写入传入的 PackedVector2Array(复用已分配内存,不触发 GC) # 对比旧写法:var _enemy_pos_cache = EnemyManager.get_pos_snapshot() 每帧 new 一个新数组 # homing 计算:从快照线性扫描,比散列查询更 cache-friendly for idx in _homing_indices: # _homing_indices: PackedInt32Array,本帧 homing 子弹列表 var bx: float = _data[idx * BULLET_STRIDE]; var by: float = _data[idx * BULLET_STRIDE + 1] var nearest := _find_nearest_pos(bx, by, _enemy_pos_snapshot) # O(M) 线性扫描快照 # 计算转向力并更新 vx/vy…M=1000 敌人、N=500 homing 子弹时,总计 500K 次向量运算,C# 热路径约 0.2ms/帧,可接受。
-
EnemyManager 对外查询接口:以下函数供 BulletManager、DropManager、BossManager、AudioManager 调用,均为 GDScript 侧接口(轻量读操作,非热路径):
# enemy_manager.gd — 对外查询接口(热路径内不调用,仅事件驱动 / 轮询路径使用) func fill_pos_snapshot(out: PackedVector2Array) -> void: # BulletManager homing 共用快照;复用传入数组内存(P6-N25) out.resize(_active_count) for i in _active_count: out[i] = Vector2(_data[i * ENEMY_STRIDE], _data[i * ENEMY_STRIDE + 1]) func get_last_position(entity_id: int) -> Vector2: # DropManager 在 ENEMY_KILLED 事件中查询死亡位置(EnemyManagerCs 死亡时缓存到字典) return _death_position_cache.get(entity_id, Vector2.ZERO) func get_type(entity_id: int) -> String: # DropManager / BossManager 查询敌人类型 ID(对应掉落表 key / boss_config key) return _entity_type_map.get(entity_id, "") # { entity_id: String },spawn 时写入 func get_hp_percent(entity_id: int) -> float: # BossManager 多阶段 HP 切换使用;entity_id 存在则返回 [0.0, 1.0],否则返回 0.0 var idx: int = _entity_index_map.get(entity_id, -1) if idx < 0: return 0.0 var hp := _data[idx * ENEMY_STRIDE + 4] # slot +4: hp(权威来源 implementation_plan §2.3.C) var hp_max := _data[idx * ENEMY_STRIDE + 5] # slot +5: hp_max return hp / max(hp_max, 0.001) func get_visible_count() -> int: # AudioManager 动态音乐层轮询;返回当前在 Camera AABB 内的存活敌人数 return _visible_count # EnemyManagerCs 的 LOD 循环维护此计数,O(1) 读取 func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2: # BulletManager.get_nearest_enemy_pos() 调用;线性扫描 _enemy_pos_snapshot,O(M) # 与 homing 子弹同一快照,无额外查询开销;仅在发射时调用,非每帧热路径 var best_pos := Vector2.ZERO var best_dist := max_dist * max_dist # 比较平方距离,避免 sqrt for i in range(_enemy_count): if _visible_flags[i] == 0: continue # 跳过屏外(可选:也可全扫) var px := _data[i * ENEMY_STRIDE + 0] var py := _data[i * ENEMY_STRIDE + 1] var d2 := (px - origin.x) * (px - origin.x) + (py - origin.y) * (py - origin.y) if d2 < best_dist: best_dist = d2 best_pos = Vector2(px, py) return best_posEnemyManager SoA 完整槽位布局(权威来源:
implementation_plan.md §2.3.C,P6-N45):ENEMY_STRIDE = 8 [ x, y, vx, vy, hp, hp_max, faction_and_type, status_bits ] 0 1 2 3 4 5 6 7+4 hp:当前生命值;+5 hp_max:最大生命值(BossManager 的get_hp_percent读取此两槽)+6 faction_and_type:高 16 位 = faction(0=敌方, 1=友方);低 16 位 = enemy_type_id+7 status_bits:燃烧/冰冻/中毒等位掩码(精确 DoT 计时存_enemy_contexts冷数据)_death_position_cache、_entity_type_map、_entity_index_map为类成员Dictionary,spawn/kill 时 GDScript 维护。
-
自动瞄准目标选取算法:法杖自动开火时,发射方向需要选取"目标敌人"。目标选取逻辑集中在
PlayerManager.get_aim_target()中,优先级如下:- 手柄/鼠标显式瞄准:若存在显式输入方向(
aim_vector.length() > 0.3),直接使用该方向,不进行目标锁定。 - 最近敌人(默认):从
_enemy_pos_snapshot线性扫描,取欧氏距离最小的存活敌人位置,作为开火方向。时间复杂度 O(M),与 Homing 快照共用,无额外查询。 - 自定义优先级扩展(P2):商店购买"目标优先级"被动时(如"优先最低 HP"、"优先最近"),
PlayerManager.aim_priority属性切换选取算法,但当前 P0/P1 阶段固定为最近敌人。
# 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 属尚未同步的旧设计。详见 审计报告。
4.4 渲染优化
子弹渲染策略依据同屏数量分三档,Area2D 与 MultiMeshInstance2D 不共存——MultiMesh 绕过节点树,意味着子弹无法挂载 Area2D。因此两者适用于不同的弹幕规模区间:
⚠️ 实现现状 (2026-07-20 审计):下表三档未实现。现状:
MultiMeshInstance2D从第 1 帧起单档常开(无 Node2D 池、无 <200/200-2000 档),子弹与敌人均走 MultiMesh。同步是 GDScript 逐实例set_instance_transform_2d循环(非文档的 C#SetBuffer批量上传);子弹无按型颜色(统一modulate),敌人有 per-instance color。既然碰撞已 SpatialGrid-only,Node 池档位已无意义,此简化为代码更优。详见 审计报告。
| 同屏弹幕数 | 渲染方案 | 碰撞方案 | 说明 |
|---|---|---|---|
| < 200 | Node Pool (Node2D) | Area2D | 全功能,支持粒子特效、信号回调 |
| 200–2000 | Node Pool + Dirty Sync | Area2D | 仅视口内节点同步 position |
| 2000+ | MultiMeshInstance2D | 自定义 SpatialGrid | 完全绕过节点树,牺牲特效换取帧率 |
-
Node Pooling: 严格的节点池管理(使用 Array 存储空闲节点,Node.process_mode = DISABLED 代替真实销毁)。
-
MultiMeshInstance2D: 仅在同屏弹幕超过 2000 且切换为 SpatialGrid 碰撞时启用。启用后 BulletManager 不再为每颗子弹维护 Node 实例,改为直接更新
MultiMesh.transform_array。SoA 索引 ↔ MultiMesh transform_array 索引映射规则:
BulletManager 的
_data: PackedFloat32Array使用 紧凑活跃列表(Compact Active List) 布局:索引 0 到_active_count - 1始终是存活子弹,dead-and-removed 子弹用 swap-and-pop 填入空位。MultiMesh 的instance_count始终等于_active_count,两者共享同一索引——SoA 中第 i 个子弹对应 MultiMesh 第 i 个实例变换。# 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 摘要)。详见 审计报告。
| 系统 | 语言 | 预算上限 | S0 实测值 | 说明 |
|---|---|---|---|---|
BulletManagerCs._PhysicsProcess |
C# | 2.0ms | 0.37ms | 2000 子弹 GDScript SoA 积分(P-S0-01;C# 热路径 S1 填充后预计更低) |
EnemyManagerCs._PhysicsProcess |
C# | 2.0ms | 0.23ms | 1000 敌人直线追踪 + fill_pos_snapshot(P-S0-02;Boid C# S1 填充) |
SpatialGridCs 重建 + query_circle |
C# | 1.5ms | < 0.001ms | GDScript stub(返回空数组);C# SpatialGridCs 实例化后 S1 回填实测 |
SpellEvaluatorCs.execute_compiled |
C# | 1.0ms | — | 高频施法内层 while 循环(S1 起填入) |
StatusManagerCs._PhysicsProcess |
C# | 1.5ms | — | 200+ DoT 逐 tick(S4 验收后填入,P-S4-02;与 implementation_plan §2.4 统一) |
ZoneManagerCs._PhysicsProcess |
C# | 1.0ms | — | 64 zones × SpatialGrid 查询(S5 验收后填入) |
| GDScript 游戏循环(WaveManager / EventBus dispatch) | GDScript | 1.5ms | — | 事件分发 + 波次状态机 |
| MultiMesh transform 上传 CPU→GPU | Godot 渲染器 | 1.5ms | — | 2000+ 子弹时(S6 验收后填入) |
| Godot 渲染器 Draw Call 基线 | 引擎 | 2.0ms | — | 背景 + 角色 + HUD(S1 场景建立后填入) |
| 保留余量(突发帧:GC、资源加载) | — | 1.17ms | — | — |
| 合计 | 16.67ms | ≈ 0.60ms(S0 GDScript 基线,2000弹+1000敌;FPS 180) |
S0 实测摘要(2026-06-04):机器 RTX 2060;Godot 4.6.2 stable mono;Windows 10。2000 子弹 + 1000 敌人同屏,GDScript fallback 运行(C# 热路径骨架未激活)。
physics_frame_time_msec(Godot Monitor)= 0.050.13ms,FPS 稳定 145180,远超 60fps 目标。R-01/R-03 风险已消除。SpatialGrid C# 实例化与 AsSpan() 零拷贝基准在 S1 阶段随 BulletManagerCs 真实热路径一同验收(P-S0-06/P-S0-07 S1 延续项)。
验收规则:
- S0 联合基准:
BulletManagerCs+EnemyManagerCs+SpatialGridCs三项合计实测 < 6ms(覆盖 P-S0-01/02/04)。 - 超标触发流程:若任一系统实测超出预算上限,该切片不得进入下一切片;立即检查是否违反 ADR-L1 规则 1(内层循环
Call())或规则 2(未使用AsSpan()零拷贝)。 - 回填规范:每个新切片的验收标准中对该切片新增系统补充帧时间目标;S6 发布前所有"—"必须替换为实测值。
4.6 内存预算表 (Memory Budget)
目标:确保在目标平台(PC 和未来 Nintendo Switch)的内存占用在可接受范围内。Switch 主内存总量 4GB,系统 + 驱动常驻约 1.5GB,游戏可用约 2.5GB;PC 目标 RSS < 512MB。
| 内存类别 | PC 预算 | Switch 预算 | 说明 |
|---|---|---|---|
| 代码 + GDScript VM + C# CLR | 80MB | 200MB | Mono 运行时常驻较大 |
| 纹理资源(Atlas + VFX + UI) | 150MB | 400MB | 压缩格式:PC=DXT5,Switch=ASTC4×4 |
| 音频资源(BGM 流式 + SFX 预加载) | 30MB | 80MB | BGM 流式播放,SFX 池全量预加载 |
| BulletManager SoA(2000 子弹) | < 1MB | < 1MB | BULLET_STRIDE=12 × 2000 × 4B ≈ 96KB |
| EnemyManager SoA(1000 敌人) | < 1MB | < 1MB | ENEMY_STRIDE=8 × 1000 × 4B ≈ 32KB |
| SpellContext 对象池(32 实例) | < 1MB | < 1MB | 每实例约 2KB(含 payloads Array) |
| Node Pool(子弹节点 2000 个) | 20MB | 50MB | 每 Node2D 约 10KB;MultiMesh 激活后降至 ~0 |
| VFX 粒子节点池(200 个) | 5MB | 10MB | MAX_ACTIVE_VFX=200 |
| DPS 环形缓冲区 + 其他运行时 | < 1MB | < 1MB | 各 PackedFloat 数组合计 |
| 存档文件(user://) | < 1MB | < 1MB | JSON 明文 < 100KB;含 HMAC 签名 |
| 合计(估算) | ~290MB | ~745MB | Switch 远低于 2.5GB 限制 |
验收规则:
- S6 P0 验收 P-S6-07:Godot Profiler → Memory 标签页实测 RSS:PC < 512MB,Switch 目标 < 2.5GB。
- 热场景峰值:Wave 20 Boss 战(最大同屏实体数)测量内存峰值,作为 S6 发布基线。
- 纹理内存监控:
RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TEXTURE_MEM_USED)不超过 PC 预算的 150MB。
5. 游戏循环与系统集成 (Game Loop & System Integration)
5.1 GameCycleManager 状态机 (FSM)
⚠️ 实现现状 (2026-07-20 审计):
GameCycleManagerAutoload 不存在。顶层状态机现内嵌在场景子节点combat_manager.gd,仅 5 态(INIT/BATTLE/SETTLEMENT/SHOP/GAME_OVER),缺文档的 WAVE_INTRO 开场、WAVE_RESULT 升级卡结算、GAME_CLEARED 通关、PAUSE 独立态(暂停改由get_tree().paused处理)。GAME_STATE_CHANGEDpayload 用字符串态名而非枚举。同理 §5.10 的UIManagerAutoload 也不存在——全部 UI 在combat_s2.gd程序化构建,无HUD.tscn/shop.tscn等场景,DamageNumber飘字未实现。下文 GameCycleManager/UIManager 相关设计保留作目标蓝图。详见 审计报告。
GameCycleManager 是游戏最顶层的协调者,持有全局状态机并驱动各 Manager 的生命周期。
# game_cycle_manager.gd (Autoload: GameCycleManager)
enum GameState {
MAIN_MENU = 0, # 主菜单(初始/死亡后/通关后回到此状态)
LOADING = 1, # 场景加载过渡(ResourceLoader 异步加载战斗场景)
SHOP = 2, # 商店/背包阶段(波次间)
WAVE_INTRO = 3, # 波次开场动画(约 1.5s,Boss 波有特殊演出)
WAVE = 4, # 战斗进行中
WAVE_RESULT = 5, # 本波结算(经验弹算、升级选择 UI)
GAME_OVER = 6, # 死亡结算界面
GAME_CLEARED = 7, # 通关结算(Wave 20 Boss 击杀后)
PAUSE = 8, # 暂停(可从 WAVE/SHOP 进入,恢复后返回原状态)
}
var _state: GameState = GameState.MAIN_MENU
var _prev_state: GameState = GameState.MAIN_MENU # 用于 PAUSE 恢复
var wave_num: int = 0 # 当前波次(UIManager / WaveManager 均读取)
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
# _rng 在每次 Run 开始时重置:_rng.seed = hash(Time.get_ticks_msec())
# UpgradeSystem.build_choices(wave_num, _rng) 与 ShopManager 独立使用各自 seed,互不干扰
func transition_to(next: GameState) -> void:
_on_exit(_state)
_prev_state = _state
_state = next
_on_enter(next)
func get_state() -> GameState: return _state
状态转换表
| 当前状态 | 触发条件 | 目标状态 | 关键副作用 |
|---|---|---|---|
MAIN_MENU |
玩家点击"开始游戏" | LOADING |
异步加载 BattleScene.tscn |
LOADING |
场景加载完成信号 | SHOP |
wave_num=1;调用 ShopManager.open_shop(wave_num) |
SHOP |
玩家点击"出发" | WAVE_INTRO |
PlayerManager.compile_all_wands();WaveManager.prepare_wave(wave_num) |
WAVE_INTRO |
开场动画播放完毕(1.5s Timer) | WAVE |
WaveManager.start_wave();BulletManager.reset();EnemyManager.reset() |
WAVE |
EventID.WAVE_COMPLETE 收到 |
WAVE_RESULT |
经验结算;存档(ADR-A2 §2 波次结束自动存档) |
WAVE |
EventID.PLAYER_DIED 收到 |
GAME_OVER |
统计数据收集;EndlessRecordsManager 不更新 |
WAVE_RESULT |
wave_num < 20,玩家确认升级 | SHOP |
wave_num++;ShopManager.restock(wave_num) |
WAVE_RESULT |
wave_num == 20 且 Boss 已击杀 | GAME_CLEARED |
EndlessRecordsManager 更新;Steam成就触发 |
WAVE_RESULT |
wave_num > 20(Endless模式) | SHOP |
wave_num++;ShopManager.restock(wave_num) |
GAME_OVER / GAME_CLEARED |
玩家点击"返回菜单" | MAIN_MENU |
卸载 BattleScene;重置所有 Autoload Manager |
| 任意战斗状态 | Input.is_action_just_pressed("pause") | PAUSE |
保存 _prev_state;Engine.time_scale = 0 |
PAUSE |
再次按暂停键 | _prev_state |
Engine.time_scale = 1 |
Manager Reset 协议
每次从 GAME_OVER/GAME_CLEARED 返回 MAIN_MENU 时,GameCycleManager 负责按顺序重置所有 Autoload:
func _reset_all_managers() -> void:
# 顺序重要:先停止所有计算,再清理数据,最后重置 UI
BulletManager.reset() # 清空所有子弹 SoA 数据
EnemyManager.reset() # 清空所有敌人 SoA 数据
MinionManager.reset() # 清空所有召唤物
ZoneManager.reset() # 清空所有地面效果
StatusManager.reset() # 清空所有状态效果
SpellEvaluator.reset() # 清空 SubPayloadRegistry
DamageContextPool.reset() # 归还所有在途 context(防止下局 context 泄漏)
PlayerManager.reset() # 重置血量/位置/蓄力状态
WaveManager.reset() # wave_num = 0;spawn 队列清空
ShopManager.reset() # 商品列表清空
ProfileManager.clear_run() # 清除本局存档
5.2 WaveManager 框架设计
WaveManager 负责读取波次配置、按时序 spawn 敌人、检测清场条件、向 GameCycleManager 上报结果。
波次配置 JSON 格式
// 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 状态与接口
# 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 数据,而是调用高层接口:
# EnemyManager 对外接口(GDScript Autoload 层)
func spawn(enemy_id: String, position: Vector2) -> int: # 返回 entity_id
func reset() -> void # 清空 SoA(由 GameCycleManager 调用)
func get_alive_count() -> int
func fill_pos_snapshot(out: PackedVector2Array) -> void # BulletManager homing 用
5.3 ShopManager 框架设计
ShopManager 负责商品池管理、商店刷新、购买流程。与 PlayerManager(货币/库存)解耦通过 EventBus 或直接调用接口。
商品池与刷新算法
# 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 接口
# 购买接口(UIManager 点击购买时调用)
func purchase(slot_idx: int) -> bool:
if slot_idx < 0 or slot_idx >= _slots.size(): return false
var item: Dictionary = _slots[slot_idx]
if item.is_empty(): return false
var cost: int = item.get("price", 0)
if not PlayerManager.spend_gold(cost): return false
# 按类型分发给对应 Manager
match item.get("type", ShopItemType.SPELL):
ShopItemType.SPELL:
PlayerManager.add_spell_to_inventory(item.get("id", ""))
ShopItemType.PASSIVE:
PlayerManager.add_passive(item.get("id", ""))
ShopItemType.CORE:
PlayerManager.replace_core(item.get("slot", 0), item.get("id", ""))
ShopItemType.CONSUMABLE:
PlayerManager.use_consumable(item.get("id", ""))
_slots[slot_idx] = {} # 标记为已售
return true
func get_slots() -> Array[Dictionary]:
# UIManager._refresh_shop_ui() 读取当前商品展示列表(只读副本)
return _slots.duplicate()
func reset() -> void:
_slots.clear(); _reroll_count_this_wave = 0
func offer_free_spell(spell_id: String) -> void:
# DropManager 拾取法术卡掉落时调用。
# 弹出"拾取法术"交互 UI,玩家确认后才实际装备(防止强制打断操作)。
# UIManager 订阅此函数弹出的事件;若 UIManager 未处理则直接给予背包。
if not SpellRegistry.has(spell_id): return
EventBus.emit(EventID.SPELL_DROP_PICKUP, {
"spell_id": spell_id,
"auto_equip": false # false = 弹 UI 让玩家决定装到哪个 Core;true = 装 active_core
})
5.4 AudioManager 框架设计
AudioManager 管理 AudioBus 层级、动态音乐分层、音效池和空间音频。
权威定义说明:本节为 AudioBus 结构的唯一权威来源。
ADR-A3(§文档末尾)提供补充规则(空间音效衰减参数、节流规则、音量持久化细节),不重新定义 Bus 结构。两者合并阅读。
AudioBus 层级(权威)
Master
├── BGM (背景音乐总线,-6dB 预衰减)
│ ├── BGM_Base (基础旋律层,常驻播放,AudioStreamPlayer 独占)
│ └── BGM_Layer (动态叠加层:战斗强度 / Boss 阶段驱动;3 轨并行,按强度 fade-in)
├── SFX (战斗音效总线,-3dB)
│ ├── SFX_Combat (子弹/爆炸/伤害,来自 BulletManager/EnemyManager)
│ └── SFX_Spatial(2D 空间音效,挂 AudioEffect2DPan + 2D Reverb;max_distance=1200px)
├── UI (UI 音效,不受空间衰减,-3dB)
├── Ambience (循环环境音,-9dB)
└── Voice (语音 / Boss 台词,最高优先级,不受 SFX 池限制)
AudioBusID 常量 Autoload(代码中所有 Bus 名称必须通过此 Autoload 引用,禁止硬编码字符串):
# 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 完整数据模型
# 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)
核心接口
# ── 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。
# 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 自行处理状态的耦合。
核心数据结构
# 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
接口与生命周期
# 施加状态(由 SpellEvaluator / ZoneManager / BulletManager 调用)
# 权威签名(三处调用方统一使用此签名):
# - SpellEvaluator: apply(target_id, status_type_id, intensity, owner_id=caster_id)
# - ZoneManager: apply(target_id, status_type_id, owner_id=zone_owner_id)(intensity=1.0 默认)
# - impl §2.4: apply(entity_id, status_type_id, stacks, duration, source_id) —
# duration/stacks 由 StatusTypeDef 驱动,不在此签名中暴露(内部细节)
func apply(entity_id: int, status_type_id: int, intensity: float = 1.0,
owner_id: int = -1) -> void:
# owner_id 用于 remove_source()(实体死亡时移除其施加的所有状态);-1 = 无主(区域/无主状态)
var existing := _find(entity_id, status_type_id)
var typedef: StatusTypeDef = StatusRegistry.get(status_type_id)
if existing >= 0:
match typedef.stack_mode:
"refresh": _data[existing * STATUS_STRIDE + 2] = typedef.duration # 刷新时长
"stack": _data[existing * STATUS_STRIDE + 4] += 1.0 # 叠加层数
"ignore": pass # 已有则忽略
else:
_add_new(entity_id, status_type_id, typedef.duration, intensity, owner_id)
EventBus.emit(EventID.STATUS_APPLIED, {"target_id": entity_id, "status_type": status_type_id,
"stacks": int(_data[existing * STATUS_STRIDE + 4]) if existing >= 0 else 1})
func remove_source(owner_id: int) -> void:
# 移除 owner_id 施加的所有状态(召唤物/Boss 死亡时调用,防止游魂状态残留)
# 详见 implementation_plan.md §2.4 remove_source 实现
var i := 0
while i < _active_count:
if int(_data[i * STATUS_STRIDE + 5]) == owner_id: # slot +5: owner_id
# swap-and-pop
if i < _active_count - 1:
for k in STATUS_STRIDE:
_data[i * STATUS_STRIDE + k] = _data[(_active_count - 1) * STATUS_STRIDE + k]
_active_count -= 1
else:
i += 1
# 查询(EnemyManager.apply_damage() 读取易伤系数)
func get_stacks(entity_id: int, status_type_id: int) -> int:
var idx := _find(entity_id, status_type_id)
return int(_data[idx * STATUS_STRIDE + 4]) if idx >= 0 else 0
func has_status(entity_id: int, status_type_id: int) -> bool:
return _find(entity_id, status_type_id) >= 0
# _physics_process:tick DoT 并倒计时 duration,swap-and-pop 移除过期实例
func _physics_process(delta: float) -> void:
var i := 0
while i < _active_count:
var base := i * STATUS_STRIDE
_data[base + 2] -= delta # remaining_duration 倒计时
_data[base + 3] += delta # tick_accumulator 累积
var typedef: StatusTypeDef = StatusRegistry.get(int(_data[base + 1]))
while _data[base + 3] >= typedef.tick_interval:
_data[base + 3] -= typedef.tick_interval
_apply_dot_tick(i, typedef) # 触发 DoT 伤害
if _data[base + 2] <= 0.0:
_swap_and_pop(i) # O(1) 移除,顺序可乱
else:
i += 1
func reset() -> void:
_active_count = 0 # 不需要清零数据,_active_count 控制有效范围
StatusTypeDef 资源格式
# status_type_def.gd - Resource 子类
class_name StatusTypeDef
extends Resource
var display_name: String = "" # tr() KEY,UI 显示用
var duration: float = 3.0 # 默认持续时长(秒)
var tick_interval: float = 1.0 # DoT 跳字间隔(非 DoT 状态设 999.0 避免误触发)
var stack_mode: String = "refresh" # "refresh" | "stack" | "ignore"
var max_stacks: int = 99 # 叠层上限(stack 模式)
var dot_damage_per_tick: float = 0.0 # 每 tick 造成的基础伤害(0 = 无 DoT)
var dot_damage_type: int = DamageType.PHYSICAL
var can_catalyze: Array[int] = [] # 可参与催化的状态 ID 列表(空 = 不参与元素反应)
var is_combo_tracker: bool = false # true = 计数追踪状态(tick 逻辑由调用方控制,不走 DoT 路径)
var vfx_id: String = "" # 状态持续时的粒子特效 ID(空 = 无)
var icon_key: String = "" # UI 状态图标的 UIAtlas 键
5.6 PlayerManager 架构设计
PlayerManager 是玩家实体的单一数据权威,持有 HP / 金币 / XP / 等级 / 三个 Core / 被动列表。所有系统通过 PlayerManager 读写玩家状态,禁止直接操作其内部字段。
玩家状态数据模型
# player_manager.gd (Autoload: PlayerManager)
# ── 生命值 ────────────────────────────────────────────────────
var hp: float = 100.0
var hp_max: float = 100.0
var hp_regen: float = 0.0 # 每秒自动回血(被动加成)
# ── 经济 ──────────────────────────────────────────────────────
var gold: int = 0
var xp: int = 0
var level: int = 1
# XP 升级阈值:⌊10 × 1.4^(level-1)⌋(见 numerical_design.md §1.2)
# level 20 = 最终等级(Wave 20 通关上限);level 21+ 服务 Endless 模式
# ── 属性词条(被动叠加结果)────────────────────────────────────
var stats: PlayerStats = PlayerStats.new() # 伤害/攻速/移速等合并后的最终值
# ── 法杖 Core 系统(三 Core 机制)──────────────────────────────
const MAX_CORES: int = 3
var cores: Array[CoreDefinition] = [] # 当前装备的 Core,长度 ≤ MAX_CORES
var active_core_idx: int = 0 # 当前激活 Core 的下标
var _raw_decks: Array[SpellDeck] = [] # 每个 Core 对应的原始 SpellDeck(compile_all_wands 的输入)
var compiled_decks: Array[CompiledDeck] = [] # 每个 Core 的预编译产物(compile_all_wands 的输出)
# ── 消耗品临时持有(当回合商店购买的消耗品,在 WAVE 开始前使用)─────
var _pending_consumables: Array[String] = [] # consumable_id 列表
# ── 被动列表 ──────────────────────────────────────────────────
var passives: Array[String] = [] # 被动 ID 列表(passive_id: String,对应 .tres 文件名)
# ── 位置(View 层只读) ────────────────────────────────────────
var _position: Vector2 = Vector2.ZERO # 由 CharacterBody2D 每帧同步;其他系统通过 get_position() 读取
func get_position() -> Vector2: return _position
PlayerStats 合并数据类
# 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)
核心接口
# ── 经济接口 ──────────────────────────────────────────────────
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,负责在敌人位置生成掉落物并处理玩家拾取。
掉落表配置格式
// 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 核心逻辑
# 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)
# 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 核心逻辑
# 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 核心设计
# boss_manager.gd (Autoload: BossManager)
# Boss 阶段定义(每个 Boss 的阶段配置在其对应 JSON 中定义)
# boss_design.md §6 详细描述各 Boss 行为,BossManager 仅处理通用框架
var _boss_entity_id: int = -1 # 当前 Boss 在 EnemyManager 中的 entity_id(-1 = 无 Boss)
var _current_phase: int = 0
var _phase_thresholds: Array[float] = [] # HP 百分比阈值(降序),如 [1.0, 0.6, 0.3]
var _is_active: bool = false
var _boss_size_cache: Dictionary = {} # spawn_boss 时缓存的 Boss 配置(含 collision_radius)
# WaveManager 在 is_boss_wave=true 时调用
func spawn_boss(boss_id: String, position: Vector2) -> void:
_boss_entity_id = EnemyManager.spawn(boss_id, position)
var cfg: Dictionary = _load_boss_config(boss_id)
_boss_size_cache = cfg
_phase_thresholds = cfg.get("phase_hp_thresholds", [1.0, 0.6, 0.3])
_current_phase = 0
_is_active = true
EventBus.emit(EventID.BOSS_PHASE_CHANGED, {
"boss_id": boss_id, "phase": 0, "bgm_layer": "boss"
})
func _physics_process(_delta: float) -> void:
if not _is_active or _boss_entity_id < 0: return
var hp_pct: float = EnemyManager.get_hp_percent(_boss_entity_id)
# 检测阶段切换(阈值降序,phase 0→1→2)
var next_phase := _current_phase
for i in range(_current_phase + 1, _phase_thresholds.size()):
if hp_pct <= _phase_thresholds[i]:
next_phase = i
if next_phase != _current_phase:
_current_phase = next_phase
_on_phase_changed(_current_phase)
func _on_phase_changed(phase: int) -> void:
EventBus.emit(EventID.BOSS_PHASE_CHANGED, {
"boss_id": EnemyManager.get_type(_boss_entity_id),
"phase": phase, "bgm_layer": "boss_phase_%d" % phase
})
# 触发特殊技能(如护盾、新 spawn pattern、移速变化)
# 具体行为由 boss_design.md §6 的各 Boss 脚本重写 _on_phase_enter(phase)
func _on_enemy_killed(payload: Dictionary) -> void:
if payload.get("enemy_id", -1) != _boss_entity_id: return
_is_active = false
_boss_entity_id = -1
EventBus.emit(EventID.BOSS_KILLED, {
"boss_id": EnemyManager.get_type(payload["enemy_id"]),
"wave_num": WaveManager.wave_num
})
# WaveManager 订阅 BOSS_KILLED 后发出 WAVE_COMPLETE(kill_boss clear_condition)
func reset() -> void:
_boss_entity_id = -1; _current_phase = 0; _is_active = false
_phase_thresholds.clear()
# ── 只读查询接口(BulletManager 碰撞检测 + UIManager Boss 血条)────
func is_active() -> bool: return _is_active
func get_boss_entity_id() -> int: return _boss_entity_id
func get_collision_rect() -> Rect2:
# Boss 碰撞 AABB(BulletManager 使用,用于超大碰撞体绕过 SpatialGrid)
if not _is_active: return Rect2()
var pos := EnemyManager.get_last_position(_boss_entity_id) # Boss 仍存活时 get_last_position 返回当前位置
var cfg: Dictionary = _boss_size_cache # spawn_boss 时从 _load_boss_config 读取 "collision_radius"
var r: float = cfg.get("collision_radius", 80.0)
return Rect2(pos.x - r, pos.y - r, r * 2, r * 2)
func _load_boss_config(boss_id: String) -> Dictionary:
# 从 res://resources/bosses/{boss_id}.json 加载 Boss 配置(同步加载,spawn 时调用一次)
var path := "res://resources/bosses/%s.json" % boss_id
if not FileAccess.file_exists(path):
push_warning("BossManager: boss config not found: %s" % path)
return {}
var raw: String = FileAccess.open(path, FileAccess.READ).get_as_text()
return JSON.parse_string(raw)
# 配置字段(规范):
# { "phase_hp_thresholds": [1.0, 0.6, 0.3],
# "collision_radius": 80.0,
# "spawn_pattern": "center",
# "phase_actions": { "1": "spawn_minions", "2": "enrage" } }
Boss 碰撞策略
Boss 半径通常 > 64px(超过 LARGE_PROJECTILE_THRESHOLD),使用与"超大弹体豁免"对称的策略:
# 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 数据模型
# ui_manager.gd (Autoload: UIManager)
# ── 各 UI 场景实例(懒加载,首次显示时实例化)────────────────────
var _shop_ui: Control = null # res://scenes/ui/ShopUI.tscn
var _inventory_ui: Control = null # res://scenes/ui/InventoryUI.tscn
var _upgrade_ui: Control = null # res://scenes/ui/UpgradeChoiceUI.tscn
var _hud: Control = null # res://scenes/ui/HUD.tscn(BattleScene 挂载时创建)
var _main_menu: Control = null # res://scenes/ui/MainMenuUI.tscn
var _game_over: Control = null # res://scenes/ui/GameOverUI.tscn
var _pause_menu: Control = null # res://scenes/ui/PauseMenuUI.tscn
# ── RNG(用于 UpgradeSystem.build_choices 的确定性种子)──────────
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
# _rng.seed 在每次进入 WAVE_RESULT 状态时重置(保持波次间确定性)
# ── 伤害数字池 ──────────────────────────────────────────────────
const MAX_DAMAGE_NUMBERS: int = 30
var _dmg_num_pool: Array[Label] = [] # 飘字 Label 池(重复利用,减少节点创建)
var _dmg_num_active: int = 0
func _ready() -> void:
EventBus.subscribe(EventID.GAME_STATE_CHANGED, _on_state_changed)
EventBus.subscribe(EventID.PLAYER_DAMAGED, _on_player_damaged)
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
EventBus.subscribe(EventID.SPELL_DROP_PICKUP, _on_spell_drop_pickup)
# 预分配伤害数字池
for i in MAX_DAMAGE_NUMBERS:
var lbl := Label.new()
lbl.visible = false
add_child(lbl)
_dmg_num_pool.append(lbl)
状态→UI 路由表
GameCycleManager 在每次 transition_to(next) 末尾发送 EventID.GAME_STATE_CHANGED(payload: {prev, next}),UIManager 的 _on_state_changed 根据下表切换:
| GameState | 显示的 UI | 隐藏的 UI | 附加操作 |
|---|---|---|---|
MAIN_MENU |
MainMenuUI | 其他全部 | 停止 HUD |
LOADING |
无(过渡遮罩由 GameCycleManager 驱动) | 其他 | — |
SHOP |
ShopUI | HUD, InventoryUI | _refresh_shop_ui() |
WAVE_INTRO |
HUD(波次预告) | ShopUI, UpgradeUI | _show_wave_intro_banner() |
WAVE |
HUD | 其他 | 激活伤害数字池 |
WAVE_RESULT |
UpgradeChoiceUI(3 张词条) | HUD | _refresh_upgrade_choices() |
GAME_OVER |
GameOverUI | 其他 | 显示分数/波次 |
GAME_CLEARED |
GameOverUI(通关变体) | 其他 | 显示 Endless 入口 |
PAUSE |
PauseMenuUI(叠加当前 UI) | — | get_tree().paused = true |
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 状态退出时解除暂停
伤害数字池接口
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 刷新协议
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 格式
# passive_def.gd - Resource 子类
class_name PassiveDef
extends Resource
var passive_id: String = "" # 全局唯一,格式:category_stat(如 "offense_damage_mult")
var display_name: String = "" # tr() KEY,UI 显示用
var description: String = "" # tr() KEY,支持 {value} 占位符(如 "+{value}% 伤害")
var icon_key: String = "" # UIAtlas 图标键(ADR-C1:必须有对应形状标识)
var rarity: int = 1 # 1=Common, 2=Uncommon, 3=Rare, 4=Legendary
var max_stack: int = 1 # 同一被动最多叠取次数(1=不可重复购买,99=无限)
var value: float = 0.0 # 效果数值(description {value} 占位符的填充值)
# ── 效果:修改 PlayerStats 的规则 ───────────────────────────────
# 支持三种效果模式(enum 选一):
enum StatPatchMode { ADD, MULTIPLY, OVERRIDE }
var stat_field: String = "" # PlayerStats 中被修改的字段名(如 "damage_mult")
var patch_mode: StatPatchMode = StatPatchMode.MULTIPLY
var patch_value: float = 1.0 # ADD: +patch_value;MULTIPLY: *patch_value;OVERRIDE: =patch_value
func apply_to(stats: PlayerStats) -> void:
if stat_field == "": return
var current: float = stats.get(stat_field)
match patch_mode:
StatPatchMode.ADD: stats.set(stat_field, current + patch_value)
StatPatchMode.MULTIPLY: stats.set(stat_field, current * patch_value)
StatPatchMode.OVERRIDE: stats.set(stat_field, patch_value)
设计约束:
apply_to()只修改PlayerStats中的 float 字段,不直接修改PlayerManager的 HP/Gold/XP(防止被动产生经济副作用)。需要修改资源上限(如hp_max_bonus)的被动,通过stats.hp_max_bonus间接影响,PlayerManager在recalculate()后重算hp_max = 100.0 + stats.hp_max_bonus。
PassiveRegistry Autoload
# 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 构建权重池用)
被动词条设计示例(资源文件)
# 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(稀有度桶) 查询。
# spell_registry.gd (Autoload: SpellRegistry)
var _registry: Dictionary = {} # { spell_id: SpellNode }
var _by_rarity: Array[Array] = [[], [], [], []] # index = rarity-1,每组为 Array[String](spell_id列表)
func _ready() -> void:
var dir := DirAccess.open("res://resources/spells/")
if dir:
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if fname.ends_with(".tres"):
var node: SpellNode = load("res://resources/spells/" + fname)
if node and node.spell_id != "":
_registry[node.spell_id] = node
var ri := clamp(node.rarity - 1, 0, 3)
_by_rarity[ri].append(node.spell_id)
fname = dir.get_next()
func get(spell_id: String) -> SpellNode:
return _registry.get(spell_id, null)
func has(spell_id: String) -> bool:
return _registry.has(spell_id)
func all() -> Array:
return _registry.values() # Array[SpellNode](用于 ShopManager 构建权重池)
func random_by_rarity(max_rarity: int, rng: RandomNumberGenerator = null) -> String:
# DropManager 使用:从稀有度 1~max_rarity 的所有法术中随机选一个 ID
# rng 为 null 时使用全局随机(掉落不需要确定性种子)
var pool: Array[String] = []
for r in range(min(max_rarity, 4)):
pool.append_array(_by_rarity[r])
if pool.is_empty(): return ""
if rng: return pool[rng.randi() % pool.size()]
return pool[randi() % pool.size()]
5.14 ProfileManager / SaveSystem 框架设计
ProfileManager 是持久化层的统一入口,负责 Run 运行时存档 和 全局设置/统计 的读写。权威实现详见 implementation_plan.md §2.5.E 和 ADR-A2,本节为接口契约。
职责边界
- 负责:JSON 文件读写(A/B 双槽写、CRC 校验)、schema 版本迁移、Run 状态持久化、全局设置持久化
- 不负责:游戏逻辑、排行榜 HMAC(EndlessRecordsManager 负责)
# profile_manager.gd (Autoload: ProfileManager)
# Run 存档:user://run_a.json(A槽)+ user://run_b.json(B槽)(权威来源:ADR-A2、certification_checklist.md ST-51)
# 全局设置:user://save_data.json(音量/无障碍等,非 Run 数据)
# schema_version 必须与 ADR-A2 中的 _migrate 链同步
const SCHEMA_VERSION: int = 1 # 每次 save 结构变更时递增
# ── 全局持久化(设置/统计,非 Run)────────────────────────────────
func get_float(key: String, default_val: float = 0.0) -> float:
return _global.get(key, default_val)
func set_float(key: String, value: float) -> void:
_global[key] = value; _dirty = true
func get_int(key: String, default_val: int = 0) -> int:
return int(_global.get(key, default_val))
func set_int(key: String, value: int) -> void:
_global[key] = value; _dirty = true
# ── Run 状态存档(ADR-A2 §2 波次结束自动存档)────────────────────
func save_run(run_data: Dictionary) -> void:
# A/B 双槽交替写入(ADR-A2 防断电损坏协议)
run_data["schema_version"] = SCHEMA_VERSION
var which := get_int("run_write_slot", 0)
var path_a := "user://run_a.json"; var path_b := "user://run_b.json"
_write_json(path_a if which == 0 else path_b, run_data)
set_int("run_write_slot", 1 - which)
func load_run() -> Dictionary:
# 优先读 A 槽;A 槽损坏则降级读 B 槽;均损坏则返回 {}(GameCycleManager 展示损坏提示)
var data := _read_json("user://run_a.json")
if data.is_empty():
data = _read_json("user://run_b.json")
if not data.is_empty():
data = _migrate(data)
return data
func has_run() -> bool:
# GameCycleManager 启动时查询是否有未完成的 Run
return not load_run().is_empty()
func clear_run() -> void:
# Run 结束(通关/死亡)后调用,删除 Run 存档(保留全局设置/统计)
_write_json("user://run_a.json", {})
_write_json("user://run_b.json", {})
func get_run_seed() -> int:
# ShopManager 确定性种子(新 Run 开始时写入,存档后恢复)
return get_int("run_seed", 0)
func get_run_count() -> int:
return get_int("run_count", 0)
func flush() -> void:
# 保存全局设置(音量、无障碍等)→ user://save_data.json(与 Run 存档 run_a/b.json 分离)
# 由 SettingsManager.save_audio_setting 触发或 App 退出时调用
if _dirty: _write_json("user://save_data.json", _global); _dirty = false
func _migrate(data: Dictionary) -> Dictionary:
# ADR-A2 §3:链式迁移,v0→v1→v2...
var v: int = data.get("schema_version", 0)
if v < 1: data = _migrate_v0_to_v1(data)
return data
5.15 SettingsManager 框架设计
SettingsManager 是游戏设置的运行时接口,持久化委托给 ProfileManager.flush()。
# settings_manager.gd (Autoload: SettingsManager)
# 依赖:ProfileManager(持久化)、AudioBusID(音量),EventBus(SETTINGS_CHANGED 通知)
func _ready() -> void:
load_audio_settings() # 恢复持久化音量
_apply_accessibility_settings() # 高对比度、字体缩放、减少闪烁
# ── 音量(ADR-A3.4)────────────────────────────────────────────────
# 见 ADR-A3.4 伪代码(load_audio_settings / save_audio_setting)
# ── 无障碍功能(ADR-C1)───────────────────────────────────────────
func set_colorblind_mode(mode: String) -> void:
# mode: "normal" / "protanopia" / "deuteranopia"(见 ADR-C1.1)
ProfileManager.set_int("colorblind_mode", ["normal","protanopia","deuteranopia"].find(mode))
EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "colorblind_mode" })
func set_font_scale(scale: float) -> void:
ProfileManager.set_float("font_scale", clampf(scale, 0.8, 1.5))
_apply_font_scale()
EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" })
func set_reduce_flash(enabled: bool) -> void:
ProfileManager.set_int("reduce_flash", int(enabled))
EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "reduce_flash" })
func set_high_contrast(enabled: bool) -> void:
ProfileManager.set_int("high_contrast", int(enabled))
_apply_high_contrast()
EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "high_contrast" })
func apply_settings() -> void:
# UIManager 在 SettingsUI 打开时调用,刷新所有设置为持久化值
load_audio_settings()
_apply_accessibility_settings()
func get_volume(save_key: String) -> float:
# UIManager 音量滑块初始化时调用
return ProfileManager.get_float(save_key, 0.0)
func _apply_accessibility_settings() -> void:
_apply_font_scale()
_apply_high_contrast()
func _apply_font_scale() -> void:
var scale: float = ProfileManager.get_float("font_scale", 1.0)
# 遍历所有 Label / RichTextLabel 节点并设置 theme_override_font_sizes
pass # 见 ADR-C1.3
func _apply_high_contrast() -> void:
pass # 切换 CanvasItem 材质 Shader uniform;见 ADR-C1.4
5.11.B CoreRegistry + ConsumableRegistry + ConsumableDef
CoreRegistry 和 ConsumableRegistry 与 SpellRegistry / PassiveRegistry 采用对称模式,补全 PlayerManager.replace_core() 和 use_consumable() 的依赖链。
# ── core_registry.gd (Autoload: CoreRegistry) ──────────────────────────────
# 扫描 res://resources/cores/ 目录,加载所有 CoreDefinition .tres 文件
var _registry: Dictionary = {} # { core_id: CoreDefinition }
func _ready() -> void:
var dir := DirAccess.open("res://resources/cores/")
if dir:
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if fname.ends_with(".tres"):
var def: CoreDefinition = load("res://resources/cores/" + fname)
if def and def.core_id != "": _registry[def.core_id] = def
fname = dir.get_next()
func get(core_id: String) -> CoreDefinition: return _registry.get(core_id, null)
func has(core_id: String) -> bool: return _registry.has(core_id)
func all() -> Array: return _registry.values()
# ── consumable_def.gd ───────────────────────────────────────────────────────
class_name ConsumableDef
extends Resource
var consumable_id: String = "" # 全局唯一,格式:consumable_effect(如 "hp_potion_50")
var display_name: String = "" # tr() KEY
var description: String = "" # tr() KEY,支持 {value} 占位符
var icon_key: String = "" # UIAtlas 图标键
var price: int = 8 # 商店默认价格(金币)
var value: float = 0.0 # 效果数值
func apply(player: PlayerManager) -> void:
pass # 子类重写,或通过 effect_type + value 驱动(参考 PassiveDef.apply_to 模式)
# 示例子类:HP 药水 → player.heal(value);速度水 → player.stats.move_speed += value(本回合)
# ── consumable_registry.gd (Autoload: ConsumableRegistry) ──────────────────
var _registry: Dictionary = {} # { consumable_id: ConsumableDef }
func _ready() -> void:
var dir := DirAccess.open("res://resources/consumables/")
if dir:
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if fname.ends_with(".tres"):
var def: ConsumableDef = load("res://resources/consumables/" + fname)
if def and def.consumable_id != "": _registry[def.consumable_id] = def
fname = dir.get_next()
func get(consumable_id: String) -> ConsumableDef: return _registry.get(consumable_id, null)
func all() -> Array: return _registry.values()
ShopManager 商品池补充:
ShopItemType.CONSUMABLE与ShopItemType.CORE商品由特定波次固定投放(不进入随机权重池),即 Boss 波前固定刷出 Core 槽位、每波随机出现 1 消耗品。_build_weighted_pool仅负责 SPELL + PASSIVE;CONSUMABLE/CORE 槽位由_fill_fixed_slots()在_fill_slots()中单独填充。
5.12 MinionManager 架构设计
MinionManager 管理玩家召唤物(友方单位)的生命周期,与 EnemyManager 共享 SpatialGrid 但 faction 位不同(+6 高16位 = 1)。召唤物数量上限较小(通常 ≤ 16),不需要 C# 热路径。
职责边界
- 不处理:战斗逻辑(委托 SpellEvaluator / StatusManager)、敌人碰撞(委托 BulletManager)
- 负责:召唤物生命周期(spawn/expire/recall)、最大上限控制、事件通知(MINION_SPAWNED/MINION_EXPIRED)
# minion_manager.gd (Autoload: MinionManager)
const MAX_MINIONS: int = 16 # 全局最大召唤物数量
const DEFAULT_LIFETIME: float = 30.0 # 默认存活秒数(可被 MinionDef 覆盖)
var _minions: Array[Dictionary] = [] # 活跃召唤物列表,每项:{ id, owner_id, def, lifetime_rem, node }
var _next_id: int = 0
func spawn_minion(def: MinionDef, owner_id: int, pos: Vector2) -> int:
# 返回 minion_id;若已达 MAX_MINIONS,先 expire 最旧的(先进先出策略)
# 发出 EventID.MINION_SPAWNED
if _minions.size() >= MAX_MINIONS:
_expire(_minions[0].id)
var id := _next_id
_next_id = (_next_id + 1) % 100000
var entry := { "id": id, "owner_id": owner_id, "def": def,
"lifetime_rem": def.lifetime if def.lifetime > 0 else DEFAULT_LIFETIME,
"pos": pos }
_minions.append(entry)
EventBus.emit(EventID.MINION_SPAWNED, { "minion_id": id, "owner_id": owner_id,
"current_count": _minions.size() })
return id
func recall_all(owner_id: int) -> void:
# 立即移除 owner_id 的所有召唤物(玩家死亡 / Run 结束时调用)
var to_expire := _minions.filter(func(m): return m.owner_id == owner_id)
for m in to_expire: _expire(m.id)
func get_count(owner_id: int = -1) -> int:
# owner_id = -1 时返回所有召唤物总数
if owner_id < 0: return _minions.size()
return _minions.filter(func(m): return m.owner_id == owner_id).size()
func fill_pos_snapshot(out: PackedFloat32Array, out_ids: PackedInt32Array) -> int:
# BulletManager homing 查询友方召唤物(阵营标志:faction=1)
# 返回写入数量;out 格式: [x0, y0, x1, y1, ...]
var n := 0
for m in _minions:
out[n * 2] = m.pos.x
out[n * 2 + 1] = m.pos.y
out_ids[n] = m.id
n += 1
return n
func _physics_process(delta: float) -> void:
var i := 0
while i < _minions.size():
_minions[i].lifetime_rem -= delta
if _minions[i].lifetime_rem <= 0.0:
_expire(_minions[i].id)
else:
i += 1
func _expire(minion_id: int) -> void:
for i in _minions.size():
if _minions[i].id == minion_id:
var owner := _minions[i].owner_id
_minions.remove_at(i)
EventBus.emit(EventID.MINION_EXPIRED, { "minion_id": minion_id, "owner_id": owner,
"current_count": _minions.size() })
return
func reset() -> void:
# GameCycleManager._reset_all_managers() 调用
_minions.clear(); _next_id = 0
MinionDef Resource:
res://resources/minions/{id}.tres,字段:minion_id: String、lifetime: float(≤0 使用默认值)、move_speed: float、damage_mult: float、spell_deck_id: String(召唤物使用的法术)。
5.13 VFXManager 框架设计
VFXManager 管理战斗特效的播放与回收,基于对象池避免运行时节点创建。权威实现参见 implementation_plan.md §2.5.D,本节为 §5 级接口契约(供其他 Manager 调用)。
职责边界
- 不处理:音效(AudioManager 负责)、伤害数字(UIManager 负责)
- 负责:粒子/精灵特效生命周期(play/stop/reset)、命中火花(spawn_hit_vfx)、区域特效(play_zone)
# vfx_manager.gd (Autoload: VFXManager)
# C# 热路径:无(特效数量有限,GDScript 对象池足够)
const MAX_ACTIVE_VFX: int = 64 # 同时活跃特效上限(超出时丢弃最旧)
# 订阅事件(_ready() 注册):
# EventID.BULLET_HIT → spawn_hit_vfx("hit_spark", world_pos)
# EventID.ENEMY_KILLED → play("death_burst", enemy_pos)
# EventID.STATUS_APPLIED → play("status_" + type_name, target_pos)
# EventID.SPELL_CAST_BEGIN→ play("cast_flash", caster_pos)
# EventID.BOSS_PHASE_CHANGED → play("boss_phase_" + phase, boss_pos)
func play(effect_id: String, world_pos: Vector2, scale: float = 1.0) -> int:
# 从池中取出一个特效节点并播放;返回 vfx_handle(用于 stop)
# effect_id 对应 res://scenes/vfx/{effect_id}.tscn
pass # 具体实现见 implementation_plan.md §2.5.D
func stop(vfx_handle: int) -> void:
# 立即停止并回收特效节点(用于持续特效提前结束,如召唤物消失)
pass
func play_zone(zone_type_id: int, world_pos: Vector2, radius: float,
duration: float) -> int:
# 为 ZoneManager 显示区域特效(持续型,duration 秒后自动停止)
# zone_type_id 对应 ZoneManager 的 zone_type_id,映射到 "zone_{type}" 特效
return play("zone_%d" % zone_type_id, world_pos, radius / 64.0)
func spawn_hit_vfx(hit_type: String, world_pos: Vector2) -> void:
# BulletManager BULLET_HIT 事件处理;hit_type:spark / elemental_X / critical
play("hit_" + hit_type, world_pos, 1.0)
func reset() -> void:
# GameCycleManager._reset_all_managers() 调用,停止并归还所有活跃特效节点
pass # 具体实现见 implementation_plan.md §2.5.D(遍历 _active_pool,调用 stop/归还)
与 implementation_plan.md 的关系:
impl §2.5.D为完整实现(含池管理细节),本节 §5.13 为接口契约层(供其他 Manager 查阅可调用的 API)。两者互为补充,实现时以 impl §2.5.D 为基础,函数签名以本节为准。
6. 技术栈选型总结
| 模块 | 方案 | 理由 |
|---|---|---|
| 引擎 | Godot 4.x | 开源免费,2D 性能强,内置物理/动画系统完善 |
| 语言 | GDScript + C#(静态职责划分,见 §6.1) | 非主备关系:GDScript 负责快迭代域(UI / 事件 / 配置 / 游戏循环 / 法术预编译),C# 负责计算密集热路径(BulletManager / EnemyManager / SpatialGrid / SpellEvaluator 内层循环);热数据通过 PackedFloat32Array.AsSpan() 零拷贝共享;详见 §6.1 |
| ECS框架 | Custom Lite (Autoload Manager-based) | 利用 Godot Autoload 实现 Manager 单例,针对本项目定制,避免引入第三方 ECS 库 |
| 物理 | Custom SpatialGrid(主力)+ Godot Area2D(低密度启动路径) | < 200 弹幕:Area2D 信号回调;200+ 弹幕:SpatialGrid dirty-list 主力碰撞;详见 §4.3 |
| 渲染批次 | MultiMeshInstance2D | 极大量同类子弹用 MultiMesh 渲染,最小化 Draw Call |
| UI | Godot Control + 自定义虚拟列表 | 背包道具可能很多,需要虚拟列表优化 |
| 配置 | JSON + GDScript 类型注解(或 .tres Resource) | JSON 灵活易热更;Resource 文件可享受 Godot 编辑器集成 |
6.1 语言职责分工 (Language Partition)
GDScript 与 C# 不是主备(备用)关系,而是按职责静态划分,从 S0 起并行建立:
| 职责域 | 语言 | 核心原因 |
|---|---|---|
| 表现层(UI / VFX / 音频 / 动画) | GDScript | 节点操作频繁;Inspector 可视化调整;帧预算宽松(< 1ms) |
| 游戏循环(WaveManager / ShopManager / CombatManager 状态机) | GDScript | 非高频热路径;快速迭代优先 |
| 数据定义(CoreDefinition / SpellNode / StatusTypeDef) | GDScript .tres |
Godot 编辑器原生 Inspector 支持;数值调整零编译 |
| 配置与存档(ConfigMgr / SpellRegistry / ProfileManager) | GDScript | JSON / Resource 读取一次性,非热路径 |
法术预编译(compile_wand) |
GDScript | 仅在换牌时触发,非 _physics_process;Dictionary 操作 GDScript 更高效 |
| 事件总线(EventBus / EventID) | GDScript | 保持单语言,避免跨语言信号绑定复杂性 |
BulletManager._physics_process |
C# | 2000 颗/帧 SoA 积分;Span<float> 零拷贝 + struct 零 GC;GDScript ≈8ms → C# ≈0.8ms |
EnemyManager._physics_process |
C# | 1000 敌人 Boid 分离力;向量运算密集;GDScript ≈6ms → C# ≈0.6ms |
SpatialGrid(重建 + 查询) |
C# | 每帧重建 + M×N query_circle;纯计算密集,受益于 JIT 内联优化 |
SpellEvaluator.execute_compiled |
C# | 内层 while 循环(MAX_OPS_PER_CPU=40 × 高频施法);switch 跳表 JIT 优化 |
StatusManager._physics_process |
C# | 200+ 状态实例逐 tick 计算;swap-and-pop 顺序内存访问 C# 受益更大 |
ZoneManager._physics_process |
C# | Zone tick + SpatialGrid 批量查询组合 |
跨语言边界规则(ADR-L1)
每次 GDScript ↔ C# Call() / Set() 约有 1–5µs 开销,以下规则确保该开销不进入任何热路径:
规则 1:禁止在 C# 内层循环体内调用 GDScript 方法
BulletManagerCs._PhysicsProcess 的 for 循环体内不得出现 GodotObject.Call() / .Set()。
2000 次/帧 × 1–5µs = 2–10ms,直接耗尽帧预算。
规则 2:热数据通过 PackedFloat32Array.AsSpan() 零拷贝共享
GDScript Autoload 持有 _data: PackedFloat32Array;C# 通过 AsSpan() 获取 Span<float> 原生指针,无内存复制:
// 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:
// 帧末单次跨语言调用(循环外)
// ⚠️ 禁止 _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();
}
规则 4:C# 组件挂为 GDScript Autoload 的子节点
(Autoload) bullet_manager.gd ← GDScript:_data PackedFloat32Array + 对外接口(spawn/despawn)
└─ BulletManagerCs.cs ← C# Node:_Ready() 缓存父节点引用;_PhysicsProcess 执行热路径
GDScript 对外接口(spawn_bullet / despawn_bullet)保持不变,其他 GDScript 系统无感知 C# 的存在。
C# _Ready() 中获取父节点并缓存 _data 引用,之后每帧直接操作,无跨语言调用。
BulletManager GDScript 对外接口(权威签名)
# bullet_manager.gd (Autoload: BulletManager)
# 以下为 GDScript 对外 API;C# 热路径(_physics_process 积分)不在此列出。
const BULLET_STRIDE: int = 12 # SoA 完整布局(权威:arch §4.2):
# [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ]
# 0 1 2 3 4 5 6 7 8 9 10 11
# 冷数据(pierce/bounce/homing/payload_id)→ _bullet_contexts: Dictionary(非热路径)
var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
var _bullet_contexts: Dictionary = {} # { bullet_id: int → Dictionary(冷数据)}
func spawn_bullet(def: ProjectileDef, pos: Vector2, vel: Vector2) -> int:
# 返回 bullet_id(SoA 槽位索引);def.lifetime 单位:秒
# SpellEvaluator、ZoneManager 调用
pass # 具体实现见 implementation_plan.md §2.2
func despawn_bullet(bullet_id: int) -> void:
# 提前回收:lifetime 置 0;C# 下帧 SoA 清理
if bullet_id < 0 or bullet_id >= _active_count: return
_data[bullet_id * BULLET_STRIDE + 4] = 0.0 # slot +4: lifetime
func get_active_count() -> int: return _active_count
func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
# PlayerManager.get_aim_direction() 调用(自动瞄准默认模式)
# 委托 EnemyManager.fill_pos_snapshot 后线性扫描;非每帧热路径(仅发射时调用)
return EnemyManager.get_nearest_pos(origin, max_dist)
func reset() -> void:
_active_count = 0 # 不需要清零数据,_active_count 控制有效范围
_bullet_defs.clear()
命名统一说明:文档早期混用
spawn()/spawn_bullet(),以此处spawn_bullet(def, pos, vel)为唯一权威签名;despawn_bullet(id)同理。ADR-L1 中提到的"GDScript 对外接口"均指此两个函数。
规则 5:compile_wand 保留在 GDScript,不迁移
法术预编译仅在换牌时触发(非 _physics_process),主要为 Dictionary / Array 构建操作,
GDScript 开发效率更高;迁移 C# 无性能收益且增加维护成本。
6.2 EventBus Autoload 接口定义
EventBus(event_bus.gd)是全局事件总线,第 6 个 Autoload 初始化(见 §9.2),所有 Manager 均依赖它。采用整数 ID 替代字符串,避免运行时 String 比较。
# event_bus.gd (Autoload: EventBus)
# 核心约定:
# - 所有事件 ID 均在 event_ids.gd (Autoload: EventID) 中声明,不接受魔术数字
# - C# 热路径通过 emit_batch 一帧内批量发送,降低跨语言调用次数
# - 订阅回调在 GDScript 侧统一处理,不在 C# 侧订阅(ADR-L1 规则 2)
var _listeners: Dictionary = {} # { event_id: int → Array[Callable] }
func subscribe(event_id: int, callback: Callable) -> void:
if not _listeners.has(event_id):
_listeners[event_id] = []
var arr: Array = _listeners[event_id]
if not arr.has(callback):
arr.append(callback)
func unsubscribe(event_id: int, callback: Callable) -> void:
if _listeners.has(event_id):
_listeners[event_id].erase(callback)
func emit(event_id: int, payload: Dictionary = {}) -> void:
if not _listeners.has(event_id): return
for cb: Callable in _listeners[event_id].duplicate(): # duplicate() 防止回调内修改订阅表
cb.call(payload)
func emit_batch(events: Array) -> void:
# C# BulletManager / EnemyManager 在 _physics_process 末尾调用(ADR-L1 规则 3)
# events 格式:[ [event_id: int, payload: Dictionary], ... ]
# 在主线程 GDScript 帧末执行,保证监听者在下一个 _process 前收到通知
for ev in events:
if ev.size() >= 2:
emit(int(ev[0]), ev[1])
elif ev.size() == 1:
emit(int(ev[0]))
func reset() -> void:
# GameCycleManager 在 Run 结束时调用,清除所有动态订阅(防跨 Run 事件泄漏)
# ⚠️ 仅清除非常驻订阅(GameCycleManager、UIManager 等 Autoload 在 _ready() 中重新订阅)
_listeners.clear()
使用约定:
subscribe/unsubscribe必须成对调用;Autoload 在_ready()中订阅,节点在_exit_tree()中取消。emit_batch仅供 C# Manager 使用,格式见注释;普通 GDScript Manager 直接调用emit。- EventID 目录权威来源:
implementation_plan.md §2.1(1–21,下一空位 22)。
7. 目录规范建议
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)而非本节的 SoAstride=8(两处文档矛盾,代码选了 Array);behavior_state枚举未实现。召唤物只实现了 Stationary 炮台一种,随从/卫星/镜像(含 hp/碰撞体积/移动)均未落地。MAX_MINIONS=20FIFO 顶替已实现。详见 审计报告。
MinionManager 为独立的 Autoload 单例,管理玩家召唤的炮台/傀儡/宠物类实体:
- 与 EnemyManager 隔离:EnemyManager 的 SoA 中
faction_and_type字段不扩展到友军, 避免碰撞 Layer 混乱和阵营判断逻辑膨胀。 - Minion SoA stride:
[x, y, vx, vy, hp, hp_max, owner_id, behavior_state](stride=8), 与 EnemyManager 的 stride=8 同构,便于复用 SpatialGrid 查询逻辑。 - 行为驱动:Minion 的 AI 行为(巡逻/护卫/定点炮击)由
behavior_state枚举驱动, 每帧在MinionManager._physics_process中批处理;不使用独立 Node_process。 - 生命周期:Minion 属性在召唤瞬间 Snapshot(见 ADR-R4-N2),由
owner_id关联到施法者; 施法者死亡时,对应的所有 Minion 同帧标记为"消退状态"(延迟 1 秒淡出销毁)。 - 上限保护:单个玩家同场最多
MAX_MINIONS = 20个 Minion,超出时最早召唤的自动消退。
详见 docs/mechanics/advanced_mechanics_summons_and_environment.md §3.1(方案 B 推荐)。
ADR-R5-N1:地面效果系统架构(ZoneManager)
结论:采用独立的 ZoneManager(Autoload),Zone 实体基于 SpatialGrid 空间索引,不使用 Area2D。
ZoneManager 管理战场中的持久化地面效果(毒液池、岩浆区、冻结地面等):
- 数据结构:
_zones: Array[ZoneData],ZoneData 字段:[cx, cy, radius, status_type_id, duration, tick_interval, tick_accum, root_owner_id](stride=8;tick_interval与tick_accum分开存储,支持每个 Zone 配置独立应用频率)。 - 碰撞检测:每
_physics_process帧调用SpatialGrid.query_circle(zone_center, zone_radius),对返回的敌人实体批量施加状态;不使用 Area2D,不产生 Node 开销。 - 上限保护:
MAX_ZONES = 64(超出时最早创建的 Zone 被顶掉)。 - 渲染:Zone 的地面贴花由
VFXManager.play_zone("poison_pool", center, radius)驱动,不在逻辑层操作节点。 - 生命周期:Zone 创建时 duration 快照,每帧 duration -= delta;降至 ≤ 0 时用 swap-and-pop 移除,同步通知 VFXManager 淡出。
- 与 SpellSystem 的接口:
ActionSplashPoison等 ACTION 节点执行时调用ZoneManager.spawn_zone(...)而非直接操作场景。
# zone_data.gd (inline struct, not a class_name Resource to avoid GC)
# 存储于 ZoneManager._zone_data: PackedFloat32Array,stride = 8
# [0]=cx [1]=cy [2]=radius [3]=status_type_id [4]=duration [5]=tick_interval [6]=tick_accum [7]=owner_id
# tick_interval:两次状态应用之间的最小间隔(秒,由 spawn_zone 调用方指定)
# tick_accum:当前累积时间(运行时变量),>= tick_interval 时触发一次 APPLY_DAMAGE 并减去 tick_interval
# zone_manager.gd (Autoload)
const ZONE_STRIDE: int = 8
const MAX_ZONES: int = 64
var _zone_data: PackedFloat32Array # SoA 热数据
var _active_zones: int = 0
func spawn_zone(cx: float, cy: float, radius: float,
status_id: int, duration: float,
tick_interval: float, # Zone 每隔多少秒对范围内敌人应用一次状态
owner_id: int) -> void:
if _active_zones >= MAX_ZONES:
# 顶掉最旧的 Zone(index 0),整体前移(O(N),N≤64 可接受)
# PackedFloat32Array 无 remove_at(),用手动循环前移实现首元素删除
for j in range(ZONE_STRIDE, _active_zones * ZONE_STRIDE):
_zone_data[j - ZONE_STRIDE] = _zone_data[j]
_active_zones -= 1
var base: int = _active_zones * ZONE_STRIDE
_zone_data.resize((_active_zones + 1) * ZONE_STRIDE)
_zone_data[base + 0] = cx; _zone_data[base + 1] = cy
_zone_data[base + 2] = radius; _zone_data[base + 3] = status_id
_zone_data[base + 4] = duration; _zone_data[base + 5] = tick_interval
_zone_data[base + 6] = 0.0 # tick_accum 初始为 0
_zone_data[base + 7] = owner_id
_active_zones += 1
func _physics_process(delta: float) -> void:
var i: int = 0
while i < _active_zones:
var base: int = i * ZONE_STRIDE
_zone_data[base + 4] -= delta # duration 倒计时
_zone_data[base + 6] += delta # tick_accum 累积
# 速率限制:仅当 tick_accum >= tick_interval 时才触发状态应用,防止 64 个 Zone 每帧
# 对 1000 个敌人各触发,产生最多 64,000 APPLY_DAMAGE 事件/帧。
# 外层 if 守卫 + 内层 while + -= tick_interval:保留余量精度,且支持大帧多次跳字。
# SpatialGrid.query_circle 仅在 if 守卫内调用一次,不在 while 内重复查询。
if _zone_data[base + 6] >= _zone_data[base + 5]:
var cx: float = _zone_data[base + 0]; var cy: float = _zone_data[base + 1]
var rad: float = _zone_data[base + 2]; var sid: int = int(_zone_data[base + 3])
var targets := SpatialGrid.query_circle(Vector2(cx, cy), rad)
while _zone_data[base + 6] >= _zone_data[base + 5]:
_zone_data[base + 6] -= _zone_data[base + 5] # -= tick_interval 保留余量精度
for t_id in targets:
# ⚠️ 修正(2026-07-20):真实签名 apply(entity_id, status_type_id, stacks:int=1, duration:float=-1.0, owner_id:int=-1)——5 参
StatusManager.apply(t_id, sid, 1, -1.0, int(_zone_data[base + 7])) # stacks=1, duration=default, owner_id=zone_owner(现实 zone_manager.gd:60 即如此调用)
if _zone_data[base + 4] <= 0.0: # duration 耗尽,移除此 Zone
var cx_exp: float = _zone_data[base + 0]; var cy_exp: float = _zone_data[base + 1]
var rad_exp: float = _zone_data[base + 2]
VFXManager.play("zone_expire", Vector2(cx_exp, cy_exp), rad_exp / 64.0)
# swap-and-pop 移除(O(1),顺序可乱)
if i < _active_zones - 1:
for k in ZONE_STRIDE:
_zone_data[base + k] = _zone_data[(_active_zones - 1) * ZONE_STRIDE + k]
_active_zones -= 1
else:
i += 1
func reset() -> void:
# GameCycleManager._reset_all_managers() 调用,清空所有活跃区域特效
_active_zones = 0 # SoA 数据不清零,_active_zones 控制有效范围(惰性清理)
与 BulletManager 的区别:BulletManager 管理短生命周期(秒级)的投射物;ZoneManager 管理中等生命周期(数秒~数十秒)的区域场。两者共享同一个
SpatialGrid实例,但查询入口不同(BulletManager 查敌人,ZoneManager 也查敌人但传入 zone_center/radius)。
ADR-R5-N2:Core Feature Tags 字符串常量规范
问题:
if "persistent_memory" not in core.feature_tags使用裸字符串比较,拼写错误时静默失效,且重构不安全。
结论:所有 Feature Tag 字符串必须通过
CoreFeatureTag常量访问,禁止在业务代码中写裸字符串。
⚠️ 实现现状 (2026-07-20 审计):代码未按本节的
String常量 +in判定实现。core_feature_tag.gd实为int常量(PERSISTENT_MEMORY=1, DUAL_STREAM=2, ALWAYS_CAST_LAST=3, SHUFFLE_DECK=4, INFINITE_SPELLS=5),core.feature_tags为int位掩码。feature_tags 实为单选枚举(游戏设计器core_tab.gd以 OptionButton 下标 0..5 写入,下标即等于常量值)。原代码用位与&判定,因常量非 2 的幂会串扰(3 & 1 = 1误判持久内存)。✅ 已修复 (fix/audit-latent-bugsdc05eea):判定改为==,常量保持 1..5(未改位标志以免与设计器 desync)。详见 审计报告 D 节。
# 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 流程:
- 在
CoreFeatureTag中追加常量(命名规则:全大写下划线分隔)。 - 在
core_wand_design.md §3Feature Tags 表格中登记效果与稀有度。 - 在
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 数组(待补)。详见 审计报告。
商业 Roguelite(Hades / Slay the Spire / Balatro)均支持中途退出后恢复到当前波次起始状态。
存档时机:
- 每波战斗结束(进入商店阶段前):序列化
PlayerState + ActiveCore + WaveNum。 - 每次商店关闭(进入下一波前):追加序列化
InventoryState + UpgradePicks。 - 游戏异常退出:通过
_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 双槽写入(防崩溃数据损坏):
# 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() 而绕过迁移。
# 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 实现规范
# 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 排行榜前的验证
# 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 形 |
渲染规则:
# 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_iconTextureRect,字号 1.5× 基准大小。 VFXManager在spawn_hit_vfx(pos, element_tag)中从 UIAtlas 读取shape_icon并生成叠加节点。
C1.2 字体缩放(S4 实现)
# settings_manager.gd 新增常量 + 接口
const FONT_SCALE_KEY: String = "font_scale"
const FONT_SCALE_MIN: float = 0.5
const FONT_SCALE_MAX: float = 1.5
const FONT_SCALE_DEF: float = 1.0
static func get_font_scale() -> float:
return ProfileManager.get_float(FONT_SCALE_KEY, FONT_SCALE_DEF)
.clamp(FONT_SCALE_MIN, FONT_SCALE_MAX)
static func apply_font_scale(root: Node) -> void:
# 遍历 UILayer 下所有 Label 节点,按 scale 覆写 custom_font_size
for label in root.find_children("*", "Label", true, false):
label.add_theme_font_size_override(
"font_size", int(label.get_theme_font_size("font_size") * get_font_scale()))
UI 初始化时调用 SettingsManager.apply_font_scale(ui_root);字体缩放变更时发送
EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" }) 触发全局重渲染(EventID 19,见 implementation_plan.md §2.1)。
C1.3 减少闪光(S6 实现)
设置项 reduce_flash(bool,默认 false):
- 开启时:VFXManager 将爆炸 / 共鸣特效的帧率限制为 12fps,白闪持续时间从 0.2s → 0.05s。
EventID.GAME_CLEARED(GameCycleManager 进入 GAME_CLEARED 状态时发出)的全屏白闪在该模式下完全跳过。
C1.4 高对比度模式(S6 实现)
设置项 high_contrast(bool,默认 false):
- 开启时:UI 背景强制黑色
#000000,所有 Label 强制白色#FFFFFF。 - 实现:
SettingsManager.apply_high_contrast(ui_root)覆写主题颜色变量,不修改资源文件。
ADR-A3 — 音频架构补充细节 (Audio Architecture - Supplementary)
注意:AudioBus 层级结构、AudioBusID 常量、
_bgm_base_player/_music_layers字段声明、play_bgm()/play_sfx()接口均已在 §5.4 完整定义,本 ADR 仅补充 §5.4 未涉及的细节(2D 空间音效衰减参数、音量持久化、SFX 节流规则)。
A3.1 AudioBus 结构参考
⚠️ 此处为索引引用,不重新定义 Bus 结构,权威定义见 §5.4。
Bus 树:Master > BGM (BGM_Base, BGM_Layer) > SFX (SFX_Combat, SFX_Spatial) > UI > Ambience > Voice
AudioBusID Autoload 常量:MASTER / BGM / BGM_BASE / BGM_LAYER / SFX / SFX_COMBAT / SFX_SPATIAL / UI / AMBIENCE / VOICE
A3.2 动态音乐分层触发规则(补充)
| 屏内敌人数 / 状态 | BGM_Layer 激活层级 | 说明 |
|---|---|---|
| 0–4 | Layer 0(基础,仅鼓点 + 低音) | 常驻,即使 0 敌人 |
| 5–19 | Layer 1(主旋律 + 和弦) | EnemyManager.get_visible_count() ≥ 5 |
| ≥ 20 或 Boss 激活 | Layer 2(弦乐/合成铅声) | 最高战斗强度 |
| SHOP / MAIN_MENU 状态 | 独立 BGM(BGM_Base 直接切换) | BGM_Layer 全部静音 |
AudioManager._process() 每 0.5s 轮询一次 EnemyManager.get_visible_count();Boss 激活/死亡通过 EventBus 订阅(BOSS_PHASE_CHANGED / BOSS_KILLED)实时响应,无需轮询。
A3.3 2D 空间音效衰减
- SFX_Spatial Bus 下的
AudioStreamPlayer2D使用 Godot 内置attenuation_filter_db曲线。 - 衰减曲线参数(
max_distance=1200px,attenuation=1.8):
0px → 0dB, 400px → -6dB, 800px → -18dB, 1200px → -40dB(截止)。 - 超出
max_distance的音源直接stop(),不参与 0.1s 节流(已无声)。
A3.4 音量持久化
# settings_manager.gd — 音量持久化
# AudioBusID 是 Autoload,其常量为整数(bus index);不支持字符串下标访问,使用显式映射表
# bus_key(保存键)→ AudioServer bus index(AudioBusID 常量)的显式映射
const _VOLUME_BUS_MAP: Dictionary = {
"vol_master": AudioBusID.MASTER,
"vol_bgm": AudioBusID.BGM,
"vol_sfx": AudioBusID.SFX,
"vol_ui": AudioBusID.UI,
}
func load_audio_settings() -> void:
# SettingsManager._ready() 调用;将持久化音量值应用到 AudioServer
for save_key in _VOLUME_BUS_MAP:
var db: float = ProfileManager.get_float(save_key, 0.0) # 0.0 dB = 100%
AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db)
func save_audio_setting(save_key: String, db: float) -> void:
# UIManager 音量滑块 on_value_changed → SettingsManager.save_audio_setting("vol_bgm", db)
if not _VOLUME_BUS_MAP.has(save_key):
push_warning("SettingsManager: unknown audio save_key: %s" % save_key)
return
AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db)
ProfileManager.set_float(save_key, db)
EventBus.emit(EventID.SETTINGS_CHANGED, { "key": save_key })
存储位置:user://save_data.json(由 ProfileManager 管理,非 Run 存档)。
范围约定:音量滑块线性映射到 -40dB ~ 0dB;mute 状态用 -80dB 替代 set_bus_mute()
以避免 Godot 的 mute 标志在存档恢复时的边界问题。
A3.5 SFX 池节流规则补充
以下补充
implementation_plan.md §2.5.B中已有的 32 池规则:
| 规则 | 说明 |
|---|---|
| 同一音效 0.1s 内只播放 1 次 | _last_play_time[sfx_id] 字典节流 |
| 同帧相同位置的碰撞音效合并为 1 次 | BulletManager 碰撞处理后批量调用 AudioManager.play_sfx_batch() |
| Boss 语音(Voice Bus)不受 SFX 池限制 | 独立 AudioStreamPlayer 节点,最高优先级 |
| 玩家死亡时淡出所有 Bus(0.5s 内 -40dB) | AudioManager.fade_out_all(duration=0.5) |
ADR-A4 — 敌人 AI 寻路方案决策 (Enemy Pathfinding)
结论:Wave 1–14 普通敌人维持当前 Boid 斥力向量(无寻路);Wave 15+ Elite 及 Boss 敌人按需引入 Godot
NavigationAgent2D,并发上限MAX_PATHFINDING_ENEMIES=20,超限降级 Boid。S5 定案(2026-06-05,P-S5-AI-01 实测):采用
NavigationAgent2D方案,不切换 FlowField。20 个 Elite 同时寻路的_update_pathfinding_movement实测 ≈0.0496ms/帧,远低于 0.5ms 预算(约 10× 余量)。实现于enemy_manager.gd(GDScript 回退路径):程序化单凸矩形NavigationRegion2D+ 每精英Node2Dhost 挂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):
NavigationAgent2D20 个 Elite 同时寻路,_physics_process帧时间增量 < 0.5ms;若超标,切换 FlowField 方案并在本 ADR 更新决策结论。
过渡策略
S1–S4 期间:所有敌人继续使用 Boid 模式,不实现寻路(维持已知性能基线)。
S5 实现 Elite 敌人时:同步实现 NavigationAgent2D 方案并运行 P-S5-AI-01 基准测试,若达标则定案为 NavigationAgent2D;否则切换 FlowField。
9. Godot 项目结构 (Project Structure)
9.1 场景树结构 (Scene Tree)
主场景(Main.tscn)
Main (Node) ← res://scenes/Main.tscn,程序入口
├── UILayer (CanvasLayer, layer=10) ← 主菜单 / 过渡 / 全局遮罩
│ └── MainMenuUI (Control)
├── GameRoot (Node2D) ← 战斗场景的挂载点(动态 add_child / remove_child)
└── [C# Manager 子节点] ← ADR-L1 规则 4:C# 热路径作为对应 Autoload 的子节点
├── BulletManagerCs (Node) ← 挂在 BulletManager Autoload 下
├── EnemyManagerCs (Node) ← 挂在 EnemyManager Autoload 下
├── SpatialGridCs (Node) ← 挂在 SpatialGrid Autoload 下(或独立 Autoload)
├── SpellEvaluatorCs (Node) ← 挂在 SpellEvaluator Autoload 下
├── StatusManagerCs (Node) ← 挂在 StatusManager Autoload 下
└── ZoneManagerCs (Node) ← 挂在 ZoneManager Autoload 下
注意:C# 子节点由对应的 GDScript Autoload 在
_ready()中通过add_child(BulletManagerCs.new())创建,不出现在 .tscn 文件中(避免场景文件依赖 C# 程序集,保持热重载友好)。
战斗场景(BattleScene.tscn)
BattleScene (Node2D) ← res://scenes/BattleScene.tscn
├── World (Node2D) ← 世界坐标系(地图 TileMap 挂这里)
│ ├── TileMap (TileMap) ← 地图地形(S1 起实现)
│ ├── BulletRenderRoot (Node2D) ← 子弹 Node2D 池的挂载根(< 2000 弹幕时)
│ ├── MultiMeshRoot (Node2D) ← MultiMeshInstance2D(2000+ 弹幕时动态启用)
│ ├── EnemyRenderRoot (Node2D) ← 敌人 Sprite2D 节点池
│ ├── VFXRoot (Node2D) ← VFXManager 的粒子节点池(跨场景挂 Autoload 自身)
│ └── ZoneVFXRoot (Node2D) ← 地面效果贴图/Shader 渲染根
├── Player (CharacterBody2D) ← 玩家节点(PlayerManager 的 View 层)
│ ├── Sprite2D
│ ├── AnimationPlayer
│ └── CollisionShape2D
└── HUDLayer (CanvasLayer, layer=5) ← 战斗 HUD(血条、法术槽、波次信息)
├── HPBar (ProgressBar)
├── WaveLabel (Label)
├── SpellSlotPanel (HBoxContainer)
├── DamageNumberPool (Node) ← 伤害数字节点池(弹出后自动归还)
└── MiniMap (Control) ← S3 实现
CanvasLayer 分层规则
| Layer | 用途 | 挂载场景 |
|---|---|---|
| 0 | 世界空间(无 CanvasLayer) | World 节点树 |
| 5 | 战斗 HUD(跟随 Viewport) | BattleScene > HUDLayer |
| 8 | 商店 / 背包 UI(模态) | ShopManager 动态创建 |
| 10 | 全局 UI(主菜单 / 过渡动画 / 暂停菜单) | Main > UILayer |
| 15 | 调试叠加层(仅 Debug 构建) | Main > DebugLayer |
9.2 Autoload 初始化顺序(project.godot 注册顺序)
Godot 按 project.godot [autoload] 中的声明顺序依次调用 _ready()。下表定义了强制顺序及原因:
| 顺序 | Autoload 名 | 文件 | 依赖(_ready() 中调用的其他 Autoload) |
|---|---|---|---|
| 1 | CrashReporter |
crash_reporter.gd |
无(最先,捕获所有后续初始化崩溃) |
| 2 | EventID |
event_ids.gd |
无(纯常量,1–21) |
| 3 | DamageType |
damage_type.gd |
无(纯常量) |
| 4 | StatusID |
status_id.gd |
无(纯常量) |
| 5 | CoreFeatureTag |
core_feature_tag.gd |
无(纯常量) |
| 6 | AudioBusID |
audio_bus_id.gd |
无(纯常量,Bus index 整数) |
| 7 | ObjectPool |
object_pool.gd |
无(通用对象池,S0 起全局使用,impl §2.1 Layer-0) |
| 8 | TimeManager |
time_manager.gd |
无(时间缩放/GameTick,impl §2.1 Layer-0) |
| 9 | EventBus |
event_bus.gd |
无(事件总线基础设施,其他 Manager 订阅事件需要它先就绪) |
| 10 | DamageContextPool |
damage_context_pool.gd |
无(独立池,预分配 64 个槽) |
| 11 | SubPayloadRegistry |
sub_payload_registry.gd |
无 |
| 12 | SpatialGrid |
spatial_grid.gd + SpatialGridCs.cs |
无(C# 子节点在其 _ready() 中 add_child) |
| 13 | SpellRegistry |
spell_registry.gd |
无(同步扫描加载 .tres) |
| 14 | PassiveRegistry |
passive_registry.gd |
无(同步扫描加载 PassiveDef .tres) |
| 15 | StatusRegistry |
status_registry.gd |
无(同步扫描加载 StatusTypeDef .tres) |
| 16 | ProfileManager |
profile_manager.gd |
无(存档读写;Run存档:run_a/b.json,设置:save_data.json) |
| 17 | SettingsManager |
settings_manager.gd |
ProfileManager(读取用户音量/辅助功能设置) |
| 18 | BulletManager |
bullet_manager.gd + BulletManagerCs.cs |
SpatialGrid(spawn 时写入格子) |
| 19 | EnemyManager |
enemy_manager.gd + EnemyManagerCs.cs |
SpatialGrid(敌人位置写入格子), DamageContextPool |
| 20 | MinionManager |
minion_manager.gd |
SpatialGrid, EnemyManager(友伤判断), EventBus |
| 21 | StatusManager |
status_manager.gd + StatusManagerCs.cs |
StatusRegistry, DamageContextPool, EventBus |
| 22 | ZoneManager |
zone_manager.gd + ZoneManagerCs.cs |
SpatialGrid, StatusManager |
| 23 | SpellEvaluator |
spell_evaluator.gd + SpellEvaluatorCs.cs |
SubPayloadRegistry, BulletManager, DamageContextPool, EventBus |
| 24 | PlayerManager |
player_manager.gd |
SpellEvaluator, PassiveRegistry, EventBus, ProfileManager |
| 25 | VFXManager |
vfx_manager.gd |
EventBus |
| 26 | AudioManager |
audio_manager.gd |
SettingsManager(初始音量), EventBus |
| 27 | UIManager |
ui_manager.gd |
EventBus(订阅 GAME_STATE_CHANGED 等) |
| 28 | CoreRegistry |
core_registry.gd |
无(纯只读注册表,扫描 res://resources/cores/) |
| 29 | ConsumableRegistry |
consumable_registry.gd |
无(纯只读注册表,扫描 res://resources/consumables/) |
| 30 | BossManager |
boss_manager.gd |
EnemyManager, EventBus |
| 31 | WaveManager |
wave_manager.gd |
EnemyManager, BossManager, EventBus |
| 32 | DropManager |
drop_manager.gd |
EnemyManager, PlayerManager, SpellRegistry, EventBus |
| 33 | UpgradeSystem |
upgrade_system.gd |
PlayerManager, SpellEvaluator, EventBus |
| 34 | ShopManager |
shop_manager.gd |
PlayerManager, SpellRegistry, PassiveRegistry, CoreRegistry, ConsumableRegistry, EventBus |
| 35 | GameCycleManager |
game_cycle_manager.gd |
所有以上 Manager(协调者,最后初始化) |
初始化安全规则:编号 N 的 Autoload 的
_ready()中只能调用编号 < N 的 Autoload。若发现需要访问编号 ≥ N 的 Autoload,必须延迟到_ready()后的第一个_process()帧,或通过EventBus信号触发。C# 子节点时机:C# 子节点(
BulletManagerCs等)在其 GDScript Autoload 的_ready()中通过add_child()挂载。C# 子节点的_Ready()在add_child()返回前同步调用(Godot 规范),因此 C# 子节点可以安全访问其 GDScript 父节点,但不得直接访问尚未初始化的其他 Autoload(通过GetNode<T>("/root/XXX")延迟获取)。