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

1077 lines
45 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.

extends Node2D
## Pawn entity — grid-snapped, sim-tick-driven movement with smooth render lerp.
##
## Movement model (docs/architecture.md "Pawn movement"):
## At 1× speed, crossing one tile costs STEP_TICKS sim ticks (10 ticks = 0.5 s
## at 20 Hz). Each sim tick advances _step_progress by 1/STEP_TICKS. When
## progress reaches 1.0 the pawn snaps to the next waypoint.
##
## Speed scaling is free: Pause → no ticks → pawn frozen; Ultra → 12× ticks/s →
## pawn crosses the map in ~7 s real time. No per-pawn speed handling needed.
##
## Render: _process() lerps world-position between current and next tile every
## render frame at 60 Hz — motion is smooth even at low sim Hz.
##
## Phase 3 additions:
## - `forced_job` slot (player override via Selection)
## - `job_runner` Node child wired externally by the World scene
## - On each sim tick: orchestrate AI first (Decision → JobRunner.tick), then
## advance the walk. The walk is still owned by the Pawn — JobRunner's WALK
## toil delegates to `walk_along_path()` and listens for `walk_completed`.
## - to_dict() / from_dict() round-trip the entire mid-walk + mid-toil state
## (architecture.md "Save format" — mid-tick suspend safe).
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.
# ── mood + sulking constants (docs/architecture.md "MoodSystem"; docs/design.md "Mood bands") ──
const MOOD_BASE: float = 50.0
const MOOD_SULK_THRESHOLD: float = 25.0 # < 25 sustained → sulking (docs/design.md "Breaking" band)
const MOOD_SULK_RECOVERY: float = 35.0 # mood must reach ≥ 35 to exit sulking
const MOOD_SULK_SUSTAIN_TICKS: int = 600 # 30 in-game min at 1× (20 ticks/s × 60 s/min × 30 min)
# ── hunger constants (docs/design.md "Health & status effects") ───────────────
# Decay rate: 0.10 / tick × 20 ticks/s = 2.0 / real-sec at 1×.
# 100 → 0 in 50 sim seconds (1×). At Fast (5×): ~10 real seconds.
# At Ultra (12×): ~4 real seconds. Phase 7 demo-friendliness: at Fast a pawn
# needs food within ~10 real seconds of spawn. Keep items in-world so hunger
# triggers before it empties entirely. Tune in Phase 20.
const HUNGER_MAX: float = 100.0
const HUNGER_DECAY_PER_TICK: float = 0.02 # ~100→0 over 5000 ticks; ~4 min at 1×, ~20 s at Ultra. Tune Phase 20.
# ── sleep constants (docs/design.md "Sleep mood" + Phase 8) ──────────────────
# Decay: 0.015 / tick × 20 ticks/s = 0.3 / real-sec at 1×.
# 100 → 0 in ~6667 ticks (1×) ≈ ~5.6 real minutes. At Fast (5×): ~67 s.
# Slower than hunger — pawns tire in ~30 sim minutes at 1×. Tune Phase 20.
const SLEEP_MAX: float = 100.0
const SLEEP_DECAY_PER_TICK: float = 0.015 # Tune Phase 20.
# ── HP + status constants (docs/design.md "Health & status effects"; Phase 9) ──
# Single HP pool per pawn. No body-part injury (design.md "Simplifications").
const HP_MAX: float = 100.0
# HP at or below this threshold triggers the Downed status (design.md "Downed & death").
const HP_DOWNED_THRESHOLD: float = 30.0
# HP at or above this threshold clears the Downed status after treatment.
# Gap between thresholds prevents rapid down/revive oscillation.
const HP_REVIVE_THRESHOLD: float = 50.0
# ── skill definitions (docs/design.md "Skills") ──────────────────────────────
# Five skills, levels 010. Level by use; multiplicative speed/quality bonus.
# Skills modify duration and quality, never permission (design.md:35).
const SKILL_MANUAL_LABOR: StringName = &"manual_labor"
const SKILL_CRAFTING: StringName = &"crafting"
const SKILL_COOKING: StringName = &"cooking"
const SKILL_MEDICINE: StringName = &"medicine"
const SKILL_COMBAT: StringName = &"combat"
const ALL_SKILLS: Array[StringName] = [
SKILL_MANUAL_LABOR, SKILL_CRAFTING, SKILL_COOKING, SKILL_MEDICINE, SKILL_COMBAT,
]
signal walk_started
signal walk_completed
signal arrived_at_destination(tile: Vector2i)
@export var pawn_name: String = ""
var tile: Vector2i = Vector2i.ZERO
# Phase 7 — hunger need (design.md "Hungry" status). Full at spawn.
var hunger: float = HUNGER_MAX
# Phase 8 — sleep need (design.md "Sleep mood" gradient). Full at spawn.
var sleep: float = SLEEP_MAX
# Player override slot — set by Selection; consumed by Decision on next sim tick.
# Untyped to dodge the autoload-class-name-ordering trap (Phase 2 gotcha).
var forced_job = null
# JobRunner node ref. Set externally by World during pawn spawn (so the runner
# can be paired with the pathfinder). May be null in tests / pre-Phase-3 scenes.
var job_runner = null
# Phase 4 — carry slot for hauling. Holds an Item node while carrying; null
# when empty-handed. PICKUP toil sets this; DEPOSIT clears it. One stack /
# one type at a time per design.md.
var carried_item = null
# Phase 6 — skill levels. Initialized to 0 for all five skills in _ready().
# Use get_skill() / set_skill() to access; direct dict mutation is allowed
# for batch operations (e.g. from_dict restoring saved data).
var skills: Dictionary = {}
# Phase 8 — mood and thoughts (docs/architecture.md "MoodSystem").
## Ordered list of active Thought entries. Do not mutate directly — use
## add_thought() / remove_thought_by_id() which keep mood in sync.
var thoughts: Array = [] # Array[Thought]
## Cached mood score 0100. Recomputed by _recompute_mood() whenever the
## thoughts array changes. Do not write directly.
var mood: float = MOOD_BASE
## Counts consecutive sim ticks where mood < MOOD_SULK_THRESHOLD.
## Reset to 0 when mood rises above the threshold.
var _sulk_low_ticks: int = 0
## True after mood has been below MOOD_SULK_THRESHOLD for MOOD_SULK_SUSTAIN_TICKS
## consecutive ticks. Cleared once mood recovers to MOOD_SULK_RECOVERY.
## Decision Layer 1 short-circuits for sulking pawns.
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]
# Phase 12 — wet / cold accumulators (0100).
# _wet_accum rises while the pawn is outdoors in rain; decays otherwise.
# Crosses WET_DAMP_THRESHOLD (25) → Damp status; WET_SOAKED_THRESHOLD (60) → Soaked.
# _cold_accum rises in winter or during a cold snap outdoors; decays otherwise.
# Mirroring StatusCatalog constants — do not read StatusCatalog inside property
# access paths; use _wet_severity() / _cold_severity() helpers instead.
var _wet_accum: float = 0.0
var _cold_accum: float = 0.0
# Phase 13 — shelter debug tracking.
## When SHELTER_DEBUG is true, any false→true or true→false transition in
## _is_sheltered() emits an Audit.log line. Off by default — debug noise.
const SHELTER_DEBUG: bool = false
var _shelter_prev: bool = false
var _path: Array[Vector2i] = []
var _step_progress: float = 0.0
var _selected: bool = false
@onready var _name_label: Label = $NameLabel
@onready var _state_label: Label = $StateLabel
func _ready() -> void:
EventBus.sim_tick.connect(_on_sim_tick)
_state_label.text = Strings.t(&"pawn.state.idle")
# Initialise all five skills to 0 if not already set (from_dict sets them
# before _ready() fires in some load paths — only fill missing keys here).
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:
pawn_name = p_name
tile = start_tile
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])
# ── public API ──────────────────────────────────────────────────────────────
func walk_along_path(new_path: Array[Vector2i]) -> void:
if new_path.is_empty():
return
var was_walking := is_walking()
_path = new_path.duplicate()
# _step_progress carries over; when it hits 1.0 the pawn snaps to
# the first tile of the new path and picks up the new direction.
if not was_walking:
walk_started.emit()
_state_label.text = Strings.t(&"pawn.state.walking")
Audit.log("pawn", "%s walk path len %d%s" % [pawn_name, new_path.size(), new_path[-1]])
func is_walking() -> bool:
return not _path.is_empty()
func set_selected(value: bool) -> void:
if _selected == value:
return
_selected = value
queue_redraw()
func is_selected() -> bool:
return _selected
# ── hunger API (Phase 7) ──────────────────────────────────────────────────────
## True when hunger is low enough that the pawn should seek food.
## Threshold matches the design.md "Hungry" state-driven thought trigger (< 30).
func is_hungry() -> bool:
return hunger < 30.0
## True when the pawn is critically hungry and health damage is imminent.
## Exposed for Phase 9 Decision Layer-3 interrupt wiring (starvation HP drain).
## Not yet connected to a work interrupt — Phase 9 follow-up.
func is_starving() -> bool:
return hunger < 5.0
## Set hunger to `value`, clamped to [0, HUNGER_MAX].
## Used by EatProvider's _tick_eat and save/load.
func set_hunger(value: float) -> void:
hunger = clampf(value, 0.0, HUNGER_MAX)
# ── sleep API (Phase 8) ───────────────────────────────────────────────────────
## True when sleep is low enough that the pawn should seek a bed.
## Threshold mirrors design.md "Tired" state-driven thought trigger (< 30).
func is_tired() -> bool:
return sleep < 30.0
## True when the pawn is critically sleep-deprived.
## Phase 9 Decision Layer-3 interrupt hook — not yet wired (exhaustion collapse).
func is_exhausted() -> bool:
return sleep < 5.0
## Set sleep to `value`, clamped to [0, SLEEP_MAX].
## Used by JobRunner's _tick_sleep and save/load.
func set_sleep(value: float) -> void:
sleep = clampf(value, 0.0, SLEEP_MAX)
# ── HP + status API (Phase 9) ─────────────────────────────────────────────────
## 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.
## 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 != &"":
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.
func heal(amount: float) -> void:
hp = minf(HP_MAX, hp + amount)
_check_revive()
## Add a status to this pawn, merging severity if one with the same id already exists.
## Stack-merge: severity increments by 1 up to max_severity (mirrors add_thought stacking).
## Emits pawn_status_added when a genuinely new status entry is appended.
func add_status(s: Status) -> void:
for existing in statuses:
if existing.id == s.id:
existing.severity = mini(existing.severity + 1, existing.max_severity)
return
statuses.append(s)
Audit.log("pawn", "%s gained status: %s (severity=%d)" % [pawn_name, s.label, s.severity])
EventBus.pawn_status_added.emit(self, s)
## Remove all statuses with the given id. Emits pawn_status_removed for each removed entry.
func remove_status_by_id(id: StringName) -> void:
for i in range(statuses.size() - 1, -1, -1):
if statuses[i].id == id:
var s: Status = statuses[i]
statuses.remove_at(i)
Audit.log("pawn", "%s lost status: %s" % [pawn_name, s.label])
EventBus.pawn_status_removed.emit(self, s)
## Returns true if the pawn has any active status with the given id.
func has_status(id: StringName) -> bool:
for s in statuses:
if s.id == id:
return true
return false
## True when the pawn is Downed (HP near zero, cannot act, awaiting rescue).
func is_downed() -> bool:
return has_status(&"downed")
## True when the pawn cannot accept any job. Decision Layer 1 probes this.
## Phase 9: only Downed blocks the pawn entirely. Future phases may add Unconscious.
func is_incapacitated() -> bool:
return is_downed()
## Internal: enter Downed state when HP drops to or below the threshold.
## add_status() emits pawn_status_added — listeners watch that to learn of Downed.
func _check_downed() -> void:
if hp <= HP_DOWNED_THRESHOLD and not is_downed():
add_status(StatusCatalog.downed())
Audit.log("pawn", "%s DOWNED (hp=%.1f)" % [pawn_name, hp])
## Internal: clear Downed when HP recovers to or above the revive threshold.
## Called by heal() — the doctor's treatment flow goes through heal().
func _check_revive() -> void:
if hp >= HP_REVIVE_THRESHOLD and is_downed():
remove_status_by_id(&"downed")
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 (010) for the given skill.
## Returns 0 for unknown skills so callers need no nil-guard.
func get_skill(skill: StringName) -> int:
return int(skills.get(skill, 0))
## Sets skill to level, clamped to [0, 10]. Asserts the key is a known skill.
func set_skill(skill: StringName, level: int) -> void:
assert(skill in ALL_SKILLS, "set_skill: unknown skill '%s'" % skill)
skills[skill] = clampi(level, 0, 10)
# ── thought / mood API (Phase 8) ─────────────────────────────────────────────
## Add a thought, merging stacks if one with the same id already exists.
## For EVENT thoughts, ticks_remaining is refreshed to the new value when
## the incoming duration is longer (keeps the best remaining window).
## Calls _recompute_mood() after any mutation.
func add_thought(t: Thought) -> void:
for existing in thoughts:
if existing.id == t.id:
existing.stacks = mini(existing.stacks + 1, existing.max_stacks)
if t.lifetime == Thought.Lifetime.EVENT:
existing.ticks_remaining = maxi(existing.ticks_remaining, t.ticks_remaining)
_recompute_mood()
return
thoughts.append(t)
_recompute_mood()
## Remove all thoughts with the given id. Calls _recompute_mood() if any were removed.
func remove_thought_by_id(id: StringName) -> void:
var removed := false
for i in range(thoughts.size() - 1, -1, -1):
if thoughts[i].id == id:
thoughts.remove_at(i)
removed = true
if removed:
_recompute_mood()
## Returns true if the pawn currently has any thought with the given id.
func has_thought(id: StringName) -> bool:
for t in thoughts:
if t.id == id:
return true
return false
## Returns true when the pawn is in a sulking soft-break state.
## Decision Layer 1 short-circuits for sulking pawns (no work accepted).
func is_sulking() -> bool:
return sulking
# ── thought / mood internals (called from _on_sim_tick) ──────────────────────
## Main per-tick thought update. Call AFTER hunger/sleep decay, BEFORE _orchestrate_ai.
##
## Sequence:
## 1. Decay EVENT thoughts — remove expired ones.
## 2. Sync PERSISTENT thoughts to current pawn state (hungry, tired).
## 3. Recompute mood if EVENT thoughts changed.
## 4. Update sulking transitions.
func _process_thoughts() -> void:
# 1. Decay EVENT thoughts.
var dirty := false
for i in range(thoughts.size() - 1, -1, -1):
var t = thoughts[i]
if t.lifetime == Thought.Lifetime.EVENT:
t.ticks_remaining -= 1
if t.ticks_remaining <= 0:
thoughts.remove_at(i)
dirty = true
# 2. Sync PERSISTENT thoughts.
_sync_persistent_thought(&"hungry", is_hungry(), ThoughtCatalog.hungry())
_sync_persistent_thought(&"tired", is_tired(), ThoughtCatalog.tired())
# Phase 11 — in_darkness fires when past dusk/before dawn AND the pawn's
# tile is unlit. architecture.md "LightingSystem": is_lit = light_map > 0.2;
# darkness_factor > 0.3 spans dusk-mid through dawn-mid.
# Phase 13 may add wall-occlusion via room BFS; for now radius-8 falloff only.
var _dark_time := Clock.darkness_factor() > 0.3
var _lit := World.is_tile_lit(tile)
_sync_persistent_thought(&"in_darkness", _dark_time and not _lit, ThoughtCatalog.in_darkness())
# Phase 12 — wet mood thoughts (mutually exclusive tiers; damp removed when soaked kicks in).
_sync_persistent_thought(&"damp", has_status(&"wet") and _wet_severity() == StatusCatalog.WET_DAMP_LEVEL, ThoughtCatalog.damp())
_sync_persistent_thought(&"soaked", has_status(&"wet") and _wet_severity() == StatusCatalog.WET_SOAKED_LEVEL, ThoughtCatalog.soaked())
# Phase 12 — cold mood thought (any cold severity triggers the single cold thought).
_sync_persistent_thought(&"cold", has_status(&"cold"), ThoughtCatalog.cold_thought())
# 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()
# 4. Sulking transitions.
_process_sulking()
## Phase 13 — sync beauty and dirtiness room thoughts for this pawn's current tile.
## Called from _process_thoughts() after the cold/damp/soaked block.
## Defensive: returns early if rooms or the beauty/dirtiness systems are not yet wired
## (Agent A's RoomDetector may land slightly after this code during startup).
##
## Beauty thoughts (mutually exclusive — only one fires):
## avg beauty >= 4.0 → beautiful_room
## avg beauty < 0.0 → ugly_room (Phase 14 corpses drive this below 0)
## else → neither
##
## Dirtiness thoughts (mutually exclusive — only one fires):
## avg dirt < 25 → clean_room
## avg dirt 25..60 → dirty_room
## avg dirt >= 60 → filthy_room
func _sync_room_thoughts() -> void:
var room = World.room_at_tile(tile)
# ── no room (outdoors or RoomDetector not yet live) → clear all room thoughts ──
if room == null:
_sync_persistent_thought(&"beautiful_room", false, ThoughtCatalog.beautiful_room())
_sync_persistent_thought(&"ugly_room", false, ThoughtCatalog.ugly_room())
_sync_persistent_thought(&"clean_room", false, ThoughtCatalog.clean_room())
_sync_persistent_thought(&"dirty_room", false, ThoughtCatalog.dirty_room())
_sync_persistent_thought(&"filthy_room", false, ThoughtCatalog.filthy_room())
return
# ── beauty ──────────────────────────────────────────────────────────────────
var avg_beauty: float = 0.0
var bs = World.get("beauty_system")
if bs != null and room.tiles.size() > 0:
var beauty_sum: float = 0.0
for rt in room.tiles:
beauty_sum += bs.beauty_at(rt)
avg_beauty = beauty_sum / float(room.tiles.size())
_sync_persistent_thought(&"beautiful_room", avg_beauty >= 4.0, ThoughtCatalog.beautiful_room())
_sync_persistent_thought(&"ugly_room", avg_beauty < 0.0, ThoughtCatalog.ugly_room())
# ── dirtiness ───────────────────────────────────────────────────────────────
var avg_dirt: float = 0.0
var ds = World.get("dirtiness_system")
if ds != null and room.tiles.size() > 0:
var dirt_sum: float = 0.0
for rt in room.tiles:
dirt_sum += ds.dirt_at(rt)
avg_dirt = dirt_sum / float(room.tiles.size())
# Mutually exclusive — only one fires (filthy wins over dirty wins over clean).
var is_filthy: bool = avg_dirt >= 60.0
var is_dirty: bool = avg_dirt >= 25.0 and not is_filthy
var is_clean: bool = avg_dirt < 25.0
_sync_persistent_thought(&"filthy_room", is_filthy, ThoughtCatalog.filthy_room())
_sync_persistent_thought(&"dirty_room", is_dirty, ThoughtCatalog.dirty_room())
_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.
func _sync_persistent_thought(id: StringName, state_active: bool, factory_result: Thought) -> void:
var present := has_thought(id)
if state_active and not present:
add_thought(factory_result)
elif not state_active and present:
remove_thought_by_id(id)
## Recompute mood from scratch using the locked formula:
## base 50 + sum(modifier × min(stacks, max_stacks))
## Clamps to [0, 100] and emits EventBus.pawn_mood_changed.
## (docs/architecture.md "MoodSystem" — MAX_STACKS_PER_THOUGHT = 5 locked)
func _recompute_mood() -> void:
var m := MOOD_BASE
for t in thoughts:
m += float(t.modifier) * float(mini(t.stacks, t.max_stacks))
mood = clampf(m, 0.0, 100.0)
EventBus.pawn_mood_changed.emit(self, mood)
## Track sustained low-mood ticks and transition sulking state accordingly.
##
## Enter sulking: mood has been < MOOD_SULK_THRESHOLD for MOOD_SULK_SUSTAIN_TICKS
## consecutive ticks (30 in-game min at 1× — docs/design.md "Breaking" band).
## Exit sulking: mood recovers to >= MOOD_SULK_RECOVERY (35).
## _sulk_low_ticks resets whenever mood rises above the threshold, so momentary
## dips do not trigger a break.
func _process_sulking() -> void:
if mood < MOOD_SULK_THRESHOLD:
_sulk_low_ticks += 1
if not sulking and _sulk_low_ticks >= MOOD_SULK_SUSTAIN_TICKS:
sulking = true
Audit.log("pawn", "%s soft-break: SULKING (mood=%.1f)" % [pawn_name, mood])
else:
_sulk_low_ticks = 0
if sulking and mood >= MOOD_SULK_RECOVERY:
sulking = false
Audit.log("pawn", "%s recovered from sulking (mood=%.1f)" % [pawn_name, mood])
# ── status tick (Phase 9) ─────────────────────────────────────────────────────
## Per-tick status update. Call AFTER _process_thoughts, BEFORE _orchestrate_ai.
##
## Sequence:
## 1. Decay EVENT statuses — remove expired ones.
## 2. Apply per-tick effects (Bleeding drains HP).
## 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.
if s.lifetime == Status.Lifetime.EVENT:
s.ticks_remaining -= 1
if s.ticks_remaining <= 0:
statuses.remove_at(i)
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 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()
## Tick the wet accumulator and sync the Wet status severity.
## Called every sim tick from _process_statuses().
func _tick_wet() -> void:
var sheltered := _is_sheltered()
if Weather.is_raining() and not sheltered:
var rate := StatusCatalog.WET_GAIN_PER_TICK * (2.0 if Weather.is_storming() else 1.0)
_wet_accum = clampf(_wet_accum + rate, 0.0, 100.0)
else:
_wet_accum = clampf(_wet_accum - StatusCatalog.WET_DECAY_PER_TICK, 0.0, 100.0)
_sync_wet_status()
## Tick the cold accumulator and sync the Cold status severity.
## Cold accumulates outdoors in winter OR during any cold snap regardless of season.
func _tick_cold() -> void:
var sheltered := _is_sheltered()
var cold_conditions := (Clock.current_season() == Clock.SEASON_WINTER or Weather.is_cold_snap())
if cold_conditions and not sheltered:
var rate := StatusCatalog.COLD_GAIN_PER_TICK * (2.0 if Weather.is_cold_snap() else 1.0)
_cold_accum = clampf(_cold_accum + rate, 0.0, 100.0)
else:
_cold_accum = clampf(_cold_accum - StatusCatalog.COLD_DECAY_PER_TICK, 0.0, 100.0)
_sync_cold_status()
## Compute the target wet severity from the current accumulator.
## Returns 0 (no status), 1 (Damp), or 2 (Soaked).
func _wet_severity() -> int:
for s in statuses:
if s.id == &"wet":
return s.severity
return 0
## Compute the target cold severity from the current accumulator.
## Returns 0 (no status), 1, 2, or 3.
func _cold_severity() -> int:
for s in statuses:
if s.id == &"cold":
return s.severity
return 0
## Sync the Wet status to match the current _wet_accum tier.
## Adds, updates severity, or removes the status with one Audit log per transition.
func _sync_wet_status() -> void:
var target: int
if _wet_accum >= StatusCatalog.WET_SOAKED_THRESHOLD:
target = StatusCatalog.WET_SOAKED_LEVEL
elif _wet_accum >= StatusCatalog.WET_DAMP_THRESHOLD:
target = StatusCatalog.WET_DAMP_LEVEL
else:
target = 0
var current := _wet_severity()
if target == current:
return
if target == 0:
remove_status_by_id(&"wet")
Audit.log("pawn", "%s dried off (wet=%.1f)" % [pawn_name, _wet_accum])
elif current == 0:
# Not wet → newly wet.
var label := "Damp" if target == StatusCatalog.WET_DAMP_LEVEL else "Soaked"
add_status(StatusCatalog.wet(target))
Audit.log("pawn", "%s now %s (wet=%.1f)" % [pawn_name, label, _wet_accum])
else:
# Severity shift — update in place to preserve the status object.
for s in statuses:
if s.id == &"wet":
var old_sev: int = s.severity
s.severity = target
var label := "Soaked" if target == StatusCatalog.WET_SOAKED_LEVEL else "Damp"
Audit.log("pawn", "%s now %s (wet=%.1f, sev %d%d)" % [pawn_name, label, _wet_accum, old_sev, target])
break
## Sync the Cold status to match the current _cold_accum tier.
## Adds, updates severity, or removes the status with one Audit log per transition.
func _sync_cold_status() -> void:
var target: int
if _cold_accum >= StatusCatalog.COLD_EXTREME_THRESHOLD:
target = 3
elif _cold_accum >= StatusCatalog.COLD_SEVERE_THRESHOLD:
target = 2
elif _cold_accum >= StatusCatalog.COLD_MILD_THRESHOLD:
target = 1
else:
target = 0
var current := _cold_severity()
if target == current:
return
if target == 0:
remove_status_by_id(&"cold")
Audit.log("pawn", "%s warmed up (cold=%.1f)" % [pawn_name, _cold_accum])
elif current == 0:
add_status(StatusCatalog.cold(target))
Audit.log("pawn", "%s now Cold severity %d (cold=%.1f)" % [pawn_name, target, _cold_accum])
else:
for s in statuses:
if s.id == &"cold":
var old_sev: int = s.severity
s.severity = target
Audit.log("pawn", "%s Cold sev %d%d (cold=%.1f)" % [pawn_name, old_sev, target, _cold_accum])
break
## Phase 13 — returns true if the pawn's current tile is inside an enclosed,
## roofed Room (via World.is_indoor). Falls back to the Phase 12 has-floor proxy
## during the brief window before RoomDetector populates the registry (boot,
## mid-build, cabin not-yet-enclosed). The fallback is graceful enough for those
## transient states and produces no false positives on open terrain.
##
## Callers: _tick_wet() and _tick_cold() both call this once per sim tick.
## If SHELTER_DEBUG is true, false→true and true→false transitions emit Audit lines.
func _is_sheltered() -> bool:
var sheltered: bool
if World.is_indoor(tile):
sheltered = true
else:
sheltered = World.floor_layer.get_cell_source_id(tile) != -1
if SHELTER_DEBUG and sheltered != _shelter_prev:
var direction := "→sheltered" if sheltered else "→unsheltered"
Audit.log("pawn", "%s shelter transition %s at %s" % [pawn_name, direction, tile])
_shelter_prev = sheltered
return sheltered
# ── save / load ─────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
var path_data: Array = []
for v in _path:
path_data.append([v.x, v.y])
# Serialise skills as {"manual_labor": 0, "crafting": 3, ...} — StringName
# keys must be stored as plain Strings for JSON round-trip safety.
var skills_data: Dictionary = {}
for skill in ALL_SKILLS:
skills_data[String(skill)] = get_skill(skill)
# Serialise Phase 8 thoughts as an array of thought dicts.
var thoughts_data: Array = []
for t in thoughts:
thoughts_data.append(t.to_dict())
# Serialise Phase 9 statuses as an array of status dicts.
var statuses_data: Array = []
for s in statuses:
statuses_data.append(s.to_dict())
return {
"class_id": &"pawn",
"name": pawn_name,
"tile_x": tile.x,
"tile_y": tile.y,
"path": path_data,
"step_progress": _step_progress,
"selected": _selected,
"forced_job": forced_job.to_dict() if forced_job != null else null,
"job_runner": job_runner.to_dict() if job_runner != null else null,
"skills": skills_data,
"hunger": hunger,
"sleep": sleep,
"thoughts": thoughts_data,
"mood": mood,
"hp": hp,
"statuses": statuses_data,
"sulk_low_ticks": _sulk_low_ticks,
"sulking": sulking,
# 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),
}
func from_dict(d: Dictionary) -> void:
pawn_name = d.get("name", "")
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
_path.clear()
for entry in d.get("path", []):
if entry is Array and entry.size() == 2:
_path.append(Vector2i(int(entry[0]), int(entry[1])))
_step_progress = float(d.get("step_progress", 0.0))
_selected = bool(d.get("selected", false))
var fj_dict: Variant = d.get("forced_job")
forced_job = Job.from_dict(fj_dict) if fj_dict is Dictionary else null
var jr_dict: Variant = d.get("job_runner")
if jr_dict is Dictionary and job_runner != null:
job_runner.from_dict(jr_dict)
# Phase 7 — restore hunger; default to full if missing (pre-Phase-7 save compat).
hunger = clampf(float(d.get("hunger", HUNGER_MAX)), 0.0, HUNGER_MAX)
# Phase 8 — restore sleep; default to full if missing (pre-Phase-8 save compat).
sleep = clampf(float(d.get("sleep", SLEEP_MAX)), 0.0, SLEEP_MAX)
# Phase 8 — restore thoughts, mood, and sulking state.
thoughts.clear()
var thoughts_raw: Variant = d.get("thoughts", [])
if thoughts_raw is Array:
for td in thoughts_raw:
if td is Dictionary:
thoughts.append(Thought.from_dict(td))
mood = clampf(float(d.get("mood", MOOD_BASE)), 0.0, 100.0)
_sulk_low_ticks = int(d.get("sulk_low_ticks", 0))
sulking = bool(d.get("sulking", false))
# Phase 9 — restore HP; default to HP_MAX for backward compat with pre-Phase-9 saves.
hp = clampf(float(d.get("hp", HP_MAX)), 0.0, HP_MAX)
# Phase 9 — restore statuses.
statuses.clear()
var statuses_raw: Variant = d.get("statuses", [])
if statuses_raw is Array:
for sd in statuses_raw:
if sd is Dictionary:
statuses.append(Status.from_dict(sd))
# Phase 12 — restore wet / cold accumulators; default 0 for pre-Phase-12 saves.
_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")
if saved_skills is Dictionary:
for raw_key in saved_skills.keys():
var skill := StringName(raw_key)
if skill in ALL_SKILLS:
skills[skill] = clampi(int(saved_skills[raw_key]), 0, 10)
_name_label.text = pawn_name
_state_label.text = Strings.t(&"pawn.state.walking") if is_walking() else Strings.t(&"pawn.state.idle")
position = _tile_to_world(tile)
queue_redraw()
Audit.log("pawn", "%s restored at %s (walking=%s, path len=%d)" % [pawn_name, tile, is_walking(), _path.size()])
# ── sim tick: orchestrate AI, then advance walk ─────────────────────────────
func _on_sim_tick(_tick_number: int) -> void:
# Phase 7 — decay hunger before orchestration so the AI sees the updated value
# this tick and can immediately seek food once hunger < 30.
hunger = maxf(0.0, hunger - HUNGER_DECAY_PER_TICK)
# Phase 8 — decay sleep before orchestration so the AI sees the updated value
# this tick and can immediately seek a bed once sleep < 30.
sleep = maxf(0.0, sleep - SLEEP_DECAY_PER_TICK)
# Phase 8 — process thoughts AFTER hunger/sleep decay so is_hungry() / is_tired()
# reflect the freshly-decayed values when _sync_persistent_thought fires.
_process_thoughts()
# Phase 9 — process statuses AFTER thoughts (bleed damage may alter mood via
# future "recently injured" thought), BEFORE AI so is_incapacitated() is current.
_process_statuses()
_orchestrate_ai()
_advance_walk()
# Phase 4 — the carry indicator changes when PICKUP/DEPOSIT toils mutate
# carried_item directly. Cheapest reliable redraw hook is here.
queue_redraw()
func _orchestrate_ai() -> void:
# Phase 3: ask Decision for a job when the pawn is idle OR when a forced job
# is queued (forced_job preempts the current job — player override semantics).
# Decision's layer 2 consumes the forced_job slot; layer 4 falls back to work
# providers when no override is queued.
if job_runner == null:
return
if forced_job != null or not job_runner.has_job():
var next_job = Decision.pick_next_job(self, World.work_providers)
if next_job != null:
job_runner.start_job(next_job)
# Tick the runner (a freshly-started job's first toil executes here in the
# same sim tick — WALK calls pawn.walk_along_path so _advance_walk below
# immediately starts moving on this tick).
if job_runner.has_job():
job_runner.tick()
func _advance_walk() -> void:
if not is_walking():
return
_step_progress += 1.0 / float(STEP_TICKS)
if _step_progress >= 1.0:
tile = _path[0]
_path.remove_at(0)
_step_progress = 0.0
if _path.is_empty():
_state_label.text = Strings.t(&"pawn.state.idle")
walk_completed.emit()
arrived_at_destination.emit(tile)
Audit.log("pawn", "%s arrived at %s" % [pawn_name, tile])
# ── render ──────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
var from_world := _tile_to_world(tile)
var next := _path[0] if is_walking() else tile
var to_world := _tile_to_world(next)
position = from_world.lerp(to_world, _step_progress)
func _draw() -> void:
# 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.
# draw_set_transform applies to all subsequent draw_* calls in this _draw.
draw_set_transform(Vector2.ZERO, PI / 2.0, Vector2.ONE)
draw_circle(Vector2.ZERO, 6.0, body_colour.lerp(Color(0.5, 0.5, 0.5), 0.6))
draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.4), 1.0)
# Reset transform so selection ring and carry indicator render upright.
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
else:
draw_circle(Vector2.ZERO, 6.0, body_colour)
# Dark outline ring.
draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.6), 1.0)
# Selection ring — drawn after body regardless of downed state.
if _selected:
draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 32, Color(1.0, 0.9, 0.2, 0.85), 2.0)
# Phase 4 — carry indicator: small coloured square at upper-right of body.
if carried_item != null:
var ci_hue := float(carried_item.item_type.hash() % 360) / 360.0
var ci_color := Color.from_hsv(ci_hue, 0.6, 0.85)
draw_rect(Rect2(6, -10, 7, 7), ci_color)
draw_rect(Rect2(6, -10, 7, 7), Color(0, 0, 0, 0.7), false, 1.0)
# ── helpers ─────────────────────────────────────────────────────────────────
func _tile_to_world(t: Vector2i) -> Vector2:
return Vector2(
t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
)