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}