rimlike/scenes/pawn/pawn.gd
megaproxy a1e5b38dd6 Phase 11 — Day/night cycle + Lighting (taken before Phase 9 per recommendation)
Three gdscript-refactor agents in parallel; Opus integrated and verified
the day-night transition + torch lighting via MCP runtime + screenshot.

Clock autoload (Agent A, autoload/clock.gd, ~138 lines):
- TICKS_PER_DAY = 4800 → 4 min/day at 1× / 48 s at Fast / 20 s at Ultra
- TICKS_PER_HOUR = 200 (so 60 min × ~3 ticks per minute)
- 4-phase day: night → dawn (5–7) → day (7–19) → dusk (19–22) → night
- darkness_factor() returns 0..1 with linear ramps across dawn/dusk
- phase_changed signal fires on phase transitions
- save_dict / apply_dict for save round-trip
- Boots at Day 1, 06:00 (mid-dawn for atmospheric start)
- Registered in project.godot autoload list (Opus)

Top-bar clock UI (Agent A):
- ClockLabel added to top_bar.tscn (center-anchored at ±80 px)
- _on_clock_refresh in top_bar.gd; early-out string compare to skip text
  assignments when unchanged (cheap per-tick)

Torch entity + lights registry (Agent B, scenes/entities/torch.{gd,tscn} +
workbench.gd + world.gd, ~210 lines):
- class Torch: buildable furniture, BUILD_TICKS=30, LIGHT_RADIUS=6
- Procedural radial gradient texture (64×64) generated at runtime with
  smoothstep falloff → no PNG dependency
- PointLight2D child with the gradient texture, warm fire tint, energy 1.2
- is_on / get_light_tile / get_light_radius duck-typed interface; same
  shape exposed by Workbench when label_text='Hearth' (HEARTH_LIGHT_RADIUS=5)
- World.light_sources registry + register/unregister + is_tile_lit(tile)
  (Manhattan distance, no occlusion — Phase 13 may add wall-occlusion)

CanvasModulate darkness + in_darkness thought (Agent C, ~30 lines mod +
new factory):
- DarkOverlay CanvasModulate node added to world.tscn (first child of
  World root so it tints all sibling layers + entities)
- world.gd._update_dark_overlay lerps DAY_TINT (white) ↔ NIGHT_TINT
  (0.20, 0.22, 0.40 deep cool blue) by Clock.darkness_factor() each tick
- ThoughtCatalog.in_darkness(): persistent, -3 mood, fires when
  darkness > 0.3 AND World.is_tile_lit(pawn.tile) is false
- Pawn._process_thoughts syncs in_darkness alongside hungry/tired

Opus integration:
- project.godot: Clock autoload registered
- world.tscn: DarkOverlay CanvasModulate node, plus the agent additions
- Demo seed: 2 torches inside cabin at (46, 26) + (49, 26), pre-built
- MCP-driven runtime test verified day→night transition + lighting
  effects:
  - Noon: world bright green, torches barely visible (over-bright at noon
    is minor polish — Phase 17 may scale torch energy by darkness)
  - Midnight: world deep blue/green tinted, torches cast yellow halos,
    Hearth ember glows orange, cabin interior warmly lit, exterior dark
- top_bar clock label updates each sim tick (early-out on no-change)

Phase 11 followups for later phases:
- Torch energy should scale with darkness — visible halos at noon are
  silly. Phase 17 will likely tie PointLight2D.energy to clamp(darkness,
  0.2, 1.0) so they're invisible at midday
- Wall-occlusion for light_map — Phase 13's room-detection BFS could
  treat completed wall tiles as occluders so light doesn't bleed through
- 'In darkness' thought currently treats ALL unlit cells as darkness;
  Phase 13's roof flag could differentiate 'indoors-dark' (different
  thought) from 'outdoors-dark'
- Light source visibility through CanvasModulate works correctly thanks
  to PointLight2D's additive blend mode

Acceptance — MCP-verified via play_scene + get_game_screenshot:
-  Day → Dusk → Night cycle visible (Clock.current_phase emits events)
-  CanvasModulate tints world deep blue at night
-  Torches cast visible yellow halos via PointLight2D additive blend
-  Hearth opts-in as a light source via label_text='Hearth' check
-  Top-bar clock shows 'Day N, HH:MM' format and updates each tick
-  in_darkness thought wires through _process_thoughts (would fire if
  a pawn were standing in an unlit night tile — demo didn't capture this
  specifically but the code path is verified)

Delegation report this phase:
- Agent A: Clock autoload + 4-phase day cycle + top-bar UI extension
- Agent B: Torch entity + PointLight2D + procedural radial texture +
  Workbench Hearth opt-in + World.light_sources registry
- Agent C: CanvasModulate world.tscn node + day/night colour lerp +
  in_darkness ThoughtCatalog entry + Pawn persistent thought sync
- Opus: Clock autoload registration in project.godot + 2 torches in
  demo seed + MCP runtime verification at midnight vs noon

~75% of Phase 11 GDScript was subagent-authored.

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

519 lines
20 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
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.
# ── 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
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.
## Used by Phase 9 status interrupts — not yet wired; exposed early for the
## save round-trip and future interrupt hook.
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 status interrupt hook — not yet wired.
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)
## 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())
# 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])
# ── 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())
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,
"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))
# 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()
_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)
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.
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
)