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. # ── 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. # ── 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 # 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 = {} 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) ## 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) # ── 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) 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, } 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) # 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) _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 )