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