extends Node ## Phase 20A acceptance: title menu, Bund selection, options, start/return/restart, input ## shielding, stage isolation, reduced motion and layout at 1280×720 / 1920×1080. ## Run: godot --path . --resolution 1920x1080 res://tools/verify_menu.tscn var main: Node3D var menu: MainMenu var checks: int = 0 var failures: Array[String] = [] func check(ok: bool, label: String) -> void: checks += 1 if not ok: failures.append(label) printerr("MENU_FAIL: ", label) func _ready() -> void: call_deferred("run") func frames(count: int = 3) -> void: for i: int in range(count): await get_tree().process_frame func click(control: Control) -> void: # Like a player, scroll the setup panel until the control is in view. var parent: Node = control.get_parent() while parent != null and not parent is ScrollContainer: parent = parent.get_parent() if parent is ScrollContainer: (parent as ScrollContainer).ensure_control_visible(control) await frames(2) var at: Vector2 = control.get_global_rect().get_center() var motion := InputEventMouseMotion.new() motion.position = at get_viewport().push_input(motion, true) await get_tree().process_frame for pressed: bool in [true, false]: var event := InputEventMouseButton.new() event.button_index = MOUSE_BUTTON_LEFT event.position = at event.pressed = pressed get_viewport().push_input(event, true) await get_tree().process_frame func key(code: Key) -> void: for pressed: bool in [true, false]: var event := InputEventKey.new() event.keycode = code event.physical_keycode = code event.pressed = pressed get_viewport().push_input(event) await get_tree().process_frame func settle() -> void: while menu.is_transitioning() or menu.stage.is_moving(): await get_tree().process_frame await frames(2) func inside(control: Control, label: String) -> void: var view := Rect2(Vector2.ZERO, get_viewport().get_visible_rect().size) check(control.is_visible_in_tree() and view.encloses(control.get_global_rect()), label + " inside the window") func run() -> void: Soundscape.enabled = false WaveManager.set_physics_process(false) var stored_path: String = Preferences.storage_path var stored_reduced: bool = Preferences.reduced_motion Preferences.storage_path = "user://phase20-menu-preferences.cfg" Preferences.reduced_motion = false GameState.factions = ["wurzelbund", "glutpakt"] GameState.timed_build = false GameState.ai_level = GameState.AI_LEARNING var started: int = Time.get_ticks_msec() main = load("res://scenes/main.tscn").instantiate() add_child(main) menu = main.hud.main_menu var loaded: int = Time.get_ticks_msec() - started await frames(4) # ---- title and identity check(GameState.phase == GameState.Phase.MENU and menu.visible, "Menu shows at start") check(menu._title_screen.visible and not menu._setup_screen.visible, "Title screen first") check(get_viewport().gui_get_focus_owner() == menu.new_game_button, "Keyboard focus starts on Neue Partie") check(menu.theme == SaumwachtTheme.shared() and main.hud.settings_panel.theme == menu.theme, "Menu and options share one theme") for variation: String in ["TitleLabel", "PrimaryButton", "ChoiceButton", "FactionCard", "MenuPanel", "QuietButton"]: check(menu.theme.get_type_variation_base(variation) != StringName(), "Theme variation " + variation) check(menu.new_game_button.theme_type_variation == "PrimaryButton" and menu._options_button.theme_type_variation == "Button", "Title entry outranks secondary entries") for path: String in ["res://assets/ui/emblems/saumwacht.svg", "res://assets/ui/emblems/glutpakt.svg", "res://assets/ui/emblems/wurzelbund.svg"]: check(load(path) is Texture2D, "Emblem " + path.get_file()) # ---- stage isolation check(menu.stage_view.own_world_3d and menu.stage.get_world_3d() != main.get_world_3d(), "Stage renders in its own World3D") check(get_tree().root.disable_3d and menu.stage_view.render_target_update_mode == SubViewport.UPDATE_ALWAYS, "Menu renders only the stage") check(main.battle.units.is_empty(), "No battle units exist in the menu") var t0: int = Time.get_ticks_msec() while not (menu.stage.is_ready("glutpakt") and menu.stage.is_ready("wurzelbund")) and Time.get_ticks_msec() - t0 < 15000: await get_tree().process_frame var ready_ms: int = Time.get_ticks_msec() - started check(menu.stage.is_ready("glutpakt") and menu.stage.is_ready("wurzelbund"), "Both formations load") var stage_units: int = 0 for node: Node in menu.stage.find_children("*", "", true, false): if node is CombatUnit: stage_units += 1 check(stage_units == 0, "Stage holds models, never combat units") var skeletal: int = 0 for faction: String in menu.stage.figures: for figure: Node3D in menu.stage.figures[faction]: if figure.has_method("update_combat"): skeletal += 1 check(skeletal >= 5, "Stage shows the real skeletal game models") # ---- setup screen by mouse await click(menu.new_game_button) check(menu.screen == MainMenu.Screen.SETUP and menu.is_transitioning(), "Neue Partie opens the setup with a guarded fade") await settle() check(menu._setup_screen.visible and not menu._title_screen.visible, "Setup replaces the title") check(menu.faction_choice.cards[1].has_focus(), "Focus lands on the selected Bund") var wurzel := menu.faction_choice.cards[1] var glut := menu.faction_choice.cards[0] check(menu.faction_choice._badges[1].visible and not menu.faction_choice._badges[0].visible and menu.faction_choice._hints[0].visible, "Selection has a text badge, not only colour") await click(glut) check(GameState.factions[0] == "glutpakt" and main.hud._shop_buttons.has("g01"), "Clicking Glutpakt selects it and rebuilds the shop") check(menu.stage.current_shot() == "glutpakt", "Shared preview follows the selection") check(menu._setup_caption.text.contains("Glasballiste") and menu._style_heading.text.contains("GLUTPAKT"), "Caption and play style describe the selected Bund") # ---- keyboard selection glut.grab_focus() await key(KEY_RIGHT) check(wurzel.has_focus(), "Arrow key moves between Bund cards") await key(KEY_ENTER) check(GameState.factions[0] == "wurzelbund", "Enter selects the focused Bund") await click(glut) # ---- all previous match options await click(menu.mode_choice.get_button(1)) check(GameState.match_mode == GameState.MODE_SHORT and DataCatalog.wave_count() == 8 and menu.length_summary.text.begins_with("Acht"), "Short match selectable") await click(menu.mode_choice.get_button(0)) check(GameState.match_mode == GameState.MODE_EXPEDITION and "Zwanzig" in main.hud._overlay_title.text, "Expedition selectable") check(not menu._advanced.visible, "Further settings start collapsed") await click(menu._advanced_button) await frames(3) check(menu._advanced.visible, "Further settings open") await click(menu.rival_choice.get_button(0)) check(GameState.factions[1] == "glutpakt", "Rival Bund selectable") await click(menu.ai_choice.get_button(1)) check(GameState.ai_level == GameState.AI_STANDARD, "AI level selectable") await click(menu.ai_choice.get_button(0)) await click(menu.timed_toggle) check(GameState.timed_build and menu._timed_hint.text.contains("45"), "Timed build selectable") await click(menu.timed_toggle) check(not GameState.timed_build, "Timed build can be switched off again") await click(menu._advanced_button) check(menu._advanced_summary.text.contains("Glutpakt") and menu._advanced_summary.text.contains("Lernmodus"), "Collapsed summary names every hidden option") # ---- Esc back and forth await key(KEY_ESCAPE) check(menu.screen == MainMenu.Screen.TITLE, "Esc returns to the title") await settle() await click(menu.new_game_button) await settle() # ---- layout at both target sizes for size: Vector2i in [Vector2i(1280, 720), Vector2i(1920, 1080)]: get_window().size = size await frames(8) check(menu.stage_view.size == get_tree().root.size, "Stage follows window size %dx%d" % [size.x, size.y]) inside(menu.start_button, "Start button %dx%d" % [size.x, size.y]) inside(menu.faction_choice.cards[0], "Glutpakt card %dx%d" % [size.x, size.y]) inside(menu.faction_choice.cards[1], "Wurzelbund card %dx%d" % [size.x, size.y]) check(menu._setup_screen.get_node("SetupPanel").get_global_rect().encloses(menu.start_button.get_global_rect()), "Start button inside the setup panel %dx%d" % [size.x, size.y]) # ---- start: the click that starts, and a click right after, never reach the board var clicks_before: int = EconomyManager.get_team(0).roster.size() await click(menu.start_button) check(GameState.phase == GameState.Phase.BUILD and GameState.factions == ["glutpakt", "glutpakt"], "Partie beginnen starts the chosen setup") check(menu.is_transitioning() or not menu.visible, "Menu leaves behind an input shield") var cell: Vector2 = main.camera.unproject_position(Vector3(-14, 0, 3)) for pressed: bool in [true, false]: var event := InputEventMouseButton.new() event.button_index = MOUSE_BUTTON_LEFT event.position = cell event.pressed = pressed get_viewport().push_input(event, true) await key(KEY_1) check(EconomyManager.get_team(0).roster.size() == clicks_before and main.selected_uid < 0 and main.build_type.is_empty(), "No click or game key reaches the board during the fade") main._clear_selection() await get_tree().create_timer(.45).timeout check(not menu.visible and not get_tree().root.disable_3d, "Menu hides and the arena renders again") check(menu.stage_view.render_target_update_mode == SubViewport.UPDATE_DISABLED and menu.stage.process_mode == Node.PROCESS_MODE_DISABLED, "Stage stops rendering and animating during play") var expected: int = EconomyManager.get_team(0).roster.size() + EconomyManager.get_team(1).roster.size() var staged: bool = true for faction: String in menu.stage.figures: for figure: Node3D in menu.stage.figures[faction]: staged = staged and menu.stage.is_ancestor_of(figure) check(main.battle.units.size() == expected and EconomyManager.get_team(0).roster.is_empty() and staged, "Only roster units enter the match; stage figures stay in the stage") check(EconomyManager.get_team(0).gold == 220 and GameState.wave == 1, "Match starts with ordinary resources") await click(main.hud._starter_button) check(EconomyManager.get_team(0).roster.size() == 3, "Board input works after the fade") # ---- return to the menu and start again main.hud.main_menu.pending_screen = MainMenu.Screen.SETUP main.hud.menu_requested.emit() await frames(4) check(GameState.phase == GameState.Phase.MENU and menu.visible and menu.screen == MainMenu.Screen.SETUP, "Neue Partie in the options returns to the Bund selection") check(get_tree().root.disable_3d and EconomyManager.get_team(0).roster.is_empty(), "Returning clears the match and resumes the stage") await click(menu.faction_choice.cards[1]) await click(menu.start_button) check(GameState.phase == GameState.Phase.BUILD and GameState.factions[0] == "wurzelbund" and EconomyManager.get_team(0).gold == 220, "Second start uses the new choice") await get_tree().create_timer(.45).timeout # ---- result screen and replay GameState.outcome = 1 GameState.core_hp = [0, 1500] GameState.set_phase(GameState.Phase.GAME_OVER) await frames(4) check(menu.visible and menu._result_panel.visible and menu.result_title.text == "NIEDERLAGE AM SAUM", "Result screen names the outcome in words") check(menu.new_game_button.text == "NOCHMAL SPIELEN" and menu._change_button.visible, "Result offers replay and setup") await click(menu.new_game_button) check(GameState.phase == GameState.Phase.BUILD and GameState.wave == 1 and GameState.core_hp == [3000, 3000], "Replay starts a fresh match") await get_tree().create_timer(.45).timeout main.hud.menu_requested.emit() await frames(4) # ---- options from the menu, reduced motion, settings round trip main.hud.toggle_settings() check(main.hud.settings_panel.visible and main.hud._reduced_check != null, "Options open from the menu with Bewegung reduzieren") main.hud._reduced_check.button_pressed = true await frames(2) check(Preferences.reduced_motion and menu.reduced_motion and menu.stage.reduced_motion, "Reduced motion reaches menu and stage") var particles_hidden: bool = true for particles: GPUParticles3D in menu.stage._particles: particles_hidden = particles_hidden and not particles.visible check(particles_hidden, "Reduced motion hides menu particles") main.hud.toggle_settings() await click(menu.new_game_button) check(not menu.is_transitioning() and not menu.stage.is_moving(), "Reduced motion switches screens and shots instantly") Preferences.reduced_motion = false Preferences.read_settings() check(Preferences.reduced_motion, "Reduced motion survives a settings reload") # ---- report DirAccess.remove_absolute(Preferences.storage_path) Preferences.storage_path = stored_path Preferences.reduced_motion = stored_reduced Preferences.apply() var report := {"checks": checks, "failures": failures, "menu_instantiate_ms": loaded, "formations_ready_ms": ready_ms, "renderer": RenderingServer.get_current_rendering_method(), "engine": Engine.get_version_info().string} var file := FileAccess.open("res://docs/phase20-menu-verification.json", FileAccess.WRITE) file.store_string(JSON.stringify(report, "\t")) file.close() print("MENU_VERIFY_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()]) await Soundscape.quit_game(0 if failures.is_empty() else 1)