rimlike/scenes/entities/bed.gd
megaproxy 922f269a6c Bug-triage patch — fix torch builds, idle-pawn traps, floor render order
Three playtest-reported bugs fixed out-of-phase before Phase 18:

* Furniture build-queue gap: Torch / Bed / Crate / Workbench / CremationPyre
  were missing World.register_build_site(self) in _ready, so newly-painted
  designations never entered ConstructionProvider's iteration. The seeded
  cabin pre-built everything via _spawn_complete_* helpers, masking the gap
  until a player painted a fresh furniture designation.

* Wall-trap regression for bystanders + walk-through pawns: Wall._complete
  now dislodges any pawn on the tile via new Pathfinder.find_nearest_walkable
  BFS helper; Pawn._advance_walk re-checks next tile walkability before
  stepping, aborts walk + cancels job + lets Decision reroute. Phase 6's
  adjacent-stand fix only protected the BUILDING pawn.

* Floor / Pawn Y-sort ambiguity: Floor was anchored at tile-center
  (same Y as Pawn), so Y-sort tiebreak fell to scene-tree order and
  Floor (spawned later) drew over Pawn. Moved Floor origin to top-of-tile
  so Floor.y < Pawn.y under Y-sort; _draw rect offsets compensate.

All three verified via MCP runtime: torch built end-to-end, all 3 pawns
working on different jobs with no idle traps, pawn renders over floor.

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

298 lines
12 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 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]
## 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.
const _BED_TEX: Texture2D = preload("res://art/tiles/FG_Interior.png")
const _BED_TILE_W: int = 16
const _BED_TILE_H: int = 32 # 2 tiles tall — head row + body row
## Atlas top-left coords (x, y) for each variant. Picked by deterministic hash
## from the bed's tile so the same bed renders the same colour each session.
const _BED_VARIANT_COORDS: Array[Vector2i] = [
Vector2i(32, 22), # brown bed (warm wood frame)
Vector2i(35, 22), # blue bed (cool quilt)
Vector2i(38, 22), # pink bed (rosy quilt)
]
# ── 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
# ── 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.
## Builds the bed sprite here (not _ready) because _ready fires before the
## caller passes in the real tile — building the sprite earlier would just
## attach it to the (0,0) default position.
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 (in the foot tile) draws over
# the bed sprite, while a pawn sleeping IN the bed (same tile) layers under
# the procedural medical-cross overlay. World scene has y_sort_enabled = true.
y_sort_enabled = true
_build_sprite()
queue_redraw()
## Adds a 16×32 Sprite2D child painted with one of the bed variants. Variant
## chosen deterministically from the tile so the same bed renders the same
## colour across boots and load/save. The sprite is centred vertically on the
## border between the bed tile and the tile below, so the head shows in the
## bed's tile and the body extends visually into the southern tile.
func _build_sprite() -> void:
# Idempotency: if a sprite was added on an earlier setup call, drop it.
var prev := get_node_or_null("Sprite")
if prev != null:
prev.queue_free()
var sprite := Sprite2D.new()
sprite.name = "Sprite"
sprite.texture = _BED_TEX
sprite.region_enabled = true
var idx: int = (tile.x * 31 + tile.y * 17) % _BED_VARIANT_COORDS.size()
var coord: Vector2i = _BED_VARIANT_COORDS[idx]
sprite.region_rect = Rect2(
coord.x * TILE_SIZE_PX,
coord.y * TILE_SIZE_PX,
_BED_TILE_W,
_BED_TILE_H,
)
sprite.centered = true
# Node position.y already sits at the bottom of the bed tile (top of the
# foot tile). A centred 16×32 sprite at offset (0, 0) then spans y -16..+16,
# covering bed-tile + foot-tile vertically. No extra offset needed.
sprite.offset = Vector2.ZERO
# Sprite stays z=0; procedural _draw overlay (cross + ghost) renders on top
# of the parent Node2D via its own _draw(), which fires after children.
sprite.z_index = 0
# Ghost state — translucent until built. Solidified in _complete().
sprite.modulate.a = 1.0 if _completed else 0.4
add_child(sprite)
# ── 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 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"))
is_medical = bool(d.get("is_medical", false))
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:
# Bed body / pillow now come from the Sprite2D child (see _build_sprite()).
# This _draw renders only the small red medical-cross overlay over the
# pillow region of the sprite. Sprite handles ghost alpha via modulate.a.
if not is_medical:
return
var alpha: float = 1.0 if _completed else 0.4
var cross := Color(0.85, 0.10, 0.10, alpha)
# Pillow on the FG_Interior bed sprite sits roughly at local-y -12..-8 inside
# the head tile. Drop a small + centred there so the player can tell medical
# beds apart at a glance.
draw_rect(Rect2(Vector2(-3.0, -11.0), Vector2(6.0, 2.0)), cross) # horizontal bar
draw_rect(Rect2(Vector2(-1.0, -13.0), Vector2(2.0, 6.0)), cross) # vertical bar
# ── 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()
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)