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>
179 lines
5.8 KiB
GDScript
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))
|