rimlike/scenes/entities/workbench.gd
megaproxy a1e5b38dd6 Phase 11 — Day/night cycle + Lighting (taken before Phase 9 per recommendation)
Three gdscript-refactor agents in parallel; Opus integrated and verified
the day-night transition + torch lighting via MCP runtime + screenshot.

Clock autoload (Agent A, autoload/clock.gd, ~138 lines):
- TICKS_PER_DAY = 4800 → 4 min/day at 1× / 48 s at Fast / 20 s at Ultra
- TICKS_PER_HOUR = 200 (so 60 min × ~3 ticks per minute)
- 4-phase day: night → dawn (5–7) → day (7–19) → dusk (19–22) → night
- darkness_factor() returns 0..1 with linear ramps across dawn/dusk
- phase_changed signal fires on phase transitions
- save_dict / apply_dict for save round-trip
- Boots at Day 1, 06:00 (mid-dawn for atmospheric start)
- Registered in project.godot autoload list (Opus)

Top-bar clock UI (Agent A):
- ClockLabel added to top_bar.tscn (center-anchored at ±80 px)
- _on_clock_refresh in top_bar.gd; early-out string compare to skip text
  assignments when unchanged (cheap per-tick)

Torch entity + lights registry (Agent B, scenes/entities/torch.{gd,tscn} +
workbench.gd + world.gd, ~210 lines):
- class Torch: buildable furniture, BUILD_TICKS=30, LIGHT_RADIUS=6
- Procedural radial gradient texture (64×64) generated at runtime with
  smoothstep falloff → no PNG dependency
- PointLight2D child with the gradient texture, warm fire tint, energy 1.2
- is_on / get_light_tile / get_light_radius duck-typed interface; same
  shape exposed by Workbench when label_text='Hearth' (HEARTH_LIGHT_RADIUS=5)
- World.light_sources registry + register/unregister + is_tile_lit(tile)
  (Manhattan distance, no occlusion — Phase 13 may add wall-occlusion)

CanvasModulate darkness + in_darkness thought (Agent C, ~30 lines mod +
new factory):
- DarkOverlay CanvasModulate node added to world.tscn (first child of
  World root so it tints all sibling layers + entities)
- world.gd._update_dark_overlay lerps DAY_TINT (white) ↔ NIGHT_TINT
  (0.20, 0.22, 0.40 deep cool blue) by Clock.darkness_factor() each tick
- ThoughtCatalog.in_darkness(): persistent, -3 mood, fires when
  darkness > 0.3 AND World.is_tile_lit(pawn.tile) is false
- Pawn._process_thoughts syncs in_darkness alongside hungry/tired

Opus integration:
- project.godot: Clock autoload registered
- world.tscn: DarkOverlay CanvasModulate node, plus the agent additions
- Demo seed: 2 torches inside cabin at (46, 26) + (49, 26), pre-built
- MCP-driven runtime test verified day→night transition + lighting
  effects:
  - Noon: world bright green, torches barely visible (over-bright at noon
    is minor polish — Phase 17 may scale torch energy by darkness)
  - Midnight: world deep blue/green tinted, torches cast yellow halos,
    Hearth ember glows orange, cabin interior warmly lit, exterior dark
- top_bar clock label updates each sim tick (early-out on no-change)

Phase 11 followups for later phases:
- Torch energy should scale with darkness — visible halos at noon are
  silly. Phase 17 will likely tie PointLight2D.energy to clamp(darkness,
  0.2, 1.0) so they're invisible at midday
- Wall-occlusion for light_map — Phase 13's room-detection BFS could
  treat completed wall tiles as occluders so light doesn't bleed through
- 'In darkness' thought currently treats ALL unlit cells as darkness;
  Phase 13's roof flag could differentiate 'indoors-dark' (different
  thought) from 'outdoors-dark'
- Light source visibility through CanvasModulate works correctly thanks
  to PointLight2D's additive blend mode

Acceptance — MCP-verified via play_scene + get_game_screenshot:
-  Day → Dusk → Night cycle visible (Clock.current_phase emits events)
-  CanvasModulate tints world deep blue at night
-  Torches cast visible yellow halos via PointLight2D additive blend
-  Hearth opts-in as a light source via label_text='Hearth' check
-  Top-bar clock shows 'Day N, HH:MM' format and updates each tick
-  in_darkness thought wires through _process_thoughts (would fire if
  a pawn were standing in an unlit night tile — demo didn't capture this
  specifically but the code path is verified)

Delegation report this phase:
- Agent A: Clock autoload + 4-phase day cycle + top-bar UI extension
- Agent B: Torch entity + PointLight2D + procedural radial texture +
  Workbench Hearth opt-in + World.light_sources registry
- Agent C: CanvasModulate world.tscn node + day/night colour lerp +
  in_darkness ThoughtCatalog entry + Pawn persistent thought sync
- Opus: Clock autoload registration in project.godot + 2 torches in
  demo seed + MCP runtime verification at midnight vs noon

~75% of Phase 11 GDScript was subagent-authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:54:15 +01:00

442 lines
18 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 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)