Files
spellforge/docs/technical/architecture_design.md
T
joywayerandClaude Opus 4.8 ad2f4c0bc6 docs: 整理文档目录并对齐代码现状
- 将开发过程/归档文档迁至 docs_dev/(development_plan、certification_checklist、
  已废弃的 Cocos 架构草案 archived_cocos_architecture_draft),并修正全部跨引用
- 新增根 README.md(项目介绍,暂定名 Spellforge)与 docs_dev/README.md 索引
- 新增 docs_dev/doc_code_audit_2026-07-20.md:文档 vs 代码交叉审计报告(经 6
  路对抗性复核,零证伪),含「代码更优 / 文档更优 / 中性」判定汇总
- 在 docs/ 各设计·技术·机制文档就地加「实现现状 (2026-07-20)」callout:
  追认代码更优实现(纯 JSON 数据驱动、SpatialGrid-only 碰撞、MultiMesh 单档、
  存档选最新槽等),订正陈旧/矛盾内容(.tres→JSON、Boss HP/阈值/波次、EventID、
  StatusManager.apply 签名等),标记未实现功能(C# 热路径、Mana、元进展、
  Boss 阶段/抗性、Tutorial、轨迹/连锁/催化等)与 latent bug(CoreFeatureTag 位运算、
  pierce 空操作、MAX_OPS 不读 cpu_limit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:35:55 +08:00

185 KiB
Raw Blame History

模块化战术土豆 (Modular Tactical Potato) - 架构设计文档

1. 概述 (Overview)

本项目旨在开发一款基于 Godot 4.x 的 Roguelite 动作射击游戏。 核心体验结合了 Brotato (土豆兄弟) 的快节奏割草体验与 Noita 的深度法术构建系统。 架构设计的首要目标是 高性能(支持同屏大量单位与弹幕)与 极高的可扩展性(特别是武器系统的模块化)。

1.1 设计目标

  1. 超模块化武器系统 (Hyper-Modular Weapon System):超越 Noita 的线性构建,引入更灵活的管道流与事件钩子机制。
  2. 高性能战斗引擎:支持同屏 500+ 敌人,2000+ 弹幕,60FPS 稳定运行。
  3. 数据驱动 (Data-Driven):所有游戏内容(法术、属性、波次)完全配表化/JSON化。

2. 系统分层架构 (Layered Architecture)

采用能够严格分离数据与表现的架构模式。虽然 Godot 是节点/组件式的,但在核心战斗层我们将采用 Manager + Data 的方式来规避逐节点更新带来的开销。

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/*.json7 个文件:spells / cores / enemies / waves / resonance / status_effects / balance),各 Manager 自行 FileAccess 直读;全项目.tres 数据资源(仅 assets/ui/ui_theme.tres),无 resources/ 目录。图中 ConfigMgr autoload 虽已注册(project.godot),但从未被任何调用方引用,是死代码。文中后续所有 res://resources/**/*.tres 路径均为设计蓝图,与实际 data/*.json 布局不符。详见 docs_dev/doc_code_audit_2026-07-20.md


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 功能专用
                                   # ⚠️ 必须声明时初始化为长度 4PackedFloat32Array() 默认长度 0
                                   # 直接访问 registers[0] 会触发 out-of-bounds 崩溃。
                                   # 生命周期:携带 CoreFeatureTag.PERSISTENT_MEMORY 的 Core 跨帧保留此值;
                                   # 非持久 Core 下,帧结束后由 SpellEvaluator 调用 registers.fill(0.0) 清零。
                                   # 与 CastState.registers 的区别:
                                   #   SpellContext.registers → 跨帧持久寄存器(属于 SpellContext 对象)
                                   #   CastState.registers    → 单次 execute() 内临时工作寄存器,每次重置
                                   # core_wand_design.md §6 将此字段称为 "memory_bank"(同一字段)
var current_payload: ProjectileDef  # 当前正在构建的弹头定义

# 池化归还时必须调用 reset(),防止脏数据污染下次施法
func reset() -> void:
    caster_id = -1
    target = Vector2.ZERO
    stats.reset()          # CastStats 内部清零
    payloads.clear()       # 清空弹头队列(不销毁元素,由 ProjectileDef 池负责回收)
    current_payload = null
    # ⚠️ registers 不在此处清零:
    # 持久 CorePERSISTENT_MEMORY)由 SpellEvaluator 决定是否保留;
    # 非持久 Core 由 SpellEvaluator 在帧结束后调用 registers.fill(0.0)。

# spell_node.gd - 基类,子类重写 execute()
# 所有系统通过 SpellType 枚举访问节点类型,禁止在业务代码中直接写裸整数(0/1/2/3)。
enum SpellType {
    ACTION   = 0,   # 产生实际飞行物或即时效果(如 spark_bolt、nuke
    MODIFIER = 1,   # 修改下一个 ACTION 的属性(如 damage_plus、homing
    TRIGGER  = 2,   # 将后续法术打包为 SubPayload,在弹体命中时执行(子母弹核心)
    LOGIC    = 3,   # 条件跳转、循环、寄存器读写(IF_HP_LOW、LOOP 等)
    SCOPE_CLOSE = 4 # 虚节点,预编译时自动插入,标记 TRIGGER/LOGIC 作用域边界
                    # 不出现在 SpellRegistry 中,玩家不可购买
}

class_name SpellNode
extends RefCounted

var id: String
var type: SpellType = SpellType.ACTION  # 业务代码赋值时须使用 SpellType.XXX,禁止写裸整数
var element_tags: Array[String] = []   # 元素亲和性标签,供共鸣系统(§3.4.C Resonance)进行模式匹配
                                        # 内容示例:["tag:water"]、["tag:lightning"]、[]
                                        # resonance_recipes.json 中 "pattern" 字段的每个元素
                                        # 对应本字段中的一个字符串;预编译时 _check_resonance()
                                        # 遍历 deck.nodes,比对 element_tags 中是否包含目标 tag
                                        # 数据来源:SpellRegistry 从 res://resources/spells/*.tres 加载

var shape_icon: String = ""            # ADR-C1: 无障碍形状标识,与颜色配合供色盲玩家区分元素。
                                        # 取值约定(UIAtlas 中对应图标键):
                                        #   "triangle"  → 火系 (Fire)
                                        #   "diamond"   → 冰系 (Ice)
                                        #   "circle"    → 水系 (Water)
                                        #   "square"    → 土系 (Earth)
                                        #   "star"      → 雷系 (Lightning)
                                        #   "cross"     → 暗系 (Dark)
                                        #   ""          → 无元素(纯伤害类)
                                        # 渲染规则:UI 显示元素标签时必须同时绘制颜色背景 + shape_icon
                                        # VFXManager 在命中 VFX 上叠加 1.5× 字号的 shape_icon TextureRect。
                                        # 禁止仅用颜色区分元素(ADR-C1 强制要求)。

# 核心执行函数:修改 Context 或产生行为,子类必须重写
func execute(ctx: SpellContext, deck: SpellDeck) -> void:
    pass

# projectile_def.gd - ACTION 节点执行时填充,最终传给 BulletManager 批量生成弹体
# 通过对象池复用,不触发 GCSpellContext.payloads 以 Array[ProjectileDef] 形式积累
class_name ProjectileDef
extends RefCounted

# 业务代码必须通过 DamageType.PHYSICAL 等常量引用,禁止直接写 0/1/2/3/4。
# 此枚举也是 BulletManager SoA 中 damage_type 字段、StatusManager DoT 分类的统一来源。
enum DamageType {
    PHYSICAL  = 0,   # 物理伤害:受护甲(Armor)平铺减免,不受元素抗性影响
    FIRE      = 1,   # 火焰伤害:受 attunement_fire 加成;可触发点燃/爆燃状态
    ICE       = 2,   # 冰霜伤害:受 attunement_ice 加成;可触发冻结/减速状态
    LIGHTNING = 3,   # 雷电伤害:受 attunement_lightning 加成;可触发麻痹/连锁导电
    POISON    = 4    # 毒素伤害:受 attunement_poison 加成;施加 DoT,叠层上限受精通影响
}
# ─────────────────────────────────────────────────────────────────────────────

# ── 伤害 ──────────────────────────────────────────────────
var base_damage: float = 5.0         # 计算前基础伤害值(已含 CastStats.damage_add 累积)
var damage_mult: float = 1.0         # 乘算系数(暴击/元素弱点已合入)
var damage_type: int = DamageType.PHYSICAL  # DamageType 枚举(见上方定义)

# ── 运动 ──────────────────────────────────────────────────
var speed: float = 600.0             # 初速度(像素/秒)
var direction: Vector2 = Vector2.RIGHT
var spread_angle_rad: float = 0.0    # 在 direction 基础上的随机偏转幅度
var homing_force: float = 0.0        # 每帧转向力(0 = 直线)
var acceleration: float = 0.0        # 速度加速度(可为负,做减速球)

# ── 生命周期 ──────────────────────────────────────────────
var lifetime: float = 3.0            # 最大存活时间(秒)
var pierce_remaining: int = 0        # 剩余穿透次数(0 = 首次命中销毁)
var bounce_remaining: int = 0        # 剩余弹射次数

# ── 碰撞体积 ──────────────────────────────────────────────
var radius: float = 6.0              # 碰撞圆半径(像素)
var size_mult: float = 1.0           # 外观与碰撞同步缩放系数

# ── 触发器 ────────────────────────────────────────────────
var on_hit_payload_id: int = -1      # SubPayloadRegistry ID-1 = 无 TRIGGER
var on_expire_payload_id: int = -1   # 到期/撞墙触发;-1 = 无
var on_kill_payload_id: int = -1     # 击杀触发;-1 = 无

# ── 归属 ──────────────────────────────────────────────────
var owner_id: int = -1               # 根源实体 ID(归击杀、吸血计算)
var source_tags: int = 0             # 位掩码: PRIMARY=1, TRIGGERED=2, SUMMON=4, REFLECTED=8
var spawn_position: Vector2          # 发射原点(填充时从 caster 位置获取)

# ── 触发系数 ───────────────────────────────────────────────
var proc_rate: float = 1.0           # Proc 系数(0.0~1.0)。高频攻击(加特林)应设置为 0.1~0.3,
                                     # 防止高攻速过度触发 on_hit 特效/技能。
                                     # 命中时实际触发概率 = SpellNode.proc_chance × proc_rate。
                                     # 默认 1.0(不削减),Tier 1 Action 通常无需修改。

func reset() -> void:
    base_damage = 5.0; damage_mult = 1.0; damage_type = DamageType.PHYSICAL
    speed = 600.0; direction = Vector2.RIGHT; spread_angle_rad = 0.0
    homing_force = 0.0; acceleration = 0.0; lifetime = 3.0
    pierce_remaining = 0; bounce_remaining = 0
    radius = 6.0; size_mult = 1.0
    on_hit_payload_id = -1; on_expire_payload_id = -1; on_kill_payload_id = -1
    owner_id = -1; source_tags = 0; proc_rate = 1.0
    spawn_position = Vector2.ZERO  # 必须重置,否则池化复用时携带上帧发射原点

B. 法术解析器 (The Evaluator)

为了高性能,解析器必须 低 GC 压力 (Low-GC)。在施法计算帧,尽量避免分配新对象。

  • 使用预分配的 SpellContext 对象池(Array 作为栈管理空闲对象)。
  • 使用 Array(作为栈)模拟递归,防止深层递归爆栈。

C. 高级特性:动态插槽与逻辑门

  • Logic Spells (逻辑法术):引入 IfHPBelow, OnKillAction, EveryNbShot 等逻辑块,让玩家实现“如果血量低于30%,则发射吸血导弹”的构建。
  • Variable Storage (变量存储):允许法术在法杖上写入/读取临时变量(例如:记录连击数)。

3.3 扩展性设计

所有法术行为通过 Strategy Pattern (策略模式) 实现。 新增一个法术只需:

  1. 在 JSON(或 Godot .tres Resource)中定义 ID 和贴图路径。
  2. 实现一个继承自 SpellNode 的 GDScript 类。
  3. 在注册表中注册。

3.4 进阶构建机制 (Advanced Mechanics) - 玩法增强

为了超越“线性堆砌”的枯燥感,架构支持以下三种深度玩法机制。

MVP 功能边界说明

  • P0(必须实现)4类 SpellNodeACTION / MODIFIER / TRIGGER / LOGIC+ LINEAR Core。
  • P1(迭代加入):拓扑插槽系统(MATRIX/CIRCUIT Core)、共鸣系统。
  • P2(可选高级内容):状态寄存器 + 条件跳转——此功能面向极硬核玩家,仅通过特殊 Core 解锁,不在新手 UI 中暴露

A. 拓扑插槽系统 (Topology Slots)【P1】

核心(Core)不再仅仅是一个列表,它可以是一个 2D 网格电路板

  • adjacency_bonus (邻接加成):某些插槽有物理连接。例如,将 [火元素] 放在 [高压槽] 旁边,会自动获得 +20% 范围。
  • Circuit Logic (电路逻辑):法术流不再只是从左到右。核心板可以有分叉路口,玩家需要用 [分流器法术] 将能量流引导到不同的分支。
  • 详见 docs/design/core_wand_design.md

拓扑适配层 (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 而非 SpellDeckspell_evaluator.gd:36)。
  • execute_compiled(compiled, caster_id: int, spawn_pos: Vector2) —— 执行期不传 ctx/corefeature_tagsCompiledDeck 编译期快照读取(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 Ai < grid_cols);Row B 槽位法术不作为独立执行节点,仅用于邻接加成注入。
    var out: Array[SpellNode] = []
    var row_a_count: int = core.grid_cols  # Row A 的槽数 = grid_cols(如 2×4 矩阵中 = 4
    for i in row_a_count:
        var adj_idx: int = i + core.grid_cols  # Row B 中与 slot[i] 垂直对齐的槽索引
        # 空槽守卫:deck.nodes[i] 可能为 null(槽为空)
        var row_a_node: SpellNode = deck.nodes[i] if i < deck.nodes.size() else null
        var row_b_node: SpellNode = deck.nodes[adj_idx] if adj_idx < deck.nodes.size() else null
        if row_a_node == null:
            continue  # 空槽:Row A 无法触发,跳过(Row B 对应槽的邻接加成也无效)
        if row_b_node != null and _has_adjacency_bonus(row_a_node, row_b_node):
            # 注入隐式 MODIFIER 节点,效果见 core_wand_design.md §2.2 邻接加成效果表
            out.append(_make_adjacency_mod(row_a_node, row_b_node))
        out.append(row_a_node)  # 仅追加 Row A 节点到执行序列
        # Row B 节点(row_b_node)不追加 → 不独立执行,仅作为邻接加成来源
    return CompiledDeck.new(out)

# 对 CIRCUIT 拓扑的有向图做 Kahn 算法拓扑排序,主链保持线性;
# 分叉节点展开为独立 SubPayload 并注册到 SubPayloadRegistry。
# edges 读自 core.edges(顶层字段);CoreDefinition 须声明 @export var edges: Array = [](见 core_wand_design.md §1
# 性能说明:仅在玩家关闭背包时触发(非 _physics_process 热路径),slot_count ≤ 16O(V+E) < 0.1ms。
func _flatten_circuit(deck: SpellDeck, core: CoreDefinition) -> CompiledDeck:
    # 1. 从 core.edges 读取顶层有向边列表;若为空,降级为 LINEAR 处理(见 core_wand_design.md §2.3
    var edges: Array = core.edges
    if edges.is_empty():
        push_warning("_flatten_circuit: core.edges 为空,降级为 LINEAR 处理(Core=%s" % core.id)
        return CompiledDeck.new(deck.nodes)

    # 2. Kahn 算法:BFS 拓扑排序(保证无环时的确定性线性化顺序)
    var in_degree: Array[int] = []
    var adj: Array         = []     # adj[i] = Array[int] 出边目标
    in_degree.resize(core.slot_count)
    adj.resize(core.slot_count)
    for i in core.slot_count:
        in_degree[i] = 0
        adj[i] = []
    for e in edges:
        adj[e["from"]].append(e["to"])
        in_degree[e["to"]] += 1

    # ⚠️ Kahn 循环结束后 in_degree 全归零;_collect_branch_path() 需要原始入度来判断汇聚点
    # orig_in_degree[nxt] > 1 表示多入边)。在 Kahn 循环前保存副本供分支收集函数使用。
    var orig_in_degree: Array[int] = in_degree.duplicate()  # Kahn 前原始入度快照

    var queue: Array[int] = []
    for i in core.slot_count:
        if in_degree[i] == 0:
            queue.append(i)

    var topo_order: Array[int] = []
    while not queue.is_empty():
        var cur: int = queue.pop_front()
        topo_order.append(cur)
        for nxt: int in adj[cur]:
            in_degree[nxt] -= 1        # 修改工作副本(orig_in_degree 保留原始值供汇聚点检测)
            if in_degree[nxt] == 0:
                queue.append(nxt)

    if topo_order.size() != core.slot_count:
        push_error("_flatten_circuit: 检测到环路,无法线性化!Core=%s" % core.id)
        return CompiledDeck.new([])  # 返回空 Deck,游戏不崩溃但该法杖无法施法

    # 3. 按拓扑顺序输出节点,出度 > 1 的槽注入 LOGIC_FORK 指令
    var out: Array[SpellNode] = []
    # in_branch_payload 记录已纳入某 SubPayload 的槽索引,防止主链循环将分支节点重复追加执行
    var in_branch_payload: Dictionary = {}
    for slot_idx in topo_order:
        # 跳过已被分支 SubPayload 收纳的节点,防止双重执行
        if in_branch_payload.has(slot_idx):
            continue
        var node: SpellNode = deck.nodes[slot_idx] if slot_idx < deck.nodes.size() else null
        if node == null:
            continue  # 空槽跳过
        if adj[slot_idx].size() > 1:
            # 分叉检测基于出度(出边数 > 1);"splitter" tag 保留为 UI/编辑器标注用途,不参与执行判断

            # 分叉槽自身的法术节点先行追加(分叉前执行,通常为空但允许 MODIFIER/ACTION
            out.append(node)

            # 为每条出边收集完整分支路径(含多节点分支,_collect_branch_path 沿单出边延伸直到叶/汇聚点)
            var branch_payload_ids: Array[int] = []
            for neighbor_idx in adj[slot_idx]:
                var branch_path: Array[SpellNode] = _collect_branch_path(
                    neighbor_idx, adj, orig_in_degree, deck, in_branch_payload)
                    # orig_in_degreeKahn 前快照,确保汇聚点检测正确
                    # in_branch_payload:共享引用,函数内登记已访问槽,主链循环可跳过
                if not branch_path.is_empty():
                    branch_payload_ids.append(SubPayloadRegistry.register(branch_path))
            if not branch_payload_ids.is_empty():
                var fork_node: SpellNode = SpellNode.new()
                fork_node.type = SpellType.LOGIC
                fork_node.id = "LOGIC_FORK"
                fork_node.set_meta("fork_branch_ids", branch_payload_ids)
                out.append(fork_node)
        else:
            out.append(node)
    return CompiledDeck.new(out)

# 从 start_idx 沿单出边路径收集节点序列,直到:
#   叶节点(出度 0)、汇聚点(orig_in_degree > 1,属于主链或另一分支)、嵌套分叉(出度 > 1,递归处理)
# orig_in_degree:必须传 Kahn 前的原始入度副本,用于汇聚点判断
# in_branch_payload:共享字典,函数内每访问一个槽即写入,消除主链双重执行
func _collect_branch_path(start_idx: int, adj: Array, orig_in_degree: Array,
                          deck: SpellDeck,
                          in_branch_payload: Dictionary) -> Array[SpellNode]:
    var path: Array[SpellNode] = []
    var cur: int = start_idx
    var visited: Dictionary = {}
    while cur >= 0 and not visited.has(cur):
        visited[cur] = true
        in_branch_payload[cur] = true  # 登记此槽已纳入 SubPayload,主链循环将跳过
        if cur < deck.nodes.size() and deck.nodes[cur] != null:
            path.append(deck.nodes[cur])

        if adj[cur].size() > 1:
            # 嵌套分叉:为每条子出边递归收集完整路径并注册 SubPayload,
            # 然后将嵌套 LOGIC_FORK 节点插入当前 path。
            var nested_branch_ids: Array[int] = []
            for nxt_idx: int in adj[cur]:
                var nested_path: Array[SpellNode] = _collect_branch_path(
                    nxt_idx, adj, orig_in_degree, deck, in_branch_payload)
                if not nested_path.is_empty():
                    nested_branch_ids.append(SubPayloadRegistry.register(nested_path))
            if not nested_branch_ids.is_empty():
                var nested_fork: SpellNode = SpellNode.new()
                nested_fork.type = SpellType.LOGIC
                nested_fork.id = "LOGIC_FORK"
                nested_fork.set_meta("fork_branch_ids", nested_branch_ids)
                path.append(nested_fork)
            break  # 嵌套分叉后路径在各子 SubPayload 中独立延伸,本路径就此结束

        elif adj[cur].size() == 1:
            var nxt: int = adj[cur][0]
            if orig_in_degree[nxt] <= 1:  # 单入边,继续延伸
                cur = nxt
            else:
                break           # 汇聚点(多入边),停止;该节点由主链处理

        else:
            break               # 叶节点(出度 0
    return path

E3 解决方案MATRIX 邻接加成通过 _flatten_matrix 在预编译阶段注入隐式 MODIFIER 节点,SpellEvaluator 运行时不感知 Core 拓扑,邻接触发与普通 MODIFIER 执行路径完全一致。

B. 状态寄存器与图灵完备 (State Registers & Turing Completeness)【P2 可选】

⚠️ 设计注意:本功能仅通过特殊 Corepersistent_memory / circuit_fork)解锁,新手不会接触寄存器和跳转指令。

为了支持硬核玩家实现真正的“图灵完备”构建,架构预留对状态存储条件跳转循环的支持。

  1. Registers (寄存器):

    • SpellContext 中引入 MemoryBank,提供 4 个 Float 寄存器 (R1, R2, R3, R4)。
    • 寄存器在同一帧内所有法术间共享,甚至可以跨帧持久化(如果法杖配置了 Persistent Memory 核心)。
  2. Instruction Set (指令集法术):

    • OPS: Add R1, 1 (加法), Set R2, HP_Percent (赋值).
    • JUMP: JumpIf R1 > 10, Label_A (条件跳转到标签A).
    • LABEL: Label_A (标记跳转点).
  3. Recursion Control (递归控制):

    • 为了防止死循环 (While(true)), 解释器引入 MaxOpLimit (最大操作数限制,例如 100 ops/frame)。超过限制强制中断并在此帧失效。
  4. 实战应用:

    • 计数器: 每射击 3 次,第 4 次发射强力火球。
    • 动态模式切换: 根据敌人距离 (R1 = EnemyDistance),如果近则跳转到 [霰弹逻辑],如果远则跳转到 [狙击逻辑]。

C. 共鸣系统 (Resonance System)【P1】

预编译阶段 (Pre-compile Phase) 进行模式匹配。

  • 如果检测到 [水] 和 [电] 法术在执行链中紧邻,架构自动插入一个隐藏的 [导电反应] 中间件。
  • 这允许设计隐藏配方(Hidden Recipes),鼓励玩家探索特定组合。
  • 发现机制:法术卡片上显示元素亲和性标签(如 雷、💧 水),引导玩家尝试组合,而非完全盲猜。
  • 缓存失效:玩家在商店修改法术顺序时,自动触发重新预编译,确保共鸣结果与当前 Deck 始终一致。
  • 共鸣配方存储 (Recipe Schema):配方存储于 res://resources/resonance_recipes.json,结构如下:
    [
      {
        "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),同时在对应位置插入共鸣结果节点。 _consumedSpellDeck(运行时对象)的字段,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 维护一个紧凑的 PackedFloat32ArraySoA 布局)。 每颗子弹占用 BULLET_STRIDE = 12 个 float,在数组中偏移量 = bullet_id × BULLET_STRIDE

      偏移 字段 说明
      +0 x 世界坐标 X(像素)
      +1 y 世界坐标 Y(像素)
      +2 vx 速度 X(像素/秒)
      +3 vy 速度 Y(像素/秒)
      +4 lifetime 剩余存活时间(秒),归零时销毁
      +5 radius 碰撞圆半径(像素)
      +6 base_damage 命中基础伤害值
      +7 damage_mult 伤害乘算系数
      +8 damage_type DamageType 整数(DamageType 枚举,0=PHYSICAL…4=POISON
      +9 owner_id 根源实体 IDfloat 存 int,精度足够 24-bit ID
      +10 source_tags 位掩码(PRIMARY/TRIGGERED/SUMMON/REFLECTED
      +11 acceleration 速度加速度(像素/秒²,0=匀速)

      冷数据(非热路径) 存储于 _bullet_contexts: Dictionarykey=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_snapshotO(M)
          # 与 homing 子弹同一快照,无额外查询开销;仅在发射时调用,非每帧热路径
          var best_pos  := Vector2.ZERO
          var best_dist := max_dist * max_dist  # 比较平方距离,避免 sqrt
          for i in range(_enemy_count):
              if _visible_flags[i] == 0: continue  # 跳过屏外(可选:也可全扫)
              var px := _data[i * ENEMY_STRIDE + 0]
              var py := _data[i * ENEMY_STRIDE + 1]
              var d2 := (px - origin.x) * (px - origin.x) + (py - origin.y) * (py - origin.y)
              if d2 < best_dist:
                  best_dist = d2
                  best_pos  = Vector2(px, py)
          return best_pos
      

      EnemyManager SoA 完整槽位布局(权威来源:implementation_plan.md §2.3.CP6-N45):

      ENEMY_STRIDE = 8
      [ x,  y,  vx,  vy,  hp,  hp_max,  faction_and_type,  status_bits ]
        0   1    2    3    4      5             6                  7
      
      • +4 hp:当前生命值;+5 hp_max:最大生命值(BossManager 的 get_hp_percent 读取此两槽)
      • +6 faction_and_type:高 16 位 = faction0=敌方, 1=友方);低 16 位 = enemy_type_id
      • +7 status_bits:燃烧/冰冻/中毒等位掩码(精确 DoT 计时存 _enemy_contexts 冷数据)
      • _death_position_cache_entity_type_map_entity_index_map 为类成员 Dictionaryspawn/kill 时 GDScript 维护。
    • 自动瞄准目标选取算法:法杖自动开火时,发射方向需要选取"目标敌人"。目标选取逻辑集中在 PlayerManager.get_aim_target() 中,优先级如下:

      1. 手柄/鼠标显式瞄准:若存在显式输入方向(aim_vector.length() > 0.3),直接使用该方向,不进行目标锁定。
      2. 最近敌人(默认):从 _enemy_pos_snapshot 线性扫描,取欧氏距离最小的存活敌人位置,作为开火方向。时间复杂度 O(M),与 Homing 快照共用,无额外查询。
      3. 自定义优先级扩展(P2:商店购买"目标优先级"被动时(如"优先最低 HP"、"优先最近"),PlayerManager.aim_priority 属性切换选取算法,但当前 P0/P1 阶段固定为最近敌人。
      # 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: int0 = Area2D1 = 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-onlyBulletManager._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-onlyNode 池档位已无意义,此简化为代码更优。详见 审计报告

同屏弹幕数 渲染方案 碰撞方案 说明
< 200 Node Pool (Node2D) Area2D 全功能,支持粒子特效、信号回调
2002000 Node Pool + Dirty Sync Area2D 仅视口内节点同步 position
2000+ MultiMeshInstance2D 自定义 SpatialGrid 完全绕过节点树,牺牲特效换取帧率
  • Node Pooling: 严格的节点池管理(使用 Array 存储空闲节点,Node.process_mode = DISABLED 代替真实销毁)。

  • MultiMeshInstance2D: 仅在同屏弹幕超过 2000 且切换为 SpatialGrid 碰撞时启用。启用后 BulletManager 不再为每颗子弹维护 Node 实例,改为直接更新 MultiMesh.transform_array

    SoA 索引 ↔ MultiMesh transform_array 索引映射规则

    BulletManager 的 _data: PackedFloat32Array 使用 紧凑活跃列表(Compact Active List 布局:索引 0 到 _active_count - 1 始终是存活子弹,dead-and-removed 子弹用 swap-and-pop 填入空位。MultiMesh 的 instance_count 始终等于 _active_count,两者共享同一索引——SoA 中第 i 个子弹对应 MultiMesh 第 i 个实例变换。

    # 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 ShaderMultiMesh 使用单一材质 + Atlas Texturetransform_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-01C# 热路径 S1 填充后预计更低)
EnemyManagerCs._PhysicsProcess C# 2.0ms 0.23ms 1000 敌人直线追踪 + fill_pos_snapshotP-S0-02Boid 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 逐 tickS4 验收后填入,P-S4-02;与 implementation_plan §2.4 统一)
ZoneManagerCs._PhysicsProcess C# 1.0ms 64 zones × SpatialGrid 查询(S5 验收后填入)
GDScript 游戏循环(WaveManager / EventBus dispatch GDScript 1.5ms 事件分发 + 波次状态机
MultiMesh transform 上传 CPU→GPU Godot 渲染器 1.5ms 2000+ 子弹时(S6 验收后填入)
Godot 渲染器 Draw Call 基线 引擎 2.0ms 背景 + 角色 + HUD(S1 场景建立后填入)
保留余量(突发帧:GC、资源加载) 1.17ms
合计 16.67ms ≈ 0.60msS0 GDScript 基线,2000弹+1000敌;FPS 180

S0 实测摘要(2026-06-04:机器 RTX 2060Godot 4.6.2 stable monoWindows 10。2000 子弹 + 1000 敌人同屏,GDScript fallback 运行(C# 热路径骨架未激活)。physics_frame_time_msecGodot Monitor= 0.050.13msFPS 稳定 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.5GBPC 目标 RSS < 512MB

内存类别 PC 预算 Switch 预算 说明
代码 + GDScript VM + C# CLR 80MB 200MB Mono 运行时常驻较大
纹理资源(Atlas + VFX + UI 150MB 400MB 压缩格式:PC=DXT5Switch=ASTC4×4
音频资源(BGM 流式 + SFX 预加载) 30MB 80MB BGM 流式播放,SFX 池全量预加载
BulletManager SoA2000 子弹) < 1MB < 1MB BULLET_STRIDE=12 × 2000 × 4B ≈ 96KB
EnemyManager SoA1000 敌人) < 1MB < 1MB ENEMY_STRIDE=8 × 1000 × 4B ≈ 32KB
SpellContext 对象池(32 实例) < 1MB < 1MB 每实例约 2KB(含 payloads Array
Node Pool(子弹节点 2000 个) 20MB 50MB 每 Node2D 约 10KBMultiMesh 激活后降至 ~0
VFX 粒子节点池(200 个) 5MB 10MB MAX_ACTIVE_VFX=200
DPS 环形缓冲区 + 其他运行时 < 1MB < 1MB 各 PackedFloat 数组合计
存档文件(user:// < 1MB < 1MB JSON 明文 < 100KB;含 HMAC 签名
合计(估算) ~290MB ~745MB Switch 远低于 2.5GB 限制

验收规则

  • S6 P0 验收 P-S6-07Godot Profiler → Memory 标签页实测 RSSPC < 512MBSwitch 目标 < 2.5GB。
  • 热场景峰值Wave 20 Boss 战(最大同屏实体数)测量内存峰值,作为 S6 发布基线。
  • 纹理内存监控RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TEXTURE_MEM_USED) 不超过 PC 预算的 150MB。

5. 游戏循环与系统集成 (Game Loop & System Integration)

5.1 GameCycleManager 状态机 (FSM)

⚠️ 实现现状 (2026-07-20 审计)GameCycleManager Autoload 不存在。顶层状态机现内嵌在场景子节点 combat_manager.gd,仅 5 态INIT/BATTLE/SETTLEMENT/SHOP/GAME_OVER),缺文档的 WAVE_INTRO 开场、WAVE_RESULT 升级卡结算、GAME_CLEARED 通关、PAUSE 独立态(暂停改由 get_tree().paused 处理)。GAME_STATE_CHANGED payload 用字符串态名而非枚举。同理 §5.10 的 UIManager Autoload 也不存在——全部 UI 在 combat_s2.gd 程序化构建,无 HUD.tscn/shop.tscn 等场景,DamageNumber 飘字未实现。下文 GameCycleManager/UIManager 相关设计保留作目标蓝图。详见 审计报告

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 > 20Endless模式) SHOP wave_num++ShopManager.restock(wave_num)
GAME_OVER / GAME_CLEARED 玩家点击"返回菜单" MAIN_MENU 卸载 BattleScene;重置所有 Autoload Manager
任意战斗状态 Input.is_action_just_pressed("pause") PAUSE 保存 _prev_stateEngine.time_scale = 0
PAUSE 再次按暂停键 _prev_state Engine.time_scale = 1

Manager Reset 协议

每次从 GAME_OVER/GAME_CLEARED 返回 MAIN_MENU 时,GameCycleManager 负责按顺序重置所有 Autoload:

func _reset_all_managers() -> void:
    # 顺序重要:先停止所有计算,再清理数据,最后重置 UI
    BulletManager.reset()        # 清空所有子弹 SoA 数据
    EnemyManager.reset()         # 清空所有敌人 SoA 数据
    MinionManager.reset()        # 清空所有召唤物
    ZoneManager.reset()          # 清空所有地面效果
    StatusManager.reset()        # 清空所有状态效果
    SpellEvaluator.reset()       # 清空 SubPayloadRegistry
    DamageContextPool.reset()    # 归还所有在途 context(防止下局 context 泄漏)
    PlayerManager.reset()        # 重置血量/位置/蓄力状态
    WaveManager.reset()          # wave_num = 0spawn 队列清空
    ShopManager.reset()          # 商品列表清空
    ProfileManager.clear_run()   # 清除本局存档

5.2 WaveManager 框架设计

WaveManager 负责读取波次配置、按时序 spawn 敌人、检测清场条件、向 GameCycleManager 上报结果。

波次配置 JSON 格式

// 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 让玩家决定装到哪个 Coretrue = 装 active_core
    })

5.4 AudioManager 框架设计

AudioManager 管理 AudioBus 层级、动态音乐分层、音效池和空间音频。

权威定义说明:本节为 AudioBus 结构的唯一权威来源。ADR-A3(§文档末尾)提供补充规则(空间音效衰减参数、节流规则、音量持久化细节),不重新定义 Bus 结构。两者合并阅读。

AudioBus 层级(权威)

Master
├── BGM            (背景音乐总线,-6dB 预衰减)
│   ├── BGM_Base   (基础旋律层,常驻播放,AudioStreamPlayer 独占)
│   └── BGM_Layer  (动态叠加层:战斗强度 / Boss 阶段驱动;3 轨并行,按强度 fade-in)
├── SFX            (战斗音效总线,-3dB)
│   ├── SFX_Combat (子弹/爆炸/伤害,来自 BulletManager/EnemyManager)
│   └── SFX_Spatial(2D 空间音效,挂 AudioEffect2DPan + 2D Reverbmax_distance=1200px)
├── UI             (UI 音效,不受空间衰减,-3dB)
├── Ambience       (循环环境音,-9dB)
└── Voice          (语音 / Boss 台词,最高优先级,不受 SFX 池限制)

AudioBusID 常量 Autoload(代码中所有 Bus 名称必须通过此 Autoload 引用,禁止硬编码字符串):

# 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.tresGodot 内置 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 音频资源索引

SfxLibraryBgmLibrary 是纯静态字典,在 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_processtick DoT 并倒计时 durationswap-and-pop 移除过期实例
func _physics_process(delta: float) -> void:
    var i := 0
    while i < _active_count:
        var base := i * STATUS_STRIDE
        _data[base + 2] -= delta              # remaining_duration 倒计时
        _data[base + 3] += delta              # tick_accumulator 累积
        var typedef: StatusTypeDef = StatusRegistry.get(int(_data[base + 1]))
        while _data[base + 3] >= typedef.tick_interval:
            _data[base + 3] -= typedef.tick_interval
            _apply_dot_tick(i, typedef)       # 触发 DoT 伤害
        if _data[base + 2] <= 0.0:
            _swap_and_pop(i)                  # O(1) 移除,顺序可乱
        else:
            i += 1

func reset() -> void:
    _active_count = 0  # 不需要清零数据,_active_count 控制有效范围

StatusTypeDef 资源格式

# status_type_def.gd - Resource 子类
class_name StatusTypeDef
extends Resource

var display_name: String = ""          # tr() KEYUI 显示用
var duration: float = 3.0              # 默认持续时长(秒)
var tick_interval: float = 1.0         # DoT 跳字间隔(非 DoT 状态设 999.0 避免误触发)
var stack_mode: String = "refresh"     # "refresh" | "stack" | "ignore"
var max_stacks: int = 99               # 叠层上限(stack 模式)
var dot_damage_per_tick: float = 0.0   # 每 tick 造成的基础伤害(0 = 无 DoT)
var dot_damage_type: int = DamageType.PHYSICAL
var can_catalyze: Array[int] = []      # 可参与催化的状态 ID 列表(空 = 不参与元素反应)
var is_combo_tracker: bool = false     # true = 计数追踪状态(tick 逻辑由调用方控制,不走 DoT 路径)
var vfx_id: String = ""                # 状态持续时的粒子特效 ID(空 = 无)
var icon_key: String = ""              # UI 状态图标的 UIAtlas 键

5.6 PlayerManager 架构设计

PlayerManager 是玩家实体的单一数据权威,持有 HP / 金币 / XP / 等级 / 三个 Core / 被动列表。所有系统通过 PlayerManager 读写玩家状态,禁止直接操作其内部字段。

玩家状态数据模型

# player_manager.gd (Autoload: PlayerManager)

# ── 生命值 ────────────────────────────────────────────────────
var hp: float = 100.0
var hp_max: float = 100.0
var hp_regen: float = 0.0         # 每秒自动回血(被动加成)

# ── 经济 ──────────────────────────────────────────────────────
var gold: int = 0
var xp: int = 0
var level: int = 1
# XP 升级阈值:⌊10 × 1.4^(level-1)⌋(见 numerical_design.md §1.2
# level 20 = 最终等级(Wave 20 通关上限);level 21+ 服务 Endless 模式

# ── 属性词条(被动叠加结果)────────────────────────────────────
var stats: PlayerStats = PlayerStats.new()  # 伤害/攻速/移速等合并后的最终值

# ── 法杖 Core 系统(三 Core 机制)──────────────────────────────
const MAX_CORES: int = 3
var cores: Array[CoreDefinition] = []        # 当前装备的 Core,长度 ≤ MAX_CORES
var active_core_idx: int = 0                # 当前激活 Core 的下标
var _raw_decks: Array[SpellDeck] = []       # 每个 Core 对应的原始 SpellDeckcompile_all_wands 的输入)
var compiled_decks: Array[CompiledDeck] = [] # 每个 Core 的预编译产物(compile_all_wands 的输出)

# ── 消耗品临时持有(当回合商店购买的消耗品,在 WAVE 开始前使用)─────
var _pending_consumables: Array[String] = []  # consumable_id 列表

# ── 被动列表 ──────────────────────────────────────────────────
var passives: Array[String] = []             # 被动 ID 列表(passive_id: String,对应 .tres 文件名)

# ── 位置(View 层只读) ────────────────────────────────────────
var _position: Vector2 = Vector2.ZERO       # 由 CharacterBody2D 每帧同步;其他系统通过 get_position() 读取
func get_position() -> Vector2: return _position

PlayerStats 合并数据类

# 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 和特殊行为的特殊敌人。BossManagerEnemyManager 的协调层,不持有独立的 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_COMPLETEkill_boss clear_condition

func reset() -> void:
    _boss_entity_id = -1; _current_phase = 0; _is_active = false
    _phase_thresholds.clear()

# ── 只读查询接口(BulletManager 碰撞检测 + UIManager Boss 血条)────
func is_active() -> bool: return _is_active
func get_boss_entity_id() -> int: return _boss_entity_id

func get_collision_rect() -> Rect2:
    # Boss 碰撞 AABBBulletManager 使用,用于超大碰撞体绕过 SpatialGrid
    if not _is_active: return Rect2()
    var pos := EnemyManager.get_last_position(_boss_entity_id)  # Boss 仍存活时 get_last_position 返回当前位置
    var cfg: Dictionary = _boss_size_cache    # spawn_boss 时从 _load_boss_config 读取 "collision_radius"
    var r: float = cfg.get("collision_radius", 80.0)
    return Rect2(pos.x - r, pos.y - r, r * 2, r * 2)

func _load_boss_config(boss_id: String) -> Dictionary:
    # 从 res://resources/bosses/{boss_id}.json 加载 Boss 配置(同步加载,spawn 时调用一次)
    var path := "res://resources/bosses/%s.json" % boss_id
    if not FileAccess.file_exists(path):
        push_warning("BossManager: boss config not found: %s" % path)
        return {}
    var raw: String = FileAccess.open(path, FileAccess.READ).get_as_text()
    return JSON.parse_string(raw)
    # 配置字段(规范):
    # { "phase_hp_thresholds": [1.0, 0.6, 0.3],
    #   "collision_radius": 80.0,
    #   "spawn_pattern": "center",
    #   "phase_actions": { "1": "spawn_minions", "2": "enrage" } }

Boss 碰撞策略

Boss 半径通常 > 64px(超过 LARGE_PROJECTILE_THRESHOLD),使用与"超大弹体豁免"对称的策略:

# 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.tscnBattleScene 挂载时创建)
var _main_menu: Control = null        # res://scenes/ui/MainMenuUI.tscn
var _game_over: Control = null        # res://scenes/ui/GameOverUI.tscn
var _pause_menu: Control = null       # res://scenes/ui/PauseMenuUI.tscn

# ── RNG(用于 UpgradeSystem.build_choices 的确定性种子)──────────
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
# _rng.seed 在每次进入 WAVE_RESULT 状态时重置(保持波次间确定性)

# ── 伤害数字池 ──────────────────────────────────────────────────
const MAX_DAMAGE_NUMBERS: int = 30
var _dmg_num_pool: Array[Label] = []  # 飘字 Label 池(重复利用,减少节点创建)
var _dmg_num_active: int = 0

func _ready() -> void:
    EventBus.subscribe(EventID.GAME_STATE_CHANGED, _on_state_changed)
    EventBus.subscribe(EventID.PLAYER_DAMAGED,     _on_player_damaged)
    EventBus.subscribe(EventID.ENEMY_KILLED,       _on_enemy_killed)
    EventBus.subscribe(EventID.SPELL_DROP_PICKUP,  _on_spell_drop_pickup)
    # 预分配伤害数字池
    for i in MAX_DAMAGE_NUMBERS:
        var lbl := Label.new()
        lbl.visible = false
        add_child(lbl)
        _dmg_num_pool.append(lbl)

状态→UI 路由表

GameCycleManager 在每次 transition_to(next) 末尾发送 EventID.GAME_STATE_CHANGEDpayload: {prev, next}),UIManager 的 _on_state_changed 根据下表切换:

GameState 显示的 UI 隐藏的 UI 附加操作
MAIN_MENU MainMenuUI 其他全部 停止 HUD
LOADING 无(过渡遮罩由 GameCycleManager 驱动) 其他
SHOP ShopUI HUD, InventoryUI _refresh_shop_ui()
WAVE_INTRO HUD(波次预告) ShopUI, UpgradeUI _show_wave_intro_banner()
WAVE HUD 其他 激活伤害数字池
WAVE_RESULT UpgradeChoiceUI3 张词条) HUD _refresh_upgrade_choices()
GAME_OVER GameOverUI 其他 显示分数/波次
GAME_CLEARED GameOverUI(通关变体) 其他 显示 Endless 入口
PAUSE PauseMenuUI(叠加当前 UI get_tree().paused = true
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 构建深度的核心扩展机制。PassiveRegistrySpellRegistry 采用对称设计,启动时从资源目录扫描加载。

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() KEYUI 显示用
var description: String = ""         # tr() KEY,支持 {value} 占位符(如 "+{value}% 伤害"
var icon_key: String = ""             # UIAtlas 图标键(ADR-C1:必须有对应形状标识)
var rarity: int = 1                   # 1=Common, 2=Uncommon, 3=Rare, 4=Legendary
var max_stack: int = 1                # 同一被动最多叠取次数(1=不可重复购买,99=无限)
var value: float = 0.0                # 效果数值(description {value} 占位符的填充值)

# ── 效果:修改 PlayerStats 的规则 ───────────────────────────────
# 支持三种效果模式(enum 选一):
enum StatPatchMode { ADD, MULTIPLY, OVERRIDE }

var stat_field: String = ""           # PlayerStats 中被修改的字段名(如 "damage_mult"
var patch_mode: StatPatchMode = StatPatchMode.MULTIPLY
var patch_value: float = 1.0          # ADD: +patch_valueMULTIPLY: *patch_valueOVERRIDE: =patch_value

func apply_to(stats: PlayerStats) -> void:
    if stat_field == "": return
    var current: float = stats.get(stat_field)
    match patch_mode:
        StatPatchMode.ADD:      stats.set(stat_field, current + patch_value)
        StatPatchMode.MULTIPLY: stats.set(stat_field, current * patch_value)
        StatPatchMode.OVERRIDE: stats.set(stat_field, patch_value)

设计约束apply_to() 只修改 PlayerStats 中的 float 字段,不直接修改 PlayerManager 的 HP/Gold/XP(防止被动产生经济副作用)。需要修改资源上限(如 hp_max_bonus)的被动,通过 stats.hp_max_bonus 间接影响,PlayerManagerrecalculate() 后重算 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.EADR-A2,本节为接口契约。

职责边界

  • 负责:JSON 文件读写(A/B 双槽写、CRC 校验)、schema 版本迁移、Run 状态持久化、全局设置持久化
  • 不负责:游戏逻辑、排行榜 HMACEndlessRecordsManager 负责)
# profile_manager.gd (Autoload: ProfileManager)
# Run 存档:user://run_a.jsonA槽)+ user://run_b.jsonB槽)(权威来源:ADR-A2、certification_checklist.md ST-51
# 全局设置:user://save_data.json(音量/无障碍等,非 Run 数据)
# schema_version 必须与 ADR-A2 中的 _migrate 链同步

const SCHEMA_VERSION: int = 1  # 每次 save 结构变更时递增

# ── 全局持久化(设置/统计,非 Run)────────────────────────────────
func get_float(key: String, default_val: float = 0.0) -> float:
    return _global.get(key, default_val)
func set_float(key: String, value: float) -> void:
    _global[key] = value; _dirty = true
func get_int(key: String, default_val: int = 0) -> int:
    return int(_global.get(key, default_val))
func set_int(key: String, value: int) -> void:
    _global[key] = value; _dirty = true

# ── Run 状态存档(ADR-A2 §2 波次结束自动存档)────────────────────
func save_run(run_data: Dictionary) -> void:
    # A/B 双槽交替写入(ADR-A2 防断电损坏协议)
    run_data["schema_version"] = SCHEMA_VERSION
    var which := get_int("run_write_slot", 0)
    var path_a := "user://run_a.json"; var path_b := "user://run_b.json"
    _write_json(path_a if which == 0 else path_b, run_data)
    set_int("run_write_slot", 1 - which)

func load_run() -> Dictionary:
    # 优先读 A 槽;A 槽损坏则降级读 B 槽;均损坏则返回 {}GameCycleManager 展示损坏提示)
    var data := _read_json("user://run_a.json")
    if data.is_empty():
        data = _read_json("user://run_b.json")
    if not data.is_empty():
        data = _migrate(data)
    return data

func has_run() -> bool:
    # GameCycleManager 启动时查询是否有未完成的 Run
    return not load_run().is_empty()

func clear_run() -> void:
    # Run 结束(通关/死亡)后调用,删除 Run 存档(保留全局设置/统计)
    _write_json("user://run_a.json", {})
    _write_json("user://run_b.json", {})

func get_run_seed() -> int:
    # ShopManager 确定性种子(新 Run 开始时写入,存档后恢复)
    return get_int("run_seed", 0)

func get_run_count() -> int:
    return get_int("run_count", 0)

func flush() -> void:
    # 保存全局设置(音量、无障碍等)→ user://save_data.json(与 Run 存档 run_a/b.json 分离)
    # 由 SettingsManager.save_audio_setting 触发或 App 退出时调用
    if _dirty: _write_json("user://save_data.json", _global); _dirty = false

func _migrate(data: Dictionary) -> Dictionary:
    # ADR-A2 §3:链式迁移,v0→v1→v2...
    var v: int = data.get("schema_version", 0)
    if v < 1: data = _migrate_v0_to_v1(data)
    return data

5.15 SettingsManager 框架设计

SettingsManager 是游戏设置的运行时接口,持久化委托给 ProfileManager.flush()

# settings_manager.gd (Autoload: SettingsManager)
# 依赖:ProfileManager(持久化)、AudioBusID(音量),EventBusSETTINGS_CHANGED 通知)

func _ready() -> void:
    load_audio_settings()         # 恢复持久化音量
    _apply_accessibility_settings() # 高对比度、字体缩放、减少闪烁

# ── 音量(ADR-A3.4)────────────────────────────────────────────────
# 见 ADR-A3.4 伪代码(load_audio_settings / save_audio_setting

# ── 无障碍功能(ADR-C1)───────────────────────────────────────────
func set_colorblind_mode(mode: String) -> void:
    # mode: "normal" / "protanopia" / "deuteranopia"(见 ADR-C1.1
    ProfileManager.set_int("colorblind_mode", ["normal","protanopia","deuteranopia"].find(mode))
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "colorblind_mode" })

func set_font_scale(scale: float) -> void:
    ProfileManager.set_float("font_scale", clampf(scale, 0.8, 1.5))
    _apply_font_scale()
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "font_scale" })

func set_reduce_flash(enabled: bool) -> void:
    ProfileManager.set_int("reduce_flash", int(enabled))
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "reduce_flash" })

func set_high_contrast(enabled: bool) -> void:
    ProfileManager.set_int("high_contrast", int(enabled))
    _apply_high_contrast()
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": "high_contrast" })

func apply_settings() -> void:
    # UIManager 在 SettingsUI 打开时调用,刷新所有设置为持久化值
    load_audio_settings()
    _apply_accessibility_settings()

func get_volume(save_key: String) -> float:
    # UIManager 音量滑块初始化时调用
    return ProfileManager.get_float(save_key, 0.0)

func _apply_accessibility_settings() -> void:
    _apply_font_scale()
    _apply_high_contrast()

func _apply_font_scale() -> void:
    var scale: float = ProfileManager.get_float("font_scale", 1.0)
    # 遍历所有 Label / RichTextLabel 节点并设置 theme_override_font_sizes
    pass  # 见 ADR-C1.3

func _apply_high_contrast() -> void:
    pass  # 切换 CanvasItem 材质 Shader uniform;见 ADR-C1.4

5.11.B CoreRegistry + ConsumableRegistry + ConsumableDef

CoreRegistryConsumableRegistrySpellRegistry / 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.CONSUMABLEShopItemType.CORE 商品由特定波次固定投放(不进入随机权重池),即 Boss 波前固定刷出 Core 槽位、每波随机出现 1 消耗品。_build_weighted_pool 仅负责 SPELL + PASSIVECONSUMABLE/CORE 槽位由 _fill_fixed_slots()_fill_slots() 中单独填充。


5.12 MinionManager 架构设计

MinionManager 管理玩家召唤物(友方单位)的生命周期,与 EnemyManager 共享 SpatialGrid 但 faction 位不同(+6 高16位 = 1)。召唤物数量上限较小(通常 ≤ 16),不需要 C# 热路径。

职责边界

  • 不处理:战斗逻辑(委托 SpellEvaluator / StatusManager)、敌人碰撞(委托 BulletManager
  • 负责:召唤物生命周期(spawn/expire/recall)、最大上限控制、事件通知(MINION_SPAWNED/MINION_EXPIRED
# 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 Resourceres://resources/minions/{id}.tres,字段:minion_id: Stringlifetime: float(≤0 使用默认值)、move_speed: floatdamage_mult: floatspell_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_typespark / elemental_X / critical
    play("hit_" + hit_type, world_pos, 1.0)

func reset() -> void:
    # GameCycleManager._reset_all_managers() 调用,停止并归还所有活跃特效节点
    pass  # 具体实现见 implementation_plan.md §2.5.D(遍历 _active_pool,调用 stop/归还)

与 implementation_plan.md 的关系impl §2.5.D 为完整实现(含池管理细节),本节 §5.13 为接口契约层(供其他 Manager 查阅可调用的 API)。两者互为补充,实现时以 impl §2.5.D 为基础,函数签名以本节为准。


6. 技术栈选型总结

模块 方案 理由
引擎 Godot 4.x 开源免费,2D 性能强,内置物理/动画系统完善
语言 GDScript + C#(静态职责划分,见 §6.1) 非主备关系:GDScript 负责快迭代域(UI / 事件 / 配置 / 游戏循环 / 法术预编译),C# 负责计算密集热路径(BulletManager / EnemyManager / SpatialGrid / SpellEvaluator 内层循环);热数据通过 PackedFloat32Array.AsSpan() 零拷贝共享;详见 §6.1
ECS框架 Custom Lite (Autoload Manager-based) 利用 Godot Autoload 实现 Manager 单例,针对本项目定制,避免引入第三方 ECS 库
物理 Custom SpatialGrid(主力)+ Godot Area2D(低密度启动路径) < 200 弹幕:Area2D 信号回调;200+ 弹幕:SpatialGrid dirty-list 主力碰撞;详见 §4.3
渲染批次 MultiMeshInstance2D 极大量同类子弹用 MultiMesh 渲染,最小化 Draw Call
UI Godot Control + 自定义虚拟列表 背包道具可能很多,需要虚拟列表优化
配置 JSON + GDScript 类型注解(或 .tres Resource JSON 灵活易热更;Resource 文件可享受 Godot 编辑器集成

6.1 语言职责分工 (Language Partition)

GDScript 与 C# 不是主备(备用)关系,而是按职责静态划分,从 S0 起并行建立:

职责域 语言 核心原因
表现层(UI / VFX / 音频 / 动画) GDScript 节点操作频繁;Inspector 可视化调整;帧预算宽松(< 1ms)
游戏循环(WaveManager / ShopManager / CombatManager 状态机) GDScript 非高频热路径;快速迭代优先
数据定义(CoreDefinition / SpellNode / StatusTypeDef GDScript .tres Godot 编辑器原生 Inspector 支持;数值调整零编译
配置与存档(ConfigMgr / SpellRegistry / ProfileManager GDScript JSON / Resource 读取一次性,非热路径
法术预编译(compile_wand GDScript 仅在换牌时触发,非 _physics_processDictionary 操作 GDScript 更高效
事件总线(EventBus / EventID GDScript 保持单语言,避免跨语言信号绑定复杂性
BulletManager._physics_process C# 2000 颗/帧 SoA 积分;Span<float> 零拷贝 + struct 零 GCGDScript ≈8ms → C# ≈0.8ms
EnemyManager._physics_process C# 1000 敌人 Boid 分离力;向量运算密集;GDScript ≈6ms → C# ≈0.6ms
SpatialGrid(重建 + 查询) C# 每帧重建 + M×N query_circle;纯计算密集,受益于 JIT 内联优化
SpellEvaluator.execute_compiled C# 内层 while 循环(MAX_OPS_PER_CPU=40 × 高频施法);switch 跳表 JIT 优化
StatusManager._physics_process C# 200+ 状态实例逐 tick 计算;swap-and-pop 顺序内存访问 C# 受益更大
ZoneManager._physics_process C# Zone tick + SpatialGrid 批量查询组合

跨语言边界规则(ADR-L1

每次 GDScript ↔ C# Call() / Set() 约有 1–5µs 开销,以下规则确保该开销不进入任何热路径:

规则 1:禁止在 C# 内层循环体内调用 GDScript 方法

BulletManagerCs._PhysicsProcessfor 循环体内不得出现 GodotObject.Call() / .Set()。 2000 次/帧 × 15µs = 2–10ms,直接耗尽帧预算。

规则 2:热数据通过 PackedFloat32Array.AsSpan() 零拷贝共享

GDScript Autoload 持有 _data: PackedFloat32ArrayC# 通过 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();
}

规则 4C# 组件挂为 GDScript Autoload 的子节点

(Autoload) bullet_manager.gd   ← GDScript_data PackedFloat32Array + 对外接口(spawn/despawn
    └─ BulletManagerCs.cs      ← C# Node_Ready() 缓存父节点引用;_PhysicsProcess 执行热路径

GDScript 对外接口(spawn_bullet / despawn_bullet)保持不变,其他 GDScript 系统无感知 C# 的存在。 C# _Ready() 中获取父节点并缓存 _data 引用,之后每帧直接操作,无跨语言调用。

BulletManager GDScript 对外接口(权威签名)

# bullet_manager.gd (Autoload: BulletManager)
# 以下为 GDScript 对外 APIC# 热路径(_physics_process 积分)不在此列出。

const BULLET_STRIDE: int = 12  # SoA 完整布局(权威:arch §4.2):
# [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ]
#   0  1   2   3      4       5          6              7           8          9          10           11
# 冷数据(pierce/bounce/homing/payload_id)→ _bullet_contexts: Dictionary(非热路径)

var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
var _bullet_contexts: Dictionary = {}  # { bullet_id: int → Dictionary(冷数据)}

func spawn_bullet(def: ProjectileDef, pos: Vector2, vel: Vector2) -> int:
    # 返回 bullet_idSoA 槽位索引);def.lifetime 单位:秒
    # SpellEvaluator、ZoneManager 调用
    pass  # 具体实现见 implementation_plan.md §2.2

func despawn_bullet(bullet_id: int) -> void:
    # 提前回收:lifetime 置 0C# 下帧 SoA 清理
    if bullet_id < 0 or bullet_id >= _active_count: return
    _data[bullet_id * BULLET_STRIDE + 4] = 0.0  # slot +4: lifetime

func get_active_count() -> int: return _active_count

func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
    # PlayerManager.get_aim_direction() 调用(自动瞄准默认模式)
    # 委托 EnemyManager.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 对外接口"均指此两个函数。

规则 5compile_wand 保留在 GDScript,不迁移

法术预编译仅在换牌时触发(非 _physics_process),主要为 Dictionary / Array 构建操作, GDScript 开发效率更高;迁移 C# 无性能收益且增加维护成本。


6.2 EventBus Autoload 接口定义

EventBusevent_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.1121,下一空位 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)而非本节的 SoA stride=8(两处文档矛盾,代码选了 Array);behavior_state 枚举未实现。召唤物只实现了 Stationary 炮台一种,随从/卫星/镜像(含 hp/碰撞体积/移动)均未落地。MAX_MINIONS=20 FIFO 顶替已实现。详见 审计报告

MinionManager 为独立的 Autoload 单例,管理玩家召唤的炮台/傀儡/宠物类实体:

  • 与 EnemyManager 隔离EnemyManager 的 SoA 中 faction_and_type 字段不扩展到友军, 避免碰撞 Layer 混乱和阵营判断逻辑膨胀。
  • Minion SoA stride[x, y, vx, vy, hp, hp_max, owner_id, behavior_state]stride=8), 与 EnemyManager 的 stride=8 同构,便于复用 SpatialGrid 查询逻辑。
  • 行为驱动Minion 的 AI 行为(巡逻/护卫/定点炮击)由 behavior_state 枚举驱动, 每帧在 MinionManager._physics_process 中批处理;不使用独立 Node _process
  • 生命周期Minion 属性在召唤瞬间 Snapshot(见 ADR-R4-N2),由 owner_id 关联到施法者; 施法者死亡时,对应的所有 Minion 同帧标记为"消退状态"(延迟 1 秒淡出销毁)。
  • 上限保护:单个玩家同场最多 MAX_MINIONS = 20 个 Minion,超出时最早召唤的自动消退。

详见 docs/mechanics/advanced_mechanics_summons_and_environment.md §3.1(方案 B 推荐)。


ADR-R5-N1:地面效果系统架构(ZoneManager

结论:采用独立的 ZoneManagerAutoload),Zone 实体基于 SpatialGrid 空间索引,不使用 Area2D。

ZoneManager 管理战场中的持久化地面效果(毒液池、岩浆区、冻结地面等):

  • 数据结构_zones: Array[ZoneData]ZoneData 字段:[cx, cy, radius, status_type_id, duration, tick_interval, tick_accum, root_owner_id]stride=8tick_intervaltick_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: PackedFloat32Arraystride = 8
# [0]=cx [1]=cy [2]=radius [3]=status_type_id [4]=duration [5]=tick_interval [6]=tick_accum [7]=owner_id
# tick_interval:两次状态应用之间的最小间隔(秒,由 spawn_zone 调用方指定)
# tick_accum:当前累积时间(运行时变量),>= tick_interval 时触发一次 APPLY_DAMAGE 并减去 tick_interval

# zone_manager.gd (Autoload)
const ZONE_STRIDE: int = 8
const MAX_ZONES: int = 64
var _zone_data: PackedFloat32Array   # SoA 热数据
var _active_zones: int = 0

func spawn_zone(cx: float, cy: float, radius: float,
                status_id: int, duration: float,
                tick_interval: float,   # Zone 每隔多少秒对范围内敌人应用一次状态
                owner_id: int) -> void:
    if _active_zones >= MAX_ZONES:
        # 顶掉最旧的 Zoneindex 0),整体前移(O(N),N≤64 可接受)
        # PackedFloat32Array 无 remove_at(),用手动循环前移实现首元素删除
        for j in range(ZONE_STRIDE, _active_zones * ZONE_STRIDE):
            _zone_data[j - ZONE_STRIDE] = _zone_data[j]
        _active_zones -= 1
    var base: int = _active_zones * ZONE_STRIDE
    _zone_data.resize((_active_zones + 1) * ZONE_STRIDE)
    _zone_data[base + 0] = cx;             _zone_data[base + 1] = cy
    _zone_data[base + 2] = radius;         _zone_data[base + 3] = status_id
    _zone_data[base + 4] = duration;       _zone_data[base + 5] = tick_interval
    _zone_data[base + 6] = 0.0            # tick_accum 初始为 0
    _zone_data[base + 7] = owner_id
    _active_zones += 1

func _physics_process(delta: float) -> void:
    var i: int = 0
    while i < _active_zones:
        var base: int = i * ZONE_STRIDE
        _zone_data[base + 4] -= delta           # duration 倒计时
        _zone_data[base + 6] += delta           # tick_accum 累积

        # 速率限制:仅当 tick_accum >= tick_interval 时才触发状态应用,防止 64 个 Zone 每帧
        # 对 1000 个敌人各触发,产生最多 64,000 APPLY_DAMAGE 事件/帧。
        # 外层 if 守卫 + 内层 while + -= tick_interval:保留余量精度,且支持大帧多次跳字。
        # SpatialGrid.query_circle 仅在 if 守卫内调用一次,不在 while 内重复查询。
        if _zone_data[base + 6] >= _zone_data[base + 5]:
            var cx: float = _zone_data[base + 0]; var cy: float = _zone_data[base + 1]
            var rad: float = _zone_data[base + 2]; var sid: int = int(_zone_data[base + 3])
            var targets := SpatialGrid.query_circle(Vector2(cx, cy), rad)
            while _zone_data[base + 6] >= _zone_data[base + 5]:
                _zone_data[base + 6] -= _zone_data[base + 5]  # -= tick_interval 保留余量精度
                for t_id in targets:
                    # ⚠️ 修正(2026-07-20):真实签名 apply(entity_id, status_type_id, stacks:int=1, duration:float=-1.0, owner_id:int=-1)——5 参
                    StatusManager.apply(t_id, sid, 1, -1.0, int(_zone_data[base + 7]))  # stacks=1, duration=default, owner_id=zone_owner(现实 zone_manager.gd:60 即如此调用)

        if _zone_data[base + 4] <= 0.0:        # duration 耗尽,移除此 Zone
            var cx_exp: float = _zone_data[base + 0]; var cy_exp: float = _zone_data[base + 1]
            var rad_exp: float = _zone_data[base + 2]
            VFXManager.play("zone_expire", Vector2(cx_exp, cy_exp), rad_exp / 64.0)
            # swap-and-pop 移除(O(1),顺序可乱)
            if i < _active_zones - 1:
                for k in ZONE_STRIDE:
                    _zone_data[base + k] = _zone_data[(_active_zones - 1) * ZONE_STRIDE + k]
            _active_zones -= 1
        else:
            i += 1

func reset() -> void:
    # GameCycleManager._reset_all_managers() 调用,清空所有活跃区域特效
    _active_zones = 0  # SoA 数据不清零,_active_zones 控制有效范围(惰性清理)

与 BulletManager 的区别BulletManager 管理短生命周期(秒级)的投射物;ZoneManager 管理中等生命周期(数秒~数十秒)的区域场。两者共享同一个 SpatialGrid 实例,但查询入口不同(BulletManager 查敌人,ZoneManager 也查敌人但传入 zone_center/radius)。


ADR-R5-N2Core Feature Tags 字符串常量规范

问题if "persistent_memory" not in core.feature_tags 使用裸字符串比较,拼写错误时静默失效,且重构不安全。

结论:所有 Feature Tag 字符串必须通过 CoreFeatureTag 常量访问,禁止在业务代码中写裸字符串。

⚠️ 实现现状 (2026-07-20 审计):代码未按本节的 String 常量 + in 判定实现core_feature_tag.gd 实为 int 常量PERSISTENT_MEMORY=1, DUAL_STREAM=2, ALWAYS_CAST_LAST=3, SHUFFLE_DECK=4, INFINITE_SPELLS=5),core.feature_tagsint 位掩码,用 & 判定。⚠️ 潜在 bug:这些值非 2 的幂,& 会串扰(如 ALWAYS_CAST_LAST(3) & PERSISTENT_MEMORY(1) = 1 误判持久内存;当前仅 wand_memory=1 未触发)。修复建议:改真·2 的幂位标志(1,2,4,8,16),或回退到本节的 String + in(后者天然无冲突)。详见 审计报告 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 流程:

  1. CoreFeatureTag 中追加常量(命名规则:全大写下划线分隔)。
  2. core_wand_design.md §3 Feature Tags 表格中登记效果与稀有度。
  3. SpellEvaluator 或对应 Manager 中实现逻辑,通过 CoreFeatureTag.XXX 引用。

ADR-A1:本地化架构(L10n Infrastructure

结论:从 S1 起强制所有玩家可见字符串通过 tr() 包裹;本地化是开发规范而非后期工程。

商业 Steam 发行需支持至少简体中文 / 繁体中文 / 英语 / 日语四语。延后本地化会导致数千条裸字符串的批量替换,历史代价极高。

规范约束

  • 所有 UI 标签、法术名称、状态名称、Boss 名称、提示文本字符串字面量必须tr("KEY") 包裹。
  • 禁止在业务代码中写裸中文字符串(仅 push_warning / push_error 调试输出除外)。
  • 使用 Godot 内置 .po 文件(兼容 Gettext 工具链),路径 res://translations/zh_CN.po / en.po

键名规范<模块>_<实体>_<含义>,全大写下划线,例:

对应文本
SPELL_SPARK_BOLT_NAME "电花弹"
SPELL_SPARK_BOLT_DESC "发射一枚快速电花弹,命中造成闪电伤害"
STATUS_BURN_NAME "燃烧"
UI_SHOP_REROLL_TOOLTIP "花费 {gold} 金币刷新商店"

实施时机S1 随 ConfigMgr 一并建立翻译加载流程(ProjectSettings/locale + TranslationServer.add_translation());S1 起所有新增文本直接按规范写键,不留技术债。


ADR-A2:局内存档 / 断点续玩(Run State Persistence

结论:S2 实现波次边界自动存档,采用 A/B 双槽写入防崩溃损坏,支持断点续玩。

⚠️ 实现现状 (2026-07-20 审计)A/B 双槽 + 波次自动存档 + WM_CLOSE 兜底 + 链式迁移已实现。差异:① 选槽逻辑现按 saved_at 时间戳选最新未损坏槽(比下文伪代码"固定顺序取第一个有效"更健壮,代码更优);② SCHEMA_VERSION 已到 2(新增 wand 键),本节仅记 v1;③ 实存字段为 wave_num/shop_seed/player_stats/wand elapsed_sec(影响无尽续玩计时)、active_core_idxunlocked_upgrades、多 Core 数组(待补)。详见 审计报告

商业 RogueliteHades / Slay the Spire / Balatro)均支持中途退出后恢复到当前波次起始状态。

存档时机

  1. 每波战斗结束(进入商店阶段前):序列化 PlayerState + ActiveCore + WaveNum
  2. 每次商店关闭(进入下一波前):追加序列化 InventoryState + UpgradePicks
  3. 游戏异常退出:通过 _notification(NOTIFICATION_WM_CLOSE_REQUEST) 触发最后一次存档。

序列化字段run_state_v1.json):

字段 内容
schema_version 当前 = 1;迁移时递增
wave_num 当前波次(读档后从此波重新开始)
elapsed_sec 本次 Run 总计时(无尽模式得分依据)
player_hp 当前血量
player_stats 所有属性词条快照(Dictionary)
cores 3 个 Core 的完整 SpellDeck JSON(含 core_id + slots,格式见 §3 E2
active_core_idx 当前激活 Core 索引(0/1/2
unlocked_upgrades 已选升级 ID 数组
shop_seed 随机种子(保证重进后商店展示相同选项,防刷新重开骗局)

A/B 双槽写入(防崩溃数据损坏)

# 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_icon TextureRect,字号 1.5× 基准大小。
  • VFXManagerspawn_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_flashbool,默认 false):

  • 开启时:VFXManager 将爆炸 / 共鸣特效的帧率限制为 12fps,白闪持续时间从 0.2s → 0.05s。
  • EventID.GAME_CLEAREDGameCycleManager 进入 GAME_CLEARED 状态时发出)的全屏白闪在该模式下完全跳过。

C1.4 高对比度模式(S6 实现)

设置项 high_contrastbool,默认 false):

  • 开启时:UI 背景强制黑色 #000000,所有 Label 强制白色 #FFFFFF
  • 实现:SettingsManager.apply_high_contrast(ui_root) 覆写主题颜色变量,不修改资源文件。

ADR-A3 — 音频架构补充细节 (Audio Architecture - Supplementary)

注意AudioBus 层级结构、AudioBusID 常量、_bgm_base_player/_music_layers 字段声明、play_bgm()/play_sfx() 接口均已在 §5.4 完整定义,本 ADR 仅补充 §5.4 未涉及的细节(2D 空间音效衰减参数、音量持久化、SFX 节流规则)。

A3.1 AudioBus 结构参考

⚠️ 此处为索引引用,不重新定义 Bus 结构,权威定义见 §5.4。

Bus 树:Master > BGM (BGM_Base, BGM_Layer) > SFX (SFX_Combat, SFX_Spatial) > UI > Ambience > Voice

AudioBusID Autoload 常量:MASTER / BGM / BGM_BASE / BGM_LAYER / SFX / SFX_COMBAT / SFX_SPATIAL / UI / AMBIENCE / VOICE

A3.2 动态音乐分层触发规则(补充)

屏内敌人数 / 状态 BGM_Layer 激活层级 说明
04 Layer 0(基础,仅鼓点 + 低音) 常驻,即使 0 敌人
519 Layer 1(主旋律 + 和弦) EnemyManager.get_visible_count() ≥ 5
≥ 20 或 Boss 激活 Layer 2(弦乐/合成铅声) 最高战斗强度
SHOP / MAIN_MENU 状态 独立 BGMBGM_Base 直接切换) BGM_Layer 全部静音

AudioManager._process() 每 0.5s 轮询一次 EnemyManager.get_visible_count()Boss 激活/死亡通过 EventBus 订阅(BOSS_PHASE_CHANGED / BOSS_KILLED)实时响应,无需轮询。

A3.3 2D 空间音效衰减

  • SFX_Spatial Bus 下的 AudioStreamPlayer2D 使用 Godot 内置 attenuation_filter_db 曲线。
  • 衰减曲线参数max_distance=1200pxattenuation=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 indexAudioBusID 常量)的显式映射
const _VOLUME_BUS_MAP: Dictionary = {
    "vol_master": AudioBusID.MASTER,
    "vol_bgm":    AudioBusID.BGM,
    "vol_sfx":    AudioBusID.SFX,
    "vol_ui":     AudioBusID.UI,
}

func load_audio_settings() -> void:
    # SettingsManager._ready() 调用;将持久化音量值应用到 AudioServer
    for save_key in _VOLUME_BUS_MAP:
        var db: float = ProfileManager.get_float(save_key, 0.0)  # 0.0 dB = 100%
        AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db)

func save_audio_setting(save_key: String, db: float) -> void:
    # UIManager 音量滑块 on_value_changed → SettingsManager.save_audio_setting("vol_bgm", db)
    if not _VOLUME_BUS_MAP.has(save_key):
        push_warning("SettingsManager: unknown audio save_key: %s" % save_key)
        return
    AudioServer.set_bus_volume_db(_VOLUME_BUS_MAP[save_key], db)
    ProfileManager.set_float(save_key, db)
    EventBus.emit(EventID.SETTINGS_CHANGED, { "key": save_key })

存储位置user://save_data.json(由 ProfileManager 管理,非 Run 存档)。
范围约定:音量滑块线性映射到 -40dB ~ 0dBmute 状态用 -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-05P-S5-AI-01 实测):采用 NavigationAgent2D 方案切换 FlowField。20 个 Elite 同时寻路的 _update_pathfinding_movement 实测 ≈0.0496ms/帧,远低于 0.5ms 预算(约 10× 余量)。实现于 enemy_manager.gd(GDScript 回退路径):程序化单凸矩形 NavigationRegion2D + 每精英 Node2D host 挂 NavigationAgent2Davoidance_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 模式(按距玩家最近优先保留寻路资格)。
  • W114 普通敌人:永远 使用 Boid 模式,不分配 NavigationAgent2D,避免节点数量爆炸。
  • W15+ Elite / Boss:入场时在 EnemyManagerCs 中标记 has_pathfinding = trueGDScript 侧为其创建 NavigationAgent2D 子节点。
  • S5 性能验收(新增 P-S5-AI-01NavigationAgent2D 20 个 Elite 同时寻路,_physics_process 帧时间增量 < 0.5ms;若超标,切换 FlowField 方案并在本 ADR 更新决策结论。

过渡策略

S1–S4 期间:所有敌人继续使用 Boid 模式,不实现寻路(维持已知性能基线)。
S5 实现 Elite 敌人时:同步实现 NavigationAgent2D 方案并运行 P-S5-AI-01 基准测试,若达标则定案为 NavigationAgent2D;否则切换 FlowField。


9. Godot 项目结构 (Project Structure)

9.1 场景树结构 (Scene Tree)

主场景(Main.tscn

Main (Node)                          ← res://scenes/Main.tscn,程序入口
├── UILayer (CanvasLayer, layer=10)  ← 主菜单 / 过渡 / 全局遮罩
│   └── MainMenuUI (Control)
├── GameRoot (Node2D)                ← 战斗场景的挂载点(动态 add_child / remove_child
└── [C# Manager 子节点]              ← ADR-L1 规则 4C# 热路径作为对应 Autoload 的子节点
    ├── BulletManagerCs (Node)       ← 挂在 BulletManager Autoload 下
    ├── EnemyManagerCs (Node)        ← 挂在 EnemyManager Autoload 下
    ├── SpatialGridCs (Node)         ← 挂在 SpatialGrid Autoload 下(或独立 Autoload
    ├── SpellEvaluatorCs (Node)      ← 挂在 SpellEvaluator Autoload 下
    ├── StatusManagerCs (Node)       ← 挂在 StatusManager Autoload 下
    └── ZoneManagerCs (Node)         ← 挂在 ZoneManager Autoload 下

注意C# 子节点由对应的 GDScript Autoload 在 _ready() 中通过 add_child(BulletManagerCs.new()) 创建,不出现在 .tscn 文件中(避免场景文件依赖 C# 程序集,保持热重载友好)。

战斗场景(BattleScene.tscn

BattleScene (Node2D)                 ← res://scenes/BattleScene.tscn
├── World (Node2D)                   ← 世界坐标系(地图 TileMap 挂这里)
│   ├── TileMap (TileMap)            ← 地图地形(S1 起实现)
│   ├── BulletRenderRoot (Node2D)    ← 子弹 Node2D 池的挂载根(< 2000 弹幕时)
│   ├── MultiMeshRoot (Node2D)       ← MultiMeshInstance2D2000+ 弹幕时动态启用)
│   ├── EnemyRenderRoot (Node2D)     ← 敌人 Sprite2D 节点池
│   ├── VFXRoot (Node2D)             ← VFXManager 的粒子节点池(跨场景挂 Autoload 自身)
│   └── ZoneVFXRoot (Node2D)         ← 地面效果贴图/Shader 渲染根
├── Player (CharacterBody2D)         ← 玩家节点(PlayerManager 的 View 层)
│   ├── Sprite2D
│   ├── AnimationPlayer
│   └── CollisionShape2D
└── HUDLayer (CanvasLayer, layer=5)  ← 战斗 HUD(血条、法术槽、波次信息)
    ├── HPBar (ProgressBar)
    ├── WaveLabel (Label)
    ├── SpellSlotPanel (HBoxContainer)
    ├── DamageNumberPool (Node)      ← 伤害数字节点池(弹出后自动归还)
    └── MiniMap (Control)            ← S3 实现

CanvasLayer 分层规则

Layer 用途 挂载场景
0 世界空间(无 CanvasLayer World 节点树
5 战斗 HUD(跟随 Viewport BattleScene > HUDLayer
8 商店 / 背包 UI(模态) ShopManager 动态创建
10 全局 UI(主菜单 / 过渡动画 / 暂停菜单) Main > UILayer
15 调试叠加层(仅 Debug 构建) Main > DebugLayer

9.2 Autoload 初始化顺序(project.godot 注册顺序)

Godot 按 project.godot [autoload] 中的声明顺序依次调用 _ready()。下表定义了强制顺序及原因:

顺序 Autoload 名 文件 依赖(_ready() 中调用的其他 Autoload
1 CrashReporter crash_reporter.gd 无(最先,捕获所有后续初始化崩溃)
2 EventID event_ids.gd 无(纯常量,121
3 DamageType damage_type.gd 无(纯常量)
4 StatusID status_id.gd 无(纯常量)
5 CoreFeatureTag core_feature_tag.gd 无(纯常量)
6 AudioBusID audio_bus_id.gd 无(纯常量,Bus index 整数)
7 ObjectPool object_pool.gd 无(通用对象池,S0 起全局使用,impl §2.1 Layer-0
8 TimeManager time_manager.gd 无(时间缩放/GameTickimpl §2.1 Layer-0
9 EventBus event_bus.gd 无(事件总线基础设施,其他 Manager 订阅事件需要它先就绪)
10 DamageContextPool damage_context_pool.gd 无(独立池,预分配 64 个槽)
11 SubPayloadRegistry sub_payload_registry.gd
12 SpatialGrid spatial_grid.gd + SpatialGridCs.cs 无(C# 子节点在其 _ready() 中 add_child
13 SpellRegistry spell_registry.gd 无(同步扫描加载 .tres
14 PassiveRegistry passive_registry.gd 无(同步扫描加载 PassiveDef .tres
15 StatusRegistry status_registry.gd 无(同步扫描加载 StatusTypeDef .tres
16 ProfileManager profile_manager.gd 无(存档读写;Run存档:run_a/b.json,设置:save_data.json
17 SettingsManager settings_manager.gd ProfileManager(读取用户音量/辅助功能设置)
18 BulletManager bullet_manager.gd + BulletManagerCs.cs SpatialGridspawn 时写入格子)
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") 延迟获取)。