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