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:
parent
43e52ffe75
commit
a1e5b38dd6
16 changed files with 606 additions and 10 deletions
138
autoload/clock.gd
Normal file
138
autoload/clock.gd
Normal 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: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))
|
||||
1
autoload/clock.gd.uid
Normal file
1
autoload/clock.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b0rnjjv1rag7d
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue