max_fleet_size() clamped itself to DEFAULT_FORMATION.size(), so the ceiling on the fleet was a property of the LAYOUT - the wedge ran out of authored positions at twenty - rather than a rule anyone decided on. Capacity is the supplied Fleet Offices and nothing else now. Dropping the clamp alone would index off the end of the formation array, so the wedge continues by rule instead of stopping: formation_slot() grows it on demand, each further pair stepping 30 further out and 40 further back, which is what the authored rows already do - the first twenty slots come back unchanged. A grown slot is rotated by formation_rotation so a fleet that outgrows the array after the player has pivoted does not sprout hulls facing the original heading, and that angle is saved with the layout. Two "fleet is full" messages pointed at the vanished ceiling. Both now say what actually buys a berth, and the hangar's stops claiming it is research, which it has not been since #219. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn
243 lines
8.7 KiB
GDScript
243 lines
8.7 KiB
GDScript
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", "formation_rotation", "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
|