class_name FightSim extends RefCounted ## Server-side encounter runner. Composes RaidState (authoritative state) and ## BossDirector (timeline) into one tickable fight, and interprets fired ## timeline events as effects on the raid state. ## ## Geometry-dependent mechanics (aoe_spread safe zones, frontal cones, stacks) ## are simplified in this layer to "damage all alive players" until arena ## geometry + server-side position rules land. The event vocabulary is stable; ## only the effect bodies get richer. ## Damage multiplier added by soft enrage per second of ramp (tunable). const SOFT_ENRAGE_RAMP_PER_SEC: float = 0.1 var encounter_id: String = "" var raid_state: RaidState var director: BossDirector var last_dialog_line: String = "" var event_log: Array[Dictionary] = [] # Every fired event, in order (tests/debug) var _data: Dictionary = {} func _init(encounter_data: Dictionary, player_data: Array[Dictionary]) -> void: _data = encounter_data encounter_id = encounter_data.get("id", "") raid_state = RaidState.new(encounter_id, player_data) director = BossDirector.new() # load_timeline wants a typed Array[Dictionary]; JSON/test data is untyped. var timeline: Array[Dictionary] = [] for entry in _data.get("timeline", []): if typeof(entry) == TYPE_DICTIONARY: timeline.append(entry) director.load_timeline(timeline) ## Loads an encounter definition from a JSON file (data/bosses/*.json). static func load_encounter(path: String) -> Dictionary: var f := FileAccess.open(path, FileAccess.READ) if f == null: push_error("FightSim: cannot open " + path) return {} var parsed = JSON.parse_string(f.get_as_text()) if typeof(parsed) != TYPE_DICTIONARY: push_error("FightSim: invalid JSON in " + path) return {} return parsed ## Begins the fight (lobby -> active). func start() -> void: var boss_hp_value := float(_data.get("boss_hp", 10000.0)) var enrage_type: String = _data.get("enrage_type", "hard") var enrage_time := float(_data.get("enrage_start_time", -1.0)) if enrage_type == "hard": raid_state.start_fight(boss_hp_value, enrage_time if enrage_time > 0.0 else 9999.0) else: # No hard kill; soft enrage is started by the timeline's # "enrage_soft_start" event(s). raid_state.start_fight(boss_hp_value, -1.0) director.set_boss_hp_pct(1.0) director.start() ## Advances the fight by delta_time seconds. Call once per server tick. func tick(delta_time: float) -> void: if raid_state.fight_phase != RaidState.FightPhase.ACTIVE: return raid_state.tick(delta_time) if raid_state.fight_phase != RaidState.FightPhase.ACTIVE: # Hard enrage fired during the state tick. director.stop() return for ev in director.tick(delta_time): _apply_event(ev) if raid_state.fight_phase != RaidState.FightPhase.ACTIVE: director.stop() ## Deals damage to the boss and drains any HP-threshold events it crossed. ## Returns true if the boss died. func deal_damage_to_boss(amount: float) -> bool: if raid_state.fight_phase != RaidState.FightPhase.ACTIVE: return false var killed := raid_state.deal_damage_to_boss(amount) director.set_boss_hp_pct(raid_state.boss_hp / maxf(raid_state.boss_max_hp, 1.0)) if killed: director.stop() return true for ev in director.tick(0.0): _apply_event(ev) return false ## Arena radius in meters (for server-side movement validation). func get_arena_radius(default_value: float = 30.0) -> float: return float(_data.get("arena_radius", default_value)) ## Current soft-enrage damage multiplier (1.0 when inactive). func get_soft_enrage_multiplier() -> float: if not raid_state.soft_enrage_active: return 1.0 return 1.0 + raid_state.soft_enrage_ramp * SOFT_ENRAGE_RAMP_PER_SEC func _apply_event(ev) -> void: event_log.append({ "type": ev.event_type, "time": raid_state.fight_time, "params": ev.params }) match ev.event_type: "dialog": last_dialog_line = ev.params.get("line", "") "aoe_spread", "frontal_cone": var dmg := float(ev.params.get("damage", 0.0)) * get_soft_enrage_multiplier() for p in raid_state.players: if not p.is_dead and not p.is_disconnected: raid_state.deal_damage_to_player(p.player_id, dmg) "stack_debuff": var dmg := float(ev.params.get("individual_damage", 0.0)) * get_soft_enrage_multiplier() for p in raid_state.players: if not p.is_dead and not p.is_disconnected: raid_state.deal_damage_to_player(p.player_id, dmg) "boss_attack": var target := _find_target(str(ev.params.get("target", "tank"))) if target != null: var dmg := float(ev.params.get("damage", 0.0)) * get_soft_enrage_multiplier() raid_state.deal_damage_to_player(target.player_id, dmg) "phase_shift": raid_state.boss_phase = int(ev.params.get("new_phase", raid_state.boss_phase)) "enrage_soft_start": raid_state.soft_enrage_active = true ## Finds the first alive player with the given role; falls back to the first ## alive player if no one matches (e.g., tank is down). func _find_target(role_name: String) -> RaidState.PlayerState: for p in raid_state.players: if not p.is_dead and not p.is_disconnected and p.role == role_name: return p for p in raid_state.players: if not p.is_dead and not p.is_disconnected: return p return null