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 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 = "" 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 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 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] 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 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") 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. func take_damage(amount: float, source: String = "") -> void: hp = maxf(0.0, hp - amount) 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() ## 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]) ## 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()) # 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally). if dirty: _recompute_mood() # 4. Sulking transitions. _process_sulking() ## 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). ## ## 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: 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: # 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() # ── 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 { "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, } 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)) # 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: # Body disc — colour derived deterministically from pawn name so each pawn # is visually distinct without any art dependency. var hue := float(pawn_name.hash() % 360) / 360.0 var body_colour := Color.from_hsv(hue, 0.7, 0.85) 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 )