rimlike/scenes/entities/bed.gd
megaproxy 9cf9b7dbfd Phase 13: Rooms + Auto-roof + Beauty + Dirtiness + Cleaning
Three-agent fan-out — Opus pre-wrote Room class, World.rooms/room_at_tile/is_indoor,
4 EventBus signals before dispatch so the slices ran fully parallel.

DECISION: Big-room UX = bump auto-roof cap to 16, banner above. Cabin
(24 tiles) intentionally exceeds cap to exercise the warning path; a
5×5 test shed (9 interior tiles) was added to exercise the roof path.

Room detection (Agent A):
- scenes/world/room.gd — class_name Room, tiles/bounds/is_under_roof,
  contains_tile() bounds-then-list-checked, recompute_bounds()
- scenes/world/room_detector.gd — class_name RoomDetector, BFS 4-dir
  from floor/door tiles, walls/terrain as boundary, doors counted as
  room interior. Detects up to 4× cap; auto-roofs only ≤16.
- World.mark_wall_tile/mark_floor_tile/mark_door_tile hook BFS recompute
- Door._complete() now erases wall-layer stamp + registers door tile
- Designation.TOOL_NO_ROOF paint mode wired (UI button deferred Phase 17)
- EventBus.room_changed / room_too_large signals

Indoor/Shelter (Agent B):
- Pawn._is_sheltered() rerouted: World.is_indoor() first, floor-proxy fallback
- IndoorTintOverlay Node2D — _draw fills roofed-room tiles at α=0.10 warm
- Crop._on_sim_tick skips stage advance when World.is_indoor(tile)

Beauty + Dirtiness + Cleaning + Room thoughts (Agent C):
- BeautySystem sparse map, linear falloff radius=3, Quality multiplier
  (SHODDY 0.5 → LEGENDARY 2.5). Base: Bed +2, Workbench +1, Torch +3, Hearth +4
- DirtinessSystem 0-100, tier crossings (clean<25/dirty<60/filthy≥60)
  emit tile_dirtiness_changed. bump/bump_clean/bump_pawn_traffic API
- CleaningProvider priority=2, KIND_CLEAN toil, 2.5 dirt/tick for ~40 ticks
- Bed/Torch/Workbench _complete() now register with BeautySystem
- 7 room mood thoughts: clean_room (+2), dirty_room (-3), filthy_room (-6),
  beautiful_room (+4), ugly_room (-3), slept_in_room (+3 EVENT, wires Ph 17),
  ate_without_table (-3 EVENT, wires Ph 17)
- Pawn._sync_room_thoughts called from _process_thoughts after cold block,
  defensive against null rooms/systems

Integration recovery (Opus):
- Agent C's BeautySystem/DirtinessSystem/CleaningProvider/IndoorTintOverlay
  instantiation in world.gd never landed (only field declarations + entity
  hooks survived). Added preloads + runtime add_child + autoload bindings +
  CleaningProvider registration + furniture pre-seed in _ready
- Added _prestamp_test_shed_for_room_detector with _spawn_complete_wall/floor
  helpers so a 5×5 visible shed exercises the auto-roof path at boot

MCP runtime verified:
- Rooms: cabin Room#2 size=24 roofed=false (room_too_large fires),
  shed Room#3 size=9 roofed=true (auto-roof active)
- beauty_map size=50 around prebuilt furniture; bed at (47,24) beauty=4.0
- Bram teleported to (36, 25) in shed → indoor=true, sheltered=true,
  thoughts=[clean_room +2], mood=52.0
- Screenshot: shed walls + brown floor visible; cabin warmly torch-lit;
  Spring 1/12 indicator; Day 1 07:52

Delegation: 3× gdscript-refactor (Sonnet) agents in parallel;
integration recovery + MCP verify on Opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:19:23 +01:00

287 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]
# ── 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)
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,
"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:
# 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)
# Medical cross: small red + over the pillow area.
# Two overlapping rects form a classic cross — universal medical symbol.
if is_medical:
var cross := Color(0.85, 0.10, 0.10, alpha)
draw_rect(Rect2(Vector2(-3.0, -8.0), Vector2(6.0, 2.0)), cross) # horizontal bar
draw_rect(Rect2(Vector2(-1.0, -10.0), Vector2(2.0, 6.0)), cross) # vertical bar
# ── 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])
# 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)