saumwacht-godot/scripts/ui/main_menu.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

518 lines
20 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.

class_name MainMenu
extends Control
## Title screen and match setup in front of the menu diorama.
## Presentation state (screen, camera shot, fades) lives here only. Match options are
## written through GameState exactly as before; nothing from the stage reaches a match.
signal start_requested
signal settings_requested
signal models_requested
signal quit_requested
signal selection_changed
const Stage = preload("res://scripts/presentation/menu_stage.gd")
const FADE := .22
enum Screen { TITLE, SETUP }
var screen: int = Screen.TITLE
var pending_screen: int = Screen.TITLE
var stage: Node3D
var stage_view: SubViewport
var occluder: Control
var start_button: Button
var new_game_button: Button
var faction_choice: FactionChoice
var rival_choice: ChoiceGroup
var mode_choice: ChoiceGroup
var ai_choice: ChoiceGroup
var timed_toggle: CheckButton
var length_summary: Label
var result_title: Label
var result_body: Label
var reduced_motion: bool = false
var _stage_rect: TextureRect
var _title_screen: Control
var _setup_screen: Control
var _title_scrim: TextureRect
var _setup_scrim: TextureRect
var _shield: Control
var _shield_left: float = 0.0
var _fade: Tween
var _locked: bool = false
var _active: bool = false
var _leaving: bool = false
var _result_panel: Control
var _change_button: Button
var _models_button: Button
var _options_button: Button
var _advanced: VBoxContainer
var _advanced_button: Button
var _advanced_summary: Label
var _timed_hint: Label
var _title_caption: Label
var _style_heading: Label
var _style_facts: Label
var _setup_caption: Label
func _ready() -> void:
name = "MainMenu"
theme = SaumwachtTheme.shared()
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
_build_stage()
_title_scrim = _scrim(Vector2(0, 1), Vector2(.78, .18), [.92, .62, 0.0], [0.0, .42, 1.0])
_setup_scrim = _scrim(Vector2(0, .5), Vector2(1, .5), [.9, .62, 0.0], [0.0, .36, .6])
var top := _scrim(Vector2(.5, 0), Vector2(.5, 1), [.55, 0.0, 0.0], [0.0, .2, 1.0])
top.name = "TopVignette"
_build_title()
_build_setup()
_shield = Control.new()
_shield.name = "InputShield"
_shield.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_shield.mouse_filter = Control.MOUSE_FILTER_STOP
_shield.visible = false
add_child(_shield)
get_tree().root.size_changed.connect(_resize_stage)
_resize_stage()
Preferences.changed.connect(_apply_preferences)
_apply_preferences()
sync()
# ---------------------------------------------------------------- building
func _build_stage() -> void:
stage_view = SubViewport.new()
stage_view.name = "StageViewport"
stage_view.own_world_3d = true
stage_view.msaa_3d = Viewport.MSAA_2X
stage_view.render_target_update_mode = SubViewport.UPDATE_DISABLED
add_child(stage_view)
stage = Stage.new()
stage_view.add_child(stage)
_stage_rect = TextureRect.new()
_stage_rect.name = "StageImage"
_stage_rect.texture = stage_view.get_texture()
_stage_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_stage_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_stage_rect.stretch_mode = TextureRect.STRETCH_SCALE
_stage_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_stage_rect)
func _scrim(from: Vector2, to: Vector2, alphas: Array, offsets: Array) -> TextureRect:
var gradient := Gradient.new()
gradient.offsets = PackedFloat32Array(offsets)
var colors := PackedColorArray()
for alpha: float in alphas:
colors.append(Color(SaumwachtTheme.NIGHT, alpha))
gradient.colors = colors
var texture := GradientTexture2D.new()
texture.gradient = gradient
texture.fill_from = from
texture.fill_to = to
texture.width = 256
texture.height = 256
var rect := TextureRect.new()
rect.texture = texture
rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
rect.stretch_mode = TextureRect.STRETCH_SCALE
rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(rect)
return rect
func _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 _button(text_value: String, variation: String, parent: Node, action: Callable) -> 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 _emblem(path: String, size: float, parent: Node) -> TextureRect:
var image := TextureRect.new()
image.texture = load(path)
image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
image.custom_minimum_size = Vector2(size, size)
image.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(image)
return image
func _build_title() -> void:
_title_screen = Control.new()
_title_screen.name = "TitleScreen"
_title_screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_title_screen.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_title_screen)
var column := VBoxContainer.new()
column.name = "TitleColumn"
column.anchor_top = 1.0
column.anchor_bottom = 1.0
column.offset_left = 88
column.offset_right = 760
column.offset_bottom = -72
column.grow_vertical = Control.GROW_DIRECTION_BEGIN
column.add_theme_constant_override("separation", 10)
_title_screen.add_child(column)
_result_panel = PanelContainer.new()
_result_panel.theme_type_variation = "CaptionPanel"
_result_panel.visible = false
column.add_child(_result_panel)
var result := VBoxContainer.new()
result.add_theme_constant_override("separation", 2)
_result_panel.add_child(result)
result_title = _label("", "HeadingLabel", result)
result_body = _label("", "MutedLabel", result)
var spacer := Control.new()
spacer.custom_minimum_size.y = 12
column.add_child(spacer)
var brand := HBoxContainer.new()
brand.add_theme_constant_override("separation", 22)
column.add_child(brand)
_emblem("res://assets/ui/emblems/saumwacht.svg", 112, brand)
var names := VBoxContainer.new()
names.alignment = BoxContainer.ALIGNMENT_CENTER
names.add_theme_constant_override("separation", 0)
brand.add_child(names)
_label("SAUMWACHT", "TitleLabel", names)
_label("DIE GRÜNE KLUFT", "SubtitleLabel", names)
var lore := _label("Stelle deine Wächter auf, schütze den Saumkern und überstehe den Rivalen.", "LoreLabel", column)
lore.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lore.custom_minimum_size.x = 560
var gap := Control.new()
gap.custom_minimum_size.y = 22
column.add_child(gap)
var primary_row := HBoxContainer.new()
primary_row.add_theme_constant_override("separation", 14)
column.add_child(primary_row)
new_game_button = _button("NEUE PARTIE", "PrimaryButton", primary_row, _on_title_primary)
new_game_button.name = "NewGame"
new_game_button.custom_minimum_size = Vector2(340, 64)
_change_button = _button("Bund und Partie ändern", "Button", primary_row, func() -> void: show_screen(Screen.SETUP))
_change_button.custom_minimum_size.y = 64
_change_button.visible = false
var secondary := HBoxContainer.new()
secondary.add_theme_constant_override("separation", 12)
column.add_child(secondary)
_models_button = _button("Modelle ansehen · V", "Button", secondary, func() -> void: models_requested.emit())
_options_button = _button("Optionen & Steuerung · F1", "Button", secondary, func() -> void: settings_requested.emit())
var quit := _button("Spiel beenden", "QuietButton", column, func() -> void: quit_requested.emit())
quit.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
var version := _label("Demo %s · Einzelspieler" % str(ProjectSettings.get_setting("application/config/version", "")), "MutedLabel", _title_screen)
version.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
version.offset_left = -360
version.offset_right = -40
version.offset_top = 30
version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_title_caption = _caption(_title_screen, "Glutpakt und Wurzelbund an den Ufern der Grünen Kluft")
func _caption(parent: Control, text_value: String) -> Label:
var plate := PanelContainer.new()
plate.theme_type_variation = "CaptionPanel"
plate.anchor_left = 1.0
plate.anchor_right = 1.0
plate.anchor_top = 1.0
plate.anchor_bottom = 1.0
plate.offset_right = -40
plate.offset_bottom = -36
plate.grow_horizontal = Control.GROW_DIRECTION_BEGIN
plate.grow_vertical = Control.GROW_DIRECTION_BEGIN
plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(plate)
var label := _label(text_value, "MutedLabel", plate)
label.add_theme_font_size_override("font_size", 15)
return label
func _build_setup() -> void:
_setup_screen = Control.new()
_setup_screen.name = "SetupScreen"
_setup_screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_setup_screen.mouse_filter = Control.MOUSE_FILTER_IGNORE
_setup_screen.visible = false
add_child(_setup_screen)
var panel := PanelContainer.new()
panel.name = "SetupPanel"
panel.theme_type_variation = "MenuPanel"
panel.anchor_bottom = 1.0
panel.offset_left = 40
panel.offset_top = 30
panel.offset_right = 700
panel.offset_bottom = -30
_setup_screen.add_child(panel)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 12)
panel.add_child(layout)
var top := HBoxContainer.new()
layout.add_child(top)
var back := _button("‹ Hauptmenü Esc", "QuietButton", top, func() -> void: show_screen(Screen.TITLE))
back.name = "Back"
var eyebrow := _label("NEUE PARTIE", "EyebrowLabel", top)
eyebrow.size_flags_horizontal = Control.SIZE_EXPAND_FILL
eyebrow.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_label("Wähle deinen Bund", "HeadingLabel", layout)
var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
layout.add_child(scroll)
# Inner padding keeps focus rings inside the scroll clip.
var padding := MarginContainer.new()
padding.size_flags_horizontal = Control.SIZE_EXPAND_FILL
for side: String in ["left", "right", "top", "bottom"]:
padding.add_theme_constant_override("margin_" + side, 8)
scroll.add_child(padding)
var body := VBoxContainer.new()
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
body.add_theme_constant_override("separation", 12)
padding.add_child(body)
faction_choice = FactionChoice.new()
faction_choice.name = "FactionChoice"
body.add_child(faction_choice)
faction_choice.item_selected.connect(func(index: int) -> void:
if GameState.select_faction(0, DataCatalog.FACTIONS[index]):
selection_changed.emit()
sync())
# One shared play-style description for the selected Bund, next to one shared 3D preview.
_style_heading = _label("", "SectionLabel", body)
_style_facts = _label("", "CardFactLabel", body)
_style_facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_style_facts.add_theme_constant_override("line_spacing", 4)
_label("PARTIELÄNGE", "SectionLabel", body)
mode_choice = ChoiceGroup.new(true)
mode_choice.name = "ModeChoice"
mode_choice.add_option("Expedition", "20 Wellen · 4 Bosse")
mode_choice.add_option("Kurzpartie", "8 Wellen · 2 Bosse")
body.add_child(mode_choice)
mode_choice.item_selected.connect(func(index: int) -> void:
GameState.select_mode(GameState.MODE_EXPEDITION if index == 0 else GameState.MODE_SHORT)
sync())
length_summary = _label("", "MutedLabel", body)
var disclosure := HBoxContainer.new()
body.add_child(disclosure)
_advanced_button = _button("+ Weitere Einstellungen", "QuietButton", disclosure, _toggle_advanced)
_advanced_button.name = "AdvancedToggle"
_advanced_summary = _label("", "MutedLabel", disclosure)
_advanced_summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_advanced_summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_advanced_summary.add_theme_font_size_override("font_size", 15)
_advanced = VBoxContainer.new()
_advanced.name = "AdvancedOptions"
_advanced.add_theme_constant_override("separation", 8)
_advanced.visible = false
body.add_child(_advanced)
_label("RIVALENBUND", "SectionLabel", _advanced)
rival_choice = ChoiceGroup.new(true)
rival_choice.name = "RivalChoice"
for id: String in DataCatalog.FACTIONS:
rival_choice.add_option(DataCatalog.FACTION_NAMES[id])
_advanced.add_child(rival_choice)
rival_choice.item_selected.connect(func(index: int) -> void:
GameState.select_faction(1, DataCatalog.FACTIONS[index])
sync())
_label("KI-STUFE", "SectionLabel", _advanced)
ai_choice = ChoiceGroup.new(true)
ai_choice.name = "AiChoice"
ai_choice.add_option("Lernmodus", "feste Formationen, milde Söldnerangriffe")
ai_choice.add_option("Standard", "verbessert sich nach Lecks, volle Söldnerangriffe")
_advanced.add_child(ai_choice)
ai_choice.item_selected.connect(func(index: int) -> void:
GameState.ai_level = index
sync())
timed_toggle = CheckButton.new()
timed_toggle.name = "TimedBuild"
timed_toggle.text = "Bauzeit begrenzen (45 / 30 Sekunden)"
timed_toggle.focus_mode = Control.FOCUS_ALL
_advanced.add_child(timed_toggle)
timed_toggle.toggled.connect(func(value: bool) -> void:
GameState.timed_build = value
sync())
start_button = _button("PARTIE BEGINNEN ›", "PrimaryButton", layout, _on_start)
start_button.name = "StartMatch"
start_button.custom_minimum_size.y = 64
_timed_hint = _label("", "MutedLabel", layout)
_timed_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timed_hint.add_theme_font_size_override("font_size", 15)
_setup_caption = _caption(_setup_screen, "")
# ---------------------------------------------------------------- state
func sync() -> void:
if faction_choice == null:
return
faction_choice.select(DataCatalog.FACTIONS.find(GameState.factions[0]))
rival_choice.select(DataCatalog.FACTIONS.find(GameState.factions[1]))
var full: bool = GameState.match_mode == GameState.MODE_EXPEDITION
mode_choice.select(0 if full else 1)
ai_choice.select(clampi(GameState.ai_level, 0, 1))
timed_toggle.set_pressed_no_signal(GameState.timed_build)
length_summary.text = "Zwanzig Wellen · vier Bosse · etwa 20 bis 30 Minuten" if full else "Acht Wellen · zwei Bosse · etwa 7 bis 10 Minuten"
_advanced_summary.text = "Rivale %s · %s · Bauzeit %s" % [DataCatalog.FACTION_NAMES[GameState.factions[1]],
"Lernmodus" if GameState.ai_level == GameState.AI_LEARNING else "Standard", "begrenzt" if GameState.timed_build else "frei"]
_timed_hint.text = "Bauphasen enden nach 45, danach je 30 Sekunden; der Kampf startet dann selbst." if GameState.timed_build else "Du startest jede Welle selbst mit der Leertaste."
var own: String = GameState.factions[0]
_style_heading.text = "SPIELWEISE · " + DataCatalog.FACTION_NAMES[own].to_upper()
var lines: PackedStringArray = []
for fact: String in FactionChoice.PROFILES[own].facts:
lines.append("• " + fact)
lines.append("• Startformation mit Tank, Nahkämpfer und Schütze für %d Gold" % FactionChoice.starter_cost(own))
_style_facts.text = "\n".join(lines)
var names: PackedStringArray = []
for entry: Array in Stage.SETUPS[GameState.factions[0]].figures:
names.append(str(DataCatalog.get_unit(entry[0]).display_name))
names.append("Saumkern")
_setup_caption.text = "IM BILD " + " · ".join(names)
if screen == Screen.SETUP and _active and stage.current_shot() != GameState.factions[0]:
stage.set_shot_named(GameState.factions[0], reduced_motion)
func is_transitioning() -> bool:
return _shield.visible
func _toggle_advanced() -> void:
_advanced.visible = not _advanced.visible
if _advanced.visible:
var scroll: ScrollContainer = _advanced.get_parent().get_parent().get_parent()
scroll.ensure_control_visible.call_deferred(_advanced)
_advanced_button.text = ("– Weniger Einstellungen" if _advanced.visible else "+ Weitere Einstellungen")
_advanced_summary.visible = not _advanced.visible
func _apply_preferences() -> void:
reduced_motion = Preferences.reduced_motion
stage.set_reduced_motion(reduced_motion)
func _resize_stage() -> void:
var pixels: Vector2i = get_tree().root.size
if pixels.x < 16 or pixels.y < 16:
pixels = Vector2i(get_viewport_rect().size)
stage_view.size = pixels
func _set_stage_active(on: bool) -> void:
_active = on
stage.process_mode = Node.PROCESS_MODE_INHERIT if on else Node.PROCESS_MODE_DISABLED
stage_view.render_target_update_mode = SubViewport.UPDATE_ALWAYS if on else SubViewport.UPDATE_DISABLED
# The arena is hidden behind the opaque stage image; skip rendering it meanwhile.
get_tree().root.disable_3d = on
func _process(delta: float) -> void:
if _shield_left > 0.0:
_shield_left -= delta
if _shield_left <= 0.0 and not _leaving:
_shield.visible = false
if _active:
var covered: bool = is_instance_valid(occluder) and occluder.visible
stage_view.render_target_update_mode = SubViewport.UPDATE_DISABLED if covered else SubViewport.UPDATE_ALWAYS
func _guard(seconds: float) -> void:
_shield.visible = true
_shield_left = maxf(_shield_left, seconds)
func _input(event: InputEvent) -> void:
# While a screen fades, clicks and confirm keys must not reach half-hidden controls.
if visible and _shield.visible and (event is InputEventMouseButton or event.is_action("ui_accept")):
get_viewport().set_input_as_handled()
func _unhandled_input(event: InputEvent) -> void:
if not visible or _shield.visible or not event.is_action_pressed("ui_cancel"):
return
if screen == Screen.SETUP:
show_screen(Screen.TITLE)
get_viewport().set_input_as_handled()
# ---------------------------------------------------------------- flow
func enter(game_over: bool = false) -> void:
if _fade != null:
_fade.kill()
_leaving = false
_locked = false
visible = true
modulate.a = 1.0
_set_stage_active(true)
_result_panel.visible = game_over
_change_button.visible = game_over
new_game_button.text = "NOCHMAL SPIELEN" if game_over else "NEUE PARTIE"
if game_over:
result_title.text = ["SIEG AM SAUM", "NIEDERLAGE AM SAUM"][GameState.outcome] if GameState.outcome >= 0 else "GLEICHSTAND AM SAUM"
result_body.text = "Welle %d von %d · Dein Kern %d HP · Rivalenkern %d HP" % [GameState.wave, DataCatalog.wave_count(), GameState.core_hp[0], GameState.core_hp[1]]
var target: int = Screen.TITLE if game_over else pending_screen
pending_screen = Screen.TITLE
screen = -1
sync()
show_screen(target, true)
func leave() -> void:
if not visible or _leaving:
return
_leaving = true
get_tree().root.disable_3d = false
if reduced_motion or DisplayServer.get_name() == "headless":
_finish_leave()
return
_guard(FADE + .1)
_fade = create_tween()
_fade.tween_property(self, "modulate:a", 0.0, FADE)
_fade.tween_callback(_finish_leave)
func _finish_leave() -> void:
visible = false
_shield.visible = false
_shield_left = 0.0
_leaving = false
modulate.a = 1.0
_set_stage_active(false)
func show_screen(next: int, instant: bool = false) -> void:
var from: Control = _title_screen if screen == Screen.TITLE else (_setup_screen if screen == Screen.SETUP else null)
var to: Control = _title_screen if next == Screen.TITLE else _setup_screen
screen = next
stage.set_shot_named("title" if next == Screen.TITLE else GameState.factions[0], instant or reduced_motion)
_title_scrim.visible = next == Screen.TITLE
_setup_scrim.visible = next == Screen.SETUP
if _fade != null and _fade.is_valid():
_fade.kill()
for node: Control in [_title_screen, _setup_screen]:
if node != to and node != from:
node.visible = false
if instant or reduced_motion or from == null or from == to:
if from != null and from != to:
from.visible = false
to.visible = true
to.modulate.a = 1.0
_focus_default.call_deferred()
return
_guard(FADE + .05)
to.visible = true
to.modulate.a = 0.0
_fade = create_tween()
_fade.tween_property(from, "modulate:a", 0.0, FADE * .6)
_fade.parallel().tween_property(to, "modulate:a", 1.0, FADE).set_delay(FADE * .25)
_fade.tween_callback(func() -> void:
from.visible = false
from.modulate.a = 1.0)
_focus_default.call_deferred()
func _focus_default() -> void:
if not is_visible_in_tree():
return
if screen == Screen.SETUP:
faction_choice.focus_selected()
else:
new_game_button.grab_focus()
func _on_title_primary() -> void:
if GameState.phase == GameState.Phase.GAME_OVER:
_on_start()
else:
show_screen(Screen.SETUP)
func _on_start() -> void:
# One start per menu visit; a second click during the fade lands on the shield.
if _locked:
return
_locked = true
start_requested.emit()