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:
parent
1b6ad2bcc6
commit
92f4e5c945
19 changed files with 692 additions and 19 deletions
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
116
autoload/weather.gd
Normal 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
1
autoload/weather.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bi358slq237p5
|
||||
|
|
@ -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 (1–2 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 (~1–2 weeks)
|
||||
## Phase 12 — Seasons + Weather (~1–2 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 4–8s 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 1–3 (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 0–5 (rain → clear → rain → clear → rain). Storm flash captured mid-animation in screenshot.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 1–2 vs design target 1–4 — 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 0–100 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
|
||||
|
|
|
|||
|
|
@ -90,6 +90,45 @@ static func in_darkness() -> Thought:
|
|||
return t
|
||||
|
||||
|
||||
## Mood penalty while wet accumulator is in Damp tier (25–59).
|
||||
## 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).
|
||||
|
|
|
|||
|
|
@ -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 (0–100).
|
||||
# _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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
179
scenes/world/rain_overlay.gd
Normal file
179
scenes/world/rain_overlay.gd
Normal 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 4–8 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 ## 4–8 seconds
|
||||
1
scenes/world/rain_overlay.gd.uid
Normal file
1
scenes/world/rain_overlay.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://1utypqouxwhe
|
||||
21
scenes/world/rain_overlay.tscn
Normal file
21
scenes/world/rain_overlay.tscn
Normal 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"]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue