rimlike/scenes/world/crate.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

269 lines
10 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 Crate extends StorageDestination
## Furniture container entity: holds up to CAPACITY item stacks and can be
## filtered like a StockpileZone. Built via Build → Furniture → Crate.
##
## Lifecycle:
## - Ghost phase: placed but not yet built (build_progress < BUILD_TICKS).
## accepts() returns false; visual is 40% alpha.
## - Completed phase: _completed == true; accepts items up to CAPACITY.
##
## StorageDestination interface:
## accepts() — filter + capacity gate; false while ghost.
## find_drop_position()— returns crate's own tile when room exists,
## Vector2i(-1, -1) when full or ghost.
## covers_tile() — single-tile container; only the crate's own tile.
##
## BuildJob interface (mirrors Wall.on_build_tick pattern):
## is_buildable() — true while still a ghost.
## on_build_tick() — increments build_progress; completes at BUILD_TICKS.
## is_completed() — true once built.
##
## register_item() is called by JobRunner._tick_deposit (Opus integration
## follow-up) after the deposit physically lands on the crate tile.
##
## See docs/architecture.md "Container" and Phase 5 implementation plan.
## Maximum item stacks this crate can hold.
const CAPACITY: int = 4
## Number of sim ticks a pawn must spend building to complete the crate.
const BUILD_TICKS: int = 60
## Pixel size of one tile — must match World.TILE_SIZE_PX.
const TILE_SIZE_PX: int = 16
# ── visual constants ──────────────────────────────────────────────────────────
## Body dimensions in pixels (centred on the tile).
const _BODY_W: int = 12
const _BODY_H: int = 10
## Crate colours.
const _COLOR_BODY: Color = Color(0.45, 0.30, 0.15, 1.0)
const _COLOR_OUTLINE: Color = Color(0.25, 0.18, 0.08, 1.0)
const _COLOR_SLAT: Color = Color(0.30, 0.20, 0.10, 1.0)
const _COLOR_FILL_DOT: Color = Color(1.0, 1.0, 1.0, 0.9)
## Ghost alpha multiplier when not yet built.
const _GHOST_ALPHA: float = 0.40
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this crate in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Player-facing label (inspect UI, Phase 17).
@export var label_text: String = "Crate"
# ── state ─────────────────────────────────────────────────────────────────────
## Sim ticks of construction work applied so far.
var build_progress: int = 0
## True once build_progress >= BUILD_TICKS.
var _completed: bool = false
## Live item nodes currently stored in this crate; capped at CAPACITY.
## Populated by register_item() (called from JobRunner._tick_deposit).
var _contents: Array = []
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
# Inherits StorageDestination defaults for priority / accepted_types.
# Crates default to NORMAL priority and wildcard (accepts all types).
priority = StorageDestination.Priority.NORMAL
accepted_types = []
# Register with the World stockpile pool so HaulingProvider sees us.
World.register_stockpile(self)
queue_redraw()
func _exit_tree() -> void:
World.unregister_stockpile(self)
## One-shot initialiser called by the spawning / placement code.
## Sets tile and snaps pixel position to the tile centre.
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 / 2.0
)
# ── StorageDestination interface ──────────────────────────────────────────────
## Returns true if this crate can accept `item` right now.
## False while still a ghost, false if the filter rejects the type,
## false if all CAPACITY slots are taken.
func accepts(item) -> bool:
if not _completed:
return false
if not _filter_accepts(item):
return false
return _contents.size() < CAPACITY
## Returns the crate's own tile when it can accept `item`, otherwise (-1,-1).
## All items stack into the crate's single tile — there is no 2D region.
func find_drop_position(item) -> Vector2i:
if accepts(item):
return tile
return Vector2i(-1, -1)
## Returns true only when `p_tile` is exactly the crate's own tile.
func covers_tile(p_tile: Vector2i) -> bool:
return p_tile == tile
# ── BuildJob interface ────────────────────────────────────────────────────────
## True while the crate has not yet been fully built.
func is_buildable() -> bool:
return not _completed
## Returns the player-visible name for build-order and inspect UI.
func label() -> String:
return label_text
## Called once per sim tick while a Construction pawn is working on this crate.
## Advances build_progress; completes the crate once BUILD_TICKS is reached.
func on_build_tick() -> void:
if _completed:
return
build_progress += 1
if build_progress >= BUILD_TICKS:
_completed = true
emit_signal("contents_changed")
Audit.log("crate", "built at %s (capacity %d)" % [tile, CAPACITY])
queue_redraw()
## True once the crate has been fully built.
func is_completed() -> bool:
return _completed
# ── inventory hooks ───────────────────────────────────────────────────────────
## Called from JobRunner._tick_deposit (Opus integration) after the item
## physically lands on the crate tile.
## Defensive: skips duplicates and over-capacity inserts (HaulingProvider may
## race ahead of capacity checks in edge cases).
func register_item(item) -> void:
if not _completed:
return
if _contents.has(item) or _contents.size() >= CAPACITY:
return
_contents.append(item)
emit_signal("contents_changed")
## Called when an item is removed from the crate (picked up by a pawn or via
## the Empty operation in Phase 17 inspect UI).
func unregister_item(item) -> void:
_contents.erase(item)
emit_signal("contents_changed")
# ── save / load ───────────────────────────────────────────────────────────────
## 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,
"build_progress": build_progress,
"completed": _completed,
"priority": int(priority),
"accepted_types": accepted_types.map(func(t): return String(t)),
}
## Restore from a dict produced by to_dict().
## Item content refs are reconnected by World.load_crates() after all items
## are spawned (Phase 16); _contents starts empty here.
func from_dict(d: Dictionary) -> void:
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
label_text = d.get("label_text", "Crate")
build_progress = int(d.get("build_progress", 0))
_completed = bool(d.get("completed", false))
priority = d.get("priority", StorageDestination.Priority.NORMAL) as StorageDestination.Priority
var raw_types: Array = d.get("accepted_types", [])
accepted_types.clear()
for s in raw_types:
accepted_types.append(StringName(s))
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
## Procedural crate graphic; no PNG dependency.
##
## Completed crate:
## Brown 12×10 body with a darker 1-px outline, two horizontal slat bands.
## Four 2×2 fill-indicator dots in the top-right corner — white dots equal
## to _contents.size() are drawn; remaining dots are drawn at low alpha.
##
## Ghost (not yet built):
## Same shapes at GHOST_ALPHA overall alpha.
func _draw() -> void:
var alpha_scale: float = _GHOST_ALPHA if not _completed else 1.0
var body_color := Color(_COLOR_BODY.r, _COLOR_BODY.g, _COLOR_BODY.b, _COLOR_BODY.a * alpha_scale)
var outline_col := Color(_COLOR_OUTLINE.r, _COLOR_OUTLINE.g, _COLOR_OUTLINE.b, _COLOR_OUTLINE.a * alpha_scale)
var slat_color := Color(_COLOR_SLAT.r, _COLOR_SLAT.g, _COLOR_SLAT.b, _COLOR_SLAT.a * alpha_scale)
# Half-extents for centering.
var hw: int = _BODY_W / 2 # 6
var hh: int = _BODY_H / 2 # 5
# Body fill.
var body_rect := Rect2(Vector2(-hw, -hh), Vector2(_BODY_W, _BODY_H))
draw_rect(body_rect, body_color, true)
# Outline (1 px wide; draw_rect with false = border).
draw_rect(body_rect, outline_col, false, 1.0)
# Two horizontal slat bands — at ⅓ and ⅔ of the body height.
# Each band is 1 px tall, inset 1 px from the sides.
var slat_x_start: float = -hw + 1.0
var slat_width: float = float(_BODY_W - 2)
var slat_y1: float = -hh + float(_BODY_H) / 3.0 - 0.5
var slat_y2: float = -hh + float(_BODY_H) * 2.0 / 3.0 - 0.5
draw_line(
Vector2(slat_x_start, slat_y1),
Vector2(slat_x_start + slat_width, slat_y1),
slat_color, 1.0
)
draw_line(
Vector2(slat_x_start, slat_y2),
Vector2(slat_x_start + slat_width, slat_y2),
slat_color, 1.0
)
# Fill-level indicator: 4 dots (2×2 px each) in the top-right corner,
# arranged in a 2×2 grid. Dots up to _contents.size() are bright white;
# the rest are dim (10% alpha).
var dot_size: float = 2.0
var dot_gap: float = 1.0
var dot_origin := Vector2(float(hw) - 2.0 * dot_size - dot_gap - 1.0, float(-hh) + 1.0)
for i in range(CAPACITY):
var col_idx: int = i % 2
var row_idx: int = i / 2
var dot_x: float = dot_origin.x + col_idx * (dot_size + dot_gap)
var dot_y: float = dot_origin.y + row_idx * (dot_size + dot_gap)
var dot_rect := Rect2(Vector2(dot_x, dot_y), Vector2(dot_size, dot_size))
var fill_alpha: float = (1.0 if i < _contents.size() else 0.15) * alpha_scale
var dot_col := Color(_COLOR_FILL_DOT.r, _COLOR_FILL_DOT.g, _COLOR_FILL_DOT.b, fill_alpha)
draw_rect(dot_rect, dot_col, true)