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