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


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


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