extends Control ## Champions of Luneraria HUD. Presentation only: it reads LuneMatch and sends the human ## player's commands through the owning LuneGame; UI state never creates ownership. signal command_requested(command: Dictionary) signal undo_requested signal ready_requested signal starter_requested signal speed_requested(value: int) signal pause_requested signal lane_focus_requested(lane: String) signal map_focus_requested(point: Vector3) signal menu_requested signal rematch_requested signal settings_requested const Minimap = preload("res://scripts/luneraria/ui/lune_minimap.gd") const HeroPanel = preload("res://scripts/luneraria/ui/lune_hero_panel.gd") const ReinforcePanel = preload("res://scripts/luneraria/ui/lune_reinforce_panel.gd") const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] const TEAM_NAMES: Array[String] = ["Blau", "Rot"] const ROLE_NAMES: Dictionary = {"front": "Front", "melee": "Nahkampf", "ranged": "Fernkampf", "splash": "Fläche", "support": "Unterstützung"} var match_state: LuneMatch var human_slot: int = 1 var minimap: Control var _phase: Label var _round: Label var _core_bars: Array[ProgressBar] = [] var _core_labels: Array[Label] = [] var _gold: Label var _income: Label var _identity: Label var _pause: Button var _speed: Button var _lane_rows: Dictionary = {} var _team_list: Label var _plan_panel: PanelContainer var _shop: GridContainer var _shop_buttons: Dictionary = {} var _roster: VBoxContainer var _roster_title: Label var _ready_button: Button var _starter: Button var _undo: Button var _combat_panel: PanelContainer var _combat_info: Label var _report: Label var _toast: Label var _toast_left: float = 0.0 var _over: Control var _over_title: Label var _over_body: Label var _roster_signature: String = "@" var hero_panel: VBoxContainer var reinforce_panel: VBoxContainer var _pages: Array[Control] = [] var _tabs: ChoiceGroup var _boss_status: Label func _ready() -> void: theme = SaumwachtTheme.shared() set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_IGNORE _top_bar() _lane_panel() _plan_side() _bottom() _game_over() func _panel(variation: String = "CaptionPanel") -> PanelContainer: var panel := PanelContainer.new() panel.theme_type_variation = variation panel.mouse_filter = Control.MOUSE_FILTER_STOP add_child(panel) return panel func _label(text_value: String, variation: String, parent: Node, size: int = 0) -> Label: var label := Label.new() label.text = text_value label.theme_type_variation = variation label.mouse_filter = Control.MOUSE_FILTER_IGNORE if size > 0: label.add_theme_font_size_override("font_size", size) parent.add_child(label) return label func _button(text_value: String, parent: Node, action: Callable, variation: String = "Button") -> Button: var button := Button.new() button.text = text_value button.theme_type_variation = variation button.focus_mode = Control.FOCUS_ALL button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.pressed.connect(action) parent.add_child(button) return button func _bar(color: Color, parent: Node) -> ProgressBar: var bar := ProgressBar.new() bar.show_percentage = false bar.custom_minimum_size = Vector2(190, 10) var back := StyleBoxFlat.new() back.bg_color = Color(0, 0, 0, .55) back.border_color = Color("5c4a33") back.set_border_width_all(1) var fill := StyleBoxFlat.new() fill.bg_color = color bar.add_theme_stylebox_override("background", back) bar.add_theme_stylebox_override("fill", fill) parent.add_child(bar) return bar func _top_bar() -> void: var top := _panel() top.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) top.offset_left = 16 top.offset_right = -16 top.offset_top = 12 var row := HBoxContainer.new() row.add_theme_constant_override("separation", 22) top.add_child(row) var title := VBoxContainer.new() title.add_theme_constant_override("separation", 0) row.add_child(title) _label("CHAMPIONS OF LUNERARIA", "EyebrowLabel", title) _phase = _label("PLANUNG", "SectionLabel", title, 20) _round = _label("Welle 1", "MutedLabel", title) for team: int in range(2): var box := VBoxContainer.new() box.add_theme_constant_override("separation", 3) row.add_child(box) _core_labels.append(_label("Ancient %s" % TEAM_NAMES[team], "MutedLabel", box, 15)) _core_bars.append(_bar(TEAM_COLORS[team], box)) var economy := VBoxContainer.new() economy.add_theme_constant_override("separation", 0) row.add_child(economy) _gold = _label("0 Gold", "CardTitleLabel", economy, 24) _income = _label("", "MutedLabel", economy, 15) _identity = _label("", "MutedLabel", row, 15) _identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL _identity.size_flags_vertical = Control.SIZE_SHRINK_CENTER _pause = _button("Pause · P", row, func() -> void: pause_requested.emit()) _speed = _button("1×", row, func() -> void: speed_requested.emit(0)) _button("Optionen · F1", row, func() -> void: settings_requested.emit()) _button("Menü", row, func() -> void: menu_requested.emit(), "QuietButton") func _lane_panel() -> void: var panel := _panel() panel.position = Vector2(16, 112) panel.custom_minimum_size = Vector2(300, 0) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 6) panel.add_child(column) _label("LANES · TÜRME", "SectionLabel", column) for lane: String in LuneMap.LANES: var button := _button("", column, func() -> void: lane_focus_requested.emit(lane), "ChoiceButton") button.custom_minimum_size = Vector2(0, 52) button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.tooltip_text = "Kamera auf %s" % LuneMap.LANE_NAMES[lane] _lane_rows[lane] = button _team_list = _label("", "MutedLabel", column, 14) _boss_status = _label("", "Label", column, 15) _boss_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _boss_status.custom_minimum_size.x = 264 func _plan_side() -> void: _plan_panel = _panel("MenuPanel") _plan_panel.anchor_left = 1.0 _plan_panel.anchor_right = 1.0 _plan_panel.anchor_bottom = 1.0 _plan_panel.offset_left = -500 _plan_panel.offset_right = -16 _plan_panel.offset_top = 112 _plan_panel.offset_bottom = -270 var outer := VBoxContainer.new() outer.add_theme_constant_override("separation", 8) _plan_panel.add_child(outer) _tabs = ChoiceGroup.new(true) _tabs.name = "PlanTabs" _tabs.add_option("Truppen") _tabs.add_option("Held") _tabs.add_option("Verstärkung") outer.add_child(_tabs) _tabs.item_selected.connect(show_page) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 8) column.size_flags_vertical = Control.SIZE_EXPAND_FILL outer.add_child(column) _pages.append(column) _label("TRUPPEN KAUFEN · 1–5", "SectionLabel", column) _shop = GridContainer.new() _shop.columns = 5 _shop.add_theme_constant_override("h_separation", 6) column.add_child(_shop) _roster_title = _label("DEINE ARMEE", "SectionLabel", column) var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED column.add_child(scroll) _roster = VBoxContainer.new() _roster.size_flags_horizontal = Control.SIZE_EXPAND_FILL _roster.add_theme_constant_override("separation", 4) scroll.add_child(_roster) for index: int in range(2): var page := ScrollContainer.new() page.size_flags_vertical = Control.SIZE_EXPAND_FILL page.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED page.visible = false outer.add_child(page) _pages.append(page) var panel: VBoxContainer = HeroPanel.new() if index == 0 else ReinforcePanel.new() panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL page.add_child(panel) panel.command_requested.connect(func(command: Dictionary) -> void: command_requested.emit(command)) if index == 0: hero_panel = panel else: reinforce_panel = panel _tabs.select(0) var actions := HBoxContainer.new() outer.add_child(actions) _starter = _button("Startaufstellung", actions, func() -> void: starter_requested.emit()) _starter.tooltip_text = "Kauft Front, Nahkampf und Fernkampf deines Bundes für deine Lane." _undo = _button("Rückgängig", actions, func() -> void: undo_requested.emit()) _ready_button = _button("BEREIT · KAMPF ›", outer, func() -> void: ready_requested.emit(), "PrimaryButton") _ready_button.custom_minimum_size.y = 58 _ready_button.tooltip_text = "Leertaste · Befehle werden jetzt verbindlich übernommen." _combat_panel = _panel() _combat_panel.anchor_left = 1.0 _combat_panel.anchor_right = 1.0 _combat_panel.offset_left = -500 _combat_panel.offset_right = -16 _combat_panel.offset_top = 112 _combat_info = _label("", "Label", _combat_panel, 16) func _bottom() -> void: var report_panel := _panel() report_panel.anchor_top = 1.0 report_panel.anchor_bottom = 1.0 report_panel.offset_left = 16 report_panel.offset_top = -120 report_panel.offset_bottom = -16 report_panel.offset_right = 760 var column := VBoxContainer.new() report_panel.add_child(column) _report = _label("Planung: Kaufe Truppen für deine Lane und drücke Bereit.", "Label", column, 16) _report.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _toast = _label("", "MutedLabel", column, 15) var map_panel := _panel() map_panel.anchor_left = 1.0 map_panel.anchor_right = 1.0 map_panel.anchor_top = 1.0 map_panel.anchor_bottom = 1.0 map_panel.offset_left = -270 map_panel.offset_right = -16 map_panel.offset_top = -258 map_panel.offset_bottom = -16 minimap = Minimap.new() map_panel.add_child(minimap) minimap.focus_requested.connect(func(point: Vector3) -> void: map_focus_requested.emit(point)) func _game_over() -> void: _over = ColorRect.new() (_over as ColorRect).color = Color(.02, .03, .04, .82) _over.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _over.mouse_filter = Control.MOUSE_FILTER_STOP _over.visible = false add_child(_over) var center := CenterContainer.new() center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _over.add_child(center) var panel := PanelContainer.new() panel.theme_type_variation = "MenuPanel" panel.custom_minimum_size = Vector2(620, 0) center.add_child(panel) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 14) panel.add_child(column) _over_title = _label("", "TitleLabel", column, 60) _over_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _over_body = _label("", "BodyLabel", column) _over_body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _over_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 12) column.add_child(row) _button("NEUE PARTIE", row, func() -> void: rematch_requested.emit(), "PrimaryButton") _button("Hauptmenü", row, func() -> void: menu_requested.emit()) # ---------------------------------------------------------------- refresh func notify(message: String, error: bool = false) -> void: _toast.text = message _toast.add_theme_color_override("font_color", Color("f0a888") if error else Color("9fe0c8")) _toast_left = 5.0 func _process(delta: float) -> void: _toast_left -= delta if _toast_left <= 0.0: _toast.text = "WASD / mittlere Maustaste: Kamera · Mausrad: Zoom · Tab: Lanes · Minimap klicken" _toast.remove_theme_color_override("font_color") if match_state != null: refresh() func show_page(index: int) -> void: _tabs.select(index) for page_index: int in range(_pages.size()): _pages[page_index].visible = page_index == index func refresh() -> void: var player: Dictionary = match_state.players[human_slot] if hero_panel.match_state != match_state: hero_panel.match_state = match_state hero_panel.slot = human_slot reinforce_panel.match_state = match_state reinforce_panel.slot = human_slot _boss_status.text = _grotto_text() var planning: bool = match_state.phase == LuneMatch.Phase.PLANNING var combat: bool = match_state.phase == LuneMatch.Phase.COMBAT var phase_name: String = "PLANUNG" if planning else ("KAMPF" if combat else "PARTIE BEENDET") _phase.text = phase_name _round.text = "Welle %d%s" % [match_state.round_number, (" · %02d:%02d" % [int(match_state.combat.elapsed) / 60, int(match_state.combat.elapsed) % 60]) if combat else ""] for team: int in range(2): var hp: float = match_state.core_hp(team) if combat and match_state.combat.buildings.has("%s_ancient" % ("blau" if team == 0 else "rot")): hp = (match_state.combat.buildings["%s_ancient" % ("blau" if team == 0 else "rot")] as LuneUnit).hp _core_bars[team].max_value = match_state.balance.core_hp _core_bars[team].value = hp _core_labels[team].text = "Ancient %s · %d" % [TEAM_NAMES[team], ceili(hp)] _gold.text = "%d Gold" % match_state.gold_of(human_slot) _income.text = "+%d pro Runde · Armee %d / %d" % [match_state.balance.round_income(match_state.round_number), (player.draft.roster as Array).size() if planning else (player.roster as Array).size(), match_state.balance.supply_cap] _identity.text = "%s · %s-Lane · %s" % [TEAM_NAMES[int(player.team)], LuneMap.LANE_NAMES[player.lane], DataCatalog.FACTION_NAMES[player.bund]] _pause.visible = combat _speed.visible = combat for lane: String in LuneMap.LANES: var button: Button = _lane_rows[lane] var own: String = "●".repeat(match_state.towers_alive(0, lane, true)) + "○".repeat(2 - match_state.towers_alive(0, lane, true)) var rival: String = "●".repeat(match_state.towers_alive(1, lane, true)) + "○".repeat(2 - match_state.towers_alive(1, lane, true)) var blue_owner: String = match_state.players[match_state.slot_of(0, lane)].name var red_owner: String = match_state.players[match_state.slot_of(1, lane)].name button.text = "%s Blau %s Rot %s\n%s · %s" % [LuneMap.LANE_NAMES[lane].to_upper(), own, rival, blue_owner, red_owner] var team_lines: PackedStringArray = [] for team: int in range(2): var names: PackedStringArray = [] for slot: int in range(team * 3, team * 3 + 3): names.append(match_state.players[slot].name) team_lines.append("%s: %s · Basistürme %d/2" % [TEAM_NAMES[team], ", ".join(names), int(match_state.building_alive_now("%s_base_1" % ("blau" if team == 0 else "rot"))) + int(match_state.building_alive_now("%s_base_2" % ("blau" if team == 0 else "rot")))]) _team_list.text = "\n".join(team_lines) _plan_panel.visible = planning _combat_panel.visible = combat if planning: _refresh_plan(player) elif combat: var lines: PackedStringArray = ["KAMPF LÄUFT · automatisch"] for lane: String in LuneMap.LANES: var counts: Array[int] = [0, 0] for unit: LuneUnit in match_state.combat.units: if unit.alive and unit.is_mobile() and unit.lane == lane: counts[unit.team] += 1 lines.append("%s Blau %d · Rot %d Einheiten" % [LuneMap.LANE_NAMES[lane], counts[0], counts[1]]) lines.append("Leertaste/P: Pause · Tempo 1×/2×/4×") _combat_info.text = "\n".join(lines) if combat: _report.text = "Welle %d läuft. Armeen und Monsterwellen rücken über alle drei Lanes vor; Türme fallen in fester Reihenfolge." % match_state.round_number elif planning and match_state.last_settlement.is_empty(): _report.text = "Planung: Kaufe Truppen für deine Lane, teile sie auf Top, Mid oder Bottom ein und drücke Bereit." if not match_state.last_settlement.is_empty() and planning: var s: Dictionary = match_state.last_settlement var lost: PackedStringArray = [] for id: String in s.buildings_lost: lost.append(_building_name(id)) _report.text = "Welle %d abgeschlossen (%s, %ds) · dein Ertrag +%d Gold (Kills %d, Einkommen %d)%s" % [s.round, _reason(s.end_reason), int(s.combat_time), int(s.gains[human_slot]) + int(s.income), int(s.gains[human_slot]), int(s.income), ("\nGefallen: " + ", ".join(lost)) if not lost.is_empty() else ""] _over.visible = match_state.phase == LuneMatch.Phase.OVER if _over.visible: var own_team: int = int(player.team) _over_title.text = "UNENTSCHIEDEN" if match_state.winner == LuneMatch.DRAW else ("SIEG" if match_state.winner == own_team else "NIEDERLAGE") _over_body.text = "%s\nNach %d Wellen." % [match_state.end_note, match_state.round_number] minimap.match_state = match_state minimap.queue_redraw() func _grotto_text() -> String: var claim: Dictionary = match_state.boss.claim if not claim.is_empty(): var team: String = TEAM_NAMES[int(claim.team)] return "BOSSGROTTE · von %s erobert (Runde %d)\nDer Boss greift in Runde %d für %s über %s an · Grotte wieder ab Runde %d" % [team, int(claim.won_round), int(claim.deploy_round), team, LuneMap.LANE_NAMES[claim.lane], int(match_state.boss.next_available_round)] if match_state.grotto_available(): return "BOSSGROTTE · verfügbar\nEinheiten mit „G“ entsenden; sie fehlen ihrer Lane die ganze Kampfphase." return "BOSSGROTTE · gesperrt bis Runde %d" % int(match_state.boss.next_available_round) func _reason(reason: String) -> String: return {"cleared": "alle Einheiten gefallen", "timeout": "Zeitfenster abgelaufen", "core": "Ancient zerstört"}.get(reason, reason) func _building_name(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 "Basisturm %s %s" % [team, parts[2]] return "%s %s-%s" % [team, LuneMap.LANE_NAMES[parts[1]], "Außenturm" if parts[2] == "outer" else "Innenturm"] func _refresh_plan(player: Dictionary) -> void: var draft: Dictionary = player.draft var ids: Array[String] = match_state.base_ids(player.bund) if _shop_buttons.is_empty() or not _shop_buttons.has(ids[0]): for child: Node in _shop.get_children(): child.free() _shop_buttons.clear() for index: int in range(ids.size()): var id: String = ids[index] var data: Dictionary = DataCatalog.get_unit(id) var button := Button.new() button.icon = AssetLibrary.icon(id) button.expand_icon = true button.custom_minimum_size = Vector2(76, 96) button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER button.vertical_icon_alignment = VERTICAL_ALIGNMENT_TOP button.text = "%d G" % int(data.cost) button.focus_mode = Control.FOCUS_ALL button.tooltip_text = "%s [%d]\n%s · %d HP · %d Schaden/s · %.1f m\n%s" % [data.display_name, index + 1, ROLE_NAMES[match_state.role_of(id)], data.max_hp, data.damage, data.attack_range, data.ability_description] button.pressed.connect(func() -> void: command_requested.emit({"type": "buy", "type_id": id})) _shop.add_child(button) _shop_buttons[id] = button for id: String in _shop_buttons: var cost: int = int(DataCatalog.get_unit(id).cost) (_shop_buttons[id] as Button).disabled = int(draft.gold) < cost or (draft.roster as Array).size() >= match_state.balance.supply_cap _undo.disabled = (player.undo as Array).is_empty() _starter.disabled = not (draft.roster as Array).is_empty() _ready_button.disabled = bool(player.ready) _ready_button.text = "BEREIT · KAMPF ›" if not player.ready else "WARTE AUF ANDERE …" var signature: String = JSON.stringify(draft) if signature == _roster_signature: return _roster_signature = signature for child: Node in _roster.get_children(): child.free() _roster_title.text = "DEINE ARMEE · %d / %d" % [(draft.roster as Array).size(), match_state.balance.supply_cap] if (draft.roster as Array).is_empty(): _label("Noch keine Truppen. Kaufe oben oder nutze die Startaufstellung.", "MutedLabel", _roster, 15).autowrap_mode = TextServer.AUTOWRAP_WORD_SMART for entry: Dictionary in draft.roster: var data: Dictionary = DataCatalog.get_unit(entry.type_id) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) _roster.add_child(row) var icon := TextureRect.new() icon.texture = AssetLibrary.icon(entry.type_id) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.custom_minimum_size = Vector2(40, 40) row.add_child(icon) var name_label := _label(str(data.display_name), "Label", row, 15) name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL name_label.clip_text = true for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]: var lane_button := Button.new() var grotto: bool = lane == LuneMatch.GROTTO lane_button.text = "G" if grotto else LuneMap.LANE_NAMES[lane].left(1) lane_button.toggle_mode = true lane_button.button_pressed = entry.lane == lane lane_button.theme_type_variation = "ChoiceButton" lane_button.custom_minimum_size = Vector2(34, 34) lane_button.disabled = grotto and not match_state.grotto_available() lane_button.tooltip_text = "In die Bossgrotte entsenden (fehlt der Lane die ganze Kampfphase)" if grotto else "Auf %s einteilen" % LuneMap.LANE_NAMES[lane] var uid: String = str(entry.uid) lane_button.pressed.connect(func() -> void: command_requested.emit({"type": "assign", "uid": uid, "lane": lane})) row.add_child(lane_button) var upgrade_id: String = str(data.get("upgrade_id", "")) var uid_value: String = str(entry.uid) var up := Button.new() up.text = "↑ %d" % int(DataCatalog.get_unit(upgrade_id).cost) if not upgrade_id.is_empty() else "max" up.disabled = upgrade_id.is_empty() or int(draft.gold) < int(DataCatalog.get_unit(upgrade_id).get("cost", 0)) up.tooltip_text = ("Aufwerten zu %s" % DataCatalog.get_unit(upgrade_id).display_name) if not upgrade_id.is_empty() else "Vollständig aufgewertet" up.pressed.connect(func() -> void: command_requested.emit({"type": "upgrade", "uid": uid_value})) row.add_child(up) var sell := Button.new() var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * match_state.balance.sell_refund) sell.text = "×" sell.tooltip_text = "Zurückgeben für %d Gold" % refund sell.pressed.connect(func() -> void: command_requested.emit({"type": "sell", "uid": uid_value})) row.add_child(sell) func set_speed_label(value: int, paused: bool) -> void: _speed.text = "%d×" % value _pause.text = "Weiter · P" if paused else "Pause · P"