Randoloot/scripts/game/save_service.gd
2026-10-27 22:20:01 +01:00

118 lines
6.1 KiB
GDScript

extends RefCounted
const DATA := preload("res://scripts/game/save_data.gd")
const PATH := "user://last_game.res"
const BACKUP := "user://last_game_backup.res"
const TEMP := "user://last_game_pending.res"
const DIRECTORY := "user://saves"
const STATE_FIELDS := ["selected_ship_path", "system_level", "system_seed", "active_distant_map", "distant_home", "highest_system", "system_seeds", "cleared_systems", "explored_pois", "asteroid_retry_seeds", "exploration_wreck_bonuses", "hud_panel_state"]
static func read_save(path: String = PATH) -> Resource:
if not FileAccess.file_exists(path): return null
var file := FileAccess.open(path, FileAccess.READ)
if file == null: return null
var header := file.get_buffer(4).get_string_from_ascii()
file.close()
if header not in ["RSRC", "RSCC"]: return null
var data := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP)
if data == null or data.get_script() != DATA or data.version != 1: return null
if not data.captain is CaptainProgress or data.state.get("system_seed", 0) == 0: return null
for key in ["cargo", "weapon", "module", "extension"]:
if not data.inventory.get(key) is Array: return null
return data
static func available() -> bool:
return not list_saves().is_empty()
static func valid_slot(id: String) -> bool:
return RegEx.create_from_string("^slot_[a-zA-Z0-9_-]{1,72}$").search(id) != null
static func slot_path(id: String) -> String:
return DIRECTORY.path_join(id+".res") if valid_slot(id) else ""
static func list_saves() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var ids: Array[String] = ["auto"]
if DirAccess.dir_exists_absolute(DIRECTORY):
for file in DirAccess.get_files_at(DIRECTORY):
if not file.ends_with(".res") or file.ends_with(".pending.res"): continue
var id := file.trim_suffix(".backup.res") if file.ends_with(".backup.res") else file.trim_suffix(".res")
if valid_slot(id) and id not in ids: ids.append(id)
for id in ids:
var path := PATH if id == "auto" else slot_path(id)
var backup := BACKUP if id == "auto" else path.trim_suffix(".res")+".backup.res"
var data := read_save(path)
var recovered := false
if data == null:
data = read_save(backup)
recovered = data != null
if data == null: continue
var timestamp: float = data.saved_unix if data.saved_unix > 0 else float(FileAccess.get_modified_time(backup if recovered else path))
entries.append({"id":id,"path":backup if recovered else path,"name":data.save_name if not data.save_name.is_empty() else ("Automatyczny zapis" if id == "auto" else "Zapis rozgrywki"),"saved_at":data.saved_at,"timestamp":timestamp,"level":int(data.state.system_level),"captain_level":data.captain.level,"credits":data.captain.credits,"ship_path":str(data.state.get("selected_ship_path","")),"recovered":recovered})
entries.sort_custom(func(a: Dictionary,b: Dictionary) -> bool: return float(a.timestamp) > float(b.timestamp))
return entries
static func write(state: Node, game: Node, save_name: String = "", id: String = "") -> Error:
var named := not save_name.is_empty()
if named and (save_name.strip_edges().is_empty() or save_name.length() > 48): return ERR_INVALID_PARAMETER
if not id.is_empty() and (not named or not valid_slot(id)): return ERR_INVALID_PARAMETER
if game == null or not game.has_node("PlayerShip") or bool(game.get("_transitioning")): return ERR_UNAVAILABLE
var ship := game.get_node("PlayerShip")
if bool(ship.get("_is_destroyed")): return ERR_UNAVAILABLE
var data := DATA.new()
data.saved_at = Time.get_datetime_string_from_system()
data.saved_unix = Time.get_unix_time_from_system()
data.save_name = save_name.strip_edges() if named else "Automatyczny zapis"
data.slot_id = id if not id.is_empty() else ("slot_%d_%08x" % [Time.get_ticks_usec(),randi()] if named else "auto")
for key in STATE_FIELDS: data.state[key] = state.get(key)
data.captain = state.captain.duplicate(true)
data.captain.skill_offers.assign(state.captain.skill_offers)
# A saved surface expedition returns safely to orbit with its current cargo.
if state.captain.expedition.active:
for key in ["active", "cargo", "cargo_modules", "cargo_records", "cargo_credits", "cargo_parts", "cargo_missiles", "active_profile", "visit_seed"]:
var value: Variant = state.captain.expedition.get(key)
data.captain.expedition.set(key, value.duplicate(true) if value is Dictionary or value is Array else value)
data.captain.expedition.finish_visit(data.captain, true)
data.skill_offers.assign(data.captain.skill_offers)
var inventory := ship.get_node("ShipInventory")
var previous: Dictionary = state.restart_inventory
inventory.preserve_for_restart()
data.inventory = state.restart_inventory
state.restart_inventory = previous
if named:
var directory_error := DirAccess.make_dir_recursive_absolute(DIRECTORY)
if directory_error != OK: return directory_error
return _commit(data, slot_path(data.slot_id), slot_path(data.slot_id).trim_suffix(".res")+".backup.res", slot_path(data.slot_id).trim_suffix(".res")+".pending.res")
return _commit(data, PATH, BACKUP, TEMP)
static func _commit(data: Resource, path: String, backup: String, temporary: String) -> Error:
var error := ResourceSaver.save(data, temporary)
if error != OK: return error
if read_save(temporary) == null: return ERR_FILE_CORRUPT
if read_save(path) != null:
error = DirAccess.copy_absolute(path, backup)
if error != OK: return error
return DirAccess.rename_absolute(temporary, path)
static func restore_state(state: Node, id: String = "") -> bool:
var entries := list_saves()
var data: Resource
for entry in entries:
if id.is_empty() or entry.id == id:
data = read_save(entry.path)
break
if data == null: return false
for key in STATE_FIELDS:
if data.state.has(key): state.set(key, data.state[key])
state.captain = data.captain
state.captain.skill_offers.assign(data.skill_offers)
state.captain.reset_combat_state()
state.restart_inventory = data.inventory
state.return_to_freighter = false
state.distant_warp_arrival = false
state.hull_bastion_used = false
# Loading always starts the current sector again, with fresh encounters.
if state.active_distant_map.is_empty(): state.cleared_systems.erase(state.system_level)
state.reset_current_system_exploration()
return true