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>
334 lines
14 KiB
GDScript
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)
|