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)