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
117 lines
3.5 KiB
GDScript
117 lines
3.5 KiB
GDScript
extends SceneTree
|
|
|
|
# Forgejo #225: the menu offers to resume a run, and only when there is one.
|
|
#
|
|
# Covers:
|
|
# A. with no save on disk there is no Continue entry at all - a first-time
|
|
# player must not meet a button that does nothing
|
|
# B. with a save, Continue appears and is offered
|
|
# C. pressing it loads the run and starts the game, rather than resetting
|
|
# to a new one the way Play does
|
|
# D. a save this build cannot open is still SHOWN, disabled, with the
|
|
# reason - a run that silently vanishes reads as lost, not incompatible
|
|
|
|
var game_state: Node
|
|
var menu: Node
|
|
var elapsed: float = 0.0
|
|
var stage: int = 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
game_state = root.get_node("GameState")
|
|
SaveGame.clear()
|
|
|
|
|
|
func _rebuild_menu() -> void:
|
|
if menu != null and is_instance_valid(menu):
|
|
menu.free()
|
|
menu = load("res://ui/MainMenu.tscn").instantiate()
|
|
root.add_child(menu)
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
elapsed += delta
|
|
match stage:
|
|
0:
|
|
if elapsed < 0.3:
|
|
return false
|
|
# --- A. nothing saved, nothing offered ----------------------
|
|
_rebuild_menu()
|
|
elapsed = 0.0
|
|
stage = 1
|
|
return false
|
|
1:
|
|
if elapsed < 0.3:
|
|
return false
|
|
if menu.continue_button.visible:
|
|
return _fail("a Continue entry is offered with no save on disk")
|
|
print("no save: no Continue entry")
|
|
|
|
# --- B. a saved run is offered ------------------------------
|
|
game_state.reset_progression()
|
|
game_state.reset_new_game()
|
|
game_state.research_points = 31
|
|
game_state.intro_active = false
|
|
if not SaveGame.save(game_state):
|
|
return _fail("could not write a save to offer")
|
|
_rebuild_menu()
|
|
elapsed = 0.0
|
|
stage = 2
|
|
return false
|
|
2:
|
|
if elapsed < 0.3:
|
|
return false
|
|
if not menu.continue_button.visible:
|
|
return _fail("a save exists but no Continue entry is offered")
|
|
if not menu.continue_button.enabled:
|
|
return _fail("Continue is offered but disabled: '%s'" % menu.status_label.text)
|
|
print("a saved run is offered")
|
|
|
|
# --- C. and pressing it resumes rather than resets -----------
|
|
game_state.research_points = 0
|
|
menu._on_continue_pressed()
|
|
if game_state.research_points != 31:
|
|
return _fail("Continue did not restore the run (research %d, expected 31)" \
|
|
% game_state.research_points)
|
|
if menu.loading_path == "":
|
|
return _fail("Continue restored the run but never started the game")
|
|
print("Continue restored the run and started the game")
|
|
|
|
# --- D. an unopenable save is shown, not hidden --------------
|
|
_corrupt_version()
|
|
_rebuild_menu()
|
|
elapsed = 0.0
|
|
stage = 3
|
|
return false
|
|
3:
|
|
if elapsed < 0.3:
|
|
return false
|
|
if not menu.continue_button.visible:
|
|
return _fail("a save this build cannot open was hidden, so the run looks lost")
|
|
if menu.continue_button.enabled:
|
|
return _fail("a save this build cannot open is still offered as loadable")
|
|
if menu.status_label.text.strip_edges() == "":
|
|
return _fail("an unopenable save is disabled with no reason given")
|
|
print("an unopenable save is shown, disabled: %s" % menu.status_label.text)
|
|
|
|
SaveGame.clear()
|
|
print("TEST_MAIN_MENU_CONTINUE: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|
|
|
|
|
|
func _corrupt_version() -> void:
|
|
var f: FileAccess = FileAccess.open("user://savegame.json", FileAccess.READ)
|
|
var data: Dictionary = JSON.parse_string(f.get_as_text())
|
|
f.close()
|
|
data["version"] = 999
|
|
f = FileAccess.open("user://savegame.json", FileAccess.WRITE)
|
|
f.store_string(JSON.stringify(data))
|
|
f.close()
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
print("TEST_MAIN_MENU_CONTINUE: FAIL (%s)" % message)
|
|
quit(1)
|
|
return true
|