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>
1058 lines
45 KiB
GDScript
1058 lines
45 KiB
GDScript
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"}
|