saumwacht-godot/scripts/ui/hud.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

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

894 lines
38 KiB
GDScript
Raw Permalink 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
## Presentation only: emits player commands and reads snapshots.
signal command_requested(command: Dictionary)
signal build_selected(type_id: String)
signal move_requested
signal base_requested
signal focus_requested
signal menu_requested
signal new_match_requested
signal starter_requested
signal cancel_requested
signal ready_requested
const INK := Color("#0b1016")
const PANEL := Color("#101a22")
const TEXT := Color("#f2f6f4")
const MUTED := Color("#9bb0aa")
const GOLD := Color("#f0d090")
const GOLD_DEEP := Color("#c9a05a")
const TEAL := Color("#7fe8c8")
const TEAL_DEEP := Color("#2a8f7a")
const CORAL := Color("#f0a888")
const VIOLET := Color("#b8a0e8")
var selected_uid: int = -1
var build_type: String = ""
var viewed_team: int = 0
var model_inspector: ColorRect
var _inspect_model: Button
var settings_panel: ColorRect
var _settings_paused: bool = false
var _settings_focus: Control
var _volume_slider: HSlider
var _mute_check: CheckButton
var _follow_check: CheckButton
var _fullscreen_check: CheckButton
var _camera_status: Label
var _boss_bar: ProgressBar
var _boss_label: Label
var _phase: Label
var _timer: Label
var _gold: Label
var _sparks: Label
var _income: Label
var _workers: Label
var _core_labels: Array = []
var _core_bars: Array = []
var _wave_title: Label
var _wave_body: Label
var _selection_icon: TextureRect
var _selection_types: HBoxContainer
var _selection_title: Label
var _selection_body: Label
var _upgrade: Button
var _sell: Button
var _move: Button
var _priority: OptionButton
var _worker_button: Button
var _ready_button: Button
var _pause_button: Button
var _speed_button: Button
var _front_button: Button
var _undo: Button
var _starter_button: Button
var _cards: HBoxContainer
var _shop_faction: String = ""
var _faction_label: Label
var _worker_info: Label
var _step_hint: Label
var _faction_pickers: Array = []
var _timed_picker: CheckButton
var _shop_buttons: Dictionary = {}
var _send_picker: OptionButton
var _send_button: Button
var _cancel_send: Button
var _send_info: Label
var _toast: Label
var _toast_left: float = 0.0
## Phase 20A: the title menu and match setup live in MainMenu. These names remain the
## entry points used by tools and tests; they point at OptionButton-compatible parts.
var main_menu: MainMenu
var _overlay: Control
var _overlay_title: Label
var _overlay_action: Button
var _ai_picker: ChoiceGroup
var _mode_picker: ChoiceGroup
var _reduced_check: CheckButton
var _summary: Label
var _queue_scroll: ScrollContainer
var _queue_row: HBoxContainer
var _queue_signature: String = "@"
var _refresh_left: float = 0.0
var _shop_panel: PanelContainer
var _wave_progress: ProgressBar
var _context_panel: PanelContainer
var _context_column: VBoxContainer
var _game_panels: Array[Control] = []
var _logo_label: Label
const SEND_SHORT_NAMES: Dictionary = {
"s01": "Späher", "s02": "Rost", "s03": "Dunst", "s04": "Keil", "s05": "Riss"}
const MATRIX_TOOLTIP: String = "Schadensmatrix (Zeile → Spalte):\nKlinge: 130 % gegen Leicht · 75 % gegen Schwer\nDurchschlag: 130 % gegen Schwer · 75 % gegen Ätherisch\nImpuls: 130 % gegen Ätherisch · 75 % gegen Leicht"
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
theme = _make_theme()
_make_header()
_make_sidebar()
_make_footer()
_make_overlay()
_make_settings()
model_inspector = preload("res://scripts/ui/model_inspector.gd").new()
add_child(model_inspector)
GameState.phase_changed.connect(_phase_changed)
WaveManager.wave_settled.connect(_show_summary)
_phase_changed(GameState.phase)
func _style(color: Color, border: Color = Color.TRANSPARENT, width: int = 0) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = color
box.border_color = border
box.set_border_width_all(width)
box.set_corner_radius_all(14)
box.anti_aliasing = true
box.content_margin_left = 16
box.content_margin_right = 16
box.content_margin_top = 10
box.content_margin_bottom = 10
return box
func _glass(bg: Color = Color(.05, .09, .12, .88), border: Color = Color(.45, .85, .75, .22)) -> StyleBoxFlat:
var box := _style(bg, border, 1)
box.set_corner_radius_all(16)
box.shadow_color = Color(0, 0, 0, .45)
box.shadow_size = 14
box.shadow_offset = Vector2(0, 6)
box.content_margin_left = 18
box.content_margin_right = 18
box.content_margin_top = 12
box.content_margin_bottom = 12
return box
func _gold_button() -> StyleBoxFlat:
var box := _style(Color("#e8c57a"), Color("#ffe2a8"), 1)
box.set_corner_radius_all(12)
box.shadow_color = Color(.95, .75, .35, .35)
box.shadow_size = 16
box.shadow_offset = Vector2(0, 2)
box.content_margin_top = 12
box.content_margin_bottom = 12
return box
func _make_theme() -> Theme:
var result := Theme.new()
result.default_font_size = 15
var modern_font := FontVariation.new()
modern_font.base_font = preload("res://assets/fonts/Cantarell-VF.otf")
modern_font.variation_opentype = {"wght": 550.0}
result.default_font = modern_font
result.set_color("font_color", "Label", TEXT)
result.set_color("font_color", "Button", TEXT)
result.set_color("font_hover_color", "Button", Color.WHITE)
result.set_color("font_disabled_color", "Button", Color("#6a807c"))
result.set_stylebox("normal", "Button", _style(Color("#1c2c36"), Color("#3d5a58"), 1))
result.set_stylebox("hover", "Button", _style(Color("#284852"), TEAL, 1))
result.set_stylebox("pressed", "Button", _style(Color("#1f3c40"), TEAL_DEEP, 2))
result.set_stylebox("focus", "Button", _style(Color(0, 0, 0, 0), GOLD, 2))
result.set_stylebox("disabled", "Button", _style(Color("#152028")))
result.set_stylebox("normal", "PanelContainer", _glass())
result.set_constant("separation", "VBoxContainer", 10)
result.set_constant("separation", "HBoxContainer", 12)
result.set_color("font_color", "OptionButton", TEXT)
result.set_stylebox("normal", "OptionButton", _style(Color("#1c2c36"), Color("#3d5a58"), 1))
result.set_stylebox("hover", "OptionButton", _style(Color("#284852"), TEAL, 1))
result.set_stylebox("focus", "OptionButton", _style(Color("#284852"), GOLD, 1))
var popup := _glass(Color(.06, .10, .14, .97), Color(.4, .7, .65, .3))
result.set_stylebox("panel", "PopupMenu", popup)
result.set_color("font_color", "PopupMenu", TEXT)
result.set_color("font_hover_color", "PopupMenu", GOLD)
return result
func _label(text_value: String, size: int, parent: Node, color: Color = TEXT) -> Label:
var label := Label.new()
label.text = text_value
label.add_theme_font_size_override("font_size", size)
label.add_theme_color_override("font_color", color)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(label)
return label
func _button(text_value: String, action: Callable, parent: Node) -> Button:
var button := Button.new()
button.text = text_value
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.pressed.connect(action)
parent.add_child(button)
return button
func _panel(parent: Node) -> PanelContainer:
var panel := PanelContainer.new()
parent.add_child(panel)
return panel
func _make_header() -> void:
var header := _panel(self)
_game_panels.append(header)
header.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
header.offset_left = 18
header.offset_top = 16
header.offset_right = -18
header.offset_bottom = 92
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 18)
header.add_child(row)
var title := VBoxContainer.new()
title.custom_minimum_size.x = 185
title.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(title)
var logo := _label("S A U M W A C H T", 22, title, Color("#f6e6b8"))
_logo_label = logo
_faction_label = _label("", 11, title, TEAL)
var phase_box := VBoxContainer.new()
phase_box.custom_minimum_size.x = 164
phase_box.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(phase_box)
_phase = _label("VORBEREITUNG", 11, phase_box, TEAL)
_timer = _label("", 22, phase_box)
_wave_progress = ProgressBar.new()
_wave_progress.custom_minimum_size.y = 3
_wave_progress.show_percentage = false
var progress_fill := _style(TEAL)
var progress_back := _style(Color("2c403e"))
for style: StyleBoxFlat in [progress_fill, progress_back]:
style.content_margin_top = 0
style.content_margin_bottom = 0
_wave_progress.add_theme_stylebox_override("fill", progress_fill)
_wave_progress.add_theme_stylebox_override("background", progress_back)
phase_box.add_child(_wave_progress)
_gold = _metric(row, "GOLD", GOLD, "res_gold")
_sparks = _metric(row, "FUNKEN", TEAL, "res_sparks")
_income = _metric(row, "PRO WELLE", GOLD, "res_income")
_workers = _metric(row, "ARBEITER", TEXT, "worker")
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(spacer)
var controls := VBoxContainer.new()
controls.add_theme_constant_override("separation", 3)
row.add_child(controls)
var buttons := HBoxContainer.new()
buttons.add_theme_constant_override("separation", 6)
controls.add_child(buttons)
_pause_button = _button("Pause [P]", toggle_pause, buttons)
_speed_button = _button("1×", _toggle_speed, buttons)
_button("Optionen", toggle_settings, buttons).tooltip_text = "Einstellungen & Steuerung [F1]"
_front_button = _button("Rivalen beobachten [Tab]", func(): focus_requested.emit(), controls)
for button: Node in buttons.get_children():
button.add_theme_font_size_override("font_size", 12)
_front_button.add_theme_font_size_override("font_size", 12)
func _metric(parent: Node, caption: String, color: Color, icon_id: String) -> Label:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", _glass(Color(.08, .13, .16, .55), Color(.4, .7, .65, .14)))
panel.custom_minimum_size.x = 118
parent.add_child(panel)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 9)
panel.add_child(row)
_icon(icon_id, row, Vector2(24,24))
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 0)
row.add_child(column)
_label(caption, 10, column, MUTED)
return _label("—", 24, column, color)
func _make_sidebar() -> void:
var guide := _panel(self)
guide.position = Vector2(22, 110)
guide.add_theme_stylebox_override("panel", _style(Color(.05,.08,.09,.86)))
var location := VBoxContainer.new()
guide.add_child(location)
_game_panels.append(guide)
_label("DIE GRÜNE KLUFT / DEINE FRONT", 11, location, TEAL)
_step_hint = _label("", 16, location)
_camera_status = _label("Q / E Drehen · R Zurücksetzen · F1 Hilfe", 12, location, MUTED)
_boss_label = _label("", 17, location, CORAL)
_boss_bar = ProgressBar.new()
_boss_bar.custom_minimum_size = Vector2(285, 13)
_boss_bar.show_percentage = false
_boss_bar.add_theme_stylebox_override("background", _style(Color("#101d25")))
_boss_bar.add_theme_stylebox_override("fill", _style(CORAL))
location.add_child(_boss_bar)
_boss_bar.visible = false
var sidebar := _panel(self)
_context_panel = sidebar
_game_panels.append(sidebar)
sidebar.anchor_left = 1.0
sidebar.anchor_right = 1.0
sidebar.anchor_bottom = 0.0
sidebar.offset_left = -348
sidebar.offset_right = -20
sidebar.offset_top = 108
sidebar.offset_bottom = 504
var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
sidebar.add_child(scroll)
var column := VBoxContainer.new()
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(column)
_context_column = column
column.add_theme_constant_override("separation", 6)
_ready_button = _button("KAMPF STARTEN [Leertaste]", request_wave_start, column)
_ready_button.custom_minimum_size.y = 52
_ready_button.add_theme_font_size_override("font_size", 15)
_ready_button.add_theme_stylebox_override("normal", _gold_button())
_ready_button.add_theme_stylebox_override("hover", _gold_button())
_ready_button.add_theme_stylebox_override("pressed", _gold_button())
_ready_button.add_theme_color_override("font_color", INK)
_ready_button.add_theme_color_override("font_hover_color", INK)
for team in range(2):
var color := TEAL if team == 0 else CORAL
var caption := _label("DEIN KERN" if team == 0 else "RIVALENKERN", 13, column, color)
_core_labels.append(caption)
var bar := ProgressBar.new()
bar.max_value = 3000
bar.value = 3000
bar.show_percentage = false
bar.custom_minimum_size.y = 7
var background := _style(Color("#101d25"))
var fill := _style(color)
for style: StyleBoxFlat in [background, fill]:
style.content_margin_top = 0
style.content_margin_bottom = 0
bar.add_theme_stylebox_override("background", background)
bar.add_theme_stylebox_override("fill", fill)
column.add_child(bar)
_core_bars.append(bar)
column.add_child(HSeparator.new())
_wave_title = _label("NÄCHSTE WELLE", 13, column, GOLD)
_wave_title.mouse_filter = Control.MOUSE_FILTER_STOP
_wave_body = _label("", 14, column, MUTED)
_wave_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_wave_body.custom_minimum_size.x = 278
column.add_child(HSeparator.new())
var selected_row := HBoxContainer.new()
column.add_child(selected_row)
_selection_icon = _icon("", selected_row, Vector2(64,64))
_selection_icon.visible = false
_selection_title = _label("DEINE FORMATION", 18, selected_row)
_selection_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_selection_types = HBoxContainer.new()
column.add_child(_selection_types)
_selection_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_selection_body = _label("", 14, column, MUTED)
_selection_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_selection_body.custom_minimum_size = Vector2(278, 45)
_upgrade = _button("Aufwerten", _upgrade_selected, column)
var actions := HBoxContainer.new()
column.add_child(actions)
_move = _button("Versetzen", func(): move_requested.emit(), actions)
_sell = _button("Verkaufen", func(): _emit_selected("sell"), actions)
_priority = OptionButton.new()
for title in ["Nächstes Ziel", "Niedrigste HP", "Höchste maximale HP"]:
_priority.add_item(title)
_priority.item_selected.connect(func(index: int):
command_requested.emit({"type": "target", "uid": selected_uid, "policy": index}))
column.add_child(_priority)
_inspect_model = _button("3D-Modell ansehen [V]", inspect_selected_model, column)
_summary = _label("", 13, column, TEAL)
_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
func _make_footer() -> void:
var footer := _panel(self)
_shop_panel = footer
_game_panels.append(footer)
footer.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
footer.offset_left = 16
footer.offset_right = -364
footer.offset_top = -212
footer.offset_bottom = -18
var shop := VBoxContainer.new()
shop.add_theme_constant_override("separation", 7)
footer.add_child(shop)
var heading := HBoxContainer.new()
shop.add_child(heading)
var title := _label("WÄCHTER / WÄHLEN & PLATZIEREN", 12, heading, TEAL)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_starter_button = _button("Startformation", func(): starter_requested.emit(), heading)
_starter_button.tooltip_text = "Stellt drei bezahlte Wächter auf: Tank und Nahkämpfer vorn, Schütze dahinter."
_undo = _button("↶ Rückgängig", func(): command_requested.emit({"type": "undo"}), heading)
_cards = HBoxContainer.new()
_cards.add_theme_constant_override("separation", 8)
shop.add_child(_cards)
_rebuild_shop()
_toast = _label("", 13, shop, MUTED)
_toast.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var economy := _panel(self)
_game_panels.append(economy)
economy.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
economy.offset_left = -348
economy.offset_right = -20
economy.offset_top = -300
economy.offset_bottom = -18
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 5)
economy.add_child(column)
var row := HBoxContainer.new()
column.add_child(row)
_label("BASIS & NACHschub".to_upper(), 12, row, TEAL).size_flags_horizontal = Control.SIZE_EXPAND_FILL
_button("Ansehen [C]", func(): base_requested.emit(), row)
_worker_button = _button("Arbeiter ausbilden", func(): command_requested.emit({"type": "worker"}), column)
_worker_button.icon = AssetLibrary.icon("worker")
_worker_button.add_theme_constant_override("icon_max_width", 25)
_worker_info = _label("", 12, column, MUTED)
_worker_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
column.add_child(HSeparator.new())
_send_picker = OptionButton.new()
for send_id in ["s01", "s02", "s03", "s04", "s05"]:
var data: Dictionary = DataCatalog.get_send(send_id, 1)
_send_picker.add_icon_item(AssetLibrary.icon(send_id), "%s · %d F · +%d G/Welle" % [data.display_name, data.cost, data.income_gain])
_send_picker.add_theme_constant_override("icon_max_width", 22)
_send_picker.get_popup().add_theme_constant_override("icon_max_width", 22)
column.add_child(_send_picker)
var send_row := HBoxContainer.new()
column.add_child(send_row)
_send_button = _button("Zum Rivalen senden", _queue_send, send_row)
_cancel_send = _button("↶", _cancel_last_send, send_row)
_cancel_send.tooltip_text = "Letzten Söldner entfernen; Funken zurückgeben"
_send_info = _label("", 12, column, MUTED)
_queue_scroll = ScrollContainer.new()
_queue_scroll.custom_minimum_size.y = 36
_queue_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
column.add_child(_queue_scroll)
_queue_row = HBoxContainer.new()
_queue_row.add_theme_constant_override("separation", 4)
_queue_scroll.add_child(_queue_row)
func _rebuild_shop() -> void:
if _shop_faction == GameState.factions[0]:
return
_shop_faction = GameState.factions[0]
_shop_buttons.clear()
for child: Node in _cards.get_children():
child.free()
var index: int = 0
for type_id: String in DataCatalog.base_ids():
var data: Dictionary = DataCatalog.get_unit(type_id)
var button := _button("", func(): build_selected.emit(type_id), _cards)
button.custom_minimum_size = Vector2(0, 121)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var content := VBoxContainer.new()
content.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.add_theme_constant_override("separation", 1)
content.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
content.offset_left = 10
content.offset_right = -10
content.offset_top = 4
content.offset_bottom = -4
button.add_child(content)
var top := HBoxContainer.new()
top.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.add_child(top)
_icon(type_id, top, Vector2(68, 68))
var price := VBoxContainer.new()
price.mouse_filter = Control.MOUSE_FILTER_IGNORE
top.add_child(price)
_label("%d G" % data.cost, 20, price, GOLD)
_label("[ %d ]" % (index+1), 12, price, MUTED)
_label(data.display_name, 15, content).horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_label(DataCatalog.ROLES[index], 12, content, TEAL).horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
button.tooltip_text = "%s · %s
%d HP · %d Schaden/s · %.1f m
%s" % [DataCatalog.DAMAGE_NAMES[data.attack_type], DataCatalog.ARMOR_NAMES[data.armor_type], data.max_hp, data.damage, data.attack_range, data.ability_description]
_shop_buttons[type_id] = button
index += 1
func _make_overlay() -> void:
main_menu = MainMenu.new()
add_child(main_menu)
_overlay = main_menu
_overlay_action = main_menu.start_button
_overlay_title = main_menu.length_summary
_faction_pickers = [main_menu.faction_choice, main_menu.rival_choice]
_mode_picker = main_menu.mode_choice
_ai_picker = main_menu.ai_choice
_timed_picker = main_menu.timed_toggle
main_menu.start_requested.connect(func() -> void: new_match_requested.emit())
main_menu.settings_requested.connect(toggle_settings)
main_menu.models_requested.connect(inspect_selected_model)
main_menu.quit_requested.connect(func() -> void: Soundscape.quit_game())
main_menu.selection_changed.connect(_rebuild_shop)
func _process(delta: float) -> void:
_toast_left -= delta
_refresh_left -= delta
if _refresh_left <= 0:
_refresh_left = 0.12
_refresh()
func _refresh() -> void:
var team: Dictionary = EconomyManager.get_team(0)
if team.is_empty():
return
_rebuild_shop()
if GameState.phase in [GameState.Phase.MENU, GameState.Phase.GAME_OVER] and is_instance_valid(main_menu):
# Match options can also change outside the menu (tools, tests); keep the display honest.
main_menu.sync()
_faction_label.text = DataCatalog.FACTION_NAMES[GameState.factions[0]].to_upper() + " / " + ("DEINE FRONT" if viewed_team == 0 else "RIVALENFRONT")
var building := GameState.phase == GameState.Phase.BUILD
_wave_progress.max_value = DataCatalog.wave_count()
_wave_progress.value = maxi(0, GameState.wave - 1)
_phase.text = GameState.phase_name() + (" · PAUSIERT" if GameState.paused else "")
_timer.text = "%02d / %02d · %02d s" % [maxi(1, GameState.wave), DataCatalog.wave_count(), ceili(WaveManager.remaining)]
if not GameState.timed_build and GameState.phase in [GameState.Phase.BUILD, GameState.Phase.RESULTS]:
_timer.text = "Welle %02d / %02d" % [maxi(1, GameState.wave), DataCatalog.wave_count()]
_gold.text = str(team.gold)
_sparks.text = "%d" % (int(team.sparks) - EconomyManager.reserved_sparks(0))
_sparks.tooltip_text = "Freie Funken. Vorgemerkte Söldner sind bereits abgezogen."
_income.text = "+%d" % team.income
_workers.text = "%d%s" % [team.workers, (" +%d" % team.pending_workers) if team.pending_workers else ""]
_workers.tooltip_text = "Aktive Arbeiter + bestellte Arbeiter. Je 10 Funken zu Beginn der nächsten Bauphase."
for side in range(2):
_core_labels[side].text = "%s %d / 3000" % [
"DEIN KERN" if side == 0 else "RIVALENKERN", GameState.core_hp[side]]
_core_bars[side].value = GameState.core_hp[side]
_pause_button.text = "Fortsetzen [P]" if GameState.paused else "Pause [P]"
_speed_button.text = "%d×" % GameState.speed
_front_button.text = "Eigene Front [Tab]" if viewed_team else "Rivalen beobachten [Tab]"
var w: int = maxi(1, GameState.wave)
var wave_data: Dictionary = DataCatalog.get_wave(w)
_wave_title.text = "%02d · %s" % [w, str(wave_data.get("title", "Welle"))]
_wave_body.text = str(wave_data.get("description", "")) + "\n\n" + DataCatalog.wave_hint(w)
_wave_title.tooltip_text = _wave_body.text
for type_id in _shop_buttons:
var data: Dictionary = DataCatalog.get_unit(type_id)
var button: Button = _shop_buttons[type_id]
button.disabled = not building or team.gold < data.cost
button.modulate = Color.WHITE
if build_type == type_id:
button.add_theme_stylebox_override("normal", _style(Color("2a5048"), TEAL, 2))
else:
var card := _style(Color("1a2c34"), Color("3a5558"), 1)
card.set_corner_radius_all(16)
card.shadow_color = Color(0, 0, 0, .3)
card.shadow_size = 6
button.add_theme_stylebox_override("normal", card)
_starter_button.disabled = not building or not team.roster.is_empty() or team.gold < DataCatalog.starter_cost()
_starter_button.text = "Startformation · %d G" % DataCatalog.starter_cost()
_undo.disabled = not building
_worker_button.text = "Arbeiter ausbilden · %d Gold" % EconomyManager.worker_cost(0)
_worker_button.disabled = not building or w >= DataCatalog.wave_count() or team.workers + team.pending_workers >= 8 or team.gold < EconomyManager.worker_cost(0)
_worker_info.text = "%d / 8 aktiv%s · +%d Funken / Welle\nNeue Arbeiter produzieren ab Welle %d." % [team.workers, " (+%d bestellt)" % team.pending_workers if team.pending_workers > 0 else "", int(team.workers) * 10, w+1]
if w >= DataCatalog.wave_count():
_worker_info.text = "Letzte Welle · Gold jetzt in Wächter investieren."
var send_cost: int = int(DataCatalog.SENDS["s%02d" % (_send_picker.selected + 1)].cost)
_send_button.disabled = not building or w < 3 or team.send_queue.size() >= 8 or mini(int(team.sparks), EconomyManager.send_budget(w)) - EconomyManager.reserved_sparks(0) < send_cost
_cancel_send.disabled = not building or team.send_queue.is_empty()
_send_picker.disabled = not building or w < 3
_send_info.text = "%d / 8 geplant · %d / %d F Budget" % [
team.send_queue.size(), EconomyManager.reserved_sparks(0), EconomyManager.send_budget(w)]
if w < 3:
_send_info.text = "Söldner verfügbar ab Welle 3"
_refresh_send_queue(team)
_ready_button.disabled = not building and GameState.phase != GameState.Phase.RESULTS
_ready_button.text = "NÄCHSTE WELLE [Leertaste]" if GameState.phase == GameState.Phase.RESULTS else ("KAMPF LÄUFT …" if GameState.phase == GameState.Phase.COMBAT else "KAMPF STARTEN [Leertaste]")
_step_hint.text = ("Erst Startformation setzen oder Wächter wählen." if team.roster.is_empty() else ("Platzieren → Aufwerten → Kampf starten" if build_type.is_empty() else "Freies Feld anklicken · Rechtsklick bricht ab")) if building else ("Welle beendet · Abrechnung rechts" if GameState.phase == GameState.Phase.RESULTS else "Deine Formation kämpft automatisch")
_refresh_selection(team, building)
if is_instance_valid(_context_column):
_context_panel.size.y = minf(_context_column.get_combined_minimum_size().y + 24, size.y - 422)
if _toast_left <= 0:
_toast.text = "1–5 Bauen · Linksklick Auswählen · Rechtsklick Abbrechen · Mausrad Zoom"
_toast.add_theme_color_override("font_color", MUTED)
func _refresh_selection(team: Dictionary, building: bool) -> void:
var selected: Dictionary = {}
for entry in team.roster:
if entry.uid == selected_uid:
selected = entry
break
var has_selection := not selected.is_empty()
_wave_title.visible = not has_selection
_summary.visible = not has_selection
_wave_body.visible = not has_selection
_inspect_model.visible = has_selection
_upgrade.visible = has_selection
_move.visible = has_selection
_sell.visible = has_selection
_priority.visible = has_selection
if not has_selection:
_selection_icon.visible = not build_type.is_empty()
_selection_icon.texture = AssetLibrary.icon(build_type)
_selection_types.visible = false
_selection_title.text = "DEINE FORMATION"
_selection_body.text = "%d / 48 Plätze · Tank vorn, Schützen dahinter.\nKlicke einen Wächter an, um ihn aufzuwerten." % team.roster.size()
if not build_type.is_empty():
var candidate: Dictionary = DataCatalog.get_unit(build_type)
_selection_title.text = candidate.display_name
_selection_body.text = "%d Gold · %d HP · %d Schaden/s\n%s\n\nLinksklick setzt einen Wächter. Shift setzt mehrere." % [candidate.cost, candidate.max_hp, candidate.damage, candidate.ability_description]
return
var data: Dictionary = DataCatalog.get_unit(selected.type_id)
_selection_icon.visible = true
_selection_icon.texture = AssetLibrary.icon(str(data.id))
_selection_types.visible = true
_refresh_type_icons(data)
_selection_title.text = data.display_name
_selection_body.text = "%d HP · %d Grund-DPS · %.1f m\n\n%s" % [
data.max_hp, data.damage, data.attack_range,
str(data.get("ability_description", ""))]
_selection_body.tooltip_text = "%s · %s" % [data.display_name, MATRIX_TOOLTIP]
var upgrade_id: String = str(data.get("upgrade_id", ""))
var upgraded: Dictionary = DataCatalog.get_unit(upgrade_id) if not upgrade_id.is_empty() else {}
_upgrade.disabled = not building or upgraded.is_empty() or team.gold < int(upgraded.get("cost", 0))
_upgrade.text = "Vollständig aufgewertet" if upgraded.is_empty() else "AUFWERTEN · %d GOLD\n%s" % [upgraded.cost, upgraded.display_name]
_upgrade.add_theme_font_size_override("font_size", 13)
_upgrade.tooltip_text = "" if upgraded.is_empty() else "%d → %d HP · %d → %d Schaden/s\n%s%s" % [data.max_hp, upgraded.max_hp, data.damage, upgraded.damage, upgraded.ability_description, "\nEs fehlen %d Gold." % (int(upgraded.cost)-int(team.gold)) if team.gold < int(upgraded.cost) else ""]
_sell.text = "Verkaufen · %d G" % floori(selected.invested * 0.75)
_sell.disabled = not building
_move.disabled = not building
_priority.disabled = not building
_priority.select(selected.target_policy)
func _refresh_send_queue(team: Dictionary) -> void:
var signature: String = ",".join(PackedStringArray(team.send_queue))
if signature == _queue_signature:
return
_queue_signature = signature
for child in _queue_row.get_children():
child.free()
var index := 0
for send_id: String in team.send_queue:
var chip := _button(SEND_SHORT_NAMES.get(send_id, send_id),
func(): command_requested.emit({"type": "cancel_send", "index": index}), _queue_row)
chip.add_theme_font_size_override("font_size", 11)
for state in ["normal", "hover", "pressed"]:
var compact := _style(Color("#26343b"), Color("#40544f") if state == "normal" else TEAL, 1)
compact.content_margin_left = 8
compact.content_margin_right = 8
compact.content_margin_top = 3
compact.content_margin_bottom = 3
chip.add_theme_stylebox_override(state, compact)
chip.tooltip_text = "%s entfernen · gibt die Funken vollständig frei" % DataCatalog.SENDS[send_id]["display_name"]
index += 1
_queue_scroll.visible = not team.send_queue.is_empty()
func _phase_changed(phase: int) -> void:
for panel: Control in _game_panels:
panel.visible = phase not in [GameState.Phase.MENU, GameState.Phase.GAME_OVER]
var fighting: bool = phase == GameState.Phase.COMBAT
_cards.visible = not fighting
_starter_button.visible = not fighting
_undo.visible = not fighting
_shop_panel.offset_top = -84 if fighting else -212
if phase in [GameState.Phase.MENU, GameState.Phase.GAME_OVER]:
main_menu.occluder = model_inspector
main_menu.enter(phase == GameState.Phase.GAME_OVER)
else:
main_menu.leave()
if phase == GameState.Phase.BUILD:
if GameState.wave == 1:
_summary.text = ""
_queue_signature = "@"
notify("Bauphase: neue Wächter, Aufwertungen und Arbeiter sind verfügbar.")
elif phase == GameState.Phase.COMBAT:
notify("Die Formation kämpft automatisch. Deine Kämpfer kehren zur nächsten Welle zurück.")
_refresh()
func _show_summary(reports: Array) -> void:
var report: Dictionary = reports[0]
_summary.text = "LETZTE ABRECHNUNG\n+%d Gold: %d Grundbetrag + %d Einkommen + %d Prämien\n%d Lecks · %d Kernschaden" % [
report.get("total_gold", 0), report.get("base_gold", 0), report.get("income", 0),
report.get("bounty", 0), report.get("leaks", 0), report.get("core_damage", 0)]
func notify(message: String, is_error: bool = false) -> void:
_toast.text = message
_toast.add_theme_color_override("font_color", CORAL if is_error else TEAL)
_toast_left = 6.0
func toggle_pause() -> void:
if GameState.phase in [GameState.Phase.MENU, GameState.Phase.GAME_OVER]:
return
GameState.paused = not GameState.paused
if GameState.paused:
Soundscape.stop_combat()
_refresh()
func _toggle_speed() -> void:
GameState.speed = 2 if GameState.speed == 1 else 1
_refresh()
func _emit_selected(type: String) -> void:
command_requested.emit({"type": type, "uid": selected_uid})
func _upgrade_selected() -> void:
_emit_selected("upgrade")
func _queue_send() -> void:
command_requested.emit({"type": "queue_send", "type_id": "s%02d" % (_send_picker.selected + 1)})
func _cancel_last_send() -> void:
var queue: Array = EconomyManager.get_team(0).send_queue
if not queue.is_empty():
command_requested.emit({"type": "cancel_send", "index": queue.size() - 1})
func _icon(id: String, parent: Node, minimum: Vector2) -> TextureRect:
var image := TextureRect.new()
image.texture = AssetLibrary.icon(id)
image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
image.custom_minimum_size = minimum
image.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(image)
return image
func _refresh_type_icons(data: Dictionary) -> void:
var key: String = "%s:%s" % [data.attack_type, data.armor_type]
if _selection_types.get_meta("key", "") == key:
return
_selection_types.set_meta("key", key)
for child: Node in _selection_types.get_children():
child.free()
_icon(["type_damage_blade","type_damage_pierce","type_damage_pulse"][int(data.attack_type)], _selection_types, Vector2(25,25))
_label(DataCatalog.DAMAGE_NAMES[data.attack_type],12,_selection_types,GOLD)
_icon(["type_armor_light","type_armor_heavy","type_armor_aether"][int(data.armor_type)], _selection_types, Vector2(25,25))
_label(DataCatalog.ARMOR_NAMES[data.armor_type],12,_selection_types,MUTED)
func _make_settings() -> void:
settings_panel = ColorRect.new()
settings_panel.color = Color(.02, .03, .04, .9)
settings_panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
# Reached from the title menu as well as in play: it shares the menu theme.
settings_panel.theme = SaumwachtTheme.shared()
add_child(settings_panel)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
settings_panel.add_child(center)
var panel := PanelContainer.new()
panel.theme_type_variation = "MenuPanel"
panel.custom_minimum_size.x = 760
center.add_child(panel)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 12)
panel.add_child(column)
var heading := _themed_label("Optionen & Steuerung", "HeadingLabel", column)
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_themed_label("Eine laufende Partie pausiert, solange dieses Fenster geöffnet ist.", "MutedLabel", column).horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_themed_label("LAUTSTÄRKE", "SectionLabel", column)
_volume_slider = HSlider.new()
_volume_slider.max_value = 100
_volume_slider.step = 1
_volume_slider.custom_minimum_size.y = 28
column.add_child(_volume_slider)
_volume_slider.value_changed.connect(func(value: float):
Preferences.volume = value / 100.0
Preferences.commit())
_volume_slider.drag_ended.connect(func(_changed: bool): Soundscape.play("place"))
var toggles := GridContainer.new()
toggles.columns = 2
toggles.add_theme_constant_override("h_separation", 28)
toggles.add_theme_constant_override("v_separation", 4)
column.add_child(toggles)
_mute_check = CheckButton.new()
_mute_check.text = "Ton stumm [M]"
toggles.add_child(_mute_check)
_mute_check.toggled.connect(func(value: bool):
Preferences.muted = value
Preferences.commit())
_follow_check = CheckButton.new()
_follow_check.text = "Kamera folgt dem Kampf"
toggles.add_child(_follow_check)
_follow_check.toggled.connect(func(value: bool):
Preferences.follow_camera = value
Preferences.commit())
_fullscreen_check = CheckButton.new()
_fullscreen_check.text = "Vollbild [F11]"
toggles.add_child(_fullscreen_check)
_fullscreen_check.toggled.connect(func(value: bool):
Preferences.fullscreen = value
Preferences.commit())
_reduced_check = CheckButton.new()
_reduced_check.text = "Bewegung reduzieren"
_reduced_check.tooltip_text = "Menü ohne Kamerafahrten, Kameraatmen, Partikel und Überblendungen."
toggles.add_child(_reduced_check)
_reduced_check.toggled.connect(func(value: bool):
Preferences.reduced_motion = value
Preferences.commit())
column.add_child(HSeparator.new())
_themed_label("STEUERUNG", "SectionLabel", column)
_themed_label("1–5 Wächter wählen Linksklick Bauen / Auswählen\nRechtsklick / Esc Bauauftrag abbrechen\nLeertaste Kampf / nächste Welle P Pause\nTab Front wechseln C Arbeiter & Kristall\nMausrad Zoomen Shift + Klick Mehrfach bauen\nQ / E Kamera drehen Mittlere Maustaste ziehen Verschieben\nR Kamera zurücksetzen und dem Kampf wieder folgen\nV 3D-Modelle ansehen, drehen und Bewegungen abspielen", "Label", column)
_themed_label("Startformation: Tank, Nahkämpfer und Schütze.\nFunkenleser liefern ab der nächsten Welle Funken.\nSöldner schickst du ab Welle 3 zum Rivalen.", "MutedLabel", column)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
column.add_child(row)
var back := _button("ZURÜCK [Esc]", toggle_settings, row)
back.theme_type_variation = "PrimaryButton"
back.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_button("Neue Partie", func():
settings_panel.hide()
main_menu.pending_screen = MainMenu.Screen.SETUP
menu_requested.emit(), row).tooltip_text = "Beendet diese Partie und öffnet die Bundauswahl."
_button("Spiel beenden", func(): Soundscape.quit_game(), row)
settings_panel.visible = false
func _themed_label(text_value: String, variation: String, parent: Node) -> Label:
var label := Label.new()
label.text = text_value
label.theme_type_variation = variation
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(label)
return label
func sync_settings() -> void:
_volume_slider.set_value_no_signal(Preferences.volume * 100.0)
_mute_check.set_pressed_no_signal(Preferences.muted)
_follow_check.set_pressed_no_signal(Preferences.follow_camera)
_fullscreen_check.set_pressed_no_signal(Preferences.fullscreen)
_reduced_check.set_pressed_no_signal(Preferences.reduced_motion)
func toggle_settings() -> void:
if settings_panel.visible:
settings_panel.hide()
GameState.paused = _settings_paused
if is_instance_valid(_settings_focus) and _settings_focus.is_visible_in_tree():
_settings_focus.grab_focus()
else:
_settings_focus = get_viewport().gui_get_focus_owner()
_settings_paused = GameState.paused
GameState.paused = true
Soundscape.stop_combat()
sync_settings()
settings_panel.show()
_volume_slider.grab_focus()
_refresh()
func update_battle_readout(units: Array, manual_camera: bool) -> void:
_camera_status.text = ("Freie Kamera · R Zurücksetzen" if manual_camera else "Q / E Drehen · Mausrad Zoom") + " · F1 Hilfe"
var boss = null
if GameState.phase == GameState.Phase.COMBAT:
for unit in units:
if unit.alive and unit.is_attacker and unit.team_id == viewed_team and unit._is_boss():
boss = unit
break
_boss_bar.visible = boss != null
_boss_label.visible = boss != null
if boss != null:
_boss_bar.max_value = boss.max_hp
_boss_bar.value = boss.hp
_boss_label.text = "%s · %d / %d HP" % [boss.data.display_name, ceili(boss.hp), ceili(boss.max_hp)]
func inspect_selected_model() -> void:
var id: String = build_type
for entry: Dictionary in EconomyManager.get_team(0).roster:
if entry.uid == selected_uid:
id = str(entry.type_id)
break
if id.is_empty():
id = "w10" if GameState.factions[0] == "wurzelbund" else "g04"
model_inspector.show_model(id)
func request_wave_start() -> void:
if GameState.paused:
notify("Setze das Spiel mit P fort, bevor du eine Welle startest.")
return
if GameState.phase == GameState.Phase.BUILD and EconomyManager.get_team(0).roster.is_empty():
notify("Setze zuerst Wächter oder nutze die Startformation.", true)
return
ready_requested.emit()