saumwacht-godot/scripts/luneraria/ui/lune_hud_model.gd
Martin Stickelbroeck 1b540f2a55 G3 (Zwischenstand): neues Ingame-HUD; Projekt wechselt nach Unity
Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of
Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das
HUD im Unity-Projekt (../luneraria-unity).

- HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen
  (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer
  Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse,
  Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails,
  Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben
- Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie
- Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715;
  verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern
- Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md
- HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:59:55 +02:00

333 lines
14 KiB
GDScript

class_name LuneHudModel
extends RefCounted
## What one seat may see, prepared for display. Every HUD element reads through this model,
## so the privacy rules live in one place:
## - gold, draft, undo and uncommitted bookings exist only for the viewer's own seat;
## - of other players the model exposes names, lanes, readiness, human/bot/offline and the
## committed hero (public since the last combat), never their gold or current plans;
## - lanes, buildings, the boss and the waves come from public state only.
## The model never changes the match; commands still go through LuneGame.
const TOWER_NAMES: Dictionary = {"outer": "Grenzwacht", "inner": "Bastion", "base": "Kernwacht", "core": "Ancient"}
const TIER_TEXT: Dictionary = {"outer": "Außenturm", "inner": "Innenturm", "base": "Basisturm", "core": "Ancient"}
var state: LuneMatch
var slot: int = 0
## Optional LuneSession; only read for the network display (role, connection).
var session: Node
var _was_human: Dictionary = {}
func _init(match_state: LuneMatch, viewer: int, network: Node = null) -> void:
state = match_state
slot = maxi(0, viewer)
session = network
# ---------------------------------------------------------------- phase and own seat
func planning() -> bool:
return state.phase == LuneMatch.Phase.PLANNING
func fighting() -> bool:
return state.phase == LuneMatch.Phase.COMBAT and state.combat != null
func over() -> bool:
return state.phase == LuneMatch.Phase.OVER
func me() -> Dictionary:
return state.players[slot]
func team() -> int:
return int(me().team)
func enemy() -> int:
return 1 - team()
func own_gold() -> int:
return state.gold_of(slot)
## Base income paid at the end of the current round's combat (rules: LuneBalance.round_income).
func income() -> int:
return state.balance.round_income(state.round_number)
## After Ready the plan is binding (the host refuses later commands).
func locked() -> bool:
return planning() and bool(me().ready)
func own_roster() -> Array:
return me().draft.roster if planning() else me().roster
func own_hero() -> Dictionary:
return me().draft.hero if planning() else me().hero
func own_bookings() -> Array:
var result: Array = []
if planning():
for booking: Dictionary in me().draft.new_bookings:
var entry: Dictionary = booking.duplicate()
entry.deploy_round = state.round_number + 1
entry.fresh = true
result.append(entry)
for booking: Dictionary in me().bookings:
if int(booking.deploy_round) >= state.round_number:
result.append(booking)
return result
func supply_cap() -> int:
return state.balance.supply_cap
func networked() -> bool:
return session != null and is_instance_valid(session) and session.is_networked()
func is_guest() -> bool:
return networked() and not session.is_authority()
# ---------------------------------------------------------------- units and heroes
func role_of(type_id: String) -> String:
return state.role_of(type_id)
static func stage_of(type_id: String) -> int:
var number: int = int(type_id.substr(1))
return 2 if number % 2 == 0 else 1
## Catalogue definition of a troop with its role and derived figures.
func troop(type_id: String) -> Dictionary:
var data: Dictionary = DataCatalog.get_unit(type_id)
data.role = role_of(type_id)
data.stage = stage_of(type_id)
return data
## Live combat unit for a persistent uid of the viewer (or null).
func live_unit(uid: String) -> LuneUnit:
if not fighting():
return null
for unit: LuneUnit in state.combat.units:
if unit.persistent_uid == uid:
return unit
return null
func hero_definition(hero: Dictionary) -> Dictionary:
var definition: Dictionary = LuneContent.hero_definition(hero)
definition.role = LuneMatch.HERO_ROLES.get(hero.hero_id, "front")
return definition
# ---------------------------------------------------------------- buildings
func building(id: String) -> Dictionary:
var definition: Dictionary = state.map.building_def(id)
var max_hp: float = float(definition.get("max_hp", 1.0))
var hp: float = float(state.building_hp.get(id, 0.0))
var alive: bool = hp > 0.0
if fighting() and state.combat.buildings.has(id):
var unit: LuneUnit = state.combat.buildings[id]
alive = unit.alive
hp = unit.hp if alive else 0.0
return {"id": id, "team": int(definition.get("team", 0)), "tier": str(definition.get("tier", "outer")), "lane": str(definition.get("lane", "")),
"alive": alive, "hp": hp, "max_hp": max_hp, "fraction": clampf(hp / maxf(1.0, max_hp), 0.0, 1.0),
"attackable": alive and LuneMap.is_attackable(definition, _alive_map())}
func _alive_map() -> Dictionary:
if fighting():
return state.combat.alive_map()
var result: Dictionary = {}
for id: String in state.building_hp:
result[id] = float(state.building_hp[id]) > 0.0
return result
static func building_title(id: String) -> String:
var parts: PackedStringArray = id.split("_")
if parts[1] == "ancient":
return "Ancient"
if parts[1] == "base":
return "Kernwacht"
return TOWER_NAMES.get(parts[2], id)
static func building_place(id: String) -> String:
var parts: PackedStringArray = id.split("_")
var team: String = "Blau" if parts[0] == "blau" else "Rot"
if parts[1] == "ancient":
return "Ancient " + team
if parts[1] == "base":
return "Basis %s · Kernwacht %s" % [team, parts[2]]
return "%s · %s %s" % [LuneMap.LANE_NAMES[parts[1]].to_upper(), building_title(id), team]
func ancient(team_index: int) -> Dictionary:
var prefix: String = "blau" if team_index == 0 else "rot"
var core: Dictionary = building(prefix + "_ancient")
core.base = [building(prefix + "_base_1"), building(prefix + "_base_2")]
return core
# ---------------------------------------------------------------- players
func player_name(seat: int) -> String:
return "Du" if seat == slot else str(state.players[seat].name)
## Seat status for display: self, human, bot, or offline (was human, a bot plays the seat).
func status_of(seat: int) -> String:
var player: Dictionary = state.players[seat]
if bool(player.is_human):
_was_human[seat] = true
if seat == slot:
return "self"
if bool(player.is_human):
return "human"
return "offline" if _was_human.has(seat) else "bot"
func members(team_index: int) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for seat: int in range(team_index * 3, team_index * 3 + 3):
var player: Dictionary = state.players[seat]
# Own seat: current draft. Everyone else: the committed hero, public since the last combat.
var hero: Dictionary = own_hero() if seat == slot else player.hero
result.append({"slot": seat, "name": player_name(seat), "lane": str(player.lane), "is_self": seat == slot,
"ready": bool(player.ready), "status": status_of(seat), "bund": str(player.bund),
"hero_id": str(hero.get("hero_id", "")), "hero_level": LuneContent.level_for(int(hero.get("xp", 0))) if not hero.is_empty() else 0})
return result
func ready_count() -> Vector2i:
var ready: int = 0
var humans: int = 0
for player: Dictionary in state.players:
if bool(player.is_human):
humans += 1
ready += int(bool(player.ready))
return Vector2i(ready, humans)
# ---------------------------------------------------------------- lanes
func lane(lane_name: String) -> Dictionary:
var own_seat: int = state.slot_of(team(), lane_name)
var enemy_seat: int = state.slot_of(enemy(), lane_name)
var own_prefix: String = "blau" if team() == 0 else "rot"
var enemy_prefix: String = "rot" if team() == 0 else "blau"
var dangers: Array[Dictionary] = []
for entry: Dictionary in state.announcements():
if str(entry.lane) == lane_name:
dangers.append(entry)
var result: Dictionary = {"lane": lane_name, "title": LuneMap.LANE_NAMES[lane_name].to_upper(), "own_player": player_name(own_seat),
"enemy_player": player_name(enemy_seat), "own_is_me": own_seat == slot,
"towers": [building("%s_%s_inner" % [own_prefix, lane_name]), building("%s_%s_outer" % [own_prefix, lane_name]),
building("%s_%s_outer" % [enemy_prefix, lane_name]), building("%s_%s_inner" % [enemy_prefix, lane_name])],
"threat": LuneLaneThreat.evaluate(state, team(), lane_name), "dangers": dangers,
"own_hero": hero_of(own_seat), "enemy_hero": hero_of(enemy_seat)}
if fighting():
var battle: Dictionary = LuneLaneThreat.battlefield(state, team(), lane_name)
result.own_count = int(battle.own_count)
result.enemy_count = int(battle.enemy_count)
return result
## A seat's hero for display: the own draft for the viewer, the committed (public) hero for
## everyone else; {} without a hero.
func hero_of(seat: int) -> Dictionary:
var hero: Dictionary = own_hero() if seat == slot else state.players[seat].hero
if hero.is_empty():
return {}
var spec: Dictionary = LuneContent.HEROES[hero.hero_id]
return {"model": str(spec.model), "name": str(spec.name), "level": LuneContent.level_for(int(hero.xp))}
# ---------------------------------------------------------------- boss grotto
func boss_bounty(round_number: int) -> int:
return LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * round_number
## Public boss state: available, contested, conquered, deploy_next, deploying, defeated,
## cooldown. On a guest the grotto fight is read from the mirrored units.
func boss() -> Dictionary:
var claim: Dictionary = state.boss.claim
var round_number: int = state.round_number
var result: Dictionary = {"state": "cooldown", "team": -1, "lane": "", "deploy_round": 0, "won_round": 0, "bounty": 0,
"rounds_left": maxi(0, int(state.boss.next_available_round) - round_number), "own": false, "chooser": false,
"next_round": int(state.boss.next_available_round)}
if fighting():
for unit: LuneUnit in state.combat.units:
if unit.kind != "boss":
continue
if unit.zone == "grotto":
if unit.alive:
result.state = "contested"
return result
var winner: int = -1
for other: LuneUnit in state.combat.units:
if other.alive and other.zone == "grotto" and other.team <= 1:
winner = other.team
result.state = "conquered" if winner >= 0 else "failed"
result.team = winner
result.own = winner == team()
result.deploy_round = round_number + 1
return result
if unit.zone == "lane":
result.state = "deploying" if unit.alive else "defeated"
result.team = unit.team
result.own = unit.team == team()
result.lane = unit.lane
result.deploy_round = round_number
result.bounty = boss_bounty(round_number)
result.hp = unit.hp
result.max_hp = unit.max_hp
return result
if not claim.is_empty() and int(claim.deploy_round) >= round_number:
result.state = "deploy_next" if planning() else "deploying"
result.team = int(claim.team)
result.own = int(claim.team) == team()
result.won_round = int(claim.won_round)
result.deploy_round = int(claim.deploy_round)
result.bounty = boss_bounty(int(claim.deploy_round))
result.chooser = int(claim.chooser_slot) == slot
# The chooser's lane change is private until combat; others see the public claim lane.
var chosen: String = str(me().draft.boss_lane) if result.chooser and planning() else ""
result.lane = chosen if not chosen.is_empty() else str(claim.lane)
return result
if state.grotto_available():
result.state = "available"
return result
# ---------------------------------------------------------------- waves
## The wave of the coming combat (planning) or the one after (combat). Monsters are public and
## identical for both sides; champions and the boss only once they are announced.
func next_wave() -> Dictionary:
var round_number: int = state.round_number if not fighting() else state.round_number + 1
var wave: Array[Dictionary] = LuneWaves.build(state.balance, round_number)
var before: PackedStringArray = LuneWaves.unlocked_types(state.balance, round_number - 1) if round_number > 1 else PackedStringArray()
var groups: Dictionary = {}
var order: Array[String] = []
for monster: Dictionary in wave:
var id: String = str(monster.id)
if not groups.has(id):
groups[id] = {"id": id, "name": str(monster.display_name), "count": 0, "hp": float(monster.max_hp), "damage": float(monster.damage),
"range": float(monster.attack_range), "new": round_number > 1 and not before.has(id), "armor": int(monster.armor_type), "attack": int(monster.attack_type)}
order.append(id)
groups[id].count = int(groups[id].count) + 1
var monsters: Array[Dictionary] = []
for id: String in order:
monsters.append(groups[id])
var champions: Array[Dictionary] = []
var boss_entry: Dictionary = {}
if not fighting():
for entry: Dictionary in state.announcements():
if entry.kind == "boss":
boss_entry = {"team": int(entry.team), "lane": str(entry.lane), "bounty": int(entry.bounty), "own": int(entry.team) == team()}
else:
champions.append({"team": int(entry.team), "lane": str(entry.lane), "champion_id": str(entry.champion_id),
"name": LuneContent.CHAMPIONS[entry.champion_id].name, "own": int(entry.team) == team()})
return {"round": round_number, "monsters": monsters, "count": wave.size(), "champions": champions, "boss": boss_entry}
# ---------------------------------------------------------------- network test support
## Everything the HUD shows that must be identical for every seat (host, guest, bot views).
func public_digest() -> Dictionary:
var buildings: Dictionary = {}
for definition: Dictionary in state.map.buildings:
var entry: Dictionary = building(definition.id)
buildings[definition.id] = [entry.alive, int(round(float(entry.hp)))]
var lanes: Dictionary = {}
for name: String in LuneMap.LANES:
for side: int in range(2):
var threat: Dictionary = LuneLaneThreat.evaluate(state, side, name)
lanes["%s/%d" % [name, side]] = [threat.level, Array(threat.reasons)]
var seats: Array = []
for player: Dictionary in state.players:
var hero: Dictionary = player.hero
seats.append([int(player.slot), str(player.lane), bool(player.ready), bool(player.is_human), str(hero.get("hero_id", "")), int(hero.get("xp", 0))])
var boss_state: Dictionary = boss()
boss_state.erase("own")
boss_state.erase("chooser")
var wave: Dictionary = next_wave()
for entry: Dictionary in wave.champions:
entry.erase("own")
if not (wave.boss as Dictionary).is_empty():
wave.boss.erase("own")
return {"round": state.round_number, "phase": state.phase, "buildings": buildings, "lanes": lanes, "seats": seats,
"boss": boss_state, "wave": wave}