saumwacht-godot/scripts/luneraria/ui/lune_hud.gd
Martin Stickelbroeck 8b17563814 G1: Drei-Lane-Layout mit Höhenfeld, Grotte, Aufmarschbereichen und MOBA-Kamera
Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform,
Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt
gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt
gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der
Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera
mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet,
neue Formen als Blockout für G2/G4.

Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38,
verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch
20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:09:48 +02:00

484 lines
22 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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