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

593 lines
26 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 procedurally (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" → wooden workbench with saw + log slabs on top
## "Smelter" → dark stone furnace with chimney and ember glow
## "Hearth" → tall stone fireplace with mantle + log fire (h=2 tiles)
## "Millstone" → wooden frame supporting a round grindstone wheel
## 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
# ── variant rendering ─────────────────────────────────────────────────────────
## All four named workbench variants render procedurally via _draw(). The
## atlas-sprite approach was abandoned in the 2026-05-15 polish pass after
## visual review: the chosen ElvGames atlas tiles read as a chest-of-drawers
## (Carpenter), a tiny candle base (Smelter), a 2-burner stove (Hearth), and
## a stack of cushions (Millstone). Procedural draws give us shape control to
## hit the silhouettes those names imply. See CremationPyre._draw_pyre() for
## precedent — same pattern, local coords centered at (0, 0) at the BOTTOM of
## the workbench tile, drawing UP into negative y.
##
## Tall variants (Hearth, h=2 logically) draw above y=-16 into the tile north
## of the bench; pawns stand correctly behind them because y_sort_enabled is
## on and position.y is anchored at the bench-tile's bottom edge.
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this workbench in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Player-visible label. Also drives the procedural _draw() variant dispatch.
## Setter triggers a redraw + lazy light build — callers can assign label_text
## either before OR after setup() and the visual catches up.
## (World.gd assigns it after setup(); SaveSystem._spawn_workbench too.)
@export var label_text: String = "Workbench":
set(value):
label_text = value
if is_inside_tree():
queue_redraw()
# 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
## Per-recipe input buffer for multi-ingredient crafts (two-trip strategy).
## Keyed by StringName item_type → int count already deposited.
## Populated by _tick_deposit_at_wb; consumed and cleared when the craft
## completes or is interrupted. Not stocked by single-ingredient recipes.
var deposited_inputs: Dictionary = {}
# ── 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.
## 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 tall variants (Hearth) drawing into the tile north of the bench
# occlude pawns standing behind. Matches Bed / Wall.
y_sort_enabled = true
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])
## Remove a bill from this workbench's queue. If the bill is currently being
## crafted, the active toil is interrupted cleanly so the pawn re-decides.
func remove_bill(b) -> void:
if current_bill == b:
on_craft_interrupted()
bills.erase(b)
Audit.log("workbench", "%s: bill removed — 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
deposited_inputs.clear()
## Called by _tick_deposit_at_wb to stash ingredient1 into the per-recipe buffer.
## Accumulates count; the same item type may be deposited in multiple trips if
## ingredient1_count > 1 (Phase 14: always 1 for corpse, but flexible for future).
func add_deposited_input(item_type: StringName, count: int) -> void:
deposited_inputs[item_type] = deposited_inputs.get(item_type, 0) + count
## Returns true if the deposited_inputs buffer holds at least `count` of `item_type`.
func has_deposited_input(item_type: StringName, count: int) -> bool:
return deposited_inputs.get(item_type, 0) >= count
## Consume (remove) exactly `count` of `item_type` from deposited_inputs.
## Clamps to zero; does nothing if not present.
func consume_deposited_input(item_type: StringName, count: int) -> void:
var held: int = deposited_inputs.get(item_type, 0)
var remaining: int = held - count
if remaining <= 0:
deposited_inputs.erase(item_type)
else:
deposited_inputs[item_type] = remaining
# ── 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())
# Serialise deposited_inputs: StringName keys → int values.
# Store as Array of [String, int] pairs for JSON safety.
var deposited_array: Array = []
for k in deposited_inputs:
deposited_array.append([String(k), deposited_inputs[k]])
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,
"deposited_inputs": deposited_array,
}
## Restore from a dict produced by to_dict().
## Bills are reconstructed here using Bill.from_dict(). current_bill is left
## null — JobRunner reconnects from its own saved state on the next sim tick.
const _BILL_SCRIPT: Script = preload("res://scenes/ai/bill.gd")
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))
# Reconstruct bills from the saved array. Clear first so this is idempotent.
bills.clear()
for bill_dict in d.get("bills", []):
if bill_dict is Dictionary:
var b: Bill = _BILL_SCRIPT.from_dict(bill_dict)
if b != null:
bills.append(b)
# Restore deposited_inputs from the [String, int] pair array.
deposited_inputs.clear()
for pair in d.get("deposited_inputs", []):
if pair is Array and pair.size() == 2:
deposited_inputs[StringName(str(pair[0]))] = int(pair[1])
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void:
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)
## Carpenter — wooden plank top with two visible legs and a hand-saw + log
## slabs on top. Reads as a workshop bench at 16×16 thanks to the saw blade
## silhouette breaking the plain top.
func _draw_carpenter(alpha: float) -> void:
var plank_top := Color(0.70, 0.50, 0.30, alpha)
var plank_front := Color(0.55, 0.38, 0.22, alpha)
var plank_edge := Color(0.35, 0.22, 0.12, alpha)
var leg := Color(0.30, 0.20, 0.10, alpha)
var saw_blade := Color(0.78, 0.78, 0.82, alpha)
var saw_handle := Color(0.55, 0.30, 0.15, alpha)
var log_face := Color(0.62, 0.42, 0.24, alpha)
var log_ring := Color(0.42, 0.27, 0.14, alpha)
var outline := Color(0.15, 0.10, 0.05, 0.7 * alpha)
# Two legs at the corners (front face).
draw_rect(Rect2(Vector2(-7.0, -10.0), Vector2(2.0, 10.0)), leg)
draw_rect(Rect2(Vector2( 5.0, -10.0), Vector2(2.0, 10.0)), leg)
# Plank front face — thick band.
draw_rect(Rect2(Vector2(-8.0, -12.0), Vector2(16.0, 5.0)), plank_front)
# Plank top — slimmer band above the front, suggesting depth.
draw_rect(Rect2(Vector2(-8.0, -15.0), Vector2(16.0, 3.0)), plank_top)
# Edge highlight between top and front.
draw_line(Vector2(-8.0, -12.0), Vector2(8.0, -12.0), plank_edge, 1.0)
# Two short log slabs sitting on the left side of the top.
draw_rect(Rect2(Vector2(-6.0, -17.0), Vector2(3.0, 2.0)), log_face)
draw_rect(Rect2(Vector2(-3.0, -17.0), Vector2(3.0, 2.0)), log_face)
draw_line(Vector2(-4.5, -17.0), Vector2(-4.5, -15.0), log_ring, 1.0)
# Saw on the right — handle + blade silhouette.
draw_rect(Rect2(Vector2(1.0, -16.0), Vector2(6.0, 1.5)), saw_blade)
draw_rect(Rect2(Vector2(5.5, -17.0), Vector2(2.0, 2.0)), saw_handle)
# Outline.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
## Smelter — stone furnace block with a stubby chimney puffing smoke and a
## bright ember-glow opening on the front face. Stone-grey base separates it
## visually from the Carpenter's warm wood.
func _draw_smelter(alpha: float) -> void:
var stone_top := Color(0.55, 0.55, 0.55, alpha)
var stone_front := Color(0.42, 0.42, 0.43, alpha)
var stone_shad := Color(0.30, 0.30, 0.32, alpha)
var ember := Color(0.98, 0.55, 0.10, alpha)
var ember_core := Color(1.00, 0.85, 0.30, alpha)
var chimney := Color(0.32, 0.30, 0.30, alpha)
var smoke := Color(0.75, 0.73, 0.70, alpha * 0.7)
var outline := Color(0.15, 0.12, 0.10, 0.7 * alpha)
# Stone front body.
draw_rect(Rect2(Vector2(-8.0, -12.0), Vector2(16.0, 12.0)), stone_front)
# Top face — slightly lighter band.
draw_rect(Rect2(Vector2(-8.0, -15.0), Vector2(16.0, 3.0)), stone_top)
# Furnace mouth — dark recess with bright ember inside.
draw_rect(Rect2(Vector2(-4.0, -9.0), Vector2(8.0, 5.0)), stone_shad)
draw_rect(Rect2(Vector2(-3.0, -8.0), Vector2(6.0, 3.0)), ember)
draw_rect(Rect2(Vector2(-2.0, -7.0), Vector2(4.0, 1.0)), ember_core)
# Mortar lines across the front for stone-block feel.
draw_line(Vector2(-8.0, -8.0), Vector2(-4.0, -8.0), stone_shad, 1.0)
draw_line(Vector2( 4.0, -8.0), Vector2( 8.0, -8.0), stone_shad, 1.0)
# Chimney + smoke wisps rising above.
draw_rect(Rect2(Vector2(2.0, -19.0), Vector2(3.0, 4.0)), chimney)
draw_rect(Rect2(Vector2(3.0, -22.0), Vector2(1.0, 3.0)), smoke)
draw_rect(Rect2(Vector2(2.0, -24.0), Vector2(1.0, 2.0)), smoke)
# Outline.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
## Hearth — tall (h=2) stone fireplace with mantle, arched opening, log fire
## with embers, and a flame licking up. Draws above y=-16 into the tile north
## of the bench (y_sort handles occlusion). Light-emitting via _maybe_build_light.
func _draw_hearth(alpha: float) -> void:
var stone := Color(0.60, 0.58, 0.55, alpha)
var stone_dark := Color(0.42, 0.40, 0.38, alpha)
var mantle := Color(0.50, 0.34, 0.20, alpha)
var mantle_edge := Color(0.32, 0.20, 0.10, alpha)
var opening := Color(0.08, 0.04, 0.02, alpha)
var log_wood := Color(0.55, 0.32, 0.15, alpha)
var ember := Color(0.98, 0.55, 0.10, alpha)
var flame_inner := Color(1.00, 0.85, 0.30, alpha)
var flame_outer := Color(0.95, 0.40, 0.05, alpha)
var outline := Color(0.15, 0.10, 0.05, 0.7 * alpha)
# Stone surround — fills the bench tile (y 16..0) and the tile above
# (y 32..-16) so the fireplace is a 16×32 silhouette.
draw_rect(Rect2(Vector2(-8.0, -32.0), Vector2(16.0, 32.0)), stone)
# Stone block mortar — a couple of horizontal seams.
draw_line(Vector2(-8.0, -22.0), Vector2(8.0, -22.0), stone_dark, 1.0)
draw_line(Vector2(-8.0, -28.0), Vector2(8.0, -28.0), stone_dark, 1.0)
draw_line(Vector2(-2.0, -32.0), Vector2(-2.0, -28.0), stone_dark, 1.0)
draw_line(Vector2( 3.0, -28.0), Vector2( 3.0, -22.0), stone_dark, 1.0)
# Wooden mantle — horizontal beam across the middle.
draw_rect(Rect2(Vector2(-8.0, -19.0), Vector2(16.0, 3.0)), mantle)
draw_line(Vector2(-8.0, -19.0), Vector2(8.0, -19.0), mantle_edge, 1.0)
draw_line(Vector2(-8.0, -16.0), Vector2(8.0, -16.0), mantle_edge, 1.0)
# Arched opening — dark recess in the lower stone block.
draw_rect(Rect2(Vector2(-6.0, -14.0), Vector2(12.0, 14.0)), opening)
# Two stacked logs sitting in the opening.
draw_rect(Rect2(Vector2(-5.0, -4.0), Vector2(10.0, 2.0)), log_wood)
draw_rect(Rect2(Vector2(-4.0, -6.0), Vector2(8.0, 2.0)), log_wood)
# Ember strip glowing under the logs.
draw_rect(Rect2(Vector2(-4.0, -2.0), Vector2(8.0, 2.0)), ember)
# Flame — tapered teardrop above the logs.
draw_rect(Rect2(Vector2(-3.0, -10.0), Vector2(6.0, 4.0)), flame_outer)
draw_rect(Rect2(Vector2(-2.0, -12.0), Vector2(4.0, 2.0)), flame_outer)
draw_rect(Rect2(Vector2(-1.0, -13.0), Vector2(2.0, 1.0)), flame_outer)
draw_rect(Rect2(Vector2(-2.0, -9.0), Vector2(4.0, 2.0)), flame_inner)
draw_rect(Rect2(Vector2(-1.0, -11.0), Vector2(2.0, 2.0)), flame_inner)
# Outline around the full 16×32 silhouette.
draw_rect(Rect2(Vector2(-8.0, -32.0), Vector2(16.0, 32.0)), outline, false, 1.0)
## Millstone — wooden frame supporting a large round grindstone, viewed
## 3/4-perspective so the wheel reads as both round (top) and solid (front).
func _draw_millstone(alpha: float) -> void:
var frame_top := Color(0.55, 0.36, 0.18, alpha)
var frame_front := Color(0.42, 0.26, 0.12, alpha)
var frame_edge := Color(0.25, 0.14, 0.06, alpha)
var wheel := Color(0.55, 0.53, 0.50, alpha)
var wheel_dark := Color(0.34, 0.32, 0.30, alpha)
var wheel_rim := Color(0.18, 0.16, 0.14, alpha)
var groove := Color(0.28, 0.26, 0.24, alpha)
var pin := Color(0.20, 0.18, 0.16, alpha)
var outline := Color(0.15, 0.10, 0.05, 0.7 * alpha)
# Wooden frame base — front + top faces.
draw_rect(Rect2(Vector2(-8.0, -7.0), Vector2(16.0, 7.0)), frame_front)
draw_rect(Rect2(Vector2(-8.0, -10.0), Vector2(16.0, 3.0)), frame_top)
draw_line(Vector2(-8.0, -7.0), Vector2(8.0, -7.0), frame_edge, 1.0)
# Grindstone — large dark-grey disc, rim slightly darker. Centred over
# the top of the frame, sticking up into the tile above only slightly.
var c := Vector2(0.0, -12.0)
draw_circle(c, 7.0, wheel_rim)
draw_circle(c, 6.0, wheel)
# Front-face shadow band across the lower half of the disc.
draw_rect(Rect2(Vector2(-6.0, -12.0), Vector2(12.0, 5.0)), wheel_dark)
# Two radial grooves — pie-slice indicators that the stone spins.
draw_line(c, c + Vector2(5.0, -3.5), groove, 1.0)
draw_line(c, c + Vector2(-5.0, -3.5), groove, 1.0)
# Centre pin / spindle.
draw_circle(c, 1.2, pin)
# Outline.
draw_rect(Rect2(Vector2(-8.0, -19.0), Vector2(16.0, 19.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
# Procedural-only variants re-read alpha through _draw() via queue_redraw.
# Phase 11: enable PointLight2D for light-emitting workbenches on completion.
if _light != null:
_light.enabled = is_on()
queue_redraw()
World.clear_designation_at(tile)
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)