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

464 lines
20 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
# ── sprite atlas (replaces procedural _draw for the four named variants) ─────
## Variant → (texture, atlas top-left coord, height in tiles). Selected from
## the ElvGames House Interior + Marketplace tilesets in the 2026-05-12 visual
## pass; see /tmp/workbench_candidates_v2.png from that session for the diff.
##
## h_tiles = 2 sprites bottom-anchor and extend UP into the tile above (Bed
## pattern) so the carpenter's tall cabinet reads as a piece of furniture
## standing in the room rather than a flat decal. h_tiles = 1 sprites stay
## within the workbench tile (anvil, stove top, barrel — squat shapes).
##
## Unrecognised label_texts fall through to procedural _draw_generic, so
## ad-hoc workbench variants keep rendering until a sprite is picked for them.
const _INTERIOR_TEX: Texture2D = preload("res://art/tiles/FG_Interior.png")
const _MARKETPLACE_TEX: Texture2D = preload("res://art/tiles/FG_Marketplace.png")
const _VARIANT_SPRITES: Dictionary = {
"Carpenter": {"tex": _INTERIOR_TEX, "coord": Vector2i(24, 20), "h_tiles": 2},
"Smelter": {"tex": _MARKETPLACE_TEX, "coord": Vector2i(8, 30), "h_tiles": 1},
"Hearth": {"tex": _INTERIOR_TEX, "coord": Vector2i(16, 32), "h_tiles": 1},
"Millstone": {"tex": _INTERIOR_TEX, "coord": Vector2i(17, 40), "h_tiles": 1},
}
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this workbench in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Player-visible label. Also drives the sprite variant (see _VARIANT_SPRITES)
## and procedural _draw fallback for unrecognised values.
## Setter rebuilds the sprite child idempotently — callers can assign
## label_text either before OR after setup() and end up with the right sprite.
## (World.gd assigns it after setup(); SaveSystem._spawn_workbench too.)
@export var label_text: String = "Workbench":
set(value):
label_text = value
# Setter fires from .tscn property initialisation BEFORE _ready, so
# guard the rebuild until the node is actually in the tree (children
# can't be added safely before then).
if is_inside_tree():
_build_sprite()
# Hearth-light catch-up: _ready() builds the PointLight2D only when
# label_text is already "Hearth", but the project's call pattern
# (add_child first, then set label_text) means _ready always saw the
# default "Workbench" and skipped the light. Build it lazily here
# so Hearth workbenches actually glow. Pre-existing bug since Phase 11.
_maybe_build_light()
## Build the PointLight2D for light-emitting workbenches if it doesn't exist
## yet. Idempotent — safe to call from both _ready() and the label_text setter.
## Enabled state is decided by is_on() (false until _complete fires).
func _maybe_build_light() -> void:
if _light != null:
return
if not label_text in LIGHT_EMITTING_LABELS:
return
_light = _build_point_light_2d()
add_child(_light)
_light.enabled = is_on()
## 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)
World.register_build_site(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)
# Builds the PointLight2D for light-emitting workbenches. Usually a no-op
# here because the standard call pattern is add_child → setup → set label
# AFTER _ready, so label_text is still the default. The label_text setter
# calls _maybe_build_light() again when the real label lands — that's the
# one that actually wires the light. Idempotent.
_maybe_build_light()
queue_redraw()
func _exit_tree() -> void:
World.unregister_workbench(self)
World.unregister_light_source(self)
World.unregister_build_site(self)
## One-shot initialiser. Call after add_child() so _ready() has fired.
## Builds the variant sprite using the current label_text — if the caller
## hasn't assigned label_text yet, the setter rebuilds the sprite on assignment.
## Idempotent (safe under save-load's instantiate → setup → from_dict → setup chain).
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 16×32 Carpenter sprite (which rises into the tile north of
# the bench) occludes pawns standing behind it. Matches Bed / Wall.
y_sort_enabled = true
_build_sprite()
queue_redraw()
## Build the variant Sprite2D child (or no-op when label_text isn't in the
## sprite table — those fall through to procedural _draw rendering).
## Idempotent: frees any previous Sprite child first. Called from setup() AND
## from the label_text setter, so the sprite always matches the current variant.
func _build_sprite() -> void:
var prev := get_node_or_null("Sprite")
if prev != null:
prev.queue_free()
var data = _VARIANT_SPRITES.get(label_text)
if data == null:
# Generic / unknown variants keep procedural rendering. _draw_generic
# fires through the existing match in _draw().
return
var sprite := Sprite2D.new()
sprite.name = "Sprite"
sprite.texture = data["tex"]
sprite.region_enabled = true
var coord: Vector2i = data["coord"]
var h_tiles: int = data["h_tiles"]
var pixels_h: int = TILE_SIZE_PX * h_tiles
sprite.region_rect = Rect2(
coord.x * TILE_SIZE_PX,
coord.y * TILE_SIZE_PX,
TILE_SIZE_PX,
pixels_h,
)
sprite.centered = true
# Parent position.y is at the BOTTOM of the workbench tile (see setup()).
# Bottom-anchor the sprite by offsetting it up by half its height, so a
# 16×16 sprite spans local y 16..0 (within the bench tile) and a 16×32
# sprite spans local y 32..0 (bench tile + the tile above it, like Bed
# but extending UPWARD — workbenches don't have a "foot tile").
sprite.offset = Vector2(0.0, -float(pixels_h) / 2.0)
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 ────────────────────────────────────────────────────────
## 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 {
"class_id": &"workbench",
"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:
# Sprite-backed variants (Carpenter / Smelter / Hearth) render entirely
# through their Sprite2D child — no procedural fallback needed. Millstone
# also has a sprite but keeps a small dark-grey wheel overlay so the
# wood barrel below reads as "grinding station" rather than a plain barrel.
# Unrecognised label_texts fall through to _draw_generic so ad-hoc
# benches still render until a sprite is picked for them.
var alpha: float = 1.0 if _completed else 0.4
if label_text == "Millstone":
_draw_millstone_overlay(alpha)
return
if _VARIANT_SPRITES.has(label_text):
return
_draw_generic(alpha)
## Stone-wheel overlay drawn on top of the Millstone barrel sprite. Without
## this, the barrel reads as "water/grain storage" rather than a millstone.
## The circle sits inside the top half of the barrel's tile.
func _draw_millstone_overlay(alpha: float) -> void:
var wheel := Color(0.40, 0.40, 0.36, alpha)
var rim := Color(0.22, 0.22, 0.20, alpha)
draw_circle(Vector2(0.0, -10.0), 4.5, wheel)
draw_arc(Vector2(0.0, -10.0), 4.5, 0.0, TAU, 12, rim, 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
# Solidify the ghost: sprite child (if any) goes from 40% to full opacity.
# Procedural-only variants reread alpha through _draw() via queue_redraw.
var sprite: Sprite2D = get_node_or_null("Sprite")
if sprite != null:
sprite.modulate.a = 1.0
# 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)