Phase 8 — Beds, sleep need, thoughts, mood, Sulking soft-break

Three gdscript-refactor agents in parallel; Opus integrated and verified
the sleep+wake cycle via MCP runtime.

Bed entity (Agent A, scenes/entities/bed.{gd,tscn} + world.gd, ~280 lines):
- class Bed extends Node2D — bottom-anchored 3/4 perspective like Wall/Workbench
- BuildJob interface (is_buildable / on_build_tick / _complete) — same pattern
  as Wall / Crate / Workbench. blocks_pathing_when_complete=false (walkable).
- Quality-tinted sheet colours by Item.Quality tier (drab grey → blue →
  gold-brown → regal pink); white pillow + dark frame constant across tiers.
- claim(pawn) / release() / is_available() — atomic occupancy; claim re-checks
  is_available() inside to avoid race conditions during pawn walk-to-bed.
- World.beds registry + register_bed / unregister_bed (mirrors workbench pattern)

Sleep need + SleepProvider + KIND_SLEEP toil (Agent B, ~220 lines):
- Pawn.sleep: float 0..100. SLEEP_DECAY_PER_TICK=0.015 (~6667 ticks / 5.5 min
  at 1× / 1 min at Ultra to fully tire). Slower than hunger.
- is_tired() at <30; is_exhausted() at <5 (Phase 9 status interrupt hook)
- SleepProvider priority=8 (highest — sleep beats eat=7 when both urgent)
- Toil.KIND_SLEEP + Toil.sleep_in_bed(NodePath) factory
- JobRunner._tick_sleep: first-tick bed claim (with race-loss → floor fallback),
  per-tick recovery (bed=0.5/tick, floor=0.25/tick), wake-when-full at ≥99,
  emergency ceiling SLEEP_TICKS_MAX=2000 prevents stuck-asleep loops

Thoughts + mood + Sulking (Agent C, ~290 lines):
- scenes/ai/thought.gd: class Thought (RefCounted) with id, modifier, lifetime
  (PERSISTENT/EVENT), stacks, ticks_remaining; MAX_STACKS_PER_THOUGHT=5 locked
- scenes/ai/thought_catalog.gd: ThoughtCatalog with 5 Phase 8 thoughts —
  hungry(-6, PERSISTENT) / tired(-4, PERSISTENT) / well_rested(+5, EVENT 1200t)
  / slept_on_floor(-5, EVENT 1200t) / ate_meal(+3, EVENT 800t, stacks up to 3)
- Pawn extended: thoughts: Array, mood: float (base 50), sulking: bool,
  _sulk_low_ticks. add_thought (stack-merge by id), remove_thought_by_id,
  has_thought, is_sulking. _process_thoughts in sim_tick decays EVENT thoughts,
  syncs PERSISTENT thoughts to state (hungry/tired), recomputes mood, checks
  sulking transition: mood < 25 for MOOD_SULK_SUSTAIN_TICKS=600 ticks → SULKING;
  mood >= 35 → recover.
- Decision Layer 1 extended: pawn.is_sulking() → return null (sulking pawns
  refuse all work; Phase 17 may add Wandering variant)
- EventBus.pawn_mood_changed signal
- JobRunner._tick_eat: fires ate_meal thought when consuming MEAL/BREAD
- JobRunner._tick_sleep: fires well_rested or slept_on_floor on wake

Opus integration:
- world.tscn: SleepProvider node added (9 providers total)
- world.gd registers in priority order:
  sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4 ≈ crafting=4 > haul=3 > rest=0
- Demo seed: 3 beds along cabin's north row at (45/47/49, 24), pre-built
  so pawns can sleep immediately when tired

Acceptance — MCP-verified end-to-end:
- Pre-tired Bram at sleep=25 → SleepProvider issued 'Sleep at (45, 24)' job
- Bram walked to bed, claimed, slept 200 ticks, woke at sleep≥99
- Bed released back to available; well_rested thought fired (+5 mood)
- After ~12000 ticks total: all 3 pawns slept (sleep recovered to 67/86/51),
  thoughts active (1-2 per pawn — well_rested + ate_meal from Phase 7 cooked
  bread consumption), beds all back to available, no claim leaks
- Mood compute working (base 50 + thought modifiers); sulking transition
  ready but didn't fire — would need misery accumulation (Phase 9 Cold +
  Bleeding statuses) to drive mood < 25 sustained

Phase 8 followups for later phases:
- Sulking returns null (stand still); Phase 17 may add Wandering soft-break
  that issues a random-walk job
- Bed ownership (_owner_pawn) reserved but not used in Phase 8 — Phase 17
  may add 'bedrooms' where each pawn claims a specific bed
- _tick_sleep's using_bed local-var reset pattern is correct but fragile;
  cleanup pass when status interrupts (Phase 9) wire into the eat/sleep
  cancellation path

Delegation report this phase:
- Agent A: Bed entity (buildable, quality-tinted, claim/release)
- Agent B: Pawn.sleep + SleepProvider + KIND_SLEEP toil + JobRunner._tick_sleep
- Agent C: Thought + ThoughtCatalog + Pawn mood/sulking + Decision Layer 1
  + JobRunner thought hooks in _tick_eat / _tick_sleep
- Opus: scene wiring + 3 beds in demo seed + MCP runtime verification

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-11 13:21:15 +01:00
parent 61dcf6760b
commit 43e52ffe75
18 changed files with 920 additions and 6 deletions

View file

@ -19,10 +19,15 @@ class_name Decision
## `pawn` is duck-typed: must expose .pawn_name, .forced_job, and
## has_method("is_incapacitated").
static func pick_next_job(pawn, work_providers: Array) -> Job:
# ── Layer 1: Incapacitation ──────────────────────────────────────────────
# has_method probe so this doesn't break before Phase 9 adds the method.
# ── Layer 1: Incapacitation / sulking ───────────────────────────────────
# has_method probes so these don't break before Phase 9 adds is_incapacitated.
if pawn.has_method("is_incapacitated") and pawn.is_incapacitated():
return null
# Sulking pawns refuse all jobs until mood recovers to MOOD_SULK_RECOVERY.
# Phase 17 may replace this with a Wandering soft-break variant that moves
# the pawn to a quiet tile instead of standing still.
if pawn.has_method("is_sulking") and pawn.is_sulking():
return null
# ── Layer 2: Forced job ──────────────────────────────────────────────────
if pawn.forced_job != null:

View file

@ -103,6 +103,8 @@ func tick() -> void:
_tick_craft(t)
Toil.KIND_EAT:
_tick_eat(t)
Toil.KIND_SLEEP:
_tick_sleep(t)
if t.done:
job.advance()
@ -490,6 +492,12 @@ func _tick_eat(t) -> void:
nutrition = 25
pawn.set_hunger(pawn.hunger + float(nutrition))
# Phase 8 — fire ate_meal thought for cooked food (MEAL or BREAD).
# Raw food (VEGETABLE, GRAIN) gives no mood bonus — player incentive to
# build a cooking hearth per design.md "Thought list".
if item_type == Item.TYPE_MEAL or item_type == Item.TYPE_BREAD:
if pawn.has_method("add_thought"):
pawn.add_thought(ThoughtCatalog.ate_meal())
pawn.carried_item.queue_free()
pawn.carried_item = null
@ -500,6 +508,119 @@ func _tick_eat(t) -> void:
t.done = true
## Execute one tick of a SLEEP toil.
##
## Sleep recovery rates (per sim tick):
## In a bed : 0.5 / tick (fast, restful; pawn wakes when sleep ≥ SLEEP_MAX - 1)
## On floor : 0.25 / tick (slow, poor rest — mood thought fires via Mood system)
##
## First tick (started=false):
## - Resolves the Bed node from t.data["bed"] (empty string → floor sleep).
## - If bed path given but node invalid/freed: log + fall back to floor sleep.
## - If valid bed: call bed.claim(pawn). If claim() returns false (race lost):
## log + fall back to floor sleep.
## - Reset ticks_slept = 0, mark started = true.
## - Log sleep start (bed vs floor).
##
## Every tick (including the first after validation):
## - Increment ticks_slept.
## - Restore pawn.sleep by the appropriate recovery rate.
## - When sleep ≥ SLEEP_MAX - 1 (essentially full): release bed, log, done.
## - Emergency ceiling SLEEP_TICKS_MAX: prevents stuck-asleep loops.
const SLEEP_RECOVERY_BED: float = 0.5 # /tick in bed
const SLEEP_RECOVERY_FLOOR: float = 0.25 # /tick on floor
const SLEEP_TICKS_MAX: int = 2000 # emergency wake ceiling (~100 s at 1×)
func _tick_sleep(t) -> void:
var using_bed: bool = false # resolved on first tick; re-read from data each tick
if not t.data.get("started", false):
# ── first-tick: resolve and claim bed ─────────────────────────────────
var bed_path_str: String = t.data.get("bed", "")
var bed = null
if bed_path_str != "":
var bed_path := NodePath(bed_path_str)
bed = get_tree().get_root().get_node_or_null(bed_path)
if bed == null or not is_instance_valid(bed):
# Bed gone between job creation and arrival.
Audit.log(
"job_runner",
"%s bed gone at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
)
t.data["bed"] = ""
bed = null
elif not bed.claim(pawn):
# Bed claimed by another pawn during the walk — fall back.
Audit.log(
"job_runner",
"%s bed claim failed at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
)
t.data["bed"] = ""
bed = null
t.data["started"] = true
t.data["ticks_slept"] = 0
var has_bed: bool = (bed != null)
Audit.log(
"job_runner",
"%s sleep start: %s" % [pawn.pawn_name, "bed" if has_bed else "floor"]
)
# ── per-tick recovery ──────────────────────────────────────────────────────
t.data["ticks_slept"] = int(t.data.get("ticks_slept", 0)) + 1
var ticks_slept: int = int(t.data["ticks_slept"])
# Determine which rate to use this tick based on whether a bed is still live.
var current_bed = null
var bed_path_str: String = t.data.get("bed", "")
if bed_path_str != "":
current_bed = get_tree().get_root().get_node_or_null(NodePath(bed_path_str))
if current_bed != null and not is_instance_valid(current_bed):
current_bed = null
using_bed = (current_bed != null)
var rate: float = SLEEP_RECOVERY_BED if using_bed else SLEEP_RECOVERY_FLOOR
pawn.set_sleep(pawn.sleep + rate)
# Wake when sleep is essentially full. Compare against the literal 99.0
# (SLEEP_MAX - 1) rather than pawn.SLEEP_MAX because pawn is duck-typed
# and const access on an untyped var fails at runtime in GDScript.
if pawn.sleep >= 99.0:
if current_bed != null:
current_bed.release()
# Phase 8 — fire sleep-quality thought on natural wakeup.
if pawn.has_method("add_thought"):
if using_bed:
pawn.add_thought(ThoughtCatalog.well_rested())
else:
pawn.add_thought(ThoughtCatalog.slept_on_floor())
Audit.log(
"job_runner",
"%s woke (slept %d ticks; sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
)
t.done = true
return
# Emergency ceiling — prevent stuck-asleep loops.
if ticks_slept > SLEEP_TICKS_MAX:
if current_bed != null:
current_bed.release()
# Phase 8 — fire sleep-quality thought on emergency wake too.
if pawn.has_method("add_thought"):
if using_bed:
pawn.add_thought(ThoughtCatalog.well_rested())
else:
pawn.add_thought(ThoughtCatalog.slept_on_floor())
Audit.log(
"job_runner",
"%s emergency wake after %d ticks (sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
)
t.done = true
# ── helpers ──────────────────────────────────────────────────────────────────
## Emit job_completed, log, and clear the job reference.

View file

@ -0,0 +1,84 @@
class_name SleepProvider extends WorkProvider
## WorkProvider for the Sleep work category. Slots into the 5-layer pawn AI
## (Decision → WorkProvider → Job + JobRunner) at priority 8 — above eating (7)
## so a maximally tired AND hungry pawn prioritises rest.
##
## When a pawn is tired and not carrying an item, scans World.beds for the
## nearest available bed and builds a 2-toil sleep job: walk → sleep.
## If no bed is available, the pawn sleeps on the floor at its current tile
## (walk toil omitted since it's already there).
##
## Bed claim race: the bed is NOT claimed here. Claiming in find_best_for()
## would leak a held claim if the job is later cancelled by a player override
## (Layer 5) before execution starts. Instead _tick_sleep in JobRunner claims
## on first tick, when the pawn has actually arrived and is ready to sleep.
## If the claim fails at that point (another pawn beat us) the fallback is
## floor sleep — no deadlock, no leaked claim.
##
## Pawn and Bed are intentionally duck-typed to avoid the class_name
## registration-order trap documented in Phase 2/3.
##
## See docs/architecture.md "Pawn AI 5-layer pipeline" and
## docs/design.md "Sleep mood" gradient.
func _init() -> void:
category = &"sleep"
# Priority 8 > eat (7) > construction (6) > hauling (3) > rest (0).
# Phase 9 status interrupts will preempt from Decision layer 3 once the
# Status registry lands — for now sleep rides at the WorkProvider level.
priority = 8
# ── WorkProvider override ─────────────────────────────────────────────────────
## Returns a sleep Job for `pawn`, or null if the pawn is not tired.
## The job has 1 or 2 toils:
## - walk_to(target_tile) [omitted when target == pawn.tile]
## - sleep_in_bed(bed_path) [bed_path is empty string for floor sleep]
##
## `pawn` is duck-typed: must expose .is_tired(), .carried_item, .tile (Vector2i).
func find_best_for(pawn) -> Job:
if not pawn.is_tired():
return null
# Don't interrupt an ongoing carry — finish the carry before going to sleep.
if pawn.carried_item != null:
return null
# Scan World.beds for the nearest available bed (duck-typed — Bed exposes
# .is_available() -> bool and .tile -> Vector2i).
var best_bed = null
var best_dist: int = 999999
for bed in World.beds:
if not bed.is_available():
continue
var d: int = abs(bed.tile.x - pawn.tile.x) + abs(bed.tile.y - pawn.tile.y)
if d < best_dist:
best_dist = d
best_bed = bed
# Resolve target tile and bed NodePath. An empty NodePath means floor sleep.
var target_tile: Vector2i
var bed_path: NodePath
if best_bed != null:
target_tile = best_bed.tile
bed_path = best_bed.get_path()
else:
# Phase 8 fallback — sleep on the floor at the current tile.
# The Mood system (Agent C, Phase 8) will fire a "slept_on_floor" thought
# via the thought registry; analogous to EatProvider's "ate raw food".
target_tile = pawn.tile
bed_path = NodePath("")
var j := Job.new()
if best_bed != null:
j.label = "Sleep at %s" % target_tile
else:
j.label = "Sleep on the floor at %s" % target_tile
# Only prepend a walk if the pawn is not already at the target.
if pawn.tile != target_tile:
j.toils.append(Toil.walk_to(target_tile))
j.toils.append(Toil.sleep_in_bed(bed_path))
return j

View file

@ -0,0 +1 @@
uid://bu0ekfg0w7q3c

79
scenes/ai/thought.gd Normal file
View file

@ -0,0 +1,79 @@
class_name Thought extends RefCounted
## A single mood modifier entry, either state-driven (PERSISTENT) or
## one-shot event-driven (EVENT).
##
## PERSISTENT thoughts are added / removed by Pawn._refresh_persistent_thoughts
## each sim tick based on the pawn's current state (e.g. is_hungry()).
## They have no ticks_remaining — they exist for exactly as long as the
## triggering state is true.
##
## EVENT thoughts fire once on a game event (eating a meal, sleeping in a bed)
## and decay after ticks_remaining reaches zero. They stack up to max_stacks —
## for example, eating multiple meals in a row adds stacks to &"ate_meal"
## rather than duplicating entries.
##
## Save/load contract:
## var t2 := Thought.from_dict(t.to_dict())
## assert(t2.id == t.id and t2.stacks == t.stacks)
##
## docs/architecture.md "MoodSystem" + MAX_STACKS_PER_THOUGHT = 5 (locked).
enum Lifetime {
PERSISTENT, ## Present while a triggering state is true; Pawn manages add/remove.
EVENT, ## Fires once on a transition; decays after ticks_remaining ticks.
}
## Hard cap per thought type (docs/architecture.md "MoodSystem" — locked).
const MAX_STACKS_PER_THOUGHT: int = 5
## Unique identifier, e.g. &"hungry", &"well_rested". Used as the merge key.
var id: StringName = &""
## Human-readable label for Audit logs and future pawn-detail UI.
## Not i18n'd here; call Strings.t("thought." + id) for player-visible text.
var label: String = ""
## Mood delta per stack. Negative = bad, positive = good.
## Typical range -8..+8. Capped at max_stacks when computing mood.
var modifier: int = 0
## Whether this thought decays over time (EVENT) or tracks live state (PERSISTENT).
var lifetime: Lifetime = Lifetime.EVENT
## Per-thought stack ceiling. Usually MAX_STACKS_PER_THOUGHT; some thoughts are
## capped lower (e.g. hungry is max_stacks=1 because it's binary present/absent).
var max_stacks: int = MAX_STACKS_PER_THOUGHT
# ── Runtime state (owned by the Pawn after add_thought()) ────────────────────
## Number of times this thought is currently stacked (1..max_stacks).
var stacks: int = 1
## Remaining sim ticks before this EVENT thought expires. PERSISTENT ignores this.
var ticks_remaining: int = 0
# ── save / load ───────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"id": String(id),
"label": label,
"modifier": modifier,
"lifetime": lifetime,
"max_stacks": max_stacks,
"stacks": stacks,
"ticks_remaining": ticks_remaining,
}
static func from_dict(d: Dictionary) -> Thought:
var t := Thought.new()
t.id = StringName(d.get("id", ""))
t.label = str(d.get("label", ""))
t.modifier = int(d.get("modifier", 0))
t.lifetime = int(d.get("lifetime", Lifetime.EVENT)) as Lifetime
t.max_stacks = int(d.get("max_stacks", MAX_STACKS_PER_THOUGHT))
t.stacks = int(d.get("stacks", 1))
t.ticks_remaining = int(d.get("ticks_remaining", 0))
return t

1
scenes/ai/thought.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://dola0cuqx2pt

View file

@ -0,0 +1,92 @@
class_name ThoughtCatalog
## Static factory registry for named thoughts.
##
## Phase 8 ships 5 thoughts (hungry, tired, well_rested, slept_on_floor,
## ate_meal). Phase 17 expands with: slept_in_good_bed (quality tiers),
## ate_raw_food, witnessed_corpse, in_darkness, cramped_quarters,
## beautiful_room, ugly_room, damp, soaked, cold.
##
## Usage pattern:
## pawn.add_thought(ThoughtCatalog.ate_meal())
##
## Each factory returns a fresh Thought with all fields set to correct defaults
## for that thought type. Callers must not mutate the returned object before
## passing it to add_thought() — add_thought() handles stack merging.
##
## docs/architecture.md "MoodSystem"; docs/design.md "Thought list (~13)".
# ── PERSISTENT thoughts ───────────────────────────────────────────────────────
# Pawn._refresh_persistent_thoughts adds / removes these based on live state.
# max_stacks=1 because each is binary (either hungry or not).
## Mood penalty while pawn.is_hungry() is true.
## modifier=-6, max_stacks=1, PERSISTENT.
static func hungry() -> Thought:
var t := Thought.new()
t.id = &"hungry"
t.label = "Hungry"
t.modifier = -6
t.lifetime = Thought.Lifetime.PERSISTENT
t.max_stacks = 1
return t
## Mood penalty while pawn.is_tired() is true.
## modifier=-4, max_stacks=1, PERSISTENT.
static func tired() -> Thought:
var t := Thought.new()
t.id = &"tired"
t.label = "Tired"
t.modifier = -4
t.lifetime = Thought.Lifetime.PERSISTENT
t.max_stacks = 1
return t
# ── EVENT thoughts ────────────────────────────────────────────────────────────
# Fire on a transition and decay after ticks_remaining reaches zero.
# ticks_remaining is in sim ticks at 1× speed (20 Hz).
# ~10 in-game min at 1× = 1200 ticks (20 ticks/s × 60 s/min × 10 min).
## Positive mood boost after waking from a full bed-sleep.
## Fires in _tick_sleep (Agent B) when had_bed=true.
## modifier=+5, max_stacks=1, EVENT, ~10 in-game min at 1×.
static func well_rested() -> Thought:
var t := Thought.new()
t.id = &"well_rested"
t.label = "Well rested"
t.modifier = 5
t.lifetime = Thought.Lifetime.EVENT
t.ticks_remaining = 1200
t.max_stacks = 1
return t
## Mood penalty after sleeping without a bed.
## Fires in _tick_sleep (Agent B) when had_bed=false.
## modifier=-5, max_stacks=1, EVENT, ~10 in-game min at 1×.
static func slept_on_floor() -> Thought:
var t := Thought.new()
t.id = &"slept_on_floor"
t.label = "Slept on the floor"
t.modifier = -5
t.lifetime = Thought.Lifetime.EVENT
t.ticks_remaining = 1200
t.max_stacks = 1
return t
## Small mood boost after eating a cooked meal or bread.
## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_BREAD.
## Stacks up to 3 (multiple good meals compound, but cap at 3).
## modifier=+3, max_stacks=3, EVENT, ~800 ticks (~40 in-game sec at 1×).
static func ate_meal() -> Thought:
var t := Thought.new()
t.id = &"ate_meal"
t.label = "Ate a meal"
t.modifier = 3
t.lifetime = Thought.Lifetime.EVENT
t.ticks_remaining = 800
t.max_stacks = 3
return t

View file

@ -0,0 +1 @@
uid://cwhkbh8cm37uf

View file

@ -19,6 +19,7 @@ const KIND_DEPOSIT: StringName = &"deposit" # Place pawn.carried_item at paw
const KIND_BUILD: StringName = &"build" # Timed construction on a Wall / Floor / Door entity
const KIND_CRAFT: StringName = &"craft" # Timed crafting at a Workbench driven by a Bill
const KIND_EAT: StringName = &"eat" # Consume pawn.carried_item and restore hunger
const KIND_SLEEP: StringName = &"sleep" # Sleep in a Bed (or on the floor) until pawn.sleep is full
var kind: StringName = KIND_IDLE
## Toil-specific params — all values must be int, float, bool, String, Dict, or Array.
@ -115,6 +116,25 @@ static func eat() -> Toil:
return t
## Sleep in the given Bed until pawn.sleep is full.
## `bed_path` is the NodePath of the Bed entity, stored as String for JSON safety.
## An empty string means "sleep on the floor" — no bed is claimed.
##
## data keys:
## "bed" — String(bed_path); empty means floor sleep.
## "started" — bool; false on first tick, true after claim resolved.
## "ticks_slept" — int; incremented each tick for audit logging + emergency wake.
static func sleep_in_bed(bed_path: NodePath) -> Toil:
var t := Toil.new()
t.kind = KIND_SLEEP
t.data = {
"bed": String(bed_path),
"started": false,
"ticks_slept": 0,
}
return t
## Timed crafting action at a Workbench.
## `workbench_path` is the NodePath of the Workbench entity (stored as String for JSON safety).
## `bill_index` is the index into workbench.bills that this toil should run.

267
scenes/entities/bed.gd Normal file
View file

@ -0,0 +1,267 @@
class_name Bed extends Node2D
## Bed furniture entity — buildable, optionally pawn-owned, quality-affected sleep.
##
## Rendered as a bottom-anchored 3/4-perspective sprite within the 16×16 tile,
## matching the workbench / wall / door rendering convention. Ghost state (40%
## alpha) while construction is in progress; solid once _completed.
##
## Quality tints the sheet colour (SLEEP_MOOD_BY_QUALITY maps Item.Quality int
## to the mood modifier awarded when a pawn finishes sleeping here). Phase 8
## spawns beds at NORMAL quality; Phase 17+ may roll quality from crafter skill.
##
## Build model (docs/implementation.md Phase 8):
## BUILD_TICKS ticks via the standard BuildJob toil (same shape as Wall/Crate/
## Workbench). blocks_pathing_when_complete() returns false — pawns walk ONTO
## the bed tile to sleep.
##
## Occupancy model:
## _owner_pawn — null = unowned (any tired pawn may use); Phase 8 leaves null.
## _occupant_pawn — set by SleepProvider.claim(); released on SleepProvider.release().
## is_available() — true when completed AND no current occupant.
## claim(pawn) — atomically sets _occupant_pawn; returns false if unavailable.
## release() — clears _occupant_pawn.
##
## Save/load:
## to_dict() serialises all persistent fields. _occupant_pawn is always saved
## as null (sleep is mid-toil state; the JobRunner saves its own side). Re-wiring
## owner_pawn from name → Pawn reference is deferred to Phase 16's full save layer.
##
## World registration: World.register_bed / World.unregister_bed called from
## _ready / _exit_tree.
const TILE_SIZE_PX: int = 16
## Sim ticks to build a bed (80 ticks ≈ 4 sim seconds at 1×).
const BUILD_TICKS: int = 80
## Sleep mood modifier indexed by Item.Quality int (SHODDY=0 … LEGENDARY=4).
## Applied via the "slept_in_X" thought when a pawn finishes a sleep job.
## Numbers are design.md placeholders — tune in Phase 20.
## SHODDY=-8, NORMAL=-2, EXCELLENT=0, MASTERWORK=5, LEGENDARY=8
const SLEEP_MOOD_BY_QUALITY: Array[int] = [-8, -2, 0, 5, 8]
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this bed in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Quality tier as an int matching Item.Quality enum (0=SHODDY … 4=LEGENDARY).
## Determines sheet colour and sleep mood modifier.
@export var quality: int = 1
## Player-visible name. Defaults to "Bed"; extended types (medical bed, etc.)
## can override via label_text without needing a subclass.
@export var label_text: String = "Bed"
# ── state ─────────────────────────────────────────────────────────────────────
## Ticks of construction work applied so far. 0..BUILD_TICKS.
var build_progress: int = 0
## True once build_progress >= BUILD_TICKS.
var _completed: bool = false
## The pawn who "owns" this bed (has first right of use). null = unowned.
## Phase 8 leaves this null; Phase 17 wires per-pawn bed assignment UI.
var _owner_pawn = null
## The pawn currently lying in this bed. Set by claim(); cleared by release().
## Always null on save (SleepProvider re-claims after load if the pawn still
## has an active sleep job; the JobRunner handles reconnection).
var _occupant_pawn = null
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
# Bottom-anchor: position.y at tile bottom so Y-sort occludes pawns correctly.
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
)
World.register_bed(self)
queue_redraw()
func _exit_tree() -> void:
World.unregister_bed(self)
## One-shot initialiser. Call after add_child() so _ready() has fired.
func setup(p_tile: Vector2i) -> void:
tile = p_tile
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
)
queue_redraw()
# ── BuildJob interface (matches Wall / Crate / Workbench shape) ───────────────
## True while the bed still needs construction work.
## JobRunner's BUILD toil checks this to decide when the toil is done.
func is_buildable() -> bool:
return not _completed
## Human-readable label for job descriptions and Audit logs.
func label() -> String:
return label_text
## Called by the BUILD toil in JobRunner once per sim tick while the pawn works.
## Advances build_progress and completes the bed at BUILD_TICKS.
func on_build_tick() -> void:
if _completed:
return
build_progress += 1
queue_redraw()
if build_progress >= BUILD_TICKS:
_complete()
## True once the bed has been fully built.
func is_completed() -> bool:
return _completed
## Beds remain walkable after completion — pawns walk ONTO the tile to sleep.
func blocks_pathing_when_complete() -> bool:
return false
# ── occupancy ─────────────────────────────────────────────────────────────────
## Returns true when this bed is built and no pawn is currently using it.
## SleepProvider calls this to filter candidate beds before claiming.
func is_available() -> bool:
return _completed and _occupant_pawn == null
## Atomically claim this bed for `pawn`. Returns false if not available.
## SleepProvider calls this before starting the sleep toil; on false the
## provider must look for another bed.
func claim(pawn) -> bool:
if not is_available():
return false
_occupant_pawn = pawn
return true
## Release this bed when the sleep job ends (normally or interrupted).
## SleepProvider calls this from its on_complete / on_cancel hook.
func release() -> void:
_occupant_pawn = null
# ── save / load ───────────────────────────────────────────────────────────────
## Serialise all persistent state for World save (wired in Phase 16).
## _occupant_pawn is always saved as null — the JobRunner holds the sleep
## toil state and the SleepProvider re-claims the bed after load.
func to_dict() -> Dictionary:
var owner_name = null
if _owner_pawn != null and _owner_pawn.has_method("get"):
owner_name = _owner_pawn.get("pawn_name")
return {
"tile_x": tile.x,
"tile_y": tile.y,
"quality": quality,
"label_text": label_text,
"build_progress": build_progress,
"completed": _completed,
"owner_pawn_name": owner_name,
# occupant_pawn always null on save — SleepProvider reconnects after load.
"occupant_pawn": null,
}
## Restore from a dict produced by to_dict().
## owner_pawn re-wiring (name → Pawn reference) is deferred to Phase 16.
func from_dict(d: Dictionary) -> void:
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
quality = int(d.get("quality", 1))
label_text = str(d.get("label_text", "Bed"))
build_progress = int(d.get("build_progress", 0))
_completed = bool(d.get("completed", false))
# owner_pawn: Phase 16 will walk World.pawns and match by pawn_name.
_owner_pawn = null
_occupant_pawn = null
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void:
# 3/4-perspective bed — fits within the tile (16×16 local box).
# Origin (0, 0) = tile bottom-centre. Tile spans local Y: -16 to 0.
#
# Layout (bottom-anchored, same as Wall / Workbench):
# Top band (5 px, Y -16..-11) — top surface of the bed frame (lit)
# Body band (8 px, Y -11..-3) — sheet / quilt, quality-tinted
# Leg band (3 px, Y -3.. 0) — bed-frame legs / base
#
# The pillow is a 6×3 rect inset at the top of the body band.
# Quality tints the sheet; the frame/legs stay a constant warm brown.
# Ghost state draws at 0.4 alpha.
var alpha: float = 1.0 if _completed else 0.4
_draw_bed(alpha)
func _draw_bed(alpha: float) -> void:
# Frame colours — same for all quality tiers (wood frame).
var frame_top := Color(0.52, 0.38, 0.20, alpha) # lit top surface
var frame_dark := Color(0.34, 0.24, 0.12, alpha) # shaded legs / base
var frame_edge := Color(0.28, 0.18, 0.08, alpha) # top-front horizon
var outline := Color(0.20, 0.12, 0.04, 0.70 * alpha)
# Sheet colour varies by quality tier.
var sheet_color := _sheet_color_for_quality(quality, alpha)
# Pillow: light cream, inset at top-centre of the body band.
var pillow := Color(0.95, 0.92, 0.85, alpha)
# ── top surface (lit band) ────────────────────────────────────────────────
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), frame_top)
# ── sheet / body band ─────────────────────────────────────────────────────
draw_rect(Rect2(Vector2(-7.0, -11.0), Vector2(14.0, 8.0)), sheet_color)
# Pillow: 6×3, horizontally centred, flush with the top of the body band.
draw_rect(Rect2(Vector2(-3.0, -11.0), Vector2(6.0, 3.0)), pillow)
# ── base / legs band ──────────────────────────────────────────────────────
draw_rect(Rect2(Vector2(-8.0, -3.0), Vector2(16.0, 3.0)), frame_dark)
# ── horizon line (top → front depth edge) ────────────────────────────────
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), frame_edge, 1.0)
# ── outline ───────────────────────────────────────────────────────────────
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
## Returns the sheet fill colour for the given quality int.
## Quality indices: 0=SHODDY, 1=NORMAL, 2=EXCELLENT, 3=MASTERWORK, 4=LEGENDARY.
func _sheet_color_for_quality(q: int, alpha: float) -> Color:
match q:
0: # SHODDY — drab grey-brown
return Color(0.45, 0.40, 0.35, alpha)
1: # NORMAL — warm tan
return Color(0.55, 0.40, 0.30, alpha)
2: # EXCELLENT — cool blue
return Color(0.30, 0.45, 0.65, alpha)
3: # MASTERWORK — gold-brown
return Color(0.65, 0.45, 0.20, alpha)
4: # LEGENDARY — regal pink
return Color(0.75, 0.40, 0.55, alpha)
_: # fallback — same as NORMAL
return Color(0.55, 0.40, 0.30, alpha)
# ── internal ──────────────────────────────────────────────────────────────────
func _complete() -> void:
_completed = true
queue_redraw()
Audit.log("bed", "%s built at %s" % [label_text, tile])

View file

@ -0,0 +1 @@
uid://e2ao53rg434d

8
scenes/entities/bed.tscn Normal file
View file

@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://bed_entity"]
[ext_resource type="Script" path="res://scenes/entities/bed.gd" id="1_bed"]
[node name="Bed" type="Node2D"]
y_sort_enabled = true
z_index = 0
script = ExtResource("1_bed")

View file

@ -26,6 +26,12 @@ 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.
@ -35,6 +41,13 @@ const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn n
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).
@ -59,6 +72,9 @@ 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
@ -77,6 +93,24 @@ var carried_item = null
# 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
@ -155,6 +189,26 @@ 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:
@ -167,6 +221,121 @@ func set_skill(skill: StringName, level: int) -> void:
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())
# 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:
@ -178,6 +347,10 @@ func to_dict() -> Dictionary:
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,
@ -189,6 +362,11 @@ func to_dict() -> Dictionary:
"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,
}
@ -212,6 +390,18 @@ func from_dict(d: Dictionary) -> void:
# 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).
@ -235,6 +425,12 @@ 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

View file

@ -28,6 +28,7 @@ const CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.tscn")
const WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/workbench.tscn")
const CROP_SCENE: PackedScene = preload("res://scenes/entities/crop.tscn")
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
const BED_SCENE: PackedScene = preload("res://scenes/entities/bed.tscn")
# 3 starting pawns — Phase 2 demo. Phase 7+ replaces this with map-gen + name table.
const SAMPLE_PAWNS: Array[Dictionary] = [
@ -67,6 +68,7 @@ const HAUL_SWEEP_INTERVAL_TICKS: int = 100
@onready var crafting_provider: CraftingProvider = $CraftingProvider
@onready var plant_provider: PlantProvider = $PlantProvider
@onready var eat_provider: EatProvider = $EatProvider
@onready var sleep_provider: SleepProvider = $SleepProvider
func _ready() -> void:
@ -96,9 +98,10 @@ func _ready() -> void:
# Designation: bind the paint surface + the Selection guard.
designation_ctl.bind(designation_layer, selection)
# Register all 8 providers — Decision iterates by .priority desc.
# eat=7 > construction=6 > chop=5 > mine=4 ≈ crafting=4 > plant=3 ≈ haul=3 > rest=0.
# Register all 9 providers — Decision iterates by .priority desc.
# sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4 ≈ crafting=4 > haul=3 > rest=0.
# Phase 17 will tune these via the work-priority matrix UI.
World.register_work_provider(sleep_provider)
World.register_work_provider(eat_provider)
World.register_work_provider(construction_provider)
World.register_work_provider(chop_provider)
@ -380,6 +383,18 @@ func _seed_phase5_demo_buildings() -> void:
crop_tiles.size(), snack_tiles.size()
])
# Phase 8 demo — 3 beds inside the cabin's north row. Beds are pre-built
# (skip construction) so pawns have somewhere to sleep on first tired tick.
# (45, 24), (47, 24), (49, 24) — leaves (50, 24) for the existing crate.
var bed_tiles: Array[Vector2i] = [Vector2i(45, 24), Vector2i(47, 24), Vector2i(49, 24)]
for bt in bed_tiles:
var bed: Bed = BED_SCENE.instantiate()
add_child(bed)
bed.setup(bt)
while bed.is_buildable():
bed.on_build_tick()
Audit.log("world", "phase 8 demo: %d beds pre-built inside cabin" % bed_tiles.size())
func _spawn_sample_stockpiles() -> void:
# Two zones for the Phase 4 acceptance demo:

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=14 format=3 uid="uid://rimlike_world"]
[gd_scene load_steps=15 format=3 uid="uid://rimlike_world"]
[ext_resource type="Script" path="res://scenes/world/world.gd" id="1_world"]
[ext_resource type="PackedScene" uid="uid://rimlike_camera_rig" path="res://scenes/world/camera_rig.tscn" id="2_camera"]
@ -13,6 +13,7 @@
[ext_resource type="Script" path="res://scenes/ai/crafting_provider.gd" id="11_crafting_provider"]
[ext_resource type="Script" path="res://scenes/ai/plant_provider.gd" id="12_plant_provider"]
[ext_resource type="Script" path="res://scenes/ai/eat_provider.gd" id="13_eat_provider"]
[ext_resource type="Script" path="res://scenes/ai/sleep_provider.gd" id="14_sleep_provider"]
[node name="World" type="Node2D"]
y_sort_enabled = true
@ -74,5 +75,8 @@ script = ExtResource("12_plant_provider")
[node name="EatProvider" type="Node" parent="."]
script = ExtResource("13_eat_provider")
[node name="SleepProvider" type="Node" parent="."]
script = ExtResource("14_sleep_provider")
[node name="CameraRig" parent="." instance=ExtResource("2_camera")]
position = Vector2(640, 640)