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
242 lines
9.5 KiB
GDScript
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"
|