初次提交
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
# Boss 系统架构设计
|
||||
|
||||
> **关联切片**:S6 P0(Mini Boss Wave 8 / Final Boss Wave 20)
|
||||
> **依赖系统**:EnemyManager、StatusManager、EventBus、VFXManager、ZoneManager
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
- **阶段驱动(Phase-Driven)**:Boss 行为由明确的阶段(Phase)枚举驱动,阶段切换在 `_physics_process` 中以血量阈值触发,不使用独立 Timer Node。
|
||||
- **与 EnemyManager 解耦**:Boss 不进入 EnemyManager SoA(避免 stride 污染和 AI 逻辑膨胀),由独立的 `BossManager` Autoload 管理;但向 SpatialGrid 注册自身坐标,使子弹归航与 ZoneManager 能命中 Boss。
|
||||
- **数据驱动**:每个 Boss 的阶段配置存储于 `.tres` Resource(`BossPhaseDef`),美术/策划可在不修改代码的情况下调整阈值与攻击模式。
|
||||
- **EventBus 集成**:阶段切换、死亡均通过 EventBus 广播,HUD/BGM/成就系统订阅。
|
||||
|
||||
---
|
||||
|
||||
## 2. BossManager Autoload
|
||||
|
||||
```gdscript
|
||||
# boss_manager.gd (Autoload: BossManager)
|
||||
# Boss 实体数量极少(≤ 2 同屏),不使用 SoA,用 Array[BossInstance] 即可。
|
||||
|
||||
const MAX_BOSSES: int = 2 # 同屏 Boss 上限
|
||||
|
||||
var _active: Array[BossInstance] = []
|
||||
|
||||
func spawn_boss(boss_def_id: String, spawn_pos: Vector2) -> int:
|
||||
# 从 BossRegistry 加载 BossDef,实例化 BossInstance,注册到 SpatialGrid
|
||||
pass
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
for boss in _active:
|
||||
boss.tick(delta)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 状态机模板
|
||||
|
||||
### 3.1 枚举与切换条件
|
||||
|
||||
每个 Boss 的阶段定义在 `BossPhaseDef` Resource 中:
|
||||
|
||||
```gdscript
|
||||
# boss_phase_def.gd
|
||||
class_name BossPhaseDef
|
||||
extends Resource
|
||||
|
||||
@export var phase_id: int # 阶段编号(0 起始)
|
||||
@export var hp_threshold: float # 进入此阶段的血量百分比(0.0~1.0),如 0.5 = 50% HP
|
||||
@export var attack_patterns: Array[String] # 此阶段可用的攻击模式 ID 列表
|
||||
@export var move_speed_mult: float = 1.0 # 移动速度倍率(狂暴阶段可设 1.5)
|
||||
@export var enrage_vfx: String = "" # 阶段切换时触发的 VFX ID(空=无)
|
||||
@export var phase_bgm_layer: int = 0 # 动态音乐层级(0=基础,1=紧张,2=狂暴)
|
||||
```
|
||||
|
||||
### 3.2 BossInstance 状态机
|
||||
|
||||
```gdscript
|
||||
# boss_instance.gd
|
||||
class_name BossInstance
|
||||
extends RefCounted
|
||||
|
||||
enum BossPhaseState {
|
||||
PHASE_0, # 初始阶段(100% HP)
|
||||
PHASE_1, # 受伤阶段(阈值由 BossDef 配置,通常 70%)
|
||||
PHASE_2, # 危机阶段(通常 40%)
|
||||
PHASE_DYING # 死亡动画阶段(HP ≤ 0,播放死亡序列后才真正移除)
|
||||
}
|
||||
|
||||
var boss_def: BossDef
|
||||
var hp: float
|
||||
var hp_max: float
|
||||
var position: Vector2
|
||||
var current_phase: BossPhaseState = BossPhaseState.PHASE_0
|
||||
var _attack_cooldown: float = 0.0
|
||||
var _pattern_index: int = 0 # 当前阶段攻击模式的轮询索引
|
||||
var _phase_just_changed: bool = false # 本帧刚切换阶段的标志,用于触发 enrage VFX
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
_check_phase_transition()
|
||||
if current_phase == BossPhaseState.PHASE_DYING:
|
||||
_tick_dying(delta)
|
||||
return
|
||||
_tick_movement(delta)
|
||||
_tick_attack(delta)
|
||||
_sync_render()
|
||||
|
||||
# 血量阈值检查(每帧)
|
||||
func _check_phase_transition() -> void:
|
||||
var hp_pct: float = hp / hp_max
|
||||
var phases: Array = boss_def.phases # Array[BossPhaseDef],按 hp_threshold 降序排列
|
||||
for i in phases.size():
|
||||
var phase_def: BossPhaseDef = phases[i]
|
||||
if hp_pct <= phase_def.hp_threshold and int(current_phase) < i + 1:
|
||||
_enter_phase(i + 1, phase_def)
|
||||
break
|
||||
|
||||
func _enter_phase(new_phase: int, phase_def: BossPhaseDef) -> void:
|
||||
current_phase = new_phase as BossPhaseState
|
||||
_pattern_index = 0
|
||||
_attack_cooldown = 0.0
|
||||
# 阶段切换 VFX
|
||||
if phase_def.enrage_vfx != "":
|
||||
VFXManager.play(phase_def.enrage_vfx, position, 1.0)
|
||||
# 通知 EventBus(HUD 高亮、BGM 切换)
|
||||
EventBus.emit(EventID.BOSS_PHASE_CHANGED, {
|
||||
"boss_id": boss_def.id,
|
||||
"phase": new_phase,
|
||||
"bgm_layer": phase_def.phase_bgm_layer
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 攻击模式系统 (Attack Pattern)
|
||||
|
||||
攻击模式以 ID 字符串存储,在 `BossAttackRegistry` 中注册(类比 `SpellRegistry`),运行时由 `_tick_attack` 调用:
|
||||
|
||||
```gdscript
|
||||
# boss_instance.gd(续)
|
||||
func _tick_attack(delta: float) -> void:
|
||||
_attack_cooldown -= delta
|
||||
if _attack_cooldown > 0.0:
|
||||
return
|
||||
var phase_def: BossPhaseDef = boss_def.phases[int(current_phase)]
|
||||
if phase_def.attack_patterns.is_empty():
|
||||
return
|
||||
# 轮询攻击模式
|
||||
var pattern_id: String = phase_def.attack_patterns[
|
||||
_pattern_index % phase_def.attack_patterns.size()
|
||||
]
|
||||
_pattern_index += 1
|
||||
var cooldown: float = BossAttackRegistry.execute(pattern_id, self)
|
||||
_attack_cooldown = cooldown
|
||||
```
|
||||
|
||||
`BossAttackRegistry.execute(id, boss)` 返回该攻击模式的冷却时间(秒)。每种攻击模式是一个独立的 GDScript 函数或 Resource,通过 SpellEvaluator 发射子弹(走标准 BulletManager 路径)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 具体 Boss 设计模板
|
||||
|
||||
### 5.1 Mini Boss — Wave 8(待策划填充)
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| `boss_id` | `"mini_boss_w8"` |
|
||||
| `hp_max` | 2000(约为普通精英 6.6×) |
|
||||
| `move_speed` | 120(比杂鱼慢,靠范围攻击弥补) |
|
||||
| **Phase 0**(100%→60% HP)| 攻击模式:`"charge_slam"` + `"spread_shot_3"` |
|
||||
| **Phase 1**(60%→30% HP)| 追加:`"summon_minion_x2"`;move_speed_mult=1.2 |
|
||||
| **Phase 2**(30%→0% HP)| 追加:`"zone_aoe_fire"`;move_speed_mult=1.5;enrage_vfx="boss_enrage" |
|
||||
| 死亡事件 | `BOSS_KILLED`(EventID 待分配,建议 ID=15)|
|
||||
| 战场机制 | 无(Mini Boss 不设场地机制,Final Boss 才有)|
|
||||
|
||||
### 5.2 Final Boss — Wave 20(待策划填充)
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| `boss_id` | `"final_boss_w20"` |
|
||||
| `hp_max` | 10000 |
|
||||
| `move_speed` | 80(极慢,靠弹幕覆盖)|
|
||||
| **Phase 0**(100%→70% HP)| 攻击模式:`"spiral_shot_8"` + `"summon_turrets_x4"` |
|
||||
| **Phase 1**(70%→40% HP)| 追加:`"zone_ice_wall"`;场地:随机生成 4 个冰墙障碍(`ZoneManager` 驱动)|
|
||||
| **Phase 2**(40%→15% HP)| 追加:`"enrage_laser_sweep"`;场地收缩:边界每 10 秒向中心缩小 50px |
|
||||
| **Phase 3**(15%→0% HP)| 全弹幕覆盖;`"phase3_barrage"`;恢复部分 HP 后进入死亡序列(演出用)|
|
||||
| 死亡事件 | `BOSS_KILLED`(`EventID` **16**)+ `GAME_CLEARED`(**17**);阶段切换为 **15**(均以 `implementation_plan.md` §2.1 为准)|
|
||||
| 战场机制 | 场地收缩(Phase 2+)、冰墙障碍(Phase 1+)|
|
||||
|
||||
---
|
||||
|
||||
## 6. EventBus 扩展(Boss 专用事件)
|
||||
|
||||
> **权威登记**:以下 ID 已并入 `technical/implementation_plan.md` §2.1 事件目录与 `event_ids.gd`,本节表为语义说明(勿改号)。
|
||||
|
||||
| 事件 ID | 常量名 | Payload | 订阅方 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 15 | `BOSS_PHASE_CHANGED` | `{boss_id, phase, bgm_layer}` | HUD(阶段提示)、AudioManager(BGM 切换)|
|
||||
| 16 | `BOSS_KILLED` | `{boss_id, wave_num}` | WaveManager(进入结算)、AchievementManager |
|
||||
| 17 | `GAME_CLEARED` | `{elapsed_sec, wave_num}` | GameCycleManager(结算屏)、排行榜 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 与现有系统的接口约定
|
||||
|
||||
| 接口 | 调用方向 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `SpatialGrid.register_boss(id, pos, radius)` | BossManager → SpatialGrid | Boss 作为可被子弹命中的实体,每帧更新坐标 |
|
||||
| `BulletManager._on_bullet_hit(bullet_id, target_id)` | BulletManager → BossManager | `target_id` 前缀区分:`enemy_xxx` vs `boss_xxx`(或通过 faction 位掩码判断)|
|
||||
| `ZoneManager.spawn_zone(...)` | BossAttackPattern → ZoneManager | Boss 攻击模式可生成 Zone(如冰墙、火圈)|
|
||||
| `VFXManager.play(vfx_id, pos, scale)` | BossInstance → VFXManager | 阶段切换 + 死亡演出 VFX |
|
||||
| `SpellEvaluator.execute_boss_pattern(pattern_id, origin)` | BossAttackRegistry → SpellEvaluator | Boss 弹幕走标准 SpellEvaluator 路径,复用子弹生成管线 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 目录规范
|
||||
|
||||
```text
|
||||
scripts/autoloads/
|
||||
boss_manager.gd # Autoload BossManager
|
||||
scripts/domain/boss/
|
||||
boss_def.gd # class_name BossDef (Resource)
|
||||
boss_phase_def.gd # class_name BossPhaseDef (Resource)
|
||||
boss_instance.gd # class_name BossInstance (RefCounted)
|
||||
boss_attack_registry.gd # BossAttackRegistry(类比 SpellRegistry)
|
||||
resources/bosses/
|
||||
mini_boss_w8.tres
|
||||
final_boss_w20.tres
|
||||
resources/boss_patterns/
|
||||
charge_slam.tres
|
||||
spread_shot_3.tres
|
||||
spiral_shot_8.tres
|
||||
# ...
|
||||
```
|
||||
@@ -0,0 +1,815 @@
|
||||
# 程序架构设计与实施计划 (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)** 技术方案:
|
||||
```gdscript
|
||||
# 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 常量),禁止在业务代码中直接写裸整数,防止撞号和可读性问题。
|
||||
```gdscript
|
||||
# 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` |
|
||||
> 新增事件从 **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,定义如下:
|
||||
> ```gdscript
|
||||
> # ── 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** — 运行时执行游标
|
||||
```gdscript
|
||||
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** — 预编译产物,关卡期间只读
|
||||
```gdscript
|
||||
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 调用的临时状态
|
||||
```gdscript
|
||||
# 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`)
|
||||
```gdscript
|
||||
# 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` 接口:
|
||||
> ```gdscript
|
||||
> # 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_id
|
||||
- `status_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 剔除)**:❌ **不使用** `VisibilityNotifier2D` per-enemy 方案(1000+ 敌人各挂一个节点 = 1000+ 额外信号监听,节点开销显著)。改为 **Manager 侧 Camera Rect AABB 批量剔除**:
|
||||
```gdscript
|
||||
# ⚠️ _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` 的屏外敌人**不跳过分离力更新**,改为以低频单独运行:
|
||||
> ```gdscript
|
||||
> 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`)
|
||||
|
||||
#### 核心常量
|
||||
|
||||
```gdscript
|
||||
# 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` 记录 |
|
||||
|
||||
```gdscript
|
||||
## 状态分组(同组内高等级覆盖低等级)
|
||||
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 规则
|
||||
|
||||
```gdscript
|
||||
// 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` 持续追踪玩家运动状态:
|
||||
> ```gdscript
|
||||
> # 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**。
|
||||
|
||||
```gdscript
|
||||
# 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.4` Throttling)。
|
||||
|
||||
#### 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)。
|
||||
* **加载时版本检查**:
|
||||
1. 读取 `schema_version`,若字段不存在则视为 `v0`。
|
||||
2. 对每个低版本执行迁移函数(`_migrate_v0_to_v1(data)`),补全缺失字段为默认值。
|
||||
3. 若 `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` 记录,便于复现调试 |
|
||||
|
||||
```gdscript
|
||||
# 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`)维护滚动窗口:
|
||||
> ```gdscript
|
||||
> # 环形缓冲区: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` 调用:
|
||||
> ```gdscript
|
||||
> 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)
|
||||
* [x] 定义 `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 权威格式)
|
||||
|
||||
```json
|
||||
{
|
||||
"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: 物理碰撞性能不够怎么办?
|
||||
* **预案**:
|
||||
1. 确保 `CollisionShape2D` 使用圆形(最快),并合理配置 Physics Layer/Mask 减少检测对数。
|
||||
2. 将碰撞检测分帧(Time-Slicing),这一帧算一半子弹,下一帧算另一半。对于高速割草游戏,延迟一帧通常不可感知。
|
||||
3. 终极方案:完全放弃 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`。禁止将 GDScript `RefCounted` 自定义类型直接传入 C#:应将复杂数据序列化为 `PackedFloat32Array` 或 `int` ID 后传递。
|
||||
* **原则**:只有当 Profiler 显示具体函数是确认瓶颈时,才将其迁移到 C#;默认先用 GDScript 实现,确保选择的正确性。
|
||||
Reference in New Issue
Block a user