243 lines
8.7 KiB
GDScript3
243 lines
8.7 KiB
GDScript3
|
|
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
|