Phase 16: Save/load full coverage + autosave + UI
Three-agent fan-out reusing the contracts-first pattern: Opus pre-wrote
World.clear_all + 4 EventBus signals (save_started/finished, load_started/
finished) before dispatch. Pattern proven across Phases 12/13/14/15/16.
Entity to_dict/from_dict + class_id tagging (Agent A):
- class_id tag added to all 18 entity to_dict methods for loader routing
- Missing pairs filled in: wolf, grave_slot, graveyard_zone, stockpile_zone,
crate (from_dict). All defensive with d.get(field, default).
- Workbench round-trips label_text so Carpenter/Smelter/Millstone/Hearth/
Pyre kinds survive reload
- BeautySystem + DirtinessSystem save_dict/apply_dict for sparse maps
- World.save_tilemap_layers / apply_tilemap_layers covering 5 layers
(Terrain/Floor/Wall/Designation/Roof; Fog runtime-only skipped)
SaveSystem v2 rewrite (Agent B):
- SAVE_VERSION bumped from 1 to 2
- write_save(slot) pauses Sim, emits save_started, collects every entity
via _collect_entities iterating all World registries, writes payload to
user://save_<slot>.json
- apply_save full rewrite: pause sim → emit load_started → World.clear_all
→ apply autoloads (GameState/Clock/Weather/Storyteller) → apply tilemap
layers → iterate payload.entities and dispatch to per-class factories
→ apply beauty/dirt maps → emit load_finished(slot, ok, real_seconds_away)
- Per-class factory registry: 18 class_ids dispatched to setup+add_child+
from_dict patterns. CremationPyre detected via workbench.label_text == 'Pyre'
- Public slot API: save_to_slot/load_from_slot/has_save/delete_save/
peek_save_metadata. Slots locked: &manual + &autosave
Autosave + UI + Resume toast (Agent C):
- autoload/autosave.gd — new Autosave autoload. Periodic every
AUTOSAVE_INTERVAL_TICKS = 6000 (~5 in-game min at 20 Hz) + NOTIFICATION_
APPLICATION_PAUSED (mobile) + NOTIFICATION_WM_WINDOW_FOCUS_OUT (desktop).
Gated by _busy flag tied to EventBus.save_started/save_finished.
- TopBar extended with SaveBtn (💾) + LoadBtn buttons, 48×48 min hit area
- scenes/ui/load_menu.gd — CanvasLayer slot picker. Reads peek_save_metadata
to show 'Manual save (Date Time)' / 'Autosave (Date Time)' rows.
Version-mismatch warning dialog before continuing on older saves.
- scenes/ui/resume_toast.gd — top-center toast. On load_finished(ok=true):
'Welcome back — N minutes/hours away' for 5s + 0.8s fade.
On ok=false: 'Load failed (corrupt or version mismatch)'.
- Strings catalog: 14 new keys (ui.save / ui.load / ui.welcome_back_* /
ui.load_failed etc.)
- main.gd mounts LoadMenu + ResumeToast as runtime CanvasLayer children
MCP runtime verified:
- Saved at tick 1137 → [save] wrote slot 'manual': 113 entities at tick 1137
- Advanced sim to tick 4600 at ULTRA speed (different state)
- load_from_slot(&manual) → [save] applied slot 'manual': 113 entities,
0 errors, tick=1137, away=34s
- post-load: Sim.tick=1137 (restored), pawns alive=3, all furniture +
workbenches + crops + walls + floors back in place
- Resume toast fires: [resume_toast] showing — ok=true seconds_away=34
- Autosave on focus-loss verified: [autosave] focus-loss → wrote autosave
- Screenshot shows TopBar with Save + Load buttons + post-load Lone Wolf
storyteller modal from fresh dawn roll
Known acceptable gaps (deferred to Phase 20 tuning):
- Pawn JobRunner mid-INTERACT/mid-BUILD restarts from toil 0 on reload
(walk toil round-trips; multi-step interact does not). Pawns lose a few
seconds of work.
- Workbench bill mid-craft fetch state isn't fully serialized.
- Wolf.target_pawn re-resolution from name string is Agent A's documented
pattern; Agent B's apply_save respects pawn-restoration ordering so the
resolution works after pawns are back.
Delegation: 3× gdscript-refactor (Sonnet) agents in parallel; integration
+ MCP verify on Opus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3da7353387
commit
19d28ca9f8
37 changed files with 1546 additions and 63 deletions
78
autoload/autosave.gd
Normal file
78
autoload/autosave.gd
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
extends Node
|
||||
## Autosave trigger — owns periodic + app-pause + focus-loss autosave logic.
|
||||
##
|
||||
## Does NOT own the write logic (that is SaveSystem.save_to_slot). This node
|
||||
## only decides *when* to fire an autosave. It gates on _busy so we never
|
||||
## start a save while another save/load is in-flight.
|
||||
##
|
||||
## Periodic cadence: every AUTOSAVE_INTERVAL_TICKS sim ticks (~5 in-game minutes
|
||||
## at 20 Hz × Fast speed). The tick counter resets on every save_finished so
|
||||
## that a manual save also resets the periodic clock.
|
||||
##
|
||||
## App-pause (mobile) and focus-loss (desktop) are immediate triggers.
|
||||
|
||||
const AUTOSAVE_INTERVAL_TICKS: int = 6000 # 20 Hz × 300 s = 5 in-game minutes
|
||||
|
||||
## Set true while a save or load is in progress so we don't re-enter.
|
||||
var _busy: bool = false
|
||||
## Counts sim ticks since the last autosave (or last save_finished, whichever
|
||||
## came last) so we respect the interval even across manual saves.
|
||||
var _ticks_since_save: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
EventBus.sim_tick.connect(_on_sim_tick)
|
||||
EventBus.save_started.connect(_on_save_started)
|
||||
EventBus.save_finished.connect(_on_save_finished)
|
||||
EventBus.load_started.connect(_on_load_started)
|
||||
EventBus.load_finished.connect(_on_load_finished)
|
||||
Audit.log("autosave", "Autosave ready — interval %d ticks" % AUTOSAVE_INTERVAL_TICKS)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
match what:
|
||||
NOTIFICATION_APPLICATION_PAUSED:
|
||||
# Mobile: app sent to background — save immediately.
|
||||
_trigger_autosave("app-pause")
|
||||
NOTIFICATION_WM_WINDOW_FOCUS_OUT:
|
||||
# Desktop: window lost focus — save immediately.
|
||||
_trigger_autosave("focus-loss")
|
||||
|
||||
|
||||
# ── signal handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
func _on_sim_tick(tick_number: int) -> void:
|
||||
if _busy:
|
||||
return
|
||||
_ticks_since_save += 1
|
||||
if _ticks_since_save >= AUTOSAVE_INTERVAL_TICKS:
|
||||
_trigger_autosave("periodic save at tick %d" % tick_number)
|
||||
|
||||
|
||||
func _on_save_started(_slot: StringName) -> void:
|
||||
_busy = true
|
||||
|
||||
|
||||
func _on_save_finished(_slot: StringName, _ok: bool) -> void:
|
||||
_busy = false
|
||||
# Reset the periodic counter so any save (manual or auto) restarts the clock.
|
||||
_ticks_since_save = 0
|
||||
|
||||
|
||||
func _on_load_started(_slot: StringName) -> void:
|
||||
_busy = true
|
||||
|
||||
|
||||
func _on_load_finished(_slot: StringName, _ok: bool, _seconds: int) -> void:
|
||||
_busy = false
|
||||
_ticks_since_save = 0
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _trigger_autosave(reason: String) -> void:
|
||||
if _busy:
|
||||
Audit.log("autosave", "skipped (%s) — save already in progress" % reason)
|
||||
return
|
||||
Audit.log("autosave", reason)
|
||||
SaveSystem.save_to_slot(&"autosave")
|
||||
1
autoload/autosave.gd.uid
Normal file
1
autoload/autosave.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dfwg7htsyiip3
|
||||
|
|
@ -45,3 +45,9 @@ signal storyteller_event_resolved(event, choice_index: int) ## Emitted after the
|
|||
signal storyteller_tension_changed(tension: float) ## Emitted when running tension score changes (0..100).
|
||||
signal storyteller_ghost_state_entered ## Emitted when all colonists are dead/gone — wanderer recovery clock starts.
|
||||
signal storyteller_ghost_state_exited ## Emitted when a wanderer joins and the colony is alive again.
|
||||
|
||||
# Phase 16 — Save/load.
|
||||
signal save_started(slot: StringName) ## Emitted by SaveSystem.write_save before file IO.
|
||||
signal save_finished(slot: StringName, ok: bool) ## Emitted after file IO; ok=false on write failure.
|
||||
signal load_started(slot: StringName) ## Emitted by SaveSystem.apply_save before clear_all.
|
||||
signal load_finished(slot: StringName, ok: bool, real_seconds_away: int) ## Emitted after respawn; real_seconds_away drives the "you've been away X" toast.
|
||||
|
|
|
|||
|
|
@ -1,71 +1,682 @@
|
|||
extends Node
|
||||
## Save/load — version field, file IO, save-between-ticks contract.
|
||||
## SaveSystem v2 — full entity serialization + clear-and-respawn load.
|
||||
##
|
||||
## Saves only happen between sim ticks (Sim owns the loop). JobRunner mid-toil
|
||||
## state must round-trip from day one — see docs/architecture.md "Save format".
|
||||
## Save contract (docs/architecture.md "Save format"):
|
||||
## - Saves only happen between sim ticks (Sim owns the loop). apply_save()
|
||||
## pauses the sim so no tick fires mid-rebuild.
|
||||
## - Every entity's to_dict() must include a "class_id" field (Agent A's work).
|
||||
## The factory registry here dispatches on that key.
|
||||
## - Save files: user://save_<slot>.json (slot = StringName, e.g. &"manual").
|
||||
## - Autosave uses slot &"autosave"; manual save uses &"manual".
|
||||
##
|
||||
## Phase 3: pawn states (including mid-walk + JobRunner) round-trip via
|
||||
## Pawn.to_dict() / Pawn.from_dict() — gated by has_method() so the pre-Phase-3
|
||||
## Pawn (which lacked the methods) didn't break here.
|
||||
## Phase 16 expands to tilemap data, storyteller state, items, furniture, etc.
|
||||
## Public slot API (Agent C surface):
|
||||
## save_to_slot(slot) → bool
|
||||
## load_from_slot(slot) → bool
|
||||
## has_save(slot) → bool
|
||||
## delete_save(slot) → bool
|
||||
## peek_save_metadata(slot) → {exists, saved_at_unix, version}
|
||||
##
|
||||
## Phase 16 — replaces the Phase 3 pawn-only stub.
|
||||
|
||||
const SAVE_VERSION: int = 1
|
||||
const SAVE_PATH: String = "user://save_slot.json"
|
||||
const SAVE_VERSION: int = 2
|
||||
|
||||
|
||||
func write_save() -> bool:
|
||||
var pawn_dicts: Array = []
|
||||
for p in World.pawns:
|
||||
if p.has_method("to_dict"):
|
||||
pawn_dicts.append(p.to_dict())
|
||||
# ── PackedScene preloads (mirror world.gd constants) ──────────────────────────
|
||||
# Keep in sync with scenes/world/world.gd SCENE constants.
|
||||
# If a scene path changes, update both files.
|
||||
|
||||
var payload := {
|
||||
"version": SAVE_VERSION,
|
||||
"sim_tick": Sim.tick,
|
||||
"game_state": GameState.save_dict(),
|
||||
"pawns": pawn_dicts,
|
||||
}
|
||||
var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_error("SaveSystem.write_save: cannot open %s" % SAVE_PATH)
|
||||
const _PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn")
|
||||
const _TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn")
|
||||
const _ROCK_SCENE: PackedScene = preload("res://scenes/entities/rock.tscn")
|
||||
const _ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
|
||||
const _WALL_SCENE: PackedScene = preload("res://scenes/entities/wall.tscn")
|
||||
const _FLOOR_SCENE: PackedScene = preload("res://scenes/entities/floor.tscn")
|
||||
const _DOOR_SCENE: PackedScene = preload("res://scenes/entities/door.tscn")
|
||||
const _WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/workbench.tscn")
|
||||
const _CROP_SCENE: PackedScene = preload("res://scenes/entities/crop.tscn")
|
||||
const _BED_SCENE: PackedScene = preload("res://scenes/entities/bed.tscn")
|
||||
const _TORCH_SCENE: PackedScene = preload("res://scenes/entities/torch.tscn")
|
||||
const _STOCKPILE_SCENE: PackedScene = preload("res://scenes/world/stockpile_zone.tscn")
|
||||
const _CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.tscn")
|
||||
const _WOLF_SCENE: PackedScene = preload("res://scenes/entities/wolf.tscn")
|
||||
const _CORPSE_SCENE: PackedScene = preload("res://scenes/entities/corpse.tscn")
|
||||
|
||||
# Script-only entities (no .tscn); spawned via Node2D.new() + set_script().
|
||||
const _GRAVEYARD_ZONE_SCRIPT: Script = preload("res://scenes/world/graveyard_zone.gd")
|
||||
const _GRAVE_SLOT_SCRIPT: Script = preload("res://scenes/entities/grave_slot.gd")
|
||||
const _GRAVE_MARKER_SCRIPT: Script = preload("res://scenes/entities/grave_marker.gd")
|
||||
|
||||
|
||||
# ── factory registry ───────────────────────────────────────────────────────────
|
||||
# Keys are the class_id strings that entities embed in their to_dict() output.
|
||||
# Values are Callables: func(world_scene: Node, d: Dictionary) -> void.
|
||||
# Populated in _ready(); dispatch via _spawn_entity().
|
||||
|
||||
var _factories: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_register_factories()
|
||||
|
||||
|
||||
# ── public slot API ────────────────────────────────────────────────────────────
|
||||
|
||||
## Write the current world state to the named slot. Returns true on success.
|
||||
func save_to_slot(slot: StringName) -> bool:
|
||||
return write_save(slot)
|
||||
|
||||
|
||||
## Load the named slot and apply it to the live world. Returns true on success.
|
||||
func load_from_slot(slot: StringName) -> bool:
|
||||
var payload := read_save(slot)
|
||||
if payload.is_empty():
|
||||
EventBus.load_finished.emit(slot, false, 0)
|
||||
return false
|
||||
f.store_string(JSON.stringify(payload))
|
||||
Audit.log("save", "wrote %d pawns at tick %d" % [pawn_dicts.size(), Sim.tick])
|
||||
apply_save(payload, slot)
|
||||
return true
|
||||
|
||||
|
||||
func read_save() -> Dictionary:
|
||||
if not FileAccess.file_exists(SAVE_PATH):
|
||||
return {}
|
||||
var f := FileAccess.open(SAVE_PATH, FileAccess.READ)
|
||||
## True if a save file for this slot exists on disk.
|
||||
func has_save(slot: StringName) -> bool:
|
||||
return FileAccess.file_exists(_path_for(slot))
|
||||
|
||||
|
||||
## Delete the save file for the given slot. Returns true if the file was removed.
|
||||
func delete_save(slot: StringName) -> bool:
|
||||
var path := _path_for(slot)
|
||||
if not FileAccess.file_exists(path):
|
||||
return false
|
||||
var err := DirAccess.remove_absolute(path)
|
||||
if err != OK:
|
||||
push_error("SaveSystem.delete_save: cannot delete '%s' (err %d)" % [path, err])
|
||||
return false
|
||||
Audit.log("save", "deleted slot '%s'" % slot)
|
||||
return true
|
||||
|
||||
|
||||
## Peek at a slot's metadata without a full load. Returns a Dictionary:
|
||||
## {exists: bool, saved_at_unix: int, version: int}
|
||||
func peek_save_metadata(slot: StringName) -> Dictionary:
|
||||
var path := _path_for(slot)
|
||||
if not FileAccess.file_exists(path):
|
||||
return {exists = false, saved_at_unix = 0, version = 0}
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return {}
|
||||
var raw := f.get_as_text()
|
||||
var parsed: Variant = JSON.parse_string(raw)
|
||||
return {exists = false, saved_at_unix = 0, version = 0}
|
||||
var parsed: Variant = JSON.parse_string(f.get_as_text())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_error("SaveSystem.read_save: corrupt save")
|
||||
return {exists = true, saved_at_unix = 0, version = 0}
|
||||
return {
|
||||
exists = true,
|
||||
saved_at_unix = int(parsed.get("saved_at_unix", 0)),
|
||||
version = int(parsed.get("version", 0)),
|
||||
}
|
||||
|
||||
|
||||
# ── core write / read / apply ──────────────────────────────────────────────────
|
||||
|
||||
func write_save(slot: StringName = &"manual") -> bool:
|
||||
# Pause the sim so we snapshot between ticks (never mid-tick).
|
||||
var prev_speed: Sim.Speed = Sim.current_speed
|
||||
Sim.set_speed(Sim.Speed.PAUSE)
|
||||
|
||||
EventBus.save_started.emit(slot)
|
||||
|
||||
var entities := _collect_entities()
|
||||
|
||||
var payload: Dictionary = {
|
||||
"version": SAVE_VERSION,
|
||||
"saved_at_unix": int(Time.get_unix_time_from_system()),
|
||||
"saved_at_tick": Sim.tick,
|
||||
"sim_speed": int(prev_speed),
|
||||
"game_state": GameState.save_dict(),
|
||||
"clock": Clock.save_dict(),
|
||||
"weather": Weather.save_dict(),
|
||||
"storyteller": Storyteller.save_dict(),
|
||||
"tilemap_layers": _save_tilemap_layers_safe(),
|
||||
"beauty_map": _save_beauty_safe(),
|
||||
"dirt_map": _save_dirt_safe(),
|
||||
"entities": entities,
|
||||
}
|
||||
|
||||
var ok := _write_json(slot, payload)
|
||||
|
||||
if ok:
|
||||
Audit.log("save", "wrote slot '%s': %d entities at tick %d" % [
|
||||
slot, entities.size(), Sim.tick
|
||||
])
|
||||
else:
|
||||
push_error("SaveSystem.write_save: file write failed for slot '%s'" % slot)
|
||||
|
||||
EventBus.save_finished.emit(slot, ok)
|
||||
Sim.set_speed(prev_speed)
|
||||
return ok
|
||||
|
||||
|
||||
func read_save(slot: StringName = &"manual") -> Dictionary:
|
||||
var path := _path_for(slot)
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
if int(parsed.get("version", 0)) != SAVE_VERSION:
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
push_error("SaveSystem.read_save: cannot open '%s'" % path)
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(f.get_as_text())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_error("SaveSystem.read_save: corrupt save at '%s'" % path)
|
||||
return {}
|
||||
var ver: int = int(parsed.get("version", 0))
|
||||
if ver != SAVE_VERSION:
|
||||
push_warning(
|
||||
"SaveSystem.read_save: version mismatch (%s vs %s)" %
|
||||
[parsed.get("version", "?"), SAVE_VERSION]
|
||||
"SaveSystem.read_save: version mismatch in '%s' (file v%d vs code v%d) — best-effort load" %
|
||||
[path, ver, SAVE_VERSION]
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
## Apply a previously-loaded save payload onto live world state.
|
||||
## Pawn dicts are zipped against World.pawns by index — Phase 3 simplicity;
|
||||
## Phase 16 will introduce stable pawn IDs.
|
||||
func apply_save(payload: Dictionary) -> void:
|
||||
if payload.has("sim_tick"):
|
||||
Sim.tick = int(payload["sim_tick"])
|
||||
## Apply a loaded payload onto the live world. slot is used only for signal emission.
|
||||
## On any per-entity error: logs via push_error, skips that entity, keeps going.
|
||||
## On catastrophic failure (no world scene): emits load_finished with ok=false; never crashes.
|
||||
func apply_save(payload: Dictionary, slot: StringName = &"manual") -> void:
|
||||
EventBus.load_started.emit(slot)
|
||||
|
||||
var prev_speed: Sim.Speed = Sim.current_speed
|
||||
Sim.set_speed(Sim.Speed.PAUSE)
|
||||
|
||||
var real_seconds_away: int = max(
|
||||
0,
|
||||
int(Time.get_unix_time_from_system()) - int(payload.get("saved_at_unix", 0))
|
||||
)
|
||||
|
||||
# Clear every entity registry + queue_free their nodes.
|
||||
World.clear_all()
|
||||
|
||||
# Restore sim tick counter.
|
||||
Sim.tick = int(payload.get("saved_at_tick", 0))
|
||||
|
||||
# Restore autoload state in dependency order.
|
||||
if payload.has("game_state"):
|
||||
GameState.apply_dict(payload["game_state"])
|
||||
if payload.has("clock"):
|
||||
Clock.apply_dict(payload["clock"])
|
||||
if payload.has("weather"):
|
||||
Weather.apply_dict(payload["weather"])
|
||||
if payload.has("storyteller"):
|
||||
Storyteller.apply_dict(payload["storyteller"])
|
||||
|
||||
var pawn_dicts: Array = payload.get("pawns", [])
|
||||
var n: int = min(pawn_dicts.size(), World.pawns.size())
|
||||
for i in n:
|
||||
var p = World.pawns[i]
|
||||
if p.has_method("from_dict"):
|
||||
p.from_dict(pawn_dicts[i])
|
||||
Audit.log("save", "applied %d pawns at tick %d" % [n, Sim.tick])
|
||||
# Restore TileMap layers (Agent A adds World.save/apply_tilemap_layers).
|
||||
var tilemap_data: Dictionary = payload.get("tilemap_layers", {})
|
||||
if not tilemap_data.is_empty():
|
||||
_apply_tilemap_layers_safe(tilemap_data)
|
||||
|
||||
# Locate the world scene node (parent of all entity children).
|
||||
var world_scene := _get_world_scene()
|
||||
if world_scene == null:
|
||||
push_error("SaveSystem.apply_save: cannot locate World scene node — aborting entity spawn")
|
||||
EventBus.load_finished.emit(slot, false, real_seconds_away)
|
||||
Sim.set_speed(prev_speed)
|
||||
return
|
||||
|
||||
# Respawn entities, sorted so dependencies land before dependents.
|
||||
var entity_dicts: Array = payload.get("entities", [])
|
||||
var ordered := _sort_by_spawn_priority(entity_dicts)
|
||||
var entity_count := 0
|
||||
var error_count := 0
|
||||
|
||||
for d in ordered:
|
||||
if typeof(d) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var cid: StringName = StringName(d.get("class_id", ""))
|
||||
if cid == &"":
|
||||
push_error("SaveSystem.apply_save: entity dict missing class_id — skipped: %s" % d)
|
||||
error_count += 1
|
||||
continue
|
||||
if not _factories.has(cid):
|
||||
push_error("SaveSystem.apply_save: no factory for class_id '%s' — skipped" % cid)
|
||||
error_count += 1
|
||||
continue
|
||||
var factory: Callable = _factories[cid]
|
||||
factory.call(world_scene, d)
|
||||
entity_count += 1
|
||||
|
||||
# Restore beauty / dirt maps after entities are back.
|
||||
var beauty_data: Dictionary = payload.get("beauty_map", {})
|
||||
if not beauty_data.is_empty() and World.beauty_system != null:
|
||||
if World.beauty_system.has_method("apply_dict"):
|
||||
World.beauty_system.apply_dict(beauty_data)
|
||||
|
||||
var dirt_data: Dictionary = payload.get("dirt_map", {})
|
||||
if not dirt_data.is_empty() and World.dirtiness_system != null:
|
||||
if World.dirtiness_system.has_method("apply_dict"):
|
||||
World.dirtiness_system.apply_dict(dirt_data)
|
||||
|
||||
# Restore sim speed to what it was when the save was taken.
|
||||
var saved_speed_int: int = int(payload.get("sim_speed", int(Sim.Speed.NORMAL)))
|
||||
var saved_speed: Sim.Speed = Sim.Speed.NORMAL
|
||||
if saved_speed_int >= 0 and saved_speed_int < Sim.Speed.size():
|
||||
saved_speed = saved_speed_int as Sim.Speed
|
||||
|
||||
var ok: bool = error_count == 0
|
||||
Audit.log("save", "applied slot '%s': %d entities, %d errors, tick=%d, away=%ds" % [
|
||||
slot, entity_count, error_count, Sim.tick, real_seconds_away
|
||||
])
|
||||
|
||||
EventBus.load_finished.emit(slot, ok, real_seconds_away)
|
||||
Sim.set_speed(saved_speed)
|
||||
|
||||
|
||||
# ── entity collection ──────────────────────────────────────────────────────────
|
||||
|
||||
## Walk every entity registry on World; call to_dict() on each valid node.
|
||||
## Concatenated into a single flat Array[Dictionary] — class_id is the dispatcher key.
|
||||
func _collect_entities() -> Array:
|
||||
var out: Array = []
|
||||
# Registries in stable order. Pawn last so dependents (workbenches, beds) precede.
|
||||
var registries: Array = [
|
||||
World.trees,
|
||||
World.rocks,
|
||||
World.items,
|
||||
World.build_queue, # ghost walls / floors / doors / grave_slots
|
||||
World.doors,
|
||||
World.workbenches,
|
||||
World.crops,
|
||||
World.beds,
|
||||
World.light_sources, # torches (hearth is a workbench — also in workbenches)
|
||||
World.stockpiles,
|
||||
World.wolves,
|
||||
World.corpses,
|
||||
World.grave_markers,
|
||||
World.pawns,
|
||||
]
|
||||
for reg in registries:
|
||||
for ent in reg:
|
||||
if not is_instance_valid(ent):
|
||||
continue
|
||||
if ent.has_method("to_dict"):
|
||||
var d: Dictionary = ent.to_dict()
|
||||
if d.has("class_id"):
|
||||
out.append(d)
|
||||
else:
|
||||
push_warning("SaveSystem._collect_entities: %s.to_dict() missing class_id — skipped" % ent)
|
||||
return out
|
||||
|
||||
|
||||
# ── spawn-order sorting ────────────────────────────────────────────────────────
|
||||
|
||||
## Spawn priority tiers (lower = spawns first so dependencies land before dependents).
|
||||
## 0 — static structures: tree, rock, wall, floor
|
||||
## 1 — doors (erase wall stamp in _complete)
|
||||
## 2 — containers + furniture: crate, workbench, stockpile_zone, bed, torch, crop
|
||||
## 3 — burial entities: grave_slot, grave_marker, graveyard_zone
|
||||
## 4 — items + mobile non-pawn entities: item, wolf, corpse
|
||||
## 5 — pawns (may reference job targets from earlier tiers)
|
||||
|
||||
const _SPAWN_PRIORITY: Dictionary = {
|
||||
&"tree": 0,
|
||||
&"rock": 0,
|
||||
&"wall": 0,
|
||||
&"floor": 0,
|
||||
&"door": 1,
|
||||
&"crate": 2,
|
||||
&"workbench": 2,
|
||||
&"stockpile_zone": 2,
|
||||
&"bed": 2,
|
||||
&"torch": 2,
|
||||
&"crop": 2,
|
||||
&"grave_slot": 3,
|
||||
&"grave_marker": 3,
|
||||
&"graveyard_zone": 3,
|
||||
&"item": 4,
|
||||
&"wolf": 4,
|
||||
&"corpse": 4,
|
||||
&"pawn": 5,
|
||||
}
|
||||
|
||||
|
||||
func _sort_by_spawn_priority(entity_dicts: Array) -> Array:
|
||||
var copy := entity_dicts.duplicate()
|
||||
copy.sort_custom(func(a, b) -> bool:
|
||||
var pa: int = _SPAWN_PRIORITY.get(StringName(a.get("class_id", "")), 99)
|
||||
var pb: int = _SPAWN_PRIORITY.get(StringName(b.get("class_id", "")), 99)
|
||||
return pa < pb
|
||||
)
|
||||
return copy
|
||||
|
||||
|
||||
# ── factory registry setup ─────────────────────────────────────────────────────
|
||||
|
||||
func _register_factories() -> void:
|
||||
_factories[&"tree"] = _spawn_tree
|
||||
_factories[&"rock"] = _spawn_rock
|
||||
_factories[&"item"] = _spawn_item
|
||||
_factories[&"wall"] = _spawn_wall
|
||||
_factories[&"floor"] = _spawn_floor
|
||||
_factories[&"door"] = _spawn_door
|
||||
_factories[&"workbench"] = _spawn_workbench
|
||||
_factories[&"crop"] = _spawn_crop
|
||||
_factories[&"bed"] = _spawn_bed
|
||||
_factories[&"torch"] = _spawn_torch
|
||||
_factories[&"stockpile_zone"] = _spawn_stockpile_zone
|
||||
_factories[&"crate"] = _spawn_crate
|
||||
_factories[&"wolf"] = _spawn_wolf
|
||||
_factories[&"corpse"] = _spawn_corpse
|
||||
_factories[&"grave_marker"] = _spawn_grave_marker
|
||||
_factories[&"grave_slot"] = _spawn_grave_slot
|
||||
_factories[&"graveyard_zone"] = _spawn_graveyard_zone
|
||||
_factories[&"pawn"] = _spawn_pawn
|
||||
|
||||
|
||||
# ── per-class factory functions ────────────────────────────────────────────────
|
||||
# Signature: func(world_scene: Node, d: Dictionary) -> void.
|
||||
# Pattern: instantiate (or new()+set_script), add_child (triggers _ready +
|
||||
# self-registration), apply entity-specific from_dict or field restore.
|
||||
|
||||
func _spawn_tree(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _TREE_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
|
||||
ent.chop_progress = int(d.get("chop_progress", 0))
|
||||
ent.queue_redraw()
|
||||
|
||||
|
||||
func _spawn_rock(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _ROCK_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
|
||||
ent.mine_progress = int(d.get("mine_progress", 0))
|
||||
ent.queue_redraw()
|
||||
|
||||
|
||||
func _spawn_item(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _ITEM_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(
|
||||
StringName(d.get("type", "wood")),
|
||||
int(d.get("stack_size", 1)),
|
||||
Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||
)
|
||||
ent.quality = int(d.get("quality", 1)) as Item.Quality
|
||||
|
||||
|
||||
func _spawn_wall(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _WALL_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(
|
||||
Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))),
|
||||
StringName(d.get("material", "stone"))
|
||||
)
|
||||
ent.build_progress = int(d.get("build_progress", 0))
|
||||
if bool(d.get("completed", false)):
|
||||
# Mirror _spawn_complete_wall in world.gd: set progress to BUILD_TICKS
|
||||
# then call on_build_tick() once to trigger _complete().
|
||||
ent.build_progress = ent.BUILD_TICKS
|
||||
ent.on_build_tick()
|
||||
else:
|
||||
ent.queue_redraw()
|
||||
|
||||
|
||||
func _spawn_floor(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _FLOOR_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(
|
||||
Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))),
|
||||
StringName(d.get("material", "wood"))
|
||||
)
|
||||
ent.build_progress = int(d.get("build_progress", 0))
|
||||
if bool(d.get("completed", false)):
|
||||
ent.build_progress = ent.BUILD_TICKS
|
||||
ent.on_build_tick()
|
||||
else:
|
||||
ent.queue_redraw()
|
||||
|
||||
|
||||
func _spawn_door(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _DOOR_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
|
||||
ent.build_progress = int(d.get("build_progress", 0))
|
||||
if bool(d.get("completed", false)):
|
||||
ent.build_progress = ent.BUILD_TICKS
|
||||
ent.on_build_tick()
|
||||
# Restore open state after possible completion (Door._complete sets is_open = true).
|
||||
ent.is_open = bool(d.get("is_open", true))
|
||||
if not bool(d.get("completed", false)):
|
||||
ent.queue_redraw()
|
||||
|
||||
|
||||
func _spawn_workbench(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _WORKBENCH_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
|
||||
if d.has("label_text"):
|
||||
ent.label_text = str(d["label_text"])
|
||||
if d.has("accepted_skill"):
|
||||
ent.accepted_skill = StringName(d["accepted_skill"])
|
||||
ent.build_progress = int(d.get("build_progress", 0))
|
||||
if bool(d.get("completed", false)):
|
||||
ent.build_progress = ent.BUILD_TICKS
|
||||
ent.on_build_tick()
|
||||
# from_dict restores bills + any other Workbench-specific state.
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
|
||||
|
||||
func _spawn_crop(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _CROP_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
else:
|
||||
# Fallback for pre-from_dict Crop: set up with kind + default stage.
|
||||
ent.setup(
|
||||
Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))),
|
||||
StringName(d.get("kind", "wheat")),
|
||||
int(d.get("stage", 0))
|
||||
)
|
||||
|
||||
|
||||
func _spawn_bed(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _BED_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
|
||||
ent.build_progress = int(d.get("build_progress", 0))
|
||||
if bool(d.get("completed", false)):
|
||||
ent.build_progress = ent.BUILD_TICKS
|
||||
ent.on_build_tick()
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
|
||||
|
||||
func _spawn_torch(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _TORCH_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
|
||||
ent.build_progress = int(d.get("build_progress", 0))
|
||||
if bool(d.get("completed", false)):
|
||||
ent.build_progress = ent.BUILD_TICKS
|
||||
ent.on_build_tick()
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
|
||||
|
||||
func _spawn_stockpile_zone(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _STOCKPILE_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
else:
|
||||
# Fallback: wire minimal fields directly when from_dict not yet present.
|
||||
ent.region = Rect2i(
|
||||
int(d.get("region_x", 0)), int(d.get("region_y", 0)),
|
||||
int(d.get("region_w", 4)), int(d.get("region_h", 4))
|
||||
)
|
||||
ent.label = str(d.get("label", "Stockpile"))
|
||||
var pri_int: int = int(d.get("priority", 2))
|
||||
if pri_int >= 0 and pri_int < StorageDestination.Priority.size():
|
||||
ent.priority = pri_int as StorageDestination.Priority
|
||||
var types_raw: Array = d.get("accepted_types", [])
|
||||
var types: Array[StringName] = []
|
||||
for t in types_raw:
|
||||
types.append(StringName(t))
|
||||
ent.accepted_types = types
|
||||
ent.queue_redraw()
|
||||
|
||||
|
||||
func _spawn_crate(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _CRATE_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
|
||||
ent.build_progress = int(d.get("build_progress", 0))
|
||||
if bool(d.get("completed", false)):
|
||||
# Crate uses a loop (mirrors world.gd's pre-build pattern).
|
||||
while ent.is_buildable():
|
||||
ent.on_build_tick()
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
|
||||
|
||||
func _spawn_wolf(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _WOLF_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
else:
|
||||
# Fallback for pre-from_dict Wolf.
|
||||
ent.tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||
ent.hp = float(d.get("hp", 40.0))
|
||||
ent.position = Vector2(ent.tile.x * 16 + 8.0, ent.tile.y * 16 + 8.0)
|
||||
ent.queue_redraw()
|
||||
|
||||
|
||||
func _spawn_corpse(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent = _CORPSE_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
|
||||
|
||||
func _spawn_grave_marker(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent := Node2D.new()
|
||||
ent.set_script(_GRAVE_MARKER_SCRIPT)
|
||||
ent.name = "GraveMarker_%s" % str(d.get("pawn_name", "Unknown"))
|
||||
world_scene.add_child(ent)
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
|
||||
|
||||
func _spawn_grave_slot(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent := Node2D.new()
|
||||
ent.set_script(_GRAVE_SLOT_SCRIPT)
|
||||
var tx: int = int(d.get("tile_x", 0))
|
||||
var ty: int = int(d.get("tile_y", 0))
|
||||
ent.name = "GraveSlot_%d_%d" % [tx, ty]
|
||||
world_scene.add_child(ent)
|
||||
if ent.has_method("setup"):
|
||||
ent.setup(Vector2i(tx, ty))
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
|
||||
|
||||
func _spawn_graveyard_zone(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent := Node2D.new()
|
||||
ent.set_script(_GRAVEYARD_ZONE_SCRIPT)
|
||||
var rx: int = int(d.get("region_x", 0))
|
||||
var ry: int = int(d.get("region_y", 0))
|
||||
ent.name = "GraveyardZone_%d_%d" % [rx, ry]
|
||||
world_scene.add_child(ent)
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
else:
|
||||
ent.region = Rect2i(rx, ry, int(d.get("region_w", 1)), int(d.get("region_h", 1)))
|
||||
ent.label = str(d.get("label", "Graveyard"))
|
||||
ent.queue_redraw()
|
||||
|
||||
|
||||
func _spawn_pawn(world_scene: Node, d: Dictionary) -> void:
|
||||
var ent: Pawn = _PAWN_SCENE.instantiate()
|
||||
world_scene.add_child(ent)
|
||||
|
||||
# Attach JobRunner — mirrors world.gd's _spawn_sample_pawns pattern.
|
||||
if World.pathfinder != null:
|
||||
var jr := JobRunner.new()
|
||||
jr.name = "JobRunner"
|
||||
ent.add_child(jr)
|
||||
jr.setup(ent, World.pathfinder)
|
||||
ent.job_runner = jr
|
||||
|
||||
# Minimal setup so tile / position is sane before from_dict overwrites everything.
|
||||
ent.setup(str(d.get("name", "Unknown")),
|
||||
Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
|
||||
|
||||
# Full state restore — overwrites setup() defaults.
|
||||
if ent.has_method("from_dict"):
|
||||
ent.from_dict(d)
|
||||
|
||||
World.register_pawn(ent)
|
||||
|
||||
|
||||
# ── tilemap helpers (defensive wrappers for Agent A's additions) ───────────────
|
||||
# World.save_tilemap_layers() and World.apply_tilemap_layers() are added by
|
||||
# Agent A in the Phase 16 parallel pass. has_method() guards keep this file
|
||||
# clean when Agent A's work has not yet landed.
|
||||
|
||||
func _save_tilemap_layers_safe() -> Dictionary:
|
||||
if World.has_method("save_tilemap_layers"):
|
||||
return World.save_tilemap_layers()
|
||||
return {}
|
||||
|
||||
|
||||
func _apply_tilemap_layers_safe(data: Dictionary) -> void:
|
||||
if World.has_method("apply_tilemap_layers"):
|
||||
World.apply_tilemap_layers(data)
|
||||
else:
|
||||
Audit.log("save", "apply_tilemap_layers: World helper not yet available — skipping")
|
||||
|
||||
|
||||
# ── beauty / dirt helpers ──────────────────────────────────────────────────────
|
||||
|
||||
func _save_beauty_safe() -> Dictionary:
|
||||
if World.beauty_system != null and World.beauty_system.has_method("save_dict"):
|
||||
return World.beauty_system.save_dict()
|
||||
return {}
|
||||
|
||||
|
||||
func _save_dirt_safe() -> Dictionary:
|
||||
if World.dirtiness_system != null and World.dirtiness_system.has_method("save_dict"):
|
||||
return World.dirtiness_system.save_dict()
|
||||
return {}
|
||||
|
||||
|
||||
# ── file I/O ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func _write_json(slot: StringName, payload: Dictionary) -> bool:
|
||||
var path := _path_for(slot)
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_error("SaveSystem._write_json: cannot open '%s' for writing" % path)
|
||||
return false
|
||||
f.store_string(JSON.stringify(payload, "\t"))
|
||||
return true
|
||||
|
||||
|
||||
func _path_for(slot: StringName) -> String:
|
||||
return "user://save_%s.json" % String(slot)
|
||||
|
||||
|
||||
# ── world scene locator ────────────────────────────────────────────────────────
|
||||
|
||||
## Returns the root Node2D of the World scene — the parent of all entity children.
|
||||
## The World autoload is a data-only Node; the scene instance is a Node2D.
|
||||
## Searches the scene tree root for a node named "World" first, then falls back
|
||||
## to the first Node2D child of root (which is the active scene on most projects).
|
||||
func _get_world_scene() -> Node:
|
||||
var root := get_tree().root
|
||||
var candidate := root.get_node_or_null("World")
|
||||
if candidate != null:
|
||||
return candidate
|
||||
# Fallback: first Node2D child of root.
|
||||
for child in root.get_children():
|
||||
if child is Node2D:
|
||||
return child
|
||||
push_error("SaveSystem._get_world_scene: cannot locate World scene node")
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -60,6 +60,23 @@ const TABLE: Dictionary = {
|
|||
# Phase 15 — Storyteller UI buttons
|
||||
&"ui.go_there": "Go there",
|
||||
&"ui.dismiss": "Dismiss",
|
||||
# Phase 16 — Save/Load UI
|
||||
&"ui.save": "Save",
|
||||
&"ui.load": "Load",
|
||||
&"ui.saved": "Saved",
|
||||
&"ui.saving": "Saving…",
|
||||
&"ui.no_saves": "No saves yet.",
|
||||
&"ui.continue": "Continue",
|
||||
&"ui.cancel": "Cancel",
|
||||
&"ui.manual_save": "Manual save",
|
||||
&"ui.autosave": "Autosave",
|
||||
&"ui.version_mismatch": "This save is from an older version (v{v}) — loading may fail. Continue?",
|
||||
&"ui.welcome_back": "Welcome back — away {n}",
|
||||
&"ui.welcome_back_min": "{n} minute",
|
||||
&"ui.welcome_back_mins": "{n} minutes",
|
||||
&"ui.welcome_back_hour": "{n} hour",
|
||||
&"ui.welcome_back_hours": "{n} hours",
|
||||
&"ui.load_failed": "Load failed (corrupt or version mismatch).",
|
||||
# Phase 15 — Storyteller event titles + bodies (25-event corpus).
|
||||
# EventDef factories in EventCatalog carry the English string directly;
|
||||
# these keys exist so Strings.t() is the indirection point for future locale
|
||||
|
|
|
|||
|
|
@ -116,6 +116,55 @@ func clear_pawns() -> void:
|
|||
pawns.clear()
|
||||
|
||||
|
||||
## Phase 16 — wipe every entity registry on the autoload AND queue_free the
|
||||
## live Nodes inside the World scene so apply_save can rebuild from scratch.
|
||||
## The World *scene* (TileMapLayers, providers, autoloads) survives — only the
|
||||
## per-entity registrations + their Node2D children get cleared.
|
||||
##
|
||||
## Safe to call between sim ticks. Called by SaveSystem.apply_save.
|
||||
func clear_all() -> void:
|
||||
# 1. queue_free every registered entity Node so it leaves the tree.
|
||||
var to_free: Array = []
|
||||
to_free.append_array(pawns)
|
||||
to_free.append_array(items)
|
||||
to_free.append_array(trees)
|
||||
to_free.append_array(rocks)
|
||||
to_free.append_array(build_queue) # ghost entities
|
||||
to_free.append_array(doors)
|
||||
to_free.append_array(workbenches)
|
||||
to_free.append_array(crops)
|
||||
to_free.append_array(beds)
|
||||
to_free.append_array(light_sources)
|
||||
to_free.append_array(wolves)
|
||||
to_free.append_array(corpses)
|
||||
to_free.append_array(grave_markers)
|
||||
to_free.append_array(stockpiles)
|
||||
for ent in to_free:
|
||||
if ent != null and is_instance_valid(ent) and ent.has_method("queue_free"):
|
||||
ent.queue_free()
|
||||
# 2. Clear every registry (entity _exit_tree handlers will also try to erase
|
||||
# themselves; double-clear is harmless on a Dictionary/Array).
|
||||
pawns.clear()
|
||||
items.clear()
|
||||
trees.clear()
|
||||
rocks.clear()
|
||||
build_queue.clear()
|
||||
doors.clear()
|
||||
workbenches.clear()
|
||||
crops.clear()
|
||||
beds.clear()
|
||||
light_sources.clear()
|
||||
wolves.clear()
|
||||
corpses.clear()
|
||||
grave_markers.clear()
|
||||
stockpiles.clear()
|
||||
items_needing_haul.clear()
|
||||
rooms.clear()
|
||||
# BeautySystem / DirtinessSystem maps survive (their owning Nodes do too);
|
||||
# their .clear_all() helpers (if any) live on the system itself.
|
||||
Audit.log("world", "clear_all: registries wiped, %d nodes queue_freed" % to_free.size())
|
||||
|
||||
|
||||
# ── Phase 4: harvestables + items + stockpiles ──────────────────────────────
|
||||
|
||||
func register_tree(t) -> void:
|
||||
|
|
@ -283,9 +332,11 @@ func is_tile_lit(p_tile: Vector2i) -> bool:
|
|||
# Called by Wall.on_build_tick() when construction completes.
|
||||
# Stamps the data-only Wall TileMap layer so room/roof/save logic sees the
|
||||
# wall. World scene exposes wall_layer via a getter set during _ready.
|
||||
var terrain_layer = null
|
||||
var wall_layer = null
|
||||
var floor_layer = null
|
||||
var designation_layer = null
|
||||
var roof_layer = null
|
||||
|
||||
|
||||
func mark_wall_tile(tile: Vector2i, material: StringName) -> void:
|
||||
|
|
@ -393,3 +444,74 @@ var beauty_system = null
|
|||
## Exposes: dirt_at(tile), bump(tile, amount), bump_clean(tile, amount),
|
||||
## bump_pawn_traffic(tile, indoor).
|
||||
var dirtiness_system = null
|
||||
|
||||
|
||||
# ── Phase 16: TileMap layer serialisation helpers ────────────────────────────
|
||||
|
||||
## Serialize the five sim-relevant TileMap layers (Terrain, Floor, Wall,
|
||||
## Designation, Roof) into a plain Dictionary. Fog layer is runtime-only and
|
||||
## is NOT serialized.
|
||||
##
|
||||
## Returns:
|
||||
## {
|
||||
## "terrain": [{x, y, source_id, atlas_x, atlas_y}, ...],
|
||||
## "floor": [...],
|
||||
## "wall": [...],
|
||||
## "designation": [...],
|
||||
## "roof": [...],
|
||||
## }
|
||||
##
|
||||
## Called by SaveSystem.build_save() between sim ticks.
|
||||
func save_tilemap_layers() -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var layer_map: Dictionary = {
|
||||
"terrain": terrain_layer,
|
||||
"floor": floor_layer,
|
||||
"wall": wall_layer,
|
||||
"designation": designation_layer,
|
||||
"roof": roof_layer,
|
||||
}
|
||||
for layer_name in layer_map:
|
||||
var layer = layer_map[layer_name]
|
||||
var entries: Array = []
|
||||
if layer == null:
|
||||
result[layer_name] = entries
|
||||
continue
|
||||
for cell in layer.get_used_cells():
|
||||
var src_id: int = layer.get_cell_source_id(cell)
|
||||
var atlas: Vector2i = layer.get_cell_atlas_coords(cell)
|
||||
entries.append({
|
||||
"x": cell.x,
|
||||
"y": cell.y,
|
||||
"source_id": src_id,
|
||||
"atlas_x": atlas.x,
|
||||
"atlas_y": atlas.y,
|
||||
})
|
||||
result[layer_name] = entries
|
||||
return result
|
||||
|
||||
|
||||
## Restore the five TileMap layers from a dict produced by save_tilemap_layers().
|
||||
## Clears each layer first, then stamps every saved cell.
|
||||
##
|
||||
## Called by SaveSystem.apply_save() before respawning entities.
|
||||
func apply_tilemap_layers(d: Dictionary) -> void:
|
||||
var layer_map: Dictionary = {
|
||||
"terrain": terrain_layer,
|
||||
"floor": floor_layer,
|
||||
"wall": wall_layer,
|
||||
"designation": designation_layer,
|
||||
"roof": roof_layer,
|
||||
}
|
||||
for layer_name in layer_map:
|
||||
var layer = layer_map[layer_name]
|
||||
if layer == null:
|
||||
continue
|
||||
layer.clear()
|
||||
for entry in d.get(layer_name, []):
|
||||
if not entry is Dictionary:
|
||||
continue
|
||||
var cell := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0)))
|
||||
var src_id: int = int(entry.get("source_id", 0))
|
||||
var atlas := Vector2i(int(entry.get("atlas_x", 0)), int(entry.get("atlas_y", 0)))
|
||||
layer.set_cell(cell, src_id, atlas)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue