extends Node ## Autoload "Saves": world slots on disk. ## ## Each world is a folder under `root_dir` (user://worlds//) holding: ## meta.json name, seed, created, last played, play time (read by the title screen) ## world.json the game state, written by world.gd's collect_save() ## ## The world itself is regenerated from its seed, so a save only stores what ## the player changed: their own state, placed pieces, chest contents, harvested ## resources and collected pickups. That keeps saves small, and it's the same ## data a dedicated server will persist in M2. ## ## Writes are atomic (temp file + rename) and the previous world.json is kept ## as world.json.bak, so a crash mid-save can't destroy a world. const VERSION := 1 var root_dir := "user://worlds" var current_id := "" ## world being played; "" = unsaved session (title screen, tests) func has_world() -> bool: return not current_id.is_empty() func world_seed() -> int: return int(read_meta(current_id).get("seed", 1337)) func _dir(id: String) -> String: return root_dir.path_join(id) func list_worlds() -> Array: var out: Array = [] var d := DirAccess.open(root_dir) if d == null: return out for id in d.get_directories(): var m := read_meta(id) if not m.is_empty(): m["id"] = id out.append(m) out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0))) return out ## `options` are the world's own settings, kept in meta.json (P4: "rot", ## how fast food spoils: "normal", "slow" or "off"). func create_world(world_name: String, seed: int, options: Dictionary = {}) -> String: DirAccess.make_dir_recursive_absolute(root_dir) var base := world_name.to_lower().replace(" ", "_").validate_filename() if base.is_empty(): base = "world" var id := base var n := 2 while DirAccess.dir_exists_absolute(_dir(id)): id = "%s_%d" % [base, n] n += 1 DirAccess.make_dir_recursive_absolute(_dir(id)) var now := Time.get_unix_time_from_system() var meta := { "name": world_name, "seed": seed, "created": now, "last_played": now, "play_time": 0.0, "version": VERSION, "worldlet": "valley", } for k in options: meta[k] = options[k] meta["home"] = String(meta.worldlet) _write_json(_dir(id).path_join("meta.json"), meta) return id ## The world slot called exactly `id` (a server's world), made with `seed` if ## it doesn't exist yet. An existing world keeps its own seed. func open_or_create(id: String, seed: int, worldlet: String = "valley") -> String: if not read_meta(id).is_empty(): var had := int(read_meta(id).get("seed", seed)) if had != seed: print("Saves: world '%s' was made with seed %d, so it keeps it (the config says %d)" % [id, had, seed]) return id DirAccess.make_dir_recursive_absolute(_dir(id)) var now := Time.get_unix_time_from_system() _write_json(_dir(id).path_join("meta.json"), { "name": id, "seed": seed, "created": now, "last_played": now, "play_time": 0.0, "version": VERSION, "worldlet": worldlet, }) return id func delete_world(id: String) -> void: var dir := _dir(id) var d := DirAccess.open(dir) if d == null: return for sub in d.get_directories(): # (worldlets/, and a server's backups/) _remove_tree(dir.path_join(sub)) for f in d.get_files(): d.remove(f) DirAccess.remove_absolute(dir) if current_id == id: current_id = "" func read_meta(id: String) -> Dictionary: return _read_json(_dir(id).path_join("meta.json")) # --- worldlets in one world (J1, docs/WORLDLETS_DESIGN.md ยง10) -------------------- # A world can span several worldlets (the journey). Each keeps its own state # file: the one it began in (its "home") is world.json, as before, so older # saves are unchanged; the others are worldlets/.json. meta.json's # "worldlet" is where you are now, so that's the one loaded. ## The worldlet world `id` is being played in now. func worldlet_of(id: String) -> String: return String(read_meta(id).get("worldlet", "valley")) ## The worldlet world `id` began in (its world.json). func home_of(id: String) -> String: var m := read_meta(id) return String(m.get("home", m.get("worldlet", "valley"))) ## The state file for worldlet `w` of world `id`. func state_path(id: String, w: String) -> String: if w == home_of(id): return _dir(id).path_join("world.json") return _dir(id).path_join("worldlets/%s.json" % w) ## Whether world `id` has been to worldlet `w` (has a state for it). func has_state(id: String, w: String) -> bool: return FileAccess.file_exists(state_path(id, w)) ## Moves world `id` to worldlet `to` (the next load is there), arriving at ## `arrive` (a place the arriving world puts you: "sea_cave", "tunnel_bottom"). func set_worldlet(id: String, to: String, arrive: String) -> void: var m := read_meta(id) if not m.has("home"): m["home"] = String(m.get("worldlet", "valley")) # (pin it before "worldlet" moves) m["worldlet"] = to m["arrive"] = arrive _write_json(_dir(id).path_join("meta.json"), m) ## Where the next load should put you, once (then forgotten). func take_arrival(id: String) -> String: var m := read_meta(id) var a := String(m.get("arrive", "")) if not a.is_empty(): m.erase("arrive") _write_json(_dir(id).path_join("meta.json"), m) return a ## The state of the worldlet being played (or of worldlet `w`). func load_state(id: String, w: String = "") -> Dictionary: var path := state_path(id, worldlet_of(id) if w.is_empty() else w) var s := _read_json(path) if s.is_empty() and FileAccess.file_exists(path + ".bak"): push_warning("Saves: %s unreadable, using backup" % path.get_file()) s = _read_json(path + ".bak") return s ## Writes the state for `id` (the worldlet being played, or `w`) and ## updates its meta. Returns true on success. func save_state(id: String, state: Dictionary, session_seconds: float, w: String = "") -> bool: var path := state_path(id, worldlet_of(id) if w.is_empty() else w) DirAccess.make_dir_recursive_absolute(path.get_base_dir()) if FileAccess.file_exists(path): DirAccess.copy_absolute(path, path + ".bak") state["version"] = VERSION if not _write_json(path, state): return false var m := read_meta(id) m["last_played"] = Time.get_unix_time_from_system() m["play_time"] = float(m.get("play_time", 0.0)) + session_seconds _write_json(_dir(id).path_join("meta.json"), m) return true func _write_json(path: String, data: Dictionary) -> bool: var tmp := path + ".tmp" var f := FileAccess.open(tmp, FileAccess.WRITE) if f == null: push_error("Saves: can't write %s (%s)" % [tmp, error_string(FileAccess.get_open_error())]) return false f.store_string(JSON.stringify(data, "\t")) f.close() return DirAccess.rename_absolute(tmp, path) == OK func _read_json(path: String) -> Dictionary: if not FileAccess.file_exists(path): return {} var data = JSON.parse_string(FileAccess.get_file_as_string(path)) return data if typeof(data) == TYPE_DICTIONARY else {} static func _remove_tree(d: String) -> void: for sub in DirAccess.get_directories_at(d): _remove_tree(d.path_join(sub)) for f in DirAccess.get_files_at(d): DirAccess.remove_absolute(d.path_join(f)) DirAccess.remove_absolute(d)