From fb3587173d86e0a243e12f27a307ca1740cf9a23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 21:32:23 +0200 Subject: [PATCH] #225: save the run, and offer to resume it 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 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- controllers/GameController.gd | 29 ++++ core/SaveGame.gd | 243 +++++++++++++++++++++++++++++++ tests/test_main_menu.gd | 5 + tests/test_main_menu_continue.gd | 117 +++++++++++++++ tests/test_save_game.gd | 179 +++++++++++++++++++++++ ui/MainMenu.gd | 31 +++- ui/MainMenu.tscn | 31 ++++ 7 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 core/SaveGame.gd create mode 100644 tests/test_main_menu_continue.gd create mode 100644 tests/test_save_game.gd diff --git a/controllers/GameController.gd b/controllers/GameController.gd index b15b5f5..6e2aa03 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -102,6 +102,14 @@ var keyboard_seen: bool = false var keyboard_steering: bool = false +# Seconds between automatic saves. Long enough that writing the file is +# never felt, short enough that a lost run is an annoyance rather than an +# evening. +const AUTOSAVE_INTERVAL: float = 30.0 + +var autosave_countdown: float = AUTOSAVE_INTERVAL + + func _ready() -> void: # Most visuals still animate per rendered frame. The main/surface scenes opt # their roots out of global interpolation, then opt their physics-follow @@ -150,6 +158,12 @@ func _held_pivot_direction() -> float: func _notification(what: int) -> void: if what == NOTIFICATION_UNPAUSED: _forget_released_keys() + # #225: the phone is where this is played, and Android kills an app in + # the background without warning - so the run is written on the way out + # rather than only on a timer. + elif what == NOTIFICATION_WM_CLOSE_REQUEST \ + or what == NOTIFICATION_APPLICATION_PAUSED: + autosave() func _forget_released_keys() -> void: @@ -296,7 +310,22 @@ func _on_joystick_direction(direction: Vector2) -> void: fleet.stop() +# Written on a timer as well as on the way out: a crash or a battery pull +# gives no notification at all, and losing half an hour is what makes a save +# system feel absent even when it is there. +func autosave() -> void: + if GameState.intro_active: + # Nothing worth keeping yet, and saving mid-prologue would resume + # into a run that has not been set up. + return + SaveGame.save(GameState) + autosave_countdown = AUTOSAVE_INTERVAL + + func _process(delta: float) -> void: + autosave_countdown -= delta + if autosave_countdown <= 0.0: + autosave() pivot_cw_tap_cooldown_left = maxf(pivot_cw_tap_cooldown_left - delta, 0.0) pivot_ccw_tap_cooldown_left = maxf(pivot_ccw_tap_cooldown_left - delta, 0.0) _update_pivot(delta) diff --git a/core/SaveGame.gd b/core/SaveGame.gd new file mode 100644 index 0000000..c1abc5d --- /dev/null +++ b/core/SaveGame.gd @@ -0,0 +1,243 @@ +class_name SaveGame +extends RefCounted + +# Forgejo #225. Writing the run to disk and reading it back. +# +# Saves are JSON rather than a binary resource: a save that can be opened in +# a text editor can be diagnosed from a bug report, and ResourceSaver would +# tie the file's shape to GDScript class layout, which is exactly the thing +# that keeps changing. +# +# WHAT IS SAVED is an explicit list (FIELDS), not every var on GameState. +# A blind dump would carry transient junk - which pointer last touched the +# screen, whether a dialog is currently holding the camera - and would break +# silently every time a field was renamed. The cost is that a new piece of +# progression has to be added to the list; the benefit is that forgetting to +# is a missing feature rather than a corrupt save. + +const SAVE_PATH: String = "user://savegame.json" + +# Bumped whenever the SHAPE of the saved data changes - a field renamed, a +# structure reorganised. Adding a brand new field does not need a bump: it +# simply arrives missing from old saves and keeps whatever GameState starts +# it at. +const SAVE_VERSION: int = 1 + +# The oldest version this build can still open. Raising it is how a format +# change too messy to migrate is DECLARED rather than bodged: #225 is +# explicit that breaking compatibility is the right call when the transition +# is too complex, and a save that loads subtly WRONG is far worse than one +# that refuses to load and says so. +const MIN_SUPPORTED_VERSION: int = 1 + +# version N -> N+1, keyed by the version being migrated FROM. A save is +# walked up one step at a time until it reaches SAVE_VERSION. A gap here is +# not an oversight - it is the declaration that saves below it are +# incompatible, and load() refuses them by name rather than guessing. +const MIGRATIONS: Dictionary = {} + +# The run, field by field. Grouped by what they mean rather than by type, so +# it is obvious what a new piece of progression sits next to. +const FIELDS: Array = [ + # The purse and the hold. + "bank_inventory", "fleet_inventory", + # What has been built, and what has been taken. + "buildings", "next_building_id", "extractors", "planet_capture", + # What has been learned. + "unlocked", "research_points", "vessel_levels", + # The fleet itself. + "fleet_position", "fleet_vessel_types", "surviving_ship_count", + "surviving_vessel_types", "formation", "stored_vessels", + "vessel_targets", "vessel_deliveries", "repair_drones", + "max_fleet_speed_multiplier", "fleet_speed_multiplier", "build_time_scale", + # The world as the player has seen it. + "explored_chunks", "scanner_target", + # The siege. + "wave_number", "wave_phase", "wave_timer", + # The campaign. + "quests_armed", "quest_index", "quest_announced", "quest_messages", + "visited_planets", "visited_planet_kinds", +] + + +static func has_save(path: String = SAVE_PATH) -> bool: + return FileAccess.file_exists(path) + + +# What the "load your last run?" prompt needs, without committing to loading +# it. Returns an empty dictionary when there is nothing to offer. +static func peek(path: String = SAVE_PATH) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var raw: Dictionary = _read(path) + if raw.is_empty(): + return {} + return { + "version": int(raw.get("version", 0)), + "saved_at": String(raw.get("saved_at", "")), + "loadable": _refusal(int(raw.get("version", 0))) == "", + "refusal": _refusal(int(raw.get("version", 0))), + } + + +static func save(state: Node, path: String = SAVE_PATH) -> bool: + if state == null: + return false + var fields: Dictionary = {} + for name in FIELDS: + fields[name] = _encode(state.get(name)) + var payload: Dictionary = { + "version": SAVE_VERSION, + "saved_at": Time.get_datetime_string_from_system(true), + "fields": fields, + } + var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify(payload, "\t")) + file.close() + return true + + +# Applies a save to GameState. Returns {ok, reason} - reason is empty on +# success and player-readable on failure, because "it did not load" with no +# explanation is the thing that makes people delete their save folder. +static func load_into(state: Node, path: String = SAVE_PATH) -> Dictionary: + if state == null: + return {"ok": false, "reason": "No game to load into."} + if not FileAccess.file_exists(path): + return {"ok": false, "reason": "There is no saved run."} + var raw: Dictionary = _read(path) + if raw.is_empty(): + return {"ok": false, "reason": "The saved run could not be read - the file is damaged."} + var version: int = int(raw.get("version", 0)) + var refusal: String = _refusal(version) + if refusal != "": + return {"ok": false, "reason": refusal} + var fields: Dictionary = raw.get("fields", {}) + if not (fields is Dictionary) or fields.is_empty(): + return {"ok": false, "reason": "The saved run has no contents."} + while version < SAVE_VERSION: + fields = (MIGRATIONS[version] as Callable).call(fields) + version += 1 + for name in FIELDS: + # A field the save predates keeps whatever a new game starts it at, + # which is what makes ADDING progression a non-breaking change. + if fields.has(name): + state.set(name, _decode(fields[name])) + return {"ok": true, "reason": ""} + + +static func clear(path: String = SAVE_PATH) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +# Why this version cannot be opened, or "" if it can. +static func _refusal(version: int) -> String: + if version <= 0: + return "The saved run does not say what version it is." + if version > SAVE_VERSION: + return "That run was saved by a NEWER version of the game (save %d, this build reads %d)." \ + % [version, SAVE_VERSION] + if version < MIN_SUPPORTED_VERSION: + return "That run was saved by an older version of the game (save %d) and cannot be opened by this one." \ + % version + var at: int = version + while at < SAVE_VERSION: + if not MIGRATIONS.has(at): + return "That run was saved by an older version of the game (save %d) and there is no way to bring it forward." \ + % version + at += 1 + return "" + + +static func _read(path: String) -> Dictionary: + var file: FileAccess = FileAccess.open(path, FileAccess.READ) + if file == null: + return {} + var text: String = file.get_as_text() + file.close() + var parsed: Variant = JSON.parse_string(text) + return parsed if parsed is Dictionary else {} + + +# --- the type-tagged codec --------------------------------------------- +# JSON has numbers, strings, arrays and string-keyed objects. The run has +# Vector2s, chunk coordinates used AS dictionary keys, and Inventory +# objects, so each of those is written with a tag saying what to rebuild. + +static func _encode(value: Variant) -> Variant: + if value is Vector2: + return {"__t": "v2", "x": value.x, "y": value.y} + if value is Vector2i: + return {"__t": "v2i", "x": value.x, "y": value.y} + if value is PackedStringArray: + return {"__t": "psa", "v": Array(value)} + if value is Inventory: + return {"__t": "inv", "a": value.snapshot(), + "cap": value.total_capacity, + "caps": value.resource_capacities.duplicate()} + if value is Array: + var out: Array = [] + for item in value: + out.append(_encode(item)) + return out + if value is Dictionary: + var out_d: Dictionary = {} + for key in value: + out_d[_encode_key(key)] = _encode(value[key]) + return out_d + return value + + +static func _decode(value: Variant) -> Variant: + if value is Array: + var out: Array = [] + for item in value: + out.append(_decode(item)) + return out + if value is Dictionary: + match String(value.get("__t", "")): + "v2": + return Vector2(float(value.get("x", 0.0)), float(value.get("y", 0.0))) + "v2i": + return Vector2i(int(value.get("x", 0)), int(value.get("y", 0))) + "psa": + return PackedStringArray(value.get("v", [])) + "inv": + var inv: Inventory = Inventory.new() + inv.total_capacity = int(value.get("cap", -1)) + for id in Dictionary(value.get("caps", {})): + inv.resource_capacities[String(id)] = int(value["caps"][id]) + for id in Dictionary(value.get("a", {})): + inv.set_amount(String(id), int(value["a"][id])) + return inv + var out_d: Dictionary = {} + for key in value: + out_d[_decode_key(String(key))] = _decode(value[key]) + return out_d + return value + + +# Every key carries its type, so a planet id that happens to look like a +# chunk coordinate cannot be mistaken for one on the way back in. +static func _encode_key(key: Variant) -> String: + if key is Vector2i: + return "v2i:%d,%d" % [key.x, key.y] + if key is int: + return "i:%d" % key + return "s:%s" % String(key) + + +static func _decode_key(key: String) -> Variant: + if key.begins_with("v2i:"): + var parts: PackedStringArray = key.substr(4).split(",") + if parts.size() == 2: + return Vector2i(int(parts[0]), int(parts[1])) + return key + if key.begins_with("i:"): + return int(key.substr(2)) + if key.begins_with("s:"): + return key.substr(2) + return key diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd index f7f2aea..37ef894 100644 --- a/tests/test_main_menu.gd +++ b/tests/test_main_menu.gd @@ -26,6 +26,11 @@ var stage: int = 0 func _initialize() -> void: game_state = root.get_node("GameState") + # #225 put a Continue entry in this stack, shown only when a save + # exists - and the autosave writes to the same user:// path every test + # that runs Main.tscn for long enough. Clear it, or which entries this + # menu has would depend on what ran before it. + SaveGame.clear() menu = load("res://ui/MainMenu.tscn").instantiate() root.add_child(menu) current_scene = menu diff --git a/tests/test_main_menu_continue.gd b/tests/test_main_menu_continue.gd new file mode 100644 index 0000000..c999886 --- /dev/null +++ b/tests/test_main_menu_continue.gd @@ -0,0 +1,117 @@ +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 diff --git a/tests/test_save_game.gd b/tests/test_save_game.gd new file mode 100644 index 0000000..c29b2f7 --- /dev/null +++ b/tests/test_save_game.gd @@ -0,0 +1,179 @@ +extends SceneTree + +# Forgejo #225: the run survives being written to disk and read back. +# +# The interesting part is not "does it write a file" but whether the things +# JSON cannot hold survive: Vector2 positions, chunk coordinates used as +# dictionary KEYS, and Inventory objects nested inside the buildings array. +# Those are what a naive save silently flattens. +# +# Covers: +# A. a rich run round-trips field for field +# B. Vector2i dictionary keys come back as Vector2i, not as strings +# C. inventories come back with their contents AND their capacity +# D. a save from a newer build is refused, with a reason +# E. a damaged file is refused rather than half-applied +# F. a field the save predates keeps its new-game value + +# Mirrored rather than read off SaveGame: a bare ClassName.CONST in a +# SceneTree script has broken method resolution for a whole file here before. +const TEST_PATH: String = "user://test_savegame.json" + +var game_state: Node + + +func _initialize() -> void: + game_state = root.get_node("GameState") + + +func _process(_delta: float) -> bool: + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_new_game() + game_state.reset_quests() + + # --- a run with something in every awkward corner ------------------- + game_state.research_points = 42 + game_state.unlocked = ["shipyards", "coordination"] + game_state.rocks = 1234 + game_state.fleet_position = Vector2(987.5, -321.25) + game_state.wave_number = 7 + game_state.quest_index = 5 + game_state.explored_chunks[Vector2i(3, -4)] = true + game_state.explored_chunks[Vector2i(-11, 0)] = true + game_state.visited_planets["some-planet-id"] = true + var b_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(600.0, -150.0)) + var record: Dictionary = game_state.find_building(b_id) + record.inventory.total_capacity = 88 + record.inventory.set_amount(GameState.RESOURCE_ORE, 17) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 9) + + var before: Dictionary = _snapshot() + + if not SaveGame.save(game_state, TEST_PATH): + return _fail("the run would not save") + if not SaveGame.has_save(TEST_PATH): + return _fail("saved, but has_save() cannot see the file") + + # Wipe it back to a fresh game, so a load that does nothing cannot pass. + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_new_game() + game_state.reset_quests() + game_state.explored_chunks.clear() + if _snapshot() == before: + return _fail("wiping the run changed nothing, so a load would prove nothing") + + var result: Dictionary = SaveGame.load_into(game_state, TEST_PATH) + if not bool(result.get("ok", false)): + return _fail("the run would not load back: %s" % result.get("reason", "")) + + # --- A. field for field --------------------------------------------- + var after: Dictionary = _snapshot() + for key in before: + if after.get(key) != before[key]: + return _fail("'%s' did not survive the round trip: %s -> %s" \ + % [key, before[key], after.get(key)]) + print("a run with %d fields round-tripped intact" % before.size()) + + # --- B. chunk coordinates are still coordinates ---------------------- + if not game_state.explored_chunks.has(Vector2i(3, -4)): + return _fail("chunk keys came back as something other than Vector2i: %s" \ + % str(game_state.explored_chunks.keys())) + print("explored chunks came back keyed by Vector2i") + + # --- C. inventories keep contents and capacity ----------------------- + var loaded: Dictionary = game_state.find_building(b_id) + if loaded.is_empty(): + return _fail("the transport hub did not come back") + if int(loaded.inventory.amount(GameState.RESOURCE_ORE)) != 17: + return _fail("the hub's ore did not survive: %d" % loaded.inventory.amount(GameState.RESOURCE_ORE)) + if int(loaded.inventory.total_capacity) != 88: + return _fail("the hub's capacity did not survive: %d" % loaded.inventory.total_capacity) + if int(game_state.fleet_inventory.amount(GameState.RESOURCE_GAS)) != 9: + return _fail("the fleet hold's gas did not survive") + print("inventories came back with their contents and their capacity") + + # --- D. a save from a newer build is refused -------------------------- + _rewrite_version(999) + var newer: Dictionary = SaveGame.load_into(game_state, TEST_PATH) + if bool(newer.get("ok", false)): + return _fail("a save from a newer build loaded anyway") + if not String(newer.get("reason", "")).to_lower().contains("newer"): + return _fail("a newer save was refused without saying why: '%s'" % newer.get("reason", "")) + print("a newer save is refused: %s" % newer.get("reason", "")) + + # --- E. a damaged file is refused, not half-applied ------------------- + var f: FileAccess = FileAccess.open(TEST_PATH, FileAccess.WRITE) + f.store_string("{ this is not json") + f.close() + var marker: int = game_state.research_points + var damaged: Dictionary = SaveGame.load_into(game_state, TEST_PATH) + if bool(damaged.get("ok", false)): + return _fail("a damaged save reported success") + if game_state.research_points != marker: + return _fail("a damaged save was partly applied before it failed") + print("a damaged save is refused: %s" % damaged.get("reason", "")) + + # --- F. a field the save predates keeps its new-game value ------------ + # Adding progression must not invalidate every existing save, so a + # missing field is left alone rather than cleared. + SaveGame.save(game_state, TEST_PATH) + _drop_field("research_points") + game_state.research_points = 77 + var partial: Dictionary = SaveGame.load_into(game_state, TEST_PATH) + if not bool(partial.get("ok", false)): + return _fail("a save missing one field was refused entirely: %s" % partial.get("reason", "")) + if game_state.research_points != 77: + return _fail("a missing field was cleared instead of left alone (%d)" % game_state.research_points) + print("a field the save predates keeps its current value") + + SaveGame.clear(TEST_PATH) + print("TEST_SAVE_GAME: PASS") + quit(0) + return true + + +# The fields this test compares. Deliberately a hand-picked spread rather +# than SaveGame.FIELDS: comparing the list against itself would pass even if +# the codec dropped everything. +func _snapshot() -> Dictionary: + return { + "research_points": game_state.research_points, + "unlocked": game_state.unlocked.duplicate(), + "rocks": game_state.rocks, + "fleet_position": game_state.fleet_position, + "wave_number": game_state.wave_number, + "quest_index": game_state.quest_index, + "building_count": game_state.buildings.size(), + "chunk_count": game_state.explored_chunks.size(), + "visited": game_state.visited_planets.size(), + } + + +func _rewrite_version(to: int) -> void: + var file: FileAccess = FileAccess.open(TEST_PATH, FileAccess.READ) + var data: Dictionary = JSON.parse_string(file.get_as_text()) + file.close() + data["version"] = to + file = FileAccess.open(TEST_PATH, FileAccess.WRITE) + file.store_string(JSON.stringify(data)) + file.close() + + +func _drop_field(name: String) -> void: + var file: FileAccess = FileAccess.open(TEST_PATH, FileAccess.READ) + var data: Dictionary = JSON.parse_string(file.get_as_text()) + file.close() + data["fields"].erase(name) + file = FileAccess.open(TEST_PATH, FileAccess.WRITE) + file.store_string(JSON.stringify(data)) + file.close() + + +func _fail(message: String) -> bool: + print("TEST_SAVE_GAME: FAIL (%s)" % message) + quit(1) + return true diff --git a/ui/MainMenu.gd b/ui/MainMenu.gd index ef0eaaf..3d639c5 100644 --- a/ui/MainMenu.gd +++ b/ui/MainMenu.gd @@ -26,6 +26,8 @@ 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 @@ -60,6 +62,18 @@ func _ready() -> void: # 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) @@ -87,7 +101,8 @@ func is_developer_build() -> bool: func _layout_stack() -> void: var rows: Array = [] - for row in [[play_button, play_label], [editor_button, editor_label], + 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) @@ -114,6 +129,20 @@ func _layout_stack() -> void: 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 diff --git a/ui/MainMenu.tscn b/ui/MainMenu.tscn index 336865d..60ba5d8 100644 --- a/ui/MainMenu.tscn +++ b/ui/MainMenu.tscn @@ -68,6 +68,37 @@ theme_override_font_sizes/font_size = 32 text = "Play" horizontal_alignment = 1 +[node name="ContinueButton" type="Control" parent="UI"] +layout_mode = 3 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -64.0 +offset_top = -300.0 +offset_right = 64.0 +offset_bottom = -172.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("2") +icon = 2 +glow_color = Color(0.45, 0.78, 1, 1) + +[node name="ContinueLabel" type="Label" parent="UI"] +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -160.0 +offset_top = -160.0 +offset_right = 160.0 +offset_bottom = -94.0 +theme_override_font_sizes/font_size = 32 +text = "Continue" +horizontal_alignment = 1 + [node name="EditorButton" type="Control" parent="UI" unique_id=1570704159] layout_mode = 3 anchors_preset = 8