Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1207 lines
50 KiB
Markdown
1207 lines
50 KiB
Markdown
# Boss 阶段 + 弹幕/位移攻击模式系统 实现计划
|
||
|
||
> **For agentic workers:** 用 superpowers:subagent-driven-development(推荐)或 executing-plans 逐任务实现。步骤用 `- [ ]` 复选框。
|
||
|
||
**Goal:** 把 Boss 从「厚血召唤杂鱼」升级为数据驱动的多阶段状态机 + 7 种攻击模式(环/扇/瞄准/旋转 + 冲刺/蓄力/近战),并新建可伤害玩家的敌方子弹子系统。
|
||
|
||
**Architecture:** Boss 实体仍在 EnemyManager SoA(渲染/受击/HP 不变),移动与招式由新 autoload `BossManager` 侧挂驱动(EnemyManager 跳过其移动);敌方子弹由新 autoload `EnemyBulletManager`(独立 SoA 池,检测 vs 玩家)承载;数据在 `data/bosses.json`,`BossManager` 自载(仿 EnemyManager 载 enemies.json 之例)。
|
||
|
||
**Tech Stack:** Godot 4.6 GDScript · 纯 JSON 数据驱动 · 验证用 Godot MCP,无单元测试框架。
|
||
|
||
> **验证约定**:每任务 MCP 运行时断言。改码后 `reload_project`+`get_editor_errors`(0;新 autoload/新常量引用误报以 `play_scene` 运行时为准)+ `play_scene(main)` 后 `execute_game_script`(`_mcp_print` 打印断言)。`_physics_process` 驱动的系统无法在单次脚本内 await 帧——按既有惯例「先建状态、隔次调用再读」。@tool 编辑器改动用 `execute_editor_script` + `CACHE_MODE_IGNORE` fresh-load;dock 驻留旧类需 `EditorInterface.restart_editor(true)`。跑完 `stop_scene`。新 `.gd` 记得提交同名 `.gd.uid`。
|
||
|
||
> **Spec 偏差备案**:设计 §4.1 原写「阶段配置经 SettingsManager getter 读」,实现改为 `BossManager` 直接 `_load_defs()` 载 `bosses.json`(与 `EnemyManager._load_json_archetypes()` 自载 enemies.json 一致,减少耦合)。功能等价。
|
||
|
||
---
|
||
|
||
### Task 1: 事件常量 BOSS_PHASE_CHANGED / BOSS_KILLED
|
||
|
||
**Files:** `scripts/autoloads/event_ids.gd`, `docs/technical/implementation_plan.md`
|
||
|
||
- [ ] **Step 1: 加两个事件常量**
|
||
|
||
`event_ids.gd` 末尾(`const MINION_EXPIRED: int = 28` 与注释行之间)插入:
|
||
```gdscript
|
||
|
||
# ── S6 Boss 事件 (29+) ────────────────────────────────────────────
|
||
const BOSS_PHASE_CHANGED: int = 29 # 负载:{boss_id, boss_type, phase}
|
||
const BOSS_KILLED: int = 30 # 负载:{boss_id, boss_type, wave}
|
||
```
|
||
|
||
- [ ] **Step 2: 同步事件目录文档**
|
||
|
||
在 `docs/technical/implementation_plan.md` §2.1 事件目录表末尾追加两行(表格列与既有一致):
|
||
```markdown
|
||
| 29 | BOSS_PHASE_CHANGED | {boss_id, boss_type, phase} | HUD 阶段提示 |
|
||
| 30 | BOSS_KILLED | {boss_id, boss_type, wave} | 结算/成就 |
|
||
```
|
||
|
||
- [ ] **Step 3: 验证(MCP)** — `play_scene(main)`:
|
||
```gdscript
|
||
_mcp_print("PHASE=%d KILLED=%d (expect 29/30)" % [EventID.BOSS_PHASE_CHANGED, EventID.BOSS_KILLED])
|
||
```
|
||
Expected: 29/30。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add scripts/autoloads/event_ids.gd docs/technical/implementation_plan.md
|
||
git commit -m "feat(boss): 事件常量 BOSS_PHASE_CHANGED=29 / BOSS_KILLED=30 + 目录同步"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: EnemyBulletManager 敌方子弹 autoload
|
||
|
||
**Files:** Create `scripts/autoloads/enemy_bullet_manager.gd`, Modify `project.godot`
|
||
|
||
- [ ] **Step 1: 新建 enemy_bullet_manager.gd**
|
||
|
||
创建 `scripts/autoloads/enemy_bullet_manager.gd`,内容:
|
||
```gdscript
|
||
## EnemyBulletManager — 敌方子弹管理器 Autoload(伤害玩家)
|
||
## 平行于 BulletManager(玩家子弹 vs 敌人);本池每帧检测 vs 玩家。
|
||
## SoA 布局(ENEMY_BULLET_STRIDE=7):[ x, y, vx, vy, lifetime, radius, damage ]
|
||
## 0 1 2 3 4 5 6
|
||
extends Node
|
||
|
||
const ENEMY_BULLET_STRIDE: int = 7 # 禁裸整数
|
||
const MAX_ENEMY_BULLETS: int = 1024
|
||
const PLAYER_HIT_RADIUS: float = 12.0 # 玩家碰撞半径估算(与玩家标记视觉一致)
|
||
|
||
var _data: PackedFloat32Array = PackedFloat32Array()
|
||
var _active_count: int = 0
|
||
|
||
func _ready() -> void:
|
||
_data.resize(MAX_ENEMY_BULLETS * ENEMY_BULLET_STRIDE)
|
||
_data.fill(0.0)
|
||
|
||
func _physics_process(delta: float) -> void:
|
||
var player: Vector2 = PlayerManager.get_position()
|
||
var i: int = 0
|
||
while i < _active_count:
|
||
var base: int = i * ENEMY_BULLET_STRIDE
|
||
_data[base + 0] += _data[base + 2] * delta
|
||
_data[base + 1] += _data[base + 3] * delta
|
||
_data[base + 4] -= delta
|
||
if _data[base + 4] <= 0.0:
|
||
_swap_and_pop(i)
|
||
continue
|
||
var dx: float = _data[base + 0] - player.x
|
||
var dy: float = _data[base + 1] - player.y
|
||
var rr: float = _data[base + 5] + PLAYER_HIT_RADIUS
|
||
if dx * dx + dy * dy <= rr * rr:
|
||
PlayerStats.take_damage(_data[base + 6])
|
||
VFXManager.play("hit_spark", Vector2(_data[base + 0], _data[base + 1]))
|
||
_swap_and_pop(i)
|
||
continue
|
||
i += 1
|
||
|
||
func _swap_and_pop(idx: int) -> void:
|
||
var last: int = _active_count - 1
|
||
if idx != last:
|
||
var b_i: int = idx * ENEMY_BULLET_STRIDE
|
||
var b_l: int = last * ENEMY_BULLET_STRIDE
|
||
for s in ENEMY_BULLET_STRIDE:
|
||
_data[b_i + s] = _data[b_l + s]
|
||
_active_count -= 1
|
||
|
||
## 生成敌方子弹,返回 id(满容返回 -1)
|
||
func spawn_bullet(pos: Vector2, vel: Vector2, lifetime: float, radius: float, damage: float) -> int:
|
||
if _active_count >= MAX_ENEMY_BULLETS:
|
||
return -1
|
||
var id: int = _active_count
|
||
var base: int = id * ENEMY_BULLET_STRIDE
|
||
_data[base + 0] = pos.x
|
||
_data[base + 1] = pos.y
|
||
_data[base + 2] = vel.x
|
||
_data[base + 3] = vel.y
|
||
_data[base + 4] = lifetime
|
||
_data[base + 5] = radius
|
||
_data[base + 6] = damage
|
||
_active_count += 1
|
||
return id
|
||
|
||
func get_active_count() -> int:
|
||
return _active_count
|
||
|
||
## 渲染:SoA 位置 → MultiMesh(与 BulletManager.sync_multimesh 同法)
|
||
func sync_multimesh(mm: MultiMesh) -> void:
|
||
if mm == null:
|
||
return
|
||
var n: int = min(_active_count, mm.instance_count)
|
||
mm.visible_instance_count = n
|
||
for i in n:
|
||
var base: int = i * ENEMY_BULLET_STRIDE
|
||
var r: float = _data[base + 5]
|
||
mm.set_instance_transform_2d(i, Transform2D(
|
||
0.0, Vector2(r * 2.0, r * 2.0), 0.0,
|
||
Vector2(_data[base + 0], _data[base + 1])))
|
||
|
||
func reset() -> void:
|
||
_active_count = 0
|
||
_data.fill(0.0)
|
||
```
|
||
|
||
- [ ] **Step 2: 注册 autoload**
|
||
|
||
`project.godot` 的 `[autoload]` 段,在 `MetaProgress="*res://scripts/autoloads/meta_progress.gd"` 之后加:
|
||
```
|
||
EnemyBulletManager="*res://scripts/autoloads/enemy_bullet_manager.gd"
|
||
```
|
||
|
||
- [ ] **Step 3: 验证(MCP)** — `validate_script` 校验新文件;`play_scene(main)`:
|
||
```gdscript
|
||
EnemyBulletManager.reset()
|
||
var id = EnemyBulletManager.spawn_bullet(PlayerManager.get_position() + Vector2(100, 0), Vector2(-200, 0), 5.0, 8.0, 7.0)
|
||
_mcp_print("spawn id=%d count=%d (expect 0/1)" % [id, EnemyBulletManager.get_active_count()])
|
||
```
|
||
Expected: id=0,count=1。隔次调用(物理跑几帧后,子弹左飞撞玩家):
|
||
```gdscript
|
||
_mcp_print("after frames count=%d (expect 0,已撞玩家或过期回收)" % EnemyBulletManager.get_active_count())
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add scripts/autoloads/enemy_bullet_manager.gd scripts/autoloads/enemy_bullet_manager.gd.uid project.godot
|
||
git commit -m "feat(boss): 新增 EnemyBulletManager 敌方子弹子系统(SoA 池 + vs 玩家碰撞)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 敌方子弹渲染层(combat_s2)
|
||
|
||
**Files:** `scenes/main/combat_s2.gd`
|
||
|
||
- [ ] **Step 1: 加成员变量**
|
||
|
||
`combat_s2.gd` 成员区 `var _bullet_mm: MultiMesh = null` 之后加:
|
||
```gdscript
|
||
var _enemy_bullet_mm: MultiMesh = null
|
||
```
|
||
|
||
- [ ] **Step 2: _setup_world_view 加第三个 MMI**
|
||
|
||
`_setup_world_view()` 末尾(`add_child(bullet_mmi)` 之后)加:
|
||
```gdscript
|
||
|
||
# 敌方子弹渲染器(红粉色 modulate,区分玩家黄弹)
|
||
_enemy_bullet_mm = MultiMesh.new()
|
||
_enemy_bullet_mm.transform_format = MultiMesh.TRANSFORM_2D
|
||
_enemy_bullet_mm.mesh = quad
|
||
_enemy_bullet_mm.instance_count = EnemyBulletManager.MAX_ENEMY_BULLETS
|
||
_enemy_bullet_mm.visible_instance_count = 0
|
||
var eb_mmi := MultiMeshInstance2D.new()
|
||
eb_mmi.multimesh = _enemy_bullet_mm
|
||
eb_mmi.modulate = Color(1.0, 0.35, 0.5)
|
||
var eb_mat := ShaderMaterial.new()
|
||
eb_mat.shader = load("res://shaders/bullet_glow.gdshader")
|
||
eb_mmi.material = eb_mat
|
||
add_child(eb_mmi)
|
||
```
|
||
|
||
- [ ] **Step 3: _process 加同步**
|
||
|
||
`_process` 里 `BulletManager.sync_multimesh(_bullet_mm)` 之后加:
|
||
```gdscript
|
||
EnemyBulletManager.sync_multimesh(_enemy_bullet_mm)
|
||
```
|
||
|
||
- [ ] **Step 4: 验证(MCP,进战斗 + 截图)**
|
||
|
||
`play_scene(main)`→`SceneManager.start_new_game()`;隔次调用生成一发并截图:
|
||
```gdscript
|
||
var cs = get_tree().current_scene
|
||
_mcp_print("enemy_bullet_mm exists=%s" % str(cs._enemy_bullet_mm != null))
|
||
EnemyBulletManager.spawn_bullet(PlayerManager.get_position() + Vector2(0, -150), Vector2(0, 40), 6.0, 10.0, 5.0)
|
||
```
|
||
`get_game_screenshot` 应见玩家上方一颗红粉发光弹缓慢下落。Expected: exists=true。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```bash
|
||
git add scenes/main/combat_s2.gd
|
||
git commit -m "feat(boss): combat_s2 敌方子弹 MultiMesh 渲染层(红粉区分玩家弹)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: EnemyManager 外部驱动移动接口 + set_entity_pos
|
||
|
||
**Files:** `scripts/autoloads/enemy_manager.gd`
|
||
|
||
- [ ] **Step 1: 加外部驱动集合成员**
|
||
|
||
`enemy_manager.gd` 的 `var _offscreen_sep_counter: int = 0` 之后加:
|
||
```gdscript
|
||
var _external_movers: Dictionary = {} # entity_id → true:移动由外部系统(BossManager)接管,_gd_update_movement 跳过
|
||
```
|
||
|
||
- [ ] **Step 2: _gd_update_movement 跳过外部驱动实体**
|
||
|
||
`_gd_update_movement(delta)` 现为:
|
||
```gdscript
|
||
func _gd_update_movement(delta: float) -> void:
|
||
var player_pos: Vector2 = PlayerManager.get_position()
|
||
var has_pf: bool = not _pathfinders.is_empty() # 无精英时零额外开销
|
||
for i in _active_count:
|
||
if has_pf and _pathfinders.has(_slot_entity_map.get(i, -1)):
|
||
continue # 寻路精英由 _update_pathfinding_movement 处理
|
||
var base: int = i * ENEMY_STRIDE
|
||
```
|
||
替换这段(到 `var base` 行)为:
|
||
```gdscript
|
||
func _gd_update_movement(delta: float) -> void:
|
||
var player_pos: Vector2 = PlayerManager.get_position()
|
||
var has_pf: bool = not _pathfinders.is_empty() # 无精英时零额外开销
|
||
var has_ext: bool = not _external_movers.is_empty()
|
||
for i in _active_count:
|
||
var eid_i: int = _slot_entity_map.get(i, -1)
|
||
if has_pf and _pathfinders.has(eid_i):
|
||
continue # 寻路精英由 _update_pathfinding_movement 处理
|
||
if has_ext and _external_movers.has(eid_i):
|
||
continue # Boss 等外部驱动实体由 BossManager 移动
|
||
var base: int = i * ENEMY_STRIDE
|
||
```
|
||
|
||
- [ ] **Step 3: 加接口方法**
|
||
|
||
`get_pos_by_id()` 函数之后加:
|
||
```gdscript
|
||
|
||
## 外部系统(BossManager)接管移动:注册后 _gd_update_movement 跳过该实体
|
||
func register_external_mover(entity_id: int) -> void:
|
||
_external_movers[entity_id] = true
|
||
|
||
func unregister_external_mover(entity_id: int) -> void:
|
||
_external_movers.erase(entity_id)
|
||
|
||
## 外部系统写回实体坐标(BossManager 驱动移动/冲刺用)
|
||
func set_entity_pos(entity_id: int, pos: Vector2) -> void:
|
||
var slot: int = _entity_index_map.get(entity_id, -1)
|
||
if slot < 0:
|
||
return
|
||
var base: int = slot * ENEMY_STRIDE
|
||
_data[base + 0] = pos.x
|
||
_data[base + 1] = pos.y
|
||
```
|
||
|
||
- [ ] **Step 4: reset() 清空**
|
||
|
||
`reset()` 里 `_slot_entity_map.clear()` 之后加:
|
||
```gdscript
|
||
_external_movers.clear()
|
||
```
|
||
|
||
- [ ] **Step 5: 验证(MCP)** — `play_scene(main)`:
|
||
```gdscript
|
||
EnemyManager.reset()
|
||
var eid = EnemyManager.spawn_enemy(Vector2(300, 0), 100.0, EnemyManager.Type.BOSS)
|
||
EnemyManager.register_external_mover(eid)
|
||
EnemyManager.set_entity_pos(eid, Vector2(123, 45))
|
||
_mcp_print("pos=%s (expect 123,45)" % str(EnemyManager.get_pos_by_id(eid)))
|
||
```
|
||
Expected: (123, 45)。隔次调用(物理跑几帧):
|
||
```gdscript
|
||
_mcp_print("ext-mover pos 仍≈123,45(未被追玩家移动)=%s" % str(EnemyManager.get_pos_by_id(eid)))
|
||
```
|
||
Expected: 位置基本不变(证明 _gd_update_movement 已跳过)。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
```bash
|
||
git add scripts/autoloads/enemy_manager.gd
|
||
git commit -m "feat(boss): EnemyManager 外部驱动移动接口(register/unregister_external_mover + set_entity_pos + 跳过)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: data/bosses.json 数据
|
||
|
||
**Files:** Create `data/bosses.json`
|
||
|
||
- [ ] **Step 1: 新建 bosses.json**
|
||
|
||
创建 `data/bosses.json`,内容:
|
||
```json
|
||
{
|
||
"4": {
|
||
"move_speed": 120.0,
|
||
"phases": [
|
||
{
|
||
"hp_threshold": 1.0,
|
||
"move_speed_mult": 1.0,
|
||
"summon_adds": 0,
|
||
"attack_patterns": [
|
||
{ "kind": "spread", "count": 5, "half_angle": 35.0, "speed": 220.0, "bullet_damage": 8.0, "bullet_radius": 8.0, "lifetime": 4.0, "cooldown": 2.4 },
|
||
{ "kind": "ring", "count": 14, "speed": 180.0, "bullet_damage": 7.0, "bullet_radius": 8.0, "lifetime": 4.0, "cooldown": 2.8 }
|
||
]
|
||
},
|
||
{
|
||
"hp_threshold": 0.66,
|
||
"move_speed_mult": 1.2,
|
||
"summon_adds": 6,
|
||
"attack_patterns": [
|
||
{ "kind": "dash", "windup": 0.8, "dash_speed": 620.0, "dash_time": 0.5, "contact_radius": 44.0, "contact_damage": 15.0, "contact_hit_cd": 0.4, "recover": 0.7, "telegraph_vfx": "hit_spark", "cooldown": 2.2 },
|
||
{ "kind": "spiral", "count": 6, "angle_step": 18.0, "speed": 200.0, "bullet_damage": 7.0, "bullet_radius": 8.0, "lifetime": 4.0, "cooldown": 2.0 }
|
||
]
|
||
},
|
||
{
|
||
"hp_threshold": 0.33,
|
||
"move_speed_mult": 1.5,
|
||
"summon_adds": 8,
|
||
"attack_patterns": [
|
||
{ "kind": "charge_up", "windup": 1.0, "release_kind": "ring", "count": 24, "speed": 220.0, "bullet_damage": 9.0, "bullet_radius": 9.0, "lifetime": 5.0, "recover": 0.8, "telegraph_vfx": "hit_spark", "cooldown": 2.6 },
|
||
{ "kind": "melee", "windup": 0.6, "melee_radius": 100.0, "melee_damage": 20.0, "recover": 0.5, "telegraph_vfx": "hit_spark", "cooldown": 2.0 }
|
||
]
|
||
}
|
||
]
|
||
},
|
||
"5": {
|
||
"move_speed": 80.0,
|
||
"phases": [
|
||
{
|
||
"hp_threshold": 1.0,
|
||
"move_speed_mult": 1.0,
|
||
"summon_adds": 0,
|
||
"attack_patterns": [
|
||
{ "kind": "spiral", "count": 8, "angle_step": 12.0, "speed": 190.0, "bullet_damage": 8.0, "bullet_radius": 8.0, "lifetime": 5.0, "cooldown": 1.8 },
|
||
{ "kind": "ring", "count": 20, "speed": 170.0, "bullet_damage": 8.0, "bullet_radius": 8.0, "lifetime": 5.0, "cooldown": 2.4 }
|
||
]
|
||
},
|
||
{
|
||
"hp_threshold": 0.66,
|
||
"move_speed_mult": 1.1,
|
||
"summon_adds": 6,
|
||
"attack_patterns": [
|
||
{ "kind": "dash", "windup": 0.7, "dash_speed": 680.0, "dash_time": 0.55, "contact_radius": 48.0, "contact_damage": 18.0, "contact_hit_cd": 0.4, "recover": 0.7, "telegraph_vfx": "hit_spark", "cooldown": 2.0 },
|
||
{ "kind": "spread", "count": 7, "half_angle": 45.0, "speed": 210.0, "bullet_damage": 8.0, "bullet_radius": 8.0, "lifetime": 5.0, "cooldown": 2.2 }
|
||
]
|
||
},
|
||
{
|
||
"hp_threshold": 0.33,
|
||
"move_speed_mult": 1.3,
|
||
"summon_adds": 10,
|
||
"attack_patterns": [
|
||
{ "kind": "charge_up", "windup": 1.1, "release_kind": "ring", "count": 32, "speed": 230.0, "bullet_damage": 10.0, "bullet_radius": 9.0, "lifetime": 6.0, "recover": 0.9, "telegraph_vfx": "hit_spark", "cooldown": 2.8 },
|
||
{ "kind": "melee", "windup": 0.6, "melee_radius": 120.0, "melee_damage": 25.0, "recover": 0.5, "telegraph_vfx": "hit_spark", "cooldown": 1.8 },
|
||
{ "kind": "spiral", "count": 10, "angle_step": 20.0, "speed": 200.0, "bullet_damage": 9.0, "bullet_radius": 8.0, "lifetime": 6.0, "cooldown": 1.6 }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证(MCP)** — `play_scene(main)`:
|
||
```gdscript
|
||
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/bosses.json"))
|
||
_mcp_print("type4 phases=%d type5 phases=%d (expect 3/3)" % [d["4"]["phases"].size(), d["5"]["phases"].size()])
|
||
_mcp_print("type4 p0 pat0 kind=%s (expect spread)" % str(d["4"]["phases"][0]["attack_patterns"][0]["kind"]))
|
||
```
|
||
Expected: 3/3;spread。(JSON 合法则 `get_editor_errors` 无。)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
```bash
|
||
git add data/bosses.json
|
||
git commit -m "feat(boss): data/bosses.json — Mini(4)/Final(5) 三阶段招式配置(7 种 kind 全覆盖)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: BossManager autoload — 阶段状态机 + 移动 + 召唤 + 事件
|
||
|
||
**Files:** Create `scripts/autoloads/boss_manager.gd`, Modify `project.godot`
|
||
|
||
> 本任务先落「阶段/移动/召唤/事件 + 招式调度骨架」;招式发射体(ranged/timed)在 Task 7、8 补。此版 `_start_pattern` 只处理未知 kind(置冷却),故此时 Boss 会漂移+进阶+召唤但不开火——中间态可测。
|
||
|
||
- [ ] **Step 1: 新建 boss_manager.gd**
|
||
|
||
创建 `scripts/autoloads/boss_manager.gd`,内容:
|
||
```gdscript
|
||
## BossManager — Boss 阶段状态机 + 攻击模式调度(Autoload)
|
||
## Boss 实体仍在 EnemyManager SoA(渲染/受击/HP);本管理器侧挂运行态、接管移动、驱动招式。
|
||
## 数据驱动:data/bosses.json(每 boss_type 的 move_speed / phases / attack_patterns),自载。
|
||
extends Node
|
||
|
||
const BOSSES_JSON: String = "res://data/bosses.json"
|
||
|
||
# boss_type(int) → { move_speed:float, phases:Array[Dictionary] }
|
||
var _defs: Dictionary = {}
|
||
# boss_id(int) → 运行态 Dictionary
|
||
var _active: Dictionary = {}
|
||
|
||
func _ready() -> void:
|
||
_load_defs()
|
||
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
|
||
|
||
func _load_defs() -> void:
|
||
if not FileAccess.file_exists(BOSSES_JSON):
|
||
push_error("BossManager: 缺失 res://data/bosses.json")
|
||
return
|
||
var data = JSON.parse_string(FileAccess.get_file_as_string(BOSSES_JSON))
|
||
if not (data is Dictionary):
|
||
push_error("BossManager: bosses.json 格式错误")
|
||
return
|
||
_defs.clear()
|
||
for k in data:
|
||
_defs[int(k)] = data[k]
|
||
|
||
## wave_manager._spawn_boss 生成 SoA Boss 后调用
|
||
func register_boss(boss_id: int, boss_type: int, max_hp: float, wave: int) -> void:
|
||
var def: Dictionary = _defs.get(boss_type, {})
|
||
var phases: Array = def.get("phases", [])
|
||
_active[boss_id] = {
|
||
"boss_type": boss_type,
|
||
"max_hp": max_hp,
|
||
"wave": wave,
|
||
"move_speed": float(def.get("move_speed", 100.0)),
|
||
"phase": 0,
|
||
"phases": phases,
|
||
"pattern_index": 0,
|
||
"cooldown": 1.0, # 入场后短暂延迟再起手
|
||
"action": {}, # 进行中招式(空=无)
|
||
"spiral_phase": 0.0, # spiral 招式的累积旋转角
|
||
}
|
||
EnemyManager.register_external_mover(boss_id)
|
||
if not phases.is_empty():
|
||
_emit_phase(boss_id, 0)
|
||
|
||
func _physics_process(delta: float) -> void:
|
||
if _active.is_empty():
|
||
return
|
||
for boss_id in _active.keys():
|
||
if not EnemyManager.has_entity(boss_id):
|
||
continue
|
||
var st: Dictionary = _active[boss_id]
|
||
_check_phase(boss_id, st)
|
||
_tick_movement(boss_id, st, delta)
|
||
_tick_action(boss_id, st, delta)
|
||
|
||
# 阶段切换:HP% 跨入更深阶段(phases 按 hp_threshold 降序,取满足阈值的最深项)
|
||
func _check_phase(boss_id: int, st: Dictionary) -> void:
|
||
var hp_pct: float = EnemyManager.get_hp_percent(boss_id)
|
||
var phases: Array = st["phases"]
|
||
var target: int = int(st["phase"])
|
||
for i in phases.size():
|
||
if hp_pct <= float(phases[i].get("hp_threshold", 1.0)):
|
||
target = i
|
||
if target > int(st["phase"]):
|
||
st["phase"] = target
|
||
st["pattern_index"] = 0
|
||
st["cooldown"] = 0.5
|
||
st["action"] = {}
|
||
_summon_adds(boss_id, int(phases[target].get("summon_adds", 0)))
|
||
_emit_phase(boss_id, target)
|
||
|
||
func _emit_phase(boss_id: int, phase: int) -> void:
|
||
EventBus.emit(EventID.BOSS_PHASE_CHANGED, {
|
||
"boss_id": boss_id, "boss_type": int(_active[boss_id]["boss_type"]), "phase": phase})
|
||
|
||
func _summon_adds(boss_id: int, n: int) -> void:
|
||
if n <= 0:
|
||
return
|
||
var boss_pos: Vector2 = EnemyManager.get_pos_by_id(boss_id)
|
||
for i in n:
|
||
var ang: float = TAU * float(i) / float(n)
|
||
var off: Vector2 = Vector2(cos(ang), sin(ang)) * 80.0
|
||
EnemyManager.spawn_enemy(boss_pos + off, 40.0, EnemyManager.Type.FAST)
|
||
|
||
func _cur_phase(st: Dictionary) -> Dictionary:
|
||
var phases: Array = st["phases"]
|
||
if phases.is_empty():
|
||
return {}
|
||
return phases[clampi(int(st["phase"]), 0, phases.size() - 1)]
|
||
|
||
# 移动:无「接管移动」的招式在进行时,朝玩家缓慢漂移
|
||
func _tick_movement(boss_id: int, st: Dictionary, delta: float) -> void:
|
||
var action: Dictionary = st["action"]
|
||
if not action.is_empty() and bool(action.get("controls_move", false)):
|
||
return
|
||
var pos: Vector2 = EnemyManager.get_pos_by_id(boss_id)
|
||
var player: Vector2 = PlayerManager.get_position()
|
||
var spd: float = float(st["move_speed"]) * float(_cur_phase(st).get("move_speed_mult", 1.0))
|
||
var to: Vector2 = player - pos
|
||
if to.length() > 1.0:
|
||
pos += to.normalized() * spd * delta
|
||
EnemyManager.set_entity_pos(boss_id, pos)
|
||
|
||
# 招式推进:有进行中招式 → 推进;否则冷却到点 → 起手下一招式
|
||
func _tick_action(boss_id: int, st: Dictionary, delta: float) -> void:
|
||
var action: Dictionary = st["action"]
|
||
if not action.is_empty():
|
||
_advance_action(boss_id, st, action, delta)
|
||
return
|
||
st["cooldown"] = float(st["cooldown"]) - delta
|
||
if float(st["cooldown"]) > 0.0:
|
||
return
|
||
var patterns: Array = _cur_phase(st).get("attack_patterns", [])
|
||
if patterns.is_empty():
|
||
st["cooldown"] = 1.0
|
||
return
|
||
var idx: int = int(st["pattern_index"]) % patterns.size()
|
||
st["pattern_index"] = int(st["pattern_index"]) + 1
|
||
_start_pattern(boss_id, st, patterns[idx])
|
||
|
||
# 招式起手(Task 7/8 补齐分支);未知 kind 兜底置冷却
|
||
func _start_pattern(boss_id: int, st: Dictionary, p: Dictionary) -> void:
|
||
st["cooldown"] = float(p.get("cooldown", 2.0))
|
||
|
||
# 招式推进(Task 8 补齐三段式);骨架版直接结束
|
||
func _advance_action(boss_id: int, st: Dictionary, action: Dictionary, delta: float) -> void:
|
||
st["action"] = {}
|
||
|
||
func _on_enemy_killed(payload: Dictionary) -> void:
|
||
var id: int = int(payload.get("entity_id", -1))
|
||
if not _active.has(id):
|
||
return
|
||
var st: Dictionary = _active[id]
|
||
EnemyManager.unregister_external_mover(id)
|
||
EventBus.emit(EventID.BOSS_KILLED, {
|
||
"boss_id": id, "boss_type": int(st["boss_type"]), "wave": int(st["wave"])})
|
||
_active.erase(id)
|
||
|
||
func get_active_boss_count() -> int:
|
||
return _active.size()
|
||
|
||
func get_phase(boss_id: int) -> int:
|
||
return int(_active.get(boss_id, {}).get("phase", -1))
|
||
|
||
func reset() -> void:
|
||
for id in _active.keys():
|
||
EnemyManager.unregister_external_mover(id)
|
||
_active.clear()
|
||
```
|
||
|
||
- [ ] **Step 2: 注册 autoload**
|
||
|
||
`project.godot` 的 `[autoload]` 段,在 Task 2 加的 `EnemyBulletManager=...` 之后加:
|
||
```
|
||
BossManager="*res://scripts/autoloads/boss_manager.gd"
|
||
```
|
||
|
||
- [ ] **Step 3: 验证(MCP)** — `validate_script`;`play_scene(main)`:
|
||
```gdscript
|
||
EnemyManager.reset(); BossManager.reset()
|
||
var bid = EnemyManager.spawn_enemy(Vector2(0, -300), 300.0, EnemyManager.Type.MINIBOSS)
|
||
BossManager.register_boss(bid, 4, 300.0, 8)
|
||
_mcp_print("active=%d phase=%d (expect 1/0)" % [BossManager.get_active_boss_count(), BossManager.get_phase(bid)])
|
||
# 掉血跨 66% → 进阶到 phase1 并召唤 6
|
||
EnemyManager.apply_damage(bid, 130.0, -1, false) # 300→170 (<66%*300=198)
|
||
```
|
||
Expected: active=1, phase=0。隔次调用(物理跑,_check_phase 触发):
|
||
```gdscript
|
||
_mcp_print("after dmg phase=%d (expect 1) enemies=%d(含召唤援军)" % [BossManager.get_phase(bid), EnemyManager.get_active_count()])
|
||
```
|
||
Expected: phase=1,敌人数增加(召唤了 6 援军)。再验死亡:
|
||
```gdscript
|
||
EnemyManager.apply_damage(bid, 999.0, -1, false)
|
||
_mcp_print("boss dead active=%d (expect 0)" % BossManager.get_active_boss_count())
|
||
```
|
||
Expected: active=0。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add scripts/autoloads/boss_manager.gd scripts/autoloads/boss_manager.gd.uid project.godot
|
||
git commit -m "feat(boss): BossManager 阶段状态机+漂移移动+进阶召唤+BOSS_PHASE_CHANGED/KILLED(招式骨架)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: BossManager 瞬时远程弹幕(ring/spread/aimed/spiral)
|
||
|
||
**Files:** `scripts/autoloads/boss_manager.gd`
|
||
|
||
- [ ] **Step 1: _start_pattern 加远程分支**
|
||
|
||
把 `_start_pattern` 整个函数替换为:
|
||
```gdscript
|
||
func _start_pattern(boss_id: int, st: Dictionary, p: Dictionary) -> void:
|
||
var kind: String = String(p.get("kind", "ring"))
|
||
var pos: Vector2 = EnemyManager.get_pos_by_id(boss_id)
|
||
match kind:
|
||
"ring", "spread", "aimed", "spiral":
|
||
_fire_ranged(boss_id, st, kind, p, pos)
|
||
st["cooldown"] = float(p.get("cooldown", 2.0))
|
||
_:
|
||
st["cooldown"] = float(p.get("cooldown", 2.0))
|
||
```
|
||
|
||
- [ ] **Step 2: 加发射实现**
|
||
|
||
在 `_advance_action` 函数之前加:
|
||
```gdscript
|
||
## 瞬时弹幕发射(ring/spread/aimed/spiral)
|
||
func _fire_ranged(boss_id: int, st: Dictionary, kind: String, p: Dictionary, pos: Vector2) -> void:
|
||
var count: int = maxi(1, int(p.get("count", 12)))
|
||
var speed: float = float(p.get("speed", 200.0))
|
||
var dmg: float = float(p.get("bullet_damage", 8.0))
|
||
var r: float = float(p.get("bullet_radius", 8.0))
|
||
var life: float = float(p.get("lifetime", 4.0))
|
||
var to_player: Vector2 = PlayerManager.get_position() - pos
|
||
var aim: float = to_player.angle() if to_player.length() > 0.01 else 0.0
|
||
match kind:
|
||
"ring":
|
||
for i in count:
|
||
_spawn_dir(pos, TAU * float(i) / float(count), speed, life, r, dmg)
|
||
"spread":
|
||
var half: float = deg_to_rad(float(p.get("half_angle", 30.0)))
|
||
for i in count:
|
||
var t: float = 0.0 if count <= 1 else (float(i) / float(count - 1)) * 2.0 - 1.0
|
||
_spawn_dir(pos, aim + half * t, speed, life, r, dmg)
|
||
"aimed":
|
||
for i in count:
|
||
_spawn_dir(pos, aim, speed, life, r, dmg)
|
||
"spiral":
|
||
var step: float = deg_to_rad(float(p.get("angle_step", 15.0)))
|
||
var base_a: float = float(st.get("spiral_phase", 0.0))
|
||
for i in count:
|
||
_spawn_dir(pos, base_a + TAU * float(i) / float(count), speed, life, r, dmg)
|
||
st["spiral_phase"] = base_a + step
|
||
|
||
func _spawn_dir(pos: Vector2, angle: float, speed: float, life: float, r: float, dmg: float) -> void:
|
||
EnemyBulletManager.spawn_bullet(pos, Vector2(cos(angle), sin(angle)) * speed, life, r, dmg)
|
||
```
|
||
|
||
- [ ] **Step 3: 验证(MCP)** — `play_scene(main)`:
|
||
```gdscript
|
||
EnemyManager.reset(); BossManager.reset(); EnemyBulletManager.reset()
|
||
var bid = EnemyManager.spawn_enemy(Vector2(0, -300), 3000.0, EnemyManager.Type.MINIBOSS)
|
||
BossManager.register_boss(bid, 4, 3000.0, 8)
|
||
# 直接触发 ring 发射(越过冷却)
|
||
BossManager._fire_ranged(bid, BossManager._active[bid], "ring", {"count": 14, "speed": 180, "bullet_damage": 7, "bullet_radius": 8, "lifetime": 4}, EnemyManager.get_pos_by_id(bid))
|
||
_mcp_print("ring 发 14 → 敌弹数=%d (expect 14)" % EnemyBulletManager.get_active_count())
|
||
```
|
||
Expected: 14。(阶段冷却到点后自动发射的端到端在 Task 12 战斗验。)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add scripts/autoloads/boss_manager.gd
|
||
git commit -m "feat(boss): 瞬时远程弹幕招式 ring/spread/aimed/spiral(经 EnemyBulletManager 发射)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: BossManager 位移/蓄力/近战招式(dash/charge_up/melee)
|
||
|
||
**Files:** `scripts/autoloads/boss_manager.gd`
|
||
|
||
- [ ] **Step 1: _start_pattern 加 timed 分支**
|
||
|
||
把 `_start_pattern` 的 `match kind:` 块替换为(加三种 timed kind):
|
||
```gdscript
|
||
match kind:
|
||
"ring", "spread", "aimed", "spiral":
|
||
_fire_ranged(boss_id, st, kind, p, pos)
|
||
st["cooldown"] = float(p.get("cooldown", 2.0))
|
||
"dash":
|
||
_telegraph(p, pos)
|
||
st["action"] = {
|
||
"kind": "dash", "stage": "windup", "timer": float(p.get("windup", 0.8)),
|
||
"controls_move": true, "p": p,
|
||
"target": PlayerManager.get_position(), "dir": Vector2.RIGHT, "hit_cd": 0.0}
|
||
"charge_up":
|
||
_telegraph(p, pos)
|
||
st["action"] = {
|
||
"kind": "charge_up", "stage": "windup", "timer": float(p.get("windup", 1.0)),
|
||
"controls_move": true, "p": p}
|
||
"melee":
|
||
_telegraph(p, pos)
|
||
st["action"] = {
|
||
"kind": "melee", "stage": "windup", "timer": float(p.get("windup", 0.6)),
|
||
"controls_move": true, "p": p}
|
||
_:
|
||
st["cooldown"] = float(p.get("cooldown", 2.0))
|
||
```
|
||
|
||
- [ ] **Step 2: 替换 _advance_action 骨架为三段式实现**
|
||
|
||
把 `_advance_action` 整个函数替换为:
|
||
```gdscript
|
||
## 前摇预警 VFX
|
||
func _telegraph(p: Dictionary, pos: Vector2) -> void:
|
||
var vfx: String = String(p.get("telegraph_vfx", ""))
|
||
if vfx != "":
|
||
VFXManager.play(vfx, pos, 1.5)
|
||
|
||
## 招式推进:三段式 windup → active → recover;结束还原并置冷却
|
||
func _advance_action(boss_id: int, st: Dictionary, action: Dictionary, delta: float) -> void:
|
||
action["timer"] = float(action["timer"]) - delta
|
||
var p: Dictionary = action["p"]
|
||
var pos: Vector2 = EnemyManager.get_pos_by_id(boss_id)
|
||
match String(action["kind"]):
|
||
"dash":
|
||
match String(action["stage"]):
|
||
"windup":
|
||
if float(action["timer"]) <= 0.0:
|
||
var dir: Vector2 = Vector2(action["target"]) - pos
|
||
action["dir"] = dir.normalized() if dir.length() > 0.01 else Vector2.RIGHT
|
||
action["stage"] = "active"
|
||
action["timer"] = float(p.get("dash_time", 0.5))
|
||
"active":
|
||
pos += Vector2(action["dir"]) * float(p.get("dash_speed", 600.0)) * delta
|
||
EnemyManager.set_entity_pos(boss_id, pos)
|
||
action["hit_cd"] = maxf(0.0, float(action["hit_cd"]) - delta)
|
||
if pos.distance_to(PlayerManager.get_position()) <= float(p.get("contact_radius", 40.0)) and float(action["hit_cd"]) <= 0.0:
|
||
PlayerStats.take_damage(float(p.get("contact_damage", 15.0)))
|
||
action["hit_cd"] = float(p.get("contact_hit_cd", 0.4))
|
||
if float(action["timer"]) <= 0.0:
|
||
action["stage"] = "recover"
|
||
action["timer"] = float(p.get("recover", 0.8))
|
||
"recover":
|
||
if float(action["timer"]) <= 0.0:
|
||
_end_action(st, p)
|
||
"charge_up":
|
||
match String(action["stage"]):
|
||
"windup":
|
||
if float(action["timer"]) <= 0.0:
|
||
_fire_ranged(boss_id, st, String(p.get("release_kind", "ring")), p, pos)
|
||
action["stage"] = "recover"
|
||
action["timer"] = float(p.get("recover", 0.8))
|
||
"recover":
|
||
if float(action["timer"]) <= 0.0:
|
||
_end_action(st, p)
|
||
"melee":
|
||
match String(action["stage"]):
|
||
"windup":
|
||
if float(action["timer"]) <= 0.0:
|
||
if pos.distance_to(PlayerManager.get_position()) <= float(p.get("melee_radius", 90.0)):
|
||
PlayerStats.take_damage(float(p.get("melee_damage", 20.0)))
|
||
VFXManager.play("hit_spark", pos, 2.0)
|
||
action["stage"] = "recover"
|
||
action["timer"] = float(p.get("recover", 0.5))
|
||
"recover":
|
||
if float(action["timer"]) <= 0.0:
|
||
_end_action(st, p)
|
||
_:
|
||
st["action"] = {}
|
||
|
||
func _end_action(st: Dictionary, p: Dictionary) -> void:
|
||
st["action"] = {}
|
||
st["cooldown"] = float(p.get("cooldown", 2.0))
|
||
```
|
||
|
||
- [ ] **Step 3: 验证(MCP)** — `play_scene(main)`:
|
||
```gdscript
|
||
EnemyManager.reset(); BossManager.reset(); EnemyBulletManager.reset()
|
||
var bid = EnemyManager.spawn_enemy(Vector2(0, -100), 3000.0, EnemyManager.Type.MINIBOSS)
|
||
BossManager.register_boss(bid, 4, 3000.0, 8)
|
||
# charge_up:前摇后释放 ring24
|
||
BossManager._start_pattern(bid, BossManager._active[bid], {"kind":"charge_up","windup":0.2,"release_kind":"ring","count":24,"speed":200,"bullet_damage":9,"bullet_radius":9,"lifetime":5,"recover":0.2,"cooldown":2.0})
|
||
_mcp_print("charge_up 起手 stage=%s (expect windup)" % str(BossManager._active[bid]["action"].get("stage")))
|
||
```
|
||
Expected: windup。隔次调用(物理跑 >0.2s,前摇结束释放弹幕):
|
||
```gdscript
|
||
_mcp_print("charge_up 释放后 敌弹数=%d(expect ~24) action空=%s" % [EnemyBulletManager.get_active_count(), str(BossManager._active[bid]["action"].is_empty())])
|
||
```
|
||
Expected: 敌弹≈24(后摇结束后 action 清空)。近战验证:
|
||
```gdscript
|
||
EnemyManager.set_entity_pos(bid, PlayerManager.get_position()) # 贴脸
|
||
var hp0 = PlayerStats.hp
|
||
BossManager._start_pattern(bid, BossManager._active[bid], {"kind":"melee","windup":0.1,"melee_radius":100,"melee_damage":20,"recover":0.1,"cooldown":2.0})
|
||
```
|
||
隔次调用:`_mcp_print("melee 贴脸后 hp 降=%s (expect true)" % str(PlayerStats.hp < hp0))`。Expected: true。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add scripts/autoloads/boss_manager.gd
|
||
git commit -m "feat(boss): 位移/蓄力/近战招式 dash/charge_up/melee(三段式+前摇预警+接管移动)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: wave_manager 接入 + combat_manager 重置
|
||
|
||
**Files:** `scripts/autoloads/wave_manager.gd`, `scripts/domain/combat/combat_manager.gd`
|
||
|
||
- [ ] **Step 1: _spawn_boss 注册 BossManager**
|
||
|
||
`wave_manager.gd` `_spawn_boss()` 里 `EventBus.emit(EventID.BOSS_SPAWNED, {...})` 之后、`print(...)` 之前加:
|
||
```gdscript
|
||
BossManager.register_boss(_boss_id, boss_type, hp, wave_num)
|
||
```
|
||
|
||
- [ ] **Step 2: 移除旧阶段逻辑**
|
||
|
||
(a)`_physics_process` 里删除这两行:
|
||
```gdscript
|
||
if _boss_wave and _boss_id >= 0:
|
||
_update_boss_phase()
|
||
```
|
||
(b)删除整个 `_update_boss_phase()` 函数与整个 `_on_boss_phase_enter(phase)` 函数(BossManager 已接管)。
|
||
(c)`_boss_phase` 变量及其赋值(`var _boss_phase: int = 0`、`_boss_phase = 0`、`_boss_phase = 1` 等)可一并删除;`_boss_id` / `_boss_wave` / `_boss_max_hp` 保留(波次完成判定仍用)。
|
||
|
||
- [ ] **Step 3: combat_manager 三处重置点加 EnemyBulletManager + BossManager**
|
||
|
||
`combat_manager.gd` 中,`start_game()`、`close_shop_and_next_wave()`、`_on_player_died()` 三处各自的 `MinionManager.reset()` 之后,都加:
|
||
```gdscript
|
||
EnemyBulletManager.reset()
|
||
BossManager.reset()
|
||
```
|
||
|
||
- [ ] **Step 4: 验证(MCP,战斗内)** — `play_scene(main)`→`SceneManager.start_new_game()`;隔次调用跳到 Boss 波:
|
||
```gdscript
|
||
WaveManager.reset()
|
||
WaveManager._spawn_boss(4, 8)
|
||
_mcp_print("boss 波:BossManager active=%d (expect 1)" % BossManager.get_active_boss_count())
|
||
```
|
||
Expected: active=1(wave_manager 正确注册)。隔次调用(物理跑数秒,Boss 应开始漂移+发弹):
|
||
```gdscript
|
||
_mcp_print("数秒后 敌弹数=%d(>0 说明招式在跑) player_hp=%s" % [EnemyBulletManager.get_active_count(), str(PlayerStats.hp)])
|
||
```
|
||
Expected: 敌弹 >0。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```bash
|
||
git add scripts/autoloads/wave_manager.gd scripts/domain/combat/combat_manager.gd
|
||
git commit -m "feat(boss): wave_manager 接入 BossManager(移除旧内联阶段逻辑)+ combat_manager 重置敌弹/Boss"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 游戏设计器「Boss」分页
|
||
|
||
**Files:** Create `addons/game_designer/boss_tab.gd`, Modify `addons/game_designer/designer_panel.gd`
|
||
|
||
> 三级选择表单:Boss(类型) → 阶段 → 招式;每级 ItemList + 增删;招式表单含 kind 下拉 + 全参数 SpinBox(不相关参数由运行时忽略)。字段化、双语、撑满,禁手写 JSON(CLAUDE.md 规范)。
|
||
|
||
- [ ] **Step 1: 新建 boss_tab.gd**
|
||
|
||
创建 `addons/game_designer/boss_tab.gd`,内容:
|
||
```gdscript
|
||
## Boss 编辑器标签 — 类型/阶段/招式(三级 select-form,字段化)
|
||
@tool
|
||
extends VBoxContainer
|
||
|
||
const UI = preload("res://addons/game_designer/designer_ui.gd")
|
||
const PATH = "res://data/bosses.json"
|
||
const BOSS_LABELS := ["精英 Mini(4)", "首领 Final(5)"]
|
||
const BOSS_KEYS := ["4", "5"]
|
||
const KIND_LABELS := ["环 ring", "扇形 spread", "瞄准 aimed", "旋转 spiral", "冲刺 dash", "蓄力 charge_up", "近战 melee"]
|
||
const KIND_KEYS := ["ring", "spread", "aimed", "spiral", "dash", "charge_up", "melee"]
|
||
|
||
var _data: Dictionary = {}
|
||
var _boss_key: String = "4"
|
||
var _phase_idx: int = -1
|
||
var _pat_idx: int = -1
|
||
|
||
var _boss_opt: OptionButton
|
||
var _move_speed: SpinBox
|
||
var _phase_list: ItemList
|
||
var _hp_thr: SpinBox
|
||
var _spd_mult: SpinBox
|
||
var _summon: SpinBox
|
||
var _pat_list: ItemList
|
||
var _kind: OptionButton
|
||
var _release_kind: OptionButton
|
||
var _f: Dictionary = {} # 参数名 → SpinBox
|
||
var _tele: LineEdit
|
||
var _status: Label
|
||
|
||
func _ready() -> void:
|
||
add_child(UI.header("👑 Boss 编辑器 — 阶段与攻击模式"))
|
||
_load()
|
||
# Boss 类型选择 + move_speed
|
||
_boss_opt = _row("Boss 类型", UI.opt(BOSS_LABELS, 0))
|
||
_boss_opt.item_selected.connect(_on_boss_selected)
|
||
_move_speed = _row("移动速度(Move Speed)", UI.spin(0, 1000, 5, 100))
|
||
add_child(HSeparator.new())
|
||
# 阶段列表
|
||
add_child(UI.header("阶段 Phases"))
|
||
_phase_list = ItemList.new(); _phase_list.custom_minimum_size = Vector2(0, 70)
|
||
_phase_list.item_selected.connect(_on_phase_selected); add_child(_phase_list)
|
||
var pr := HBoxContainer.new(); add_child(pr)
|
||
pr.add_child(UI.btn("➕加阶段", _on_add_phase))
|
||
pr.add_child(UI.btn("🗑删阶段", _on_del_phase))
|
||
_hp_thr = _row("血量阈值(HP Threshold 0~1)", UI.spin(0, 1, 0.01, 1.0))
|
||
_spd_mult = _row("速度倍率(Move Speed Mult)", UI.spin(0.1, 5, 0.1, 1.0))
|
||
_summon = _row("进阶召唤数(Summon Adds)", UI.spin(0, 40, 1, 0))
|
||
add_child(HSeparator.new())
|
||
# 招式列表
|
||
add_child(UI.header("招式 Attack Patterns"))
|
||
_pat_list = ItemList.new(); _pat_list.custom_minimum_size = Vector2(0, 70)
|
||
_pat_list.item_selected.connect(_on_pat_selected); add_child(_pat_list)
|
||
var ar := HBoxContainer.new(); add_child(ar)
|
||
ar.add_child(UI.btn("➕加招式", _on_add_pat))
|
||
ar.add_child(UI.btn("🗑删招式", _on_del_pat))
|
||
_kind = _row("类型(Kind)", UI.opt(KIND_LABELS, 0))
|
||
_release_kind = _row("蓄力释放类型(Release Kind)", UI.opt(KIND_LABELS, 0))
|
||
_f["cooldown"] = _row("冷却(Cooldown s)", UI.spin(0, 20, 0.1, 2.0))
|
||
_f["count"] = _row("弹数(Count)", UI.spin(0, 200, 1, 12))
|
||
_f["speed"] = _row("弹速(Speed)", UI.spin(0, 2000, 10, 200))
|
||
_f["half_angle"] = _row("扇形半角(Half Angle°)", UI.spin(0, 180, 1, 30))
|
||
_f["angle_step"] = _row("旋转步进(Angle Step°)", UI.spin(0, 180, 1, 15))
|
||
_f["bullet_damage"] = _row("弹伤(Bullet Damage)", UI.spin(0, 500, 1, 8))
|
||
_f["bullet_radius"] = _row("弹半径(Bullet Radius)", UI.spin(1, 64, 1, 8))
|
||
_f["lifetime"] = _row("弹存活(Lifetime s)", UI.spin(0.5, 20, 0.5, 4))
|
||
_f["windup"] = _row("前摇(Windup s)", UI.spin(0, 5, 0.1, 0.8))
|
||
_f["recover"] = _row("后摇(Recover s)", UI.spin(0, 5, 0.1, 0.8))
|
||
_f["dash_speed"] = _row("冲刺速度(Dash Speed)", UI.spin(0, 3000, 10, 600))
|
||
_f["dash_time"] = _row("冲刺时长(Dash Time s)", UI.spin(0, 3, 0.05, 0.5))
|
||
_f["contact_radius"] = _row("冲刺命中半径(Contact Radius)", UI.spin(0, 300, 2, 40))
|
||
_f["contact_damage"] = _row("冲刺伤害(Contact Damage)", UI.spin(0, 500, 1, 15))
|
||
_f["contact_hit_cd"] = _row("冲刺命中冷却(Contact Hit CD s)", UI.spin(0, 3, 0.05, 0.4))
|
||
_f["melee_radius"] = _row("近战半径(Melee Radius)", UI.spin(0, 400, 5, 90))
|
||
_f["melee_damage"] = _row("近战伤害(Melee Damage)", UI.spin(0, 500, 1, 20))
|
||
_tele = _row("预警 VFX(Telegraph VFX)", UI.line("hit_spark"))
|
||
add_child(HSeparator.new())
|
||
var sr := HBoxContainer.new(); add_child(sr)
|
||
sr.add_child(UI.btn("✓应用招式", _on_apply_pat))
|
||
sr.add_child(UI.btn("✓应用阶段", _on_apply_phase))
|
||
sr.add_child(UI.btn("💾保存 bosses.json", _save))
|
||
_status = UI.status_label(); add_child(_status)
|
||
_refresh_boss()
|
||
|
||
func _row(label: String, ctrl: Control) -> Control:
|
||
var h := HBoxContainer.new(); h.add_child(UI.cell_label(label)); h.add_child(ctrl); add_child(h)
|
||
return ctrl
|
||
|
||
func _load() -> void:
|
||
var d = UI.load_json(PATH)
|
||
_data = d if d is Dictionary else {}
|
||
|
||
func _boss() -> Dictionary:
|
||
if not _data.has(_boss_key):
|
||
_data[_boss_key] = {"move_speed": 100.0, "phases": []}
|
||
return _data[_boss_key]
|
||
|
||
func _phases() -> Array:
|
||
return _boss().get("phases", [])
|
||
|
||
func _on_boss_selected(idx: int) -> void:
|
||
_boss_key = BOSS_KEYS[idx]; _phase_idx = -1; _pat_idx = -1
|
||
_refresh_boss()
|
||
|
||
func _refresh_boss() -> void:
|
||
_move_speed.value = float(_boss().get("move_speed", 100.0))
|
||
_refresh_phases()
|
||
|
||
func _refresh_phases() -> void:
|
||
_phase_list.clear()
|
||
var ph := _phases()
|
||
for i in ph.size():
|
||
_phase_list.add_item("阶段%d 阈值%.2f 招式×%d" % [i, float(ph[i].get("hp_threshold", 1.0)), (ph[i].get("attack_patterns", []) as Array).size()])
|
||
_refresh_pats()
|
||
|
||
func _on_phase_selected(idx: int) -> void:
|
||
_phase_idx = idx; _pat_idx = -1
|
||
var ph: Dictionary = _phases()[idx]
|
||
_hp_thr.value = float(ph.get("hp_threshold", 1.0))
|
||
_spd_mult.value = float(ph.get("move_speed_mult", 1.0))
|
||
_summon.value = float(ph.get("summon_adds", 0))
|
||
_refresh_pats()
|
||
|
||
func _refresh_pats() -> void:
|
||
_pat_list.clear()
|
||
if _phase_idx < 0 or _phase_idx >= _phases().size():
|
||
return
|
||
var pats: Array = _phases()[_phase_idx].get("attack_patterns", [])
|
||
for i in pats.size():
|
||
_pat_list.add_item("%d. %s" % [i, str(pats[i].get("kind", "ring"))])
|
||
|
||
func _on_pat_selected(idx: int) -> void:
|
||
_pat_idx = idx
|
||
var p: Dictionary = _phases()[_phase_idx]["attack_patterns"][idx]
|
||
_kind.selected = maxi(0, KIND_KEYS.find(String(p.get("kind", "ring"))))
|
||
_release_kind.selected = maxi(0, KIND_KEYS.find(String(p.get("release_kind", "ring"))))
|
||
for key in _f:
|
||
(_f[key] as SpinBox).value = float(p.get(key, (_f[key] as SpinBox).value))
|
||
_tele.text = String(p.get("telegraph_vfx", "hit_spark"))
|
||
|
||
func _on_add_phase() -> void:
|
||
_phases().append({"hp_threshold": 1.0, "move_speed_mult": 1.0, "summon_adds": 0, "attack_patterns": []})
|
||
_refresh_phases(); UI.set_status(_status, "已加阶段(记得保存)")
|
||
|
||
func _on_del_phase() -> void:
|
||
if _phase_idx >= 0 and _phase_idx < _phases().size():
|
||
_phases().remove_at(_phase_idx); _phase_idx = -1; _refresh_phases()
|
||
|
||
func _on_apply_phase() -> void:
|
||
if _phase_idx < 0:
|
||
UI.set_status(_status, "✗ 先选阶段", true); return
|
||
var ph: Dictionary = _phases()[_phase_idx]
|
||
ph["hp_threshold"] = _hp_thr.value
|
||
ph["move_speed_mult"] = _spd_mult.value
|
||
ph["summon_adds"] = int(_summon.value)
|
||
_boss()["move_speed"] = _move_speed.value
|
||
_refresh_phases(); UI.set_status(_status, "✓ 阶段已应用(记得保存)")
|
||
|
||
func _on_add_pat() -> void:
|
||
if _phase_idx < 0:
|
||
UI.set_status(_status, "✗ 先选阶段", true); return
|
||
(_phases()[_phase_idx]["attack_patterns"] as Array).append({"kind": "ring", "count": 12, "speed": 180, "bullet_damage": 7, "bullet_radius": 8, "lifetime": 4, "cooldown": 2.0})
|
||
_refresh_pats()
|
||
|
||
func _on_del_pat() -> void:
|
||
if _phase_idx >= 0 and _pat_idx >= 0:
|
||
(_phases()[_phase_idx]["attack_patterns"] as Array).remove_at(_pat_idx); _pat_idx = -1; _refresh_pats()
|
||
|
||
func _on_apply_pat() -> void:
|
||
if _phase_idx < 0 or _pat_idx < 0:
|
||
UI.set_status(_status, "✗ 先选招式", true); return
|
||
var kind: String = KIND_KEYS[_kind.selected]
|
||
var p: Dictionary = {"kind": kind, "cooldown": _f["cooldown"].value}
|
||
# 按 kind 只写相关字段,保持 JSON 简洁
|
||
match kind:
|
||
"ring":
|
||
for k in ["count", "speed", "bullet_damage", "bullet_radius", "lifetime"]: p[k] = _f[k].value
|
||
"spread":
|
||
for k in ["count", "half_angle", "speed", "bullet_damage", "bullet_radius", "lifetime"]: p[k] = _f[k].value
|
||
"aimed":
|
||
for k in ["count", "speed", "bullet_damage", "bullet_radius", "lifetime"]: p[k] = _f[k].value
|
||
"spiral":
|
||
for k in ["count", "angle_step", "speed", "bullet_damage", "bullet_radius", "lifetime"]: p[k] = _f[k].value
|
||
"dash":
|
||
for k in ["windup", "dash_speed", "dash_time", "contact_radius", "contact_damage", "contact_hit_cd", "recover"]: p[k] = _f[k].value
|
||
p["telegraph_vfx"] = _tele.text
|
||
"charge_up":
|
||
p["release_kind"] = KIND_KEYS[_release_kind.selected]
|
||
for k in ["windup", "count", "speed", "half_angle", "angle_step", "bullet_damage", "bullet_radius", "lifetime", "recover"]: p[k] = _f[k].value
|
||
p["telegraph_vfx"] = _tele.text
|
||
"melee":
|
||
for k in ["windup", "melee_radius", "melee_damage", "recover"]: p[k] = _f[k].value
|
||
p["telegraph_vfx"] = _tele.text
|
||
# 整数化 count
|
||
if p.has("count"): p["count"] = int(p["count"])
|
||
_phases()[_phase_idx]["attack_patterns"][_pat_idx] = p
|
||
_refresh_pats(); UI.set_status(_status, "✓ 招式已应用(记得保存)")
|
||
|
||
func _save() -> void:
|
||
if UI.save_json(PATH, _data):
|
||
UI.set_status(_status, "💾 已保存 bosses.json(重启 F5 生效)")
|
||
else:
|
||
UI.set_status(_status, "✗ 保存失败", true)
|
||
```
|
||
|
||
- [ ] **Step 2: designer_panel 注册分页**
|
||
|
||
`designer_panel.gd` `_init()` 里 `_add_tab(tabs, "⚖ 平衡", ...)` 之前加:
|
||
```gdscript
|
||
_add_tab(tabs, "👑 Boss", preload("res://addons/game_designer/boss_tab.gd").new())
|
||
```
|
||
|
||
- [ ] **Step 3: 验证(编辑器 MCP fresh-load 往返)**
|
||
|
||
改 dock 类需先 `EditorInterface.restart_editor(true)` 清缓存;重启后 `execute_editor_script`:
|
||
```gdscript
|
||
var Tab = ResourceLoader.load("res://addons/game_designer/boss_tab.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
|
||
var t = Tab.new(); EditorInterface.get_base_control().add_child(t)
|
||
t._on_boss_selected(0) # boss 4
|
||
t._on_phase_selected(1) # phase1
|
||
t._on_pat_selected(0) # 第一招式(dash)
|
||
var FAILS = 0
|
||
if t._phases().size() != 3: FAILS += 1
|
||
if String(t._phases()[1]["attack_patterns"][0].get("kind")) != "dash": FAILS += 1
|
||
# 应用往返:改冷却→应用→回读
|
||
t._f["cooldown"].value = 3.5; t._on_apply_pat()
|
||
if abs(float(t._phases()[1]["attack_patterns"][0].get("cooldown", 0)) - 3.5) > 0.001: FAILS += 1
|
||
_mcp_print("Boss tab 往返 FAILS=%d (expect 0)" % FAILS)
|
||
t.free()
|
||
```
|
||
Expected: FAILS=0。`get_editor_errors`(清 stale 后)0。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add addons/game_designer/boss_tab.gd addons/game_designer/boss_tab.gd.uid addons/game_designer/designer_panel.gd
|
||
git commit -m "feat(boss): 游戏设计器 Boss 分页(类型/阶段/招式三级字段化表单,双语,禁手写JSON)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: HUD Boss 阶段提示 + 文档现状更新
|
||
|
||
**Files:** `scenes/main/combat_s2.gd`, `docs/technical/boss_design.md`
|
||
|
||
- [ ] **Step 1: HUD 订阅阶段事件(轻量 toast)**
|
||
|
||
`combat_s2.gd` 的 `_ready()`(HUD 建好后)里,找到已有的 `EventBus.subscribe(...)` 调用附近,加:
|
||
```gdscript
|
||
EventBus.subscribe(EventID.BOSS_PHASE_CHANGED, _on_boss_phase_changed)
|
||
```
|
||
并加回调函数(放到文件内其它 `_on_*` 回调附近):
|
||
```gdscript
|
||
func _on_boss_phase_changed(payload: Dictionary) -> void:
|
||
var msg := "Boss 阶段 %d!" % int(payload.get("phase", 0))
|
||
if _lbl_status:
|
||
_lbl_status.text = msg
|
||
```
|
||
> 若 `combat_s2.gd` 无 `_lbl_status` 成员,则改用现有任一 HUD 提示 Label(如 kills 行同款);仅做一行文字提示,非硬需求。执行者按实际 HUD 成员名接线。
|
||
|
||
- [ ] **Step 2: 更新 boss_design.md 现状注记**
|
||
|
||
`docs/technical/boss_design.md` 顶部「⚠️ 实现现状 (2026-07-20 审计)」注记块末尾追加一句:
|
||
```markdown
|
||
>
|
||
> **✅ 更新 (2026-07-22)**:阶段状态机 + 7 种攻击模式(ring/spread/aimed/spiral/dash/charge_up/melee)+ 敌方子弹(`EnemyBulletManager`)+ `BOSS_PHASE_CHANGED=29`/`BOSS_KILLED=30` 事件已实现(`BossManager` autoload + `data/bosses.json` + 设计器 Boss 分页)。Boss 仍留在 EnemyManager SoA,移动由 BossManager 接管(非本文档 §2 的完全解耦方案)。仍未实现:场地机制(冰墙/收缩)、通关演出/`GAME_CLEARED`、`.tres`(本工程纯 JSON)。
|
||
```
|
||
|
||
- [ ] **Step 3: 验证(MCP)** — `play_scene(main)`:手动发一次阶段事件,截图看 HUD:
|
||
```gdscript
|
||
EventBus.emit(EventID.BOSS_PHASE_CHANGED, {"boss_id": 1, "boss_type": 4, "phase": 2})
|
||
_mcp_print("已发阶段事件(截图看 HUD 文字)")
|
||
```
|
||
`get_game_screenshot` 确认 HUD 出现「Boss 阶段 2!」。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add scenes/main/combat_s2.gd docs/technical/boss_design.md
|
||
git commit -m "feat(boss): HUD Boss 阶段提示 + boss_design.md 现状更新为已实现"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: 全量回归验收
|
||
|
||
**Files:** 无(纯验证)
|
||
|
||
- [ ] **Step 1: 重载 + 0 错误**:`clear_output`→`reload_project`→`get_editor_errors`=0(新 autoload/常量引用的 stale 误报以运行时为准)。
|
||
|
||
- [ ] **Step 2: 端到端 Boss 战(`play_scene(main)`→`SceneManager.start_new_game()`)**
|
||
|
||
隔次调用起 Boss 波并观测:
|
||
```gdscript
|
||
WaveManager.reset(); EnemyBulletManager.reset(); BossManager.reset()
|
||
WaveManager._spawn_boss(5, 20)
|
||
_mcp_print("[final] BossManager active=%d (1) phase=%d (0)" % [BossManager.get_active_boss_count(), BossManager.get_phase(WaveManager._boss_id)])
|
||
```
|
||
隔数秒调用(Boss 漂移+发弹+招式轮询在跑):
|
||
```gdscript
|
||
_mcp_print("[final] 敌弹数=%d(>0) player_hp=%s(可能已受击)" % [EnemyBulletManager.get_active_count(), str(PlayerStats.hp)])
|
||
```
|
||
`get_game_screenshot` 应见:红色首领体、红粉弹幕、玩家。逐步掉 Boss 血验阶段/召唤:
|
||
```gdscript
|
||
var bid = WaveManager._boss_id
|
||
EnemyManager.apply_damage(bid, EnemyManager.get_pos_by_id(bid).x*0.0 + 1200.0, -1, false) # 大量掉血跨阈值
|
||
```
|
||
隔次调用:`_mcp_print("[final] 掉血后 phase=%d(≥1) enemies=%d(含召唤)" % [BossManager.get_phase(bid), EnemyManager.get_active_count()])`。
|
||
|
||
- [ ] **Step 3: 死亡发 BOSS_KILLED + 波次完成**
|
||
```gdscript
|
||
var got = [false]
|
||
EventBus.subscribe(EventID.BOSS_KILLED, func(p): got[0] = true)
|
||
EnemyManager.apply_damage(WaveManager._boss_id, 999999.0, -1, false)
|
||
_mcp_print("[final] boss 死:BOSS_KILLED=%s active=%d(0)" % [str(got[0]), BossManager.get_active_boss_count()])
|
||
```
|
||
Expected: true / 0。
|
||
|
||
- [ ] **Step 4: 回归 —— 普通波不受影响**
|
||
```gdscript
|
||
WaveManager.reset(); EnemyManager.reset(); BossManager.reset()
|
||
WaveManager.start_wave(1) # 或既有的起波接口
|
||
```
|
||
隔次调用:`_mcp_print("[regr] W1 敌人移动正常(非外部驱动)、无敌弹=%d(0) active_boss=%d(0)" % [EnemyBulletManager.get_active_count(), BossManager.get_active_boss_count()])`。Expected: 0/0。`stop_scene`。
|
||
|
||
- [ ] **Step 5: DoD 核对**(spec §0 范围内全绿;范围外项确认未触碰)。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
```bash
|
||
git commit --allow-empty -m "chore(boss): Boss 阶段+攻击模式系统验收通过(7 招式+敌弹+阶段/召唤/事件运行时实测)"
|
||
```
|
||
|
||
---
|
||
|
||
## 自检结论
|
||
|
||
- **Spec 覆盖**:§1 EnemyBulletManager→T2;渲染 §6→T3;§2 BossManager/移动接管→T4/T6;§3.A 远程→T7;§3.B dash/charge_up/melee→T8;§4.1 数据→T5(BossManager 自载,偏差已备案);§4.2 设计器分页→T10;§5 事件→T1;保留召唤→T6;HUD→T11;验证 §8→各任务+T12;文档→T1/T11。全覆盖。
|
||
- **无占位**:每步精确文件/完整代码/期望值。
|
||
- **类型一致**:`EnemyBulletManager.spawn_bullet(pos,vel,lifetime,radius,damage)`/`sync_multimesh`/`reset`/`get_active_count`/`ENEMY_BULLET_STRIDE`/`MAX_ENEMY_BULLETS`;`EnemyManager.register_external_mover/unregister_external_mover/set_entity_pos`;`BossManager.register_boss(boss_id,boss_type,max_hp,wave)`/`reset`/`get_active_boss_count`/`get_phase`/内部 `_fire_ranged/_spawn_dir/_start_pattern/_advance_action/_telegraph/_end_action/_active/_check_phase/_summon_adds/_cur_phase`;`EventID.BOSS_PHASE_CHANGED=29`/`BOSS_KILLED=30`;`data/bosses.json` 键 `move_speed/phases/hp_threshold/move_speed_mult/summon_adds/attack_patterns` 与招式参数键全程一致。
|
||
- **注意**:autoload 顺序 `EnemyBulletManager` 在 `BossManager` 之前(后者引用前者);两者置 autoload 列表末尾(依赖的 EnemyManager/PlayerManager/PlayerStats/VFXManager/EventBus 已先加载)。
|
||
```
|