saumwacht-godot/scripts/luneraria/core/lune_match.gd
Martin Stickelbroeck c27a0097c3 M1: spielbarer Drei-Lane-Kern mit Karte, HUD, Menü und Abnahme
Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die
alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm,
HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden
aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller
(Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch.
Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün.

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

334 lines
14 KiB
GDScript

class_name LuneMatch
extends RefCounted
## Authoritative match: six player slots in two teams, persistent ownership, planning
## drafts that are validated and committed atomically, combat, settlement and victory.
## A match ends only when an Ancient falls. There is no round limit and no HP decision.
enum Phase { PLANNING, COMBAT, OVER }
const ONGOING := -2
const DRAW := -1
const BOT_NAMES: Array[String] = ["Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt"]
const ROLES: Dictionary = {"g01": "melee", "g03": "front", "g05": "ranged", "g07": "splash", "g09": "support",
"w01": "melee", "w03": "front", "w05": "ranged", "w07": "splash", "w09": "support"}
const ROW_ORDER: Array[String] = ["front", "melee", "splash", "ranged", "support"]
var balance: LuneBalance
var map: LuneMap
var rng := RandomNumberGenerator.new()
var seed_value: int = 0
var round_number: int = 1
var phase: int = Phase.PLANNING
var winner: int = ONGOING
var end_note: String = ""
var players: Array[Dictionary] = []
var building_hp: Dictionary = {}
var combat: LuneCombat
var last_settlement: Dictionary = {}
var history: Array[Dictionary] = []
## Remainder rotation per team so both teams follow the same distribution pattern.
var _reward_rotation: Array[int] = [0, 0]
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
map = LuneMap.new(balance)
## options: seed, human_slot (-1 for six bots), bunds (Array of six), names.
func setup(options: Dictionary = {}) -> void:
seed_value = int(options.get("seed", 1))
rng.seed = seed_value
round_number = 1
phase = Phase.PLANNING
winner = ONGOING
end_note = ""
history.clear()
last_settlement = {}
_reward_rotation = [0, 0]
players.clear()
var human: int = int(options.get("human_slot", 1))
var bunds: Array = options.get("bunds", [])
for slot: int in range(6):
var bund: String = str(bunds[slot]) if slot < bunds.size() else ("glutpakt" if (slot + seed_value) % 2 == 0 else "wurzelbund")
var player := {"slot": slot, "team": slot / 3, "lane": LuneMap.LANES[slot % 3], "is_human": slot == human,
"name": "Du" if slot == human else BOT_NAMES[(slot + seed_value) % BOT_NAMES.size()], "bund": bund,
"gold": balance.start_gold, "roster": [], "ready": slot != human, "earned": 0, "spent": 0, "next_seq": 1}
player.draft = _snapshot(player)
player.undo = []
players.append(player)
building_hp.clear()
for definition: Dictionary in map.buildings:
building_hp[definition.id] = float(definition.max_hp)
func _snapshot(player: Dictionary) -> Dictionary:
return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true), "next_seq": int(player.next_seq)}
func slot_of(team: int, lane: String) -> int:
return team * 3 + LuneMap.LANES.find(lane)
func base_ids(bund: String) -> Array[String]:
return DataCatalog.faction_ids(bund)
# ---------------------------------------------------------------- planning commands
func submit(slot: int, command: Dictionary) -> Dictionary:
if phase != Phase.PLANNING:
return _result(false, "Befehle sind nur in der Planung möglich.")
if slot < 0 or slot >= players.size():
return _result(false, "Unbekannter Spieler.")
var player: Dictionary = players[slot]
var draft: Dictionary = player.draft
var before: Dictionary = draft.duplicate(true)
var result: Dictionary = _apply(player, draft, command)
if result.ok:
(player.undo as Array).append(before)
return result
func undo(slot: int) -> Dictionary:
var player: Dictionary = players[slot]
if phase != Phase.PLANNING or (player.undo as Array).is_empty():
return _result(false, "Nichts rückgängig zu machen.")
player.draft = (player.undo as Array).pop_back()
return _result(true, "Letzte Planung zurückgenommen.")
func _find(roster: Array, uid: String) -> int:
for index: int in range(roster.size()):
if str(roster[index].uid) == uid:
return index
return -1
func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dictionary:
var roster: Array = draft.roster
match str(command.get("type", "")):
"buy":
var type_id: String = str(command.get("type_id", ""))
if not base_ids(player.bund).has(type_id):
return _result(false, "Diese Truppe gehört nicht zu deinem Bund.")
if roster.size() >= balance.supply_cap:
return _result(false, "Versorgungsgrenze erreicht (%d Truppen)." % balance.supply_cap)
var cost: int = int(DataCatalog.get_unit(type_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
var lane: String = str(command.get("lane", player.lane))
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.gold = int(draft.gold) - cost
# Per-player numbering keeps both teams' ids in the same order; seq sorts numerically.
var seq: int = int(draft.get("next_seq", player.next_seq))
draft.next_seq = seq + 1
roster.append({"uid": "p%d-%d" % [player.slot, seq], "seq": seq, "type_id": type_id, "lane": lane, "invested": cost, "fresh": true})
return _result(true, "%s gekauft." % DataCatalog.get_unit(type_id).display_name)
"upgrade":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
var upgrade_id: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", ""))
if upgrade_id.is_empty():
return _result(false, "Bereits vollständig aufgewertet.")
var cost: int = int(DataCatalog.get_unit(upgrade_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
entry.type_id = upgrade_id
entry.invested = int(entry.invested) + cost
return _result(true, "Zu %s aufgewertet." % DataCatalog.get_unit(upgrade_id).display_name)
"sell":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
# A purchase from this planning is cancelled at full price; committed troops sell at the refund rate.
var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * balance.sell_refund)
draft.gold = int(draft.gold) + refund
roster.remove_at(index)
return _result(true, "Für %d Gold zurückgegeben." % refund)
"assign":
var index: int = _find(roster, str(command.get("uid", "")))
var lane: String = str(command.get("lane", ""))
if index < 0 or not LuneMap.LANES.has(lane):
return _result(false, "Ungültige Zuweisung.")
roster[index].lane = lane
return _result(true, "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
return _result(false, "Unbekannter Befehl.")
func _result(ok: bool, message: String) -> Dictionary:
return {"ok": ok, "message": message}
func set_ready(slot: int, value: bool = true) -> void:
if phase == Phase.PLANNING:
players[slot].ready = value
func all_ready() -> bool:
for player: Dictionary in players:
if not bool(player.ready):
return false
return true
# ---------------------------------------------------------------- phase changes
## Validates every draft again and commits all of them at once, then starts combat.
func begin_combat() -> bool:
if phase != Phase.PLANNING:
return false
for player: Dictionary in players:
if not _draft_valid(player):
player.draft = _snapshot(player)
for player: Dictionary in players:
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(player.draft.gold))
player.gold = int(player.draft.gold)
player.next_seq = int(player.draft.get("next_seq", player.next_seq))
player.roster = (player.draft.roster as Array).duplicate(true)
for entry: Dictionary in player.roster:
entry.erase("fresh")
player.undo = []
combat = LuneCombat.new(map, balance, round_number)
for definition: Dictionary in map.buildings:
if float(building_hp[definition.id]) > 0.0:
combat.add_building(definition, float(building_hp[definition.id]))
_spawn_waves()
_spawn_armies()
phase = Phase.COMBAT
return true
func _draft_valid(player: Dictionary) -> bool:
var draft: Dictionary = player.draft
if int(draft.gold) < 0 or (draft.roster as Array).size() > balance.supply_cap:
return false
var allowed: Array[String] = base_ids(player.bund)
for entry: Dictionary in draft.roster:
var id: String = str(entry.type_id)
var base: String = id
for candidate: String in allowed:
if str(DataCatalog.get_unit(candidate).get("upgrade_id", "")) == id:
base = candidate
if not allowed.has(base) or not LuneMap.LANES.has(str(entry.lane)):
return false
return true
func _spawn_waves() -> void:
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
for team: int in range(2):
for lane: String in LuneMap.LANES:
for index: int in range(wave.size()):
var row: int = index / 3
var lateral: float = (float(index % 3) - 1.0) * 1.3
combat.spawn("monster", team, lane, wave[index].duplicate(true), 11.0 - float(row) * 1.4, lateral)
func _spawn_armies() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
# The lane owner's troops form the first rows; supporting players follow behind.
var owner: int = slot_of(team, lane)
var ordered: Array = []
for slot: int in [owner] + [team * 3, team * 3 + 1, team * 3 + 2].filter(func(s: int) -> bool: return s != owner):
var entries: Array = (players[slot].roster as Array).filter(func(e: Dictionary) -> bool: return e.lane == lane)
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var ra: int = ROW_ORDER.find(role_of(a.type_id))
var rb: int = ROW_ORDER.find(role_of(b.type_id))
return int(a.seq) < int(b.seq) if ra == rb else ra < rb)
for entry: Dictionary in entries:
ordered.append([slot, entry])
for index: int in range(ordered.size()):
var slot: int = ordered[index][0]
var entry: Dictionary = ordered[index][1]
var row: int = index / 4
var lateral: float = (float(index % 4) - 1.5) * 1.2
var definition: Dictionary = DataCatalog.get_unit(entry.type_id)
combat.spawn("troop", team, lane, definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, slot, str(entry.uid))
func role_of(type_id: String) -> String:
var id: String = type_id
for base: String in ROLES:
if base == id or str(DataCatalog.get_unit(base).get("upgrade_id", "")) == id:
return ROLES[base]
return "melee"
func step_combat() -> void:
if phase != Phase.COMBAT or combat == null:
return
combat.step()
if combat.finished:
_settle()
## Runs the current combat phase to its end (tests, bots-only simulations).
func finish_combat() -> void:
while phase == Phase.COMBAT:
step_combat()
func _settle() -> void:
var gains: Array[int] = [0, 0, 0, 0, 0, 0]
var lane_kills: Dictionary = {}
var buildings_lost: Array[String] = []
for event: Dictionary in combat.events:
var dying: int = int(event.team)
var killer: int = 1 - dying
var bounty: int = int(event.bounty)
if event.kind in ["monster", "champion", "boss"] and bounty > 0 and event.zone == "lane":
var owner: int = slot_of(killer, str(event.lane))
var lane_part: int = int(floor(float(bounty) * balance.lane_share))
gains[owner] += lane_part
_split(gains, killer, bounty - lane_part)
lane_kills[event.lane] = int(lane_kills.get(event.lane, 0)) + 1
elif event.kind in ["tower", "core"]:
buildings_lost.append(str(event.building_id))
_split(gains, killer, bounty)
for unit: LuneUnit in combat.units:
if unit.is_building():
building_hp[unit.building_id] = unit.hp if unit.alive else 0.0
var income: int = balance.round_income(round_number)
for player: Dictionary in players:
var total: int = gains[player.slot] + (0 if not combat.destroyed_cores.is_empty() else income)
player.gold = int(player.gold) + total
player.earned = int(player.earned) + total
last_settlement = {"round": round_number, "gains": gains, "income": income, "buildings_lost": buildings_lost,
"end_reason": combat.end_reason, "combat_time": combat.elapsed, "lane_kills": lane_kills}
history.append({"round": round_number, "end_reason": combat.end_reason, "combat_time": combat.elapsed,
"buildings": _alive_count(), "gold": players.map(func(p: Dictionary) -> int: return int(p.gold)),
"lost": buildings_lost.duplicate()})
if not combat.destroyed_cores.is_empty():
winner = DRAW if combat.destroyed_cores.size() >= 2 else 1 - combat.destroyed_cores[0]
end_note = "Beide Ancients fielen im selben Schritt." if winner == DRAW else "Ancient von %s zerstört." % ("Blau" if combat.destroyed_cores[0] == 0 else "Rot")
phase = Phase.OVER
return
round_number += 1
phase = Phase.PLANNING
for player: Dictionary in players:
player.draft = _snapshot(player)
player.undo = []
player.ready = not bool(player.is_human)
## Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.
func _split(gains: Array[int], team: int, amount: int) -> void:
if amount <= 0:
return
var share: int = amount / 3
var rest: int = amount % 3
for offset: int in range(3):
gains[team * 3 + offset] += share
for index: int in range(rest):
gains[team * 3 + (_reward_rotation[team] + index) % 3] += 1
_reward_rotation[team] = (_reward_rotation[team] + rest) % 3
func _alive_count() -> Array[int]:
var counts: Array[int] = [0, 0]
for id: String in building_hp:
if float(building_hp[id]) > 0.0:
counts[0 if id.begins_with("blau") else 1] += 1
return counts
func building_alive(id: String) -> bool:
return float(building_hp.get(id, 0.0)) > 0.0
## Live state for displays: during combat the combat instance is authoritative.
func building_alive_now(id: String) -> bool:
if phase == Phase.COMBAT and combat != null and combat.buildings.has(id):
return (combat.buildings[id] as LuneUnit).alive
return building_alive(id)
func towers_alive(team: int, lane: String, live: bool = false) -> int:
var prefix: String = "blau" if team == 0 else "rot"
var probe: Callable = building_alive_now if live else building_alive
return int(probe.call("%s_%s_outer" % [prefix, lane])) + int(probe.call("%s_%s_inner" % [prefix, lane]))
func core_hp(team: int) -> float:
return float(building_hp["blau_ancient" if team == 0 else "rot_ancient"])
func gold_of(slot: int) -> int:
return int(players[slot].draft.gold) if phase == Phase.PLANNING else int(players[slot].gold)