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

126 lines
4.7 KiB
GDScript
Raw Permalink 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 Corpse extends Node2D
## Phase 14 — pawn corpse. Spawns when a Pawn dies, carries the deceased
## pawn's identity for the burial / cremation / grave-marker pipeline.
##
## DECAY MODEL (design.md "Death & corpses"):
## decay 0..50 — fresh (no dirt contribution beyond the existing tile)
## decay 50..100 — rotting (DirtinessSystem.bump per tick, "rotting body in colony" thought severity ramps)
## decay 100 — rotted: corpse is destroyed, EventBus.corpse_rotted_away fires
##
## Hauling: corpses haul-route through StorageDestinations exactly like Items,
## but accept-filter is gated by Item.TYPE_CORPSE on the destination side.
## GraveyardZone (Agent B) is the destination class that opts in.
##
## When a corpse reaches a GraveSlot (Agent B), the slot consumes it and spawns
## a GraveMarker carrying the deceased identity. EventBus.corpse_buried fires.
##
## When a corpse is fed to a CremationPyre (Agent C), it's consumed and an
## ash item drops + "cremated friend" mood thought fires.
##
## Save round-trip carries: tile, deceased_name, deceased_portrait_color,
## decay, death_tick, death_cause.
const DECAY_PER_TICK: float = 0.05 ## ~33 in-game min from fresh→rotted at 1×
const DECAY_FRESH_MAX: float = 50.0
const DECAY_ROTTED: float = 100.0
## Tile position (mirrors Pawn/Item convention).
@export var tile: Vector2i = Vector2i.ZERO
## Deceased identity — carried to the GraveMarker on burial.
@export var deceased_name: String = ""
@export var deceased_portrait_color: Color = Color.WHITE
## Decay 0..100. Drives visual + dirty contribution + mood-thought severity.
@export var decay: float = 0.0
## Sim tick at death; useful for "how long ago" alerts.
@export var death_tick: int = 0
## Brief categorization, used by mood thoughts ("died of bleeding", "killed by wolf").
@export var death_cause: StringName = &""
func _ready() -> void:
World.register_corpse(self)
EventBus.sim_tick.connect(_on_sim_tick)
z_index = 4 # above floors, below pawns
func _exit_tree() -> void:
World.unregister_corpse(self)
## Public setup helper called by the death pipeline. Mirrors Wall.setup() shape.
func setup(p_tile: Vector2i, p_name: String, p_color: Color, p_cause: StringName) -> void:
tile = p_tile
global_position = Vector2(tile.x * 16 + 8, tile.y * 16 + 8)
deceased_name = p_name
deceased_portrait_color = p_color
death_cause = p_cause
death_tick = Sim.tick
queue_redraw()
## True for HaulingProvider — corpses haul like items.
## The matching filter on the StorageDestination side is `Item.TYPE_CORPSE`.
func get_item_type() -> StringName:
return &"corpse"
## True when decay is past the rotting threshold; mood thought severity ramps from here.
func is_rotting() -> bool:
return decay >= DECAY_FRESH_MAX
func _on_sim_tick(_n: int) -> void:
decay += DECAY_PER_TICK
if is_rotting():
# Bump dirtiness at this tile (Phase 13 hook). +5/h at decay=100 per design.md.
if World.dirtiness_system != null:
World.dirtiness_system.bump(tile, 0.04) # ~+8/in-game-min
if decay >= DECAY_ROTTED:
Audit.log("corpse", "%s rotted away at %s" % [deceased_name, tile])
EventBus.corpse_rotted_away.emit(self)
queue_free()
# ── visual: simple procedural sprite ─────────────────────────────────────────
func _draw() -> void:
# Body color desaturates as decay progresses; turns olive-green when rotting.
var t := clampf(decay / DECAY_ROTTED, 0.0, 1.0)
var fresh := Color(0.70, 0.60, 0.55)
var rotten := Color(0.45, 0.50, 0.30)
var body_color := fresh.lerp(rotten, t)
# Lay-down silhouette: 14×6 rect centered.
draw_rect(Rect2(-7, -3, 14, 6), body_color)
# Head dot
draw_circle(Vector2(-9, 0), 3, deceased_portrait_color.lerp(body_color, 0.3))
# ── save / load ──────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"class_id": &"corpse",
"tile_x": tile.x,
"tile_y": tile.y,
"name": deceased_name,
"color_r": deceased_portrait_color.r,
"color_g": deceased_portrait_color.g,
"color_b": deceased_portrait_color.b,
"decay": decay,
"death_tick": death_tick,
"cause": String(death_cause),
}
func from_dict(d: Dictionary) -> void:
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
global_position = Vector2(tile.x * 16 + 8, tile.y * 16 + 8)
deceased_name = d.get("name", "")
deceased_portrait_color = Color(d.get("color_r", 1.0), d.get("color_g", 1.0), d.get("color_b", 1.0))
decay = d.get("decay", 0.0)
death_tick = int(d.get("death_tick", 0))
death_cause = StringName(d.get("cause", ""))
queue_redraw()