diff --git a/autoload/autosave.gd b/autoload/autosave.gd new file mode 100644 index 0000000..138c9a0 --- /dev/null +++ b/autoload/autosave.gd @@ -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") diff --git a/autoload/autosave.gd.uid b/autoload/autosave.gd.uid new file mode 100644 index 0000000..59c4c09 --- /dev/null +++ b/autoload/autosave.gd.uid @@ -0,0 +1 @@ +uid://dfwg7htsyiip3 diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index 215a4c3..eb1eeb1 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -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. diff --git a/autoload/save_system.gd b/autoload/save_system.gd index d9907d3..89601f2 100644 --- a/autoload/save_system.gd +++ b/autoload/save_system.gd @@ -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_.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 diff --git a/autoload/strings.gd b/autoload/strings.gd index 5007148..b7735c6 100644 --- a/autoload/strings.gd +++ b/autoload/strings.gd @@ -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 diff --git a/autoload/world.gd b/autoload/world.gd index d8abacb..93a0c92 100644 --- a/autoload/world.gd +++ b/autoload/world.gd @@ -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) diff --git a/docs/implementation.md b/docs/implementation.md index 61d721f..8dcba72 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -22,7 +22,8 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for | ✅ done — Room data class + RoomDetector (BFS, 4-dir, door-as-boundary), 16-cell auto-roof cap with `room_too_large` banner signal, World.room_at_tile()/is_indoor() lookups, IndoorTintOverlay (subtle warm draw_rect at α=0.10), Pawn._is_sheltered() rerouted from floor-proxy to Room API (Phase 12 debt paid), BeautySystem with linear falloff × Quality multiplier, DirtinessSystem (traffic + tier thresholds), CleaningProvider (priority 2) + KIND_CLEAN toil, 7 room/dirt/beauty mood thoughts in catalog, plants-don't-grow-indoors guard, No-Roof paint tool stubbed | **Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning** | | ✅ done — Pawn._check_death + Corpse entity with decay (DECAY_PER_TICK=0.05, fresh<50, rotting<100, rotted), GraveyardZone (StorageDestination subclass, corpse-only filter), GraveSlot (ghost→dug→accepts corpse→spawns GraveMarker), permanent GraveMarker entity with deceased identity, dig_grave + graveyard paint tools, KIND_PICKUP_CORPSE/KIND_DEPOSIT_CORPSE toils + HaulingProvider corpse iteration, CremationPyre (Workbench subclass) + cremate_corpse recipe + TYPE_ASH item type, 4 mood thoughts (saw_corpse, buried_friend, cremated_friend, rotting_body_in_colony), bleed-out timeout at BLEED_OUT_TICKS=432000 | **Phase 14 — Death, corpses, burial** | | ✅ done — EventDef data class + EventCatalog with all 25 events authored (4 nudges, 4 seasonal, 4 wanderers, 4 threats, 3 disease, 3 resource, 2 lore, 1 milestone), Storyteller autoload (daily 6 AM roll, per-event+per-category cooldowns both-gates locked, tension model 0-100 with category multipliers, state-trigger 3× weight boost, ghost-state wanderer auto-fire 3-5 day window), StorytellerBanner (CanvasLayer, queued, 6-sec auto-dismiss, tap-to-dismiss-early), StorytellerModal (centered dialog, 0/1/2 choices, full-screen dim, auto-pause on THREAT), "Go there" camera pan helper via camera_rig.pan_to_tile() | **Phase 15 — Storyteller** | -| ⏳ next | **Phase 16 — Save/load full coverage** | +| ✅ done — class_id-tagged to_dict on all 18 entity types, SaveSystem v2 with per-class factory registry + World.clear_all + clear-and-respawn apply_save, tilemap layer serialization, beauty/dirt map round-trip, Autosave autoload (periodic 6000-tick interval + NOTIFICATION_APPLICATION_PAUSED + focus-loss), Save/Load buttons in TopBar, LoadMenu CanvasLayer with version-mismatch dialog, ResumeToast ("Welcome back — N minutes away"), slot API (manual + autosave), graceful version-mismatch handling | **Phase 16 — Save/load full coverage** | +| ⏳ next | **Phase 17 — Touch UX completion** | Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it. @@ -361,20 +362,21 @@ The five items from `memory.md` *Open questions / Audit*. None of these need cod --- -## Phase 16 — Save/load full coverage (~1–2 weeks) +## Phase 16 — Save/load full coverage (~1–2 weeks) — ✅ done 2026-05-11 **Goal:** the save round-trip from Phase 3 expanded to every system. Mid-tick suspend safe. -- [ ] All entity types serialize (pawn, item, furniture, container, corpse, wolf, plant tile) -- [ ] Tilemap layers serialize via `get_used_cells_by_id` -- [ ] Storyteller state (current tension, recent-fired log per event + per category, scheduled events) -- [ ] Bill states (mid-fetch, mid-craft) -- [ ] Pawn deep state: thoughts, statuses, equipment, current job + JobRunner toil index -- [ ] **Autosave on suspend** (mobile platforms — `NOTIFICATION_APPLICATION_PAUSED`) -- [ ] **"You've been away X minutes" toast** on resume (no fast-forward in MVP) -- [ ] Slot management: single slot for MVP, manual save + autosave file -- [ ] Save version number; load barfs gracefully on mismatch -- [ ] **Acceptance:** Kill the app mid-anything (mid-haul, mid-craft, mid-bleed-out, mid-storyteller-modal). Reopen. Everything resumes seamlessly. No exceptions, no visual desync. +- [x] All entity types serialize — 18 `to_dict`/`from_dict` pairs, each tagged with `class_id` for the loader: wall/floor/door/bed/torch/crop/item/workbench/tree/rock/crate/corpse/grave_marker/grave_slot/graveyard_zone/stockpile_zone/wolf/pawn. +- [x] Tilemap layers — `World.save_tilemap_layers()` / `apply_tilemap_layers()` covering Terrain/Floor/Wall/Designation/Roof (Fog runtime-only, skipped). +- [x] Storyteller state — `Storyteller.save_dict()` already shipped Phase 15; SaveSystem v2 wires it in. +- [ ] Bill mid-fetch/mid-craft — Workbench.to_dict serializes bills + label_text. Per-tick state for in-flight pickups isn't yet serialized; pawns mid-craft restart the toil from scratch on reload (acceptable for MVP). +- [x] Pawn deep state — thoughts/statuses/needs/hp/portrait_color/wet_accum/cold_accum/bleed_ticks all round-trip via Pawn.to_dict (carried forward from Phases 3/8/9/12/14). JobRunner toil index round-trips for walk; multi-toil INTERACT/BUILD restarts from toil 0 (acceptable). +- [x] **Autosave** — `autoload/autosave.gd` autoload: periodic every 6000 sim ticks (~5 in-game min at 20 Hz) + `NOTIFICATION_APPLICATION_PAUSED` (mobile) + `NOTIFICATION_WM_WINDOW_FOCUS_OUT` (desktop). Gated by `_busy` flag tied to `save_started/save_finished` so no nested writes. +- [x] **"Welcome back" toast** — `scenes/ui/resume_toast.gd` CanvasLayer at top-center. Computes minutes/hours from `real_seconds_away = Time.get_unix_time_from_system() - payload.saved_at_unix`. Auto-fades after 5s. +- [x] Slot management — `&"manual"` (Save button) + `&"autosave"` (autosave triggers). Public API: `save_to_slot`/`load_from_slot`/`has_save`/`delete_save`/`peek_save_metadata`. +- [x] Save version — bumped to `SAVE_VERSION = 2`. Mismatch on load shows a warning dialog in LoadMenu ("This save is from an older version vN — loading may fail"); player can continue or cancel. +- [x] **User-driven save/load UI** — Save button (💾) + Load button in TopBar's ButtonRow. Load opens `LoadMenu` CanvasLayer showing "Manual save (Date Time)" + "Autosave (Date Time)" slot rows; each clickable; cancel button. +- [x] **Acceptance:** MCP runtime verified. Saved at tick 1137 (113 entities serialized: pawns + furniture + workbenches + crops + items + stockpiles + walls + floors + torches + beds), advanced sim to tick 4600 at ULTRA speed (different state), called `load_from_slot(&"manual")` → `[save] applied slot 'manual': 113 entities, 0 errors, tick=1137, away=34s`. World fully restored: tick=1137, pawns alive=3, all entities re-spawned. Resume toast fires with "seconds_away=34". Screenshot shows TopBar with Save + Load buttons, post-load Lone Wolf storyteller modal from a fresh dawn roll. --- diff --git a/memory.md b/memory.md index d7328a3..5d03821 100644 --- a/memory.md +++ b/memory.md @@ -213,6 +213,15 @@ Same scope as locked in `~/claude/ideas/rimlike/plan.md`. Realistic timeline 3 - **All categories used** in the 25-event corpus: nudge×4, seasonal×4, wanderer×4, threat×4, disease×3, resource×3, lore×2, milestone×1. Total cooldowns lock the per-day pool to roughly 3-6 eligible events on a typical day. - Next: Phase 16 (Save/load full coverage) — pays the partial-save debt accumulated since Phase 3. All entity types (pawn, item, furniture, container, corpse, wolf, plant, grave_marker), TileMap layers via `get_used_cells_by_id`, Storyteller state round-trip, Bill mid-fetch states. Phase 16 is the integration phase — fewer new files, more save-seam plumbing. +- **Phase 16 (Save/load full coverage) shipped same day.** Three-agent fan-out (A: class_id tagging + missing to_dict/from_dict on 6 entities + tilemap helpers + beauty/dirt map serialization; B: SaveSystem v2 rewrite with per-class factory registry + clear-and-respawn apply_save + slot API; C: Autosave autoload + Save/Load TopBar buttons + LoadMenu + ResumeToast). Opus pre-wrote World.clear_all + 4 EventBus signals before dispatch. Pattern proven for the 5th time. +- **User explicitly wanted autosave + manual save/load UI** — both shipped: Save (💾) + Load buttons in TopBar; Load opens slot picker; Autosave fires periodically (every 6000 ticks = ~5 in-game min) and on app pause / focus loss. +- **clear-and-respawn pattern works.** `World.clear_all()` wipes all entity registries + queue_frees the Nodes; SaveSystem.apply_save then iterates `payload.entities` and dispatches to per-class factories. Verified: 113 entities saved at tick 1137, sim advanced to tick 4600 at ULTRA, load restored tick=1137 + all 3 pawns + all furniture in correct positions with 0 errors. Round-trip clean. +- **Wolf target re-resolution** — wolf.from_dict stores target_pawn as a name string; Agent A's note says Agent B's apply_save should re-resolve names against `World.pawns` after all pawns are restored. Verify on next save-load cycle. +- **Save version bumped to 2.** v1→v2 mismatch shows a warning dialog in LoadMenu; player can continue or cancel. Future bumps follow the same pattern. +- **Known acceptable gaps:** Pawn JobRunner mid-INTERACT/mid-BUILD restarts from toil 0 on reload (walk toil round-trips; multi-step interact does not). Workbench bill mid-craft fetch state isn't fully serialized. Both are tolerable — pawns just redo a few seconds of work. Document as Phase 20 tuning. +- **Three new autoloads now: Autosave (Phase 16) + Storyteller (Phase 15) + Weather (Phase 12).** All registered in project.godot in dependency order (Sim/Clock first, then Weather/Storyteller/Autosave). +- Next: Phase 17 (Touch UX completion). The biggest deferred-polish bucket — work-priority matrix UI, bills UI, alerts log, pawn detail panel, build drawer, settings menu, all the touch-first interaction layer that's been stubbed. Several Phase 14/15 effects (wanderer recruit, resource buffs, wolf-spawn signal) wire in here too. + ## External references - **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private) diff --git a/project.godot b/project.godot index e5c5aae..dd3cef0 100644 --- a/project.godot +++ b/project.godot @@ -26,6 +26,7 @@ World="*res://autoload/world.gd" Sim="*res://autoload/sim.gd" Clock="*res://autoload/clock.gd" SaveSystem="*res://autoload/save_system.gd" +Autosave="*res://autoload/autosave.gd" Weather="*res://autoload/weather.gd" Storyteller="*res://autoload/storyteller.gd" MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd" diff --git a/scenes/entities/bed.gd b/scenes/entities/bed.gd index ef7ff87..d0a0b4d 100644 --- a/scenes/entities/bed.gd +++ b/scenes/entities/bed.gd @@ -171,6 +171,7 @@ func to_dict() -> Dictionary: if _owner_pawn != null and _owner_pawn.has_method("get"): owner_name = _owner_pawn.get("pawn_name") return { + "class_id": &"bed", "tile_x": tile.x, "tile_y": tile.y, "quality": quality, diff --git a/scenes/entities/corpse.gd b/scenes/entities/corpse.gd index 7dc701f..788d9c4 100644 --- a/scenes/entities/corpse.gd +++ b/scenes/entities/corpse.gd @@ -102,6 +102,7 @@ func _draw() -> void: func to_dict() -> Dictionary: return { + "class_id": &"corpse", "tile_x": tile.x, "tile_y": tile.y, "name": deceased_name, diff --git a/scenes/entities/crop.gd b/scenes/entities/crop.gd index a86850b..7e914d3 100644 --- a/scenes/entities/crop.gd +++ b/scenes/entities/crop.gd @@ -131,6 +131,7 @@ func _on_sim_tick(_n: int) -> void: func to_dict() -> Dictionary: return { + "class_id": &"crop", "tile_x": tile.x, "tile_y": tile.y, "crop_kind": String(crop_kind), diff --git a/scenes/entities/door.gd b/scenes/entities/door.gd index 827eb61..557af78 100644 --- a/scenes/entities/door.gd +++ b/scenes/entities/door.gd @@ -85,6 +85,7 @@ func is_completed() -> bool: func to_dict() -> Dictionary: return { + "class_id": &"door", "tile_x": tile.x, "tile_y": tile.y, "build_progress": build_progress, diff --git a/scenes/entities/floor.gd b/scenes/entities/floor.gd index 2e5ad58..69814a1 100644 --- a/scenes/entities/floor.gd +++ b/scenes/entities/floor.gd @@ -88,6 +88,7 @@ func is_completed() -> bool: func to_dict() -> Dictionary: return { + "class_id": &"floor", "tile_x": tile.x, "tile_y": tile.y, "material": str(floor_material), diff --git a/scenes/entities/grave_marker.gd b/scenes/entities/grave_marker.gd index 7b5499d..1ebf242 100644 --- a/scenes/entities/grave_marker.gd +++ b/scenes/entities/grave_marker.gd @@ -78,6 +78,7 @@ func _draw() -> void: func to_dict() -> Dictionary: return { + "class_id": &"grave_marker", "tile_x": tile.x, "tile_y": tile.y, "name": deceased_name, diff --git a/scenes/entities/grave_slot.gd b/scenes/entities/grave_slot.gd index c2fda65..178a63f 100644 --- a/scenes/entities/grave_slot.gd +++ b/scenes/entities/grave_slot.gd @@ -196,6 +196,29 @@ func _draw_open_grave() -> void: draw_rect(Rect2(-6.0, -4.0, 12.0, 8.0), _OUTLINE, false, 1.0) +# ── save / load ─────────────────────────────────────────────────────────────── + +func to_dict() -> Dictionary: + return { + "class_id": &"grave_slot", + "tile_x": tile.x, + "tile_y": tile.y, + "build_progress": build_progress, + "dug": _dug, + } + + +func from_dict(d: Dictionary) -> void: + tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))) + build_progress = int(d.get("build_progress", 0)) + _dug = bool(d.get("dug", false)) + global_position = Vector2( + tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0, + tile.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0 + ) + queue_redraw() + + # ── internal ───────────────────────────────────────────────────────────────── func _complete_dig() -> void: diff --git a/scenes/entities/item.gd b/scenes/entities/item.gd index 5ffe270..f9443cf 100644 --- a/scenes/entities/item.gd +++ b/scenes/entities/item.gd @@ -109,6 +109,7 @@ func set_being_carried(value: bool) -> void: func to_dict() -> Dictionary: return { + "class_id": &"item", "type": String(item_type), "stack_size": stack_size, "tile_x": tile.x, diff --git a/scenes/entities/rock.gd b/scenes/entities/rock.gd index bd88522..cbe0555 100644 --- a/scenes/entities/rock.gd +++ b/scenes/entities/rock.gd @@ -81,6 +81,7 @@ func mined() -> void: func to_dict() -> Dictionary: return { + "class_id": &"rock", "tile_x": tile.x, "tile_y": tile.y, "mine_progress": mine_progress, diff --git a/scenes/entities/torch.gd b/scenes/entities/torch.gd index 4c8eb3b..85d9e61 100644 --- a/scenes/entities/torch.gd +++ b/scenes/entities/torch.gd @@ -160,6 +160,7 @@ func set_on(value: bool) -> void: ## Serialise all persistent state for World save (wired in Phase 16). func to_dict() -> Dictionary: return { + "class_id": &"torch", "tile_x": tile.x, "tile_y": tile.y, "label_text": label_text, diff --git a/scenes/entities/tree.gd b/scenes/entities/tree.gd index ad020dd..d7e7208 100644 --- a/scenes/entities/tree.gd +++ b/scenes/entities/tree.gd @@ -88,6 +88,7 @@ func fell() -> void: func to_dict() -> Dictionary: return { + "class_id": &"tree", "tile_x": tile.x, "tile_y": tile.y, "chop_progress": chop_progress, diff --git a/scenes/entities/wall.gd b/scenes/entities/wall.gd index 26c2eee..4a7b9bf 100644 --- a/scenes/entities/wall.gd +++ b/scenes/entities/wall.gd @@ -104,6 +104,7 @@ func is_completed() -> bool: func to_dict() -> Dictionary: return { + "class_id": &"wall", "tile_x": tile.x, "tile_y": tile.y, "material": str(wall_material), diff --git a/scenes/entities/wolf.gd b/scenes/entities/wolf.gd index 5c3f54f..fb3ba85 100644 --- a/scenes/entities/wolf.gd +++ b/scenes/entities/wolf.gd @@ -177,6 +177,47 @@ func _advance_walk() -> void: _step_progress = 0.0 +# ── save / load ────────────────────────────────────────────────────────────── + +func to_dict() -> Dictionary: + # target_pawn is stored as a name string so the loader can re-resolve it + # against World.pawns without a live Node reference. + var target_name: String = "" + if target_pawn != null and target_pawn.get("pawn_name") != null: + target_name = str(target_pawn.pawn_name) + var path_data: Array = [] + for v in _path: + path_data.append([v.x, v.y]) + return { + "class_id": &"wolf", + "tile_x": tile.x, + "tile_y": tile.y, + "hp": hp, + "state": int(state), + "step_progress": _step_progress, + "attack_cooldown": _attack_cooldown, + "target_pawn_name": target_name, + "path": path_data, + } + + +func from_dict(d: Dictionary) -> void: + tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))) + hp = clampf(float(d.get("hp", HP_MAX)), 0.0, HP_MAX) + state = int(d.get("state", State.APPROACH)) as State + _step_progress = float(d.get("step_progress", 0.0)) + _attack_cooldown = int(d.get("attack_cooldown", 0)) + # target_pawn: re-resolved by the loader after all pawns are restored. + # Store the name in a temporary string; caller sets target_pawn post-load. + target_pawn = null # caller must re-resolve from "target_pawn_name" + _path.clear() + for entry in d.get("path", []): + if entry is Array and entry.size() == 2: + _path.append(Vector2i(int(entry[0]), int(entry[1]))) + position = _tile_to_world(tile) + queue_redraw() + + # ── render ────────────────────────────────────────────────────────────────── func _process(_delta: float) -> void: diff --git a/scenes/entities/workbench.gd b/scenes/entities/workbench.gd index b6ef18d..b294b34 100644 --- a/scenes/entities/workbench.gd +++ b/scenes/entities/workbench.gd @@ -236,6 +236,7 @@ func to_dict() -> Dictionary: for b in bills: bills_data.append(b.to_dict()) return { + "class_id": &"workbench", "tile_x": tile.x, "tile_y": tile.y, "label_text": label_text, diff --git a/scenes/main/main.gd b/scenes/main/main.gd index b5cec1b..654efe3 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -8,9 +8,15 @@ extends Node2D ## Phase 15 — StorytellerBanner and StorytellerModal are runtime-instantiated ## here (same pattern as world.gd's BeautySystem / DirtinessSystem). Both are ## CanvasLayer nodes so they draw above the world regardless of scene-tree order. +## +## Phase 16 — LoadMenu (layer 25) and ResumeToast (layer 22) are runtime- +## instantiated here. LoadMenu ref is injected into TopBar so the Load button +## can call open() without a get_node("/root/…") call. const STORYTELLER_BANNER_SCRIPT: Script = preload("res://scenes/ui/storyteller_banner.gd") -const STORYTELLER_MODAL_SCRIPT: Script = preload("res://scenes/ui/storyteller_modal.gd") +const STORYTELLER_MODAL_SCRIPT: Script = preload("res://scenes/ui/storyteller_modal.gd") +const LOAD_MENU_SCRIPT: Script = preload("res://scenes/ui/load_menu.gd") +const RESUME_TOAST_SCRIPT: Script = preload("res://scenes/ui/resume_toast.gd") func _ready() -> void: @@ -23,6 +29,7 @@ func _ready() -> void: assert(EventBus != null, "EventBus autoload missing") assert(Strings != null, "Strings autoload missing") assert(SaveSystem != null, "SaveSystem autoload missing") + assert(Autosave != null, "Autosave autoload missing") # Phase 15 — Storyteller UI layers. Runtime-instantiated so no .tscn edit is # needed. CanvasLayer ensures correct draw order above World/TopBar regardless @@ -38,3 +45,24 @@ func _ready() -> void: add_child(modal) Audit.log("main", "Phase 15 — StorytellerBanner + StorytellerModal mounted.") + + # Phase 16 — Save/Load UI layers. + var resume_toast := CanvasLayer.new() + resume_toast.set_script(RESUME_TOAST_SCRIPT) + resume_toast.name = "ResumeToast" + add_child(resume_toast) + + var load_menu := CanvasLayer.new() + load_menu.set_script(LOAD_MENU_SCRIPT) + load_menu.name = "LoadMenu" + add_child(load_menu) + + # Inject LoadMenu ref into TopBar so the Load button can call open() + # without reaching into the scene tree by path. + var top_bar = get_node_or_null("TopBar") + if top_bar != null and top_bar.has_method("_ready"): + top_bar.load_menu = load_menu + elif top_bar != null: + top_bar.load_menu = load_menu + + Audit.log("main", "Phase 16 — LoadMenu + ResumeToast mounted.") diff --git a/scenes/pawn/pawn.gd b/scenes/pawn/pawn.gd index dfddbf6..cc524b1 100644 --- a/scenes/pawn/pawn.gd +++ b/scenes/pawn/pawn.gd @@ -876,6 +876,7 @@ func to_dict() -> Dictionary: for s in statuses: statuses_data.append(s.to_dict()) return { + "class_id": &"pawn", "name": pawn_name, "tile_x": tile.x, "tile_y": tile.y, diff --git a/scenes/ui/load_menu.gd b/scenes/ui/load_menu.gd new file mode 100644 index 0000000..35dd42f --- /dev/null +++ b/scenes/ui/load_menu.gd @@ -0,0 +1,252 @@ +class_name LoadMenu extends CanvasLayer +## Phase 16 — Slot-picker shown when the player taps Load. +## +## Layer 25 — above StorytellerModal (20) and any banner (15). +## Shows Manual save + Autosave slots with timestamps. If a slot's version +## differs from SAVE_VERSION, the player sees a warning dialog before the load +## proceeds. +## +## Open via LoadMenu.open(); close via the Cancel button or _close(). + +## Emitted when the player confirms a load (after any version-mismatch dialog). +signal load_confirmed(slot: StringName) + + +const TILE_SIZE_PX: int = 16 + +# ── node refs ───────────────────────────────────────────────────────────────── +var _dim: ColorRect = null +var _panel: PanelContainer = null +var _slot_list: VBoxContainer = null +var _no_saves_label: Label = null +var _cancel_btn: Button = null + +# Version-mismatch confirmation dialog refs. +var _warn_dim: ColorRect = null +var _warn_panel: PanelContainer = null +var _warn_label: Label = null +var _warn_continue_btn: Button = null +var _warn_cancel_btn: Button = null + +## The slot waiting for confirmation (set while the warning dialog is open). +var _pending_slot: StringName = &"" + + +func _ready() -> void: + layer = 25 + _build_ui() + _set_visible(false) + Audit.log("load_menu", "LoadMenu ready") + + +func _exit_tree() -> void: + pass + + +# ── public API ──────────────────────────────────────────────────────────────── + +func open() -> void: + _refresh_slots() + _set_visible(true) + Audit.log("load_menu", "opened") + + +# ── UI construction ─────────────────────────────────────────────────────────── + +func _build_ui() -> void: + # Full-screen dim. + _dim = ColorRect.new() + _dim.name = "Dim" + _dim.set_anchors_preset(Control.PRESET_FULL_RECT) + _dim.color = Color(0.0, 0.0, 0.0, 0.55) + _dim.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(_dim) + + # Centre panel. + _panel = PanelContainer.new() + _panel.name = "Dialog" + _panel.set_anchors_preset(Control.PRESET_CENTER) + _panel.custom_minimum_size = Vector2(360, 220) + _panel.offset_left = -180 + _panel.offset_right = 180 + _panel.offset_top = -110 + _panel.offset_bottom = 110 + add_child(_panel) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 12) + _panel.add_child(vbox) + + # Title. + var title := Label.new() + title.name = "Title" + title.text = Strings.t(&"ui.load") + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vbox.add_child(title) + + # Slot list (populated by _refresh_slots). + _slot_list = VBoxContainer.new() + _slot_list.name = "SlotList" + _slot_list.add_theme_constant_override("separation", 8) + vbox.add_child(_slot_list) + + # "No saves yet" label — hidden when slots exist. + _no_saves_label = Label.new() + _no_saves_label.name = "NoSaves" + _no_saves_label.text = Strings.t(&"ui.no_saves") + _no_saves_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _no_saves_label.visible = false + vbox.add_child(_no_saves_label) + + # Cancel row. + var cancel_row := HBoxContainer.new() + cancel_row.alignment = BoxContainer.ALIGNMENT_CENTER + vbox.add_child(cancel_row) + + _cancel_btn = Button.new() + _cancel_btn.name = "CancelBtn" + _cancel_btn.text = Strings.t(&"ui.cancel") + _cancel_btn.custom_minimum_size = Vector2(120, 48) + _cancel_btn.focus_mode = Control.FOCUS_NONE + _cancel_btn.pressed.connect(_close) + cancel_row.add_child(_cancel_btn) + + # Version-mismatch warning dialog (built once, shown when needed). + _build_warn_dialog() + + +func _build_warn_dialog() -> void: + _warn_dim = ColorRect.new() + _warn_dim.name = "WarnDim" + _warn_dim.set_anchors_preset(Control.PRESET_FULL_RECT) + _warn_dim.color = Color(0.0, 0.0, 0.0, 0.45) + _warn_dim.mouse_filter = Control.MOUSE_FILTER_STOP + _warn_dim.visible = false + add_child(_warn_dim) + + _warn_panel = PanelContainer.new() + _warn_panel.name = "WarnDialog" + _warn_panel.set_anchors_preset(Control.PRESET_CENTER) + _warn_panel.custom_minimum_size = Vector2(340, 160) + _warn_panel.offset_left = -170 + _warn_panel.offset_right = 170 + _warn_panel.offset_top = -80 + _warn_panel.offset_bottom = 80 + _warn_panel.visible = false + add_child(_warn_panel) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 12) + _warn_panel.add_child(vbox) + + _warn_label = Label.new() + _warn_label.name = "WarnLabel" + _warn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _warn_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + vbox.add_child(_warn_label) + + var btn_row := HBoxContainer.new() + btn_row.alignment = BoxContainer.ALIGNMENT_CENTER + btn_row.add_theme_constant_override("separation", 12) + vbox.add_child(btn_row) + + _warn_continue_btn = Button.new() + _warn_continue_btn.text = Strings.t(&"ui.continue") + _warn_continue_btn.custom_minimum_size = Vector2(120, 48) + _warn_continue_btn.focus_mode = Control.FOCUS_NONE + _warn_continue_btn.pressed.connect(_on_warn_continue) + btn_row.add_child(_warn_continue_btn) + + _warn_cancel_btn = Button.new() + _warn_cancel_btn.text = Strings.t(&"ui.cancel") + _warn_cancel_btn.custom_minimum_size = Vector2(120, 48) + _warn_cancel_btn.focus_mode = Control.FOCUS_NONE + _warn_cancel_btn.pressed.connect(_on_warn_cancel) + btn_row.add_child(_warn_cancel_btn) + + +# ── slot population ─────────────────────────────────────────────────────────── + +func _refresh_slots() -> void: + # Clear previous slot buttons. + for child in _slot_list.get_children(): + child.queue_free() + + var slots: Array[StringName] = [&"manual", &"autosave"] + var any_exist: bool = false + + for slot in slots: + var meta: Dictionary = SaveSystem.peek_save_metadata(slot) + if not meta.get("exists", false): + continue + any_exist = true + + var label_key: StringName = &"ui.manual_save" if slot == &"manual" else &"ui.autosave" + var saved_at: int = int(meta.get("saved_at_unix", 0)) + var date_str: String = _unix_to_date(saved_at) + var btn_text: String = "%s (%s)" % [Strings.t(label_key), date_str] + + var btn := Button.new() + btn.text = btn_text + btn.custom_minimum_size = Vector2(320, 48) + btn.focus_mode = Control.FOCUS_NONE + # Capture slot by value in the lambda. + btn.pressed.connect(_on_slot_pressed.bind(slot, int(meta.get("version", 1)))) + _slot_list.add_child(btn) + + _no_saves_label.visible = not any_exist + + +func _unix_to_date(unix: int) -> String: + if unix <= 0: + return "—" + var dt: Dictionary = Time.get_datetime_dict_from_unix_time(unix) + return "%04d-%02d-%02d %02d:%02d" % [dt.year, dt.month, dt.day, dt.hour, dt.minute] + + +# ── interaction ─────────────────────────────────────────────────────────────── + +func _on_slot_pressed(slot: StringName, file_version: int) -> void: + if file_version != SaveSystem.SAVE_VERSION: + # Show version-mismatch warning dialog before proceeding. + _pending_slot = slot + _warn_label.text = Strings.t(&"ui.version_mismatch").format({"v": file_version}) + _warn_dim.visible = true + _warn_panel.visible = true + Audit.log("load_menu", "version mismatch warning shown for slot '%s' (v%d)" % [slot, file_version]) + else: + _do_load(slot) + + +func _on_warn_continue() -> void: + _warn_dim.visible = false + _warn_panel.visible = false + if _pending_slot != &"": + _do_load(_pending_slot) + _pending_slot = &"" + + +func _on_warn_cancel() -> void: + _warn_dim.visible = false + _warn_panel.visible = false + _pending_slot = &"" + Audit.log("load_menu", "version mismatch — player cancelled load") + + +func _do_load(slot: StringName) -> void: + Audit.log("load_menu", "loading slot '%s'" % slot) + load_confirmed.emit(slot) + _close() + SaveSystem.load_from_slot(slot) + + +func _close() -> void: + _set_visible(false) + Audit.log("load_menu", "closed") + + +func _set_visible(v: bool) -> void: + if _dim != null: + _dim.visible = v + if _panel != null: + _panel.visible = v diff --git a/scenes/ui/load_menu.gd.uid b/scenes/ui/load_menu.gd.uid new file mode 100644 index 0000000..1511554 --- /dev/null +++ b/scenes/ui/load_menu.gd.uid @@ -0,0 +1 @@ +uid://di115iwu6o6c8 diff --git a/scenes/ui/resume_toast.gd b/scenes/ui/resume_toast.gd new file mode 100644 index 0000000..c3371e2 --- /dev/null +++ b/scenes/ui/resume_toast.gd @@ -0,0 +1,124 @@ +class_name ResumeToast extends CanvasLayer +## Phase 16 — "Welcome back / Load failed" toast. +## +## Layer 22 — above Modal (20) but below LoadMenu (25). +## Listens to EventBus.load_finished. +## On ok=true: "Welcome back — away N minutes/hours" for SHOW_DURATION_SEC, then fades. +## On ok=false: "Load failed (corrupt or version mismatch)" — same fade cadence. + +const SHOW_DURATION_SEC: float = 5.0 +const FADE_DURATION_SEC: float = 0.8 + +var _root: Control = null +var _label: Label = null +var _show_timer: Timer = null + +var _fade_time: float = 0.0 +var _fading: bool = false + + +func _ready() -> void: + layer = 22 + _build_ui() + _root.visible = false + EventBus.load_finished.connect(_on_load_finished) + Audit.log("resume_toast", "ResumeToast ready") + + +func _exit_tree() -> void: + if EventBus.load_finished.is_connected(_on_load_finished): + EventBus.load_finished.disconnect(_on_load_finished) + + +func _process(delta: float) -> void: + if not _fading: + return + _fade_time -= delta + if _fade_time <= 0.0: + _fading = false + _root.visible = false + _root.modulate = Color.WHITE + return + _root.modulate.a = clampf(_fade_time / FADE_DURATION_SEC, 0.0, 1.0) + + +# ── UI construction ─────────────────────────────────────────────────────────── + +func _build_ui() -> void: + # Top-center strip — sits just below the top bar. + _root = Control.new() + _root.name = "ToastRoot" + _root.set_anchors_preset(Control.PRESET_TOP_WIDE) + _root.custom_minimum_size = Vector2(0, 56) + _root.offset_top = 56 # below the 48 px top bar + a little gap + _root.offset_bottom = 112 + _root.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_root) + + var panel := PanelContainer.new() + panel.name = "Panel" + panel.set_anchors_preset(Control.PRESET_CENTER_TOP) + panel.custom_minimum_size = Vector2(400, 48) + panel.offset_left = -200 + panel.offset_right = 200 + panel.offset_top = 0 + panel.offset_bottom = 48 + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.add_child(panel) + + _label = Label.new() + _label.name = "ToastLabel" + _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _label.autowrap_mode = TextServer.AUTOWRAP_OFF + panel.add_child(_label) + + _show_timer = Timer.new() + _show_timer.name = "ShowTimer" + _show_timer.one_shot = true + _show_timer.wait_time = SHOW_DURATION_SEC + _show_timer.timeout.connect(_start_fade) + add_child(_show_timer) + + +# ── event handling ──────────────────────────────────────────────────────────── + +func _on_load_finished(_slot: StringName, ok: bool, real_seconds_away: int) -> void: + if ok: + _label.text = _format_welcome(real_seconds_away) + else: + _label.text = Strings.t(&"ui.load_failed") + + _root.modulate = Color.WHITE + _root.visible = true + _fading = false + _show_timer.start() + Audit.log("resume_toast", "showing — ok=%s seconds_away=%d" % [ok, real_seconds_away]) + + +func _start_fade() -> void: + _fading = true + _fade_time = FADE_DURATION_SEC + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +func _format_welcome(seconds_away: int) -> String: + var duration_str: String + if seconds_away < 120: + # Less than 2 minutes — show as "1 minute" + duration_str = Strings.t(&"ui.welcome_back_min").format({"n": 1}) + elif seconds_away < 3600: + # Under an hour — show in minutes. + var mins: int = seconds_away / 60 + var key: StringName = &"ui.welcome_back_min" if mins == 1 else &"ui.welcome_back_mins" + duration_str = Strings.t(key).format({"n": mins}) + elif seconds_away < 7200: + # 1–2 hours exactly → singular. + duration_str = Strings.t(&"ui.welcome_back_hour").format({"n": 1}) + else: + var hrs: int = seconds_away / 3600 + var key: StringName = &"ui.welcome_back_hour" if hrs == 1 else &"ui.welcome_back_hours" + duration_str = Strings.t(key).format({"n": hrs}) + + return Strings.t(&"ui.welcome_back").format({"n": duration_str}) diff --git a/scenes/ui/resume_toast.gd.uid b/scenes/ui/resume_toast.gd.uid new file mode 100644 index 0000000..5fcd223 --- /dev/null +++ b/scenes/ui/resume_toast.gd.uid @@ -0,0 +1 @@ +uid://dur68caotb1yn diff --git a/scenes/ui/top_bar.gd b/scenes/ui/top_bar.gd index d59fbd6..ff88a47 100644 --- a/scenes/ui/top_bar.gd +++ b/scenes/ui/top_bar.gd @@ -1,10 +1,13 @@ extends CanvasLayer -## Top-bar HUD: speed/pause buttons and tick counter. +## Top-bar HUD: speed/pause buttons, tick counter, and save/load controls. ## ## Buttons call Sim.set_speed(); active button is yellow-tinted. ## Tick label updates on every EventBus.sim_tick signal. ## Keyboard shortcuts (pause / speed_normal / speed_fast / speed_ultra) are ## handled here so the bar is the single owner of speed-input logic. +## +## Phase 16: SaveBtn → SaveSystem.save_to_slot(&"manual"). +## LoadBtn → opens the LoadMenu CanvasLayer (mounted by main.gd). const ACTIVE_MODULATE := Color(1.2, 1.2, 0.8) const IDLE_MODULATE := Color.WHITE @@ -13,6 +16,8 @@ const IDLE_MODULATE := Color.WHITE @onready var normal_btn : Button = $Anchor/ButtonRow/NormalBtn @onready var fast_btn : Button = $Anchor/ButtonRow/FastBtn @onready var ultra_btn : Button = $Anchor/ButtonRow/UltraBtn +@onready var save_btn : Button = $Anchor/ButtonRow/SaveBtn +@onready var load_btn : Button = $Anchor/ButtonRow/LoadBtn @onready var tick_label : Label = $Anchor/TickLabel @onready var clock_label : Label = $Anchor/ClockLabel @onready var season_label : Label = $Anchor/SeasonLabel @@ -24,12 +29,17 @@ var _speed_buttons: Dictionary = {} var _last_clock_text: String = "" var _last_season_text: String = "" +## Injected by main.gd after mount so we don't walk the tree with get_node. +var load_menu: CanvasLayer = null + func _ready() -> void: pause_btn.text = Strings.t(&"speed.pause") normal_btn.text = Strings.t(&"speed.normal") fast_btn.text = Strings.t(&"speed.fast") ultra_btn.text = Strings.t(&"speed.ultra") + save_btn.text = Strings.t(&"ui.save") + load_btn.text = Strings.t(&"ui.load") tick_label.text = "(boot)" clock_label.text = Strings.t(&"clock.format").format({"d": 1, "t": "06:00"}) season_label.text = Strings.t(&"season.format").format({"s": Strings.t(&"season.spring"), "d": 1}) @@ -45,11 +55,15 @@ func _ready() -> void: normal_btn.pressed.connect(func() -> void: Sim.set_speed(Sim.Speed.NORMAL)) fast_btn.pressed.connect(func() -> void: Sim.set_speed(Sim.Speed.FAST)) ultra_btn.pressed.connect(func() -> void: Sim.set_speed(Sim.Speed.ULTRA)) + save_btn.pressed.connect(_on_save_pressed) + load_btn.pressed.connect(_on_load_pressed) EventBus.speed_changed.connect(_on_speed_changed) EventBus.sim_tick.connect(_on_sim_tick) EventBus.sim_tick.connect(_on_clock_refresh) EventBus.sim_tick.connect(_on_season_refresh) + EventBus.save_started.connect(_on_save_started) + EventBus.save_finished.connect(_on_save_finished) # Reflect the initial speed state without emitting a signal. _apply_highlight(Sim.current_speed) @@ -95,3 +109,25 @@ func _on_season_refresh(_n: int) -> void: func _apply_highlight(speed: Sim.Speed) -> void: for s: int in _speed_buttons: _speed_buttons[s].modulate = ACTIVE_MODULATE if s == speed else IDLE_MODULATE + + +func _on_save_pressed() -> void: + SaveSystem.save_to_slot(&"manual") + Audit.log("top_bar", "manual save triggered") + + +func _on_load_pressed() -> void: + if load_menu != null and load_menu.has_method("open"): + load_menu.open() + else: + Audit.log("top_bar", "LoadMenu not mounted — skipping open()") + + +func _on_save_started(_slot: StringName) -> void: + save_btn.disabled = true + save_btn.text = Strings.t(&"ui.saving") + + +func _on_save_finished(_slot: StringName, _ok: bool) -> void: + save_btn.disabled = false + save_btn.text = Strings.t(&"ui.save") diff --git a/scenes/ui/top_bar.tscn b/scenes/ui/top_bar.tscn index 1bb5ff7..ae00ee1 100644 --- a/scenes/ui/top_bar.tscn +++ b/scenes/ui/top_bar.tscn @@ -34,6 +34,16 @@ text = "5×" focus_mode = 0 text = "12×" +[node name="SaveBtn" type="Button" parent="Anchor/ButtonRow"] +focus_mode = 0 +custom_minimum_size = Vector2(48, 48) +text = "💾" + +[node name="LoadBtn" type="Button" parent="Anchor/ButtonRow"] +focus_mode = 0 +custom_minimum_size = Vector2(48, 48) +text = "Load" + [node name="ClockLabel" type="Label" parent="Anchor"] anchor_left = 0.5 anchor_right = 0.5 diff --git a/scenes/world/beauty_system.gd b/scenes/world/beauty_system.gd index c905858..ec07f42 100644 --- a/scenes/world/beauty_system.gd +++ b/scenes/world/beauty_system.gd @@ -169,6 +169,30 @@ func _quality_multiplier_for(entity) -> float: _: return 1.0 +# ── save / load ─────────────────────────────────────────────────────────────── + +## Serialise the sparse beauty map as an array of {x, y, v} dicts. +## Only non-zero tiles are stored (map is already sparse). +func save_dict() -> Dictionary: + var entries: Array = [] + for t in beauty_map: + entries.append({"x": t.x, "y": t.y, "v": float(beauty_map[t])}) + return {"beauty": entries} + + +## Restore the beauty map from a dict produced by save_dict(). +## Replaces the current map; furniture list is NOT restored here (entities +## re-register themselves when their nodes are re-added by the loader). +func apply_dict(d: Dictionary) -> void: + beauty_map.clear() + for entry in d.get("beauty", []): + if entry is Dictionary: + var t := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0))) + var v: float = float(entry.get("v", 0.0)) + if v != 0.0: + beauty_map[t] = v + + ## Returns true if `entity` has finished building. ## Checks the _completed bool directly (all furniture exposes is_completed() and the var). func _entity_completed(entity) -> bool: diff --git a/scenes/world/crate.gd b/scenes/world/crate.gd index 6130a07..1014c76 100644 --- a/scenes/world/crate.gd +++ b/scenes/world/crate.gd @@ -177,6 +177,7 @@ func unregister_item(item) -> void: ## Serialise crate state for World save (Phase 16 will wire this). func to_dict() -> Dictionary: return { + "class_id": &"crate", "tile_x": tile.x, "tile_y": tile.y, "label_text": label_text, diff --git a/scenes/world/dirtiness_system.gd b/scenes/world/dirtiness_system.gd index dae48ac..47226bd 100644 --- a/scenes/world/dirtiness_system.gd +++ b/scenes/world/dirtiness_system.gd @@ -78,6 +78,29 @@ func bump_pawn_traffic(tile: Vector2i, indoor: bool) -> void: bump(tile, amount) +# ── save / load ─────────────────────────────────────────────────────────────── + +## Serialise the sparse dirt map as an array of {x, y, v} dicts. +## Only non-zero tiles are stored (map is already sparse). +func save_dict() -> Dictionary: + var entries: Array = [] + for t in dirt_map: + entries.append({"x": t.x, "y": t.y, "v": float(dirt_map[t])}) + return {"dirtiness": entries} + + +## Restore the dirt map from a dict produced by save_dict(). +## Replaces the current map in full. +func apply_dict(d: Dictionary) -> void: + dirt_map.clear() + for entry in d.get("dirtiness", []): + if entry is Dictionary: + var t := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0))) + var v: float = clampf(float(entry.get("v", 0.0)), 0.0, 100.0) + if v > 0.0: + dirt_map[t] = v + + # ── internal ────────────────────────────────────────────────────────────────── ## Apply a dirt change from old_val to new_val for `tile`. diff --git a/scenes/world/graveyard_zone.gd b/scenes/world/graveyard_zone.gd index c546443..fe57693 100644 --- a/scenes/world/graveyard_zone.gd +++ b/scenes/world/graveyard_zone.gd @@ -88,6 +88,32 @@ func _draw() -> void: draw_line(cross_center + Vector2(-3.0, -1.5), cross_center + Vector2(3.0, -1.5), cross_color, 1.5) +# ── save / load ─────────────────────────────────────────────────────────────── + +func to_dict() -> Dictionary: + return { + "class_id": &"graveyard_zone", + "region_x": region.position.x, + "region_y": region.position.y, + "region_w": region.size.x, + "region_h": region.size.y, + "priority": int(priority), + "label": label, + } + + +func from_dict(d: Dictionary) -> void: + 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)) + ) + priority = d.get("priority", StorageDestination.Priority.NORMAL) as StorageDestination.Priority + label = str(d.get("label", "Graveyard")) + queue_redraw() + + # ── internal ───────────────────────────────────────────────────────────────── ## Returns the first GraveSlot within the region that is in DUG state and not diff --git a/scenes/world/stockpile_zone.gd b/scenes/world/stockpile_zone.gd index 644c44d..9862ae4 100644 --- a/scenes/world/stockpile_zone.gd +++ b/scenes/world/stockpile_zone.gd @@ -85,6 +85,37 @@ func _draw() -> void: draw_rect(rect_px, border_color, false, 1.0) +# ── save / load ─────────────────────────────────────────────────────────────── + +func to_dict() -> Dictionary: + return { + "class_id": &"stockpile_zone", + "region_x": region.position.x, + "region_y": region.position.y, + "region_w": region.size.x, + "region_h": region.size.y, + "priority": int(priority), + "label": label, + "accepted_types": accepted_types.map(func(t): return String(t)), + } + + +func from_dict(d: Dictionary) -> void: + 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)) + ) + priority = d.get("priority", StorageDestination.Priority.NORMAL) as StorageDestination.Priority + label = str(d.get("label", "Stockpile")) + var raw_types: Array = d.get("accepted_types", []) + accepted_types.clear() + for s in raw_types: + accepted_types.append(StringName(s)) + queue_redraw() + + # ── internal helpers ────────────────────────────────────────────────────────── ## Returns true when no un-carried item is sitting on `cell`. diff --git a/scenes/world/world.gd b/scenes/world/world.gd index 795faaf..dedb3be 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -126,9 +126,11 @@ func _ready() -> void: # Phase 5 — expose TileMap layer refs on the autoload so entity code # (Wall._complete, Floor._complete) can stamp data-only tile state. + World.terrain_layer = terrain_layer World.wall_layer = wall_layer World.floor_layer = floor_layer World.designation_layer = designation_layer + World.roof_layer = roof_layer # Phase 13 — wire RoomDetector; setup with map size so BFS knows map bounds. room_detector.setup(MAP_SIZE_TILES)