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>
This commit is contained in:
parent
9cf9b7dbfd
commit
67ec2cce7f
26 changed files with 1306 additions and 33 deletions
|
|
@ -23,6 +23,9 @@ extends Node2D
|
|||
|
||||
class_name Pawn
|
||||
|
||||
## Phase 14 — corpse scene instantiated on death.
|
||||
const CORPSE_SCENE: PackedScene = preload("res://scenes/entities/corpse.tscn")
|
||||
|
||||
const STEP_TICKS: int = 10
|
||||
const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn needs no World reference.
|
||||
|
||||
|
|
@ -123,6 +126,22 @@ var sulking: bool = false
|
|||
# Phase 9 — HP (design.md "Single HP per pawn", default 100).
|
||||
var hp: float = HP_MAX
|
||||
|
||||
# Phase 14 — body colour derived once from the pawn's name hash (same formula
|
||||
# as _draw() used to compute inline). Stored so the Corpse entity can carry it
|
||||
# for its head-dot visual without access to the living pawn.
|
||||
var portrait_color: Color = Color.WHITE
|
||||
|
||||
# Phase 14 — last named damage source passed to take_damage(); used by
|
||||
# _check_death() to label the corpse cause when bleeding is not active.
|
||||
var _last_damage_source: StringName = &""
|
||||
|
||||
# Phase 14 — bleed-out timeout counter. Increments each sim tick while the
|
||||
# pawn has a Bleeding status AND is still alive (HP > 0). Resets when the
|
||||
# Bleeding status is cleared. If this reaches StatusCatalog.BLEED_OUT_TICKS
|
||||
# (432000 ticks ≈ 6 in-game hours) and no doctor has treated the pawn, a
|
||||
# lethal take_damage() call forces _check_death().
|
||||
var _bleed_ticks: int = 0
|
||||
|
||||
# Phase 9 — active status effects. Do not mutate directly — use add_status() /
|
||||
# remove_status_by_id() which emit EventBus signals and enforce stack-merge logic.
|
||||
var statuses: Array = [] # Array[Status]
|
||||
|
|
@ -158,6 +177,13 @@ func _ready() -> void:
|
|||
for skill in ALL_SKILLS:
|
||||
if not skills.has(skill):
|
||||
skills[skill] = 0
|
||||
# Phase 14 — connect to burial / cremation signals for closure thoughts.
|
||||
# Defensive: signals are declared in EventBus Phase 14 block; guard for
|
||||
# environments where a pre-Phase-14 shim might be in use.
|
||||
if EventBus.has_signal("corpse_buried"):
|
||||
EventBus.corpse_buried.connect(_on_corpse_buried)
|
||||
if EventBus.has_signal("corpse_cremated"):
|
||||
EventBus.corpse_cremated.connect(_on_corpse_cremated)
|
||||
|
||||
|
||||
func setup(p_name: String, start_tile: Vector2i) -> void:
|
||||
|
|
@ -166,6 +192,10 @@ func setup(p_name: String, start_tile: Vector2i) -> void:
|
|||
position = _tile_to_world(tile)
|
||||
_name_label.text = pawn_name
|
||||
_state_label.text = Strings.t(&"pawn.state.idle")
|
||||
# Phase 14 — compute portrait_color once so Corpse can carry it.
|
||||
# Same formula as _draw() body disc: deterministic hue from name hash.
|
||||
var hue := float(pawn_name.hash() % 360) / 360.0
|
||||
portrait_color = Color.from_hsv(hue, 0.7, 0.85)
|
||||
Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile])
|
||||
|
||||
|
||||
|
|
@ -245,12 +275,17 @@ func set_sleep(value: float) -> void:
|
|||
## Reduce HP by amount, clamped to [0, HP_MAX]. Emits pawn_took_damage.
|
||||
## Pass a non-empty source string for Audit context (e.g. "bleeding", "wolf bite").
|
||||
## Does NOT log per-tick bleed ticks — only logs on direct named calls.
|
||||
func take_damage(amount: float, source: String = "") -> void:
|
||||
## Phase 14: tracks _last_damage_source for corpse cause labelling; calls
|
||||
## _check_death() after _check_downed() so death is detected on the same tick.
|
||||
func take_damage(amount: float, source: StringName = &"") -> void:
|
||||
hp = maxf(0.0, hp - amount)
|
||||
if source != &"":
|
||||
_last_damage_source = source
|
||||
EventBus.pawn_took_damage.emit(self, amount)
|
||||
if source != "":
|
||||
if source != &"":
|
||||
Audit.log("pawn", "%s took %.1f damage from '%s' (hp=%.1f)" % [pawn_name, amount, source, hp])
|
||||
_check_downed()
|
||||
_check_death()
|
||||
|
||||
|
||||
## Restore HP by amount, clamped to [0, HP_MAX]. Checks revive condition.
|
||||
|
|
@ -317,6 +352,60 @@ func _check_revive() -> void:
|
|||
Audit.log("pawn", "%s revived from Downed (hp=%.1f)" % [pawn_name, hp])
|
||||
|
||||
|
||||
# ── death (Phase 14) ─────────────────────────────────────────────────────────
|
||||
|
||||
## True when the pawn's HP has reached zero.
|
||||
func is_dead() -> bool:
|
||||
return hp <= 0.0
|
||||
|
||||
|
||||
## Internal: detect death and run the corpse-spawn pipeline.
|
||||
## Called by take_damage() after _check_downed(), and by _process_statuses()
|
||||
## after the bleed-out timeout fires.
|
||||
##
|
||||
## Re-entrant guard: returns immediately if hp > 0 or node is already freed.
|
||||
##
|
||||
## Pipeline (in order):
|
||||
## 1. Determine cause (bleeding > _last_damage_source > "unknown").
|
||||
## 2. Audit.log the death event.
|
||||
## 3. Emit EventBus.pawn_died BEFORE removal so listeners see a valid pawn.
|
||||
## 4. Spawn Corpse at death tile; call setup() then add_child() on parent.
|
||||
## 5. Emit EventBus.corpse_spawned so hauling + UI systems react.
|
||||
## 6. Unregister from World.pawns; queue_free() this node.
|
||||
func _check_death() -> void:
|
||||
if not is_dead():
|
||||
return
|
||||
# Guard against re-entrant calls (bleed tick and take_damage on the same tick).
|
||||
if is_queued_for_deletion():
|
||||
return
|
||||
|
||||
# Determine cause of death.
|
||||
var cause: StringName
|
||||
if has_status(&"bleeding"):
|
||||
cause = &"bleeding"
|
||||
elif _last_damage_source != &"":
|
||||
cause = _last_damage_source
|
||||
else:
|
||||
cause = &"unknown"
|
||||
|
||||
Audit.log("pawn", "%s DIED (cause=%s, tile=%s)" % [pawn_name, cause, tile])
|
||||
|
||||
# Notify listeners before the pawn is removed (e.g. future "saw colonist die" mood thought).
|
||||
EventBus.pawn_died.emit(self, cause)
|
||||
|
||||
# Spawn corpse at the death tile. get_parent() is the World scene root,
|
||||
# same as the WolfSpawner pattern — no get_node("/root/...") smell.
|
||||
var corpse: Corpse = CORPSE_SCENE.instantiate()
|
||||
corpse.setup(tile, pawn_name, portrait_color, cause)
|
||||
get_parent().add_child(corpse)
|
||||
Audit.log("pawn", "corpse spawned for %s at %s" % [pawn_name, tile])
|
||||
EventBus.corpse_spawned.emit(corpse)
|
||||
|
||||
# Remove from World registry before freeing; _exit_tree on Corpse handles corpse registration.
|
||||
World.unregister_pawn(self)
|
||||
queue_free()
|
||||
|
||||
|
||||
## Returns the pawn's current level (0–10) for the given skill.
|
||||
## Returns 0 for unknown skills so callers need no nil-guard.
|
||||
func get_skill(skill: StringName) -> int:
|
||||
|
|
@ -409,6 +498,8 @@ func _process_thoughts() -> void:
|
|||
# Phase 13 — room beauty and dirtiness thoughts.
|
||||
# Defensive: World.room_at_tile returns null if rooms are empty (Agent A may land later).
|
||||
_sync_room_thoughts()
|
||||
# Phase 14 — corpse proximity and colony-wide rotting thoughts.
|
||||
_sync_corpse_thoughts()
|
||||
# 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally).
|
||||
if dirty:
|
||||
_recompute_mood()
|
||||
|
|
@ -472,6 +563,73 @@ func _sync_room_thoughts() -> void:
|
|||
_sync_persistent_thought(&"clean_room", is_clean, ThoughtCatalog.clean_room())
|
||||
|
||||
|
||||
## Phase 14 — sync corpse-related thoughts each sim tick.
|
||||
##
|
||||
## saw_corpse (EVENT): fires when any corpse is within 5 Manhattan tiles and
|
||||
## the pawn doesn't already have this thought (natural EVENT decay handles
|
||||
## the refresh window; do not re-add while it is still ticking).
|
||||
##
|
||||
## rotting_body_in_colony (PERSISTENT): synced from World.corpses. After
|
||||
## sync, the existing thought's stacks are updated to match the rotting
|
||||
## count (capped at max_stacks=3) so severity scales with corpse count.
|
||||
func _sync_corpse_thoughts() -> void:
|
||||
# saw_corpse — scan for any corpse within 5 Manhattan tiles.
|
||||
if not has_thought(&"saw_corpse"):
|
||||
for c in World.corpses:
|
||||
var d: int = abs(c.tile.x - tile.x) + abs(c.tile.y - tile.y)
|
||||
if d <= 5:
|
||||
add_thought(ThoughtCatalog.saw_corpse())
|
||||
Audit.log("pawn", "%s: saw_corpse thought added (corpse '%s' at dist %d)" % [pawn_name, c.deceased_name, d])
|
||||
break # One add per tick — stacks via add_thought merge on next sighting
|
||||
|
||||
# rotting_body_in_colony — PERSISTENT, severity scales with rotting count.
|
||||
var rotting_count: int = _count_rotting_corpses()
|
||||
_sync_persistent_thought(&"rotting_body_in_colony", rotting_count > 0, ThoughtCatalog.rotting_body_in_colony())
|
||||
if rotting_count > 0 and has_thought(&"rotting_body_in_colony"):
|
||||
for t in thoughts:
|
||||
if t.id == &"rotting_body_in_colony":
|
||||
t.stacks = mini(rotting_count, t.max_stacks)
|
||||
break
|
||||
|
||||
|
||||
## Returns the count of corpses in World.corpses that are currently rotting.
|
||||
func _count_rotting_corpses() -> int:
|
||||
var count: int = 0
|
||||
for c in World.corpses:
|
||||
if c.is_rotting():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
## Phase 14 — fires when EventBus.corpse_buried is emitted (Agent B).
|
||||
## Gives the buried_friend thought if this pawn was nearby (8 tiles).
|
||||
## Defensive: marker may be null or lack expected fields if Agent B's
|
||||
## GraveMarker shape differs; access only via null-safe .get() checks.
|
||||
func _on_corpse_buried(corpse, marker) -> void:
|
||||
if corpse == null:
|
||||
return
|
||||
# Prefer the marker's tile for the burial site; fall back to corpse tile.
|
||||
var burial_tile: Vector2i = corpse.tile
|
||||
if marker != null and marker.get("tile") != null:
|
||||
burial_tile = marker.tile
|
||||
var dist: int = abs(burial_tile.x - tile.x) + abs(burial_tile.y - tile.y)
|
||||
if dist <= 8:
|
||||
add_thought(ThoughtCatalog.buried_friend())
|
||||
Audit.log("pawn", "%s: buried_friend thought added (burial at %s, dist=%d)" % [pawn_name, burial_tile, dist])
|
||||
|
||||
|
||||
## Phase 14 — fires when EventBus.corpse_cremated is emitted (CremationPyre).
|
||||
## Gives the cremated_friend thought if this pawn was nearby (8 tiles).
|
||||
func _on_corpse_cremated(corpse, pyre) -> void:
|
||||
if pyre == null:
|
||||
return
|
||||
var pyre_tile: Vector2i = pyre.tile if pyre.get("tile") != null else Vector2i(-999, -999)
|
||||
var dist: int = abs(pyre_tile.x - tile.x) + abs(pyre_tile.y - tile.y)
|
||||
if dist <= 8:
|
||||
add_thought(ThoughtCatalog.cremated_friend())
|
||||
Audit.log("pawn", "%s: cremated_friend thought added (pyre at %s, dist=%d)" % [pawn_name, pyre_tile, dist])
|
||||
|
||||
|
||||
## Add or remove a PERSISTENT thought based on a boolean state flag.
|
||||
## Calls add_thought() / remove_thought_by_id() (which recompute mood) only
|
||||
## when the presence actually needs to change — avoids redundant recomputes.
|
||||
|
|
@ -522,11 +680,18 @@ func _process_sulking() -> void:
|
|||
## Sequence:
|
||||
## 1. Decay EVENT statuses — remove expired ones.
|
||||
## 2. Apply per-tick effects (Bleeding drains HP).
|
||||
## 3. Phase 12 — tick wet / cold accumulators and sync statuses.
|
||||
## 3. Phase 14 — bleed-out timeout: if a pawn has been bleeding for
|
||||
## StatusCatalog.BLEED_OUT_TICKS (432000 ticks ≈ 6 in-game hours) with
|
||||
## no doctor treating them, force-kill via take_damage(hp) to trigger
|
||||
## _check_death(). _bleed_ticks resets when the Bleeding status is cleared
|
||||
## (i.e. when a doctor successfully treats the wound).
|
||||
## 4. Phase 14 — check death from bleed damage (hp reached 0 this tick).
|
||||
## 5. Phase 12 — tick wet / cold accumulators and sync statuses.
|
||||
##
|
||||
## Bleeding does NOT log per-tick — would flood Audit. Named-source logging
|
||||
## happens in take_damage() only when source is non-empty.
|
||||
func _process_statuses() -> void:
|
||||
var was_bleeding := false
|
||||
for i in range(statuses.size() - 1, -1, -1):
|
||||
var s: Status = statuses[i]
|
||||
# 1. Decay EVENT statuses.
|
||||
|
|
@ -537,11 +702,26 @@ func _process_statuses() -> void:
|
|||
continue
|
||||
# 2. Apply per-tick effects.
|
||||
if s.kind == Status.Kind.BLEEDING:
|
||||
was_bleeding = true
|
||||
# No source string to suppress per-tick Audit flood.
|
||||
hp = maxf(0.0, hp - StatusCatalog.BLEED_HP_PER_TICK * float(s.severity))
|
||||
EventBus.pawn_took_damage.emit(self, StatusCatalog.BLEED_HP_PER_TICK * float(s.severity))
|
||||
_check_downed()
|
||||
# 3. Phase 12 — wet / cold environment exposure.
|
||||
# 3. Phase 14 — bleed-out timeout.
|
||||
# Design note: BLEED_OUT_TICKS = 432000 ≈ 6 in-game hours at 20 Hz.
|
||||
# Simplified: death-by-bleed = hp ≤ 0 path through _check_death() handles
|
||||
# the moment-of-death case; the _bleed_ticks timer is the "long neglect"
|
||||
# safety net that prevents an immortal downed pawn when bleed rate is low.
|
||||
if was_bleeding:
|
||||
_bleed_ticks += 1
|
||||
if _bleed_ticks >= StatusCatalog.BLEED_OUT_TICKS:
|
||||
Audit.log("pawn", "%s bleed-out timeout after %d ticks" % [pawn_name, _bleed_ticks])
|
||||
take_damage(hp, &"bleed_out") # lethal; triggers _check_death()
|
||||
else:
|
||||
_bleed_ticks = 0 # reset when bleeding is cleared (doctor treated)
|
||||
# 4. Phase 14 — detect death from bleed tick this frame (hp → 0 via bleeding).
|
||||
_check_death()
|
||||
# 5. Phase 12 — wet / cold environment exposure.
|
||||
_tick_wet()
|
||||
_tick_cold()
|
||||
|
||||
|
|
@ -716,6 +896,9 @@ func to_dict() -> Dictionary:
|
|||
# Phase 12 — wet / cold accumulators. Default 0 for pre-Phase-12 save compat.
|
||||
"wet_accum": _wet_accum,
|
||||
"cold_accum": _cold_accum,
|
||||
# Phase 14 — bleed-out timeout counter. Default 0 for pre-Phase-14 saves.
|
||||
"bleed_ticks": _bleed_ticks,
|
||||
"last_damage_source": String(_last_damage_source),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -766,6 +949,13 @@ func from_dict(d: Dictionary) -> void:
|
|||
_wet_accum = clampf(float(d.get("wet_accum", 0.0)), 0.0, 100.0)
|
||||
_cold_accum = clampf(float(d.get("cold_accum", 0.0)), 0.0, 100.0)
|
||||
|
||||
# Phase 14 — restore bleed-out counter and last damage source.
|
||||
_bleed_ticks = int(d.get("bleed_ticks", 0))
|
||||
_last_damage_source = StringName(d.get("last_damage_source", ""))
|
||||
# Recompute portrait_color from pawn_name (same formula as setup()).
|
||||
var pc_hue := float(pawn_name.hash() % 360) / 360.0
|
||||
portrait_color = Color.from_hsv(pc_hue, 0.7, 0.85)
|
||||
|
||||
# Restore skills — set directly on the dict to bypass the ALL_SKILLS assert
|
||||
# (from_dict must be resilient to saves that pre-date a new skill being added).
|
||||
var saved_skills: Variant = d.get("skills")
|
||||
|
|
@ -847,10 +1037,10 @@ func _process(_delta: float) -> void:
|
|||
|
||||
|
||||
func _draw() -> void:
|
||||
# Body disc — colour derived deterministically from pawn name so each pawn
|
||||
# is visually distinct without any art dependency.
|
||||
var hue := float(pawn_name.hash() % 360) / 360.0
|
||||
var body_colour := Color.from_hsv(hue, 0.7, 0.85)
|
||||
# Phase 14 — use the stored portrait_color (computed once in setup()/from_dict()).
|
||||
# This is the same formula as the old inline hue derivation; consolidating here
|
||||
# removes the duplication and ensures the corpse head-dot matches exactly.
|
||||
var body_colour := portrait_color
|
||||
|
||||
if is_downed():
|
||||
# Phase 9 — Downed pawn: rotated 90° (lying on ground) + desaturated.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue