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>
This commit is contained in:
megaproxy 2026-05-11 15:54:15 +01:00
parent 43e52ffe75
commit a1e5b38dd6
16 changed files with 606 additions and 10 deletions

245
scenes/entities/torch.gd Normal file
View file

@ -0,0 +1,245 @@
class_name Torch extends Node2D
## Torch furniture entity — buildable wall-hung or floor-standing light source.
##
## Rendered as a small stick + wrapped head + flame using _draw() in the same
## 3/4-perspective convention as Wall / Bed / Workbench. Ghost state (40% alpha)
## while construction is in progress; solid + lit once _completed.
##
## Light model (docs/architecture.md "LightingSystem"):
## Emits a PointLight2D with a procedural radial-gradient texture (soft
## falloff). The Godot visual light is additive with CanvasModulate for
## night-time darkness. The sim-side light radius (LIGHT_RADIUS tiles,
## Manhattan distance) is registered with World.light_sources so that
## is_tile_lit() can answer "in darkness" thought queries cheaply.
##
## Light-source duck-type interface (shared with Hearth / Workbench):
## is_on() → bool
## get_light_tile() → Vector2i
## get_light_radius() → int
##
## Build model (same BuildJob interface as Wall / Bed / Workbench):
## BUILD_TICKS ticks via the standard BuildJob toil; PointLight2D enabled
## only after _complete().
##
## Save/load: to_dict / from_dict capture tile, label_text, build_progress,
## _completed, _is_on. Phase 16 wires these into the full save layer.
##
## World registration: World.register_light_source / World.unregister_light_source
## called from _ready / _exit_tree.
const TILE_SIZE_PX: int = 16
## Sim ticks to build a torch (30 ticks ≈ 1.5 sim seconds at 1×).
const BUILD_TICKS: int = 30
## Sim-side light radius in tiles (Manhattan). Max 8 per architecture.md.
const LIGHT_RADIUS: int = 6
## Pixel size of the procedural radial gradient used for the PointLight2D.
## Larger values give a smoother falloff; 64 is sufficient for a 6-tile radius.
const LIGHT_TEXTURE_SIZE: int = 64
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this torch in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Player-visible label. Drives Audit logs and job descriptions.
@export var label_text: String = "Torch"
# ── 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
## Whether the torch is emitting light. Always true for Phase 11;
## Phase 17 may add fuel consumption or a player on/off toggle.
var _is_on: bool = true
## PointLight2D child node for Godot's visual lighting pipeline.
## Built in _ready(); enabled only once _complete() fires.
var _light: PointLight2D = null
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
# Bottom-anchor so Y-sort occludes pawns correctly (same pivot as Wall/Bed).
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
)
World.register_light_source(self)
_light = _build_point_light_2d()
add_child(_light)
_light.enabled = false # dark until built
queue_redraw()
func _exit_tree() -> void:
World.unregister_light_source(self)
## One-shot initialiser. Call after add_child() so _ready() has already 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 (matches Wall / Bed / Workbench shape) ─────────────────
## True while the torch still needs construction work.
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 while the pawn works.
## Advances build_progress and completes the torch 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 torch has been fully built.
func is_completed() -> bool:
return _completed
## Torches are walkable — pawns step around them visually but the tile remains
## passable for pathfinding purposes.
func blocks_pathing_when_complete() -> bool:
return false
# ── light-source duck-type interface ──────────────────────────────────────────
## Used by World.is_tile_lit() and the "in darkness" thought.
## Hearth / Workbench exposes the same three functions.
## True when the torch is built and switched on.
func is_on() -> bool:
return _completed and _is_on
## The tile this light source occupies (for distance calculations).
func get_light_tile() -> Vector2i:
return tile
## The sim-side Manhattan-distance radius of this light source.
func get_light_radius() -> int:
return LIGHT_RADIUS
## Toggle the torch on/off. Phase 17 may call this from a fuel-depletion system
## or player action; safe to call any time including before _complete().
func set_on(value: bool) -> void:
_is_on = value
if _light != null:
_light.enabled = _completed and _is_on
Audit.log("light", "torch at %s set_on → %s" % [tile, value])
# ── save / load ───────────────────────────────────────────────────────────────
## Serialise all persistent state for World save (wired in Phase 16).
func to_dict() -> Dictionary:
return {
"tile_x": tile.x,
"tile_y": tile.y,
"label_text": label_text,
"build_progress": build_progress,
"completed": _completed,
"is_on": _is_on,
}
## Restore from a dict produced by to_dict().
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", "Torch"))
build_progress = int(d.get("build_progress", 0))
_completed = bool(d.get("completed", false))
_is_on = bool(d.get("is_on", true))
# _light is rebuilt in _ready() — no need to restore the node.
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void:
# 3/4-perspective torch — fits within the tile (16×16 local box).
# Origin (0,0) = tile bottom-centre. Tile spans local Y: -16 to 0.
# Ghost (not yet built) draws at 0.4 alpha.
var alpha: float = 1.0 if _completed else 0.4
# Stick — narrow vertical brown rect, centred horizontally.
draw_rect(Rect2(Vector2(-1.0, -9.0), Vector2(2.0, 9.0)), Color(0.35, 0.22, 0.10, alpha))
# Wrapped head — slightly wider, darker, at the top of the stick.
draw_rect(Rect2(Vector2(-2.0, -13.0), Vector2(4.0, 4.0)), Color(0.20, 0.14, 0.06, alpha))
# Flame — only when built and on; two overlapping circles for depth.
if _completed and _is_on:
draw_circle(Vector2(0.0, -15.0), 2.5, Color(1.0, 0.65, 0.10, alpha))
draw_circle(Vector2(0.0, -16.0), 1.5, Color(1.0, 0.90, 0.40, alpha))
# ── internal helpers ──────────────────────────────────────────────────────────
## Construct and return the PointLight2D that provides Godot-side visual
## lighting. The light is positioned at the flame, not the tile base.
func _build_point_light_2d() -> PointLight2D:
var p := PointLight2D.new()
p.texture = _build_radial_light_texture(LIGHT_TEXTURE_SIZE)
# Scale the texture so its radius in world-pixels matches LIGHT_RADIUS tiles.
p.texture_scale = float(LIGHT_RADIUS) * float(TILE_SIZE_PX) / float(LIGHT_TEXTURE_SIZE) * 2.0
p.color = Color(1.0, 0.85, 0.55, 1.0) # warm fire tint
p.energy = 1.2
# Offset upward so the light originates from the flame position.
p.position = Vector2(0.0, -15.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.
## Called once per torch in _ready(); result is ~4 KB of VRAM at 64×64 RGBA8.
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)
# Smoothstep so the edge is soft rather than a hard circle cutoff.
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)
## Called when build_progress reaches BUILD_TICKS.
func _complete() -> void:
_completed = true
if _light != null:
_light.enabled = _is_on
queue_redraw()
Audit.log("torch", "built at %s" % tile)

View file

@ -0,0 +1 @@
uid://d0yg2xw7btr1a

View file

@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://torch_entity"]
[ext_resource type="Script" path="res://scenes/entities/torch.gd" id="1_torch"]
[node name="Torch" type="Node2D"]
y_sort_enabled = true
z_index = 0
script = ExtResource("1_torch")

View file

@ -31,6 +31,17 @@ 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.
@ -68,6 +79,11 @@ var current_bill = null
## 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 ─────────────────────────────────────────────────────────────────
@ -78,11 +94,20 @@ func _ready() -> void:
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.
@ -180,6 +205,29 @@ func on_craft_interrupted() -> void:
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).
@ -352,5 +400,43 @@ func _draw_generic(alpha: float) -> void:
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)