rimlike/scenes/entities/bed.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner,
wolf target now all survive reload via Bill.from_dict reconstruction,
_spawn_crop using setup(), and a new _post_load_resolve_references pass.

PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile.

CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil
and Workbench.deposited_inputs buffer. Cremation pyre now actually
consumes wood.

HaulingProvider: per-item haul_retry_count + haul_rejected after 3
orphan passes; new EventBus.stockpile_layout_changed resets rejects on
any player stockpile edit.

Storyteller: 14 stubbed event effects implemented. New buff registry
(add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives
seasonal/resource events. New request_pawn_spawn signal + WANDERER
table for arrivals. New SICK status + 3 mood thoughts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:06:55 +01:00

296 lines
13 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.

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 resolved in SaveSystem._post_load_resolve_references().
##
## 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]
## ElvGames House Interior atlas — 16×32 single-column bed crops. Each variant
## shows a complete narrow bed: head with rounded pillow + frame on top tile,
## body + wood foot on the bottom tile. See /tmp/bed_candidates.png from the
## 2026-05-12 visual pass for the visual diff that picked these coords.
##
## The sprite spans the bed's tile + the tile immediately south. The southern
## tile stays walkable in the pathfinder — pawns can pass through the visual
## foot of the bed — matching the Phase 4 "rocks are walkable" simplification.
## Bed body is now drawn procedurally (top-down view, 16×32, anchored at the
## foot). Earlier passes used FG_Interior atlas coords but the available bed
## sprites were either side-on chairs (32,22)/(35,22)/(38,22 — actually
## chair-with-cushion, mislabelled in the 2026-05-12 visual pass) or 3-tile-
## wide doubles. A clear top-down single is easier to read at a glance.
##
## Colour variant is picked by deterministic hash from tile so the same bed
## stays the same colour across sessions.
const _BED_VARIANT_BLANKET: Array[Color] = [
Color(0.85, 0.50, 0.25, 1.0), # warm tan / wool — saturated to survive torch tint
Color(0.25, 0.45, 0.85, 1.0), # cool blue — saturated
Color(0.85, 0.35, 0.55, 1.0), # rose — saturated
]
const _BED_FRAME_DARK: Color = Color(0.30, 0.20, 0.12, 1.0)
const _BED_FRAME_LIGHT: Color = Color(0.55, 0.38, 0.22, 1.0)
const _BED_PILLOW: Color = Color(0.94, 0.94, 0.90, 1.0)
const _BED_PILLOW_SHADE: Color = Color(0.78, 0.78, 0.74, 1.0)
const _BED_SHEET_SHADE: Color = Color(0.0, 0.0, 0.0, 0.18)
# ── 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"
## When true, this bed is designated as a medical treatment site.
## DoctorProvider (Phase 9) prefers medical beds over regular beds when
## choosing a destination for downed pawns. A small red cross is drawn over
## the pillow in _draw() so the player can tell medical beds apart at a glance.
@export var is_medical: bool = false
# ── 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
## Transient: set by from_dict() to the saved owner's pawn_name string.
## SaveSystem._post_load_resolve_references() walks World.pawns, matches by
## pawn_name, assigns _owner_pawn, then clears this field.
var _pending_owner_name: String = ""
# ── 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)
World.register_build_site(self)
queue_redraw()
func _exit_tree() -> void:
World.unregister_bed(self)
World.unregister_build_site(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
)
# Y-sort so a pawn standing south of the bed (foot tile) draws over the
# bed, while a pawn sleeping in the head tile draws under any overlay.
y_sort_enabled = true
# Drop the legacy Sprite2D child if a save from before procedural rendering
# left one around.
var prev := get_node_or_null("Sprite")
if prev != null:
prev.queue_free()
# ── 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 {
"class_id": &"bed",
"tile_x": tile.x,
"tile_y": tile.y,
"quality": quality,
"label_text": label_text,
"is_medical": is_medical,
"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 is re-wired by SaveSystem._post_load_resolve_references() after
## all pawns are spawned: it reads _pending_owner_name, matches pawn_name,
## assigns _owner_pawn, then clears _pending_owner_name.
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"))
is_medical = bool(d.get("is_medical", false))
build_progress = int(d.get("build_progress", 0))
_completed = bool(d.get("completed", false))
_owner_pawn = null
_occupant_pawn = null
# Store name for the post-load fixup pass; cleared there once resolved.
_pending_owner_name = str(d.get("owner_pawn_name", ""))
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void:
# Top-down bed view, anchor at the foot's bottom-centre.
# Local Y spans -32..0: -32..-16 = head/pillow tile, -16..0 = body/foot tile.
# Local X spans -8..+8 (16 px wide, 1 tile).
var alpha: float = 1.0 if _completed else 0.4
var idx: int = (tile.x * 31 + tile.y * 17) % _BED_VARIANT_BLANKET.size()
var blanket: Color = _BED_VARIANT_BLANKET[idx]
blanket.a = alpha
var frame_dark := Color(_BED_FRAME_DARK.r, _BED_FRAME_DARK.g, _BED_FRAME_DARK.b, alpha)
var frame_light := Color(_BED_FRAME_LIGHT.r, _BED_FRAME_LIGHT.g, _BED_FRAME_LIGHT.b, alpha)
var pillow := Color(_BED_PILLOW.r, _BED_PILLOW.g, _BED_PILLOW.b, alpha)
var pillow_shade := Color(_BED_PILLOW_SHADE.r, _BED_PILLOW_SHADE.g, _BED_PILLOW_SHADE.b, alpha)
# Outer wood frame (dark) — 16×32 base.
draw_rect(Rect2(Vector2(-8, -32), Vector2(16, 32)), frame_dark)
# Inner frame highlight (lighter wood) — 1px inside the dark frame.
draw_rect(Rect2(Vector2(-7, -31), Vector2(14, 30)), frame_light)
# Mattress / blanket — fills the inner area, leaves 2px wood rim on each side.
draw_rect(Rect2(Vector2(-6, -28), Vector2(12, 26)), blanket)
# Pillow at the head — wide rect with shadowed underside for depth.
draw_rect(Rect2(Vector2(-5, -28), Vector2(10, 6)), pillow)
draw_rect(Rect2(Vector2(-5, -23), Vector2(10, 1)), pillow_shade)
# Blanket fold near the foot — a thin lighter band suggests sheet edge.
draw_rect(Rect2(Vector2(-6, -6), Vector2(12, 2)), _BED_PILLOW)
# Wood foot board accent — 2px dark band at the very bottom of the body tile.
draw_rect(Rect2(Vector2(-7, -3), Vector2(14, 2)), frame_dark)
# Medical-bed marker — red cross over the pillow.
if is_medical:
var cross := Color(0.85, 0.10, 0.10, alpha)
draw_rect(Rect2(Vector2(-3, -26), Vector2(6, 2)), cross)
draw_rect(Rect2(Vector2(-1, -28), Vector2(2, 6)), cross)
# ── internal ──────────────────────────────────────────────────────────────────
func _complete() -> void:
_completed = true
# Solidify the ghost: sprite goes from 40% to full opacity.
var sprite: Sprite2D = get_node_or_null("Sprite")
if sprite != null:
sprite.modulate.a = 1.0
queue_redraw()
World.clear_designation_at(tile)
Audit.log("bed", "%s built at %s" % [label_text, tile])
# Phase 13 — notify BeautySystem so nearby tile beauty scores update.
var bs = World.get("beauty_system")
if bs != null:
bs.register_furniture(self)
bs.recompute_around(tile)