Files
spellforge/docs_dev/plans/2026-07-22-boss-phases.md

1207 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-loaddock 驻留旧类需 `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=0count=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/3spread。(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=1wave_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 数据→T5BossManager 自载,偏差已备案);§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 已先加载)。
```