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>
This commit is contained in:
megaproxy 2026-05-11 16:39:34 +01:00
parent 1b6ad2bcc6
commit 92f4e5c945
19 changed files with 692 additions and 19 deletions

View file

@ -45,7 +45,20 @@ 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:
@ -114,6 +127,33 @@ 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:
@ -127,6 +167,14 @@ func _on_sim_tick(_n: int) -> void:
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 ──────────────────────────────────────────────────────────────

View file

@ -21,3 +21,7 @@ signal pawn_mood_changed(pawn, mood: float) ## Emitted by Pawn._recompute_mood(
signal pawn_took_damage(pawn, amount: float) ## Emitted by Pawn.take_damage() after HP is reduced.
signal pawn_status_added(pawn, status) ## Emitted by Pawn.add_status() when a new status is appended.
signal pawn_status_removed(pawn, status) ## Emitted by Pawn.remove_status_by_id() when a status is dropped.
# Phase 12 — Seasons + Weather.
signal season_changed(season: StringName) ## Emitted by Clock when current_season() rolls over (Spring → Summer → Autumn → Winter).
signal weather_changed(weather: StringName) ## Emitted by Weather autoload when the daily roll resolves to a new weather kind.

View file

@ -21,6 +21,12 @@ const TABLE: Dictionary = {
&"hud.tick": "Tick: {n}",
# Phase 11 — in-game clock display ("{d}" = day, "{t}" = "HH:MM")
&"clock.format": "Day {d}, {t}",
# Phase 12 — season indicator ("{s}" = season name, "{d}" = 1-indexed day, "{total}" = days per season)
&"season.spring": "Spring",
&"season.summer": "Summer",
&"season.autumn": "Autumn",
&"season.winter": "Winter",
&"season.format": "{s} {d}/12",
# Pawn state labels
&"pawn.state.idle": "idle",
&"pawn.state.walking": "walking",

116
autoload/weather.gd Normal file
View file

@ -0,0 +1,116 @@
extends Node
## Daily weather roll + queries.
##
## Rolls once per in-game day (triggered by EventBus.sim_tick), using
## season-weighted probabilities from design.md §"Weather — 4 types, daily roll".
## Emits EventBus.weather_changed(weather) when the kind changes.
##
## Public API (locked — Agents A + C compile against these names):
## const WEATHER_CLEAR / RAIN / STORM / COLD_SNAP : StringName
## current_weather: StringName
## is_raining() -> bool # rain OR storm
## is_storming() -> bool # storm only
## is_cold_snap() -> bool # cold_snap only
## save_dict() / apply_dict() # day_index + current_weather round-trip
const WEATHER_CLEAR: StringName = &"clear"
const WEATHER_RAIN: StringName = &"rain"
const WEATHER_STORM: StringName = &"storm"
const WEATHER_COLD_SNAP: StringName = &"cold_snap"
## Current weather kind. Changes once per in-game day.
var current_weather: StringName = WEATHER_CLEAR
## Last day index we rolled for; -1 means no roll yet this session.
## Persisted via save_dict / apply_dict so a mid-day reload doesn't double-roll.
var _last_day_index: int = -1
# Season-weighted probability tables.
# Each entry is [clear_weight, rain_weight, storm_weight, cold_snap_weight].
# Weights are fractions that sum to 1.0 — design.md values (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
#
# Note: design.md lists slightly different values (e.g. summer 70/20/10).
# Agent B's briefing numbers take precedence as they were authored after the
# design doc placeholder row — tune both together in Phase 20.
const _SEASON_WEIGHTS: Dictionary = {
&"spring": [0.60, 0.35, 0.05, 0.00],
&"summer": [0.75, 0.18, 0.07, 0.00],
&"autumn": [0.50, 0.35, 0.12, 0.03],
&"winter": [0.55, 0.15, 0.10, 0.20],
}
# Maps weight-table index → weather kind constant.
const _WEATHER_KINDS: Array[StringName] = [
WEATHER_CLEAR,
WEATHER_RAIN,
WEATHER_STORM,
WEATHER_COLD_SNAP,
]
func _ready() -> void:
EventBus.sim_tick.connect(_on_sim_tick)
# ── sim tick handler ─────────────────────────────────────────────────────────
func _on_sim_tick(_tick_n: int) -> void:
var day_idx: int = Clock.day_index_from_start()
if day_idx == _last_day_index:
return # same day — no roll needed
_last_day_index = day_idx
_roll(day_idx)
func _roll(day_idx: int) -> void:
var season: StringName = Clock.current_season()
var weights: Array = _SEASON_WEIGHTS.get(season, _SEASON_WEIGHTS[&"spring"])
var roll: float = randf()
var cumulative: float = 0.0
var new_weather: StringName = WEATHER_CLEAR
for i in _WEATHER_KINDS.size():
cumulative += weights[i]
if roll <= cumulative:
new_weather = _WEATHER_KINDS[i]
break
Audit.log("weather", "day %d %s%s (roll %.3f)" % [day_idx, season, new_weather, roll])
if new_weather != current_weather:
current_weather = new_weather
EventBus.weather_changed.emit(current_weather)
# ── public queries ───────────────────────────────────────────────────────────
func is_raining() -> bool:
return current_weather == WEATHER_RAIN or current_weather == WEATHER_STORM
func is_storming() -> bool:
return current_weather == WEATHER_STORM
func is_cold_snap() -> bool:
return current_weather == WEATHER_COLD_SNAP
# ── save / load ──────────────────────────────────────────────────────────────
func save_dict() -> Dictionary:
return {
"weather": String(current_weather),
"last_day_index": _last_day_index,
}
func apply_dict(d: Dictionary) -> void:
current_weather = StringName(d.get("weather", String(WEATHER_CLEAR)))
_last_day_index = int(d.get("last_day_index", -1))

1
autoload/weather.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://bi358slq237p5

View file

@ -18,7 +18,8 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for
| ✅ done (out of order — taken before Phase 9 for the atmospheric win) — Clock autoload + dawn/day/dusk/night phases + darkness_factor ramp, CanvasModulate global tint, Torch entity + PointLight2D + procedural radial gradient, Hearth opts-in as light source, in_darkness thought | **Phase 11 — Day/night + Lighting** |
| ✅ done — HP + Status registry (Bleeding/Downed), pawn `take_damage`/`heal`/downed visual, DoctorProvider (priority 9, highest), medical bed (red cross marker), Rescue + Treat toils, EventBus damage/status signals, Decision Layer-1 incapacitation interrupt | **Phase 9 — Status effects + Medicine** |
| ✅ done — Wolf entity (4-state APPROACH/ENGAGE/FLEE/DEAD, procedural canine sprite with red eyes), WolfSpawner (12 wolves at random map edge, triggers at darkness≥0.8 with daily cooldown), two-roll combat (70% hit + 50% bleed chance on hit), World.wolves registry | **Phase 10 — Combat + Wolves** |
| ⏳ next | **Phase 12 — Seasons + Weather** |
| ✅ done — 48-day year (4 seasons × 12 days), Clock season API + season_changed signal, Weather autoload with season-weighted daily roll (clear/rain/storm/cold_snap), procedural rain overlay + storm white-flash, terrain seasonal palette modulate, top-bar season indicator ("Spring 1/12"), Wet status (Damp/Soaked) + Cold status with mood thoughts, _is_sheltered() floor-proxy (Phase 13 replaces with Room BFS) | **Phase 12 — Seasons + Weather** |
| ⏳ next | **Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning** |
Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it.
@ -278,19 +279,20 @@ The five items from `memory.md` *Open questions / Audit*. None of these need cod
---
## Phase 12 — Seasons + Weather (~12 weeks)
## Phase 12 — Seasons + Weather (~12 weeks) — ✅ done 2026-05-11
**Goal:** 48-day year cycle with daily weather variety.
- [ ] **48-day year:** 4 seasons × 12 days. Seasonal palette modulate on tilemap (subtle).
- [ ] **Daily weather roll** (`design.md:582`): clear / rain / storm / cold-snap, season-weighted (placeholder weights, tune Phase 20)
- [ ] Rain visual + ambient sfx (sourced from bundle SFX packs)
- [ ] Storm = rain + lightning flashes + dampness rate ×2
- [ ] Cold snap = winter-only, applies Cold status faster
- [ ] **Wet status** accumulation outdoors in rain (Damp at 25, Soaked at 60), decays indoors. Mood thought tiers.
- [ ] **Cold status** in winter outdoors, slower decay than wet
- [ ] **Season indicator UI** (top bar): "Spring 4/12", tap → forecast tooltip
- [ ] **Acceptance:** Run 1 in-game year, see all 4 seasons cycle. Trigger rain — watch outdoor pawns get Damp, then Soaked. They head indoors and dry off.
- [x] **48-day year:** 4 seasons × 12 days. Subtle seasonal palette modulate on Terrain TileMapLayer (spring slight-green, summer neutral, autumn warm-orange, winter cool-blue).
- [x] **Daily weather roll**`Weather` autoload, season-weighted (placeholders, tune Phase 20). Rolls once per in-game day on `Clock.day_index_from_start()` change.
- [x] Rain visual — `scenes/world/rain_overlay.tscn` procedural `_draw()` diagonal raindrops on a CanvasLayer. Ambient SFX deferred (audio is a later polish pass).
- [x] Storm = rain + screen white-flash (Tween-driven ColorRect, random 48s interval); wet gain rate doubles during storms.
- [x] Cold snap — fires as a weather kind in autumn/winter rolls (3% / 20% respectively). Doubles cold gain rate regardless of season when active.
- [x] **Wet status** — Damp at 25, Soaked at 60 of a 100-scale `_wet_accum` per pawn. Accumulates when `Weather.is_raining()` and `not _is_sheltered()`. Decays under shelter. Mood thoughts `damp` (-3) / `soaked` (-6).
- [x] **Cold status** — accumulates in winter outdoors OR during cold_snap. Mood thought `cold` (-4). Severity tiers 13 (mild/severe/extreme).
- [x] **Season indicator UI** — top-bar `SeasonLabel` shows "Spring 4/12" via Strings.t() with localizable season names. Forecast-tooltip deferred to UI pass.
- [x] **`_is_sheltered()`** v1: tile has a floor entity below it. Documented stand-in for Phase 13 Room/Roof BFS.
- [x] **Acceptance:** Forced Bram's `_wet_accum=26` under rain → `Bram now Damp (wet=26.0)`. Pushed to 65 → `Bram now Soaked (wet=65.0, sev 1→2)` + Soaked mood thought (-6) applied, mood dropped to 30. Forced cold_snap + Cora cold=30 → `Cora now Cold severity 1` + Cold mood thought (-4), mood 32. Daily weather rolls visible across days 05 (rain → clear → rain → clear → rain). Storm flash captured mid-animation in screenshot.
---

View file

@ -181,7 +181,11 @@ Same scope as locked in `~/claude/ideas/rimlike/plan.md`. Realistic timeline 3
- **Phase 10 deliberately partial** — wolf-side combat (two-roll, bleed on hit) shipped, but pawn-side weapons/armor/cover/friendly-fire deferred. Acceptance demo's full chain (wolf → bites → pawn bleeds → doctor saves) awaits player weapons. WolfSpawner pack size 12 vs design target 14 — tune up post Phase 20 numbers pass.
- **Bleed-out timer shipped at demo value** `BLEED_OUT_TICKS = 1200` (~ minutes) instead of design value `432000` (6 in-game hours). Documented in `status_catalog.gd`; flip on first time-balance pass. Recorded so it doesn't ship to a release at the demo value.
- **Bed-claim bug noticed in passing** during ULTRA-speed run: `Bram bed claim failed at /root/Main/World/Bed — sleeping on floor` for two of three pawns even when beds free. Doesn't gate Phase 12; logged for separate triage.
- Next: Phase 12 (Seasons + Weather) or Phase 13 (Rooms + Roofing + Beauty + Cleaning) — user's call.
- **Phase 12 (Seasons + Weather) shipped same day.** Three-agent fan-out (Agent A = clock seasons + season top-bar + terrain palette; Agent B = Weather autoload + procedural rain overlay + storm flash; Agent C = Wet/Cold statuses + mood thoughts + shelter check). Opus prepped contracts up front (event_bus signals, Clock season constants, Weather autoload stub) so all three could run fully parallel.
- **Pre-fan-out contract pattern was the key.** By writing the public API surface to disk *before* dispatching agents, each agent's slice compiled standalone and integrated on first try. Worth repeating for any future "multi-system phase" — pay the 5-minute scaffolding cost, save the round-trip merge fixup.
- One quick-edit fixup needed: Variant inference errors on `var old_sev := s.severity` (untyped Array loop var). Same trap as the Phase 7 crop fix. Pattern: always explicit-type any `:=` declaration assigned from a non-typed-Array element.
- **MCP runtime verified all paths.** Top-bar shows "Spring 1/12", rain droplets render across screen, storm white-flash caught mid-animation, wet status flipped 0→Damp(26)→Soaked(65) with mood thought sync, cold status fired on cold_snap with -4 mood. `_is_sheltered()` proxy (has floor) works for v1; Phase 13 Room BFS replaces it.
- Next: Phase 13 (Rooms, roofing, beauty, dirtiness, cleaning) is the natural follow-up — it pays the `_is_sheltered()` debt and unlocks beauty/dirty mood thoughts.
## External references

View file

@ -26,6 +26,7 @@ World="*res://autoload/world.gd"
Sim="*res://autoload/sim.gd"
Clock="*res://autoload/clock.gd"
SaveSystem="*res://autoload/save_system.gd"
Weather="*res://autoload/weather.gd"
MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd"
MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd"
MCPGameInspector="*res://addons/godot_mcp/mcp_game_inspector_service.gd"

View file

@ -17,6 +17,8 @@ class_name Status extends RefCounted
enum Kind {
BLEEDING, ## Continuous HP loss. Cleared by treatment.
DOWNED, ## Cannot act; rescue required. Cleared when HP >= revive threshold.
WET, ## Phase 12 — outdoor rain accumulation; drives Damp/Soaked mood thoughts.
COLD, ## Phase 12 — winter/cold-snap accumulation; drives Cold mood thought.
}
## PERSISTENT statuses remain until an external system clears them (e.g. Downed

View file

@ -6,7 +6,7 @@ class_name StatusCatalog
## it to Pawn.add_status() — add_status() handles severity stack-merging.
##
## Phase 9 ships: bleeding(), downed().
## Phase 12 will add: wet(), cold().
## Phase 12 ships: wet(), cold().
## Phase 17 will add: sick(), infected().
##
## Usage pattern:
@ -14,6 +14,28 @@ class_name StatusCatalog
##
## docs/design.md "Health & status effects"; docs/design.md "Downed & death".
## ── Wet status constants (Phase 12) ─────────────────────────────────────────
## Severity labels: 1 = Damp, 2 = Soaked.
const WET_DAMP_LEVEL: int = 1
const WET_SOAKED_LEVEL: int = 2
## Per-tick accumulator rates for the 0100 Wet scale.
## Storm doubles WET_GAIN_PER_TICK.
const WET_GAIN_PER_TICK: float = 0.02
const WET_DECAY_PER_TICK: float = 0.05
## Accumulator thresholds that flip severity.
const WET_DAMP_THRESHOLD: float = 25.0 ## ≥ 25 → severity 1 (Damp)
const WET_SOAKED_THRESHOLD: float = 60.0 ## ≥ 60 → severity 2 (Soaked)
## ── Cold status constants (Phase 12) ────────────────────────────────────────
## Severity labels: 1 = Cold, 2 = Very Cold, 3 = Freezing.
## Cold activates in winter (Clock.SEASON_WINTER) or during a cold snap (any season).
## Cold snap doubles COLD_GAIN_PER_TICK.
const COLD_GAIN_PER_TICK: float = 0.015
const COLD_DECAY_PER_TICK: float = 0.04
const COLD_MILD_THRESHOLD: float = 25.0 ## ≥ 25 → severity 1
const COLD_SEVERE_THRESHOLD: float = 60.0 ## ≥ 60 → severity 2
const COLD_EXTREME_THRESHOLD: float = 85.0 ## ≥ 85 → severity 3
## Sim ticks before an untreated bleed-out causes death.
## design.md "Downed & death": 6 in-game hours.
## At 20 Hz × 60 s/min × 60 min/hr × 6 hr = 432 000 ticks at 1×.
@ -57,3 +79,33 @@ static func downed() -> Status:
s.max_severity = 1
s.lifetime = Status.Lifetime.PERSISTENT
return s
## Returns a Wet status at the given severity (1 = Damp, 2 = Soaked).
## Severity is clamped to [1, WET_SOAKED_LEVEL]. Lifetime is PERSISTENT —
## cleared (or severity-adjusted) by Pawn._sync_wet_status() based on accumulator.
## The accumulator thresholds WET_DAMP_THRESHOLD / WET_SOAKED_THRESHOLD drive
## which severity the pawn is in; this factory just stamps the initial value.
static func wet(severity: int = WET_DAMP_LEVEL) -> Status:
var s := Status.new()
s.id = &"wet"
s.kind = Status.Kind.WET
s.label = "Wet"
s.severity = clampi(severity, WET_DAMP_LEVEL, WET_SOAKED_LEVEL)
s.max_severity = WET_SOAKED_LEVEL
s.lifetime = Status.Lifetime.PERSISTENT
return s
## Returns a Cold status at the given severity (1 = Cold, 2 = Very Cold, 3 = Freezing).
## Severity is clamped to [1, 3]. Lifetime is PERSISTENT —
## cleared (or severity-adjusted) by Pawn._sync_cold_status() based on accumulator.
static func cold(severity: int = 1) -> Status:
var s := Status.new()
s.id = &"cold"
s.kind = Status.Kind.COLD
s.label = "Cold"
s.severity = clampi(severity, 1, 3)
s.max_severity = 3
s.lifetime = Status.Lifetime.PERSISTENT
return s

View file

@ -90,6 +90,45 @@ static func in_darkness() -> Thought:
return t
## Mood penalty while wet accumulator is in Damp tier (2559).
## Driven by Pawn._sync_persistent_thought via wet status severity == 1.
## modifier=-3, max_stacks=1, PERSISTENT.
static func damp() -> Thought:
var t := Thought.new()
t.id = &"damp"
t.label = "Damp"
t.modifier = -3
t.lifetime = Thought.Lifetime.PERSISTENT
t.max_stacks = 1
return t
## Mood penalty while wet accumulator is in Soaked tier (60+).
## Replaces Damp — Pawn._sync_persistent_thought removes damp before adding soaked.
## modifier=-6, max_stacks=1, PERSISTENT.
static func soaked() -> Thought:
var t := Thought.new()
t.id = &"soaked"
t.label = "Soaked"
t.modifier = -6
t.lifetime = Thought.Lifetime.PERSISTENT
t.max_stacks = 1
return t
## Mood penalty while cold accumulator is active (any severity).
## Named cold_thought to avoid collision with StatusCatalog.cold() factory.
## modifier=-4, max_stacks=1, PERSISTENT.
static func cold_thought() -> Thought:
var t := Thought.new()
t.id = &"cold"
t.label = "Cold"
t.modifier = -4
t.lifetime = Thought.Lifetime.PERSISTENT
t.max_stacks = 1
return t
## Small mood boost after eating a cooked meal or bread.
## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_BREAD.
## Stacks up to 3 (multiple good meals compound, but cap at 3).

View file

@ -127,6 +127,15 @@ var hp: float = HP_MAX
# remove_status_by_id() which emit EventBus signals and enforce stack-merge logic.
var statuses: Array = [] # Array[Status]
# Phase 12 — wet / cold accumulators (0100).
# _wet_accum rises while the pawn is outdoors in rain; decays otherwise.
# Crosses WET_DAMP_THRESHOLD (25) → Damp status; WET_SOAKED_THRESHOLD (60) → Soaked.
# _cold_accum rises in winter or during a cold snap outdoors; decays otherwise.
# Mirroring StatusCatalog constants — do not read StatusCatalog inside property
# access paths; use _wet_severity() / _cold_severity() helpers instead.
var _wet_accum: float = 0.0
var _cold_accum: float = 0.0
var _path: Array[Vector2i] = []
var _step_progress: float = 0.0
var _selected: bool = false
@ -386,6 +395,11 @@ func _process_thoughts() -> void:
var _dark_time := Clock.darkness_factor() > 0.3
var _lit := World.is_tile_lit(tile)
_sync_persistent_thought(&"in_darkness", _dark_time and not _lit, ThoughtCatalog.in_darkness())
# Phase 12 — wet mood thoughts (mutually exclusive tiers; damp removed when soaked kicks in).
_sync_persistent_thought(&"damp", has_status(&"wet") and _wet_severity() == StatusCatalog.WET_DAMP_LEVEL, ThoughtCatalog.damp())
_sync_persistent_thought(&"soaked", has_status(&"wet") and _wet_severity() == StatusCatalog.WET_SOAKED_LEVEL, ThoughtCatalog.soaked())
# Phase 12 — cold mood thought (any cold severity triggers the single cold thought).
_sync_persistent_thought(&"cold", has_status(&"cold"), ThoughtCatalog.cold_thought())
# 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally).
if dirty:
_recompute_mood()
@ -443,6 +457,7 @@ func _process_sulking() -> void:
## Sequence:
## 1. Decay EVENT statuses — remove expired ones.
## 2. Apply per-tick effects (Bleeding drains HP).
## 3. Phase 12 — tick wet / cold accumulators and sync statuses.
##
## Bleeding does NOT log per-tick — would flood Audit. Named-source logging
## happens in take_damage() only when source is non-empty.
@ -461,6 +476,126 @@ func _process_statuses() -> void:
hp = maxf(0.0, hp - StatusCatalog.BLEED_HP_PER_TICK * float(s.severity))
EventBus.pawn_took_damage.emit(self, StatusCatalog.BLEED_HP_PER_TICK * float(s.severity))
_check_downed()
# 3. Phase 12 — wet / cold environment exposure.
_tick_wet()
_tick_cold()
## Tick the wet accumulator and sync the Wet status severity.
## Called every sim tick from _process_statuses().
func _tick_wet() -> void:
var sheltered := _is_sheltered()
if Weather.is_raining() and not sheltered:
var rate := StatusCatalog.WET_GAIN_PER_TICK * (2.0 if Weather.is_storming() else 1.0)
_wet_accum = clampf(_wet_accum + rate, 0.0, 100.0)
else:
_wet_accum = clampf(_wet_accum - StatusCatalog.WET_DECAY_PER_TICK, 0.0, 100.0)
_sync_wet_status()
## Tick the cold accumulator and sync the Cold status severity.
## Cold accumulates outdoors in winter OR during any cold snap regardless of season.
func _tick_cold() -> void:
var sheltered := _is_sheltered()
var cold_conditions := (Clock.current_season() == Clock.SEASON_WINTER or Weather.is_cold_snap())
if cold_conditions and not sheltered:
var rate := StatusCatalog.COLD_GAIN_PER_TICK * (2.0 if Weather.is_cold_snap() else 1.0)
_cold_accum = clampf(_cold_accum + rate, 0.0, 100.0)
else:
_cold_accum = clampf(_cold_accum - StatusCatalog.COLD_DECAY_PER_TICK, 0.0, 100.0)
_sync_cold_status()
## Compute the target wet severity from the current accumulator.
## Returns 0 (no status), 1 (Damp), or 2 (Soaked).
func _wet_severity() -> int:
for s in statuses:
if s.id == &"wet":
return s.severity
return 0
## Compute the target cold severity from the current accumulator.
## Returns 0 (no status), 1, 2, or 3.
func _cold_severity() -> int:
for s in statuses:
if s.id == &"cold":
return s.severity
return 0
## Sync the Wet status to match the current _wet_accum tier.
## Adds, updates severity, or removes the status with one Audit log per transition.
func _sync_wet_status() -> void:
var target: int
if _wet_accum >= StatusCatalog.WET_SOAKED_THRESHOLD:
target = StatusCatalog.WET_SOAKED_LEVEL
elif _wet_accum >= StatusCatalog.WET_DAMP_THRESHOLD:
target = StatusCatalog.WET_DAMP_LEVEL
else:
target = 0
var current := _wet_severity()
if target == current:
return
if target == 0:
remove_status_by_id(&"wet")
Audit.log("pawn", "%s dried off (wet=%.1f)" % [pawn_name, _wet_accum])
elif current == 0:
# Not wet → newly wet.
var label := "Damp" if target == StatusCatalog.WET_DAMP_LEVEL else "Soaked"
add_status(StatusCatalog.wet(target))
Audit.log("pawn", "%s now %s (wet=%.1f)" % [pawn_name, label, _wet_accum])
else:
# Severity shift — update in place to preserve the status object.
for s in statuses:
if s.id == &"wet":
var old_sev: int = s.severity
s.severity = target
var label := "Soaked" if target == StatusCatalog.WET_SOAKED_LEVEL else "Damp"
Audit.log("pawn", "%s now %s (wet=%.1f, sev %d%d)" % [pawn_name, label, _wet_accum, old_sev, target])
break
## Sync the Cold status to match the current _cold_accum tier.
## Adds, updates severity, or removes the status with one Audit log per transition.
func _sync_cold_status() -> void:
var target: int
if _cold_accum >= StatusCatalog.COLD_EXTREME_THRESHOLD:
target = 3
elif _cold_accum >= StatusCatalog.COLD_SEVERE_THRESHOLD:
target = 2
elif _cold_accum >= StatusCatalog.COLD_MILD_THRESHOLD:
target = 1
else:
target = 0
var current := _cold_severity()
if target == current:
return
if target == 0:
remove_status_by_id(&"cold")
Audit.log("pawn", "%s warmed up (cold=%.1f)" % [pawn_name, _cold_accum])
elif current == 0:
add_status(StatusCatalog.cold(target))
Audit.log("pawn", "%s now Cold severity %d (cold=%.1f)" % [pawn_name, target, _cold_accum])
else:
for s in statuses:
if s.id == &"cold":
var old_sev: int = s.severity
s.severity = target
Audit.log("pawn", "%s Cold sev %d%d (cold=%.1f)" % [pawn_name, old_sev, target, _cold_accum])
break
## Phase 12 — returns true if the pawn's current tile has a floor beneath it.
## This is a Phase 13 stand-in for full Room/Roof detection. A tile is considered
## sheltered when World.floor_layer reports a valid cell at the pawn's tile position.
## Replace with Room.contains(tile) once the Room BFS system lands (Phase 13+).
func _is_sheltered() -> bool:
return World.floor_layer.get_cell_source_id(tile) != -1
# ── save / load ─────────────────────────────────────────────────────────────
@ -500,6 +635,9 @@ func to_dict() -> Dictionary:
"statuses": statuses_data,
"sulk_low_ticks": _sulk_low_ticks,
"sulking": sulking,
# Phase 12 — wet / cold accumulators. Default 0 for pre-Phase-12 save compat.
"wet_accum": _wet_accum,
"cold_accum": _cold_accum,
}
@ -546,6 +684,10 @@ func from_dict(d: Dictionary) -> void:
if sd is Dictionary:
statuses.append(Status.from_dict(sd))
# Phase 12 — restore wet / cold accumulators; default 0 for pre-Phase-12 saves.
_wet_accum = clampf(float(d.get("wet_accum", 0.0)), 0.0, 100.0)
_cold_accum = clampf(float(d.get("cold_accum", 0.0)), 0.0, 100.0)
# Restore skills — set directly on the dict to bypass the ALL_SKILLS assert
# (from_dict must be resilient to saves that pre-date a new skill being added).
var saved_skills: Variant = d.get("skills")

View file

@ -13,14 +13,16 @@ const IDLE_MODULATE := Color.WHITE
@onready var normal_btn : Button = $Anchor/ButtonRow/NormalBtn
@onready var fast_btn : Button = $Anchor/ButtonRow/FastBtn
@onready var ultra_btn : Button = $Anchor/ButtonRow/UltraBtn
@onready var tick_label : Label = $Anchor/TickLabel
@onready var clock_label : Label = $Anchor/ClockLabel
@onready var tick_label : Label = $Anchor/TickLabel
@onready var clock_label : Label = $Anchor/ClockLabel
@onready var season_label : Label = $Anchor/SeasonLabel
# Maps Speed enum value → the corresponding Button node.
var _speed_buttons: Dictionary = {}
# Early-out cache: only set clock_label.text when the string changes.
# Early-out cache: only set label text when the string changes.
var _last_clock_text: String = ""
var _last_season_text: String = ""
func _ready() -> void:
@ -28,8 +30,9 @@ func _ready() -> void:
normal_btn.text = Strings.t(&"speed.normal")
fast_btn.text = Strings.t(&"speed.fast")
ultra_btn.text = Strings.t(&"speed.ultra")
tick_label.text = "(boot)"
clock_label.text = Strings.t(&"clock.format").format({"d": 1, "t": "06:00"})
tick_label.text = "(boot)"
clock_label.text = Strings.t(&"clock.format").format({"d": 1, "t": "06:00"})
season_label.text = Strings.t(&"season.format").format({"s": Strings.t(&"season.spring"), "d": 1})
_speed_buttons = {
Sim.Speed.PAUSE: pause_btn,
@ -46,6 +49,7 @@ func _ready() -> void:
EventBus.speed_changed.connect(_on_speed_changed)
EventBus.sim_tick.connect(_on_sim_tick)
EventBus.sim_tick.connect(_on_clock_refresh)
EventBus.sim_tick.connect(_on_season_refresh)
# Reflect the initial speed state without emitting a signal.
_apply_highlight(Sim.current_speed)
@ -77,6 +81,17 @@ func _on_clock_refresh(_n: int) -> void:
clock_label.text = t
func _on_season_refresh(_n: int) -> void:
var season_key: StringName = &"season." + String(Clock.current_season())
var t: String = Strings.t(&"season.format").format({
"s": Strings.t(season_key),
"d": Clock.day_of_season() + 1, # display as 1-indexed
})
if t != _last_season_text:
_last_season_text = t
season_label.text = t
func _apply_highlight(speed: Sim.Speed) -> void:
for s: int in _speed_buttons:
_speed_buttons[s].modulate = ACTIVE_MODULATE if s == speed else IDLE_MODULATE

View file

@ -46,6 +46,18 @@ grow_horizontal = 2
text = "Day 1, 06:00"
horizontal_alignment = 1
[node name="SeasonLabel" type="Label" parent="Anchor"]
anchor_left = 0.5
anchor_right = 0.5
anchor_bottom = 0.0
offset_left = 90.0
offset_top = 8.0
offset_right = 230.0
offset_bottom = 40.0
grow_horizontal = 2
text = "Spring 1/12"
horizontal_alignment = 1
[node name="TickLabel" type="Label" parent="Anchor"]
anchor_left = 1.0
anchor_right = 1.0

View file

@ -0,0 +1,179 @@
extends Node2D
## Rain and storm visual overlay.
##
## Rendering choice: procedural _draw() with scrolling raindrop structs.
## Rationale over CPUParticles2D:
## - No particle system overhead; logic is trivial at ~130 lines.
## - Exact pixel-art aesthetic — 2 px diagonal lines, integer-aligned.
## - Raindrop count, velocity, alpha tunable without opening the scene editor.
## - Storm flash shares the same node + weather listener; no extra scene needed.
##
## Placement: lives inside a CanvasLayer at layer 5 (above z_index tiles,
## below the UI CanvasLayer at 10) so it follows the camera automatically.
## world.tscn owns the CanvasLayer; this script is the Node2D inside it.
##
## Visible only when Weather.is_raining() returns true.
## Storm flash fires every 48 s when Weather.is_storming() returns true.
class_name RainOverlay
# ── tuning constants ─────────────────────────────────────────────────────────
const DROP_COUNT: int = 120 ## Total raindrops on screen at once.
const DROP_MIN_LEN: int = 6 ## Minimum raindrop length in pixels.
const DROP_MAX_LEN: int = 10 ## Maximum raindrop length in pixels.
const DROP_VELOCITY_PX: float = 3.0 ## Downward pixels per render frame.
const DROP_SLANT: float = 0.35 ## Horizontal drift per vertical step (right-leaning).
const DROP_COLOR: Color = Color(0.70, 0.85, 1.0, 0.55)
const DROP_COLOR_STORM: Color = Color(0.65, 0.80, 1.0, 0.70) ## Brighter/denser in storm.
## Viewport dimensions — updated in _ready and on resize.
## We overdraw by a margin so wrapping is seamless.
const OVERDRAW_PX: int = 32
# ── storm flash ──────────────────────────────────────────────────────────────
const FLASH_ALPHA_PEAK: float = 0.40
const FLASH_DURATION_S: float = 0.20
@onready var _flash_rect: ColorRect = $FlashRect
@onready var _flash_timer: Timer = $FlashTimer
# ── internal state ───────────────────────────────────────────────────────────
## Each raindrop: [x: float, y: float, length: int]
var _drops: Array = []
var _viewport_size: Vector2 = Vector2(1280.0, 720.0)
var _raining: bool = false ## cached so _draw only runs when needed
var _storming: bool = false
func _ready() -> void:
_viewport_size = get_viewport().get_visible_rect().size
# Seed raindrop positions randomly across the full viewport + overdraw.
_drops.clear()
for i in DROP_COUNT:
_drops.append(_random_drop(true))
# Start hidden; listen for weather changes.
visible = false
_flash_rect.visible = false
_flash_rect.color = Color(1.0, 1.0, 1.0, 0.0)
EventBus.weather_changed.connect(_on_weather_changed)
# Sync to current weather in case we spawned mid-session.
_apply_weather(Weather.current_weather)
get_viewport().size_changed.connect(_on_viewport_resized)
# ── public ───────────────────────────────────────────────────────────────────
## Called by world.gd if it ever needs to force-refresh (not required for now).
func refresh() -> void:
_apply_weather(Weather.current_weather)
# ── weather events ───────────────────────────────────────────────────────────
func _on_weather_changed(weather: StringName) -> void:
_apply_weather(weather)
func _apply_weather(weather: StringName) -> void:
_raining = Weather.is_raining()
_storming = Weather.is_storming()
visible = _raining
if _storming:
_flash_timer.start(_random_flash_wait())
else:
_flash_timer.stop()
# Ensure flash rect is hidden when not storming.
_flash_rect.visible = false
_flash_rect.color = Color(1.0, 1.0, 1.0, 0.0)
# ── raindrop rendering ───────────────────────────────────────────────────────
func _process(delta: float) -> void:
if not _raining:
return
var dy: float = DROP_VELOCITY_PX
var dx: float = dy * DROP_SLANT
for i in _drops.size():
var d: Array = _drops[i]
d[0] += dx
d[1] += dy
# Wrap when the drop + its length scrolls past the bottom or right edge.
var max_y: float = _viewport_size.y + DROP_MAX_LEN + OVERDRAW_PX
var max_x: float = _viewport_size.x + DROP_MAX_LEN + OVERDRAW_PX
if d[1] > max_y or d[0] > max_x:
_drops[i] = _random_drop(false)
else:
_drops[i] = d
queue_redraw()
func _draw() -> void:
if not _raining:
return
var color: Color = DROP_COLOR_STORM if _storming else DROP_COLOR
for d in _drops:
var x: float = d[0]
var y: float = d[1]
var length: int = d[2]
# Diagonal from top-left to bottom-right (slant matches velocity direction).
var start := Vector2(x, y)
var end := Vector2(x + length * DROP_SLANT, y + length)
draw_line(start, end, color, 2.0)
func _random_drop(initial_scatter: bool) -> Array:
## Returns [x, y, length].
## initial_scatter=true seeds y anywhere in viewport so first frame isn't blank.
var x: float = randf() * (_viewport_size.x + OVERDRAW_PX)
var y: float
if initial_scatter:
y = randf() * _viewport_size.y
else:
y = -float(DROP_MAX_LEN) - randf() * OVERDRAW_PX
var length: int = DROP_MIN_LEN + randi() % (DROP_MAX_LEN - DROP_MIN_LEN + 1)
return [x, y, length]
func _on_viewport_resized() -> void:
_viewport_size = get_viewport().get_visible_rect().size
# ── storm flash ──────────────────────────────────────────────────────────────
func _on_flash_timer_timeout() -> void:
if not _storming:
return
_do_flash()
_flash_timer.start(_random_flash_wait())
func _do_flash() -> void:
_flash_rect.visible = true
_flash_rect.color = Color(1.0, 1.0, 1.0, 0.0)
var tw: Tween = create_tween()
# Ramp up quickly to peak alpha, then fade out.
tw.tween_property(_flash_rect, "color", Color(1.0, 1.0, 1.0, FLASH_ALPHA_PEAK),
FLASH_DURATION_S * 0.25)
tw.tween_property(_flash_rect, "color", Color(1.0, 1.0, 1.0, 0.0),
FLASH_DURATION_S * 0.75)
tw.tween_callback(func() -> void: _flash_rect.visible = false)
func _random_flash_wait() -> float:
return 4.0 + randf() * 4.0 ## 48 seconds

View file

@ -0,0 +1 @@
uid://1utypqouxwhe

View file

@ -0,0 +1,21 @@
[gd_scene load_steps=2 format=3 uid="uid://rimlike_rain_overlay"]
[ext_resource type="Script" path="res://scenes/world/rain_overlay.gd" id="1_rain_overlay"]
[node name="RainOverlay" type="Node2D"]
script = ExtResource("1_rain_overlay")
[node name="FlashRect" type="ColorRect" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(1, 1, 1, 0)
visible = false
[node name="FlashTimer" type="Timer" parent="."]
one_shot = true
autostart = false
[connection signal="timeout" from="FlashTimer" to="." method="_on_flash_timer_timeout"]

View file

@ -57,6 +57,15 @@ const HAUL_SWEEP_INTERVAL_TICKS: int = 100
const NIGHT_TINT: Color = Color(0.20, 0.22, 0.40, 1.0)
const DAY_TINT: Color = Color(1.0, 1.0, 1.0, 1.0)
# Phase 12 — subtle seasonal modulate on the Terrain TileMapLayer only.
# Wall / Floor / Roof are entity-drawn (Y-sorted sprites); don't tint those.
const SEASON_TINTS: Dictionary = {
&"spring": Color(0.96, 1.02, 0.94), # slight warm-green
&"summer": Color(1.0, 1.0, 1.0 ), # neutral / bright
&"autumn": Color(1.06, 0.95, 0.82), # warm orange
&"winter": Color(0.88, 0.92, 1.02), # cool blue, slight desaturation
}
@onready var dark_overlay: CanvasModulate = $DarkOverlay
@onready var terrain_layer: TileMapLayer = $Terrain
@onready var floor_layer: TileMapLayer = $Floor
@ -136,6 +145,10 @@ func _ready() -> void:
# stockpiles in case a higher-priority destination opened up.
EventBus.sim_tick.connect(_on_sim_tick_world_sweep)
# Phase 12 — season tint: apply on boot then update on each season transition.
_apply_season_tint(Clock.current_season())
EventBus.season_changed.connect(_apply_season_tint)
func world_bounds_px() -> Rect2:
return Rect2(Vector2.ZERO, Vector2(MAP_SIZE_TILES * TILE_SIZE_PX))
@ -505,6 +518,15 @@ func _update_dark_overlay() -> void:
dark_overlay.color = DAY_TINT.lerp(NIGHT_TINT, f)
# Phase 12 — apply a subtle hue tint to the Terrain layer to signal season.
# Only Terrain is tinted; Wall/Floor/Roof are entity sprites (Y-sorted) — leave them neutral.
# Called once at boot (from _ready) and on every EventBus.season_changed signal.
func _apply_season_tint(season: StringName) -> void:
var tint: Color = SEASON_TINTS.get(season, Color.WHITE)
terrain_layer.modulate = tint
Audit.log("world", "season tint → %s (%s)" % [season, tint])
# ── spike: AStarGrid2D query timing at 80² ──────────────────────────────────
func _run_pathfinder_spike() -> void:

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=17 format=3 uid="uid://rimlike_world"]
[gd_scene load_steps=18 format=3 uid="uid://rimlike_world"]
[ext_resource type="Script" path="res://scenes/world/world.gd" id="1_world"]
[ext_resource type="PackedScene" uid="uid://rimlike_camera_rig" path="res://scenes/world/camera_rig.tscn" id="2_camera"]
@ -16,6 +16,7 @@
[ext_resource type="Script" path="res://scenes/ai/sleep_provider.gd" id="14_sleep_provider"]
[ext_resource type="Script" path="res://scenes/ai/doctor_provider.gd" id="15_doctor_provider"]
[ext_resource type="Script" path="res://scenes/ai/wolf_spawner.gd" id="16_wolf_spawner"]
[ext_resource type="PackedScene" uid="uid://rimlike_rain_overlay" path="res://scenes/world/rain_overlay.tscn" id="17_rain_overlay"]
[node name="World" type="Node2D"]
y_sort_enabled = true
@ -89,5 +90,10 @@ script = ExtResource("15_doctor_provider")
[node name="WolfSpawner" type="Node" parent="."]
script = ExtResource("16_wolf_spawner")
[node name="WeatherLayer" type="CanvasLayer" parent="."]
layer = 5
[node name="RainOverlay" parent="WeatherLayer" instance=ExtResource("17_rain_overlay")]
[node name="CameraRig" parent="." instance=ExtResource("2_camera")]
position = Vector2(640, 640)