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