rimlike/autoload/save_system.gd
megaproxy d98d2c2425 Renewable resources: tree growth + WildGrowth + Quarry on BigRockNode
Trees: 4 growth stages (Sapling→Young→Growing→Mature), only Mature
yields wood. WildGrowth ticker fires every in-game hour; rejection-
samples grass tiles and plants a sapling with ~30% probability (capped
at MAP_TREE_LIMIT=60). New `paint_plant_tree` designation lets the
player manually plant — ghost sapling registered as a build_site that
ConstructionProvider fulfils. Stage round-trips through save/load.
Initial seed mixes 4 saplings + 6 mature so growth is visible day 1.

Quarry: new BigRockNode entity (2×2 permanent stone outcrop, never
depletes). 3 nodes seeded far from cabin. New QuarryWorkbench
(extends Workbench, auto-FOREVER `quarry_stone` bill, recipe drops
1 stone per 300 work-ticks). New `paint_quarry` designation only
accepts BigRockNode tiles. CraftingProvider now supports recipes
with `ingredient_count == 0` — skips ingredient-fetch and goes
straight to walk+craft toils. Recipe gains `ingredient_count` field
(defaults 0). Save/load layering: big_rock_node spawns at priority 0
(same as rock/tree), quarry_workbench at priority 2 (after the node).

UI: Plant tree + Build quarry buttons added to Build drawer.
build_drawer_thumb gains `plant_tree` (sapling sprout in dirt) and
`paint_quarry` (stone block + chisel + cut-stone pile) shapes.
inspect_tooltip recognises BigRockNode + shows tree growth stage on
hover.

Delegation: gdscript-refactor (Sonnet ×2) for trees full impl +
quarry skeleton; quick-edit (Haiku) for CraftingProvider no-ingredient
plumbing + TopBar polish; integration handled on Opus.
2026-05-16 16:36:16 +01:00

717 lines
26 KiB
GDScript

extends Node
## SaveSystem v2 — full entity serialization + clear-and-respawn load.
##
## 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".
##
## 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 = 2
# ── PackedScene preloads (mirror world.gd constants) ──────────────────────────
# Keep in sync with scenes/world/world.gd SCENE constants.
# If a scene path changes, update both files.
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 _BIG_ROCK_SCENE: PackedScene = preload("res://scenes/entities/big_rock.tscn")
const _BIG_ROCK_NODE_SCENE: PackedScene = preload("res://scenes/entities/big_rock_node.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
apply_save(payload, slot)
return true
## 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 {exists = false, saved_at_unix = 0, version = 0}
var parsed: Variant = JSON.parse_string(f.get_as_text())
if typeof(parsed) != TYPE_DICTIONARY:
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 {}
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 in '%s' (file v%d vs code v%d) — best-effort load" %
[path, ver, SAVE_VERSION]
)
return parsed
## 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"])
# 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.big_rock_nodes,
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,
&"big_rock": 0,
&"big_rock_node": 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[&"big_rock"] = _spawn_big_rock
_factories[&"big_rock_node"] = _spawn_big_rock_node
_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)
# Pass growth_stage to setup() so _refresh_sprite() picks the right visual.
# Default 3 (STAGE_MATURE) so pre-growth-system saves load as mature trees.
var gs: int = int(d.get("growth_stage", 3))
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))), gs)
ent.chop_progress = int(d.get("chop_progress", 0))
ent.growth_progress = int(d.get("growth_progress", 0))
ent.chop_designated = bool(d.get("chop_designated", false))
ent.pending_plant = bool(d.get("pending_plant", false))
ent._plant_progress = int(d.get("plant_progress", 0))
# Re-register as build site if planting is still in progress.
if ent.pending_plant:
World.register_build_site(ent)
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_big_rock(world_scene: Node, d: Dictionary) -> void:
var ent = _BIG_ROCK_SCENE.instantiate()
world_scene.add_child(ent)
ent.setup(Vector2i(int(d.get("origin_x", 0)), int(d.get("origin_y", 0))))
ent.mine_progress = int(d.get("mine_progress", 0))
ent.queue_redraw()
func _spawn_big_rock_node(world_scene: Node, d: Dictionary) -> void:
var ent = _BIG_ROCK_NODE_SCENE.instantiate()
world_scene.add_child(ent)
ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))))
ent.is_quarry_site = bool(d.get("is_quarry_site", false))
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
ent.subtype = StringName(d.get("subtype", ""))
ent.queue_redraw()
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