Repository navigation
Weather FX Contract
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;dtclamped; canvas sized to its parent withResizeObserver,dpr = min(devicePixelRatio, 2);document.visibilitychangeand anIntersectionObserverpause the loop; frames longer than 24 ms three times in a row lowerbudgetby 0.25 (floor 0.25), fifty fast frames raise it again. Layers are composed frompresets.ts. All drawing uses the 2D context withglobalCompositeOperationwhere it helps (lighter for sparkles/fireflies/lightning). No allocation per frame inside update/draw (preallocate typed arrays or plain arrays once ininit). -
useWeatherCanvas(canvasRef, type: Ref<WeatherType>, intensity: Ref<number>, active: Ref<boolean>)wires the engine into Vue lifecycle (mount/unmount, watchers), readsmatchMedia('(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, optionaldarkoption 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.tsmaps 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.tswith 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-fxlayer, usesuseWeatherCanvasfrom 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, emitseffect-loadedafter the first frame, and plays the preset's ambient sound throughuseWeatherFX. -
useWeatherFX.ts+sounds.ts: ambient loops are LOCAL files imported from@/assets/audio/weather/<name>.oggwith.mp3fallback (Fable renders them: rain, storm, wind, ocean, night, cave). OneAudioper 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 andsoundEnabled), 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.vuekeeps its own grass/haze markup but adds<weather-fx type="plains" :intensity="4" />;WorldIslandsuses "tropical"; adddesert,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.tsmounting the wrapper with the engine mocked (jest.mock('../engine/useWeatherCanvas')), asserting the canvas renders, the preset sound starts/stops withactive/soundEnabled, and unmount stops audio.
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.
- Architecture
- Component-Library
- Hooks-Reference
- Temple-System
- Battle-Field-Architecture
- current_context - For AI/Human Handoff
- User Menu
- General Settings
- Family Settings
- Sound Settings
- Notification Settings
- Reward Settings
- Party Settings
- Theme Settings
- Chore Settings