rimlike/scenes/entities/workbench.gd
megaproxy c97ada80d7 Procedural workbench redraws + 3-season tree variety
Workbenches: replace atlas sprites (which read as chest-of-drawers,
candle base, kitchen stove, cushion stack) with procedural _draw_ methods
following CremationPyre._draw_pyre's pattern. Carpenter shows a wood bench
with saw + log slabs; Smelter a stone furnace with smoking chimney; Hearth
a tall h=2 stone fireplace with arched opening + log fire; Millstone a
wood frame supporting a round grindstone wheel.

Trees: add Summer + Fall atlases alongside Spring (12 visual variants
from 4 silhouettes × 3 seasons). Selection hash mixes season independently
so neighbouring tiles don't all share the same palette.
2026-05-15 20:22:55 +01:00

537 lines
24 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
# ── 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])
## 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:
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)