rimlike/scenes/entities/door.gd
megaproxy 19d28ca9f8 Phase 16: Save/load full coverage + autosave + UI
Three-agent fan-out reusing the contracts-first pattern: Opus pre-wrote
World.clear_all + 4 EventBus signals (save_started/finished, load_started/
finished) before dispatch. Pattern proven across Phases 12/13/14/15/16.

Entity to_dict/from_dict + class_id tagging (Agent A):
- class_id tag added to all 18 entity to_dict methods for loader routing
- Missing pairs filled in: wolf, grave_slot, graveyard_zone, stockpile_zone,
  crate (from_dict). All defensive with d.get(field, default).
- Workbench round-trips label_text so Carpenter/Smelter/Millstone/Hearth/
  Pyre kinds survive reload
- BeautySystem + DirtinessSystem save_dict/apply_dict for sparse maps
- World.save_tilemap_layers / apply_tilemap_layers covering 5 layers
  (Terrain/Floor/Wall/Designation/Roof; Fog runtime-only skipped)

SaveSystem v2 rewrite (Agent B):
- SAVE_VERSION bumped from 1 to 2
- write_save(slot) pauses Sim, emits save_started, collects every entity
  via _collect_entities iterating all World registries, writes payload to
  user://save_<slot>.json
- apply_save full rewrite: pause sim → emit load_started → World.clear_all
  → apply autoloads (GameState/Clock/Weather/Storyteller) → apply tilemap
  layers → iterate payload.entities and dispatch to per-class factories
  → apply beauty/dirt maps → emit load_finished(slot, ok, real_seconds_away)
- Per-class factory registry: 18 class_ids dispatched to setup+add_child+
  from_dict patterns. CremationPyre detected via workbench.label_text == 'Pyre'
- Public slot API: save_to_slot/load_from_slot/has_save/delete_save/
  peek_save_metadata. Slots locked: &manual + &autosave

Autosave + UI + Resume toast (Agent C):
- autoload/autosave.gd — new Autosave autoload. Periodic every
  AUTOSAVE_INTERVAL_TICKS = 6000 (~5 in-game min at 20 Hz) + NOTIFICATION_
  APPLICATION_PAUSED (mobile) + NOTIFICATION_WM_WINDOW_FOCUS_OUT (desktop).
  Gated by _busy flag tied to EventBus.save_started/save_finished.
- TopBar extended with SaveBtn (💾) + LoadBtn buttons, 48×48 min hit area
- scenes/ui/load_menu.gd — CanvasLayer slot picker. Reads peek_save_metadata
  to show 'Manual save (Date Time)' / 'Autosave (Date Time)' rows.
  Version-mismatch warning dialog before continuing on older saves.
- scenes/ui/resume_toast.gd — top-center toast. On load_finished(ok=true):
  'Welcome back — N minutes/hours away' for 5s + 0.8s fade.
  On ok=false: 'Load failed (corrupt or version mismatch)'.
- Strings catalog: 14 new keys (ui.save / ui.load / ui.welcome_back_* /
  ui.load_failed etc.)
- main.gd mounts LoadMenu + ResumeToast as runtime CanvasLayer children

MCP runtime verified:
- Saved at tick 1137 → [save] wrote slot 'manual': 113 entities at tick 1137
- Advanced sim to tick 4600 at ULTRA speed (different state)
- load_from_slot(&manual) → [save] applied slot 'manual': 113 entities,
  0 errors, tick=1137, away=34s
- post-load: Sim.tick=1137 (restored), pawns alive=3, all furniture +
  workbenches + crops + walls + floors back in place
- Resume toast fires: [resume_toast] showing — ok=true seconds_away=34
- Autosave on focus-loss verified: [autosave] focus-loss → wrote autosave
- Screenshot shows TopBar with Save + Load buttons + post-load Lone Wolf
  storyteller modal from fresh dawn roll

Known acceptable gaps (deferred to Phase 20 tuning):
- Pawn JobRunner mid-INTERACT/mid-BUILD restarts from toil 0 on reload
  (walk toil round-trips; multi-step interact does not). Pawns lose a few
  seconds of work.
- Workbench bill mid-craft fetch state isn't fully serialized.
- Wolf.target_pawn re-resolution from name string is Agent A's documented
  pattern; Agent B's apply_save respects pawn-restoration ordering so the
  resolution works after pawns are back.

Delegation: 3× gdscript-refactor (Sonnet) agents in parallel; integration
+ MCP verify on Opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:24:59 +01:00

152 lines
6.1 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name Door extends Node2D
## Door entity — built by a pawn with a Build job. Unlike walls, doors remain
## walkable once complete. Phase 5: always-open (always walkable). Phase 7+
## will add open/close animation and toggling.
##
## Door is rendered as a bottom-anchored tall sprite (Y-sorted), same as Wall,
## so it occludes pawns walking behind it — matching the 3/4-perspective
## rendering pivot (see memory.md Decisions: "Wall layer rendering").
##
## Build model (docs/implementation.md Phase 5):
## A ConstructionProvider creates a Job whose BUILD toil calls on_build_tick()
## once per sim tick via JobRunner. After BUILD_TICKS ticks the door is
## complete: it registers with World for future open/close logic, does NOT
## call set_cell_walkable(false), and transitions from ghost to solid.
##
## World registration: World.register_build_site / World.unregister_build_site
## are called from _ready / _exit_tree. Methods land in the Opus integration pass.
const TILE_SIZE_PX: int = 16
## Sim ticks to build a door at 1× speed (80 ticks = 4 sim seconds).
const BUILD_TICKS: int = 80
# ── state ──────────────────────────────────────────────────────────────────────
@export var tile: Vector2i = Vector2i.ZERO
## 0..BUILD_TICKS.
var build_progress: int = 0
var _completed: bool = false
## Phase 5: always-open. Phase 7+ toggles this for open/close animation.
var is_open: bool = true
# ── lifecycle ──────────────────────────────────────────────────────────────────
func _ready() -> void:
World.register_build_site(self)
func _exit_tree() -> void:
World.unregister_build_site(self)
# ── public API ─────────────────────────────────────────────────────────────────
## One-shot initialiser. Call after add_child() so _ready() has already fired.
func setup(p_tile: Vector2i) -> void:
tile = p_tile
# Bottom-anchor: same as Wall so it Y-sorts correctly with pawns.
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
)
queue_redraw()
Audit.log("door", "door ghost placed at %s" % tile)
## True while the door still needs construction work.
func is_buildable() -> bool:
return not _completed
## Human-readable label for job descriptions and Audit logs.
func label() -> String:
return "door"
## Called by the BUILD toil in JobRunner once per sim tick.
func on_build_tick() -> void:
if _completed:
return
build_progress += 1
queue_redraw()
if build_progress >= BUILD_TICKS:
_complete()
## True once the door has been fully built.
func is_completed() -> bool:
return _completed
# ── save / load ────────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"class_id": &"door",
"tile_x": tile.x,
"tile_y": tile.y,
"build_progress": build_progress,
"completed": _completed,
"is_open": is_open,
}
static func from_dict(d: Dictionary) -> Dictionary:
return {
"tile_x": int(d.get("tile_x", 0)),
"tile_y": int(d.get("tile_y", 0)),
"build_progress": int(d.get("build_progress", 0)),
"completed": bool(d.get("completed", false)),
"is_open": bool(d.get("is_open", true)),
}
# ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void:
# 3/4-perspective door — fits strictly within the tile (16×16) so it
# slots cleanly between adjacent walls. Origin (0,0) is at the tile's
# bottom-centre. Tile spans local Y: -16 to 0.
var alpha: float = 1.0 if _completed else 0.4
# Matches Wall's 3/4-band layout so doors and walls share a top horizon.
var lintel_color := Color(0.55, 0.40, 0.22, alpha) # stone-warmed top lintel
var frame_color := Color(0.32, 0.22, 0.10, alpha)
var panel_color := Color(0.52, 0.36, 0.18, alpha)
var hinge_color := Color(0.20, 0.18, 0.16, alpha)
var outline := Color(0.16, 0.10, 0.04, 0.7 * alpha)
# Top lintel band (matches wall top-face height of 5 px so it lines up).
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), lintel_color)
# Door frame — fills the front-face band (matches wall front 11 px).
draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), frame_color)
# Door panel (inset 1 px each side, leaves 2 px frame visible left/right).
draw_rect(Rect2(Vector2(-6.0, -10.0), Vector2(12.0, 10.0)), panel_color)
# Hinge dot.
draw_circle(Vector2(-5.0, -5.0), 1.0, hinge_color)
# Top/bottom borders + tile outline.
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), Color(0.20, 0.14, 0.06, alpha), 1.0)
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
# ── internal ───────────────────────────────────────────────────────────────────
func _complete() -> void:
_completed = true
# Doors are walkable — do NOT call set_cell_walkable(false).
# Phase 13 — erase any wall-layer stamp at this tile. The demo seed
# pre-stamps the door slot as a wall so BFS can detect the cabin at boot;
# the real door completing supersedes that. Must happen before register_door
# so the BFS in mark_door_tile sees the correct wall-layer state.
if World.wall_layer != null:
World.wall_layer.erase_cell(tile)
# Register so future open/close logic can locate this door by tile.
World.register_door(self)
# Phase 13 — notify RoomDetector so the door tile is eligible as an
# interior boundary tile for room BFS.
World.mark_door_tile(tile)
queue_redraw()
Audit.log("door", "door completed at %s" % tile)