rimlike/scenes/entities/bed.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

288 lines
12 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 Bed extends Node2D
## Bed furniture entity — buildable, optionally pawn-owned, quality-affected sleep.
##
## Rendered as a bottom-anchored 3/4-perspective sprite within the 16×16 tile,
## matching the workbench / wall / door rendering convention. Ghost state (40%
## alpha) while construction is in progress; solid once _completed.
##
## Quality tints the sheet colour (SLEEP_MOOD_BY_QUALITY maps Item.Quality int
## to the mood modifier awarded when a pawn finishes sleeping here). Phase 8
## spawns beds at NORMAL quality; Phase 17+ may roll quality from crafter skill.
##
## Build model (docs/implementation.md Phase 8):
## BUILD_TICKS ticks via the standard BuildJob toil (same shape as Wall/Crate/
## Workbench). blocks_pathing_when_complete() returns false — pawns walk ONTO
## the bed tile to sleep.
##
## Occupancy model:
## _owner_pawn — null = unowned (any tired pawn may use); Phase 8 leaves null.
## _occupant_pawn — set by SleepProvider.claim(); released on SleepProvider.release().
## is_available() — true when completed AND no current occupant.
## claim(pawn) — atomically sets _occupant_pawn; returns false if unavailable.
## release() — clears _occupant_pawn.
##
## Save/load:
## to_dict() serialises all persistent fields. _occupant_pawn is always saved
## as null (sleep is mid-toil state; the JobRunner saves its own side). Re-wiring
## owner_pawn from name → Pawn reference is deferred to Phase 16's full save layer.
##
## World registration: World.register_bed / World.unregister_bed called from
## _ready / _exit_tree.
const TILE_SIZE_PX: int = 16
## Sim ticks to build a bed (80 ticks ≈ 4 sim seconds at 1×).
const BUILD_TICKS: int = 80
## Sleep mood modifier indexed by Item.Quality int (SHODDY=0 … LEGENDARY=4).
## Applied via the "slept_in_X" thought when a pawn finishes a sleep job.
## Numbers are design.md placeholders — tune in Phase 20.
## SHODDY=-8, NORMAL=-2, EXCELLENT=0, MASTERWORK=5, LEGENDARY=8
const SLEEP_MOOD_BY_QUALITY: Array[int] = [-8, -2, 0, 5, 8]
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this bed in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Quality tier as an int matching Item.Quality enum (0=SHODDY … 4=LEGENDARY).
## Determines sheet colour and sleep mood modifier.
@export var quality: int = 1
## Player-visible name. Defaults to "Bed"; extended types (medical bed, etc.)
## can override via label_text without needing a subclass.
@export var label_text: String = "Bed"
## When true, this bed is designated as a medical treatment site.
## DoctorProvider (Phase 9) prefers medical beds over regular beds when
## choosing a destination for downed pawns. A small red cross is drawn over
## the pillow in _draw() so the player can tell medical beds apart at a glance.
@export var is_medical: bool = false
# ── state ─────────────────────────────────────────────────────────────────────
## Ticks of construction work applied so far. 0..BUILD_TICKS.
var build_progress: int = 0
## True once build_progress >= BUILD_TICKS.
var _completed: bool = false
## The pawn who "owns" this bed (has first right of use). null = unowned.
## Phase 8 leaves this null; Phase 17 wires per-pawn bed assignment UI.
var _owner_pawn = null
## The pawn currently lying in this bed. Set by claim(); cleared by release().
## Always null on save (SleepProvider re-claims after load if the pawn still
## has an active sleep job; the JobRunner handles reconnection).
var _occupant_pawn = null
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
# Bottom-anchor: position.y at tile bottom so Y-sort occludes pawns correctly.
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
)
World.register_bed(self)
queue_redraw()
func _exit_tree() -> void:
World.unregister_bed(self)
## One-shot initialiser. Call after add_child() so _ready() has fired.
func setup(p_tile: Vector2i) -> void:
tile = p_tile
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
)
queue_redraw()
# ── BuildJob interface (matches Wall / Crate / Workbench shape) ───────────────
## True while the bed still needs construction work.
## JobRunner's BUILD toil checks this to decide when the toil is done.
func is_buildable() -> bool:
return not _completed
## Human-readable label for job descriptions and Audit logs.
func label() -> String:
return label_text
## Called by the BUILD toil in JobRunner once per sim tick while the pawn works.
## Advances build_progress and completes the bed at BUILD_TICKS.
func on_build_tick() -> void:
if _completed:
return
build_progress += 1
queue_redraw()
if build_progress >= BUILD_TICKS:
_complete()
## True once the bed has been fully built.
func is_completed() -> bool:
return _completed
## Beds remain walkable after completion — pawns walk ONTO the tile to sleep.
func blocks_pathing_when_complete() -> bool:
return false
# ── occupancy ─────────────────────────────────────────────────────────────────
## Returns true when this bed is built and no pawn is currently using it.
## SleepProvider calls this to filter candidate beds before claiming.
func is_available() -> bool:
return _completed and _occupant_pawn == null
## Atomically claim this bed for `pawn`. Returns false if not available.
## SleepProvider calls this before starting the sleep toil; on false the
## provider must look for another bed.
func claim(pawn) -> bool:
if not is_available():
return false
_occupant_pawn = pawn
return true
## Release this bed when the sleep job ends (normally or interrupted).
## SleepProvider calls this from its on_complete / on_cancel hook.
func release() -> void:
_occupant_pawn = null
# ── save / load ───────────────────────────────────────────────────────────────
## Serialise all persistent state for World save (wired in Phase 16).
## _occupant_pawn is always saved as null — the JobRunner holds the sleep
## toil state and the SleepProvider re-claims the bed after load.
func to_dict() -> Dictionary:
var owner_name = null
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,
"label_text": label_text,
"is_medical": is_medical,
"build_progress": build_progress,
"completed": _completed,
"owner_pawn_name": owner_name,
# occupant_pawn always null on save — SleepProvider reconnects after load.
"occupant_pawn": null,
}
## Restore from a dict produced by to_dict().
## owner_pawn re-wiring (name → Pawn reference) is deferred to Phase 16.
func from_dict(d: Dictionary) -> void:
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
quality = int(d.get("quality", 1))
label_text = str(d.get("label_text", "Bed"))
is_medical = bool(d.get("is_medical", false))
build_progress = int(d.get("build_progress", 0))
_completed = bool(d.get("completed", false))
# owner_pawn: Phase 16 will walk World.pawns and match by pawn_name.
_owner_pawn = null
_occupant_pawn = null
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void:
# 3/4-perspective bed — fits within the tile (16×16 local box).
# Origin (0, 0) = tile bottom-centre. Tile spans local Y: -16 to 0.
#
# Layout (bottom-anchored, same as Wall / Workbench):
# Top band (5 px, Y -16..-11) — top surface of the bed frame (lit)
# Body band (8 px, Y -11..-3) — sheet / quilt, quality-tinted
# Leg band (3 px, Y -3.. 0) — bed-frame legs / base
#
# The pillow is a 6×3 rect inset at the top of the body band.
# Quality tints the sheet; the frame/legs stay a constant warm brown.
# Ghost state draws at 0.4 alpha.
var alpha: float = 1.0 if _completed else 0.4
_draw_bed(alpha)
func _draw_bed(alpha: float) -> void:
# Frame colours — same for all quality tiers (wood frame).
var frame_top := Color(0.52, 0.38, 0.20, alpha) # lit top surface
var frame_dark := Color(0.34, 0.24, 0.12, alpha) # shaded legs / base
var frame_edge := Color(0.28, 0.18, 0.08, alpha) # top-front horizon
var outline := Color(0.20, 0.12, 0.04, 0.70 * alpha)
# Sheet colour varies by quality tier.
var sheet_color := _sheet_color_for_quality(quality, alpha)
# Pillow: light cream, inset at top-centre of the body band.
var pillow := Color(0.95, 0.92, 0.85, alpha)
# ── top surface (lit band) ────────────────────────────────────────────────
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), frame_top)
# ── sheet / body band ─────────────────────────────────────────────────────
draw_rect(Rect2(Vector2(-7.0, -11.0), Vector2(14.0, 8.0)), sheet_color)
# Pillow: 6×3, horizontally centred, flush with the top of the body band.
draw_rect(Rect2(Vector2(-3.0, -11.0), Vector2(6.0, 3.0)), pillow)
# Medical cross: small red + over the pillow area.
# Two overlapping rects form a classic cross — universal medical symbol.
if is_medical:
var cross := Color(0.85, 0.10, 0.10, alpha)
draw_rect(Rect2(Vector2(-3.0, -8.0), Vector2(6.0, 2.0)), cross) # horizontal bar
draw_rect(Rect2(Vector2(-1.0, -10.0), Vector2(2.0, 6.0)), cross) # vertical bar
# ── base / legs band ──────────────────────────────────────────────────────
draw_rect(Rect2(Vector2(-8.0, -3.0), Vector2(16.0, 3.0)), frame_dark)
# ── horizon line (top → front depth edge) ────────────────────────────────
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), frame_edge, 1.0)
# ── outline ───────────────────────────────────────────────────────────────
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
## Returns the sheet fill colour for the given quality int.
## Quality indices: 0=SHODDY, 1=NORMAL, 2=EXCELLENT, 3=MASTERWORK, 4=LEGENDARY.
func _sheet_color_for_quality(q: int, alpha: float) -> Color:
match q:
0: # SHODDY — drab grey-brown
return Color(0.45, 0.40, 0.35, alpha)
1: # NORMAL — warm tan
return Color(0.55, 0.40, 0.30, alpha)
2: # EXCELLENT — cool blue
return Color(0.30, 0.45, 0.65, alpha)
3: # MASTERWORK — gold-brown
return Color(0.65, 0.45, 0.20, alpha)
4: # LEGENDARY — regal pink
return Color(0.75, 0.40, 0.55, alpha)
_: # fallback — same as NORMAL
return Color(0.55, 0.40, 0.30, alpha)
# ── internal ──────────────────────────────────────────────────────────────────
func _complete() -> void:
_completed = true
queue_redraw()
Audit.log("bed", "%s built at %s" % [label_text, tile])
# Phase 13 — notify BeautySystem so nearby tile beauty scores update.
var bs = World.get("beauty_system")
if bs != null:
bs.register_furniture(self)
bs.recompute_around(tile)