saumwacht-godot/tools/verify_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

239 lines
13 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 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)