JSON at user://savegame.json rather than a binary resource: a save that can
be opened in a text editor is diagnosable from a bug report, and
ResourceSaver would tie the file's shape to GDScript class layout, which is
the thing that keeps changing.
The run is not plain data, which is the actual work here. A tagged codec
carries Vector2, Vector2i, PackedStringArray and the Inventory objects
nested inside `buildings`. explored_chunks is keyed by Vector2i and JSON has
no such key, so every key carries its own type prefix ("v2i:3,-4") - without
that a planet id could come back as a chunk coordinate.
WHAT is saved is an explicit list of 30 fields, not a dump of GameState's
58 vars. A dump would carry transient junk - which pointer last touched the
screen, whether a dialog holds the camera - and would break silently on
every rename. The cost is that new progression must be added to the list;
the benefit is that forgetting is a missing feature, not a corrupt save.
Versioning, as the ticket asked. MIGRATIONS walks a save up one version at a
time; MIN_SUPPORTED_VERSION is how a break too messy to migrate gets
DECLARED rather than bodged. A save that cannot be walked forward is refused
with a player-readable reason, because a save that loads subtly WRONG is
worse than one that refuses and says so. Adding a field needs no bump: it
arrives missing from old saves and keeps its new-game value (case F).
The menu offers Continue only when there is something to offer. A save this
build CANNOT open is still shown, disabled, with the reason underneath -
silently hiding someone's run reads as lost rather than incompatible.
Autosave every 30s, plus on close and on Android's app-pause, since the
phone kills backgrounded apps with no warning. Skipped during the prologue.
That autosave writes to the same user:// path every test that loads
Main.tscn shares, so it could make Continue appear and shift the menu stack
under test_main_menu depending on what ran first. That test clears the save
before building the menu.
Verified by breaking it: neutering the key decoder fails with "chunk keys
came back as [v2i:-11,0, v2i:3,-4]".
Still absent, deliberately: no save-on-demand button, and no route back to
the menu from in-game, so a resume currently needs a relaunch.
175/175.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
241 lines
9.4 KiB
GDScript
241 lines
9.4 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
|
|
_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"
|