rimlike/scenes/entities/corpse.gd
megaproxy 67ec2cce7f Phase 14: Death + Corpses + Burial + Cremation
Three-agent fan-out. Opus pre-wrote Corpse class + 5 EventBus signals +
World registries (corpses, grave_markers) before dispatch so all three
slices ran fully parallel. Pattern proven across Phases 12/13/14.

Death pipeline (Agent A):
- Pawn.is_dead(), _check_death() — pawn_died signal → corpse spawn →
  corpse_spawned signal → World.unregister_pawn → queue_free
- _last_damage_source carries cause from take_damage() (now StringName)
- Bleed-out timeout: _bleed_ticks accumulates while bleeding active;
  at BLEED_OUT_TICKS=432000 (6 in-game hours) force-kills via take_damage
- Pawn.portrait_color stored field for corpse head-color hand-off
- Corpse: DECAY_PER_TICK=0.05 (~33 in-game min fresh→rotted at 1×),
  is_rotting()@50, queue_free@100 with corpse_rotted_away signal.
  Rotting bumps DirtinessSystem (Phase 13 hook) +0.04/tick (~+8/in-game-min)
- DEMO_PHASE14_AUTOKILL toggle in world.gd (default false, gates safety)

Graveyard + GraveSlot + GraveMarker + Hauling (Agent B):
- scenes/world/graveyard_zone.gd — StorageDestination subclass,
  accepted_types=[corpse], brownish overlay, finds dug GraveSlots
- scenes/entities/grave_slot.gd — buildable (ghost→dug) state machine,
  StorageDestination duck-type interface, accept_corpse() spawns
  GraveMarker + emits corpse_buried + queue_frees self
- scenes/entities/grave_marker.gd — permanent memorial, procedural
  stone-cross _draw, carries deceased identity, save round-trip
- TOOL_GRAVEYARD + TOOL_DIG_GRAVE paint modes (Designation dispatch)
- KIND_PICKUP_CORPSE + KIND_DEPOSIT_CORPSE toils + JobRunner handlers
- HaulingProvider.find_best_for iterates World.corpses in addition to
  items_needing_haul; corpse-payload stored as Node metadata on pawn
- ConstructionProvider duck-type already accepts GraveSlot (no change)

Cremation + Ash + Mood thoughts (Agent C):
- scenes/entities/cremation_pyre.gd — extends Workbench, label 'Pyre',
  auto-populates FOREVER bill for cremate_corpse, on_craft_complete
  drops 1 ash + emits corpse_cremated + queue_frees corpse
- Recipe.ingredient2_type/count added with save round-trip; recipe
  catalog entry cremate_corpse(TYPE_CORPSE primary + 5 wood secondary)
  NOTE: CraftingProvider still only enforces ingredient1 — documented
  gap, ships when crafting is generalized.
- Item.TYPE_ASH added + ALL_TYPES filter array entry
- 4 mood thoughts: saw_corpse (-3 EVENT 1200t max=3), buried_friend
  (+2 EVENT 2400t), cremated_friend (+2 EVENT 2400t),
  rotting_body_in_colony (-4 PERSISTENT stacks=count capped at 3)
- Pawn sync hooks: proximity scan (saw_corpse), signal listeners
  (buried/cremated within 8-tile radius), count helper for rotting

MCP runtime verified:
- DEMO_PHASE14_AUTOKILL toggle force-killed Bram at tick 50
- 'Bram DIED (cause=demo_kill, tile=(20, 36))' + corpse spawned
- 'Cora: saw_corpse thought added (corpse Bram at dist 5)' — mood -3
- Painted graveyard + dig_grave → grave dug to completion verified
  in build_queue (grave @(22, 39) complete=true)
- Hauler round-trip (corpse → GraveSlot → GraveMarker) WIRED correctly
  but didn't land within decay window at ULTRA speed (12×) — corpse
  rotted before priority-3 corpse-haul scheduled. Tuning for Phase 20.
- Screenshot captured: fresh corpse silhouette at cabin doorway

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

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

125 lines
4.7 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 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 {
"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()