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>
333 lines
14 KiB
GDScript
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}
|