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>
138 lines
4.9 KiB
GDScript
138 lines
4.9 KiB
GDScript
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:00–05:00
|
||
## Dawn: 05:00–07:00 (2-hour smooth ramp, darkness 1.0 → 0.0)
|
||
## Day: 07:00–19:00
|
||
## Dusk: 19:00–22: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:00–19:00] → 0.0 flat
|
||
## Night [22:00–05:00] → 1.0 flat
|
||
## Dawn [05:00–07:00] → 1.0 ramps down to 0.0 linearly over 2 hours
|
||
## Dusk [19:00–22: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))
|