rimlike/scenes/world/rain_overlay.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

179 lines
6.5 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 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