react-weather-effects 0.1.0-beta.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -3
- package/dist/index.cjs +267 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -3
- package/dist/index.d.mts +40 -3
- package/dist/index.d.ts +40 -3
- package/dist/index.mjs +267 -44
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Allwin Antony
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Composable, high-performance rain, snow, and fog visual effects for React, rendered on a single lightweight 2D canvas.
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/react-weather-effects)
|
|
6
|
+
[](https://react-weather-effects-smoky.vercel.app/)
|
|
7
|
+
[](https://github.com/allwin-antony/react-weather-effects)
|
|
8
|
+
[](https://github.com/sponsors/allwin-antony)
|
|
9
|
+
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
|
|
11
|
+
- **Live Demo:** [https://react-weather-effects-smoky.vercel.app/](https://react-weather-effects-smoky.vercel.app/)
|
|
12
|
+
- **GitHub:** [https://github.com/allwin-antony/react-weather-effects](https://github.com/allwin-antony/react-weather-effects)
|
|
13
|
+
- **npm:** [https://www.npmjs.com/package/react-weather-effects](https://www.npmjs.com/package/react-weather-effects)
|
|
14
|
+
- **Sponsor:** [https://github.com/sponsors/allwin-antony](https://github.com/sponsors/allwin-antony)
|
|
15
|
+
|
|
5
16
|
## Installation
|
|
6
17
|
|
|
7
18
|
Install `react-weather-effects` using your package manager of choice:
|
|
@@ -61,6 +72,7 @@ By default, `<WeatherCanvas>` runs in `mode="fullscreen"`, covering the viewport
|
|
|
61
72
|
- **Single Canvas Engine:** One 2D `<canvas>` manages all effects, batching rendering at 60 FPS without DOM bloat.
|
|
62
73
|
- **Composable:** Combine multiple effects (`Rain`, `Snow`, `Fog`) inside a single canvas.
|
|
63
74
|
- **Configurable:** Adjust intensity, speed, wind angle, colors, and custom snowflake sprite artwork.
|
|
75
|
+
- **Smooth Scene Changes:** Opt into particle-preserving intensity and density fades with `transitionDuration`.
|
|
64
76
|
- **Motion-Aware & Accessible:** Automatically respects `prefers-reduced-motion` and pauses when hidden or offscreen.
|
|
65
77
|
- **Contained & Fullscreen:** Place inside specific cards/heroes (`mode="contained"`) or cover the whole screen.
|
|
66
78
|
- **SSR & Next.js Safe:** Safe for React Server Components (RSC) and Next.js App Router (runs cleanly with `'use client'`).
|
|
@@ -76,6 +88,7 @@ By default, `<WeatherCanvas>` runs in `mode="fullscreen"`, covering the viewport
|
|
|
76
88
|
- [Server rendering and Next.js](#server-rendering-and-nextjs)
|
|
77
89
|
- [Troubleshooting](#troubleshooting)
|
|
78
90
|
- [Local development](#local-development)
|
|
91
|
+
- [Sponsoring](#sponsoring)
|
|
79
92
|
- [License](#license)
|
|
80
93
|
|
|
81
94
|
## Usage examples
|
|
@@ -130,6 +143,30 @@ export function RainyScene() {
|
|
|
130
143
|
|
|
131
144
|
Fog draws behind rain and snow. Rain and snow share a drawing priority; instances at that priority draw in registration order. Each effect has its own settings and can be mounted or removed independently.
|
|
132
145
|
|
|
146
|
+
### Transition between scenes
|
|
147
|
+
|
|
148
|
+
Set `transitionDuration` on each effect to fade intensity or density changes over that many milliseconds. Keep the canvas and effects mounted, and use `0` to hide an effect instead of conditionally removing it:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { WeatherCanvas, Rain, Snow, Fog } from 'react-weather-effects';
|
|
152
|
+
|
|
153
|
+
export function TransitioningScene({ scene }: { scene: 'rain' | 'snow' | 'clear' }) {
|
|
154
|
+
return (
|
|
155
|
+
<WeatherCanvas>
|
|
156
|
+
<Fog density={scene === 'rain' ? 0.3 : 0} transitionDuration={800} />
|
|
157
|
+
<Rain intensity={scene === 'rain' ? 0.8 : 0} transitionDuration={800} />
|
|
158
|
+
<Snow intensity={scene === 'snow' ? 0.6 : 0} transitionDuration={800} />
|
|
159
|
+
</WeatherCanvas>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- The default is `0`, preserving instant updates. Initial mounting and unmounting are also immediate.
|
|
165
|
+
- Rain and snow fade added or removed particles without resetting the pool. Retained particles stay visible and continue moving; fog keeps its existing wisps and texture.
|
|
166
|
+
- Changing the target mid-fade continues from the current visibility. Negative or non-finite durations are treated as `0`.
|
|
167
|
+
- Fades use the shared simulation clock, so hidden/offscreen time does not advance them. Static and paused presentations apply the latest settings immediately without animating.
|
|
168
|
+
- Only rain/snow `intensity` and fog `density` are transitioned. Other props, including rain's existing velocity smoothing, retain their current behavior.
|
|
169
|
+
|
|
133
170
|
### Use custom snow artwork
|
|
134
171
|
|
|
135
172
|
Add a static image to your application's public assets, for example `public/snowflakes/crystal.svg`, and pass its served URL:
|
|
@@ -167,7 +204,7 @@ Pass a **URL string**, not an SVG React component or raw SVG markup. PNG, SVG, a
|
|
|
167
204
|
|
|
168
205
|
For remote images, the image server must allow anonymous cross-origin requests with an appropriate `Access-Control-Allow-Origin` header. Serving artwork from your own application is the simplest option.
|
|
169
206
|
|
|
170
|
-
`wobble={false}` disables
|
|
207
|
+
`wobble={false}` disables animated sideways sway and fall flutter. You can control sprite rotation separately with `rotation` (set `false` to let custom sprites drift upright without rotating, or `true` with `wobble={false}` to rotate without swaying) and adjust the spinning rate with `rotationSpeed` (multiplier, default `1`; set `0` to freeze rotation).
|
|
171
208
|
|
|
172
209
|
### Pause and resume a scene
|
|
173
210
|
|
|
@@ -220,21 +257,30 @@ Wind is an effect-specific input, not a velocity in pixels per second. Rain and
|
|
|
220
257
|
| Prop | Type | Default | Description |
|
|
221
258
|
| --- | --- | --- | --- |
|
|
222
259
|
| `intensity` | `number` | `0.7` | Fraction of this instance's particle budget to draw. `0` draws no rain. |
|
|
260
|
+
| `transitionDuration` | `number` | `0` | Intensity fade duration in milliseconds. Keep mounted and set intensity to `0` to fade out. |
|
|
223
261
|
| `angle` | `number` | `10` | Degrees from vertical. Positive values lean right; negative values lean left. |
|
|
224
262
|
| `speed` | `number` | `1` | Multiplier for intrinsic falling speed. |
|
|
225
|
-
| `
|
|
263
|
+
| `dropLength` | `number` | `1` | Multiplier for raindrop streak length (< 1 for fine drizzle, > 1 for heavy downpour). Alias: `length`. |
|
|
264
|
+
| `dropThickness` | `number` | `1` | Multiplier for raindrop stroke thickness (< 1 for fine mist, > 1 for heavy rain). Alias: `thickness`. |
|
|
265
|
+
| `splashes` | `boolean` | `false` | Enable animated bottom-edge splashes for contained cards or heroes. Alias: `splash`. |
|
|
266
|
+
| `color` | `string` | `'rgba(190, 215, 245, 0.8)'` | CSS color for rain trails and splashes. |
|
|
267
|
+
|
|
268
|
+
With `splashes`, visible drops produce small, varied droplets that rise and fall over a softly expanding ground ripple. Splash size follows `dropThickness`; existing impacts finish their short animation when rain stops. Under heavy rain, excess impacts are skipped instead of restarting active splashes. Splashes stay at the current canvas bottom edge after resizing, and paused/static redraws preserve their state. Use contained mode for a defined ground edge; splashes do not detect other DOM surfaces.
|
|
226
269
|
|
|
227
270
|
### `Snow`
|
|
228
271
|
|
|
229
272
|
| Prop | Type | Default | Description |
|
|
230
273
|
| --- | --- | --- | --- |
|
|
231
274
|
| `intensity` | `number` | `0.5` | Fraction of this instance's particle budget to draw. `0` draws no snow. |
|
|
275
|
+
| `transitionDuration` | `number` | `0` | Intensity fade duration in milliseconds. Keep mounted and set intensity to `0` to fade out. |
|
|
232
276
|
| `speed` | `number` | `1` | Multiplier for intrinsic falling speed and wobble progression. |
|
|
233
277
|
| `color` | `string` | `'rgba(255, 255, 255, 0.85)'` | CSS color for built-in flakes and the sprite fallback. Does not tint artwork. |
|
|
234
278
|
| `sprite` | `string` | None | Static image URL. Omitting it uses soft, built-in snowflakes. |
|
|
235
279
|
| `minSize` | `number` | `1.5`, or `6` with a sprite | Minimum flake radius in CSS pixels. |
|
|
236
280
|
| `maxSize` | `number` | `4`, or `18` with a sprite | Maximum flake radius in CSS pixels. |
|
|
237
|
-
| `wobble` | `boolean` | `true` | Enable animated sway and
|
|
281
|
+
| `wobble` | `boolean` | `true` | Enable animated sideways sway and fall flutter. |
|
|
282
|
+
| `rotation` | `boolean` | `true` (or `wobble`) | Enable sprite rotation/spinning. Defaults to `wobble`. Set `false` to drift without rotating. |
|
|
283
|
+
| `rotationSpeed` | `number` | `1` | Multiplier for sprite rotation/spinning speed. Defaults to `1`. Set to `0` to freeze angle. |
|
|
238
284
|
|
|
239
285
|
Use nonnegative sizes, with `minSize <= maxSize`. Built-in flakes have a feathered glow that extends beyond their nominal radius.
|
|
240
286
|
|
|
@@ -243,6 +289,7 @@ Use nonnegative sizes, with `minSize <= maxSize`. Built-in flakes have a feather
|
|
|
243
289
|
| Prop | Type | Default | Description |
|
|
244
290
|
| --- | --- | --- | --- |
|
|
245
291
|
| `density` | `number` | `0.4` | Opacity multiplier. `0` draws no fog. |
|
|
292
|
+
| `transitionDuration` | `number` | `0` | Density fade duration in milliseconds. Keep mounted and set density to `0` to fade out. |
|
|
246
293
|
| `speed` | `number` | `1` | Multiplier for intrinsic drift and shape movement. |
|
|
247
294
|
| `color` | `string` | `'rgba(220, 225, 235, 0.35)'` | CSS color used to tint the cached fog texture. |
|
|
248
295
|
| `layers` | `number` | `3` | Layer count, rounded and clamped between `1` and `6`. |
|
|
@@ -413,6 +460,12 @@ npm install /path/to/react-weather-effects-0.1.0-beta.0.tgz
|
|
|
413
460
|
|
|
414
461
|
For implementation contracts, package boundaries, and future plans, see `ARCHITECTURE.md` in the source repository. Keep changes focused, cover rendering and lifecycle behavior with tests, and verify the build, types, and size budget before submitting changes.
|
|
415
462
|
|
|
463
|
+
## Sponsoring
|
|
464
|
+
|
|
465
|
+
If you find `react-weather-effects` useful, please consider sponsoring its ongoing development and maintenance:
|
|
466
|
+
|
|
467
|
+
💖 [**Sponsor on GitHub**](https://github.com/sponsors/allwin-antony)
|
|
468
|
+
|
|
416
469
|
## License
|
|
417
470
|
|
|
418
471
|
Licensed under the MIT license.
|
package/dist/index.cjs
CHANGED
|
@@ -179,6 +179,7 @@ function WeatherCanvas({
|
|
|
179
179
|
const frameState = {
|
|
180
180
|
ctx,
|
|
181
181
|
dt,
|
|
182
|
+
isAnimating: shouldAnimateRef.current,
|
|
182
183
|
width: dimensions.width,
|
|
183
184
|
height: dimensions.height,
|
|
184
185
|
dpr: currentDpr,
|
|
@@ -491,6 +492,51 @@ function growParticlePool(pool, count) {
|
|
|
491
492
|
next.set(pool);
|
|
492
493
|
return next;
|
|
493
494
|
}
|
|
495
|
+
function resolveDuration(duration) {
|
|
496
|
+
return Number.isFinite(duration) ? Math.max(0, duration) : 0;
|
|
497
|
+
}
|
|
498
|
+
function createTransition(target, duration) {
|
|
499
|
+
return { target, duration: resolveDuration(duration), remaining: 0 };
|
|
500
|
+
}
|
|
501
|
+
function stepTransition(transition, target, duration, state) {
|
|
502
|
+
const nextDuration = resolveDuration(duration);
|
|
503
|
+
if (target !== transition.target || nextDuration !== transition.duration) {
|
|
504
|
+
transition.target = target;
|
|
505
|
+
transition.duration = nextDuration;
|
|
506
|
+
transition.remaining = nextDuration;
|
|
507
|
+
}
|
|
508
|
+
if (!state.isAnimating || nextDuration === 0) {
|
|
509
|
+
transition.remaining = 0;
|
|
510
|
+
return 1;
|
|
511
|
+
}
|
|
512
|
+
if (transition.remaining === 0) return 1;
|
|
513
|
+
const elapsed = state.dt * 1e3;
|
|
514
|
+
const step = elapsed + 1e-6 >= transition.remaining ? 1 : elapsed / transition.remaining;
|
|
515
|
+
transition.remaining = step === 1 ? 0 : transition.remaining - elapsed;
|
|
516
|
+
return step;
|
|
517
|
+
}
|
|
518
|
+
function createParticleTransition(count, capacity, duration) {
|
|
519
|
+
const visibility = new Float32Array(capacity);
|
|
520
|
+
visibility.fill(1, 0, count);
|
|
521
|
+
return { transition: createTransition(count, duration), visibility, count };
|
|
522
|
+
}
|
|
523
|
+
function updateParticleTransition(particles, targetCount, duration, state) {
|
|
524
|
+
const capacity = state.budget.maxParticles;
|
|
525
|
+
if (particles.visibility.length < capacity) {
|
|
526
|
+
const visibility = new Float32Array(capacity);
|
|
527
|
+
visibility.set(particles.visibility);
|
|
528
|
+
particles.visibility = visibility;
|
|
529
|
+
}
|
|
530
|
+
if (particles.count > capacity) particles.visibility.fill(0, capacity, particles.count);
|
|
531
|
+
const count = Math.min(capacity, Math.max(particles.count, targetCount));
|
|
532
|
+
const step = stepTransition(particles.transition, targetCount, duration, state);
|
|
533
|
+
for (let i = 0; i < count; i++) {
|
|
534
|
+
const target = i < targetCount ? 1 : 0;
|
|
535
|
+
particles.visibility[i] = step === 1 ? target : particles.visibility[i] + (target - particles.visibility[i]) * step;
|
|
536
|
+
}
|
|
537
|
+
particles.count = step === 1 ? targetCount : count;
|
|
538
|
+
}
|
|
539
|
+
var SPLASH_STRIDE = 7;
|
|
494
540
|
function seedParticle(pool, i, state, settings, isInitial) {
|
|
495
541
|
const base = i * PARTICLE_STRIDE;
|
|
496
542
|
const angleRad = settings.angle * Math.PI / 180;
|
|
@@ -514,7 +560,7 @@ function seedParticleRange(pool, startIndex, endIndex, state, settings) {
|
|
|
514
560
|
seedParticle(pool, i, state, settings, true);
|
|
515
561
|
}
|
|
516
562
|
}
|
|
517
|
-
function updatePool(pool, count, state, settings) {
|
|
563
|
+
function updatePool(pool, count, state, settings, visibility, splashPool, maxSplashes, splashState) {
|
|
518
564
|
const dt = state.dt;
|
|
519
565
|
const width = state.width;
|
|
520
566
|
const height = state.height;
|
|
@@ -523,6 +569,15 @@ function updatePool(pool, count, state, settings) {
|
|
|
523
569
|
const sinAngle = Math.sin(angleRad);
|
|
524
570
|
const cosAngle = Math.cos(angleRad);
|
|
525
571
|
const response = 1 - Math.exp(-dt * 5);
|
|
572
|
+
if (splashState.active) {
|
|
573
|
+
splashState.active = false;
|
|
574
|
+
for (let s = 0; s < maxSplashes; s++) {
|
|
575
|
+
const sBase = s * SPLASH_STRIDE;
|
|
576
|
+
if (splashPool[sBase + 5] === 0 || splashPool[sBase + 2] >= 1) continue;
|
|
577
|
+
splashPool[sBase + 2] = Math.min(1, splashPool[sBase + 2] + dt * splashPool[sBase + 3]);
|
|
578
|
+
if (splashPool[sBase + 2] < 1) splashState.active = true;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
526
581
|
for (let i = 0; i < count; i++) {
|
|
527
582
|
const base = i * PARTICLE_STRIDE;
|
|
528
583
|
let x = pool[base];
|
|
@@ -535,8 +590,28 @@ function updatePool(pool, count, state, settings) {
|
|
|
535
590
|
const targetVy = cosAngle * speed + state.wind.y * (60 + depth * 50);
|
|
536
591
|
vx += (targetVx - vx) * response;
|
|
537
592
|
vy += (targetVy - vy) * response;
|
|
593
|
+
const prevY = y;
|
|
538
594
|
x += vx * dt;
|
|
539
595
|
y += vy * dt;
|
|
596
|
+
if (settings.splashes && prevY < height && y >= height && visibility[i] > 0.05) {
|
|
597
|
+
const elapsed = (y - height) / vy;
|
|
598
|
+
const impactX = x - vx * elapsed;
|
|
599
|
+
if (impactX >= 0 && impactX <= width) {
|
|
600
|
+
const sBase = splashState.head * SPLASH_STRIDE;
|
|
601
|
+
splashState.head = (splashState.head + 1) % maxSplashes;
|
|
602
|
+
if (splashPool[sBase + 5] === 0 || splashPool[sBase + 2] >= 1) {
|
|
603
|
+
const duration = 0.3 + depth * 0.18;
|
|
604
|
+
splashState.active = true;
|
|
605
|
+
splashPool[sBase] = impactX;
|
|
606
|
+
splashPool[sBase + 1] = Math.max(-40, Math.min(40, vx * 0.04));
|
|
607
|
+
splashPool[sBase + 2] = elapsed / duration;
|
|
608
|
+
splashPool[sBase + 3] = 1 / duration;
|
|
609
|
+
splashPool[sBase + 4] = (2.8 + depth * 4.6) * Math.sqrt(Math.max(0.4, Math.min(3, settings.dropThickness)));
|
|
610
|
+
splashPool[sBase + 5] = (0.25 + depth * 0.45) * visibility[i];
|
|
611
|
+
splashPool[sBase + 6] = (depth + i * 0.618) % 1;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
540
615
|
if (x > width + 100 || x < -100) {
|
|
541
616
|
x = ((x + 100) % wrapWidth + wrapWidth) % wrapWidth - 100;
|
|
542
617
|
}
|
|
@@ -550,65 +625,162 @@ function updatePool(pool, count, state, settings) {
|
|
|
550
625
|
}
|
|
551
626
|
}
|
|
552
627
|
}
|
|
553
|
-
function drawPool(pool, count, state, settings) {
|
|
554
|
-
if (count === 0) return;
|
|
628
|
+
function drawPool(pool, count, state, settings, visibility, splashPool, maxSplashes) {
|
|
629
|
+
if (count === 0 && (!settings.splashes || maxSplashes === 0)) return;
|
|
555
630
|
const ctx = state.ctx;
|
|
556
631
|
ctx.strokeStyle = settings.color;
|
|
557
632
|
ctx.lineCap = "round";
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
633
|
+
if (count > 0) {
|
|
634
|
+
for (let i = 0; i < count; i++) {
|
|
635
|
+
if (visibility[i] === 0) continue;
|
|
636
|
+
const base = i * PARTICLE_STRIDE;
|
|
637
|
+
const x = pool[base];
|
|
638
|
+
const y = pool[base + 1];
|
|
639
|
+
const vx = pool[base + 2];
|
|
640
|
+
const vy = pool[base + 3];
|
|
641
|
+
const depth = pool[base + 6];
|
|
642
|
+
const trailFactor = (0.012 + depth * 0.025) * settings.dropLength;
|
|
643
|
+
const fade = Math.max(0, Math.min(1, (y + 25) / 45, (state.height + 35 - y) / 80));
|
|
644
|
+
for (let segment = 0; segment < 3; segment++) {
|
|
645
|
+
const start = segment / 3 * trailFactor;
|
|
646
|
+
const end = (segment + 1) / 3 * trailFactor;
|
|
647
|
+
ctx.globalAlpha = pool[base + 4] * fade * visibility[i] * Math.pow(1 - segment / 3, 2);
|
|
648
|
+
ctx.lineWidth = (0.6 + depth * 1.1) * (1 - segment * 0.2) * settings.dropThickness;
|
|
649
|
+
ctx.beginPath();
|
|
650
|
+
ctx.moveTo(x - vx * start, y - vy * start);
|
|
651
|
+
ctx.lineTo(x - vx * end, y - vy * end);
|
|
652
|
+
ctx.stroke();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (settings.splashes) {
|
|
657
|
+
ctx.fillStyle = settings.color;
|
|
658
|
+
const thickness = Math.max(0.4, Math.min(3, settings.dropThickness));
|
|
659
|
+
for (let s = 0; s < maxSplashes; s++) {
|
|
660
|
+
const sBase = s * SPLASH_STRIDE;
|
|
661
|
+
const progress = splashPool[sBase + 2];
|
|
662
|
+
if (progress >= 1 || splashPool[sBase + 5] === 0) continue;
|
|
663
|
+
const x = splashPool[sBase];
|
|
664
|
+
const y = state.height;
|
|
665
|
+
const size = splashPool[sBase + 4];
|
|
666
|
+
const alpha = splashPool[sBase + 5];
|
|
667
|
+
const elapsed = progress / splashPool[sBase + 3];
|
|
668
|
+
const drift = splashPool[sBase + 1] * elapsed;
|
|
669
|
+
ctx.globalAlpha = alpha * 0.45 * Math.pow(1 - progress, 1.5);
|
|
670
|
+
ctx.lineWidth = Math.max(0.35, 0.6 * thickness * (1 - progress * 0.4));
|
|
671
|
+
const rx = size * (0.25 + 1.9 * (1 - Math.pow(1 - progress, 3)));
|
|
672
|
+
const ry = Math.max(0.25, rx * 0.19);
|
|
572
673
|
ctx.beginPath();
|
|
573
|
-
ctx.
|
|
574
|
-
ctx.lineTo(x - vx * end, y - vy * end);
|
|
674
|
+
ctx.ellipse(x, y, rx, ry, 0, Math.PI, Math.PI * 2);
|
|
575
675
|
ctx.stroke();
|
|
676
|
+
for (let bead = 0; bead < 5; bead++) {
|
|
677
|
+
const variation = (splashPool[sBase + 6] + bead * 0.37) % 1;
|
|
678
|
+
const beadProgress = progress / (0.72 + variation * 0.28);
|
|
679
|
+
if (beadProgress >= 1) continue;
|
|
680
|
+
const direction = (bead - 2) * 0.5;
|
|
681
|
+
const peak = size * (0.9 + (1 - Math.abs(direction)) * 1.1 + variation * 0.4);
|
|
682
|
+
const beadX = x + drift + direction * size * (1.5 + variation * 0.7) * beadProgress;
|
|
683
|
+
const beadY = y - peak * 4 * beadProgress * (1 - beadProgress);
|
|
684
|
+
const radius = Math.max(0.3, (0.4 + thickness * 0.35) * (1 - beadProgress * 0.45));
|
|
685
|
+
ctx.globalAlpha = alpha * Math.pow(1 - beadProgress, 1.25);
|
|
686
|
+
ctx.beginPath();
|
|
687
|
+
ctx.arc(beadX, beadY, radius, 0, Math.PI * 2);
|
|
688
|
+
ctx.fill();
|
|
689
|
+
}
|
|
576
690
|
}
|
|
577
691
|
}
|
|
578
692
|
ctx.globalAlpha = 1;
|
|
579
693
|
}
|
|
580
694
|
function Rain({
|
|
581
695
|
intensity = 0.7,
|
|
696
|
+
transitionDuration = 0,
|
|
582
697
|
angle = 10,
|
|
583
698
|
color = "rgba(190, 215, 245, 0.8)",
|
|
584
|
-
speed = 1
|
|
699
|
+
speed = 1,
|
|
700
|
+
dropLength,
|
|
701
|
+
length,
|
|
702
|
+
dropThickness,
|
|
703
|
+
thickness,
|
|
704
|
+
splashes,
|
|
705
|
+
splash
|
|
585
706
|
}) {
|
|
586
707
|
const ticker = React.useContext(TickerCtx);
|
|
587
708
|
if (!ticker) {
|
|
588
709
|
throw new Error("<Rain /> must be used inside <WeatherCanvas>");
|
|
589
710
|
}
|
|
590
|
-
const
|
|
711
|
+
const effectiveDropLength = dropLength ?? length ?? 1;
|
|
712
|
+
const effectiveDropThickness = dropThickness ?? thickness ?? 1;
|
|
713
|
+
const effectiveSplashes = splashes ?? splash ?? false;
|
|
714
|
+
const settingsRef = React.useRef({
|
|
715
|
+
intensity,
|
|
716
|
+
transitionDuration,
|
|
717
|
+
angle,
|
|
718
|
+
color,
|
|
719
|
+
speed,
|
|
720
|
+
dropLength: effectiveDropLength,
|
|
721
|
+
dropThickness: effectiveDropThickness,
|
|
722
|
+
splashes: effectiveSplashes
|
|
723
|
+
});
|
|
591
724
|
React.useEffect(() => {
|
|
592
|
-
settingsRef.current = {
|
|
725
|
+
settingsRef.current = {
|
|
726
|
+
intensity,
|
|
727
|
+
transitionDuration,
|
|
728
|
+
angle,
|
|
729
|
+
color,
|
|
730
|
+
speed,
|
|
731
|
+
dropLength: effectiveDropLength,
|
|
732
|
+
dropThickness: effectiveDropThickness,
|
|
733
|
+
splashes: effectiveSplashes
|
|
734
|
+
};
|
|
593
735
|
ticker.invalidate();
|
|
594
|
-
}, [
|
|
736
|
+
}, [
|
|
737
|
+
ticker,
|
|
738
|
+
intensity,
|
|
739
|
+
transitionDuration,
|
|
740
|
+
angle,
|
|
741
|
+
color,
|
|
742
|
+
speed,
|
|
743
|
+
effectiveDropLength,
|
|
744
|
+
effectiveDropThickness,
|
|
745
|
+
effectiveSplashes
|
|
746
|
+
]);
|
|
595
747
|
React.useEffect(() => {
|
|
596
|
-
|
|
748
|
+
const capacity = ticker.getBudget().maxParticles;
|
|
749
|
+
let pool = createParticlePool(capacity);
|
|
750
|
+
const particles = createParticleTransition(
|
|
751
|
+
Math.round(Math.max(0, Math.min(1, settingsRef.current.intensity)) * capacity),
|
|
752
|
+
capacity,
|
|
753
|
+
settingsRef.current.transitionDuration
|
|
754
|
+
);
|
|
597
755
|
let initializedCount = 0;
|
|
756
|
+
const maxSplashes = Math.min(128, Math.max(32, Math.round(capacity * 0.5)));
|
|
757
|
+
const splashPool = new Float32Array(maxSplashes * SPLASH_STRIDE);
|
|
758
|
+
const splashState = { head: 0, active: false };
|
|
598
759
|
const unregister = ticker.register(LAYER.MIDGROUND, (state) => {
|
|
599
760
|
const settings = settingsRef.current;
|
|
600
|
-
const
|
|
761
|
+
const targetCount = Math.round(
|
|
601
762
|
Math.max(0, Math.min(1, settings.intensity)) * state.budget.maxParticles
|
|
602
763
|
);
|
|
764
|
+
updateParticleTransition(particles, targetCount, settings.transitionDuration, state);
|
|
765
|
+
const count = particles.count;
|
|
603
766
|
pool = growParticlePool(pool, state.budget.maxParticles);
|
|
604
767
|
if (count > initializedCount) {
|
|
605
768
|
seedParticleRange(pool, initializedCount, count, state, settings);
|
|
606
769
|
initializedCount = count;
|
|
607
770
|
}
|
|
608
771
|
if (state.dt > 0) {
|
|
609
|
-
updatePool(
|
|
772
|
+
updatePool(
|
|
773
|
+
pool,
|
|
774
|
+
count,
|
|
775
|
+
state,
|
|
776
|
+
settings,
|
|
777
|
+
particles.visibility,
|
|
778
|
+
splashPool,
|
|
779
|
+
maxSplashes,
|
|
780
|
+
splashState
|
|
781
|
+
);
|
|
610
782
|
}
|
|
611
|
-
drawPool(pool, count, state, settings);
|
|
783
|
+
drawPool(pool, count, state, settings, particles.visibility, splashPool, maxSplashes);
|
|
612
784
|
});
|
|
613
785
|
return unregister;
|
|
614
786
|
}, [ticker]);
|
|
@@ -674,12 +846,13 @@ function seedSnowflake(pool, i, state, isInitial) {
|
|
|
674
846
|
y = -20 - state.random() * 50;
|
|
675
847
|
}
|
|
676
848
|
}
|
|
849
|
+
const phase = state.random() * Math.PI * 2;
|
|
677
850
|
pool[base] = x;
|
|
678
851
|
pool[base + 1] = y;
|
|
679
|
-
pool[base + 2] =
|
|
852
|
+
pool[base + 2] = phase;
|
|
680
853
|
pool[base + 3] = fallSpeed;
|
|
681
854
|
pool[base + 4] = 0.3 + depth * 0.6;
|
|
682
|
-
pool[base + 5] =
|
|
855
|
+
pool[base + 5] = phase;
|
|
683
856
|
pool[base + 6] = depth;
|
|
684
857
|
}
|
|
685
858
|
function seedSnowRange(pool, startIndex, endIndex, state) {
|
|
@@ -697,6 +870,7 @@ function updateSnow(pool, count, state, settings) {
|
|
|
697
870
|
const base = i * PARTICLE_STRIDE;
|
|
698
871
|
let x = pool[base];
|
|
699
872
|
let y = pool[base + 1];
|
|
873
|
+
let angle = pool[base + 2];
|
|
700
874
|
let phase = pool[base + 5];
|
|
701
875
|
const baseVy = pool[base + 3];
|
|
702
876
|
const depth = pool[base + 6];
|
|
@@ -704,6 +878,11 @@ function updateSnow(pool, count, state, settings) {
|
|
|
704
878
|
phase += dt * (0.65 + depth * 1.4) * settings.speed;
|
|
705
879
|
pool[base + 5] = phase;
|
|
706
880
|
}
|
|
881
|
+
if (settings.rotation && settings.rotationSpeed !== 0) {
|
|
882
|
+
const spinDir = i % 2 === 0 ? 1 : -1;
|
|
883
|
+
const spinRate = (0.65 + depth * 1.4) * spinDir;
|
|
884
|
+
angle += dt * spinRate * settings.rotationSpeed;
|
|
885
|
+
}
|
|
707
886
|
const sway = settings.wobble ? (Math.sin(phase) * (10 + depth * 22) + Math.sin(phase * 0.5) * 8) * settings.speed : 0;
|
|
708
887
|
const vx = sway + state.wind.x * (50 + depth * 80) * gust;
|
|
709
888
|
const flutter = settings.wobble ? 1 + Math.sin(phase * 0.8) * 0.12 : 1;
|
|
@@ -718,16 +897,17 @@ function updateSnow(pool, count, state, settings) {
|
|
|
718
897
|
} else {
|
|
719
898
|
pool[base] = x;
|
|
720
899
|
pool[base + 1] = y;
|
|
721
|
-
pool[base + 2] =
|
|
900
|
+
pool[base + 2] = angle;
|
|
722
901
|
}
|
|
723
902
|
}
|
|
724
903
|
}
|
|
725
|
-
function drawSnow(pool, count, state, settings, texture, sprite) {
|
|
904
|
+
function drawSnow(pool, count, state, settings, texture, sprite, visibility) {
|
|
726
905
|
if (count === 0) return;
|
|
727
906
|
const ctx = state.ctx;
|
|
728
907
|
const aspect = sprite ? sprite.aspect : 1;
|
|
729
908
|
ctx.fillStyle = settings.color;
|
|
730
909
|
for (let i = 0; i < count; i++) {
|
|
910
|
+
if (visibility[i] === 0) continue;
|
|
731
911
|
const base = i * PARTICLE_STRIDE;
|
|
732
912
|
const x = pool[base];
|
|
733
913
|
const y = pool[base + 1];
|
|
@@ -735,14 +915,14 @@ function drawSnow(pool, count, state, settings, texture, sprite) {
|
|
|
735
915
|
const r = Math.max(0, settings.minSize + depth * (settings.maxSize - settings.minSize));
|
|
736
916
|
if (r === 0) continue;
|
|
737
917
|
const fade = Math.max(0, Math.min(1, (y + 20) / 45, (state.height + 20 - y) / 55));
|
|
738
|
-
ctx.globalAlpha = pool[base + 4] * fade;
|
|
918
|
+
ctx.globalAlpha = pool[base + 4] * fade * visibility[i];
|
|
739
919
|
if (sprite) {
|
|
740
920
|
const halfWidth = r * Math.min(1, aspect);
|
|
741
921
|
const halfHeight = r / Math.max(1, aspect);
|
|
742
922
|
ctx.save();
|
|
743
923
|
try {
|
|
744
924
|
ctx.translate(x, y);
|
|
745
|
-
if (settings.
|
|
925
|
+
if (settings.rotation) ctx.rotate(pool[base + 2]);
|
|
746
926
|
ctx.drawImage(sprite.texture, -halfWidth, -halfHeight, halfWidth * 2, halfHeight * 2);
|
|
747
927
|
} finally {
|
|
748
928
|
ctx.restore();
|
|
@@ -760,31 +940,60 @@ function drawSnow(pool, count, state, settings, texture, sprite) {
|
|
|
760
940
|
}
|
|
761
941
|
function Snow({
|
|
762
942
|
intensity = 0.5,
|
|
943
|
+
transitionDuration = 0,
|
|
763
944
|
speed = 1,
|
|
764
945
|
color = "rgba(255, 255, 255, 0.85)",
|
|
765
946
|
sprite,
|
|
766
947
|
minSize = sprite ? 6 : 1.5,
|
|
767
948
|
maxSize = sprite ? 18 : 4,
|
|
768
|
-
wobble = true
|
|
949
|
+
wobble = true,
|
|
950
|
+
rotation,
|
|
951
|
+
rotationSpeed
|
|
769
952
|
}) {
|
|
770
953
|
const ticker = React.useContext(TickerCtx);
|
|
771
954
|
if (!ticker) {
|
|
772
955
|
throw new Error("<Snow /> must be used inside <WeatherCanvas>");
|
|
773
956
|
}
|
|
957
|
+
const effectiveRotation = rotation ?? (rotationSpeed !== void 0 ? true : wobble);
|
|
958
|
+
const effectiveRotationSpeed = rotationSpeed ?? 1;
|
|
774
959
|
const settingsRef = React.useRef({
|
|
775
960
|
intensity,
|
|
961
|
+
transitionDuration,
|
|
776
962
|
speed,
|
|
777
963
|
color,
|
|
778
964
|
minSize,
|
|
779
965
|
maxSize,
|
|
780
|
-
wobble
|
|
966
|
+
wobble,
|
|
967
|
+
rotation: effectiveRotation,
|
|
968
|
+
rotationSpeed: effectiveRotationSpeed
|
|
781
969
|
});
|
|
782
970
|
const textureRef = React.useRef(null);
|
|
783
971
|
const spriteRef = React.useRef(null);
|
|
784
972
|
React.useEffect(() => {
|
|
785
|
-
settingsRef.current = {
|
|
973
|
+
settingsRef.current = {
|
|
974
|
+
intensity,
|
|
975
|
+
transitionDuration,
|
|
976
|
+
speed,
|
|
977
|
+
color,
|
|
978
|
+
minSize,
|
|
979
|
+
maxSize,
|
|
980
|
+
wobble,
|
|
981
|
+
rotation: effectiveRotation,
|
|
982
|
+
rotationSpeed: effectiveRotationSpeed
|
|
983
|
+
};
|
|
786
984
|
ticker.invalidate();
|
|
787
|
-
}, [
|
|
985
|
+
}, [
|
|
986
|
+
ticker,
|
|
987
|
+
intensity,
|
|
988
|
+
transitionDuration,
|
|
989
|
+
speed,
|
|
990
|
+
color,
|
|
991
|
+
minSize,
|
|
992
|
+
maxSize,
|
|
993
|
+
wobble,
|
|
994
|
+
effectiveRotation,
|
|
995
|
+
effectiveRotationSpeed
|
|
996
|
+
]);
|
|
788
997
|
React.useEffect(() => {
|
|
789
998
|
textureRef.current = createSnowTexture(color);
|
|
790
999
|
ticker.invalidate();
|
|
@@ -827,13 +1036,21 @@ function Snow({
|
|
|
827
1036
|
};
|
|
828
1037
|
}, [ticker, sprite]);
|
|
829
1038
|
React.useEffect(() => {
|
|
830
|
-
|
|
1039
|
+
const capacity = ticker.getBudget().maxParticles;
|
|
1040
|
+
let pool = createParticlePool(capacity);
|
|
1041
|
+
const particles = createParticleTransition(
|
|
1042
|
+
Math.round(Math.max(0, Math.min(1, settingsRef.current.intensity)) * capacity),
|
|
1043
|
+
capacity,
|
|
1044
|
+
settingsRef.current.transitionDuration
|
|
1045
|
+
);
|
|
831
1046
|
let initializedCount = 0;
|
|
832
1047
|
const unregister = ticker.register(LAYER.MIDGROUND, (state) => {
|
|
833
1048
|
const settings = settingsRef.current;
|
|
834
|
-
const
|
|
1049
|
+
const targetCount = Math.round(
|
|
835
1050
|
Math.max(0, Math.min(1, settings.intensity)) * state.budget.maxParticles
|
|
836
1051
|
);
|
|
1052
|
+
updateParticleTransition(particles, targetCount, settings.transitionDuration, state);
|
|
1053
|
+
const count = particles.count;
|
|
837
1054
|
pool = growParticlePool(pool, state.budget.maxParticles);
|
|
838
1055
|
if (count > initializedCount) {
|
|
839
1056
|
seedSnowRange(pool, initializedCount, count, state);
|
|
@@ -850,7 +1067,7 @@ function Snow({
|
|
|
850
1067
|
spriteRef.current = createSnowSprite(cachedSprite.image, size) ?? cachedSprite;
|
|
851
1068
|
}
|
|
852
1069
|
}
|
|
853
|
-
drawSnow(pool, count, state, settings, textureRef.current, spriteRef.current);
|
|
1070
|
+
drawSnow(pool, count, state, settings, textureRef.current, spriteRef.current, particles.visibility);
|
|
854
1071
|
});
|
|
855
1072
|
return unregister;
|
|
856
1073
|
}, [ticker]);
|
|
@@ -892,6 +1109,7 @@ function renderRadialGradient(surface, size, color) {
|
|
|
892
1109
|
}
|
|
893
1110
|
function Fog({
|
|
894
1111
|
density = 0.4,
|
|
1112
|
+
transitionDuration = 0,
|
|
895
1113
|
speed = 1,
|
|
896
1114
|
color = "rgba(220, 225, 235, 0.35)",
|
|
897
1115
|
layers = 3
|
|
@@ -900,11 +1118,11 @@ function Fog({
|
|
|
900
1118
|
if (!ticker) {
|
|
901
1119
|
throw new Error("<Fog /> must be used inside <WeatherCanvas>");
|
|
902
1120
|
}
|
|
903
|
-
const settingsRef = React.useRef({ density, speed, color, layers });
|
|
1121
|
+
const settingsRef = React.useRef({ density, transitionDuration, speed, color, layers });
|
|
904
1122
|
React.useEffect(() => {
|
|
905
|
-
settingsRef.current = { density, speed, color, layers };
|
|
1123
|
+
settingsRef.current = { density, transitionDuration, speed, color, layers };
|
|
906
1124
|
ticker.invalidate();
|
|
907
|
-
}, [ticker, density, speed, color, layers]);
|
|
1125
|
+
}, [ticker, density, transitionDuration, speed, color, layers]);
|
|
908
1126
|
const textureRef = React.useRef(null);
|
|
909
1127
|
const imageBitmapRef = React.useRef(null);
|
|
910
1128
|
React.useEffect(() => {
|
|
@@ -951,13 +1169,18 @@ function Fog({
|
|
|
951
1169
|
}, [ticker, color]);
|
|
952
1170
|
React.useEffect(() => {
|
|
953
1171
|
const puffs = [];
|
|
1172
|
+
let currentDensity = Math.max(0, Math.min(1, settingsRef.current.density));
|
|
1173
|
+
const transition = createTransition(currentDensity, settingsRef.current.transitionDuration);
|
|
954
1174
|
const unregister = ticker.register(LAYER.BACKGROUND, (state) => {
|
|
955
1175
|
const texture = textureRef.current;
|
|
956
1176
|
if (!texture) return;
|
|
957
1177
|
const settings = settingsRef.current;
|
|
958
1178
|
const numLayers = Math.round(Math.max(1, Math.min(6, settings.layers)));
|
|
959
1179
|
const count = numLayers * 3;
|
|
960
|
-
const
|
|
1180
|
+
const targetDensity = Math.max(0, Math.min(1, settings.density));
|
|
1181
|
+
const step = stepTransition(transition, targetDensity, settings.transitionDuration, state);
|
|
1182
|
+
currentDensity = step === 1 ? targetDensity : currentDensity + (targetDensity - currentDensity) * step;
|
|
1183
|
+
const baseAlpha = currentDensity;
|
|
961
1184
|
if (baseAlpha === 0) return;
|
|
962
1185
|
for (let i = puffs.length; i < count; i++) {
|
|
963
1186
|
const layer = Math.floor(i / 3);
|