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

138
autoload/clock.gd Normal file
View file

@ -0,0 +1,138 @@
extends Node
## In-game clock: sim-tick → day, hour, minute, darkness factor.
##
## Design (docs/architecture.md "Time / tick model"):
## - 1 in-game day = TICKS_PER_DAY sim ticks
## 4800 ticks/day = 240 s real at 1× = 48 s at Fast (5×) = 20 s at Ultra (12×)
## - 24 in-game hours per day → TICKS_PER_HOUR = 200
## - TICKS_PER_MINUTE = 200 / 60 = 3 (integer floor; ~3.33 ticks per in-game min)
##
## Time-of-day phases (docs/design.md "Day/Night cycle"):
## Night: 22:0005:00
## Dawn: 05:0007:00 (2-hour smooth ramp, darkness 1.0 → 0.0)
## Day: 07:0019:00
## Dusk: 19:0022:00 (3-hour smooth ramp, darkness 0.0 → 1.0)
##
## darkness_factor() returns 0.0 (full daylight) to 1.0 (deepest night).
##
## Game starts at Day 1, 06:00 (mid-dawn — atmospheric, not full dark).
##
## Save seam: save_dict() / apply_dict() persist _start_offset_ticks so
## the time-of-day survives a session reload.
const TICKS_PER_DAY: int = 4800
const TICKS_PER_HOUR: int = 200
## Integer floor of 200/60 ≈ 3.33. Minute display rounds down — fine for a clock.
const TICKS_PER_MINUTE: int = TICKS_PER_HOUR / 60
const START_HOUR: int = 6
const HOURS_PER_DAY: int = 24
# Phase boundaries (in-game hours, inclusive lower bound).
const DAWN_START_HOUR: int = 5
const DAWN_END_HOUR: int = 7
const DUSK_START_HOUR: int = 19
const DUSK_END_HOUR: int = 22
## Internal: added to Sim.tick so we begin at START_HOUR rather than midnight.
var _start_offset_ticks: int = START_HOUR * TICKS_PER_HOUR
## Fired when the time-phase transitions (Night → Dawn, Dawn → Day, etc.).
signal phase_changed(phase: StringName)
const PHASE_NIGHT: StringName = &"night"
const PHASE_DAWN: StringName = &"dawn"
const PHASE_DAY: StringName = &"day"
const PHASE_DUSK: StringName = &"dusk"
var _last_emitted_phase: StringName = &""
func _ready() -> void:
EventBus.sim_tick.connect(_on_sim_tick)
# ── public API ───────────────────────────────────────────────────────────────
## Current in-game day (1-indexed). Day 1 starts at boot.
func current_day() -> int:
return 1 + (_offset_ticks() / TICKS_PER_DAY)
## Current hour 0..23.
func current_hour() -> int:
return (_offset_ticks() / TICKS_PER_HOUR) % HOURS_PER_DAY
## Current minute 0..59.
func current_minute() -> int:
var ticks_into_hour: int = _offset_ticks() % TICKS_PER_HOUR
return (ticks_into_hour * 60) / TICKS_PER_HOUR
## Time of day as a 0..1 fraction (0.0 = midnight, 0.5 = noon).
func time_of_day_fraction() -> float:
return float(_offset_ticks() % TICKS_PER_DAY) / float(TICKS_PER_DAY)
## Returns 0.0 (full daylight) to 1.0 (deepest night).
##
## Ramp shapes:
## Day [07:0019:00] → 0.0 flat
## Night [22:0005:00] → 1.0 flat
## Dawn [05:0007:00] → 1.0 ramps down to 0.0 linearly over 2 hours
## Dusk [19:0022:00] → 0.0 ramps up to 1.0 linearly over 3 hours
func darkness_factor() -> float:
var h: float = float(current_hour()) + float(current_minute()) / 60.0
# Full daylight band.
if h >= DAWN_END_HOUR and h < DUSK_START_HOUR:
return 0.0
# Full night band — handles the wrap (h >= 22 OR h < 5).
if h >= DUSK_END_HOUR or h < DAWN_START_HOUR:
return 1.0
# Dawn ramp: 1.0 at 05:00 → 0.0 at 07:00
if h < DAWN_END_HOUR:
return 1.0 - (h - DAWN_START_HOUR) / float(DAWN_END_HOUR - DAWN_START_HOUR)
# Dusk ramp: 0.0 at 19:00 → 1.0 at 22:00
return (h - DUSK_START_HOUR) / float(DUSK_END_HOUR - DUSK_START_HOUR)
## Current phase as a StringName constant. Phase transitions emit phase_changed.
func current_phase() -> StringName:
var h: int = current_hour()
if h < DAWN_START_HOUR or h >= DUSK_END_HOUR:
return PHASE_NIGHT
if h < DAWN_END_HOUR:
return PHASE_DAWN
if h < DUSK_START_HOUR:
return PHASE_DAY
return PHASE_DUSK
## "HH:MM" formatted 24-hour time string.
func time_string() -> String:
return "%02d:%02d" % [current_hour(), current_minute()]
# ── internal ─────────────────────────────────────────────────────────────────
func _offset_ticks() -> int:
return _start_offset_ticks + Sim.tick
func _on_sim_tick(_n: int) -> void:
var phase: StringName = current_phase()
if phase != _last_emitted_phase:
_last_emitted_phase = phase
emit_signal("phase_changed", phase)
Audit.log("clock", "phase → %s (day %d, %s)" % [phase, current_day(), time_string()])
# ── save / load ──────────────────────────────────────────────────────────────
func save_dict() -> Dictionary:
return {"start_offset_ticks": _start_offset_ticks}
func apply_dict(d: Dictionary) -> void:
_start_offset_ticks = int(d.get("start_offset_ticks", START_HOUR * TICKS_PER_HOUR))

1
autoload/clock.gd.uid Normal file
View file

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

View file

@ -19,6 +19,8 @@ const TABLE: Dictionary = {
&"speed.ultra": "12×",
# HUD
&"hud.tick": "Tick: {n}",
# Phase 11 — in-game clock display ("{d}" = day, "{t}" = "HH:MM")
&"clock.format": "Day {d}, {t}",
# Pawn state labels
&"pawn.state.idle": "idle",
&"pawn.state.walking": "walking",
@ -41,6 +43,8 @@ const TABLE: Dictionary = {
# Phase 7 — pawn hunger states
&"pawn.state.eating": "eating",
&"pawn.state.hungry": "hungry",
# Phase 11 — mood thoughts (player-visible in pawn-detail, Phase 17)
&"thought.in_darkness": "In darkness",
# Phase 6 — quality tier labels
&"quality.shoddy": "Shoddy",
&"quality.normal": "Normal",

View file

@ -54,6 +54,13 @@ var crops: Array = []
# Storyteller also reads beds.size() for the "First Beds" state predicate.
var beds: Array = []
# Phase 11 — light-source entities (Torch + Hearth workbench). Entities call
# register_light_source() in _ready and unregister_light_source() in _exit_tree.
# is_tile_lit() is queried by the "in darkness" thought and any future
# darkness-rendering shader bridge. All entries expose the duck-type interface:
# is_on() → bool | get_light_tile() → Vector2i | get_light_radius() → int
var light_sources: Array = []
# Phase 4 — hauling dirty set. Keys are Items, value is unused (we just use .keys()).
# An Item is added when it spawns (Tree.fell, Rock.mined, workbench drop, ...)
# and removed when it lands at its highest-priority valid destination.
@ -193,6 +200,33 @@ func unregister_bed(b) -> void:
beds.erase(b)
# ── Phase 11: light-source registry ────────────────────────────────────────
func register_light_source(ls) -> void:
if not light_sources.has(ls):
light_sources.append(ls)
func unregister_light_source(ls) -> void:
light_sources.erase(ls)
## Returns true if `tile` is within get_light_radius() of any is_on() light
## source. Uses Manhattan distance (no wall-occlusion in Phase 11; Phase 13
## may add BFS-based occlusion through the room/roof system).
##
## Called by the "in darkness" Thought trigger on each pawn sim tick.
## O(light_sources) per call; trivial at our scale (< 50 sources in MVP).
func is_tile_lit(p_tile: Vector2i) -> bool:
for ls in light_sources:
if not ls.is_on():
continue
var d: int = abs(ls.get_light_tile().x - p_tile.x) + abs(ls.get_light_tile().y - p_tile.y)
if d <= ls.get_light_radius():
return true
return false
# Called by Wall.on_build_tick() when construction completes.
# Stamps the data-only Wall TileMap layer so room/roof/save logic sees the
# wall. World scene exposes wall_layer via a getter set during _ready.

View file

@ -15,6 +15,7 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for
| ✅ done — Recipe + Bill data, Workbench entity (Carpenter / Smelter via label_text), CraftingProvider, KIND_CRAFT toil, 5-tier Quality system, Pawn skills, wall-trap fix | **Phase 6 — Production: workbenches, recipes, bills, quality** |
| ✅ done — Crop entity (6-stage state machine), PlantProvider (harvest), Hunger need + EatProvider w/ food-priority ladder, Hearth/Millstone via label_text, grain/flour/bread/meal types | **Phase 7 — Plants, cooking, hunger** |
| ✅ done — Bed entity (quality-tinted, claim/release), Sleep need + SleepProvider + KIND_SLEEP toil, Thought registry + mood compute + Sulking soft-break, Decision Layer-1 interrupt | **Phase 8 — Sleep, mood, thoughts** |
| ✅ done (out of order — taken before Phase 9 for the atmospheric win) — Clock autoload + dawn/day/dusk/night phases + darkness_factor ramp, CanvasModulate global tint, Torch entity + PointLight2D + procedural radial gradient, Hearth opts-in as light source, in_darkness thought | **Phase 11 — Day/night + Lighting** |
| ⏳ next | **Phase 9 — Status effects + Medicine** |
Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it.

View file

@ -24,6 +24,7 @@ Audit="*res://autoload/audit.gd"
GameState="*res://autoload/game_state.gd"
World="*res://autoload/world.gd"
Sim="*res://autoload/sim.gd"
Clock="*res://autoload/clock.gd"
SaveSystem="*res://autoload/save_system.gd"
MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd"
MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd"

View file

@ -77,6 +77,19 @@ static func slept_on_floor() -> Thought:
return t
## Mood penalty while a pawn is in an unlit tile at night.
## modifier=-3, max_stacks=1, PERSISTENT.
## Phase 17 polish may split into "outdoor dark" / "cave dark" tiers.
static func in_darkness() -> Thought:
var t := Thought.new()
t.id = &"in_darkness"
t.label = "In darkness"
t.modifier = -3
t.lifetime = Thought.Lifetime.PERSISTENT
t.max_stacks = 1
return t
## Small mood boost after eating a cooked meal or bread.
## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_BREAD.
## Stacks up to 3 (multiple good meals compound, but cap at 3).

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)

View file

@ -286,6 +286,13 @@ func _process_thoughts() -> void:
# 2. Sync PERSISTENT thoughts.
_sync_persistent_thought(&"hungry", is_hungry(), ThoughtCatalog.hungry())
_sync_persistent_thought(&"tired", is_tired(), ThoughtCatalog.tired())
# Phase 11 — in_darkness fires when past dusk/before dawn AND the pawn's
# tile is unlit. architecture.md "LightingSystem": is_lit = light_map > 0.2;
# darkness_factor > 0.3 spans dusk-mid through dawn-mid.
# Phase 13 may add wall-occlusion via room BFS; for now radius-8 falloff only.
var _dark_time := Clock.darkness_factor() > 0.3
var _lit := World.is_tile_lit(tile)
_sync_persistent_thought(&"in_darkness", _dark_time and not _lit, ThoughtCatalog.in_darkness())
# 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally).
if dirty:
_recompute_mood()

View file

@ -9,22 +9,27 @@ extends CanvasLayer
const ACTIVE_MODULATE := Color(1.2, 1.2, 0.8)
const IDLE_MODULATE := Color.WHITE
@onready var pause_btn : Button = $Anchor/ButtonRow/PauseBtn
@onready var normal_btn : Button = $Anchor/ButtonRow/NormalBtn
@onready var fast_btn : Button = $Anchor/ButtonRow/FastBtn
@onready var ultra_btn : Button = $Anchor/ButtonRow/UltraBtn
@onready var tick_label : Label = $Anchor/TickLabel
@onready var pause_btn : Button = $Anchor/ButtonRow/PauseBtn
@onready var normal_btn : Button = $Anchor/ButtonRow/NormalBtn
@onready var fast_btn : Button = $Anchor/ButtonRow/FastBtn
@onready var ultra_btn : Button = $Anchor/ButtonRow/UltraBtn
@onready var tick_label : Label = $Anchor/TickLabel
@onready var clock_label : Label = $Anchor/ClockLabel
# Maps Speed enum value → the corresponding Button node.
var _speed_buttons: Dictionary = {}
# Early-out cache: only set clock_label.text when the string changes.
var _last_clock_text: String = ""
func _ready() -> void:
pause_btn.text = Strings.t(&"speed.pause")
normal_btn.text = Strings.t(&"speed.normal")
fast_btn.text = Strings.t(&"speed.fast")
ultra_btn.text = Strings.t(&"speed.ultra")
tick_label.text = "(boot)"
pause_btn.text = Strings.t(&"speed.pause")
normal_btn.text = Strings.t(&"speed.normal")
fast_btn.text = Strings.t(&"speed.fast")
ultra_btn.text = Strings.t(&"speed.ultra")
tick_label.text = "(boot)"
clock_label.text = Strings.t(&"clock.format").format({"d": 1, "t": "06:00"})
_speed_buttons = {
Sim.Speed.PAUSE: pause_btn,
@ -40,6 +45,7 @@ func _ready() -> void:
EventBus.speed_changed.connect(_on_speed_changed)
EventBus.sim_tick.connect(_on_sim_tick)
EventBus.sim_tick.connect(_on_clock_refresh)
# Reflect the initial speed state without emitting a signal.
_apply_highlight(Sim.current_speed)
@ -64,6 +70,13 @@ func _on_sim_tick(tick_number: int) -> void:
tick_label.text = Strings.t(&"hud.tick").format({"n": tick_number})
func _on_clock_refresh(_n: int) -> void:
var t: String = Strings.t(&"clock.format").format({"d": Clock.current_day(), "t": Clock.time_string()})
if t != _last_clock_text:
_last_clock_text = t
clock_label.text = t
func _apply_highlight(speed: Sim.Speed) -> void:
for s: int in _speed_buttons:
_speed_buttons[s].modulate = ACTIVE_MODULATE if s == speed else IDLE_MODULATE

View file

@ -34,6 +34,18 @@ text = "5×"
focus_mode = 0
text = "12×"
[node name="ClockLabel" type="Label" parent="Anchor"]
anchor_left = 0.5
anchor_right = 0.5
anchor_bottom = 0.0
offset_left = -80.0
offset_top = 8.0
offset_right = 80.0
offset_bottom = 40.0
grow_horizontal = 2
text = "Day 1, 06:00"
horizontal_alignment = 1
[node name="TickLabel" type="Label" parent="Anchor"]
anchor_left = 1.0
anchor_right = 1.0

View file

@ -29,6 +29,7 @@ const WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/workbench.ts
const CROP_SCENE: PackedScene = preload("res://scenes/entities/crop.tscn")
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
const BED_SCENE: PackedScene = preload("res://scenes/entities/bed.tscn")
const TORCH_SCENE: PackedScene = preload("res://scenes/entities/torch.tscn")
# 3 starting pawns — Phase 2 demo. Phase 7+ replaces this with map-gen + name table.
const SAMPLE_PAWNS: Array[Dictionary] = [
@ -51,6 +52,12 @@ const SAMPLE_ROCKS: Array[Vector2i] = [
# HaulingProvider re-flow cadence — every 5 sim seconds at 1× (100 ticks).
const HAUL_SWEEP_INTERVAL_TICKS: int = 100
# Phase 11 — global darkness tint. Day = white, night = deep cool blue.
# Driven by Clock.darkness_factor() (0..1) each sim tick.
const NIGHT_TINT: Color = Color(0.20, 0.22, 0.40, 1.0)
const DAY_TINT: Color = Color(1.0, 1.0, 1.0, 1.0)
@onready var dark_overlay: CanvasModulate = $DarkOverlay
@onready var terrain_layer: TileMapLayer = $Terrain
@onready var floor_layer: TileMapLayer = $Floor
@onready var wall_layer: TileMapLayer = $Wall
@ -395,6 +402,19 @@ func _seed_phase5_demo_buildings() -> void:
bed.on_build_tick()
Audit.log("world", "phase 8 demo: %d beds pre-built inside cabin" % bed_tiles.size())
# Phase 11 demo — 2 torches inside cabin (north-east + south-west corners
# of interior) so the indoor area stays lit at night. Combined with the
# Hearth's light radius=5, the cabin interior should be mostly bright
# while the outdoors goes deep-blue tinted.
var torch_tiles: Array[Vector2i] = [Vector2i(46, 26), Vector2i(49, 26)]
for tt in torch_tiles:
var torch: Torch = TORCH_SCENE.instantiate()
add_child(torch)
torch.setup(tt)
while torch.is_buildable():
torch.on_build_tick()
Audit.log("world", "phase 11 demo: %d torches pre-built inside cabin" % torch_tiles.size())
func _spawn_sample_stockpiles() -> void:
# Two zones for the Phase 4 acceptance demo:
@ -463,11 +483,20 @@ func _on_designation_cleared(cell: Vector2i) -> void:
# ── periodic re-flow (the "wood floats up" cascade) ─────────────────────────
func _on_sim_tick_world_sweep(tick_n: int) -> void:
_update_dark_overlay()
if tick_n % HAUL_SWEEP_INTERVAL_TICKS != 0:
return
hauling_provider.sweep_for_better_destinations()
# Phase 11 — interpolate CanvasModulate between DAY_TINT and NIGHT_TINT based
# on Clock.darkness_factor() (0 = full day, 1 = full night).
# Called every sim tick; Color.lerp is a handful of float ops — negligible cost.
func _update_dark_overlay() -> void:
var f := Clock.darkness_factor()
dark_overlay.color = DAY_TINT.lerp(NIGHT_TINT, f)
# ── spike: AStarGrid2D query timing at 80² ──────────────────────────────────
func _run_pathfinder_spike() -> void:

View file

@ -19,6 +19,9 @@
y_sort_enabled = true
script = ExtResource("1_world")
[node name="DarkOverlay" type="CanvasModulate" parent="."]
color = Color(1, 1, 1, 1)
[node name="Terrain" type="TileMapLayer" parent="."]
z_index = 0