spaceship/ui/MainMenu.gd
Claude 62acb0052c #227: resume at the mothership, with the crew the save had
Loading a save fell through to the brand-new-game branch in
PlanetAtmosphereController, which spawns STARTING_SHIP_COUNT interceptors at
the world origin. So resuming stranded the player in empty space with the
wrong ships while their base sat wherever they had left it - thousands of
units away on a charted map.

fleet_position was in the save all along, but nothing ever read it: only
Fleet writes it, as a mirror for payment-range checks.

A resumed run now spawns at get_mothership_position(). That is already the
canonical home - BuildController._check_fleet_wipe rebuilds a wiped fleet
there - so this is the existing answer to "where does the fleet belong",
not a new one. It falls back to the position the save was written at, and
only then to the origin, because a run can have no mothership standing.

resumed_from_save is a one-shot handoff like has_return_position, and is
deliberately NOT saved: it describes this launch, not the run.

Verified by reverting the branch: "resumed at (0.0, -48.0), but the
mothership is at (7400.0, -3100.0)".

One note for whoever writes the next fleet test: asserting a three-ship crew
proves nothing without capacity. Since #219 the fleet holds BASE_FLEET_SIZE
(1) plus one berth per supplied fleet office, so spawn_ships clamps to 1 and
the assertion passes against the wrong number. Case A gives the save two
offices and checks max_fleet_size() first.

176/176 at JOBS=3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-12 15:57:48 +02:00

242 lines
9.5 KiB
GDScript

extends Node2D
# The menu stacks whatever entries are actually available, rather than
# trusting the positions authored in the scene.
#
# Two of the three entries only exist when the game is run from Godot, so a
# shipped build shows one entry and an editor run shows three. There is no
# Quit: a web build has nothing to quit TO, and on Android the system back
# gesture already does it. Fixed
# offsets would leave holes where the hidden entries used to be - the same
# failure the HUD's button columns had, where a literal y meant "below the
# thing above me" and stopped being true when that thing moved. Here the
# stack is measured instead, and centred on whatever it ends up containing.
const ROW_BUTTON_HEIGHT: float = 128.0
const ROW_LABEL_OFFSET: float = 134.0
const ROW_LABEL_HEIGHT: float = 56.0
const ROW_HEIGHT: float = ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
const ROW_GAP: float = 20.0
const ROW_PITCH: float = ROW_HEIGHT + ROW_GAP
# The stack is centred on this rather than on the screen, to leave the title
# above it room to breathe.
const STACK_CENTER_Y: float = 40.0
const STATUS_GAP: float = 20.0
const STATUS_HEIGHT: float = 70.0
@onready var play_button: IconButton = $UI/PlayButton
@onready var play_label: Label = $UI/PlayLabel
@onready var continue_button: IconButton = $UI/ContinueButton
@onready var continue_label: Label = $UI/ContinueLabel
@onready var editor_button: IconButton = $UI/EditorButton
@onready var editor_label: Label = $UI/EditorLabel
@onready var dev_button: IconButton = $UI/DevModeButton
@onready var dev_label: Label = $UI/DevModeLabel
@onready var status_label: Label = $UI/StatusLabel
# #169: what is being opened, and the cover that says so. Empty path means
# nothing is loading, which is also what stops a second press starting a
# second scene - on the web a button fires on PRESS (see IconButton), and an
# impatient double tap on Play would otherwise build the world twice.
const LOADING_SCREEN = preload("res://ui/LoadingScreen.gd")
# How the player is told what they are waiting for. Keyed by scene path so a
# new entry cannot go in without a name to put on the screen.
const SCENE_NAMES: Dictionary = {
"res://world/Main.tscn": "Entering the convoy lane",
"res://surface/PlanetSetupEditor.tscn": "Opening the map editor",
}
# The load only starts once the cover has been up for a frame or two. One
# would do; two is cheap and removes any argument about whether the first one
# reached the screen.
const COVER_FRAMES: int = 2
var loading_path: String = ""
var loading_screen: Control = null
func _ready() -> void:
# #148: the theme starts here, at the first screen, and keeps playing
# through every scene after it.
Music.ensure(get_tree())
# Same moment, same reason: build the sfx voices before the first
# sound needs them rather than during it.
Sfx.warm(get_tree())
play_button.pressed.connect(_on_play_pressed)
# Offered only when there is something to offer, so a first-time player
# never sees a button that does nothing. #225.
continue_button.pressed.connect(_on_continue_pressed)
var save: Dictionary = SaveGame.peek()
continue_button.visible = not save.is_empty()
continue_label.visible = continue_button.visible
if not save.is_empty() and not bool(save.get("loadable", false)):
# A save that cannot be opened is still shown, greyed, with the
# reason under it - silently hiding someone's run makes it look
# lost rather than incompatible.
continue_button.enabled = false
status_label.text = String(save.get("refusal", ""))
editor_button.pressed.connect(_on_editor_pressed)
editor_button.enabled = true
dev_button.pressed.connect(_on_dev_mode_pressed)
dev_button.enabled = true
# Dev mode and the map editor are development tools, not features of the
# game: they are for building and play-testing it, and a player who
# installed a build has no use for either. `editor` is the feature tag
# Godot sets when running from the editor and clears in an export, which
# is exactly the "started from Godot" distinction wanted here.
var developing: bool = is_developer_build()
editor_button.visible = developing
editor_label.visible = developing
dev_button.visible = developing
dev_label.visible = developing
_layout_stack()
# Wrapped so tests can state the rule they are checking rather than
# duplicating the feature-tag string.
func is_developer_build() -> bool:
return OS.has_feature("editor")
func _layout_stack() -> void:
var rows: Array = []
for row in [[continue_button, continue_label], [play_button, play_label],
[editor_button, editor_label],
[dev_button, dev_label]]:
if row[0].visible:
rows.append(row)
if rows.is_empty():
return
# The last row carries no trailing gap, so the block is one pitch per row
# minus that gap.
var block_height: float = float(rows.size()) * ROW_PITCH - ROW_GAP
var top: float = STACK_CENTER_Y - block_height * 0.5
for row in rows:
var button: Control = row[0]
var label: Label = row[1]
button.offset_top = top
button.offset_bottom = top + ROW_BUTTON_HEIGHT
label.offset_top = top + ROW_LABEL_OFFSET
label.offset_bottom = top + ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
top += ROW_PITCH
# Status sits under the whole stack, where a message about any entry can
# be read without it shifting the entries themselves.
var status_top: float = top - ROW_GAP + STATUS_GAP
status_label.offset_top = status_top
status_label.offset_bottom = status_top + STATUS_HEIGHT
func _on_continue_pressed() -> void:
if loading_path != "":
return
var result: Dictionary = SaveGame.load_into(GameState)
if not bool(result.get("ok", false)):
status_label.text = String(result.get("reason", "That run could not be opened."))
continue_button.enabled = false
return
# A resumed run never replays the prologue, whatever it was doing when
# it was written.
GameState.intro_active = false
GameState.resumed_from_save = true
_start("res://world/Main.tscn")
func _on_play_pressed() -> void:
# Checked here as well as in _start: this one has a side effect, and a
# second press while the first is still loading must not wipe the game
# that first press already set up (#169).
if loading_path != "":
return
GameState.reset_new_game()
_start("res://world/Main.tscn")
# Loaded, built and added by hand rather than through change_scene_to_file().
# That call does all three in one opaque step, so when the web build hung
# there was no way to tell which part failed. Split out, each step can say so
# - and a scene change that fails otherwise leaves the menu up, which looks
# exactly like an unpressed button.
#
# #169: and now it does not look like anything else either. The press raises a
# loading screen straight away and the load itself happens over the following
# frames, driven from _process below, so the wait is something the player is
# watching rather than a menu that has stopped responding.
func _start(scene_path: String) -> void:
if loading_path != "":
return
loading_path = scene_path
status_label.text = ""
loading_screen = LOADING_SCREEN.new()
$UI.add_child(loading_screen)
loading_screen.begin(String(SCENE_NAMES.get(scene_path, scene_path.get_file())))
# The load itself is still the ordinary blocking one, deliberately.
#
# ResourceLoader.load_threaded_request() was the obvious alternative and it
# does not work here: asked for these scenes it comes back FAILED, with the
# text-scene parser reporting errors ("Parse Error ... Rock.tscn:9", "Could
# not resolve external class member PROFILES") that the same files do not
# produce through load(). The project's scripts and scenes reach each other
# through global class names and preloads, and that resolution is not
# available off the loading thread. A progress bar is not worth a menu that
# sometimes refuses to start the game - and the web build could not have used
# the threaded path anyway, since it ships with thread support off.
#
# So what the cover buys is honest and worth having on its own: the press is
# answered instantly, the player is told what is opening, and the frame that
# blocks happens with something on the screen rather than behind a menu that
# has stopped responding.
func _process(_delta: float) -> void:
if loading_path == "":
return
# Nothing blocking until the cover is actually up - see LoadingScreen's
# own note. This is the whole reason the load waits for a frame at all.
if loading_screen == null or loading_screen.frames_shown < COVER_FRAMES:
return
var scene_path: String = loading_path
loading_path = ""
var packed: PackedScene = load(scene_path)
if packed == null:
_abandon_loading("Could not load %s" % scene_path.get_file())
return
var scene: Node = packed.instantiate()
if scene == null:
_abandon_loading("Could not build %s" % scene_path.get_file())
return
get_tree().root.add_child(scene)
get_tree().current_scene = scene
queue_free()
# A failed load must leave a working menu behind, not a cover over a screen
# with nothing coming.
func _abandon_loading(message: String) -> void:
if loading_screen != null:
loading_screen.queue_free()
loading_screen = null
status_label.text = message
func _on_dev_mode_pressed() -> void:
if loading_path != "":
return
# Guarded as well as hidden. Visibility is presentation; this is the rule.
if not is_developer_build():
status_label.text = "Dev mode only runs from Godot"
return
GameState.start_dev_game()
_start("res://world/Main.tscn")
func _on_editor_pressed() -> void:
if not is_developer_build():
status_label.text = "Map editor only runs from Godot"
return
if editor_button.enabled:
_start("res://surface/PlanetSetupEditor.tscn")
return
status_label.text = "Map editor coming soon"