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") ## Slice-1 pawn sprite helper. Preloaded (not class_name) because the global ## class registry isn't reliable during cold-start parse for sibling scripts. const _PAWN_SPRITE_FRAMES: Script = preload("res://scenes/pawn/pawn_sprite_frames.gd") 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 0–10. 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 = "" ## Last-known facing direction as a unit Vector2i (one of (0,1) down, (-1,0) ## left, (1,0) right, (0,-1) up). Updated when walking; persists when idle ## so the idle sprite faces the last direction walked. Defaults to down. var facing: Vector2i = Vector2i(0, 1) 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 17 — per-pawn work-priority matrix. Maps WorkProvider.category # (StringName) → priority level 1..4 (1 = Critical, 4 = Low), 0 = OFF. # Decision layer filters work providers by `pawn.work_priorities.get(p.category, 3)` # so a missing entry defaults to NORMAL (3). # UI matrix (Agent C) writes here on tap-to-cycle. Survives save/load via # to_dict / from_dict. var work_priorities: Dictionary = { &"construction": 3, &"chop": 3, &"plant": 3, &"mine": 3, &"crafting": 3, &"haul": 3, &"clean": 3, &"doctor": 3, # Needs categories (rest/eat/sleep) are not player-configurable — they # fire from need thresholds, not from a priority cell. } # 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 0–100. 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 (0–100). # _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 ## AnimatedSprite2D child painted with peasant atlases. Built in _ready(); ## animation switched by _update_anim() each _process tick. var _sprite: AnimatedSprite2D = null 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) # Sprite mount is deferred to setup() / from_dict() because the atlas pick # depends on pawn_name (deterministic name-hash), which isn't assigned yet # when _ready() fires. See _mount_sprite() below. 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) # Slice-1 character sprite: depends on pawn_name, so mount here (not _ready). _mount_sprite() Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile]) ## Build the AnimatedSprite2D child from the peasant atlas trio picked ## deterministically from pawn_name. Idempotent — safe to call from setup() ## AND from_dict() (the save-load path also re-enters setup). func _mount_sprite() -> void: if _sprite != null: _sprite.queue_free() _sprite = null var atlases := _atlas_for_pawn(self) var sf: SpriteFrames = _PAWN_SPRITE_FRAMES.build(atlases) _sprite = AnimatedSprite2D.new() _sprite.name = "Sprite" _sprite.sprite_frames = sf _sprite.centered = true _sprite.offset = Vector2(0, -8) # bottom-anchor: feet ≈ tile bottom edge # show_behind_parent lets the sprite render at the same z_index as the # parent Pawn (so it stays above the floor TileMap), while ALSO drawing # beneath the parent's _draw() callback — so selection ring + carry # indicator overlay on top. z_index=-1 would sink below the floor too. _sprite.show_behind_parent = true _sprite.play(&"idle_down") add_child(_sprite) # ── 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 work-speed penalty fraction from the SICK status (0.0 if not sick). ## severity 1 → 0.25 penalty (75% speed), severity 2 → 0.50, severity 3 → 0.75. ## The resulting multiplier is clamped to at least 0.25 so pawns never stop ## working entirely from illness alone (design.md — sick pawns work slowly, not stop). func _sick_speed_penalty() -> float: for s in statuses: if s.kind == Status.Kind.SICK: return clampf(0.25 * float(s.severity), 0.0, 0.75) return 0.0 ## 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: 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 corpse == null or 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), "facing_x": facing.x, "facing_y": facing.y, # Phase 17 — per-pawn work-priority matrix. Keys stored as plain Strings for # JSON round-trip safety (StringName keys survive the cast back via StringName()). "work_priorities": _serialise_work_priorities(), } 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))) facing = Vector2i(int(d.get("facing_x", 0)), int(d.get("facing_y", 1))) # Re-mount sprite now that pawn_name is set (atlas pick is name-hash driven). _mount_sprite() _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) # Phase 17 — restore work_priorities; default to 3 (NORMAL) for missing keys # so pre-Phase-17 saves and unknown categories degrade gracefully. var saved_priorities: Variant = d.get("work_priorities") if saved_priorities is Dictionary: for raw_key in saved_priorities.keys(): var cat := StringName(raw_key) if work_priorities.has(cat): work_priorities[cat] = clampi(int(saved_priorities[raw_key]), 0, 4) _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()]) ## Serialise work_priorities as plain String keys for JSON round-trip safety. ## Only serialise categories in the known default set; unknown keys are skipped ## so save files stay forward-compatible when categories are added in future phases. func _serialise_work_priorities() -> Dictionary: var out: Dictionary = {} for cat in work_priorities.keys(): out[String(cat)] = int(work_priorities[cat]) return out # ── 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_decay buff (summer): multiplier > 1.0 → faster depletion. sleep = maxf(0.0, sleep - SLEEP_DECAY_PER_TICK * Storyteller.get_buff_multiplier(&"sleep_decay")) # 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: var next_tile: Vector2i = _path[0] # Guard: next tile may have become impassable mid-walk (e.g. another # pawn finished a wall on it). Walking onto it would strand us on a # non-walkable cell with no escape. Abort the walk; Decision reroutes. if World.pathfinder != null and not World.pathfinder.is_walkable(next_tile): _path.clear() _step_progress = 0.0 if job_runner != null: job_runner.cancel_job() Audit.log("pawn", "%s walk aborted: %s became impassable" % [pawn_name, next_tile]) return var delta := next_tile - tile if delta != Vector2i.ZERO: facing = _canonical_facing(delta) tile = next_tile _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) _update_anim() ## Pick the right animation each tick based on walk state, facing, and downed. ## Called from _process(). Cheap: only calls _sprite.play() when the target ## animation differs from the current one (AnimatedSprite2D restarts from ## frame 0 on every play() call, so we must guard). func _update_anim() -> void: if _sprite == null: return if is_downed(): if _sprite.animation != &"dead": _sprite.play(&"dead") return var prefix: StringName = &"walk" if is_walking() else &"idle" var dir_suffix: StringName = _facing_suffix() var target: StringName = StringName("%s_%s" % [prefix, dir_suffix]) if _sprite.animation != target: _sprite.play(target) ## Map `facing` Vector2i to the animation-name suffix (down/left/right/up). func _facing_suffix() -> StringName: if facing == Vector2i(0, -1): return &"up" if facing == Vector2i(-1, 0): return &"left" if facing == Vector2i(1, 0): return &"right" return &"down" func _draw() -> void: # Body is the AnimatedSprite2D child (see _ready). _draw() is now overlay-only. # Selection ring — drawn on top of the sprite (parent _draw runs after the # child's z_index=-1 sprite draws). if _selected: draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 32, Color(1.0, 0.9, 0.2, 0.85), 2.0) # Carry indicator — draw a shrunk version of the actual item shape at the # pawn's upper-right ("held out at chest height"). Uses Item.draw_item_shape # at 0.55× scale (the shapes are authored for a 12×12 box; 0.55× → ~7×7). # Falls back to a hue-hashed square if the item type isn't shape-registered. if carried_item != null: var ci_center := Vector2(7.0, -12.0) var ci_scale := 0.55 draw_set_transform(ci_center, 0.0, Vector2(ci_scale, ci_scale)) if not Item.draw_item_shape(self, carried_item.item_type, carried_item.subtype): 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, -6, 12, 12), ci_color) draw_rect(Rect2(-6, -6, 12, 12), Color(0, 0, 0, 0.7), false, 1.0) draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) # ── 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 ) ## Maps any tile-delta to a cardinal Vector2i facing direction. Prefers the ## axis with larger absolute magnitude; ties favor horizontal. Returns down ## (0, 1) for zero delta as a safe default. static func _canonical_facing(delta: Vector2i) -> Vector2i: if delta == Vector2i.ZERO: return Vector2i(0, 1) if abs(delta.x) >= abs(delta.y): return Vector2i(sign(delta.x), 0) if delta.x != 0 else Vector2i(0, 1) return Vector2i(0, sign(delta.y)) ## Returns the {idle, walk, dead} atlas trio for a pawn. Slice 1: always ## peasant, picked deterministically from name hash (mod 15, +1 for 001-015 ## naming). Slice 2 will branch on equipped armor (helm + cuirass + boots → ## knight atlas, etc.) at this single extension point. const _PEASANT_COUNT: int = 15 static func _atlas_for_pawn(pawn) -> Dictionary: var idx: int = (absi(pawn.pawn_name.hash()) % _PEASANT_COUNT) + 1 var n: String = "%03d" % idx return { "idle": load("res://art/sprites/characters/Character_%s_Idle.png" % n), "walk": load("res://art/sprites/characters/Character_%s_Walk.png" % n), "dead": load("res://art/sprites/characters/Character_%s_Dead.png" % n), }