class_name BattleController extends Node3D ## Pure combat owner: step() returns ledger deltas, never edits match economy. ## WaveManager alone calls step from the fixed physics clock. const Unit = preload("res://scripts/combat/unit.gd") const MAX_COMBAT_TIME: float = 90.0 const LANE_CENTERS = [-9.0, 9.0] const SPAWN_COLUMNS = [0.0, -1.5, 1.5, -3.0, 3.0, -4.5, 4.5] var render_enabled: bool = true var units: Array = [] var elapsed: float = 0.0 var wave_number: int = 1 var finished: bool = true var bounties: Array[int] = [0, 0] var leaks: Array[int] = [0, 0] var natural_leaks: Array[int] = [0, 0] var front_lifetime: Array[float] = [-1.0, -1.0] var _active: bool = false func show_formation(rosters: Array) -> void: _clear() _spawn_guardians(rosters) for unit in units: unit.refresh_visuals() finished = true _active = false func begin_battle(rosters: Array, attackers: Array, wave: int) -> void: _clear() wave_number = maxi(1, wave) _spawn_guardians(rosters) for lane in range(2): var definitions: Array = attackers[lane] if lane < attackers.size() else [] var melee: Array = [] var ranged: Array = [] for definition in definitions: var group = melee if float(definition.get("attack_range", 1.0)) <= 1.5 else ranged if str(definition.get("id", "")) in ["e07", "e08", "e09", "e10"]: group.push_front(definition) else: group.append(definition) var spawn_index = 0 var row_offset = 0 for group in [melee, ranged]: for index in range(group.size()): var unit = Unit.new() add_child(unit) # Guardian IDs occupy the 0 and 1,000,000 namespaces. unit.setup(group[index], 2000000 + lane * 100000 + spawn_index, lane, true, Vector2i(-1, -1), 0, render_enabled) unit.wave_number = wave_number unit.position = Vector3(float(LANE_CENTERS[lane]) + float(SPAWN_COLUMNS[index % 7]), 0.0, -10.0 - float(row_offset + index / 7) * 1.5) unit.facing = 0.0 units.append(unit) spawn_index += 1 row_offset += ceili(float(group.size()) / 7.0) finished = false _active = true for lane in range(2): if not _has_guardians(lane): front_lifetime[lane] = 0.0 _refresh_visuals() func step(delta: float) -> Dictionary: var leak_damage: Array[int] = [0, 0] if not _active or finished: return _result(leak_damage) var dt = clampf(delta, 0.0, maxf(0.0, MAX_COMBAT_TIME - elapsed)) elapsed = minf(MAX_COMBAT_TIME, elapsed + dt) # One explicit order for every step, including the last step at 90 seconds: # status expiry/DoT -> ready support -> targets -> movement -> primary+bonus # attacks -> deaths/line crossings -> simultaneous timeout ledger. for unit in units: if unit.alive: unit.advance_status(dt) for unit in units: if unit.alive: unit.ability_component.step(unit, units, dt) for unit in units: if unit.alive: unit.target = unit.targeting_component.choose(unit, units) for unit in units: if unit.alive: unit.movement_component.step(unit, units, dt, float(LANE_CENTERS[unit.team_id])) for unit in units: if unit.alive: unit.attack_component.step(unit, units, dt) for unit in units: if not unit.is_attacker or unit.resolved: continue if not unit.alive: unit.resolved = true bounties[unit.team_id] += int(unit.data.get("bounty", 0)) elif unit.position.z >= 16.0: _resolve_leak(unit, leak_damage) for lane in range(2): if front_lifetime[lane] < 0.0 and not _has_guardians(lane): front_lifetime[lane] = elapsed if elapsed >= MAX_COMBAT_TIME - 0.000001: for unit in units: if unit.is_attacker and unit.alive and not unit.resolved: _resolve_leak(unit, leak_damage) finished = true for unit in units: if unit.is_attacker and unit.alive: finished = false break if finished: _active = false for unit in units: unit.effects.clear() unit.dots.clear() unit.target = null _refresh_visuals(dt) return _result(leak_damage) func _resolve_leak(unit, leak_damage: Array[int]) -> void: var damage = ceili(float(unit.data.get("leak_value", 0)) * (1.0 + 0.07 * float(wave_number - 1)) * maxf(0.25, unit.hp / unit.max_hp)) leak_damage[unit.team_id] += damage leaks[unit.team_id] += 1 if str(unit.data.get("id", "")).begins_with("e"): natural_leaks[unit.team_id] += 1 unit.resolved = true unit.leaked = true unit.alive = false unit.target = null func _result(leak_damage: Array[int]) -> Dictionary: return { "finished": finished, "leak_damage": leak_damage, "bounties": bounties.duplicate(), "leaks": leaks.duplicate(), "natural_leaks": natural_leaks.duplicate(), "elapsed": elapsed, "front_lifetime": [elapsed if front_lifetime[0] < 0.0 else front_lifetime[0], elapsed if front_lifetime[1] < 0.0 else front_lifetime[1]], } func _has_guardians(lane: int) -> bool: for unit in units: if unit.alive and not unit.is_attacker and unit.team_id == lane: return true return false func _spawn_guardians(rosters: Array) -> void: var catalog = get_node("/root/DataCatalog") for lane in range(mini(2, rosters.size())): for owned in rosters[lane]: var definition: Dictionary = catalog.get_unit(str(owned.type_id)) if definition.is_empty(): continue var unit = Unit.new() add_child(unit) unit.setup(definition, int(owned.uid), lane, false, owned.cell, int(owned.get("target_policy", 0)), render_enabled) unit.wave_number = wave_number unit.position = Vector3(float(LANE_CENTERS[lane]) - 5.0 + float(owned.cell.x) * 2.0, 0.0, -7.0 + float(owned.cell.y) * 2.0) units.append(unit) func _clear() -> void: for unit in units: unit.free() units.clear() elapsed = 0.0 bounties = [0, 0] leaks = [0, 0] natural_leaks = [0, 0] front_lifetime = [-1.0, -1.0] _active = false finished = true func _refresh_visuals(delta: float = 0.0) -> void: if render_enabled: for unit in units: unit.refresh_visuals(delta) func _process(delta: float) -> void: if render_enabled and finished and not GameState.paused: _refresh_visuals(minf(delta,.1))