extends Control ## Champions of Luneraria in-match HUD (G3). Presentation only: it reads the match through ## LuneHudModel (the privacy filter) and sends the human player's commands through LuneGame; ## no UI state creates ownership or changes a rule. ## ## Layout in logical pixels (1706×960 at 16:9, UI scale applied on top): ## top match status: own Ancient, WELLE n and phase, enemy Ancient; options and menu ## left lanes (threat, towers, players, announced dangers), own team, event feed ## bottom gold, unit cards or attack champions, own army, hero ## right context only: boss grotto, next wave, the selected unit ## corner Bereit (planning) or pause and tempo (combat) above the minimap ## Updates follow changes: every component compares a signature and redraws only when its ## data changed; the HUD polls at 10 Hz in planning and 5 Hz in combat, the minimap at 10 Hz. 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 camera_requested(target: String) signal menu_requested signal rematch_requested signal settings_requested const Minimap = preload("res://scripts/luneraria/ui/lune_minimap.gd") const MARGIN: float = 12.0 const LEFT_WIDTH: float = 312.0 const RIGHT_WIDTH: float = 322.0 const MAP_EDGE: float = 224.0 const READY_HEIGHT: float = 84.0 const READY_COMBAT: float = 54.0 const BAR_PLANNING: float = 176.0 const BAR_COMBAT: float = 128.0 ## Smallest logical area the layout is built for; the UI scale never shrinks below it. const MIN_AREA := Vector2(1560, 880) const LANE_KEYS: Dictionary = {"top": "F2", "mid": "F3", "bot": "F4"} const PLANNING_RATE: float = .1 const COMBAT_RATE: float = .2 const MAP_RATE: float = .1 const FEED_RATE: float = .25 var match_state: LuneMatch var human_slot: int = 1 ## Optional LuneSession (network display only: seats, connection). var session: Node var model: LuneHudModel var minimap: Control var tooltip: LuneTooltip var ui_scale: float = 1.0 var hero_card: LuneHeroCard var hero_detail: LuneHeroDetail var champion_panel: LuneChampionPanel var boss_card: LuneBossIndicator var next_wave: LuneNextWave var unit_detail: LuneUnitDetail var feed: LuneEventFeed var lanes: Dictionary = {} var selected_uid: String = "" ## Counters for tests and the performance log. var syncs: int = 0 var sync_usec: float = 0.0 var _top: PanelContainer var _ancients: Array[Dictionary] = [] var _wave: Label var _phase_badge: LuneStatusBadge var _system: HBoxContainer var _left: VBoxContainer var _team: LuneTeamPanel var _right: VBoxContainer var _bottom: PanelContainer var _gold: LuneResourceDisplay var _undo: Button var _starter: Button var _purchase: VBoxContainer var _purchase_tabs: LuneTabBar var _troop_row: HBoxContainer var _shop_buttons: Dictionary = {} var _army: VBoxContainer var _army_title: Label var _army_lanes: Label var _strip: HFlowContainer var _chips: Dictionary = {} var _chip_signature: String = "@" var _ready_box: PanelContainer var _ready_button: Button var _ready_note: Label var _ready_check: LuneGlyph var _combat_row: HBoxContainer var _clock: Label var _auto: Label ## Bereit ignores clicks and Space for a moment after a combat ended (stale clicks). var _ready_block_until: int = 0 var _pause: Button var _speed: Button var _map_panel: PanelContainer var _banners: VBoxContainer var _banner_signature: String = "@" var _status: PanelContainer var _report: Label var _toast: Label var _toast_left: float = 0.0 var _modal: LuneModalPanel var _over: Control var _over_title: Label var _over_body: Label var _tick: float = 0.0 var _map_tick: float = 0.0 var _feed_tick: float = 0.0 var _dirty: bool = true var _last_phase: int = -1 var _last_round: int = -1 var _layout_phase: int = -1 var _known_alive: Dictionary = {} var _ancient_level: Array[int] = [0, 0] var _boss_seen: Dictionary = {} var _hero_level: int = 0 # ---------------------------------------------------------------- build func _ready() -> void: theme = LuneUiTheme.shared() mouse_filter = Control.MOUSE_FILTER_IGNORE model = LuneHudModel.new(match_state, human_slot, session) _build_top() _build_system() _build_left() _build_right() _build_bottom() _build_ready() _build_map() _build_banner() _build_status() _build_modal() _build_over() tooltip = LuneTooltip.new() add_child(tooltip) # Mouse wheel over any HUD panel must not zoom the world (Godot passes scroll events on by default). for node: Node in find_children("*", "Control", true, false): var control := node as Control if control.mouse_filter == Control.MOUSE_FILTER_STOP: control.mouse_force_pass_scroll_events = false get_viewport().size_changed.connect(_fit) Preferences.changed.connect(_fit) _fit() _remember_buildings() _hero_level = _own_hero_level() func _panel(variation: String = "HudPanel") -> PanelContainer: var panel := PanelContainer.new() panel.theme_type_variation = variation panel.mouse_filter = Control.MOUSE_FILTER_STOP add_child(panel) return panel func _box(vertical: bool, separation: int, parent: Node) -> BoxContainer: var box: BoxContainer = VBoxContainer.new() if vertical else HBoxContainer.new() box.add_theme_constant_override("separation", separation) box.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(box) return box func _spacer(parent: Node) -> Control: var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(spacer) return spacer func _build_top() -> void: _top = _panel() var row: HBoxContainer = _box(false, 20, _top) for side: int in range(2): var block: Dictionary = _ancient_block(side == 0) _ancients.append(block) if side == 0: row.add_child(block.box) var center: VBoxContainer = _box(true, 2, row) center.custom_minimum_size.x = 190 center.alignment = BoxContainer.ALIGNMENT_CENTER _wave = LuneUiTheme.label("WELLE 1", "HudWave", center) _wave.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _phase_badge = LuneStatusBadge.new() _phase_badge.size_flags_horizontal = Control.SIZE_SHRINK_CENTER center.add_child(_phase_badge) LuneTooltip.attach(_top, _wave_tip) else: row.add_child(block.box) _top.minimum_size_changed.connect(_layout) ## Ancient block: team emblem, name, HP bar and value, the two base towers, critical badge. func _ancient_block(own: bool) -> Dictionary: var box := HBoxContainer.new() box.add_theme_constant_override("separation", 10) box.mouse_filter = Control.MOUSE_FILTER_IGNORE box.custom_minimum_size.x = 300 var emblem := LuneGlyph.make("emblem_0", LuneUiTheme.TEAM[0], 36.0) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 3) column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.mouse_filter = Control.MOUSE_FILTER_IGNORE if own: box.add_child(emblem) box.add_child(column) else: box.add_child(column) box.add_child(emblem) var head := HBoxContainer.new() head.add_theme_constant_override("separation", 6) head.mouse_filter = Control.MOUSE_FILTER_IGNORE column.add_child(head) var caption := LuneUiTheme.label("", "HudCaps") var badge := LuneStatusBadge.new() badge.visible = false if own: head.add_child(caption) _spacer(head) head.add_child(badge) else: head.add_child(badge) _spacer(head) head.add_child(caption) var bar := LuneHpBar.new() bar.custom_minimum_size = Vector2(240, 10) bar.ticks = 4 column.add_child(bar) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 5) row.mouse_filter = Control.MOUSE_FILTER_IGNORE column.add_child(row) var value := LuneUiTheme.label("", "HudValue") var base_label := LuneUiTheme.label("BASIS", "HudMuted") var towers: Array[LuneTowerIndicator] = [] for index: int in range(2): var tower := LuneTowerIndicator.new() tower.custom_minimum_size = Vector2(18, 22) tower.tier = "base" towers.append(tower) if own: row.add_child(value) _spacer(row) row.add_child(base_label) for tower: LuneTowerIndicator in towers: row.add_child(tower) else: for tower: LuneTowerIndicator in towers: row.add_child(tower) row.add_child(base_label) _spacer(row) row.add_child(value) var block: Dictionary = {"box": box, "emblem": emblem, "caption": caption, "badge": badge, "bar": bar, "value": value, "towers": towers, "pulse": 0.0, "own": own, "data": {}} LuneTooltip.attach(box, _ancient_tip.bind(block)) for index: int in range(2): LuneTooltip.attach(towers[index], _base_tip.bind(block, index)) return block func _build_system() -> void: _system = HBoxContainer.new() _system.add_theme_constant_override("separation", 6) _system.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_system) # Pause and tempo live up here, far from Bereit: a late click at the end of a combat must # never land on Bereit (found in the G3 playtest). _pause = _icon_button("pause", "P", _system) _pause.visible = false _pause.pressed.connect(func() -> void: pause_requested.emit()) LuneTooltip.attach(_pause, func() -> Dictionary: return {"title": "Pause", "text": "Hält die Simulation an (offline). Im Netzspiel bestimmt der Host.", "note": "P oder Leertaste"}) _speed = LuneUiTheme.button("1×", "HudIconButton", _system) _speed.custom_minimum_size = Vector2(46, 36) _speed.visible = false _speed.pressed.connect(func() -> void: speed_requested.emit(0)) LuneTooltip.attach(_speed, func() -> Dictionary: return {"title": "Tempo", "text": "Wechselt 1× · 2× · 4×; die Regeln bleiben gleich."}) var options := _icon_button("gear", "F1", _system) options.pressed.connect(func() -> void: settings_requested.emit()) LuneTooltip.attach(options, func() -> Dictionary: return {"title": "Optionen", "text": "Lautstärke, Vollbild, Bewegung reduzieren, UI-Größe und alle Tasten.", "note": "F1"}) var menu := _icon_button("menu", "Menü", _system) menu.pressed.connect(func() -> void: menu_requested.emit()) LuneTooltip.attach(menu, func() -> Dictionary: return {"title": "Hauptmenü", "text": "Verlässt die Partie."}) _system.minimum_size_changed.connect(_layout) func _icon_button(glyph: String, text_value: String, parent: Node) -> Button: var button := LuneUiTheme.button("", "HudIconButton", parent) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 5) row.mouse_filter = Control.MOUSE_FILTER_IGNORE row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) row.alignment = BoxContainer.ALIGNMENT_CENTER button.add_child(row) LuneGlyph.make(glyph, LuneUiTheme.TEXT, 15.0, row) var label := LuneUiTheme.label(text_value, "HudText", row) button.custom_minimum_size = Vector2(maxf(40.0, 30.0 + text_value.length() * 8.5), 36) button.set_meta("label", label) return button func _build_left() -> void: _left = _box(true, 8, self) as VBoxContainer var lane_panel := PanelContainer.new() lane_panel.theme_type_variation = "HudPanel" _left.add_child(lane_panel) var column: VBoxContainer = _box(true, 5, lane_panel) var head: HBoxContainer = _box(false, 6, column) LuneUiTheme.label("LANES", "HudCaps", head) _spacer(head) LuneUiTheme.label("Klick oder F2–F4", "HudMuted", head) for lane: String in LuneMap.LANES: var indicator := LuneLaneIndicator.new(lane, LANE_KEYS[lane]) column.add_child(indicator) indicator.pressed.connect(func() -> void: lane_focus_requested.emit(lane)) lanes[lane] = indicator var team_panel := PanelContainer.new() team_panel.theme_type_variation = "HudPanel" _left.add_child(team_panel) _team = LuneTeamPanel.new() team_panel.add_child(_team) feed = LuneEventFeed.new() _left.add_child(feed) feed.focus_requested.connect(_focus_target) func _build_right() -> void: _right = _box(true, 8, self) as VBoxContainer boss_card = LuneBossIndicator.new() _right.add_child(boss_card) boss_card.focus_requested.connect(func() -> void: camera_requested.emit("grotto")) boss_card.lane_chosen.connect(func(lane: String) -> void: command_requested.emit({"type": "boss_lane", "lane": lane})) next_wave = LuneNextWave.new() _right.add_child(next_wave) unit_detail = LuneUnitDetail.new() unit_detail.visible = false _right.add_child(unit_detail) unit_detail.command_requested.connect(func(command: Dictionary) -> void: command_requested.emit(command)) unit_detail.closed.connect(clear_selection) func _build_bottom() -> void: _bottom = _panel() var row: HBoxContainer = _box(false, 14, _bottom) # Gold and undo. var gold_column: VBoxContainer = _box(true, 4, row) gold_column.custom_minimum_size.x = 150 LuneUiTheme.label("GOLD", "HudCaps", gold_column) _gold = LuneResourceDisplay.new() gold_column.add_child(_gold) LuneTooltip.attach(_gold, _gold_tip) _undo = LuneUiTheme.button("Rückgängig", "HudButton", gold_column) _undo.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN _undo.pressed.connect(func() -> void: undo_requested.emit()) LuneTooltip.attach(_undo, func() -> Dictionary: return {"title": "Rückgängig", "text": "Nimmt den letzten Befehl dieser Planung zurück (Kauf, Einsatz, Item, Buchung)."}) # Purchases: troops (1–5) or attack champions. _purchase = _box(true, 4, row) as VBoxContainer _purchase_tabs = LuneTabBar.new() _purchase.add_child(_purchase_tabs) _purchase_tabs.add_tab("TRUPPEN · 1–5", "Dauerhafte Truppen", "Bleiben in deiner Armee, kämpfen jede Welle, lassen sich aufwerten.") _purchase_tabs.add_tab("ANGRIFFSCHAMPIONS", "Angriffschampions", "Einmalige Söldner: jetzt kaufen, eine Welle später auf der Ziel-Lane angreifen, danach weg.") _purchase_tabs.tab_changed.connect(_show_purchase) _troop_row = _box(false, 6, _purchase) as HBoxContainer for index: int in range(5): var card := LuneUnitCard.new() _troop_row.add_child(card) champion_panel = LuneChampionPanel.new() champion_panel.visible = false _purchase.add_child(champion_panel) champion_panel.command_requested.connect(func(command: Dictionary) -> void: command_requested.emit(command)) # Own army, ordered by lane. _army = _box(true, 4, row) as VBoxContainer _army.size_flags_horizontal = Control.SIZE_EXPAND_FILL _army.resized.connect(mark_dirty) var head: HBoxContainer = _box(false, 8, _army) _army_title = LuneUiTheme.label("ARMEE", "HudCaps", head) _army_lanes = LuneUiTheme.label("", "HudMuted", head) _army_lanes.size_flags_horizontal = Control.SIZE_EXPAND_FILL _army_lanes.clip_text = true _strip = HFlowContainer.new() _strip.add_theme_constant_override("h_separation", 4) _strip.add_theme_constant_override("v_separation", 4) _strip.mouse_filter = Control.MOUSE_FILTER_IGNORE _army.add_child(_strip) _starter = LuneUiTheme.button("Startaufstellung", "HudButton", _army) _starter.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN _starter.pressed.connect(func() -> void: starter_requested.emit()) LuneTooltip.attach(_starter, _starter_tip) hero_card = LuneHeroCard.new() row.add_child(hero_card) hero_card.pressed.connect(func() -> void: open_hero()) func _build_ready() -> void: _ready_box = _panel("HudPanelTight") var column: VBoxContainer = _box(true, 2, _ready_box) _ready_button = LuneUiTheme.button("BEREIT", "PrimaryButton", column) _ready_button.custom_minimum_size.y = 44 _ready_button.pressed.connect(func() -> void: if not ready_blocked(): ready_requested.emit()) LuneTooltip.attach(_ready_button, _ready_tip) _ready_check = LuneGlyph.make("check", SaumwachtTheme.INK, 22.0, _ready_button) _ready_check.set_anchors_and_offsets_preset(Control.PRESET_CENTER_LEFT) _ready_check.position.x = 16 _ready_check.visible = false _ready_note = LuneUiTheme.label("Leertaste", "HudMuted", column) _ready_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _combat_row = _box(false, 8, column) as HBoxContainer _combat_row.visible = false _combat_row.alignment = BoxContainer.ALIGNMENT_CENTER LuneGlyph.make("melee", LuneUiTheme.DANGER.lightened(.15), 16.0, _combat_row) _clock = LuneUiTheme.label("", "HudValue", _combat_row) _auto = LuneUiTheme.label("", "HudMuted", _combat_row) _auto.clip_text = true _auto.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS func _build_map() -> void: _map_panel = _panel("HudPanelTight") minimap = Minimap.new() minimap.match_state = match_state _map_panel.add_child(minimap) minimap.focus_requested.connect(func(point: Vector3) -> void: map_focus_requested.emit(point)) LuneTooltip.attach(minimap, func() -> Dictionary: return {"title": "Minimap", "text": "Klicken oder ziehen bewegt die Kamera. Runde Marker: Blau · eckige Marker: Rot. Krone: Boss, Pfeil: Angriffschampion, Goldring: Held.", "note": "Die Minimap löst nie Käufe oder Befehle aus."}) func _build_banner() -> void: _banners = _box(true, 6, self) as VBoxContainer func _build_status() -> void: _status = _panel("HudPanelTight") var column: VBoxContainer = _box(true, 0, _status) _report = LuneUiTheme.label("", "HudText", column) _report.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _toast = LuneUiTheme.label("", "HudMuted", column) _toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status.visible = false _status.minimum_size_changed.connect(_layout) func _build_modal() -> void: _modal = LuneModalPanel.new(Vector2(1040, 660)) add_child(_modal) hero_detail = LuneHeroDetail.new() _modal.body.add_child(hero_detail) hero_detail.command_requested.connect(func(command: Dictionary) -> void: command_requested.emit(command)) func _build_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 = _box(true, 14, panel) _over_title = LuneUiTheme.label("", "TitleLabel", column, 60) _over_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _over_body = LuneUiTheme.label("", "BodyLabel", column) _over_body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _over_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART var row: HBoxContainer = _box(false, 12, column) row.alignment = BoxContainer.ALIGNMENT_CENTER LuneUiTheme.button("NEUE PARTIE", "PrimaryButton", row).pressed.connect(func() -> void: rematch_requested.emit()) LuneUiTheme.button("Menü", "Button", row).pressed.connect(func() -> void: menu_requested.emit()) # ---------------------------------------------------------------- layout ## UI scale: the preference, capped so the layout always has at least MIN_AREA logical pixels. func _fit() -> void: var area: Vector2 = get_viewport().get_visible_rect().size var wanted: float = clampf(float(Preferences.get("ui_scale") if Preferences.get("ui_scale") != null else 1.0), .75, 1.5) var fits: float = minf(area.x / MIN_AREA.x, area.y / MIN_AREA.y) ui_scale = clampf(minf(wanted, fits), .6, 1.5) position = Vector2.ZERO scale = Vector2(ui_scale, ui_scale) size = area / ui_scale _layout() func _layout() -> void: if _over == null: return var w: float = size.x var h: float = size.y _top.size = _top.get_combined_minimum_size() _top.position = Vector2(floorf((w - _top.size.x) * .5), MARGIN) _system.size = _system.get_combined_minimum_size() _system.position = Vector2(w - MARGIN - _system.size.x, MARGIN) _left.position = Vector2(MARGIN, MARGIN) _left.size = Vector2(LEFT_WIDTH, 0) var box: float = MAP_EDGE + 16.0 _map_panel.position = Vector2(w - MARGIN - box, h - MARGIN - box) _map_panel.size = Vector2(box, box) var planning: bool = match_state == null or match_state.phase == LuneMatch.Phase.PLANNING var ready_height: float = READY_HEIGHT if planning else READY_COMBAT _ready_box.position = Vector2(_map_panel.position.x, _map_panel.position.y - 8.0 - ready_height) _ready_box.size = Vector2(box, ready_height) var bar: float = maxf(BAR_PLANNING if planning else BAR_COMBAT, _bottom.get_combined_minimum_size().y) _bottom.position = Vector2(MARGIN, h - MARGIN - bar) _bottom.size = Vector2(_map_panel.position.x - 10.0 - MARGIN, bar) _right.position = Vector2(w - MARGIN - RIGHT_WIDTH, MARGIN + _system.size.y + 8.0) _right.size = Vector2(RIGHT_WIDTH, 0) _banners.size = Vector2(minf(700.0, w - 2.0 * (LEFT_WIDTH + 2.0 * MARGIN)), 0) _banners.position = Vector2(floorf((w - _banners.size.x) * .5), _top.position.y + _top.size.y + 8.0) _status.size = _status.get_combined_minimum_size() _status.position = Vector2(floorf(_bottom.position.x + (_bottom.size.x - _status.size.x) * .5), _bottom.position.y - 8.0 - _status.size.y) _layout_phase = match_state.phase if match_state != null else -1 ## Screen rectangles of the main regions (layout tests). func regions() -> Dictionary: var result: Dictionary = {"top": _top, "system": _system, "left": _left, "right": _right, "bottom": _bottom, "ready": _ready_box, "minimap": _map_panel} var rects: Dictionary = {} for key: String in result: var control: Control = result[key] if control.visible: rects[key] = control.get_global_rect() return rects # ---------------------------------------------------------------- per frame func _process(delta: float) -> void: if match_state == null: return _toast_left -= delta if _toast_left <= 0.0 and not _toast.text.is_empty(): _toast.text = "" _status_visibility() for block: Dictionary in _ancients: if float(block.pulse) > 0.0: block.pulse = maxf(0.0, float(block.pulse) - delta) (block.bar as Control).modulate = Color(1, 1, 1, .45 + .55 * absf(cos(float(block.pulse) * PI * 2.0))) if float(block.pulse) <= 0.0: (block.bar as Control).modulate = Color.WHITE _tick += delta _map_tick += delta _feed_tick += delta var fighting: bool = match_state.phase == LuneMatch.Phase.COMBAT if fighting and _map_tick >= MAP_RATE: _map_tick = 0.0 minimap.refresh(match_state.announcements()) if _feed_tick >= FEED_RATE: _track_events() _feed_tick = 0.0 if _dirty or match_state.phase != _last_phase or _tick >= (COMBAT_RATE if fighting else PLANNING_RATE): var started: int = Time.get_ticks_usec() _sync(_tick) _tick = 0.0 _dirty = false syncs += 1 sync_usec = lerpf(sync_usec, float(Time.get_ticks_usec() - started), .2) func mark_dirty() -> void: _dirty = true ## True for one second after a combat ended: Bereit (click or Space) is ignored meanwhile. func ready_blocked() -> bool: return Time.get_ticks_msec() < _ready_block_until func _sync(delta: float) -> void: var phase: int = match_state.phase if phase != _last_phase: _phase_changed(_last_phase, phase) _last_phase = phase if _layout_phase != phase: _layout() var planning: bool = model.planning() var fighting: bool = model.fighting() _sync_top() var reduced: bool = Preferences.reduced_motion for lane: String in lanes: (lanes[lane] as LuneLaneIndicator).apply(model.lane(lane), model.team(), delta, reduced) _team.apply(model.members(model.team()), model.team(), planning, model.networked()) _sync_right(planning, fighting) _sync_bottom(planning, fighting) _sync_ready(planning, fighting) _sync_banners() if not fighting: minimap.viewer_team = model.team() minimap.human_slot = human_slot minimap.refresh(match_state.announcements()) if _modal.visible: hero_detail.apply(model) _modal.title.text = hero_detail.title_text(model) _over.visible = model.over() if _over.visible: var own_team: int = model.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] func _sync_top() -> void: for side: int in range(2): var team: int = model.team() if side == 0 else model.enemy() var block: Dictionary = _ancients[side] var ancient: Dictionary = model.ancient(team) block.data = ancient var tint: Color = LuneUiTheme.team_color(team) (block.emblem as LuneGlyph).glyph = "emblem_%d" % team (block.emblem as LuneGlyph).color = tint (block.caption as Label).text = ("DEIN ANCIENT · %s" if side == 0 else "GEGNER · %s") % LuneUiTheme.team_name(team).to_upper() var bar: LuneHpBar = block.bar bar.fraction = float(ancient.fraction) bar.fill = LuneHpBar.health_color(bar.fraction, tint) (block.value as Label).text = "%s / %s" % [LuneUiTheme.number(ancient.hp), LuneUiTheme.number(ancient.max_hp)] var towers: Array = block.towers for index: int in range(2): var base: Dictionary = ancient.base[index] (towers[index] as LuneTowerIndicator).team = team (towers[index] as LuneTowerIndicator).set_state(bool(base.alive), float(base.fraction), bool(base.attackable)) var level: int = 2 if bar.fraction < .3 else (1 if bar.fraction < .5 else 0) var badge: LuneStatusBadge = block.badge badge.visible = level > 0 or not ancient.alive if not ancient.alive: badge.set_state("ZERSTÖRT", LuneUiTheme.DANGER, "close", true) elif level == 2: badge.set_state("KRITISCH", LuneUiTheme.DANGER, "warning", true) elif level == 1: badge.set_state("BESCHÄDIGT", LuneUiTheme.WARN) elif bool(ancient.attackable): badge.visible = true badge.set_state("ANGREIFBAR", LuneUiTheme.WARN) _wave.text = "WELLE %d" % match_state.round_number if model.over(): _phase_badge.set_state("PARTIE BEENDET", LuneUiTheme.MUTED) elif model.fighting(): var elapsed: int = int(match_state.combat.elapsed) _phase_badge.set_state("KAMPF · %02d:%02d" % [elapsed / 60, elapsed % 60], LuneUiTheme.DANGER.lightened(.1), "melee") elif model.locked(): _phase_badge.set_state("PLANUNG · BEREIT", LuneUiTheme.GOOD, "check") else: _phase_badge.set_state("PLANUNG", LuneUiTheme.BRONZE_LIGHT, "clock") func _sync_right(planning: bool, fighting: bool) -> void: var boss: Dictionary = model.boss() boss_card.apply(boss, model.team(), match_state.round_number, planning) if boss.has("hp"): boss_card.set_boss_hp(float(boss.hp) / maxf(1.0, float(boss.max_hp))) var entry: Dictionary = _selected_entry() if entry.is_empty() and not selected_uid.is_empty(): selected_uid = "" unit_detail.visible = not entry.is_empty() boss_card.set_compact(unit_detail.visible) if unit_detail.visible: unit_detail.apply(entry, model, model.live_unit(selected_uid)) next_wave.visible = not unit_detail.visible and not model.over() if next_wave.visible: next_wave.apply(model.next_wave(), model.team(), fighting) func _selected_entry() -> Dictionary: if selected_uid.is_empty(): return {} for entry: Dictionary in model.own_roster(): if str(entry.uid) == selected_uid: return entry return {} func _sync_bottom(planning: bool, fighting: bool) -> void: _gold.set_values(model.own_gold(), model.income(), not model.over()) var roster: Array = model.own_roster() var locked: bool = model.locked() var can_act: bool = planning and not locked _purchase.visible = planning _army.visible = not planning or not champion_panel.visible _undo.visible = planning _undo.disabled = not can_act or (model.me().undo as Array).is_empty() _starter.visible = planning and roster.is_empty() _starter.disabled = not can_act if planning: var ids: Array[String] = match_state.base_ids(model.me().bund) var gold: int = model.own_gold() var full: bool = roster.size() >= model.supply_cap() if not _shop_buttons.has(ids[0]): _shop_buttons.clear() for index: int in range(ids.size()): var card: LuneUnitCard = _troop_row.get_child(index) var id: String = ids[index] var data: Dictionary = DataCatalog.get_unit(id) card.setup(id, id, str(data.display_name), model.role_of(id), int(data.cost), str(index + 1)) for connection: Dictionary in card.pressed.get_connections(): card.pressed.disconnect(connection.callable) card.pressed.connect(func() -> void: command_requested.emit({"type": "buy", "type_id": id})) if not card.has_meta("tip"): card.set_meta("tip", true) LuneTooltip.attach(card, func() -> Dictionary: return LuneStats.troop_tip(card.type_id, model.role_of(card.type_id), card.reason)) _shop_buttons[id] = card for id: String in _shop_buttons: var card: LuneUnitCard = _shop_buttons[id] var why: String = "" if locked: why = "Bereit gemeldet – die Planung ist verbindlich." elif full: why = "Armee voll (%d/%d)." % [roster.size(), model.supply_cap()] elif gold < card.cost: why = "Es fehlen %d Gold." % (card.cost - gold) card.set_available(why.is_empty(), why, locked, gold < card.cost) if champion_panel.visible: champion_panel.apply(model) _army_title.text = "ARMEE %d/%d" % [roster.size(), model.supply_cap()] var per_lane: Dictionary = {} var alive_lane: Dictionary = {} for entry: Dictionary in roster: var lane: String = str(entry.lane) per_lane[lane] = int(per_lane.get(lane, 0)) + 1 if fighting: var unit: LuneUnit = model.live_unit(str(entry.uid)) if unit != null and unit.alive: alive_lane[lane] = int(alive_lane.get(lane, 0)) + 1 var parts: PackedStringArray = [] for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]: if per_lane.has(lane): var short: String = "GROTTE" if lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[lane].to_upper() parts.append(("%s %d/%d" % [short, int(alive_lane.get(lane, 0)), int(per_lane[lane])]) if fighting else ("%s %d" % [short, int(per_lane[lane])])) _army_lanes.text = " · ".join(parts) _sync_chips(roster, fighting) var hero: Dictionary = model.own_hero() hero_card.apply(hero, model, model.live_unit(str(hero.get("uid", ""))) if not hero.is_empty() else null) func _sync_chips(roster: Array, fighting: bool) -> void: var ordered: Array = roster.duplicate() var order: Array = LuneMap.LANES + [LuneMatch.GROTTO] ordered.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: var la: int = order.find(str(a.lane)) var lb: int = order.find(str(b.lane)) return int(a.seq) < int(b.seq) if la == lb else la < lb) var edge: float = _chip_edge(ordered.size(), fighting) var signature: String = "%s|%d" % [str(ordered.map(func(e: Dictionary) -> String: return "%s:%s:%s" % [e.uid, e.type_id, e.lane])), int(edge)] if signature != _chip_signature: _chip_signature = signature for child: Node in _strip.get_children(): child.free() _chips.clear() for entry: Dictionary in ordered: _chips[str(entry.uid)] = _make_chip(entry, edge) for uid: String in _chips: var chip: LuneArmyChip = _chips[uid] chip.set_pressed_no_signal(uid == selected_uid) if fighting: var unit: LuneUnit = model.live_unit(uid) chip.fallen = unit == null or not unit.alive else: chip.fallen = false ## Largest chip edge (46 … 30 px) at which the whole army fits into the strip. func _chip_edge(count: int, fighting: bool) -> float: var width: float = _army.size.x if _army.size.x > 60.0 else 300.0 var height: float = (BAR_COMBAT if fighting else BAR_PLANNING) - 20.0 - 26.0 for edge: float in [46.0, 42.0, 38.0, 34.0, 30.0]: var per_row: int = maxi(1, floori((width + 4.0) / (edge + 4.0))) var rows: int = ceili(float(count) / float(per_row)) if float(rows) * (edge + LuneArmyChip.BAND + 4.0) <= height: return edge return 30.0 func _make_chip(entry: Dictionary, edge: float) -> LuneArmyChip: var chip := LuneArmyChip.new() _strip.add_child(chip) chip.setup(entry, edge) var uid: String = str(entry.uid) chip.pressed.connect(func() -> void: if selected_uid == uid: clear_selection() else: select_unit(uid)) var type_id: String = str(entry.type_id) var lane: String = str(entry.lane) LuneTooltip.attach(chip, func() -> Dictionary: var tip: Dictionary = LuneStats.troop_tip(type_id, model.role_of(type_id)) tip.note = "Einsatz: %s · Klick: Details, Aufwerten, Einsatz" % ("Grotte" if lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[lane]) if chip.fallen: tip.warn = "In diesem Kampf gefallen; kehrt zur nächsten Planung zurück." return tip) return chip func _sync_ready(planning: bool, fighting: bool) -> void: _ready_box.visible = not model.over() _ready_button.visible = planning _ready_note.visible = planning _combat_row.visible = fighting _pause.visible = fighting _speed.visible = fighting if planning: var locked: bool = model.locked() _ready_button.disabled = locked or ready_blocked() _ready_button.text = "BEREIT" _ready_check.visible = locked if locked: var count: Vector2i = model.ready_count() _ready_note.text = "Warte auf %d Mitspieler" % maxi(0, count.y - count.x) if count.y > count.x else "Kampf beginnt …" else: _ready_note.text = "Leertaste · startet Welle %d" % match_state.round_number if fighting: var elapsed: int = int(match_state.combat.elapsed) _clock.text = "KAMPF %02d:%02d" % [elapsed / 60, elapsed % 60] _auto.text = "Tempo %s" % _speed.text var guest: bool = model.is_guest() _pause.disabled = guest _speed.disabled = guest func _sync_banners() -> void: var lines: Array = [] var boss: Dictionary = model.boss() if str(boss.state) in ["deploy_next", "deploying"]: var own: bool = bool(boss.own) var lane: String = LuneMap.LANE_NAMES.get(str(boss.lane), "?").to_upper() var text: String if own: text = "Deine SCHLUNDKRONE greift in Welle %d auf %s an · Kopfgeld für den Gegner %s Gold" % [int(boss.deploy_round), lane, LuneUiTheme.number(boss.bounty)] else: text = "SCHLUNDKRONE (%s) greift in Welle %d auf %s an · Kopfgeld für euch %s Gold" % [LuneUiTheme.team_name(int(boss.team)), int(boss.deploy_round), lane, LuneUiTheme.number(boss.bounty)] lines.append([text, "crown", own, {"lane": str(boss.lane)}]) var core: Dictionary = model.ancient(model.team()) if bool(core.alive) and float(core.fraction) < .3: lines.append(["Dein Ancient ist kritisch: %d %% Leben" % int(round(float(core.fraction) * 100.0)), "warning", false, {"camera": "base"}]) var signature: String = str(lines) if signature == _banner_signature: return _banner_signature = signature for child: Node in _banners.get_children(): child.free() for line: Array in lines: var panel := PanelContainer.new() panel.theme_type_variation = "HudBannerOwn" if line[2] else "HudBannerDanger" panel.mouse_filter = Control.MOUSE_FILTER_STOP panel.mouse_force_pass_scroll_events = false panel.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND _banners.add_child(panel) var row: HBoxContainer = _box(false, 10, panel) row.alignment = BoxContainer.ALIGNMENT_CENTER LuneGlyph.make(line[1], LuneUiTheme.team_color(model.team()) if line[2] else LuneUiTheme.DANGER, 20.0, row) var label := LuneUiTheme.label(line[0], "HudValue", row) label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.size_flags_horizontal = Control.SIZE_EXPAND_FILL var target: Dictionary = line[3] panel.gui_input.connect(func(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: _focus_target(target)) func _status_visibility() -> void: _status.visible = not _report.text.is_empty() or not _toast.text.is_empty() _report.visible = not _report.text.is_empty() _toast.visible = not _toast.text.is_empty() # ---------------------------------------------------------------- phase changes and events func _phase_changed(previous: int, phase: int) -> void: if phase == LuneMatch.Phase.COMBAT: close_modal() _report.text = "" _status_visibility() for entry: Dictionary in match_state.announcements(): var own: bool = int(entry.team) == model.team() var lane: String = LuneMap.LANE_NAMES[str(entry.lane)].to_upper() if entry.kind == "boss": feed.push("%s – Schlundkrone (%s) greift an" % [lane, LuneUiTheme.team_name(int(entry.team))], "good" if own else "danger", "crown", {"lane": str(entry.lane)}) else: feed.push("%s – Angriffschampion %s (%s) erschienen" % [lane, LuneContent.CHAMPIONS[entry.champion_id].name, LuneUiTheme.team_name(int(entry.team))], "info" if own else "warn", "champion", {"lane": str(entry.lane)}) elif phase == LuneMatch.Phase.PLANNING and previous == LuneMatch.Phase.COMBAT: _ready_block_until = Time.get_ticks_msec() + 1000 _report_settlement() elif phase == LuneMatch.Phase.PLANNING and previous < 0 and not match_state.last_settlement.is_empty(): _report_settlement() _boss_seen.clear() _layout() func _report_settlement() -> void: var s: Dictionary = match_state.last_settlement if s.is_empty(): return var gained: int = int((s.gains as Array)[human_slot]) + int(s.income) _report.text = "Welle %d abgeschlossen (%s, %ds) · dein Ertrag +%d Gold (Kills %d, Einkommen %d)" % [int(s.round), _reason(str(s.end_reason)), int(s.combat_time), gained, int((s.gains as Array)[human_slot]), int(s.income)] _status_visibility() feed.push("Welle %d abgeschlossen · +%d Gold" % [int(s.round), gained], "info", "coin") var claim: Dictionary = match_state.boss.claim if str(s.get("boss", "")) == "won" and not claim.is_empty(): var own: bool = int(claim.team) == model.team() feed.push("Boss in Welle %d auf %s (%s)" % [int(claim.deploy_round), LuneMap.LANE_NAMES[claim.lane].to_upper(), LuneUiTheme.team_name(int(claim.team))], "good" if own else "danger", "crown", {"lane": str(claim.lane)}) var level: int = _own_hero_level() if level > _hero_level and _hero_level > 0: var hero: Dictionary = model.own_hero() feed.push("%s erreicht Stufe %d" % [LuneContent.HEROES[hero.hero_id].name, level], "good", "star", {"camera": "hero"}) _hero_level = level func _own_hero_level() -> int: var hero: Dictionary = model.own_hero() return 0 if hero.is_empty() else LuneContent.level_for(int(hero.xp)) func _remember_buildings() -> void: _known_alive.clear() for definition: Dictionary in match_state.map.buildings: _known_alive[definition.id] = model.building(definition.id).alive ## Diffs public state for the event feed: fallen buildings, Ancient thresholds, the boss. func _track_events() -> void: if model.over() and _known_alive.is_empty(): return for definition: Dictionary in match_state.map.buildings: var id: String = definition.id var alive: bool = model.building(id).alive if bool(_known_alive.get(id, alive)) and not alive: var own: bool = int(definition.team) == model.team() var place: String = "BASIS" if str(definition.lane) == "base" else LuneMap.LANE_NAMES[str(definition.lane)].to_upper() var title: String = LuneHudModel.building_title(id) feed.push("%s – %s (%s) zerstört" % [place, title, LuneUiTheme.team_name(int(definition.team))], "danger" if own else "good", "tower", {"point": definition.position}) _known_alive[id] = alive for side: int in range(2): var team: int = model.team() if side == 0 else model.enemy() var core: Dictionary = model.ancient(team) var level: int = 2 if float(core.fraction) < .3 else (1 if float(core.fraction) < .5 else 0) if level > _ancient_level[side] and bool(core.alive): if level == 2: feed.push("Dein Ancient kritisch (%d %%)" % int(round(float(core.fraction) * 100.0)) if side == 0 else "Gegnerischer Ancient kritisch (%d %%)" % int(round(float(core.fraction) * 100.0)), "danger" if side == 0 else "good", "warning", {"camera": "base" if side == 0 else "enemy_base"}) if not Preferences.reduced_motion: _ancients[side].pulse = 1.5 _ancient_level[side] = level if model.fighting(): for unit: LuneUnit in match_state.combat.units: if unit.kind != "boss": continue var key: String = "%s-%d" % [unit.zone, unit.id] var was: bool = bool(_boss_seen.get(key, unit.alive)) if was and not unit.alive: if unit.zone == "grotto": var boss: Dictionary = model.boss() if int(boss.team) >= 0: feed.push("Bossgrotte erobert – %s" % LuneUiTheme.team_name(int(boss.team)), "good" if bool(boss.own) else "warn", "crown", {"camera": "grotto"}) else: feed.push("SCHLUNDKRONE besiegt (%s)" % LuneMap.LANE_NAMES.get(unit.lane, "").to_upper(), "good" if unit.team != model.team() else "warn", "crown", {"lane": unit.lane}) _boss_seen[key] = unit.alive func _focus_target(target: Dictionary) -> void: if target.has("lane") and LuneMap.LANES.has(str(target.lane)): lane_focus_requested.emit(str(target.lane)) elif target.has("point"): var point: Vector2 = target.point map_focus_requested.emit(Vector3(point.x, 0, point.y)) elif target.has("camera"): camera_requested.emit(str(target.camera)) func _reason(reason: String) -> String: return {"cleared": "alle Einheiten gefallen", "timeout": "Zeitfenster abgelaufen", "core": "Ancient zerstört"}.get(reason, reason) # ---------------------------------------------------------------- public API func notify(message: String, error: bool = false) -> void: _toast.text = message _toast.add_theme_color_override("font_color", LuneUiTheme.DANGER if error else LuneUiTheme.GOOD) _toast_left = 5.0 _status_visibility() _dirty = true func set_speed_label(value: int, paused: bool) -> void: _speed.text = "%d×" % value var label: Label = _pause.get_meta("label") label.text = "P" var glyph: LuneGlyph = _pause.get_child(0).get_child(0) glyph.glyph = "play" if paused else "pause" ## G2 compatibility: 0 = troops, 1 = hero, 2 = attack champions. func show_page(index: int) -> void: match index: 1: open_hero() 2: close_modal() open_champions() _: close_modal() open_troops() func open_hero(tab: int = LuneHeroDetail.TAB_VALUES) -> void: hero_detail.tab = tab hero_detail.show_tab(tab) hero_detail.apply(model) _modal.open(hero_detail.title_text(model)) func open_items() -> void: open_hero(LuneHeroDetail.TAB_ITEMS) func open_talents() -> void: open_hero(LuneHeroDetail.TAB_TALENTS) func close_modal() -> void: _modal.close() func modal_open() -> bool: return _modal.visible func open_champions() -> void: _purchase_tabs.select(1) _show_purchase(1) func open_troops() -> void: _purchase_tabs.select(0) _show_purchase(0) func _show_purchase(index: int) -> void: _troop_row.visible = index == 0 champion_panel.visible = index == 1 _army.visible = index == 0 _purchase.size_flags_horizontal = Control.SIZE_EXPAND_FILL if index == 1 else Control.SIZE_FILL _dirty = true func select_unit(uid: String) -> void: selected_uid = uid _dirty = true func clear_selection() -> void: selected_uid = "" _dirty = true func public_digest() -> Dictionary: return model.public_digest() func _unhandled_key_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not selected_uid.is_empty() and not _modal.visible: clear_selection() get_viewport().set_input_as_handled() # ---------------------------------------------------------------- tooltips func _wave_tip() -> Dictionary: var wave: Dictionary = model.next_wave() return {"title": "Welle %d" % match_state.round_number, "glyph": "clock", "text": "Wellen sind unbegrenzt: Die Partie endet nur, wenn ein Ancient fällt. Monster werden jede Runde stärker, alle drei Runden kommt eines je Lane hinzu (bis 7).", "note": "Kommende Welle %d: %d Monster je Lane." % [int(wave.round), int(wave.count)]} func _ancient_tip(block: Dictionary) -> Dictionary: var ancient: Dictionary = block.data if ancient.is_empty(): return {} var team: int = int(ancient.team) var standing: int = int(ancient.base[0].alive) + int(ancient.base[1].alive) return {"title": "Ancient %s · %s" % [LuneUiTheme.team_name(team), LuneUiTheme.TEAM_EMBLEMS[team]], "glyph": "emblem_%d" % team, "glyph_color": LuneUiTheme.team_color(team), "stats": [["heart", "Leben", "%s / %s" % [LuneUiTheme.number(ancient.hp), LuneUiTheme.number(ancient.max_hp)]], ["tower", "Basistürme", "%d von 2 stehen" % standing]], "text": "Fällt ein Ancient, ist die Partie vorbei. Er ist erst angreifbar, wenn beide Basistürme (Kernwacht) gefallen sind; die fallen erst, wenn eine Lane ganz offen ist.", "note": "B: Kamera zum eigenen Ancient" if bool(block.own) else ""} func _base_tip(block: Dictionary, index: int) -> Dictionary: var ancient: Dictionary = block.data if ancient.is_empty(): return {} var base: Dictionary = ancient.base[index] return {"title": "Kernwacht %d · %s" % [index + 1, LuneUiTheme.team_name(int(base.team))], "glyph": "tower", "glyph_color": LuneUiTheme.team_color(int(base.team)), "stats": [["heart", "Leben", LuneLaneIndicator._tower_value(base)]], "text": "Basisturm vor dem Ancient. Angreifbar, sobald auf einer Lane Grenzwacht und Bastion gefallen sind." if base.alive else "Zerstört."} func _gold_tip() -> Dictionary: return {"title": "Dein Gold", "glyph": "coin", "glyph_color": LuneUiTheme.GOLD, "stats": [["coin", "Jetzt", LuneUiTheme.number(model.own_gold())], ["clock", "Einkommen", "+%s nach dieser Welle" % LuneUiTheme.number(model.income())]], "text": "Dazu kommt Gold für besiegte Monster, Champions, Gebäude und den Boss auf der eigenen Seite (Teile gehen ans Team).", "note": "Das Gold der anderen Spieler bleibt privat."} func _starter_tip() -> Dictionary: var ids: Array[String] = match_state.base_ids(model.me().bund) var cost: int = 0 var names: PackedStringArray = [] for index: int in [1, 0, 2]: cost += int(DataCatalog.get_unit(ids[index]).cost) names.append(str(DataCatalog.get_unit(ids[index]).display_name)) return {"title": "Startaufstellung · %d Gold" % cost, "text": "Kauft %s für deine Lane." % ", ".join(names)} func _ready_tip() -> Dictionary: if model.locked(): return {"title": "Bereit gemeldet", "text": "Deine Planung ist verbindlich. Der Kampf beginnt, sobald alle bereit sind."} return {"title": "Bereit", "text": "Übernimmt deine Planung verbindlich. Der Kampf läuft automatisch über alle drei Lanes.", "note": "Leertaste"}