rimlike/autoload/clock.gd
megaproxy 92f4e5c945 Phase 12: Seasons + Weather (rolls, rain, storm, wet/cold)
48-day year (4 seasons × 12 days), daily weather rolls, rain visual,
storm flash, Wet/Cold statuses with mood thoughts.

Three-agent fan-out — Opus prepped contracts up front (event_bus
signals, Clock season constants, Weather autoload stub) so the three
slices could run fully parallel and integrate on first try.

Calendar (Agent A):
- Clock season API — SEASON_SPRING/SUMMER/AUTUMN/WINTER constants,
  current_season(), current_season_index(), day_of_season(),
  current_year(), DAYS_PER_SEASON=12, DAYS_PER_YEAR=48
- EventBus.season_changed emitted on transition (mirrors phase_changed)
- Top-bar SeasonLabel ('Spring 1/12') with localized season names via
  Strings.t() — season.spring/summer/autumn/winter + season.format
- Terrain TileMapLayer seasonal palette modulate
  (spring=warm-green, summer=neutral, autumn=warm-orange, winter=cool-blue)

Weather (Agent B):
- autoload/weather.gd — daily roll triggered by Clock day-index change
  Probability tables per season (placeholders, tune Phase 20):
    spring  60% clear / 35% rain /  5% storm
    summer  75% clear / 18% rain /  7% storm
    autumn  50% clear / 35% rain / 12% storm /  3% cold_snap
    winter  55% clear / 15% rain / 10% storm / 20% cold_snap
- EventBus.weather_changed signal
- scenes/world/rain_overlay.tscn — procedural _draw() diagonal raindrops
  on a CanvasLayer (chosen over CPUParticles2D for pixel-art exactness
  and to colocate storm-flash logic in one weather-aware script)
- Storm flash — Tween-driven ColorRect at random 4-8s intervals
- Save round-trip preserves _last_day_index to prevent double-rolling

Wet + Cold + Mood (Agent C):
- StatusCatalog.wet(severity 1-2) — Damp at 25, Soaked at 60 (of 100)
- StatusCatalog.cold(severity 1-3) — Mild at 25, Severe at 60, Extreme at 85
- ThoughtCatalog.damp(-3), soaked(-6), cold_thought(-4)
- Pawn._wet_accum / _cold_accum floats, ticked in _process_statuses:
  +0.02/tick rain (×2 storm), -0.05/tick decay when sheltered
  +0.015/tick cold winter-or-snap (×2 cold_snap)
- _sync_wet_status / _sync_cold_status — severity-flip detection with
  one Audit line per transition
- _is_sheltered() v1: World.floor_layer.get_cell_source_id != -1
  Phase 13 replaces with proper Room BFS
- _wet_accum / _cold_accum round-trip through Pawn.to_dict / from_dict
- Persistent thought sync in _process_thoughts after in_darkness

MCP runtime verified:
- Top-bar 'Spring 1/12' renders; green seasonal terrain tint visible
- Rain droplets render across screen; storm flash captured mid-animation
- Bram wet=26 (Damp) → wet=65 (Soaked) with mood thought (-6), mood=30
- Cora cold=30 cold_snap → Cold status sev=1 + Cold thought (-4), mood=32
- Daily weather rolls visible day 0-5 (rain → clear → rain → clear → rain)

Quick-edit fixup mid-flight: Variant inference errors on
'var old_sev := s.severity' (untyped Array loop var). Same trap as
the Phase 7 crop fix; pattern is now to always explicit-type ':='
when the rhs is non-typed-Array element access.

Delegation: 3× gdscript-refactor agents in parallel, 1× quick-edit
for the Variant-inference fix; integration + MCP verify on Opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:39:34 +01:00

186 lines
6.6 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"
# Phase 12 — Season contracts (Agent A fills in current_season() + day_of_season()).
# Names locked here so Agent B + C can reference them without race conditions.
const SEASON_SPRING: StringName = &"spring"
const SEASON_SUMMER: StringName = &"summer"
const SEASON_AUTUMN: StringName = &"autumn"
const SEASON_WINTER: StringName = &"winter"
const SEASONS: Array[StringName] = [SEASON_SPRING, SEASON_SUMMER, SEASON_AUTUMN, SEASON_WINTER]
const DAYS_PER_SEASON: int = 12
const SEASONS_PER_YEAR: int = 4
const DAYS_PER_YEAR: int = DAYS_PER_SEASON * SEASONS_PER_YEAR # 48
var _last_emitted_phase: StringName = &""
## Mirrors _last_emitted_phase — guards the season_changed emit against repeat fires.
var _last_emitted_season: 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()]
# Phase 12 — Season public API (stub; Agent A wires emit + season_changed).
## Day index since game start (0-indexed, ignores 1-indexed display).
func day_index_from_start() -> int:
return _offset_ticks() / TICKS_PER_DAY
## Current season index 0..3 (spring/summer/autumn/winter).
func current_season_index() -> int:
return (day_index_from_start() / DAYS_PER_SEASON) % SEASONS_PER_YEAR
## Current season as a locked StringName constant.
func current_season() -> StringName:
return SEASONS[current_season_index()]
## Day within the current season, 0..DAYS_PER_SEASON-1.
func day_of_season() -> int:
return day_index_from_start() % DAYS_PER_SEASON
## Current year (1-indexed; Year 1 starts at boot).
func current_year() -> int:
return 1 + day_index_from_start() / DAYS_PER_YEAR
# ── 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()])
var season: StringName = current_season()
if season != _last_emitted_season:
_last_emitted_season = season
EventBus.season_changed.emit(season)
Audit.log("clock", "season → %s (day %d/%d of season, year %d)" % [
season, day_of_season() + 1, DAYS_PER_SEASON, current_year()
])
# ── 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))