rimlike/scenes/entities/workbench.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

449 lines
19 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 Workbench extends Node2D
## Workbench entity — buildable structure where pawns craft items per bills.
##
## Rendered as a bottom-anchored sprite (Y-sorted) matching the 3/4-perspective
## convention from Wall/Door. Ghost state (40% alpha) while construction is
## in progress; solid once _completed.
##
## Variant appearance is driven by label_text:
## "Carpenter" → warm-brown wood bench with a vise detail
## "Smelter" → dark grey stone block with an orange ember glow
## Other → generic warm-grey fallback
##
## Bill model (architecture.md "Production: workbenches, recipes, bills"):
## bills[] — ordered queue of Bill objects (untyped; Bill class
## is authored by a sibling agent and may not be
## registered when this file compiles).
## current_bill — the Bill actively being worked; null when idle.
## current_work_progress — tick counter within the current craft cycle.
## JobRunner._tick_craft increments this each sim tick.
## Workbench resets it to 0 on cycle completion or
## when the pawn leaves mid-craft.
##
## Save/load: to_dict / from_dict capture all persistent fields, including
## each bill via bill.to_dict(). Mirrors the Crate save pattern.
##
## World registration: World.register_workbench / World.unregister_workbench
## are called from _ready / _exit_tree.
const TILE_SIZE_PX: int = 16
## Sim ticks to build a workbench (90 ticks ≈ 4.5 sim seconds at 1×).
const BUILD_TICKS: int = 90
# ── Phase 11: light-source support ───────────────────────────────────────────
## Workbenches whose label_text is in this list emit light when completed.
## Currently only the Hearth (open-fire cooking) qualifies.
const LIGHT_EMITTING_LABELS: Array[String] = ["Hearth"]
## Sim-side Manhattan-distance light radius for the Hearth (tiles). Max 8.
const HEARTH_LIGHT_RADIUS: int = 5
## Pixel size of the procedural radial gradient used for PointLight2D.
const LIGHT_TEXTURE_SIZE: int = 64
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this workbench in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Player-visible label. Also drives the _draw() variant.
## Recognised values: "Carpenter", "Smelter", "Hearth", "Millstone". Others render generic.
@export var label_text: String = "Workbench"
## Which skill category this bench accepts.
## CraftingProvider filters by this before assigning a pawn.
@export var accepted_skill: StringName = &"crafting"
# ── 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
## Ordered queue of Bill objects. Untyped so this file compiles before the
## Bill class is registered by the sibling agent. CraftingProvider reads this.
var bills: Array = []
## The Bill being actively worked right now. null when idle.
## Set by JobRunner when it begins a craft toil; cleared on completion or
## when the pawn walks away (job cancelled / interrupted).
var current_bill = null
## Sim-tick progress within the current craft cycle. Incremented by
## JobRunner._tick_craft once per sim tick. Reset to 0:
## - when a craft completes (on_craft_complete)
## - when no pawn is actively crafting (JobRunner cancel / pawn interruption)
## JobRunner reads this to decide whether the recipe's work_ticks are done.
var current_work_progress: int = 0
## PointLight2D child for workbenches that emit light (Hearth). null for all
## others. Built in _ready() when label_text is in LIGHT_EMITTING_LABELS;
## enabled only after _complete() fires.
var _light: PointLight2D = null
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
# Position is bottom-anchored 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_workbench(self)
# Phase 11: light-emitting workbenches register with the light-source registry.
# All workbenches register; non-emitters return false from is_on() so
# World.is_tile_lit() skips them at zero cost.
World.register_light_source(self)
if label_text in LIGHT_EMITTING_LABELS:
_light = _build_point_light_2d()
add_child(_light)
_light.enabled = false # dark until built
queue_redraw()
func _exit_tree() -> void:
World.unregister_workbench(self)
World.unregister_light_source(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 ────────────────────────────────────────────────────────
## True while the workbench 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.
## Advances build_progress; completes the workbench 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 workbench has been fully built.
func is_completed() -> bool:
return _completed
# ── Bills ─────────────────────────────────────────────────────────────────────
## Append a bill to the queue.
func add_bill(b) -> void:
bills.append(b)
Audit.log("workbench", "%s: bill added — recipe '%s'" % [label_text, b.recipe.id])
## Return the first bill that is active and whose required_skill matches
## this bench's accepted_skill. Returns null when none qualify.
## CraftingProvider calls this; JobRunner also calls it when the current_bill
## becomes inactive (UNTIL_N threshold reached, paused, etc.).
func find_active_bill():
for b in bills:
if not b.is_active():
continue
if b.recipe.required_skill != accepted_skill:
continue
return b
return null
# ── Craft-cycle hooks (called by JobRunner) ───────────────────────────────────
## JobRunner calls this when it starts working a bill on this bench.
## Stores the active bill and resets the tick counter.
func begin_craft(b) -> void:
current_bill = b
current_work_progress = 0
## JobRunner calls this once per sim tick while a pawn is actively crafting.
## Returns true when the recipe's work_ticks have been reached (craft done).
func tick_craft() -> bool:
if current_bill == null:
return false
current_work_progress += 1
return current_work_progress >= current_bill.recipe.work_ticks
## JobRunner calls this on craft completion before spawning the output item.
## Records the completion on the bill, resets state.
func on_craft_complete() -> void:
if current_bill != null:
current_bill.record_completion()
current_bill = null
current_work_progress = 0
## JobRunner calls this when the craft is interrupted (pawn leaves, cancel, etc.).
## Resets in-progress state so another pawn can start fresh.
func on_craft_interrupted() -> void:
current_bill = null
current_work_progress = 0
# ── Phase 11: light-source duck-type interface ────────────────────────────────
## Shared by Torch. World.is_tile_lit() and the "in darkness" thought call these.
## True when this workbench emits light and has finished construction.
## Non-emitting workbenches (Carpenter, Smelter, Millstone) always return false.
func is_on() -> bool:
return _completed and label_text in LIGHT_EMITTING_LABELS
## The tile this light source occupies (for Manhattan-distance calculation).
func get_light_tile() -> Vector2i:
return tile
## The sim-side Manhattan-distance radius of this light source.
## Returns 0 for non-emitting workbenches; World.is_tile_lit() still calls this
## but d <= 0 is never true for a non-adjacent tile so it's a no-op in practice.
func get_light_radius() -> int:
if label_text in LIGHT_EMITTING_LABELS:
return HEARTH_LIGHT_RADIUS
return 0
# ── save / load ───────────────────────────────────────────────────────────────
## Serialise workbench state for World save (wired in Phase 16).
func to_dict() -> Dictionary:
var bills_data: Array = []
for b in bills:
bills_data.append(b.to_dict())
return {
"tile_x": tile.x,
"tile_y": tile.y,
"label_text": label_text,
"accepted_skill": String(accepted_skill),
"build_progress": build_progress,
"completed": _completed,
"current_work_progress": current_work_progress,
"bills": bills_data,
}
## Restore from a dict produced by to_dict().
## Bill objects are reconstructed by the caller using Bill.from_dict() once the
## Bill class is registered. current_bill is left null — JobRunner reconnects
## from its own saved state on the next sim tick.
func from_dict(d: Dictionary) -> void:
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
label_text = str(d.get("label_text", "Workbench"))
accepted_skill = StringName(d.get("accepted_skill", "crafting"))
build_progress = int(d.get("build_progress", 0))
_completed = bool(d.get("completed", false))
current_work_progress = int(d.get("current_work_progress", 0))
# Bills are re-populated by World.load_workbenches() after Bill class loads.
# Raw dicts are kept in the dict; the caller handles reconstruction.
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void:
# 3/4-perspective bench rendering — fits within the tile (16×16 local box).
# Origin (0,0) = tile bottom-centre. Tile spans local Y: -16 to 0.
# Two-band look (matches Wall): lit top band + shaded front face.
# Ghost (not yet built) draws at 0.4 alpha.
var alpha: float = 1.0 if _completed else 0.4
match label_text:
"Carpenter":
_draw_carpenter(alpha)
"Smelter":
_draw_smelter(alpha)
"Hearth":
_draw_hearth(alpha)
"Millstone":
_draw_millstone(alpha)
_:
_draw_generic(alpha)
func _draw_carpenter(alpha: float) -> void:
# Warm-brown wood bench. Top band lit, front face darker.
# Vise/saw detail: a small darker square at the top-right corner of the
# front face to suggest a mounted tool.
var top_face := Color(0.62, 0.45, 0.25, alpha)
var front_face := Color(0.52, 0.36, 0.18, alpha)
var plank := Color(0.34, 0.22, 0.10, alpha)
var vise := Color(0.28, 0.18, 0.08, alpha)
var outline := Color(0.20, 0.12, 0.04, 0.7 * alpha)
# Top face — lit strip at upper-third of tile.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), top_face)
# Front face — lower body.
draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), front_face)
# Horizontal plank seam across the front face.
draw_line(Vector2(-8.0, -6.0), Vector2(8.0, -6.0), plank, 1.0)
# Vise detail: 4×4 px darker square at top-right of front face.
draw_rect(Rect2(Vector2(3.0, -11.0), Vector2(4.0, 4.0)), vise)
# Top/front edge horizon line.
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), plank, 1.0)
# Tile outline.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
func _draw_smelter(alpha: float) -> void:
# Dark grey stone block. Top band slightly lighter.
# Ember glow: orange rect centred at the bottom of the front face.
var top_face := Color(0.42, 0.42, 0.40, alpha)
var front_face := Color(0.32, 0.32, 0.30, alpha)
var mortar := Color(0.20, 0.20, 0.18, alpha)
var ember := Color(0.95, 0.45, 0.10, alpha)
var outline := Color(0.14, 0.14, 0.12, 0.7 * alpha)
# Top face.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), top_face)
# Front face.
draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), front_face)
# Mortar line.
draw_line(Vector2(-8.0, -6.0), Vector2(8.0, -6.0), mortar, 1.0)
# Ember glow: 6×3 px orange rect, horizontally centred, at bottom of front face.
draw_rect(Rect2(Vector2(-3.0, -3.0), Vector2(6.0, 3.0)), ember)
# Top/front edge horizon line.
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), mortar, 1.0)
# Tile outline.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
func _draw_hearth(alpha: float) -> void:
# Dark grey stone block with a large orange flame at centre of front face and
# a thin smoke wisp poking above the top face. Visually heavier than the
# Smelter (which has a small ember) to signal open-fire cooking.
var top_face := Color(0.35, 0.30, 0.25, alpha)
var front_face := Color(0.28, 0.24, 0.20, alpha)
var mortar := Color(0.18, 0.14, 0.12, alpha)
var flame := Color(0.95, 0.55, 0.10, alpha)
var smoke := Color(0.72, 0.70, 0.68, alpha * 0.6)
var outline := Color(0.14, 0.10, 0.08, 0.7 * alpha)
# Top face.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), top_face)
# Front face.
draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), front_face)
# Mortar seam.
draw_line(Vector2(-8.0, -6.0), Vector2(8.0, -6.0), mortar, 1.0)
# Flame: 6×4 px orange rect, horizontally centred in the front face.
draw_rect(Rect2(Vector2(-3.0, -8.0), Vector2(6.0, 4.0)), flame)
# Smoke wisp: 1×2 px vertical light-grey rect rising above the top face.
draw_rect(Rect2(Vector2(-0.5, -18.0), Vector2(1.0, 2.0)), smoke)
# Top/front edge horizon line.
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), mortar, 1.0)
# Tile outline.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
func _draw_millstone(alpha: float) -> void:
# Very light grey stone block with a circular dark-grey stone wheel inset
# at the centre of the front face. Suggests a grinding wheel.
var top_face := Color(0.78, 0.78, 0.72, alpha)
var front_face := Color(0.65, 0.65, 0.60, alpha)
var seam := Color(0.45, 0.45, 0.42, alpha)
var wheel := Color(0.40, 0.40, 0.36, alpha)
var outline := Color(0.28, 0.28, 0.26, 0.7 * alpha)
# Top face.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), top_face)
# Front face.
draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), front_face)
# Seam.
draw_line(Vector2(-8.0, -6.0), Vector2(8.0, -6.0), seam, 1.0)
# Stone wheel: filled circle radius 5 px, centred on the front face.
draw_circle(Vector2(0.0, -5.5), 5.0, wheel)
# Top/front edge horizon line.
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), seam, 1.0)
# Tile outline.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
func _draw_generic(alpha: float) -> void:
# Warm-grey fallback bench. Simple two-band block with a single seam.
var top_face := Color(0.58, 0.55, 0.50, alpha)
var front_face := Color(0.42, 0.40, 0.36, alpha)
var seam := Color(0.30, 0.28, 0.25, alpha)
var outline := Color(0.20, 0.18, 0.16, 0.7 * alpha)
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), top_face)
draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), front_face)
draw_line(Vector2(-8.0, -6.0), Vector2(8.0, -6.0), seam, 1.0)
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), seam, 1.0)
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
# ── internal ──────────────────────────────────────────────────────────────────
func _complete() -> void:
_completed = true
# Phase 11: enable PointLight2D for light-emitting workbenches on completion.
if _light != null:
_light.enabled = is_on()
queue_redraw()
Audit.log("workbench", "%s built at %s" % [label_text, tile])
# Phase 13 — notify BeautySystem so nearby tile beauty scores update.
# Hearth gets base beauty 4 (warm glow); other benches get 1.
# Beauty lookup key is label_text ("Hearth", "Carpenter", etc.).
var bs = World.get("beauty_system")
if bs != null:
bs.register_furniture(self)
bs.recompute_around(tile)
# ── Phase 11: internal light helpers ─────────────────────────────────────────
## Construct and return the PointLight2D that provides Godot-side visual lighting.
## Mirrors Torch._build_point_light_2d(). Duplicated here to avoid a cross-file
## dependency; the ~20 lines of duplication is the lesser cost.
func _build_point_light_2d() -> PointLight2D:
var p := PointLight2D.new()
p.texture = _build_radial_light_texture(LIGHT_TEXTURE_SIZE)
# Scale so the texture radius covers HEARTH_LIGHT_RADIUS tiles in world-pixels.
p.texture_scale = float(HEARTH_LIGHT_RADIUS) * float(TILE_SIZE_PX) / float(LIGHT_TEXTURE_SIZE) * 2.0
p.color = Color(1.0, 0.80, 0.50, 1.0) # warm hearthfire tint, slightly redder than torch
p.energy = 1.0
# Offset upward so the light originates from the flame area, not the tile base.
p.position = Vector2(0.0, -10.0)
return p
## Build a soft radial gradient Image and return it as an ImageTexture.
## White centre fades to transparent at the edge via smoothstep falloff.
static func _build_radial_light_texture(size: int) -> Texture2D:
var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
var cx: float = float(size) / 2.0
var cy: float = float(size) / 2.0
var max_r: float = float(size) / 2.0
for x in size:
for y in size:
var dx: float = float(x) - cx
var dy: float = float(y) - cy
var d: float = sqrt(dx * dx + dy * dy)
var t: float = clampf(1.0 - d / max_r, 0.0, 1.0)
var a: float = t * t * (3.0 - 2.0 * t)
img.set_pixel(x, y, Color(1.0, 1.0, 1.0, a))
return ImageTexture.create_from_image(img)