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.