From a1fb239a50f3ca44d71f3a0eae47b7d99f2e68ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 14:43:46 +0200 Subject: [PATCH] Add a main menu scene (#47) MainMenu.tscn is now project.godot's entry point: Play / Map Editor (disabled, explains itself - #48 wires it up) / Quit (hidden in web builds), styled like the rest of the game's menus and reusing the starfield shader for its background. Play calls GameState.reset_new_game(), so a second playthrough in the same process starts fresh instead of inheriting whatever the previous run left in the autoload - existing tests still load Main.tscn directly and are unaffected. Part of the #40 conquest-mechanic breakdown. --- GameState.gd | 27 +++++++ MainMenu.gd | 33 +++++++++ MainMenu.gd.uid | 1 + MainMenu.tscn | 139 ++++++++++++++++++++++++++++++++++++ project.godot | 2 +- tests/test_main_menu.gd | 95 ++++++++++++++++++++++++ tests/test_main_menu.gd.uid | 1 + 7 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 MainMenu.gd create mode 100644 MainMenu.gd.uid create mode 100644 MainMenu.tscn create mode 100644 tests/test_main_menu.gd create mode 100644 tests/test_main_menu.gd.uid diff --git a/GameState.gd b/GameState.gd index 0f2a3db..5487299 100644 --- a/GameState.gd +++ b/GameState.gd @@ -344,6 +344,33 @@ func reset_progression() -> void: vessel_levels.clear() +# Called from the main menu's Play button (#47): the autoload survives +# change_scene_to_file(), so without this a second playthrough in the same +# process would start from wherever the last one left off instead of a +# fresh game. +func reset_new_game() -> void: + has_return_position = false + surviving_ship_count = BASE_FLEET_SIZE + surviving_vessel_types = PackedStringArray() + return_world_position = Vector2.ZERO + return_camera_zoom = 1.0 + planet_name = "" + planet_id = "" + planet_definition_id = "rocky" + explored_chunks.clear() + bank_inventory = INVENTORY.new({RESOURCE_ORE: 500}) + fleet_inventory = INVENTORY.new() + buildings.clear() + next_building_id = 1 + stored_vessels.clear() + vessel_deliveries.clear() + extractors.clear() + transporters.clear() + planet_capture.clear() + reset_progression() + reset_formation() + + # --- building stock and production queue (#16) ----------------------------- # Since #12, a building cannot just deduct from the bank at the moment of diff --git a/MainMenu.gd b/MainMenu.gd new file mode 100644 index 0000000..e5afbcb --- /dev/null +++ b/MainMenu.gd @@ -0,0 +1,33 @@ +extends Node2D + +@onready var play_button: IconButton = $UI/PlayButton +@onready var editor_button: IconButton = $UI/EditorButton +@onready var quit_button: IconButton = $UI/QuitButton +@onready var status_label: Label = $UI/StatusLabel + + +func _ready() -> void: + play_button.pressed.connect(_on_play_pressed) + editor_button.pressed.connect(_on_editor_pressed) + # #48 wires the real map editor scene up to this button; until then it + # stays visible but explains itself instead of silently doing nothing + # or pointing at a scene that doesn't exist yet. + editor_button.enabled = false + quit_button.pressed.connect(_on_quit_pressed) + quit_button.visible = not OS.has_feature("web") + + +func _on_play_pressed() -> void: + GameState.reset_new_game() + get_tree().change_scene_to_file("res://Main.tscn") + + +func _on_editor_pressed() -> void: + if editor_button.enabled: + get_tree().change_scene_to_file("res://PlanetSetupEditor.tscn") + return + status_label.text = "Map editor coming soon" + + +func _on_quit_pressed() -> void: + get_tree().quit() diff --git a/MainMenu.gd.uid b/MainMenu.gd.uid new file mode 100644 index 0000000..165914d --- /dev/null +++ b/MainMenu.gd.uid @@ -0,0 +1 @@ +uid://p7yl0hc43yai diff --git a/MainMenu.tscn b/MainMenu.tscn new file mode 100644 index 0000000..7cf4450 --- /dev/null +++ b/MainMenu.tscn @@ -0,0 +1,139 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://MainMenu.gd" id="1"] +[ext_resource type="Script" path="res://IconButton.gd" id="2"] +[ext_resource type="Shader" path="res://starfield.gdshader" id="3"] + +[sub_resource type="ShaderMaterial" id="1"] +shader = ExtResource("3") +shader_parameter/camera_cell = Vector2(0, 0) +shader_parameter/camera_frac = Vector2(0, 0) +shader_parameter/camera_zoom = 1.0 +shader_parameter/viewport_size = Vector2(720, 1280) +shader_parameter/cell_size = 60.0 +shader_parameter/bg_color = Color(0.02, 0.02, 0.08, 1) + +[node name="MainMenu" type="Node2D"] +script = ExtResource("1") + +[node name="BackgroundLayer" type="CanvasLayer" parent="."] +layer = -10 + +[node name="Starfield" type="ColorRect" parent="BackgroundLayer"] +material = SubResource("1") +mouse_filter = 2 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="UI" type="CanvasLayer" parent="."] + +[node name="TitleLabel" type="Label" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -300 +offset_right = 300 +offset_top = -560 +offset_bottom = -460 +theme_override_font_sizes/font_size = 60 +horizontal_alignment = 1 +text = "SPACESHIP TEST" + +[node name="PlayButton" type="Control" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = -300 +offset_bottom = -172 +script = ExtResource("2") +icon = 2 +glow_color = Color(0.35, 0.95, 0.55, 1) + +[node name="PlayLabel" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -160 +offset_right = 160 +offset_top = -160 +offset_bottom = -94 +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 +text = "Play" + +[node name="EditorButton" type="Control" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = -20 +offset_bottom = 108 +script = ExtResource("2") +icon = 0 +glow_color = Color(0.5, 0.75, 1, 1) + +[node name="EditorLabel" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -200 +offset_right = 200 +offset_top = 120 +offset_bottom = 186 +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 +text = "Map Editor" + +[node name="StatusLabel" type="Label" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -260 +offset_right = 260 +offset_top = 200 +offset_bottom = 260 +theme_override_font_sizes/font_size = 26 +horizontal_alignment = 1 +autowrap_mode = 2 +text = "" + +[node name="QuitButton" type="Control" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = 320 +offset_bottom = 448 +script = ExtResource("2") +icon = 1 +glow_color = Color(0.9, 0.35, 0.35, 1) + +[node name="QuitLabel" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -160 +offset_right = 160 +offset_top = 460 +offset_bottom = 526 +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 +text = "Quit" diff --git a/project.godot b/project.godot index 05d44a8..0dcc344 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="Spaceship Test" -run/main_scene="res://Main.tscn" +run/main_scene="res://MainMenu.tscn" config/features=PackedStringArray("4.7") config/icon="res://icon.svg" diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd new file mode 100644 index 0000000..5193f33 --- /dev/null +++ b/tests/test_main_menu.gd @@ -0,0 +1,95 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_main_menu.gd +# Verifies: +# 1. the menu loads with Play/Map Editor/Quit, the editor button starts +# disabled and explains itself instead of silently doing nothing +# 2. Quit's visibility follows OS.has_feature("web") (this platform is +# not a web export, so this run exercises the "visible" branch; the +# condition itself is what's under test, not the concrete boolean) +# 3. Play resets GameState to a fresh game AND reaches Main.tscn + +var menu: Node +var game_state: Node +var elapsed: float = 0.0 +var stage: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + menu = load("res://MainMenu.tscn").instantiate() + root.add_child(menu) + current_scene = menu + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # A scene added to the tree inside _initialize() has not had its + # _ready() flushed yet - see test_planet_setups.gd for the same + # hazard - so the menu's @onready buttons aren't valid until a + # frame has actually processed. + if elapsed < 0.1: + return false + if menu.editor_button.enabled: + return _fail("map editor button is not disabled ahead of #48") + if menu.quit_button.visible != (not OS.has_feature("web")): + return _fail("quit button visibility did not follow OS.has_feature('web')") + + _press(menu.editor_button) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.1: + return false + if "coming soon" not in menu.status_label.text.to_lower(): + return _fail("disabled editor button press did not explain itself") + if current_scene != menu: + return _fail("pressing the disabled editor button changed scenes") + + # Dirty the game state exactly like an in-progress run would, + # so a genuine reset is distinguishable from a coincidental match. + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 999) + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + game_state.surviving_ship_count = 1 + game_state.planet_capture["chunk:1:1"] = {"captured": [true], "conquered": true} + + _press(menu.play_button) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + var on_main: bool = current_scene != null and current_scene.name == "Main" + var reset_ok: bool = game_state.rocks == 500 and game_state.buildings.is_empty() \ + and game_state.surviving_ship_count == GameState.BASE_FLEET_SIZE \ + and game_state.planet_capture.is_empty() + print("on_main=%s reset_ok=%s rocks=%d buildings=%d ship_count=%d" % [ + on_main, reset_ok, game_state.rocks, game_state.buildings.size(), + game_state.surviving_ship_count + ]) + if not on_main or not reset_ok: + return _fail("Play did not reset to a fresh game before reaching Main.tscn") + print("TEST_MAIN_MENU: PASS") + quit(0) + return true + return false + + +func _press(button: IconButton) -> void: + var down: InputEventScreenTouch = InputEventScreenTouch.new() + down.pressed = true + button._gui_input(down) + var up: InputEventScreenTouch = InputEventScreenTouch.new() + up.pressed = false + button._gui_input(up) + + +func _fail(message: String) -> bool: + print("TEST_MAIN_MENU: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_main_menu.gd.uid b/tests/test_main_menu.gd.uid new file mode 100644 index 0000000..984875e --- /dev/null +++ b/tests/test_main_menu.gd.uid @@ -0,0 +1 @@ +uid://dgf2ichdb7d4j