Files
spellforge/scripts/autoloads/boss_manager.gd
T

267 lines
10 KiB
GDScript
Raw 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.
## 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 _active.has(boss_id):
continue # 防重入:本帧内被 reset()/erase(如玩家死亡致命一击)→ 跳过已失效键
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 8 再补 timed 招式分支)
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))
"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))
## 瞬时弹幕发射(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)
## 前摇预警 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))
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()