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) # 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) ## 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)