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