react-weather-effects 0.0.0-stage → 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 +470 -2
- package/dist/index.cjs +1232 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +101 -0
- package/dist/index.d.mts +101 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.mjs +1223 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback, useContext, useId, useEffect, useMemo } from 'react';
|
|
2
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
// ../core/dist/index.mjs
|
|
5
|
+
var QUALITY_LIMITS = {
|
|
6
|
+
low: { maxParticles: 150, dprCap: 1 },
|
|
7
|
+
medium: { maxParticles: 300, dprCap: 2 },
|
|
8
|
+
high: { maxParticles: 600, dprCap: 2 }
|
|
9
|
+
};
|
|
10
|
+
function clampDPR(weight = "normal") {
|
|
11
|
+
if (typeof window === "undefined") return 1;
|
|
12
|
+
const raw = window.devicePixelRatio ?? 1;
|
|
13
|
+
const caps = { light: 2, normal: 2, heavy: 1.5 };
|
|
14
|
+
return Math.min(raw, caps[weight]);
|
|
15
|
+
}
|
|
16
|
+
function resolveRenderBudget(quality = "medium", dprWeight = "normal") {
|
|
17
|
+
const limits = QUALITY_LIMITS[quality];
|
|
18
|
+
const dpr = Math.min(clampDPR(dprWeight), limits.dprCap);
|
|
19
|
+
return {
|
|
20
|
+
maxParticles: limits.maxParticles,
|
|
21
|
+
dpr,
|
|
22
|
+
quality
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function resolveMotion(prop = "auto", osPreference) {
|
|
26
|
+
if (prop === "auto") {
|
|
27
|
+
return osPreference === false ? "animated" : "static";
|
|
28
|
+
}
|
|
29
|
+
return prop;
|
|
30
|
+
}
|
|
31
|
+
function hashString(str) {
|
|
32
|
+
let hash = 2166136261 >>> 0;
|
|
33
|
+
for (let i = 0; i < str.length; i++) {
|
|
34
|
+
hash ^= str.charCodeAt(i);
|
|
35
|
+
hash = Math.imul(hash, 16777619);
|
|
36
|
+
}
|
|
37
|
+
return hash >>> 0;
|
|
38
|
+
}
|
|
39
|
+
function createSeededRandom(seed) {
|
|
40
|
+
let state = (typeof seed === "string" ? hashString(seed) : seed) >>> 0;
|
|
41
|
+
return () => {
|
|
42
|
+
state |= 0;
|
|
43
|
+
state = state + 1831565813 | 0;
|
|
44
|
+
let t = Math.imul(state ^ state >>> 15, 1 | state);
|
|
45
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
46
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
var defaultScheduler = {
|
|
50
|
+
request: (cb) => {
|
|
51
|
+
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
|
52
|
+
return window.requestAnimationFrame(cb);
|
|
53
|
+
}
|
|
54
|
+
return setTimeout(() => cb(Date.now()), 16);
|
|
55
|
+
},
|
|
56
|
+
cancel: (id) => {
|
|
57
|
+
if (typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
|
|
58
|
+
window.cancelAnimationFrame(id);
|
|
59
|
+
} else {
|
|
60
|
+
clearTimeout(id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var SchedulerCtx = React.createContext(null);
|
|
65
|
+
var LAYER = {
|
|
66
|
+
BACKGROUND: 0,
|
|
67
|
+
MIDGROUND: 100};
|
|
68
|
+
var TickerCtx = React.createContext(null);
|
|
69
|
+
var __DEV__ = typeof process !== "undefined" && process.env && true;
|
|
70
|
+
function WeatherCanvas({
|
|
71
|
+
children,
|
|
72
|
+
mode = "fullscreen",
|
|
73
|
+
wind = { x: 0, y: 0 },
|
|
74
|
+
dpr = "normal",
|
|
75
|
+
quality = "medium",
|
|
76
|
+
reducedMotion = "auto",
|
|
77
|
+
paused = false,
|
|
78
|
+
seed,
|
|
79
|
+
zIndex = 1,
|
|
80
|
+
onContextLost
|
|
81
|
+
}) {
|
|
82
|
+
const [canvasNode, setCanvasNode] = useState(null);
|
|
83
|
+
const canvasRef = useRef(null);
|
|
84
|
+
const containerRef = useRef(null);
|
|
85
|
+
const setCanvasRef = useCallback((node) => {
|
|
86
|
+
canvasRef.current = node;
|
|
87
|
+
setCanvasNode(node);
|
|
88
|
+
}, []);
|
|
89
|
+
const injectedScheduler = useContext(SchedulerCtx);
|
|
90
|
+
const scheduler = injectedScheduler ?? defaultScheduler;
|
|
91
|
+
const autoSeed = useId();
|
|
92
|
+
const effectiveSeed = seed ?? autoSeed;
|
|
93
|
+
const randomFnRef = useRef(createSeededRandom(effectiveSeed));
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
randomFnRef.current = createSeededRandom(effectiveSeed);
|
|
96
|
+
}, [effectiveSeed]);
|
|
97
|
+
const [contextAvailable, setContextAvailable] = useState(true);
|
|
98
|
+
const ctxRef = useRef(null);
|
|
99
|
+
const [dimensions, setDimensions] = useState({
|
|
100
|
+
width: 0,
|
|
101
|
+
height: 0
|
|
102
|
+
});
|
|
103
|
+
const [osPreference, setOsPreference] = useState(null);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
106
|
+
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
107
|
+
setOsPreference(mq.matches);
|
|
108
|
+
const handler = (e) => setOsPreference(e.matches);
|
|
109
|
+
mq.addEventListener("change", handler);
|
|
110
|
+
return () => mq.removeEventListener("change", handler);
|
|
111
|
+
}, []);
|
|
112
|
+
const motionMode = resolveMotion(reducedMotion, osPreference);
|
|
113
|
+
const [pageVisible, setPageVisible] = useState(() => {
|
|
114
|
+
if (typeof document === "undefined") return true;
|
|
115
|
+
return document.visibilityState === "visible";
|
|
116
|
+
});
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (typeof document === "undefined") return;
|
|
119
|
+
const handleVisibility = () => {
|
|
120
|
+
setPageVisible(document.visibilityState === "visible");
|
|
121
|
+
};
|
|
122
|
+
document.addEventListener("visibilitychange", handleVisibility);
|
|
123
|
+
return () => document.removeEventListener("visibilitychange", handleVisibility);
|
|
124
|
+
}, []);
|
|
125
|
+
const [inViewport, setInViewport] = useState(true);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const el = canvasNode;
|
|
128
|
+
if (!el || typeof IntersectionObserver === "undefined") return;
|
|
129
|
+
const observer = new IntersectionObserver(
|
|
130
|
+
(entries) => {
|
|
131
|
+
const entry = entries[0];
|
|
132
|
+
if (entry) {
|
|
133
|
+
setInViewport(entry.isIntersecting);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{ threshold: 0 }
|
|
137
|
+
);
|
|
138
|
+
observer.observe(el);
|
|
139
|
+
return () => observer.disconnect();
|
|
140
|
+
}, [canvasNode]);
|
|
141
|
+
const [budget, setBudget] = useState(() => resolveRenderBudget(quality, dpr));
|
|
142
|
+
const budgetRef = useRef(budget);
|
|
143
|
+
const nextRegId = useRef(1);
|
|
144
|
+
const nextSeq = useRef(1);
|
|
145
|
+
const registrationsRef = useRef([]);
|
|
146
|
+
const orderedSnapshotRef = useRef([]);
|
|
147
|
+
const [effectCount, setEffectCount] = useState(0);
|
|
148
|
+
const windRef = useRef(wind);
|
|
149
|
+
const simulationTime = useRef(0);
|
|
150
|
+
const lastTimestamp = useRef(null);
|
|
151
|
+
const rafId = useRef(null);
|
|
152
|
+
const pendingAssetsRef = useRef(/* @__PURE__ */ new Set());
|
|
153
|
+
const isMicrotaskScheduled = useRef(false);
|
|
154
|
+
const isDirtyRef = useRef(true);
|
|
155
|
+
const canPresent = contextAvailable && dimensions.width > 0 && dimensions.height > 0 && pageVisible && inViewport && motionMode !== "off";
|
|
156
|
+
const shouldAnimate = canPresent && effectCount > 0 && !paused && motionMode === "animated";
|
|
157
|
+
const canPresentRef = useRef(canPresent);
|
|
158
|
+
canPresentRef.current = canPresent;
|
|
159
|
+
const shouldAnimateRef = useRef(shouldAnimate);
|
|
160
|
+
shouldAnimateRef.current = shouldAnimate;
|
|
161
|
+
const motionModeRef = useRef(motionMode);
|
|
162
|
+
motionModeRef.current = motionMode;
|
|
163
|
+
const onContextLostRef = useRef(onContextLost);
|
|
164
|
+
onContextLostRef.current = onContextLost;
|
|
165
|
+
const drawFrame = useCallback((dt, timestamp) => {
|
|
166
|
+
const canvas = canvasRef.current;
|
|
167
|
+
const ctx = ctxRef.current;
|
|
168
|
+
if (!canvas || !ctx || canvas.width === 0 || canvas.height === 0) return;
|
|
169
|
+
const snapshot = orderedSnapshotRef.current;
|
|
170
|
+
const currentBudget = budgetRef.current;
|
|
171
|
+
const currentDpr = currentBudget.dpr;
|
|
172
|
+
isDirtyRef.current = false;
|
|
173
|
+
const frameState = {
|
|
174
|
+
ctx,
|
|
175
|
+
dt,
|
|
176
|
+
isAnimating: shouldAnimateRef.current,
|
|
177
|
+
width: dimensions.width,
|
|
178
|
+
height: dimensions.height,
|
|
179
|
+
dpr: currentDpr,
|
|
180
|
+
budget: currentBudget,
|
|
181
|
+
wind: windRef.current,
|
|
182
|
+
pointer: null,
|
|
183
|
+
timestamp,
|
|
184
|
+
random: randomFnRef.current
|
|
185
|
+
};
|
|
186
|
+
ctx.save();
|
|
187
|
+
try {
|
|
188
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
189
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
190
|
+
ctx.setTransform(currentDpr, 0, 0, currentDpr, 0, 0);
|
|
191
|
+
if (motionModeRef.current === "off" || snapshot.length === 0) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const reg of snapshot) {
|
|
195
|
+
ctx.save();
|
|
196
|
+
ctx.beginPath();
|
|
197
|
+
try {
|
|
198
|
+
reg.fn(frameState);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (__DEV__) {
|
|
201
|
+
console.error("Weather effect draw error:", err);
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
ctx.restore();
|
|
205
|
+
ctx.beginPath();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
ctx.restore();
|
|
210
|
+
ctx.beginPath();
|
|
211
|
+
}
|
|
212
|
+
}, [dimensions.width, dimensions.height]);
|
|
213
|
+
const drawFrameRef = useRef(drawFrame);
|
|
214
|
+
drawFrameRef.current = drawFrame;
|
|
215
|
+
const invalidate = useCallback(() => {
|
|
216
|
+
isDirtyRef.current = true;
|
|
217
|
+
if (shouldAnimateRef.current) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!isMicrotaskScheduled.current) {
|
|
221
|
+
isMicrotaskScheduled.current = true;
|
|
222
|
+
queueMicrotask(() => {
|
|
223
|
+
isMicrotaskScheduled.current = false;
|
|
224
|
+
if (!canPresentRef.current) return;
|
|
225
|
+
drawFrameRef.current(0, simulationTime.current);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}, []);
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const nextBudget = resolveRenderBudget(quality, dpr);
|
|
231
|
+
budgetRef.current = nextBudget;
|
|
232
|
+
setBudget(nextBudget);
|
|
233
|
+
invalidate();
|
|
234
|
+
}, [quality, dpr, invalidate]);
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
237
|
+
let mql;
|
|
238
|
+
const updateDpr = () => {
|
|
239
|
+
if (mql) mql.removeEventListener("change", updateDpr);
|
|
240
|
+
const nextBudget = resolveRenderBudget(quality, dpr);
|
|
241
|
+
budgetRef.current = nextBudget;
|
|
242
|
+
setBudget(nextBudget);
|
|
243
|
+
invalidate();
|
|
244
|
+
const currentDppx = window.devicePixelRatio ?? 1;
|
|
245
|
+
mql = window.matchMedia(`(resolution: ${currentDppx}dppx)`);
|
|
246
|
+
mql.addEventListener("change", updateDpr, { once: true });
|
|
247
|
+
};
|
|
248
|
+
updateDpr();
|
|
249
|
+
return () => mql?.removeEventListener("change", updateDpr);
|
|
250
|
+
}, [quality, dpr, invalidate]);
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
windRef.current = wind;
|
|
253
|
+
invalidate();
|
|
254
|
+
}, [wind.x, wind.y, invalidate]);
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (canPresent && isDirtyRef.current && !shouldAnimate) {
|
|
257
|
+
invalidate();
|
|
258
|
+
}
|
|
259
|
+
}, [canPresent, shouldAnimate, invalidate]);
|
|
260
|
+
const updateSnapshot = useCallback(() => {
|
|
261
|
+
const sorted = [...registrationsRef.current].sort((a, b) => {
|
|
262
|
+
if (a.layer !== b.layer) return a.layer - b.layer;
|
|
263
|
+
return a.seq - b.seq;
|
|
264
|
+
});
|
|
265
|
+
orderedSnapshotRef.current = sorted;
|
|
266
|
+
setEffectCount(sorted.length);
|
|
267
|
+
}, []);
|
|
268
|
+
const register = useCallback(
|
|
269
|
+
(layer, fn) => {
|
|
270
|
+
const id = nextRegId.current++;
|
|
271
|
+
const seq = nextSeq.current++;
|
|
272
|
+
const reg = { id, layer, fn, seq };
|
|
273
|
+
registrationsRef.current.push(reg);
|
|
274
|
+
updateSnapshot();
|
|
275
|
+
invalidate();
|
|
276
|
+
let unregistered = false;
|
|
277
|
+
return () => {
|
|
278
|
+
if (unregistered) return;
|
|
279
|
+
unregistered = true;
|
|
280
|
+
registrationsRef.current = registrationsRef.current.filter((r) => r.id !== id);
|
|
281
|
+
updateSnapshot();
|
|
282
|
+
invalidate();
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
[updateSnapshot, invalidate]
|
|
286
|
+
);
|
|
287
|
+
const getBudget = useCallback(() => budgetRef.current, []);
|
|
288
|
+
const trackAsset = useCallback(
|
|
289
|
+
(task) => {
|
|
290
|
+
pendingAssetsRef.current.add(task);
|
|
291
|
+
let settled = false;
|
|
292
|
+
const finish = () => {
|
|
293
|
+
if (!settled) {
|
|
294
|
+
settled = true;
|
|
295
|
+
pendingAssetsRef.current.delete(task);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
task.then(
|
|
299
|
+
() => {
|
|
300
|
+
finish();
|
|
301
|
+
invalidate();
|
|
302
|
+
},
|
|
303
|
+
() => {
|
|
304
|
+
finish();
|
|
305
|
+
invalidate();
|
|
306
|
+
}
|
|
307
|
+
);
|
|
308
|
+
return () => {
|
|
309
|
+
finish();
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
[invalidate]
|
|
313
|
+
);
|
|
314
|
+
const tickerContext = useMemo(
|
|
315
|
+
() => ({
|
|
316
|
+
register,
|
|
317
|
+
invalidate,
|
|
318
|
+
getBudget,
|
|
319
|
+
trackAsset
|
|
320
|
+
}),
|
|
321
|
+
[register, invalidate, getBudget, trackAsset]
|
|
322
|
+
);
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
const canvas = canvasNode;
|
|
325
|
+
if (!canvas) return;
|
|
326
|
+
let ctx = null;
|
|
327
|
+
try {
|
|
328
|
+
ctx = canvas.getContext("2d");
|
|
329
|
+
} catch {
|
|
330
|
+
ctx = null;
|
|
331
|
+
}
|
|
332
|
+
if (!ctx) {
|
|
333
|
+
if (__DEV__) {
|
|
334
|
+
console.warn("WeatherCanvas: Failed to acquire 2D rendering context.");
|
|
335
|
+
}
|
|
336
|
+
setContextAvailable(false);
|
|
337
|
+
onContextLostRef.current?.();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
ctxRef.current = ctx;
|
|
341
|
+
setContextAvailable(true);
|
|
342
|
+
invalidate();
|
|
343
|
+
const handleContextLost = (e) => {
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
setContextAvailable(false);
|
|
346
|
+
ctxRef.current = null;
|
|
347
|
+
onContextLostRef.current?.();
|
|
348
|
+
};
|
|
349
|
+
const handleContextRestored = () => {
|
|
350
|
+
const restoredCtx = canvas.getContext("2d");
|
|
351
|
+
if (restoredCtx) {
|
|
352
|
+
ctxRef.current = restoredCtx;
|
|
353
|
+
setContextAvailable(true);
|
|
354
|
+
invalidate();
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
canvas.addEventListener("contextlost", handleContextLost);
|
|
358
|
+
canvas.addEventListener("contextrestored", handleContextRestored);
|
|
359
|
+
return () => {
|
|
360
|
+
canvas.removeEventListener("contextlost", handleContextLost);
|
|
361
|
+
canvas.removeEventListener("contextrestored", handleContextRestored);
|
|
362
|
+
};
|
|
363
|
+
}, [canvasNode, invalidate]);
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
const target = mode === "fullscreen" ? canvasNode : containerRef.current ?? canvasNode;
|
|
366
|
+
if (!target) return;
|
|
367
|
+
if (mode === "fullscreen") {
|
|
368
|
+
const updateFullscreenSize = () => {
|
|
369
|
+
const w = typeof window !== "undefined" ? window.innerWidth : 0;
|
|
370
|
+
const h = typeof window !== "undefined" ? window.innerHeight : 0;
|
|
371
|
+
setDimensions((prev) => {
|
|
372
|
+
if (prev.width === w && prev.height === h) return prev;
|
|
373
|
+
return { width: w, height: h };
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
updateFullscreenSize();
|
|
377
|
+
window.addEventListener("resize", updateFullscreenSize);
|
|
378
|
+
return () => window.removeEventListener("resize", updateFullscreenSize);
|
|
379
|
+
}
|
|
380
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
381
|
+
const ro = new ResizeObserver((entries) => {
|
|
382
|
+
const entry = entries[0];
|
|
383
|
+
if (!entry) return;
|
|
384
|
+
const { width, height } = entry.contentRect;
|
|
385
|
+
setDimensions((prev) => {
|
|
386
|
+
if (prev.width === width && prev.height === height) return prev;
|
|
387
|
+
return { width, height };
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
ro.observe(target);
|
|
391
|
+
return () => ro.disconnect();
|
|
392
|
+
}, [mode, canvasNode]);
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
const canvas = canvasNode;
|
|
395
|
+
if (!canvas) return;
|
|
396
|
+
const currentDpr = budget.dpr;
|
|
397
|
+
const backingWidth = Math.round(dimensions.width * currentDpr);
|
|
398
|
+
const backingHeight = Math.round(dimensions.height * currentDpr);
|
|
399
|
+
if (canvas.width !== backingWidth || canvas.height !== backingHeight) {
|
|
400
|
+
canvas.width = backingWidth;
|
|
401
|
+
canvas.height = backingHeight;
|
|
402
|
+
invalidate();
|
|
403
|
+
}
|
|
404
|
+
}, [dimensions, budget.dpr, canvasNode, invalidate]);
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
if (motionMode === "off") {
|
|
407
|
+
const canvas = canvasRef.current;
|
|
408
|
+
const ctx = ctxRef.current;
|
|
409
|
+
if (canvas && ctx && canvas.width > 0 && canvas.height > 0) {
|
|
410
|
+
ctx.save();
|
|
411
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
412
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
413
|
+
ctx.restore();
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
invalidate();
|
|
417
|
+
}
|
|
418
|
+
}, [motionMode, invalidate]);
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
if (!shouldAnimate) {
|
|
421
|
+
if (rafId.current !== null) {
|
|
422
|
+
scheduler.cancel(rafId.current);
|
|
423
|
+
rafId.current = null;
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
let stopped = false;
|
|
428
|
+
lastTimestamp.current = null;
|
|
429
|
+
function tick(now) {
|
|
430
|
+
rafId.current = null;
|
|
431
|
+
if (stopped || !shouldAnimateRef.current) return;
|
|
432
|
+
const dt = lastTimestamp.current === null ? 0 : Math.min(Math.max((now - lastTimestamp.current) / 1e3, 0), 0.1);
|
|
433
|
+
lastTimestamp.current = now;
|
|
434
|
+
simulationTime.current += dt * 1e3;
|
|
435
|
+
drawFrameRef.current(dt, simulationTime.current);
|
|
436
|
+
if (!stopped && shouldAnimateRef.current) {
|
|
437
|
+
rafId.current = scheduler.request(tick);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
rafId.current = scheduler.request(tick);
|
|
441
|
+
return () => {
|
|
442
|
+
stopped = true;
|
|
443
|
+
if (rafId.current !== null) {
|
|
444
|
+
scheduler.cancel(rafId.current);
|
|
445
|
+
rafId.current = null;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}, [shouldAnimate, scheduler]);
|
|
449
|
+
const containerStyle = mode === "fullscreen" ? {
|
|
450
|
+
position: "fixed",
|
|
451
|
+
top: 0,
|
|
452
|
+
left: 0,
|
|
453
|
+
width: "100vw",
|
|
454
|
+
height: "100vh",
|
|
455
|
+
pointerEvents: "none",
|
|
456
|
+
zIndex
|
|
457
|
+
} : {
|
|
458
|
+
position: "absolute",
|
|
459
|
+
top: 0,
|
|
460
|
+
left: 0,
|
|
461
|
+
width: "100%",
|
|
462
|
+
height: "100%",
|
|
463
|
+
pointerEvents: "none",
|
|
464
|
+
zIndex
|
|
465
|
+
};
|
|
466
|
+
const canvasStyle = {
|
|
467
|
+
position: "absolute",
|
|
468
|
+
top: 0,
|
|
469
|
+
left: 0,
|
|
470
|
+
width: "100%",
|
|
471
|
+
height: "100%",
|
|
472
|
+
pointerEvents: "none"
|
|
473
|
+
};
|
|
474
|
+
return /* @__PURE__ */ jsx(TickerCtx.Provider, { value: tickerContext, children: /* @__PURE__ */ jsxs("div", { ref: containerRef, style: containerStyle, children: [
|
|
475
|
+
/* @__PURE__ */ jsx("canvas", { ref: setCanvasRef, "aria-hidden": "true", style: canvasStyle }),
|
|
476
|
+
children
|
|
477
|
+
] }) });
|
|
478
|
+
}
|
|
479
|
+
var PARTICLE_STRIDE = 7;
|
|
480
|
+
function createParticlePool(count) {
|
|
481
|
+
return new Float32Array(count * PARTICLE_STRIDE);
|
|
482
|
+
}
|
|
483
|
+
function growParticlePool(pool, count) {
|
|
484
|
+
if (pool.length >= count * PARTICLE_STRIDE) return pool;
|
|
485
|
+
const next = createParticlePool(count);
|
|
486
|
+
next.set(pool);
|
|
487
|
+
return next;
|
|
488
|
+
}
|
|
489
|
+
function resolveDuration(duration) {
|
|
490
|
+
return Number.isFinite(duration) ? Math.max(0, duration) : 0;
|
|
491
|
+
}
|
|
492
|
+
function createTransition(target, duration) {
|
|
493
|
+
return { target, duration: resolveDuration(duration), remaining: 0 };
|
|
494
|
+
}
|
|
495
|
+
function stepTransition(transition, target, duration, state) {
|
|
496
|
+
const nextDuration = resolveDuration(duration);
|
|
497
|
+
if (target !== transition.target || nextDuration !== transition.duration) {
|
|
498
|
+
transition.target = target;
|
|
499
|
+
transition.duration = nextDuration;
|
|
500
|
+
transition.remaining = nextDuration;
|
|
501
|
+
}
|
|
502
|
+
if (!state.isAnimating || nextDuration === 0) {
|
|
503
|
+
transition.remaining = 0;
|
|
504
|
+
return 1;
|
|
505
|
+
}
|
|
506
|
+
if (transition.remaining === 0) return 1;
|
|
507
|
+
const elapsed = state.dt * 1e3;
|
|
508
|
+
const step = elapsed + 1e-6 >= transition.remaining ? 1 : elapsed / transition.remaining;
|
|
509
|
+
transition.remaining = step === 1 ? 0 : transition.remaining - elapsed;
|
|
510
|
+
return step;
|
|
511
|
+
}
|
|
512
|
+
function createParticleTransition(count, capacity, duration) {
|
|
513
|
+
const visibility = new Float32Array(capacity);
|
|
514
|
+
visibility.fill(1, 0, count);
|
|
515
|
+
return { transition: createTransition(count, duration), visibility, count };
|
|
516
|
+
}
|
|
517
|
+
function updateParticleTransition(particles, targetCount, duration, state) {
|
|
518
|
+
const capacity = state.budget.maxParticles;
|
|
519
|
+
if (particles.visibility.length < capacity) {
|
|
520
|
+
const visibility = new Float32Array(capacity);
|
|
521
|
+
visibility.set(particles.visibility);
|
|
522
|
+
particles.visibility = visibility;
|
|
523
|
+
}
|
|
524
|
+
if (particles.count > capacity) particles.visibility.fill(0, capacity, particles.count);
|
|
525
|
+
const count = Math.min(capacity, Math.max(particles.count, targetCount));
|
|
526
|
+
const step = stepTransition(particles.transition, targetCount, duration, state);
|
|
527
|
+
for (let i = 0; i < count; i++) {
|
|
528
|
+
const target = i < targetCount ? 1 : 0;
|
|
529
|
+
particles.visibility[i] = step === 1 ? target : particles.visibility[i] + (target - particles.visibility[i]) * step;
|
|
530
|
+
}
|
|
531
|
+
particles.count = step === 1 ? targetCount : count;
|
|
532
|
+
}
|
|
533
|
+
var SPLASH_STRIDE = 7;
|
|
534
|
+
function seedParticle(pool, i, state, settings, isInitial) {
|
|
535
|
+
const base = i * PARTICLE_STRIDE;
|
|
536
|
+
const angleRad = settings.angle * Math.PI / 180;
|
|
537
|
+
const depth = state.random();
|
|
538
|
+
const fallSpeed = 550 + depth * 650;
|
|
539
|
+
const speed = fallSpeed * settings.speed;
|
|
540
|
+
const vx = Math.sin(angleRad) * speed + state.wind.x * (140 + depth * 150);
|
|
541
|
+
const vy = Math.cos(angleRad) * speed + state.wind.y * (60 + depth * 50);
|
|
542
|
+
const x = state.random() * (state.width + 200) - 100;
|
|
543
|
+
const y = isInitial ? state.random() * (state.height + 100) - 50 : -50 - state.random() * 100;
|
|
544
|
+
pool[base] = x;
|
|
545
|
+
pool[base + 1] = y;
|
|
546
|
+
pool[base + 2] = vx;
|
|
547
|
+
pool[base + 3] = vy;
|
|
548
|
+
pool[base + 4] = 0.3 + depth * 0.6;
|
|
549
|
+
pool[base + 5] = fallSpeed;
|
|
550
|
+
pool[base + 6] = depth;
|
|
551
|
+
}
|
|
552
|
+
function seedParticleRange(pool, startIndex, endIndex, state, settings) {
|
|
553
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
554
|
+
seedParticle(pool, i, state, settings, true);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function updatePool(pool, count, state, settings, visibility, splashPool, maxSplashes, splashState) {
|
|
558
|
+
const dt = state.dt;
|
|
559
|
+
const width = state.width;
|
|
560
|
+
const height = state.height;
|
|
561
|
+
const wrapWidth = width + 200;
|
|
562
|
+
const angleRad = settings.angle * Math.PI / 180;
|
|
563
|
+
const sinAngle = Math.sin(angleRad);
|
|
564
|
+
const cosAngle = Math.cos(angleRad);
|
|
565
|
+
const response = 1 - Math.exp(-dt * 5);
|
|
566
|
+
if (splashState.active) {
|
|
567
|
+
splashState.active = false;
|
|
568
|
+
for (let s = 0; s < maxSplashes; s++) {
|
|
569
|
+
const sBase = s * SPLASH_STRIDE;
|
|
570
|
+
if (splashPool[sBase + 5] === 0 || splashPool[sBase + 2] >= 1) continue;
|
|
571
|
+
splashPool[sBase + 2] = Math.min(1, splashPool[sBase + 2] + dt * splashPool[sBase + 3]);
|
|
572
|
+
if (splashPool[sBase + 2] < 1) splashState.active = true;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
for (let i = 0; i < count; i++) {
|
|
576
|
+
const base = i * PARTICLE_STRIDE;
|
|
577
|
+
let x = pool[base];
|
|
578
|
+
let y = pool[base + 1];
|
|
579
|
+
let vx = pool[base + 2];
|
|
580
|
+
let vy = pool[base + 3];
|
|
581
|
+
const depth = pool[base + 6];
|
|
582
|
+
const speed = pool[base + 5] * settings.speed;
|
|
583
|
+
const targetVx = sinAngle * speed + state.wind.x * (140 + depth * 150);
|
|
584
|
+
const targetVy = cosAngle * speed + state.wind.y * (60 + depth * 50);
|
|
585
|
+
vx += (targetVx - vx) * response;
|
|
586
|
+
vy += (targetVy - vy) * response;
|
|
587
|
+
const prevY = y;
|
|
588
|
+
x += vx * dt;
|
|
589
|
+
y += vy * dt;
|
|
590
|
+
if (settings.splashes && prevY < height && y >= height && visibility[i] > 0.05) {
|
|
591
|
+
const elapsed = (y - height) / vy;
|
|
592
|
+
const impactX = x - vx * elapsed;
|
|
593
|
+
if (impactX >= 0 && impactX <= width) {
|
|
594
|
+
const sBase = splashState.head * SPLASH_STRIDE;
|
|
595
|
+
splashState.head = (splashState.head + 1) % maxSplashes;
|
|
596
|
+
if (splashPool[sBase + 5] === 0 || splashPool[sBase + 2] >= 1) {
|
|
597
|
+
const duration = 0.3 + depth * 0.18;
|
|
598
|
+
splashState.active = true;
|
|
599
|
+
splashPool[sBase] = impactX;
|
|
600
|
+
splashPool[sBase + 1] = Math.max(-40, Math.min(40, vx * 0.04));
|
|
601
|
+
splashPool[sBase + 2] = elapsed / duration;
|
|
602
|
+
splashPool[sBase + 3] = 1 / duration;
|
|
603
|
+
splashPool[sBase + 4] = (2.8 + depth * 4.6) * Math.sqrt(Math.max(0.4, Math.min(3, settings.dropThickness)));
|
|
604
|
+
splashPool[sBase + 5] = (0.25 + depth * 0.45) * visibility[i];
|
|
605
|
+
splashPool[sBase + 6] = (depth + i * 0.618) % 1;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (x > width + 100 || x < -100) {
|
|
610
|
+
x = ((x + 100) % wrapWidth + wrapWidth) % wrapWidth - 100;
|
|
611
|
+
}
|
|
612
|
+
if (y > height + 60) {
|
|
613
|
+
seedParticle(pool, i, state, settings, false);
|
|
614
|
+
} else {
|
|
615
|
+
pool[base] = x;
|
|
616
|
+
pool[base + 1] = y;
|
|
617
|
+
pool[base + 2] = vx;
|
|
618
|
+
pool[base + 3] = vy;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function drawPool(pool, count, state, settings, visibility, splashPool, maxSplashes) {
|
|
623
|
+
if (count === 0 && (!settings.splashes || maxSplashes === 0)) return;
|
|
624
|
+
const ctx = state.ctx;
|
|
625
|
+
ctx.strokeStyle = settings.color;
|
|
626
|
+
ctx.lineCap = "round";
|
|
627
|
+
if (count > 0) {
|
|
628
|
+
for (let i = 0; i < count; i++) {
|
|
629
|
+
if (visibility[i] === 0) continue;
|
|
630
|
+
const base = i * PARTICLE_STRIDE;
|
|
631
|
+
const x = pool[base];
|
|
632
|
+
const y = pool[base + 1];
|
|
633
|
+
const vx = pool[base + 2];
|
|
634
|
+
const vy = pool[base + 3];
|
|
635
|
+
const depth = pool[base + 6];
|
|
636
|
+
const trailFactor = (0.012 + depth * 0.025) * settings.dropLength;
|
|
637
|
+
const fade = Math.max(0, Math.min(1, (y + 25) / 45, (state.height + 35 - y) / 80));
|
|
638
|
+
for (let segment = 0; segment < 3; segment++) {
|
|
639
|
+
const start = segment / 3 * trailFactor;
|
|
640
|
+
const end = (segment + 1) / 3 * trailFactor;
|
|
641
|
+
ctx.globalAlpha = pool[base + 4] * fade * visibility[i] * Math.pow(1 - segment / 3, 2);
|
|
642
|
+
ctx.lineWidth = (0.6 + depth * 1.1) * (1 - segment * 0.2) * settings.dropThickness;
|
|
643
|
+
ctx.beginPath();
|
|
644
|
+
ctx.moveTo(x - vx * start, y - vy * start);
|
|
645
|
+
ctx.lineTo(x - vx * end, y - vy * end);
|
|
646
|
+
ctx.stroke();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (settings.splashes) {
|
|
651
|
+
ctx.fillStyle = settings.color;
|
|
652
|
+
const thickness = Math.max(0.4, Math.min(3, settings.dropThickness));
|
|
653
|
+
for (let s = 0; s < maxSplashes; s++) {
|
|
654
|
+
const sBase = s * SPLASH_STRIDE;
|
|
655
|
+
const progress = splashPool[sBase + 2];
|
|
656
|
+
if (progress >= 1 || splashPool[sBase + 5] === 0) continue;
|
|
657
|
+
const x = splashPool[sBase];
|
|
658
|
+
const y = state.height;
|
|
659
|
+
const size = splashPool[sBase + 4];
|
|
660
|
+
const alpha = splashPool[sBase + 5];
|
|
661
|
+
const elapsed = progress / splashPool[sBase + 3];
|
|
662
|
+
const drift = splashPool[sBase + 1] * elapsed;
|
|
663
|
+
ctx.globalAlpha = alpha * 0.45 * Math.pow(1 - progress, 1.5);
|
|
664
|
+
ctx.lineWidth = Math.max(0.35, 0.6 * thickness * (1 - progress * 0.4));
|
|
665
|
+
const rx = size * (0.25 + 1.9 * (1 - Math.pow(1 - progress, 3)));
|
|
666
|
+
const ry = Math.max(0.25, rx * 0.19);
|
|
667
|
+
ctx.beginPath();
|
|
668
|
+
ctx.ellipse(x, y, rx, ry, 0, Math.PI, Math.PI * 2);
|
|
669
|
+
ctx.stroke();
|
|
670
|
+
for (let bead = 0; bead < 5; bead++) {
|
|
671
|
+
const variation = (splashPool[sBase + 6] + bead * 0.37) % 1;
|
|
672
|
+
const beadProgress = progress / (0.72 + variation * 0.28);
|
|
673
|
+
if (beadProgress >= 1) continue;
|
|
674
|
+
const direction = (bead - 2) * 0.5;
|
|
675
|
+
const peak = size * (0.9 + (1 - Math.abs(direction)) * 1.1 + variation * 0.4);
|
|
676
|
+
const beadX = x + drift + direction * size * (1.5 + variation * 0.7) * beadProgress;
|
|
677
|
+
const beadY = y - peak * 4 * beadProgress * (1 - beadProgress);
|
|
678
|
+
const radius = Math.max(0.3, (0.4 + thickness * 0.35) * (1 - beadProgress * 0.45));
|
|
679
|
+
ctx.globalAlpha = alpha * Math.pow(1 - beadProgress, 1.25);
|
|
680
|
+
ctx.beginPath();
|
|
681
|
+
ctx.arc(beadX, beadY, radius, 0, Math.PI * 2);
|
|
682
|
+
ctx.fill();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
ctx.globalAlpha = 1;
|
|
687
|
+
}
|
|
688
|
+
function Rain({
|
|
689
|
+
intensity = 0.7,
|
|
690
|
+
transitionDuration = 0,
|
|
691
|
+
angle = 10,
|
|
692
|
+
color = "rgba(190, 215, 245, 0.8)",
|
|
693
|
+
speed = 1,
|
|
694
|
+
dropLength,
|
|
695
|
+
length,
|
|
696
|
+
dropThickness,
|
|
697
|
+
thickness,
|
|
698
|
+
splashes,
|
|
699
|
+
splash
|
|
700
|
+
}) {
|
|
701
|
+
const ticker = useContext(TickerCtx);
|
|
702
|
+
if (!ticker) {
|
|
703
|
+
throw new Error("<Rain /> must be used inside <WeatherCanvas>");
|
|
704
|
+
}
|
|
705
|
+
const effectiveDropLength = dropLength ?? length ?? 1;
|
|
706
|
+
const effectiveDropThickness = dropThickness ?? thickness ?? 1;
|
|
707
|
+
const effectiveSplashes = splashes ?? splash ?? false;
|
|
708
|
+
const settingsRef = useRef({
|
|
709
|
+
intensity,
|
|
710
|
+
transitionDuration,
|
|
711
|
+
angle,
|
|
712
|
+
color,
|
|
713
|
+
speed,
|
|
714
|
+
dropLength: effectiveDropLength,
|
|
715
|
+
dropThickness: effectiveDropThickness,
|
|
716
|
+
splashes: effectiveSplashes
|
|
717
|
+
});
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
settingsRef.current = {
|
|
720
|
+
intensity,
|
|
721
|
+
transitionDuration,
|
|
722
|
+
angle,
|
|
723
|
+
color,
|
|
724
|
+
speed,
|
|
725
|
+
dropLength: effectiveDropLength,
|
|
726
|
+
dropThickness: effectiveDropThickness,
|
|
727
|
+
splashes: effectiveSplashes
|
|
728
|
+
};
|
|
729
|
+
ticker.invalidate();
|
|
730
|
+
}, [
|
|
731
|
+
ticker,
|
|
732
|
+
intensity,
|
|
733
|
+
transitionDuration,
|
|
734
|
+
angle,
|
|
735
|
+
color,
|
|
736
|
+
speed,
|
|
737
|
+
effectiveDropLength,
|
|
738
|
+
effectiveDropThickness,
|
|
739
|
+
effectiveSplashes
|
|
740
|
+
]);
|
|
741
|
+
useEffect(() => {
|
|
742
|
+
const capacity = ticker.getBudget().maxParticles;
|
|
743
|
+
let pool = createParticlePool(capacity);
|
|
744
|
+
const particles = createParticleTransition(
|
|
745
|
+
Math.round(Math.max(0, Math.min(1, settingsRef.current.intensity)) * capacity),
|
|
746
|
+
capacity,
|
|
747
|
+
settingsRef.current.transitionDuration
|
|
748
|
+
);
|
|
749
|
+
let initializedCount = 0;
|
|
750
|
+
const maxSplashes = Math.min(128, Math.max(32, Math.round(capacity * 0.5)));
|
|
751
|
+
const splashPool = new Float32Array(maxSplashes * SPLASH_STRIDE);
|
|
752
|
+
const splashState = { head: 0, active: false };
|
|
753
|
+
const unregister = ticker.register(LAYER.MIDGROUND, (state) => {
|
|
754
|
+
const settings = settingsRef.current;
|
|
755
|
+
const targetCount = Math.round(
|
|
756
|
+
Math.max(0, Math.min(1, settings.intensity)) * state.budget.maxParticles
|
|
757
|
+
);
|
|
758
|
+
updateParticleTransition(particles, targetCount, settings.transitionDuration, state);
|
|
759
|
+
const count = particles.count;
|
|
760
|
+
pool = growParticlePool(pool, state.budget.maxParticles);
|
|
761
|
+
if (count > initializedCount) {
|
|
762
|
+
seedParticleRange(pool, initializedCount, count, state, settings);
|
|
763
|
+
initializedCount = count;
|
|
764
|
+
}
|
|
765
|
+
if (state.dt > 0) {
|
|
766
|
+
updatePool(
|
|
767
|
+
pool,
|
|
768
|
+
count,
|
|
769
|
+
state,
|
|
770
|
+
settings,
|
|
771
|
+
particles.visibility,
|
|
772
|
+
splashPool,
|
|
773
|
+
maxSplashes,
|
|
774
|
+
splashState
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
drawPool(pool, count, state, settings, particles.visibility, splashPool, maxSplashes);
|
|
778
|
+
});
|
|
779
|
+
return unregister;
|
|
780
|
+
}, [ticker]);
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
function getSnowSpriteSize(settings, dpr) {
|
|
784
|
+
const diameter = Math.max(0, settings.minSize, settings.maxSize) * 2 * dpr;
|
|
785
|
+
return Math.min(1024, Math.max(64, 2 ** Math.ceil(Math.log2(diameter || 1))));
|
|
786
|
+
}
|
|
787
|
+
function createSnowSprite(image, size) {
|
|
788
|
+
if (typeof document === "undefined") return null;
|
|
789
|
+
try {
|
|
790
|
+
const aspect = image.naturalWidth / image.naturalHeight;
|
|
791
|
+
const texture = document.createElement("canvas");
|
|
792
|
+
texture.width = Math.max(1, Math.ceil(size * Math.min(1, aspect)));
|
|
793
|
+
texture.height = Math.max(1, Math.ceil(size / Math.max(1, aspect)));
|
|
794
|
+
const ctx = texture.getContext("2d");
|
|
795
|
+
if (!ctx) return null;
|
|
796
|
+
ctx.drawImage(image, 0, 0, texture.width, texture.height);
|
|
797
|
+
return { image, texture, aspect, requestedSize: size };
|
|
798
|
+
} catch {
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function createSnowTexture(color) {
|
|
803
|
+
if (typeof document === "undefined") return null;
|
|
804
|
+
const surface = document.createElement("canvas");
|
|
805
|
+
surface.width = surface.height = 64;
|
|
806
|
+
const ctx = surface.getContext("2d");
|
|
807
|
+
if (!ctx) return null;
|
|
808
|
+
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
|
809
|
+
gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
|
|
810
|
+
gradient.addColorStop(0.25, "rgba(255, 255, 255, 0.95)");
|
|
811
|
+
gradient.addColorStop(0.5, "rgba(255, 255, 255, 0.55)");
|
|
812
|
+
gradient.addColorStop(0.75, "rgba(255, 255, 255, 0.15)");
|
|
813
|
+
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
|
|
814
|
+
ctx.fillStyle = gradient;
|
|
815
|
+
ctx.fillRect(0, 0, 64, 64);
|
|
816
|
+
ctx.globalCompositeOperation = "source-in";
|
|
817
|
+
ctx.fillStyle = color;
|
|
818
|
+
ctx.fillRect(0, 0, 64, 64);
|
|
819
|
+
return surface;
|
|
820
|
+
}
|
|
821
|
+
function seedSnowflake(pool, i, state, isInitial) {
|
|
822
|
+
const base = i * PARTICLE_STRIDE;
|
|
823
|
+
const depth = Math.pow(state.random(), 1.5);
|
|
824
|
+
const fallSpeed = 22 + depth * 54 + state.random() * 12;
|
|
825
|
+
let x;
|
|
826
|
+
let y;
|
|
827
|
+
if (isInitial) {
|
|
828
|
+
x = state.random() * (state.width + 100) - 50;
|
|
829
|
+
y = state.random() * (state.height + 50) - 25;
|
|
830
|
+
} else {
|
|
831
|
+
const windX = state.wind.x;
|
|
832
|
+
const vFlux = Math.max(20, fallSpeed) * state.width;
|
|
833
|
+
const hFlux = Math.abs(windX * 90) * state.height;
|
|
834
|
+
const sideRatio = hFlux / (vFlux + hFlux);
|
|
835
|
+
if (state.random() < sideRatio) {
|
|
836
|
+
x = windX > 0 ? -20 - state.random() * 30 : state.width + 20 + state.random() * 30;
|
|
837
|
+
y = state.random() * (state.height + 50) - 25;
|
|
838
|
+
} else {
|
|
839
|
+
x = state.random() * (state.width + 100) - 50;
|
|
840
|
+
y = -20 - state.random() * 50;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const phase = state.random() * Math.PI * 2;
|
|
844
|
+
pool[base] = x;
|
|
845
|
+
pool[base + 1] = y;
|
|
846
|
+
pool[base + 2] = phase;
|
|
847
|
+
pool[base + 3] = fallSpeed;
|
|
848
|
+
pool[base + 4] = 0.3 + depth * 0.6;
|
|
849
|
+
pool[base + 5] = phase;
|
|
850
|
+
pool[base + 6] = depth;
|
|
851
|
+
}
|
|
852
|
+
function seedSnowRange(pool, startIndex, endIndex, state) {
|
|
853
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
854
|
+
seedSnowflake(pool, i, state, true);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function updateSnow(pool, count, state, settings) {
|
|
858
|
+
const dt = state.dt;
|
|
859
|
+
const width = state.width;
|
|
860
|
+
const height = state.height;
|
|
861
|
+
const wrapWidth = width + 100;
|
|
862
|
+
const gust = 1 + Math.sin(state.timestamp * 4e-4) * 0.2;
|
|
863
|
+
for (let i = 0; i < count; i++) {
|
|
864
|
+
const base = i * PARTICLE_STRIDE;
|
|
865
|
+
let x = pool[base];
|
|
866
|
+
let y = pool[base + 1];
|
|
867
|
+
let angle = pool[base + 2];
|
|
868
|
+
let phase = pool[base + 5];
|
|
869
|
+
const baseVy = pool[base + 3];
|
|
870
|
+
const depth = pool[base + 6];
|
|
871
|
+
if (settings.wobble) {
|
|
872
|
+
phase += dt * (0.65 + depth * 1.4) * settings.speed;
|
|
873
|
+
pool[base + 5] = phase;
|
|
874
|
+
}
|
|
875
|
+
if (settings.rotation && settings.rotationSpeed !== 0) {
|
|
876
|
+
const spinDir = i % 2 === 0 ? 1 : -1;
|
|
877
|
+
const spinRate = (0.65 + depth * 1.4) * spinDir;
|
|
878
|
+
angle += dt * spinRate * settings.rotationSpeed;
|
|
879
|
+
}
|
|
880
|
+
const sway = settings.wobble ? (Math.sin(phase) * (10 + depth * 22) + Math.sin(phase * 0.5) * 8) * settings.speed : 0;
|
|
881
|
+
const vx = sway + state.wind.x * (50 + depth * 80) * gust;
|
|
882
|
+
const flutter = settings.wobble ? 1 + Math.sin(phase * 0.8) * 0.12 : 1;
|
|
883
|
+
const vy = baseVy * settings.speed * flutter;
|
|
884
|
+
x += vx * dt;
|
|
885
|
+
y += (vy + state.wind.y * 30) * dt;
|
|
886
|
+
if (x > width + 50 || x < -50) {
|
|
887
|
+
x = ((x + 50) % wrapWidth + wrapWidth) % wrapWidth - 50;
|
|
888
|
+
}
|
|
889
|
+
if (y > height + 30) {
|
|
890
|
+
seedSnowflake(pool, i, state, false);
|
|
891
|
+
} else {
|
|
892
|
+
pool[base] = x;
|
|
893
|
+
pool[base + 1] = y;
|
|
894
|
+
pool[base + 2] = angle;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
function drawSnow(pool, count, state, settings, texture, sprite, visibility) {
|
|
899
|
+
if (count === 0) return;
|
|
900
|
+
const ctx = state.ctx;
|
|
901
|
+
const aspect = sprite ? sprite.aspect : 1;
|
|
902
|
+
ctx.fillStyle = settings.color;
|
|
903
|
+
for (let i = 0; i < count; i++) {
|
|
904
|
+
if (visibility[i] === 0) continue;
|
|
905
|
+
const base = i * PARTICLE_STRIDE;
|
|
906
|
+
const x = pool[base];
|
|
907
|
+
const y = pool[base + 1];
|
|
908
|
+
const depth = pool[base + 6];
|
|
909
|
+
const r = Math.max(0, settings.minSize + depth * (settings.maxSize - settings.minSize));
|
|
910
|
+
if (r === 0) continue;
|
|
911
|
+
const fade = Math.max(0, Math.min(1, (y + 20) / 45, (state.height + 20 - y) / 55));
|
|
912
|
+
ctx.globalAlpha = pool[base + 4] * fade * visibility[i];
|
|
913
|
+
if (sprite) {
|
|
914
|
+
const halfWidth = r * Math.min(1, aspect);
|
|
915
|
+
const halfHeight = r / Math.max(1, aspect);
|
|
916
|
+
ctx.save();
|
|
917
|
+
try {
|
|
918
|
+
ctx.translate(x, y);
|
|
919
|
+
if (settings.rotation) ctx.rotate(pool[base + 2]);
|
|
920
|
+
ctx.drawImage(sprite.texture, -halfWidth, -halfHeight, halfWidth * 2, halfHeight * 2);
|
|
921
|
+
} finally {
|
|
922
|
+
ctx.restore();
|
|
923
|
+
}
|
|
924
|
+
} else if (texture) {
|
|
925
|
+
const radius = r * 1.55;
|
|
926
|
+
ctx.drawImage(texture, x - radius, y - radius, radius * 2, radius * 2);
|
|
927
|
+
} else {
|
|
928
|
+
ctx.beginPath();
|
|
929
|
+
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
930
|
+
ctx.fill();
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
ctx.globalAlpha = 1;
|
|
934
|
+
}
|
|
935
|
+
function Snow({
|
|
936
|
+
intensity = 0.5,
|
|
937
|
+
transitionDuration = 0,
|
|
938
|
+
speed = 1,
|
|
939
|
+
color = "rgba(255, 255, 255, 0.85)",
|
|
940
|
+
sprite,
|
|
941
|
+
minSize = sprite ? 6 : 1.5,
|
|
942
|
+
maxSize = sprite ? 18 : 4,
|
|
943
|
+
wobble = true,
|
|
944
|
+
rotation,
|
|
945
|
+
rotationSpeed
|
|
946
|
+
}) {
|
|
947
|
+
const ticker = useContext(TickerCtx);
|
|
948
|
+
if (!ticker) {
|
|
949
|
+
throw new Error("<Snow /> must be used inside <WeatherCanvas>");
|
|
950
|
+
}
|
|
951
|
+
const effectiveRotation = rotation ?? (rotationSpeed !== void 0 ? true : wobble);
|
|
952
|
+
const effectiveRotationSpeed = rotationSpeed ?? 1;
|
|
953
|
+
const settingsRef = useRef({
|
|
954
|
+
intensity,
|
|
955
|
+
transitionDuration,
|
|
956
|
+
speed,
|
|
957
|
+
color,
|
|
958
|
+
minSize,
|
|
959
|
+
maxSize,
|
|
960
|
+
wobble,
|
|
961
|
+
rotation: effectiveRotation,
|
|
962
|
+
rotationSpeed: effectiveRotationSpeed
|
|
963
|
+
});
|
|
964
|
+
const textureRef = useRef(null);
|
|
965
|
+
const spriteRef = useRef(null);
|
|
966
|
+
useEffect(() => {
|
|
967
|
+
settingsRef.current = {
|
|
968
|
+
intensity,
|
|
969
|
+
transitionDuration,
|
|
970
|
+
speed,
|
|
971
|
+
color,
|
|
972
|
+
minSize,
|
|
973
|
+
maxSize,
|
|
974
|
+
wobble,
|
|
975
|
+
rotation: effectiveRotation,
|
|
976
|
+
rotationSpeed: effectiveRotationSpeed
|
|
977
|
+
};
|
|
978
|
+
ticker.invalidate();
|
|
979
|
+
}, [
|
|
980
|
+
ticker,
|
|
981
|
+
intensity,
|
|
982
|
+
transitionDuration,
|
|
983
|
+
speed,
|
|
984
|
+
color,
|
|
985
|
+
minSize,
|
|
986
|
+
maxSize,
|
|
987
|
+
wobble,
|
|
988
|
+
effectiveRotation,
|
|
989
|
+
effectiveRotationSpeed
|
|
990
|
+
]);
|
|
991
|
+
useEffect(() => {
|
|
992
|
+
textureRef.current = createSnowTexture(color);
|
|
993
|
+
ticker.invalidate();
|
|
994
|
+
return () => {
|
|
995
|
+
textureRef.current = null;
|
|
996
|
+
};
|
|
997
|
+
}, [ticker, color]);
|
|
998
|
+
useEffect(() => {
|
|
999
|
+
if (!sprite || typeof Image === "undefined") return;
|
|
1000
|
+
let isMounted = true;
|
|
1001
|
+
let settle;
|
|
1002
|
+
const image = new Image();
|
|
1003
|
+
image.crossOrigin = "anonymous";
|
|
1004
|
+
const task = new Promise((resolve) => {
|
|
1005
|
+
settle = resolve;
|
|
1006
|
+
image.onload = () => {
|
|
1007
|
+
if (isMounted && image.naturalWidth > 0 && image.naturalHeight > 0) {
|
|
1008
|
+
spriteRef.current = createSnowSprite(
|
|
1009
|
+
image,
|
|
1010
|
+
getSnowSpriteSize(settingsRef.current, ticker.getBudget().dpr)
|
|
1011
|
+
);
|
|
1012
|
+
ticker.invalidate();
|
|
1013
|
+
}
|
|
1014
|
+
resolve();
|
|
1015
|
+
};
|
|
1016
|
+
image.onerror = () => {
|
|
1017
|
+
resolve();
|
|
1018
|
+
};
|
|
1019
|
+
});
|
|
1020
|
+
const releaseTask = ticker.trackAsset(task);
|
|
1021
|
+
image.src = sprite;
|
|
1022
|
+
return () => {
|
|
1023
|
+
isMounted = false;
|
|
1024
|
+
image.onload = null;
|
|
1025
|
+
image.onerror = null;
|
|
1026
|
+
image.removeAttribute("src");
|
|
1027
|
+
spriteRef.current = null;
|
|
1028
|
+
settle();
|
|
1029
|
+
releaseTask();
|
|
1030
|
+
};
|
|
1031
|
+
}, [ticker, sprite]);
|
|
1032
|
+
useEffect(() => {
|
|
1033
|
+
const capacity = ticker.getBudget().maxParticles;
|
|
1034
|
+
let pool = createParticlePool(capacity);
|
|
1035
|
+
const particles = createParticleTransition(
|
|
1036
|
+
Math.round(Math.max(0, Math.min(1, settingsRef.current.intensity)) * capacity),
|
|
1037
|
+
capacity,
|
|
1038
|
+
settingsRef.current.transitionDuration
|
|
1039
|
+
);
|
|
1040
|
+
let initializedCount = 0;
|
|
1041
|
+
const unregister = ticker.register(LAYER.MIDGROUND, (state) => {
|
|
1042
|
+
const settings = settingsRef.current;
|
|
1043
|
+
const targetCount = Math.round(
|
|
1044
|
+
Math.max(0, Math.min(1, settings.intensity)) * state.budget.maxParticles
|
|
1045
|
+
);
|
|
1046
|
+
updateParticleTransition(particles, targetCount, settings.transitionDuration, state);
|
|
1047
|
+
const count = particles.count;
|
|
1048
|
+
pool = growParticlePool(pool, state.budget.maxParticles);
|
|
1049
|
+
if (count > initializedCount) {
|
|
1050
|
+
seedSnowRange(pool, initializedCount, count, state);
|
|
1051
|
+
initializedCount = count;
|
|
1052
|
+
}
|
|
1053
|
+
if (state.dt > 0) {
|
|
1054
|
+
updateSnow(pool, count, state, settings);
|
|
1055
|
+
}
|
|
1056
|
+
const cachedSprite = spriteRef.current;
|
|
1057
|
+
if (cachedSprite && count > 0) {
|
|
1058
|
+
const size = getSnowSpriteSize(settings, state.dpr);
|
|
1059
|
+
if (size > cachedSprite.requestedSize) {
|
|
1060
|
+
cachedSprite.requestedSize = size;
|
|
1061
|
+
spriteRef.current = createSnowSprite(cachedSprite.image, size) ?? cachedSprite;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
drawSnow(pool, count, state, settings, textureRef.current, spriteRef.current, particles.visibility);
|
|
1065
|
+
});
|
|
1066
|
+
return unregister;
|
|
1067
|
+
}, [ticker]);
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
function createOffscreenSurface(size) {
|
|
1071
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
1072
|
+
return new OffscreenCanvas(size, size);
|
|
1073
|
+
}
|
|
1074
|
+
if (typeof document !== "undefined") {
|
|
1075
|
+
const canvas = document.createElement("canvas");
|
|
1076
|
+
canvas.width = size;
|
|
1077
|
+
canvas.height = size;
|
|
1078
|
+
return canvas;
|
|
1079
|
+
}
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
function renderRadialGradient(surface, size, color) {
|
|
1083
|
+
const ctx = surface.getContext("2d");
|
|
1084
|
+
if (!ctx) return;
|
|
1085
|
+
ctx.clearRect(0, 0, size, size);
|
|
1086
|
+
for (let i = 0; i < 6; i++) {
|
|
1087
|
+
const x = size * (0.22 + i * 0.112);
|
|
1088
|
+
const y = size * (0.48 + Math.sin(i * 2.4) * 0.09);
|
|
1089
|
+
const radius = size * (0.18 + Math.sin(i * 1.7) * 0.03);
|
|
1090
|
+
const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
|
1091
|
+
grad.addColorStop(0, "rgba(255, 255, 255, 0.85)");
|
|
1092
|
+
grad.addColorStop(0.4, "rgba(255, 255, 255, 0.5)");
|
|
1093
|
+
grad.addColorStop(0.75, "rgba(255, 255, 255, 0.1)");
|
|
1094
|
+
grad.addColorStop(1, "rgba(255, 255, 255, 0)");
|
|
1095
|
+
ctx.fillStyle = grad;
|
|
1096
|
+
ctx.beginPath();
|
|
1097
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
1098
|
+
ctx.fill();
|
|
1099
|
+
}
|
|
1100
|
+
ctx.globalCompositeOperation = "source-in";
|
|
1101
|
+
ctx.fillStyle = color;
|
|
1102
|
+
ctx.fillRect(0, 0, size, size);
|
|
1103
|
+
}
|
|
1104
|
+
function Fog({
|
|
1105
|
+
density = 0.4,
|
|
1106
|
+
transitionDuration = 0,
|
|
1107
|
+
speed = 1,
|
|
1108
|
+
color = "rgba(220, 225, 235, 0.35)",
|
|
1109
|
+
layers = 3
|
|
1110
|
+
}) {
|
|
1111
|
+
const ticker = useContext(TickerCtx);
|
|
1112
|
+
if (!ticker) {
|
|
1113
|
+
throw new Error("<Fog /> must be used inside <WeatherCanvas>");
|
|
1114
|
+
}
|
|
1115
|
+
const settingsRef = useRef({ density, transitionDuration, speed, color, layers });
|
|
1116
|
+
useEffect(() => {
|
|
1117
|
+
settingsRef.current = { density, transitionDuration, speed, color, layers };
|
|
1118
|
+
ticker.invalidate();
|
|
1119
|
+
}, [ticker, density, transitionDuration, speed, color, layers]);
|
|
1120
|
+
const textureRef = useRef(null);
|
|
1121
|
+
const imageBitmapRef = useRef(null);
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
let isMounted = true;
|
|
1124
|
+
const budget = ticker.getBudget();
|
|
1125
|
+
const textureSize = budget.quality === "low" ? 256 : budget.quality === "high" ? 512 : 384;
|
|
1126
|
+
const surface = createOffscreenSurface(textureSize);
|
|
1127
|
+
if (!surface) return;
|
|
1128
|
+
renderRadialGradient(surface, textureSize, color);
|
|
1129
|
+
textureRef.current = surface;
|
|
1130
|
+
ticker.invalidate();
|
|
1131
|
+
if (typeof createImageBitmap === "function") {
|
|
1132
|
+
const assetTask = (async () => {
|
|
1133
|
+
try {
|
|
1134
|
+
const bitmap = await createImageBitmap(surface);
|
|
1135
|
+
if (!isMounted) {
|
|
1136
|
+
bitmap.close();
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (imageBitmapRef.current) {
|
|
1140
|
+
imageBitmapRef.current.close();
|
|
1141
|
+
}
|
|
1142
|
+
imageBitmapRef.current = bitmap;
|
|
1143
|
+
textureRef.current = bitmap;
|
|
1144
|
+
ticker.invalidate();
|
|
1145
|
+
} catch {
|
|
1146
|
+
}
|
|
1147
|
+
})();
|
|
1148
|
+
const releaseTask = ticker.trackAsset(assetTask);
|
|
1149
|
+
return () => {
|
|
1150
|
+
isMounted = false;
|
|
1151
|
+
releaseTask();
|
|
1152
|
+
if (imageBitmapRef.current) {
|
|
1153
|
+
imageBitmapRef.current.close();
|
|
1154
|
+
imageBitmapRef.current = null;
|
|
1155
|
+
}
|
|
1156
|
+
textureRef.current = null;
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
return () => {
|
|
1160
|
+
isMounted = false;
|
|
1161
|
+
textureRef.current = null;
|
|
1162
|
+
};
|
|
1163
|
+
}, [ticker, color]);
|
|
1164
|
+
useEffect(() => {
|
|
1165
|
+
const puffs = [];
|
|
1166
|
+
let currentDensity = Math.max(0, Math.min(1, settingsRef.current.density));
|
|
1167
|
+
const transition = createTransition(currentDensity, settingsRef.current.transitionDuration);
|
|
1168
|
+
const unregister = ticker.register(LAYER.BACKGROUND, (state) => {
|
|
1169
|
+
const texture = textureRef.current;
|
|
1170
|
+
if (!texture) return;
|
|
1171
|
+
const settings = settingsRef.current;
|
|
1172
|
+
const numLayers = Math.round(Math.max(1, Math.min(6, settings.layers)));
|
|
1173
|
+
const count = numLayers * 3;
|
|
1174
|
+
const targetDensity = Math.max(0, Math.min(1, settings.density));
|
|
1175
|
+
const step = stepTransition(transition, targetDensity, settings.transitionDuration, state);
|
|
1176
|
+
currentDensity = step === 1 ? targetDensity : currentDensity + (targetDensity - currentDensity) * step;
|
|
1177
|
+
const baseAlpha = currentDensity;
|
|
1178
|
+
if (baseAlpha === 0) return;
|
|
1179
|
+
for (let i = puffs.length; i < count; i++) {
|
|
1180
|
+
const layer = Math.floor(i / 3);
|
|
1181
|
+
puffs.push({
|
|
1182
|
+
x: i % 3 * 0.55 - 0.05 + (state.random() - 0.5) * 0.25,
|
|
1183
|
+
y: 0.15 + layer / numLayers * 0.72 + (state.random() - 0.5) * 0.12,
|
|
1184
|
+
scaleX: 1 + state.random() * 0.7,
|
|
1185
|
+
scaleY: 0.65 + state.random() * 0.55,
|
|
1186
|
+
baseSpeed: 8 + (layer + 1) * 7,
|
|
1187
|
+
alpha: 0.5 + state.random() * 0.4,
|
|
1188
|
+
phase: state.random() * Math.PI * 2
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
const dt = state.dt;
|
|
1192
|
+
if (dt > 0) {
|
|
1193
|
+
for (let i = 0; i < count; i++) {
|
|
1194
|
+
const puff = puffs[i];
|
|
1195
|
+
const vx = puff.baseSpeed * settings.speed + state.wind.x * 35;
|
|
1196
|
+
puff.x += vx * dt / state.width;
|
|
1197
|
+
puff.phase += dt * 0.12 * settings.speed;
|
|
1198
|
+
const margin = Math.max(320 / state.width, 0.65) * puff.scaleX * 0.55;
|
|
1199
|
+
const totalWidth = 1 + margin * 2;
|
|
1200
|
+
if (puff.x > 1 + margin || puff.x < -margin) {
|
|
1201
|
+
puff.x = ((puff.x + margin) % totalWidth + totalWidth) % totalWidth - margin;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const ctx = state.ctx;
|
|
1206
|
+
for (let i = 0; i < count; i++) {
|
|
1207
|
+
const puff = puffs[i];
|
|
1208
|
+
const drawW = Math.max(320, state.width * 0.65) * puff.scaleX * (1 + Math.sin(puff.phase) * 0.03);
|
|
1209
|
+
const drawH = Math.max(160, state.height * 0.55) * puff.scaleY;
|
|
1210
|
+
const y = puff.y * state.height + Math.sin(puff.phase) * drawH * 0.08;
|
|
1211
|
+
ctx.globalAlpha = baseAlpha * puff.alpha * (0.9 + Math.sin(puff.phase * 0.7) * 0.1);
|
|
1212
|
+
ctx.drawImage(texture, puff.x * state.width - drawW / 2, y - drawH / 2, drawW, drawH);
|
|
1213
|
+
}
|
|
1214
|
+
ctx.globalAlpha = 1;
|
|
1215
|
+
});
|
|
1216
|
+
return unregister;
|
|
1217
|
+
}, [ticker]);
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
export { Fog, Rain, Snow, WeatherCanvas };
|
|
1222
|
+
//# sourceMappingURL=index.mjs.map
|
|
1223
|
+
//# sourceMappingURL=index.mjs.map
|