Workbench sprites — swap procedural draw for tileset art per variant

- Carpenter → FG_Interior (24, 20) 16×32 wood cabinet w/ drawers
- Smelter   → FG_Marketplace (8, 30) anvil + hot metal
- Hearth    → FG_Interior (16, 32) stove w/ burners
- Millstone → FG_Interior (17, 40) wood barrel + procedural wheel overlay

Adds label_text setter so the sprite rebuilds idempotently whether the
caller assigns label before or after setup() (world.gd assigns after,
SaveSystem after). Setter also calls _maybe_build_light() to fix a
pre-existing Phase 11 bug where Hearth never built its PointLight2D
(label_text was still default when _ready fired).

Unrecognised label_texts fall through to _draw_generic so ad-hoc
workbench variants keep rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-12 15:23:28 +01:00
parent 0b09db9fd6
commit c4f94fb543
3 changed files with 170 additions and 118 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d24k3glxf7xt4"
path="res://.godot/imported/FG_Marketplace.png-44bb1301a91d0a5f6f9519ce316c9fed.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/tiles/FG_Marketplace.png"
dest_files=["res://.godot/imported/FG_Marketplace.png-44bb1301a91d0a5f6f9519ce316c9fed.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -42,14 +42,64 @@ const HEARTH_LIGHT_RADIUS: int = 5
## Pixel size of the procedural radial gradient used for PointLight2D. ## Pixel size of the procedural radial gradient used for PointLight2D.
const LIGHT_TEXTURE_SIZE: int = 64 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 ─────────────────────────────────────────────────────────────────── # ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this workbench in world-tile coordinates. ## Tile position of this workbench in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO @export var tile: Vector2i = Vector2i.ZERO
## Player-visible label. Also drives the _draw() variant. ## Player-visible label. Also drives the sprite variant (see _VARIANT_SPRITES)
## Recognised values: "Carpenter", "Smelter", "Hearth", "Millstone". Others render generic. ## and procedural _draw fallback for unrecognised values.
@export var label_text: String = "Workbench" ## 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. ## Which skill category this bench accepts.
## CraftingProvider filters by this before assigning a pawn. ## CraftingProvider filters by this before assigning a pawn.
@ -98,10 +148,12 @@ func _ready() -> void:
# All workbenches register; non-emitters return false from is_on() so # All workbenches register; non-emitters return false from is_on() so
# World.is_tile_lit() skips them at zero cost. # World.is_tile_lit() skips them at zero cost.
World.register_light_source(self) World.register_light_source(self)
if label_text in LIGHT_EMITTING_LABELS: # Builds the PointLight2D for light-emitting workbenches. Usually a no-op
_light = _build_point_light_2d() # here because the standard call pattern is add_child → setup → set label
add_child(_light) # AFTER _ready, so label_text is still the default. The label_text setter
_light.enabled = false # dark until built # 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() queue_redraw()
@ -111,15 +163,61 @@ func _exit_tree() -> void:
## One-shot initialiser. Call after add_child() so _ready() has fired. ## 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: func setup(p_tile: Vector2i) -> void:
tile = p_tile tile = p_tile
position = Vector2( position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0, tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX 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() 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 ──────────────────────────────────────────────────────── # ── BuildJob interface ────────────────────────────────────────────────────────
## True while the workbench still needs construction work. ## True while the workbench still needs construction work.
@ -267,120 +365,29 @@ func from_dict(d: Dictionary) -> void:
# ── render ───────────────────────────────────────────────────────────────────── # ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void: func _draw() -> void:
# 3/4-perspective bench rendering — fits within the tile (16×16 local box). # Sprite-backed variants (Carpenter / Smelter / Hearth) render entirely
# Origin (0,0) = tile bottom-centre. Tile spans local Y: -16 to 0. # through their Sprite2D child — no procedural fallback needed. Millstone
# Two-band look (matches Wall): lit top band + shaded front face. # also has a sprite but keeps a small dark-grey wheel overlay so the
# Ghost (not yet built) draws at 0.4 alpha. # 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 var alpha: float = 1.0 if _completed else 0.4
if label_text == "Millstone":
match label_text: _draw_millstone_overlay(alpha)
"Carpenter": return
_draw_carpenter(alpha) if _VARIANT_SPRITES.has(label_text):
"Smelter": return
_draw_smelter(alpha)
"Hearth":
_draw_hearth(alpha)
"Millstone":
_draw_millstone(alpha)
_:
_draw_generic(alpha) _draw_generic(alpha)
func _draw_carpenter(alpha: float) -> void: ## Stone-wheel overlay drawn on top of the Millstone barrel sprite. Without
# Warm-brown wood bench. Top band lit, front face darker. ## this, the barrel reads as "water/grain storage" rather than a millstone.
# Vise/saw detail: a small darker square at the top-right corner of the ## The circle sits inside the top half of the barrel's tile.
# front face to suggest a mounted tool. func _draw_millstone_overlay(alpha: float) -> void:
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 wheel := Color(0.40, 0.40, 0.36, alpha)
var outline := Color(0.28, 0.28, 0.26, 0.7 * alpha) var rim := Color(0.22, 0.22, 0.20, alpha)
draw_circle(Vector2(0.0, -10.0), 4.5, wheel)
# Top face. draw_arc(Vector2(0.0, -10.0), 4.5, 0.0, TAU, 12, rim, 1.0)
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: func _draw_generic(alpha: float) -> void:
@ -401,6 +408,11 @@ func _draw_generic(alpha: float) -> void:
func _complete() -> void: func _complete() -> void:
_completed = true _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. # Phase 11: enable PointLight2D for light-emitting workbenches on completion.
if _light != null: if _light != null:
_light.enabled = is_on() _light.enabled = is_on()