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()