Weather FX Contract - xopherdeep/do-it-for-the-xp GitHub Wiki

Weather FX v2 contract (2026-10-10)

Goal: every region of the world map gets living weather that looks great on a phone and costs almost nothing: ONE canvas, ONE requestAnimationFrame loop, particle budgets that scale with intensity and device, pausing when the page is hidden, honouring prefers-reduced-motion. No DOM particles, no per-particle CSS animations.

Folder: src/components/organisms/WeatherFX/ (keep the public API of WeatherFX.vue so the world pages keep working: props type, active, intensity (0-10), soundEnabled, soundVolume, customEffectProps; emits effect-loaded, effect-changed). The old fx/*.vue files, RainyEffect.vue and TropicalEffect.vue are deleted.

Shared types: types.ts (owner: engine agent writes it FIRST, exactly as below, the wrapper agent imports it)

export type WeatherType =
  | "none" | "sunny" | "tropical" | "rainy" | "stormy" | "snowy" | "cloudy" | "plains"
  | "desert" | "swamp" | "forest" | "mountains" | "moon" | "cave" | "dungeon" | "urban";
export type WeatherSoundType = "rain" | "storm" | "wind" | "ocean" | "night" | "cave";
export interface EffectFrame {
  ctx: CanvasRenderingContext2D;
  width: number;   // CSS pixels
  height: number;
  dpr: number;
  intensity: number;   // 0-10
  time: number;        // seconds since the effect started
  dt: number;          // seconds since the last frame, clamped to 0.05
  budget: number;      // 0.25-1: particle multiplier the engine lowers on slow devices
  reducedMotion: boolean;
}
export interface WeatherEffect {
  /** Called once with the first frame and again after a resize. Allocate particles here. */
  init(frame: EffectFrame): void;
  /** Advance state; may be skipped on dropped frames. */
  update(frame: EffectFrame): void;
  /** Draw this layer; the engine clears the canvas before the first layer. */
  draw(frame: EffectFrame): void;
  dispose?(): void;
}
export type EffectFactory = (options?: Record<string, unknown>) => WeatherEffect;
export interface WeatherPreset { layers: EffectFactory[]; sound: WeatherSoundType | null; }

Engine (owner: engine agent): engine/WeatherCanvas.ts + engine/useWeatherCanvas.ts + effects/*.ts + presets.ts

  • createWeatherCanvas(canvas, { type, intensity, reducedMotion }) returns { setType, setIntensity, start, stop, dispose }. One rAF loop; dt clamped; canvas sized to its parent with ResizeObserver, dpr = min(devicePixelRatio, 2); document.visibilitychange and an IntersectionObserver pause the loop; frames longer than 24 ms three times in a row lower budget by 0.25 (floor 0.25), fifty fast frames raise it again. Layers are composed from presets.ts. All drawing uses the 2D context with globalCompositeOperation where it helps (lighter for sparkles/fireflies/lightning). No allocation per frame inside update/draw (preallocate typed arrays or plain arrays once in init).
  • useWeatherCanvas(canvasRef, type: Ref<WeatherType>, intensity: Ref<number>, active: Ref<boolean>) wires the engine into Vue lifecycle (mount/unmount, watchers), reads matchMedia('(prefers-reduced-motion: reduce)').
  • Effects (each a factory in effects/): rain (angled streaks with depth: far thin, near thick; splashes at the bottom edge; wind sway; intensity -> count 60..400 * budget), lightning (random flashes every 6-20 s at intensity >= 6: a full-canvas white flash that decays in 180 ms plus a jagged bolt polyline drawn for 2 frames), snow (round flakes with sine drift, three depths, slow; 40..260), clouds (4-6 soft cloud shapes made of radial gradients on an offscreen canvas, three parallax speeds, optional dark option for storms), sunRays (3-6 rotating translucent wedges from the top-left, lighter), sparkles (twinkling points with scale pulse, for water/sun, 20..60), birds (2-5 tiny "V" shapes flapping across, every 8-15 s), wind (short translucent streaks and pollen dots that gust, for plains), heatHaze (slow sine distortion of 2-3 horizontal translucent bands near the horizon), sand (desert: fast horizontal grains + a tan haze band that gusts), fog (swamp/cave: 3 drifting fog blobs with soft alpha, breathing), fireflies (forest/swamp at low intensity: 12..40 glowing dots wandering with ease, lighter), leaves (forest: 15..40 tumbling ellipses with rotation), stars (moon/night: 80..200 twinkling points, one shooting star every 10-20 s), aurora (moon: 2 slow wavy translucent ribbons), embers (dungeon: rising orange sparks), drips (cave: occasional falling drops with a ripple), smog (urban: low grey drift + window lights twinkle).
  • presets.ts maps every WeatherType to layers + sound: none []; sunny [sunRays, sparkles]; tropical [sunRays, sparkles, birds] sound ocean; rainy [clouds, rain] sound rain; stormy [clouds{dark}, rain{heavy}, lightning, wind] sound storm; snowy [clouds{thin}, snow] sound wind; cloudy [clouds, sunRays{faint}] sound wind; plains [wind, heatHaze, birds] sound wind; desert [sand, heatHaze, sunRays{faint}] sound wind; swamp [fog, fireflies] sound night; forest [leaves, fireflies, sunRays{faint}] sound night; mountains [clouds{thin}, wind, birds] sound wind; moon [stars, aurora] sound night; cave [fog{dark}, drips] sound cave; dungeon [embers, fog{dark}] sound cave; urban [smog, stars{faint}] sound wind.
  • Tests: __tests__/WeatherCanvas.spec.ts with a stubbed 2D context (jest-canvas-mock is NOT installed: write a minimal fake context object with the methods used, assert call counts) covering: every preset instantiates and runs 10 frames without throwing; budget drops after three slow frames and recovers; stop() cancels the loop; reducedMotion halves counts and disables lightning; dispose() disconnects observers.

Wrapper (owner: wrapper agent): WeatherFX.vue, useWeatherFX.ts, sounds.ts, dev page src/app/Dev/WeatherFXDevTools/WeatherFXDevTools.vue, world pages

  • WeatherFX.vue: renders <canvas class="weather-canvas"> inside the absolutely positioned .weather-fx layer, uses useWeatherCanvas from the engine (import path ./engine/useWeatherCanvas, treat its signature from the contract as given even if the file does not exist yet while you work), keeps the props/emits above, emits effect-loaded after the first frame, and plays the preset's ambient sound through useWeatherFX.
  • useWeatherFX.ts + sounds.ts: ambient loops are LOCAL files imported from @/assets/audio/weather/<name>.ogg with .mp3 fallback (Fable renders them: rain, storm, wind, ocean, night, cave). One Audio per sound, lazily created, looped, faded in/out over 600 ms with a tiny interval, volume = preset default * soundVolume, never autoplayed on mount (only when a preset asks and soundEnabled), all stopped on unmount. Respect the app's AudioEngine mute if easy (AudioEngine.getInstance().state.muted), else ignore.
  • Dev page: a select with every WeatherType, intensity slider 0-10, sound toggle, a live FPS counter and the engine's current budget, a "stress" button that sets intensity 10 on stormy.
  • World pages: WorldPlains.vue keeps its own grass/haze markup but adds <weather-fx type="plains" :intensity="4" />; WorldIslands uses "tropical"; add desert, swamp, forest, mountains, moon, clouds -> "cloudy", ice -> "snowy" to their pages (src/app/Console/WorldMap/World*/World*.vue: put the component as the first child inside <ion-content> with the page's background behind it; sound off by default on map pages so the music stays in front: :sound-enabled="false").
  • Tests: __tests__/WeatherFX.spec.ts mounting the wrapper with the engine mocked (jest.mock('../engine/useWeatherCanvas')), asserting the canvas renders, the preset sound starts/stops with active/soundEnabled, and unmount stops audio.

Done when

pnpm typecheck clean, pnpm lint 0 errors, both specs green, dev page shows every type, the world pages compile. Keep every frame under 4 ms of JS on a mid-range phone: budgets above are the ceiling, not a target.

⚠️ **GitHub.com Fallback** ⚠️