rimlike/autoload/clock.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

138 lines
4.9 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.

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))