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>
519 lines
20 KiB
GDScript
519 lines
20 KiB
GDScript
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 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
|
||
|
||
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 (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])
|
||
|
||
|
||
# ── 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
|
||
)
|