54 KiB
程序架构设计与实施计划 (Implementation Plan & Technical Architecture)
1. 核心设计原则 (Core Design Principles)
1.1 性能优先 (Performance First)
鉴于已确定的"海量弹幕+同屏千人"需求,本项目的核心代码将偏离标准的 Godot 节点组件模式,采用更底层的 Data-Oriented (面向数据) 思想。
- 原则 A: 战斗循环中尽量避免
new(GDScript 实例化)。所有高频对象(Context, Bullet, Enemy)必须池化。 - 原则 B: 逻辑与渲染分离。位置更新在 Autoload Manager 的
_physics_process中批量处理,节点的_process仅负责同步 View。 - 原则 C: 避免匿名函数与 Lambda。解释器核心循环使用
match和for循环,减少函数调用开销。
1.2 极度模块化 (Extreme Modularity)
法术系统的每个功能点(如:追踪、分裂)都必须封装为独立的 Strategy 类,通过 ID 动态加载。核心系统不能直接依赖具体法术。
法术注册表 (Spell Registry) 技术方案:
# spell_registry.gd (Autoload)
# 启动时自动扫描 res://resources/spells/ 目录,加载所有 .tres SpellNode Resource
var _registry: Dictionary = {} # spell_id (String) -> SpellNode
func _ready() -> void:
# ⚠️ F13 注意:当前使用同步 load(),主线程阻塞直到所有法术资源加载完毕。
# 法术数量较少(< 50)时可接受;若未来资源增多,应改用异步预加载:
# ResourceLoader.load_threaded_request(path) → 在 Loading 界面 _process 中轮询
# load_threaded_get_status(),加载完成后发出信号,不阻塞主线程。
# P2 路线图:引入 SpellRegistry.preload_async(on_complete: Callable) 接口。
var dir := DirAccess.open("res://resources/spells/")
if dir == null:
push_error("SpellRegistry: 无法打开 res://resources/spells/ 目录")
return
dir.list_dir_begin()
var file := dir.get_next()
while file != "":
if file.ends_with(".tres"):
var res: SpellNode = load("res://resources/spells/" + file)
if res != null:
_registry[res.id] = res
else:
push_warning("SpellRegistry: 加载失败 - %s" % file)
file = dir.get_next()
dir.list_dir_end() # ⚠️ 必须调用,否则目录句柄不会释放
func get(id: String) -> SpellNode:
return _registry.get(id, null)
func all_ids() -> Array:
return _registry.keys() # ⚠️ keys() 返回无类型 Array,不可声明为 Array[String]
# GDScript 4.x 中 Dictionary.keys() 的静态返回类型为 Array
- 新增法术只需在
res://resources/spells/中放入一个.tres文件,无需修改任何代码。 SpellNode.id字段必须全局唯一,推荐使用category_name格式(如modifier_damage_plus)。
1.3 零 GC 与内存高效架构 (Zero-GC & Memory Efficiency)
GDScript 的垃圾回收虽不如 JavaScript 激进,但频繁实例化 RefCounted 对象同样会带来性能抖动。架构上强制执行以下模式:
- Struct-like Classes: 核心算子类(如
Vector2)优先使用 GDScript 内置值类型(Value Type),它们在栈上分配,无 GC 压力。自定义数据类继承RefCounted并通过对象池复用。 - Ring Buffers: 音频、粒子、伤害数字使用环形缓冲区,覆盖旧数据而非销毁。
- Integer IDs > Objects: 在
EventBus和 ECS 中,传递整数 ID (entity_id) 而非传递整个对象引用,减少引用计数复杂度。
2. 模块详细设计 (Detailed Module Design)
2.1 核心库层 (Layer 0: Core Libs)
| 模块 | 类名/文件 | 职责说明 | 性能关键点 |
|---|---|---|---|
| Object Pool | ObjectPool (Autoload) |
通用对象池,支持 reset() 接口。 |
使用 Array 做栈,append/pop_back 操作。 |
| Event Bus | EventSystem (Autoload) |
类型安全的全局事件总线。 | 使用整数 ID 代替字符串 Key,减少哈希查找开销;也可直接使用 Godot 原生 Signal。 |
| Spatial Hash | SpatialGrid |
2D 空间网格,替代 Quadtree。 | 使用 PackedInt32Array 一维数组模拟二维网格,直接索引访问 grid[x + y * width]。 |
| Timer | TimeManager (Autoload) |
缩放时间、帧率节流控制。 | 支持 GameTick (physics) 与 RenderFrame (process) 分离。 |
EventBus 事件目录 (Event Catalog)
所有跨系统通信必须通过此表对齐,不得私自定义事件 ID。
E-N1 规范:事件 ID 必须使用以下具名常量(
EventID枚举或 Autoload 常量),禁止在业务代码中直接写裸整数,防止撞号和可读性问题。
# event_ids.gd (Autoload: EventID)
const APPLY_DAMAGE: int = 1
const ENEMY_KILLED: int = 2
const SPELL_CAST_BEGIN: int = 3
const BULLET_HIT: int = 4
const PLAYER_DAMAGED: int = 5
const PLAYER_DIED: int = 6
const WAVE_COMPLETE: int = 7
const STATUS_APPLIED: int = 8
const CHARGE_STATE_CHANGED: int = 9 # 停步蓄力进度变化 → UIManager
const ON_PLAYER_HURT: int = 10 # 玩家受到伤害时触发(用于受击释放法术/反弹尖刺等 OnHurt 钩子)
const MINION_SPAWNED: int = 11 # 召唤物创建 → UIManager 更新召唤物计数显示
const MINION_EXPIRED: int = 12 # 召唤物消亡 → UIManager 更新召唤物计数显示
const DASH_TRIGGERED: int = 13 # 玩家执行冲刺动作时触发(dash 输入按下瞬间)→ SpellEvaluator(OnDash 钩子,dash-wave Core 附加法术), UIManager(清除蓄力进度 UI)
# 负载: { "caster_id": int, "stationary_time": float }(冲刺前已积累的静止时长,供 dash-wave Core 读取)
# ⚠️ 与 CHARGE_FIRED(14) 的区别:DASH_TRIGGERED 由冲刺操作触发;CHARGE_FIRED 由停步静止时长首次超阈值触发(无冲刺)。
const CHARGE_FIRED: int = 14 # 停步蓄力首次达到阈值时发出(仅一次,非每帧)→ SpellEvaluator(执行超载版法术)
# 区别于 CHARGE_STATE_CHANGED(每帧进度,仅供 UI):
# CHARGE_STATE_CHANGED 持续更新进度环动画;
# CHARGE_FIRED 是一次性触发信号,SpellEvaluator 只订阅此事件来实际释放法术。
# 负载: { "caster_id": int }
const BOSS_PHASE_CHANGED: int = 15 # boss_design.md §6:Boss 阶段切换 → HUD、AudioManager(BGM 分层)
const BOSS_KILLED: int = 16 # boss_design.md §6:Boss 死亡 → WaveManager、AchievementManager
const GAME_CLEARED: int = 17 # boss_design.md §6:通关 → GameCycleManager、排行榜
const ACHIEVEMENT_UNLOCKED: int = 18 # certification_checklist.md ST-10:Steam 成就解锁(须幂等)
# 负载: { "achievement_id": String }
const SETTINGS_CHANGED: int = 19 # architecture_design.md ADR-C1 / SettingsManager:字体缩放、高对比度等
# 负载: { "key": String }(例:`font_scale`、`high_contrast`)
const GAME_STATE_CHANGED: int = 20 # architecture_design.md §5.1:FSM 状态切换(GameCycleManager 发出)
# 负载: { "prev": GameState, "next": GameState }
# 订阅方:UIManager(路由 UI 切换)、AudioManager(切换 BGM/SFX)
const SPELL_DROP_PICKUP: int = 21 # architecture_design.md §5.3:法术掉落拾取弹 UI 事件
# 负载: { "spell_id": String, "auto_equip": bool }
# 发出方:DropManager(掉落拾取)、ShopManager.offer_free_spell()
# 订阅方:UIManager(弹背包选 Core 界面)、PlayerManager(auto_equip=true 时直接装备)
# 新增事件从 22 起递增,并向下表登记
| 事件 ID | 具名常量 | 名称 | 负载内容 | 发出方 | 订阅方 |
|---|---|---|---|---|---|
1 |
EventID.APPLY_DAMAGE |
APPLY_DAMAGE |
damage_context_id: int |
SpellEvaluator / StatusManager |
EnemyManager |
2 |
EventID.ENEMY_KILLED |
ENEMY_KILLED |
enemy_id: int, killer_id: int |
EnemyManager |
PlayerStats, DropManager, MissionTracker |
3 |
EventID.SPELL_CAST_BEGIN |
SPELL_CAST_BEGIN |
caster_id: int, deck_id: int |
SpellEvaluator |
VFXManager, AudioManager |
4 |
EventID.BULLET_HIT |
BULLET_HIT |
bullet_id: int, target_id: int |
BulletManager |
SpellEvaluator(proc), VFXManager |
5 |
EventID.PLAYER_DAMAGED |
PLAYER_DAMAGED |
amount: float, source_id: int |
PlayerManager |
UIManager, AudioManager |
6 |
EventID.PLAYER_DIED |
PLAYER_DIED |
无 | PlayerManager |
GameCycleManager |
7 |
EventID.WAVE_COMPLETE |
WAVE_COMPLETE |
wave_num: int |
WaveManager |
ShopManager, GameCycleManager |
8 |
EventID.STATUS_APPLIED |
STATUS_APPLIED |
target_id: int, status_type: int, stacks: int |
StatusManager |
VFXManager, EnemyManager |
9 |
EventID.CHARGE_STATE_CHANGED |
CHARGE_STATE_CHANGED |
caster_id: int, progress: float |
PlayerManager |
UIManager |
10 |
EventID.ON_PLAYER_HURT |
ON_PLAYER_HURT |
amount: float, source_id: int |
PlayerManager |
SpellEvaluator(OnHurt 钩子), VFXManager, AudioManager |
11 |
EventID.MINION_SPAWNED |
MINION_SPAWNED |
minion_id: int, owner_id: int, current_count: int |
MinionManager |
UIManager(召唤物计数显示) |
12 |
EventID.MINION_EXPIRED |
MINION_EXPIRED |
minion_id: int, owner_id: int, current_count: int |
MinionManager |
UIManager(召唤物计数显示) |
13 |
EventID.DASH_TRIGGERED |
DASH_TRIGGERED |
caster_id: int, stationary_time: float |
PlayerManager |
SpellEvaluator(OnDash钩子,dash-wave Core 附加法术), UIManager(蓄力UI清除) |
14 |
EventID.CHARGE_FIRED |
CHARGE_FIRED |
caster_id: int |
PlayerManager |
SpellEvaluator(执行超载版法术) |
15 |
EventID.BOSS_PHASE_CHANGED |
BOSS_PHASE_CHANGED |
boss_id, phase, bgm_layer |
BossManager |
UIManager, AudioManager |
16 |
EventID.BOSS_KILLED |
BOSS_KILLED |
boss_id, wave_num |
BossManager |
WaveManager, AchievementManager |
17 |
EventID.GAME_CLEARED |
GAME_CLEARED |
elapsed_sec, wave_num |
BossManager / GameCycleManager |
GameCycleManager, 排行榜 |
18 |
EventID.ACHIEVEMENT_UNLOCKED |
ACHIEVEMENT_UNLOCKED |
achievement_id: String |
各游戏系统 | AchievementManager → Steam |
19 |
EventID.SETTINGS_CHANGED |
SETTINGS_CHANGED |
key: String |
SettingsManager |
UIManager(全局主题/字体重载) |
20 |
EventID.GAME_STATE_CHANGED |
GAME_STATE_CHANGED |
prev: int, next: int |
GameCycleManager |
UIManager, AudioManager |
21 |
EventID.SPELL_DROP_PICKUP |
SPELL_DROP_PICKUP |
spell_id: String, auto_equip: bool |
DropManager, ShopManager |
UIManager, PlayerManager |
29 |
EventID.BOSS_PHASE_CHANGED |
BOSS_PHASE_CHANGED |
boss_id, boss_type, phase |
BossManager |
UIManager(HUD 阶段提示) |
30 |
EventID.BOSS_KILLED |
BOSS_KILLED |
boss_id, boss_type, wave |
BossManager |
GameCycleManager(结算), AchievementManager(成就) |
新增事件从 22 起递增,并向下表登记。规则:编号永不重用(可标 DEPRECATED 但不删除)。
E-N2 跨语言边界协议(GDScript ↔ C#):
architecture_design.md §6规定跨语言只传基本类型(int/float/Vector2/Godot.Array)。DamageContext(RefCounted)不得直接穿越语言边界。约定如下:
EnemyManager遵循与BulletManager相同的 ADR-L1 规则 4 模式:enemy_manager.gd(GDScript Autoload)持有_data: PackedFloat32Array并对外暴露接口;EnemyManagerCs.cs(C# 子节点)处理_PhysicsProcess热路径(Boid 分离力、SoA 积分),预期帧时间 GDScript ≈6ms → C# ≈0.6ms(见 §6.1 语言分工表)。APPLY_DAMAGE事件的处理逻辑位于 GDScript Autoload 层,负载damage_context_id: int是DamageContextPool的槽位索引,GDScript 层调用DamageContextPool.get_context(id)取得对象后处理伤害,处理完毕立即release(id)。DamageContext不穿越 GDScript ↔ C# 语言边界。
DamageContextPool 定义:E-N2 中引用的
DamageContextPool为伤害上下文对象池 Autoload,定义如下:# ── damage_context.gd ────────────────────────────────────────────────────────── # DamageContext 数据类必须单独放在 damage_context.gd 中, # 禁止将 class_name DamageContext 写在 damage_context_pool.gd 里(否则 Autoload 实例自身就是 # DamageContext 类型,导致 _pool: Array[DamageContext] 存入了具有池管理方法的对象,语义错误)。 class_name DamageContext extends RefCounted var base_damage: float = 0.0 var mult: float = 1.0 var damage_type: int = DamageType.PHYSICAL # 使用枚举常量,禁止裸整数 var owner_id: int = -1 var source_tags: int = 0 var is_crit: bool = false # 影响暴击字体/特效(见 combat_mechanics_depth.md §4) var pierce_rate: float = 0.0 # 护甲穿透率 (0.0~1.0),EnemyManager.apply_damage() 中使用 # ── damage_context_pool.gd (Autoload: DamageContextPool) ────────────────────── # 本文件不声明 class_name;由 Godot Project Settings 注册为 Autoload "DamageContextPool"。 # 采用固定大小槽位数组 + PackedInt32Array 空闲 ID 栈:O(1) 申请/归还,零动态分配,零 GC。 const POOL_SIZE: int = 64 var _slots: Array[DamageContext] # 固定槽位,索引即 ID(预分配,_ready() 填充) var _free_ids: PackedInt32Array # 空闲 ID 栈(append/pop_back,O(1),零 GC) var _in_use: PackedByteArray # 占用标记:1=已 acquire;防重复 release func _ready() -> void: _slots.resize(POOL_SIZE) _free_ids.resize(POOL_SIZE) _in_use.resize(POOL_SIZE) for i in POOL_SIZE: _slots[i] = DamageContext.new() _free_ids[i] = POOL_SIZE - 1 - i # 初始化:index 0 最先被弹出 _in_use[i] = 0 # ⚠️ 必须先调用 ctx.reset(),防止从池中取出的旧实例残留 source_tags 等脏数据。 func acquire(base_dmg: float, m: float, dtype: int, owner: int, crit: bool = false, pierce: float = 0.0) -> int: if _free_ids.is_empty(): push_warning("DamageContextPool: 池已耗尽,动态扩展(POOL_SIZE=%d 建议上调)" % POOL_SIZE) var new_id: int = _slots.size() _slots.append(DamageContext.new()); _in_use.append(0) return _fill_slot(new_id, base_dmg, m, dtype, owner, crit, pierce) var id: int = _free_ids[_free_ids.size() - 1] _free_ids.resize(_free_ids.size() - 1) # pop_back,O(1) return _fill_slot(id, base_dmg, m, dtype, owner, crit, pierce) func _fill_slot(id: int, base_dmg: float, m: float, dtype: int, owner: int, crit: bool, pierce: float) -> int: var ctx: DamageContext = _slots[id] ctx.reset() # 清除所有残留字段后再覆写,保证每次 acquire 的上下文状态干净 ctx.base_damage = base_dmg; ctx.mult = m ctx.damage_type = dtype; ctx.owner_id = owner ctx.is_crit = crit; ctx.pierce_rate = pierce _in_use[id] = 1 return id func get_context(id: int) -> DamageContext: if id < 0 or id >= _slots.size() or _in_use[id] == 0: return null return _slots[id] func release(id: int) -> void: if id < 0 or id >= _slots.size() or _in_use[id] == 0: push_warning("DamageContextPool.release: 无效或重复归还 ID=%d" % id) return _in_use[id] = 0 _free_ids.append(id) # push_back,O(1),不触发 GC func reset() -> void: # GameCycleManager._reset_all_managers() 在波次/Run 重置时调用。 # 将所有 in-use 槽位强制归还(防止跨波次泄漏),清空空闲 ID 栈后重建。 _free_ids.clear() for i in _slots.size(): _in_use[i] = 0 _free_ids.append(i)使用约定:
SpellEvaluator发出APPLY_DAMAGE前调用DamageContextPool.acquire(...)获取整数 ID;EnemyManager收到事件后调用get_context(id)处理伤害,处理完毕立即调用release(id)归还, 不得持有 DamageContext 跨帧引用。
2.2 法术解释器层 (Layer 1: Spell Virtual Machine)
这是本项目的"CPU",负责执行 SpellNode 指令。
数据结构
SpellDeck — 运行时执行游标
class_name SpellDeck
# _nodes: Array[SpellNode],预编译后不再修改
# _cursor: int,当前执行位置
#
# Scope 边界标记:预编译时每个 SCOPE_OPEN 节点(LOGIC_IF_*、TRIGGER 后续体)
# 在 _nodes 中紧跟虚拟节点 SpellNode{type=SCOPE_CLOSE, id="_scope_end"}。
# skip_until_scope_end() 推进 cursor 直到遇到第一个 SCOPE_CLOSE(深度计数器处理嵌套)。
# consume_until_scope_end() 同理,返回中间节点序列用于打包 SubPayload。
# O(N) 线性扫描定位边界,N ≤ MAX_OPS_PER_CPU × cpu_limit。
func has_next() -> bool: return _cursor < _nodes.size()
func pop() -> SpellNode:
# 完整实现须包含 _consumed 掩码跳过(见 architecture_design.md §3.4.C)
if _cursor >= _nodes.size(): return null
var node := _nodes[_cursor]
_cursor += 1 # GDScript 不支持后置 ++ 运算符
return node
func skip_until_scope_end() -> void: ...
func consume_until_scope_end() -> Array[SpellNode]: ...
CompiledDeck — 预编译产物,关卡期间只读
class_name CompiledDeck
# compile_wand(core: CoreDefinition, raw_deck: SpellDeck) -> CompiledDeck
# 由 SpellEvaluator 在背包关闭时调用
#
# nodes: Array[SpellNode] 拓扑扁平化 + SCOPE 虚节点插入后的线性序列
# label_table: Dictionary { label_id: int -> node_index: int }(LOGIC_LABEL 专用)
# sub_payload_ids: Array[int] 本 Deck 预编译时注册到 SubPayloadRegistry 的所有 ID
# topology_type: int 0=LINEAR 1=MATRIX 2=CIRCUIT(影响 _flatten_* 选择)
# checksum: int 插槽内容哈希,用于判断是否需要重新编译
# 算法:多项式滚动哈希(对换位敏感,避免 [A,B] 与 [B,A] 产生相同 checksum):
# checksum = topology_type ^ slot_count ^ (grid_cols << 8) ^ hash(core.id)
# // grid_cols 防止不同列宽的 MATRIX Core 碰撞(2×4 与 4×2 slot_count 相同)
# // hash(core.id) 防止不同 Core ID 但拓扑相同时 checksum 碰撞导致跳过重编译
# for i in range(nodes.size()):
# checksum = checksum * 31 ^ hash(nodes[i].id)
#
# 运行时 SpellEvaluator 从 CompiledDeck 构造一个 SpellDeck(拷贝 nodes 并重置 cursor)
var nodes: Array[SpellNode]
var label_table: Dictionary
var sub_payload_ids: Array[int]
var topology_type: int = 0
var checksum: int = 0
CastState — 单次 execute 调用的临时状态
# trigger_depth: int,当前 execute_sub 嵌套深度(防止 TRIGGER 无限递归)
# registers: PackedFloat32Array,长度 4,R1-R4
# ⚠️ 重要区分:
# CastState.registers → 单次 execute() 内的临时工作寄存器,每次 execute() 开始时重置,不跨调用保留
# SpellContext.registers → 跨帧持久寄存器(见 architecture_design.md §3.2)
# LOGIC_EVERY_N_SHOTS【必须】使用 SpellContext.registers(否则计数器每帧重置,永远等效为"每次都触发")。
# _execute_logic() 收到 LOGIC_EVERY_N_SHOTS 时通过 spell_ctx 参数访问 spell_ctx.registers[0]。
执行流程 (SpellEvaluator.execute)
# MAX_OPS_PER_CPU = 40:每点玩家属性 cpu_limit 换算为 40 步执行额度
# 运行时:_max_ops = PlayerStats.cpu_limit × MAX_OPS_PER_CPU(默认 cpu_limit=5 → MAX_OPS=200)
# 超过限制立即中断,防止图灵完备构建死循环
const MAX_OPS_PER_CPU: int = 40
const MAX_TRIGGER_DEPTH: int = 3 # execute_sub 嵌套最大深度;超过 3 层时 TRIGGER 不再触发新的 SubPayload
# 分工:MAX_OPS 管单次施法内指令总量;MAX_TRIGGER_DEPTH 管跨弹体调用深度
# ⚠️ 超限时不得静默失效:当 execute_sub 被 TRIGGER_DEPTH 隔断时,
# EventBus 发出 SPELL_CAST_BEGIN 事件并附带 flag "depth_exceeded=true",
# UIManager 订阅该 flag,在 HUD 显示短暂黄色闪烁("[法术链太深,部分触发已忽略]")。
# 开发期保留 push_warning;正式版将 push_warning 改为发出事件。
var _max_ops: int = 200 # 战斗开始时更新:_max_ops = PlayerStats.cpu_limit * MAX_OPS_PER_CPU
while deck.has_next() and ops_count < _max_ops:
ops_count += 1
var node: SpellNode = deck.pop()
match node.type:
SpellType.ACTION:
_push_projectile(node, context)
SpellType.MODIFIER:
node.apply(context) # 直接修改 context.stats
# Multicast 边缘规则:若 Deck 剩余法术数 < draw_count,以"尽力而为"策略执行
# (弹出现有法术直至 Deck 耗尽,不崩溃),法杖编辑 UI 对此显示黄色警告。
SpellType.TRIGGER:
# 实现方案:预编译时打包 SubPayload,运行时查表执行
# 1. 将 TRIGGER 后续的 SpellNode 序列打包为 SubPayload,注册到全局表,得到整数 ID
# 2. 子弹在 PackedFloat32Array 中仅携带 on_hit_payload_id (int)
# 3. 命中时 BulletManager 查表并调用 SpellEvaluator.execute_sub(payload_id, hit_pos, owner_id)
# 这样子弹逻辑与 PackedFloat32Array 热数据完全解耦
var payload_id: int = SubPayloadRegistry.register(deck.consume_until_scope_end())
context.current_payload.on_hit_payload_id = payload_id
SpellType.LOGIC:
# 最小指令集(详细行为见 architecture_design.md §3.4.B):
# LOGIC_IF_HP_BELOW → 读 caster HP%,条件不满足则 deck.skip_until_scope_end()
# LOGIC_IF_ENEMY_NEARBY → SpatialGrid 查询 context.range 内是否有敌方实体
# LOGIC_EVERY_N_SHOTS → 读写 spell_ctx.registers[0..3](SpellContext,跨帧持久!)
# ⚠️ 严禁使用 CastState.registers(每帧重置 → 计数器永远不累积)
# LOGIC_LOOP(count, body_size)
# → 将紧随其后的 body_size 个节点重复执行 count 次(count ≤ 8,超限裁剪)
# → 每次循环消耗 body_size 个 ops;总耗费 = count × body_size,计入 ops_count
# → 与 MAX_OPS 交互:LOOP(5, 3) = 15 ops;若 ops_count + 15 > _max_ops 则提前截断
# → game_design.md §3.2.D 中「循环执行后续 3 个法术 5 次」即为 LOGIC_LOOP(5, 3)
# LOGIC_LABEL / LOGIC_JUMP_IF → 仅限 persistent_memory Core;预编译时建立 _label_table
# LOGIC_FORK → CIRCUIT 拓扑分叉专用(由 _flatten_circuit 注入,玩家不可购买)
# node.id = "LOGIC_FORK";node.get_meta("fork_branch_ids") = Array[int] SubPayload ID 列表
# _execute_logic 识别 LOGIC_FORK 时对每个 fork_branch_id 调用 execute_sub()
# 消耗 fork_branch_ids.size() 个 ops(每个分支算 1 步),计入 ops_count
_execute_logic(node, context, deck)
# ops_count 达到上限时:① 开发期 push_warning;② 通过 EventBus 向玩家 HUD 发出反馈
# 截断不得静默,玩家须知构建已被截断(与 MAX_TRIGGER_DEPTH "depth_exceeded" 事件相同规范)。
# UIManager 订阅 SPELL_CAST_BEGIN 中 "ops_exceeded: true" 标志,HUD 显示短暂黄色闪烁提示。
if ops_count >= _max_ops:
push_warning("SpellEvaluator: MAX_OPS reached, build may be too complex")
EventBus.emit(EventID.SPELL_CAST_BEGIN, {
"caster_id": context.caster_id, "deck_id": -1, "ops_exceeded": true
})
LOGIC 指令集扩展路径:当前
_execute_logic使用中央match node.id硬编码所有 LOGIC 指令,新增 LOGIC 类型需修改核心(OCP 限制)。 P0/P1:保留 match 实现,LOGIC 指令集固定不超过 8 条,可接受。 P2 路线图:若 LOGIC 指令集需扩展超过 10 条,引入LogicHandler接口:# logic_handler.gd class_name LogicHandler extends RefCounted func handle(node: SpellNode, context: SpellContext, deck: SpellDeck) -> void: pass # 在 SpellRegistry 中按 node.id 注册: # SpellRegistry.register_logic_handler("LOGIC_MY_NEW", MyNewHandler.new()) # _execute_logic 改为: # SpellRegistry.get_logic_handler(node.id).handle(node, context, deck)迁移前无需改变 P0/P1 行为,仅 P2 新增 LOGIC 法术卡采用新接口注册。
SubPayloadRegistry 生命周期
- 创建时机:玩家关闭背包(预编译)时,
SpellEvaluator.compile_wand(core, raw_deck)清空并重建整个 Registry。 - 清空时机:关卡切换、游戏结束时全量清除。滚动最终销毁所有未使用的 SubPayload 对象还入对象池。
- 并发处理:多弹一帧内同时命中时,
execute_sub可并行执行同一payload_id, Registry 不发生冲突(只读)。 - 竞争条件安全保证:Registry 重建发生在玩家关闭背包时,而非打开时。
因此飞行中的弹体在玩家进入商店编辑阶段时,其
on_hit_payload_id仍指向当前 Registry 中的有效条目—— 战斗波次结束后所有弹体自然清空,Registry 重建(下次关闭背包)必定早于下波战斗开始; 不存在"弹体持有旧 payload_id、Registry 已重建为新 ID 映射"的竞争窗口。禁止行为:不得在波次进行中允许玩家修改 Deck(如暂停菜单热插拔法术),否则将违反此不变量。 如未来需支持"战斗中暂停换牌",必须先清空所有飞行中的弹体,再执行重编译,最后恢复生成。
2.3 战斗实体层 (Layer 2: Combat ECS-Lite)
A. 物理与碰撞 (Physics & Collision)
碰撞系统采用分档策略,不同弹幕密度区间使用不同方案;BulletManager._collision_mode 标志控制当前路径(详见 architecture_design.md §4.3):
| 同屏弹幕数 | 碰撞方案 | 说明 |
|---|---|---|
| < 200 | Area2D + CircleShape2D | Godot 内置碰撞;Physics Layer/Mask 过滤阵营;body_entered 信号回调 _on_bullet_hit() |
| 200–2000 | 自定义 SpatialGrid(主力) | BulletManager 每帧 dirty-list 重建网格,手动 query_circle();Area2D 实例全部禁用 |
| 2000+ | SpatialGrid + MultiMesh | 碰撞继续走 SpatialGrid;渲染切换 MultiMeshInstance2D |
⚠️ S0 验证目标(风险 R-04):S0 期间在 500+ 弹幕时实测 Area2D 帧时间(预期 > 8ms)。验证后永久锁定 SpatialGrid 为 200+ 弹幕的主力碰撞方案,Area2D 路径不再作为运行时"降级回退"保留。
- Physics Layer & Mask 配置(< 200 弹幕 Area2D 路径):Layer 1 = 玩家,Layer 2 = 子弹,Layer 3 = 敌人;子弹 Layer 2 只检测 Layer 3;
body_entered→_on_bullet_hit(bullet_id, enemy_id)。 - SpatialGrid 主路径(200+):两路径最终均调用相同的
_on_bullet_hit(bullet_id, enemy_id)处理函数,后处理逻辑完全共用;切换入口仅在_collision_mode标志处。
B. 子弹系统 (BulletManager)
- 混合渲染模式:
- 逻辑层: 依然使用
PackedFloat32Array管理子弹的生命周期和属性(速度/伤害),SoA 布局保证缓存友好。 - 表现层: 利用 Node Pool 复用
Node2D节点;对于极大量同类子弹,改用MultiMeshInstance2D直接 GPU 实例化渲染,大幅降低 Draw Call。 - 同步优化: 只有当子弹位置发生显著变化或处于屏幕视口内时,才设置
Node2D.position,利用 Godot 的脏标记机制避免无效渲染树更新。
- 逻辑层: 依然使用
C. 敌人系统 (EnemyManager)
EnemyManager 热数据 SoA 布局(权威定义)
敌人数量可达 1000+,同 BulletManager 一样需要 PackedFloat32Array SoA 结构:
const ENEMY_STRIDE: int = 8
stride = ENEMY_STRIDE 个 float
[ x, y, vx, vy, hp, hp_max, faction_and_type, status_bits ]
0 1 2 3 4 5 6 7
faction_and_type:高 16 位 = faction(0=敌方, 1=友方/召唤物),低 16 位 = enemy_type_idstatus_bits:燃烧/冰冻/中毒等状态的位掩码(各类 DoT 的精确数值存冷数据)- 复杂数据(AI 状态、DoT 计时器、combo_tracker)存入
_enemy_contexts: Dictionary(key = entity_id)
SpatialGrid 每帧清空策略(P5)
SpatialGrid 每 _physics_process 帧重建一次,采用 Dirty-List 方案而非全量 memset:
_dirty_cells: PackedInt32Array记录本帧有写入的 Cell 索引。- 帧末遍历
_dirty_cells,仅清空这些 Cell,跳过空 Cell。 - 当
_active_count > 1500时切换为全量 fill(0)(此时脏格子数量接近总数,遍历反而更慢)。
- AI 状态机:AI 逻辑不使用 AnimationTree 驱动,而是以轻量级枚举存于
_enemy_contexts[entity_id]["ai_state"](0=IDLE / 1=CHASE / 2=ATTACK / 3=RETREAT)。EnemyManager._physics_process在 Manager 内部批量完成所有状态转换,不经过 Godot 场景树。- 单向驱动:帧末对屏内敌人(
_visible_flags[i] == 1)将ai_state映射为 AnimationTree 参数(anim_tree.set("parameters/conditions/is_running", ...)),AnimationTree 仅接收结果,不反向影响逻辑;屏外敌人完全跳过 AnimationTree 更新。 - ⚠️ 禁止:不得用 AnimationTree StateMachine 驱动 AI 逻辑转换;不得在
_physics_process热路径内对屏外敌人调用anim_tree.set()(大量屏外时 1000× 调用/帧严重浪费)。
- 单向驱动:帧末对屏内敌人(
- LOD(屏外 AI 剔除):❌ 不使用
VisibilityNotifier2Dper-enemy 方案(1000+ 敌人各挂一个节点 = 1000+ 额外信号监听,节点开销显著)。改为 Manager 侧 Camera Rect AABB 批量剔除:# ⚠️ _visible_flags 必须声明为 EnemyManager 的类成员(PackedByteArray), # 禁止在 _physics_process 内用 var 声明(否则每帧分配新数组,触发 GC 抖动)。 # 类成员声明(EnemyManager 顶部): # var _visible_flags: PackedByteArray = PackedByteArray() # 每当 _enemy_count 扩容时同步 _visible_flags.resize(_enemy_count) # # EnemyManager._physics_process 每帧执行(O(N) 单次矩形测试): var cam_rect: Rect2 = _get_camera_rect_expanded(128.0) # 扩大 128px 防闪烁 for i in range(_enemy_count): var pos := Vector2(_data[i * ENEMY_STRIDE + 0], _data[i * ENEMY_STRIDE + 1]) _visible_flags[i] = int(cam_rect.has_point(pos)) # ⚠️ 必须显式 int() 转换 # has_point() 返回 bool, # 直接赋给 PackedByteArray[i] 会静默写入错误值 # _physics_process 遍历时跳过 _visible_flags[i] == 0 的实体 AI 计算- 相比 VisibilityNotifier2D,此方案无额外节点、无信号开销,N=1000 时约节省 ~1000 次信号 dispatch/帧,适合 ECS-Lite 架构。
屏外 Boid 分离力低频保留:完全跳过屏外敌人 AI 会导致 Boid 分离斥力缺失——大量屏外实体相互挤叠,进入视野瞬间爆发弹射(视觉闪烁)。 解决方案:
_visible_flags[i] == 0的屏外敌人不跳过分离力更新,改为以低频单独运行:const OFFSCREEN_SEPARATION_INTERVAL: int = 10 # 每 10 帧更新一次屏外分离力 for i in range(_enemy_count): if _visible_flags[i]: _update_ai_full(i) # 全量 AI:索敌 + 分离力 + 动画参数 elif Engine.get_physics_frames() % OFFSCREEN_SEPARATION_INTERVAL == i % OFFSCREEN_SEPARATION_INTERVAL: _update_separation_only(i) # 仅分离斥力:查 SpatialGrid 最近 8 格,O(1)
_update_separation_only无 AI 状态机、无动画更新,每帧参与计算的屏外实体数 ≤ N/10。- 10 帧低频下最坏分离响应时延 ≈ 166ms(60 Hz),不可感知;可消除进视野瞬间弹射。
2.4 状态效果系统 (StatusManager)
核心常量
# status_manager.gd (Autoload: StatusManager)
# _physics_process 热路径见 csharp/autoloads/StatusManagerCs.cs (ADR-L1)
## 每个实体允许同时存在的最大状态类型数量(STATUS_BATCH_LIMIT)
const STATUS_BATCH_LIMIT: int = 8
## 同一状态类型的最大叠加层数上限
const STATUS_MAX_STACKS: int = 16
设计依据:单实体最多 8 种状态类型。_physics_process 最坏情况 = 1000 敌人 × 8 状态 = 8000 次 tick 检查/帧,仍在帧预算内(ADR-L1 规定 StatusManagerCs < 1.5ms)。
超限淘汰策略(Eviction)
当某实体活跃状态数 = STATUS_BATCH_LIMIT 且接收到新状态时,按以下顺序决策:
| 优先级 | 规则 |
|---|---|
| 1(最高) | 同类型 → 刷新 duration,按叠加规则增减 stacks,不占新槽 |
| 2 | 同组升级(如 CHILL→FREEZE)→ 替换,保留剩余 duration,不增加槽数 |
| 3 | LRU 淘汰 → 踢出 last_applied_time 最早的状态,空槽后写入新状态 |
| 4(最低) | 新状态优先级 < 所有现存状态 → 静默丢弃,push_warning 记录 |
## 状态分组(同组内高等级覆盖低等级)
const STATUS_GROUP: Dictionary = {
StatusType.BURN: "fire_dot", StatusType.SCORCH: "fire_dot",
StatusType.POISON: "toxic_dot", StatusType.CORRODE: "toxic_dot",
StatusType.CHILL: "cold", StatusType.FREEZE: "cold",
StatusType.SLOW: "cc", StatusType.STUN: "cc",
}
DoT Tick 规则
// StatusManagerCs.cs —— _physics_process 热路径(C#)
// tick_timer 使用 while 模式,与 ZoneManager 规范一致:
// while (tickTimer >= TICK_INTERVAL) { tickTimer -= TICK_INTERVAL; ApplyDot(entityId, status); }
// 最坏情况:STATUS_BATCH_LIMIT = 8 种 × 1000 敌人 = 8000 次检查/帧
与 ZoneManager 的交互规则
- ZoneManager 通过
StatusManager.apply(entity_id, type, stacks, duration=0, source_id=zone_id)施加持续性区域 DoT。 duration=0表示"持续存在直到来源主动移除";区域销毁时调用StatusManager.remove_source(zone_id)批量清除。- 同一来源的相同状态不占新槽,直接刷新 timer(防止多个同类型区域叠满 8 槽)。
2.5 基础服务框架 (Layer 3: System Services)
使用 Godot 4 的 InputMap 系统作为底层驱动,在上层封装一层 "Action-Based" 的抽象。
- 抽象层: 游戏逻辑通过
Input.get_vector("move_left", "move_right", "move_up", "move_down")获取移动向量,不关心具体设备。 - 设备支持:
- 键盘/鼠标:
WASD在 InputMap 中映射为move_*Action,鼠标位置通过get_global_mouse_position()获取瞄准方向。 - 虚拟摇杆: 监听 UI 层触摸事件,转换为 (-1, 1) 的向量,映射到同名 Action。
- 手柄 (Gamepad): 利用 Godot 内置的
Input.get_joy_axis()和Input.is_joy_button_pressed(),InputMap 已支持手柄轴自动绑定。- 自动适配: 识别
Input.get_joy_name(device_id)来区分 Xbox / PS 的按键图标显示。 - 死区处理: 在 InputMap 中直接配置
Deadzone参数,或在 Manager 层统一处理防止摇杆漂移。
- 自动适配: 识别
- 键盘/鼠标:
PlayerManager 移动状态追踪:
game_design.md §5描述了移动敏感型法术 (移动增益型、冲刺波、停步蓄力型),这些功能要求PlayerManager持续追踪玩家运动状态:# player_manager.gd(相关字段,非完整类定义) var _is_moving: bool = false # 当前帧是否在移动(move_vector.length() > MOVE_THRESHOLD_NORM) var _stationary_time: float = 0.0 # 连续静止时长(秒);移动时立即清零 var _charge_triggered: bool = false # 停步蓄力已触发标志;边沿检测,防止每帧重复发出 CHARGE_FIRED var _last_dash_time: float = -999.0 # 上次冲刺时刻(用于冲刺波冷却) # MOVE_THRESHOLD_NORM:归一化输入轴幅度(0.0~1.0),非像素/秒速度 # 0.067 = 约 6.7% 摇杆偏移即判定为移动(死区 + 轻微触碰阈值) const MOVE_THRESHOLD_NORM: float = 0.067 # 对应约 20px/s @ 300px/s 最大速度 const CHARGE_TRIGGER_TIME: float = 1.5 # 停步蓄力法术所需静止时长(秒) func _physics_process(delta: float) -> void: var move_vec := Input.get_vector("move_left", "move_right", "move_up", "move_down") _is_moving = move_vec.length() > MOVE_THRESHOLD_NORM if _is_moving: if _stationary_time > 0.0: _stationary_time = 0.0 _charge_triggered = false # 移动后重置标志,允许下次停步重新触发 EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": player_id, "progress": 0.0}) else: _stationary_time += delta if _stationary_time >= 0.3: # 0.3 秒后开始显示蓄力进度(game_design.md §5) var prog := clampf(_stationary_time / CHARGE_TRIGGER_TIME, 0.0, 1.0) EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": player_id, "progress": prog}) # 边沿检测:仅首次越过阈值时发出一次 CHARGE_FIRED;持续静止不会重复发送 # SpellEvaluator 只订阅 CHARGE_FIRED 以执行停步蓄力法术(非 CHARGE_STATE_CHANGED)。 if _stationary_time >= CHARGE_TRIGGER_TIME and not _charge_triggered: _charge_triggered = true EventBus.emit(EventID.CHARGE_FIRED, {"caster_id": player_id}) # 冲刺输入检测(冲刺波 Core 专属) if Input.is_action_just_pressed("dash"): _last_dash_time = Time.get_ticks_msec() / 1000.0 # 附带 stationary_time:SpellEvaluator 用于判断蓄力时长奖励;UIManager 用于清除蓄力 UI EventBus.emit(EventID.DASH_TRIGGERED, { "caster_id": player_id, "stationary_time": _stationary_time # 冲刺前已积累的静止时长(秒) }) _stationary_time = 0.0 # 冲刺后立即清零 _charge_triggered = false # 冲刺打断蓄力,重置触发标志允许下次重新蓄力 # SpellEvaluator 订阅 DASH_TRIGGERED,触发 dash-wave Core 的附加法术释放设计约束:
CHARGE_STATE_CHANGED事件仅由UIManager订阅以驱动蓄力进度环动画;SpellEvaluator订阅CHARGE_FIRED(一次性边沿触发信号)以实际释放停步蓄力法术。 两者均通过 EventBus 解耦,PlayerManager不直接持有 UI 引用或 SpellEvaluator 引用。 ⚠️ 禁止让SpellEvaluator订阅CHARGE_STATE_CHANGED并在progress >= 1.0时触发法术—— 该事件每帧发送(60Hz),会导致蓄力法术每帧重复释放,违反"首次越阈值只触发一次"的设计原则。
B. 音频系统 (AudioManager)
针对"割草"游戏的高并发特性进行封装。
- Audio Pool: 预先实例化 32 个
AudioStreamPlayer2D节点,循环使用。 - Throttling (节流): 维护一个
last_played_time字典。- 规则: 如果
explosion_sound在 0.1s 内已经播放过,且新的请求音量没有更大,则丢弃该请求。这避免了 50 个敌人同时炸裂时的爆音。
- 规则: 如果
- Spatial (空间感): 使用
AudioStreamPlayer2D的内置衰减曲线,或根据摄像机位置动态调整volume_db和panning_strength。
C. 资源管理 (ResourceManager)
- 后台加载: 使用
ResourceLoader.load_threaded_request(path)异步加载法术图标、特效场景等,避免卡顿。 - 分组管理: 利用 Godot 的
ResourceGroup或按文件夹路径约定管理资源(如res://resources/spells/、res://scenes/enemies/)。 - 引用计数: Godot 的
Resource本身基于引用计数,场景卸载时未被引用的资源自动释放。对于手动预加载的资源,切换场景时显式queue_free()对应节点以触发释放。
D. 特效管理 (VFXManager)
VFXManager 是 Autoload 单例,所有视觉特效(命中闪光、爆炸、DoT 气泡等)必须通过此管理器播放,禁止在逻辑层直接操作 Node。
# vfx_manager.gd (Autoload)
# 职责:接收逻辑事件并以数据驱动方式触发对应特效,逻辑层与表现层完全解耦
const MAX_ACTIVE_VFX: int = 200 # 同屏最大活跃特效数量上限
var _vfx_pool: Dictionary = {} # { "effect_id": Array[GPUParticles2D] } — 按类型分池
# VFX 场景预加载表;_ready() 中按配置填充,play() 通过此表实例化节点
# 例: _vfx_scenes["hit_spark"] = preload("res://scenes/vfx/hit_spark.tscn")
var _vfx_scenes: Dictionary = {} # { "effect_id": String -> PackedScene }
# 维护活跃特效计数器(O(1) 计数,不在 play() 中遍历 pool 统计)
var _active_count: int = 0 # 当前正在 emitting 的特效节点总数(O(1) 计数,避免遍历 pool 统计)
# EventBus 订阅列表(在 _ready() 中注册):
# EventID.BULLET_HIT → play("hit_spark", hit_position)
# EventID.ENEMY_KILLED → play("death_burst", enemy_position)
# EventID.STATUS_APPLIED → play("status_" + status_name, target_position)
# EventID.SPELL_CAST_BEGIN→ play("cast_flash", caster_position)
func play(effect_id: String, world_pos: Vector2, override_scale: float = 1.0) -> void:
if _active_count >= MAX_ACTIVE_VFX: # O(1) 检查,达上限时静默丢弃
return
var node := _pool_pop(effect_id)
node.position = world_pos
node.scale = Vector2.ONE * override_scale
node.emitting = true
_active_count += 1
node.finished.connect(_pool_return.bind(effect_id, node), CONNECT_ONE_SHOT)
func _pool_pop(effect_id: String) -> GPUParticles2D:
if not _vfx_pool.has(effect_id) or _vfx_pool[effect_id].is_empty():
return _instantiate_vfx(effect_id) # 按需实例化并加入场景树
return _vfx_pool[effect_id].pop_back()
func _pool_return(effect_id: String, node: GPUParticles2D) -> void:
node.emitting = false
_active_count -= 1 # CONNECT_ONE_SHOT 保证每个节点只调用一次
# 首次归还时 _vfx_pool 中可能尚无此 effect_id 键,需先初始化空列表再 append
if not _vfx_pool.has(effect_id):
_vfx_pool[effect_id] = []
_vfx_pool[effect_id].append(node)
# _pool_pop() 中对应 effect_id 的池为空时,按需创建新 GPUParticles2D 节点。
# ⚠️ node.one_shot 必须为 true:若为 false,GPUParticles2D 持续循环发射,
# finished 信号永不触发,_pool_return 不被调用,_active_count 只增不减,
# 触达 MAX_ACTIVE_VFX 后所有后续特效全部被静默丢弃(完全失效)。
# 父节点:VFXManager 自身(Autoload 单例),跨场景持久存活,确保池化节点复用。
func _instantiate_vfx(effect_id: String) -> GPUParticles2D:
if not _vfx_scenes.has(effect_id):
# 未注册的 effect_id:返回空占位节点(不崩溃),开发期通过 push_warning 提示漏配
push_warning("VFXManager: 未知特效 ID='%s',请检查 _ready() 中的预加载配置" % effect_id)
var placeholder := GPUParticles2D.new()
placeholder.one_shot = true # 占位节点同样需要 one_shot,确保 finished 可触发
add_child(placeholder)
return placeholder
var node: GPUParticles2D = _vfx_scenes[effect_id].instantiate()
node.one_shot = true # 必须为 true,否则 finished 永不触发,池化失效
node.emitting = false # 初始不发射,等待 play() 设置 position/scale 后再启动
add_child(node) # 挂载到 VFXManager(Autoload)节点下,跨场景持久存活
return node
性能约束(V-N1):
MAX_ACTIVE_VFX = 200超出时静默丢弃,避免大量爆炸帧暴涨。 伤害数字(DamageNumber)由UIManager而非VFXManager管理,两者隔离(参见architecture_design.md §4.4Throttling)。
E. 元数据管理 (ProfileManager)
- Save/Load: 使用 Godot 的
FileAccess读写 JSON 字符串,或使用ConfigFile存储键值对数据,存储路径为user://save_data.json。 - Schema: 定义
SaveData字典结构,包含unlocked_spells: Array[String]、achievements: Array[int]等字段。 - 版本字段:
SaveData根层必须包含schema_version: int(当前版本 = 1)。 - 加载时版本检查:
- 读取
schema_version,若字段不存在则视为v0。 - 对每个低版本执行迁移函数(
_migrate_v0_to_v1(data)),补全缺失字段为默认值。 - 若
schema_version > 当前版本(由更新版本写入),提示版本不匹配并回退到初始状态,避免崩溃。
- 读取
- 参考:
docs/design/core_wand_design.md中的 SaveData JSON 序列化示例。
E.1 存档损坏 UX 规范(C-GAP-3 修复)
load_run() 和 EndlessRecordsManager.load() 不得静默降级——返回空数据时必须通过 UI 告知玩家,禁止无声失败。
| 失败场景 | 触发条件 | UI 行为 |
|---|---|---|
load_run() 解析失败 |
JSON 格式错误 / schema_version 缺失 / 两槽均损坏 |
UIManager.show_data_corrupted_dialog("run") → 对话框:「检测到存档异常,当前游戏进度无法读取。是否开始新游戏?」;确认后清除 run 文件并进入主菜单新游戏流程 |
EndlessRecordsManager.load() 签名失败 |
HMAC 不匹配(文件被篡改或损坏) | UIManager.show_data_corrupted_dialog("leaderboard") → Toast 提示:「排行榜分数验证失败,本地记录已重置」;不影响游戏进程,直接以空记录继续 |
load_run() 返回 {} 但存档文件存在 |
两槽均损坏 | 同 run 对话框,额外追加一条 crash_log.txt 记录,便于复现调试 |
# profile_manager.gd — load_run() 调用方规范(游戏启动时)
var run_data := load_run()
if run_data.is_empty() and (
FileAccess.file_exists(_SLOT_A) or FileAccess.file_exists(_SLOT_B)):
# 文件存在但解析为空 → 损坏,不静默降级
UIManager.show_data_corrupted_dialog("run")
return # 由对话框回调决定后续流程(新游戏 or 退出)
UIManager.show_data_corrupted_dialog(type: String)接口规范:
type = "run":模态对话框,两个按钮:「开始新游戏」(清除损坏 Run 文件)/ 「返回主菜单」。type = "leaderboard":非模态 Toast(3 秒自动消失),不阻断游戏流程。- 对话框文案须通过
tr()包裹(L10n 支持):键名"DIALOG_SAVE_CORRUPTED_RUN"/"TOAST_LEADERBOARD_RESET"。
game_design.md §8.1要求死亡时自动截图保存构建快照。实现规范如下:DPS 计算:
CombatManager(或ProfileManager)维护滚动窗口:# 环形缓冲区:O(1) 写入/覆盖,无内存分配,零 GC 压力(替代 Array + pop_front() 的 O(N) 方案) # _recalc_window() 懒加载:仅在 get_dps() 查询时计算(不在每次 record_damage() 触发) # 理由:record_damage() 每帧可能被数百次 AoE 命中调用;UIManager 仅每 0.5 秒轮询 DPS const DPS_WINDOW_SEC: float = 3.0 const _RB_SIZE: int = 256 # 环形缓冲区槽数(上限:3秒内最多 256 次伤害事件) var _rb_time: PackedFloat64Array # 各槽时间戳 var _rb_dmg: PackedFloat32Array # 各槽伤害值 var _rb_head: int = 0 # 下一次写入位置(覆盖最旧记录) var _rb_count: int = 0 # 有效记录数(最大 = _RB_SIZE) var _accumulated_dmg: float = 0.0 # 当前窗口内伤害累计(主动维护,O(1) 读取) func _ready() -> void: _rb_time = PackedFloat64Array(); _rb_time.resize(_RB_SIZE) _rb_dmg = PackedFloat32Array(); _rb_dmg.resize(_RB_SIZE) func record_damage(amount: float) -> void: var now := Time.get_ticks_msec() / 1000.0 _rb_time[_rb_head] = now _rb_dmg[_rb_head] = amount _rb_head = (_rb_head + 1) % _RB_SIZE if _rb_count < _RB_SIZE: _rb_count += 1 _accumulated_dmg += amount # 乐观累加;get_dps() 时统一裁剪过期值 func _recalc_window(now: float) -> void: var sum: float = 0.0 var tail := (_rb_head - _rb_count + _RB_SIZE) % _RB_SIZE var valid: int = 0 for k in _rb_count: var idx := (tail + k) % _RB_SIZE if (now - _rb_time[idx]) <= DPS_WINDOW_SEC: sum += _rb_dmg[idx] valid += 1 _accumulated_dmg = sum _rb_count = valid func get_dps() -> float: _recalc_window(Time.get_ticks_msec() / 1000.0) # 懒加载裁剪 return _accumulated_dmg / DPS_WINDOW_SEC # 平均 DPS(过去 3 秒)
UIManager每 0.5 秒轮询CombatManager.get_dps(),更新 HUD DPS 标签;靶场模式下同步更新 DPS 面板。Run 截图:死亡时
GameCycleManager调用:func _save_run_screenshot() -> void: var img: Image = get_viewport().get_texture().get_image() var path := "user://runs/%s_run_%d.png" % [ Time.get_datetime_string_from_system().substr(0, 10), ProfileManager.get_run_count() ] img.save_png(path)截图在主线程调用(
get_image()是同步操作),建议在死亡动画第 1 帧完成后、统计屏显示前执行, 避免截到黑屏。
3. 开发阶段规划 (Development Stages)
Phase 1: 核心验证 (The Engine)
- 定义
SpellNode基类 (已完成)。 - 实现
SpellEvaluator(纯逻辑,无 UI)。 - 实现
SpatialGrid碰撞检测。 - 编写单元测试:构建一个简单的"散弹枪"逻辑,验证输出数据是否符合预期。
Phase 2: 视觉化 (The Visuals)
- 搭建 Godot 场景,实现玩家移动 (键盘/手柄)。
- 接入
BulletManager,将逻辑坐标同步到 Sprite2D。 - 实现简单的敌人 AI (追着玩家跑)。
Phase 3: 构建系统 (The Builder)
- 实现各种具体的
SpellNode(Actions, Modifiers)。 - UI 开发:背包拖拽系统。
- 存档与解析系统 (JSON <-> SpellDeck),含 Schema 版本字段。
SpellDeck JSON 序列化格式 (E2 权威格式)
{
"schema_version": 1,
"core_id": "linear_core_t2",
"slots": [
{ "spell_id": "modifier_damage_plus", "position": 0 },
{ "spell_id": "action_spark_bolt", "position": 1 },
{ "spell_id": "trigger_on_hit", "position": 2 },
{ "spell_id": "action_chain_bolt", "position": 3 }
]
}
core_id:核心 ID,决定插槽拓扑结构。slots按position排列,position为核心插槽座标(线性核心就是序数,网格核心为{x, y}对)。spell_id对应SpellRegistry中的全局唯一 ID。
Phase 4: 循环与内容 (The Game)
- 商店逻辑。
- 波次管理器。
- 数值接入。
- ZoneManager(地面效果系统)。
- VFXManager 特效池。
- Endless 排行榜本地存储。
4. 关键技术难点预案
Q1: GDScript 如何避免 GC 抖动?
- 预案: 所有的
ProjectilePayload、SpellContext在游戏启动时预分配并放入Array池。使用时pool.pop_back()取出,用完调用reset()后pool.append()归还,不依赖 GDScript 的自动引用计数销毁。
Q2: 复杂的法术链如何调试?
- 预案: 开发一个 Godot 编辑器插件(
EditorPlugin)作为可视化的 "Debug Graph" 工具。在编辑器模式下,可以单步执行SpellEvaluator,看到当前的 Stack 和 Deck 状态,并在 2D 视图中高亮对应节点。
Q3: 物理碰撞性能不够怎么办?
- 预案:
- 确保
CollisionShape2D使用圆形(最快),并合理配置 Physics Layer/Mask 减少检测对数。 - 将碰撞检测分帧(Time-Slicing),这一帧算一半子弹,下一帧算另一半。对于高速割草游戏,延迟一帧通常不可感知。
- 终极方案:完全放弃 Area2D,改用自定义
SpatialGrid,在_physics_process中手动执行圆形重叠测试。
- 确保
Q4: PackedFloat32Array 删除策略?
- 问题:直接调用
PackedFloat32Array.resize()删除元素是 O(n),对 2000+ 弹幕每帧有明显性能开销。 - 预案:末位交换 (Swap-and-Pop):
- 子弹 “死亡” 时,将数组最后一个元素的数据复制到当前位置,然后缩减数组长度 1 个 stride。
- 复杂度 O(1),但子弹顺序不再保证(对融剆这个游戏类型无影响)。
- 实际实现:
BulletManager内部维护_active_count: int,子弹数据帮存在[0, _active_count)区间内,不依赖 resize()。
Q5: GDScript 与 C# 如何协同工作?
- 热路径 C# 文件目录:
scripts/core_csharp/BulletManagerCore.cs、SpellEvaluatorCore.cs。 - 调用方式:在 Godot 4 中,GDScript 可通过
var evaluator = preload("res://scripts/SpellEvaluatorCore.cs").new()直接实例化 C# 类,调用公开方法无需特殊处理。 - 跨语言传递类型:只传递 GDScript / C# 双方都支持的类型:
int、float、Vector2、Godot.Array、Godot.Dictionary。禁止将 GDScriptRefCounted自定义类型直接传入 C#:应将复杂数据序列化为PackedFloat32Array或intID 后传递。 - 原则:只有当 Profiler 显示具体函数是确认瓶颈时,才将其迁移到 C#;默认先用 GDScript 实现,确保选择的正确性。