saumwacht-godot/scripts/combat/battle_controller.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

179 lines
5.8 KiB
GDScript

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))