snowstack 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 +169 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +907 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Carl Kendrick Camus
|
|
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
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# βοΈ SnowStack
|
|
2
|
+
|
|
3
|
+
**SnowStack** is a lightweight, interactive snow effect for React/Next.js that combines:
|
|
4
|
+
|
|
5
|
+
- Smooth snowfall (React-Snowfallβstyle calm mode)
|
|
6
|
+
- Snow accumulation at the bottom of the screen
|
|
7
|
+
- Optional **interactive shovel** (scoop + throw with physics)
|
|
8
|
+
- Mobile-safe interaction (no scroll hijacking)
|
|
9
|
+
- Performance-bounded presets to protect production sites
|
|
10
|
+
|
|
11
|
+
Built with **Canvas + Matter.js**, designed to be fun without being destructive.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## β¨ Features
|
|
16
|
+
|
|
17
|
+
- π¨ **Snowfall Engine** β density, speed, size, wind
|
|
18
|
+
- βοΈ **Accumulation** β piles up naturally at the bottom
|
|
19
|
+
- π₯ **Interactive Shovel (optional)** β scoop and throw chunks with physics
|
|
20
|
+
- π± **Mobile-safe controls** β shovel ON/OFF toggle + pointer capture
|
|
21
|
+
- π§ **Presets-first** β safe defaults, bounded ranges
|
|
22
|
+
- β‘ **Performance guarded** β capped FPS and chunk limits
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## π¦ Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install snowstack
|
|
30
|
+
# or
|
|
31
|
+
pnpm add snowstack
|
|
32
|
+
# or
|
|
33
|
+
yarn add snowstack
|
|
34
|
+
````
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## π Quick Start
|
|
39
|
+
|
|
40
|
+
> β οΈ SnowStack is a client-side effect and must be used inside `"use client"` components.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { SnowStack } from "snowstack";
|
|
45
|
+
|
|
46
|
+
export default function Page() {
|
|
47
|
+
return <SnowStack />;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Default behavior is **calm, non-interactive snowfall**.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## π Presets
|
|
56
|
+
|
|
57
|
+
Available presets:
|
|
58
|
+
|
|
59
|
+
* `calm` (default) β subtle snowfall
|
|
60
|
+
* `cozy` β a bit denser
|
|
61
|
+
* `storm` β heavy snow + stronger wind (still clamped for safety)
|
|
62
|
+
* `interactive` β enables shovel + physics chunks
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
<SnowStack preset="cozy" />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## π₯ Interactive Mode (Shovel)
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
<SnowStack preset="interactive" interactive />
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Desktop
|
|
77
|
+
|
|
78
|
+
* Hold **Shift**
|
|
79
|
+
* Drag near the snow pile to scoop
|
|
80
|
+
* Release to throw
|
|
81
|
+
|
|
82
|
+
### Mobile
|
|
83
|
+
|
|
84
|
+
* Tap **Shovel: ON**
|
|
85
|
+
* Drag to scoop
|
|
86
|
+
* Release to throw
|
|
87
|
+
* Tap **Shovel: OFF** to disable
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Safe Customization (Limited by Design)
|
|
92
|
+
|
|
93
|
+
SnowStack exposes only safe knobs:
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
<SnowStack
|
|
97
|
+
preset="calm"
|
|
98
|
+
intensity={0.4} // 0..1 (density)
|
|
99
|
+
speed={0.3} // 0..1 (fall speed)
|
|
100
|
+
wind={0.2} // 0..1 (wind influence)
|
|
101
|
+
interactive={false}
|
|
102
|
+
/>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
All internal values are **clamped** to prevent runaway CPU usage.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## π· Demo
|
|
110
|
+
|
|
111
|
+

|
|
112
|
+
|
|
113
|
+
- This demo used a preset "storm"
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## π§ Tech
|
|
117
|
+
|
|
118
|
+
* React / Next.js (`"use client"`)
|
|
119
|
+
* Canvas rendering
|
|
120
|
+
* Matter.js (snow chunks only)
|
|
121
|
+
* requestAnimationFrame loops
|
|
122
|
+
* Minimal DOM (mostly canvas)
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## β‘ Performance Notes
|
|
127
|
+
|
|
128
|
+
SnowStack automatically:
|
|
129
|
+
|
|
130
|
+
* caps snowflake counts
|
|
131
|
+
* limits physics bodies
|
|
132
|
+
* removes off-screen chunks
|
|
133
|
+
* keeps FPS bounded
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## π€ Contributing & Forking
|
|
138
|
+
|
|
139
|
+
SnowStack is intentionally designed to be **safe by default**.
|
|
140
|
+
|
|
141
|
+
You are welcome to:
|
|
142
|
+
|
|
143
|
+
- Fork the project
|
|
144
|
+
- Modify visuals (SVGs, colors, shovel appearance)
|
|
145
|
+
- Create new presets
|
|
146
|
+
- Optimize or extend physics behavior
|
|
147
|
+
|
|
148
|
+
### Design Philosophy
|
|
149
|
+
|
|
150
|
+
To keep SnowStack production-friendly:
|
|
151
|
+
|
|
152
|
+
- Public props are **intentionally limited**
|
|
153
|
+
- All internal values are **clamped**
|
|
154
|
+
- Presets are preferred over raw configuration
|
|
155
|
+
- Heavy physics runs **only when interactive mode is enabled**
|
|
156
|
+
|
|
157
|
+
If you add new controls, please:
|
|
158
|
+
- keep reasonable bounds
|
|
159
|
+
- avoid unbounded loops or unguarded physics
|
|
160
|
+
- respect mobile performance constraints
|
|
161
|
+
|
|
162
|
+
Pull requests are welcome if they align with these goals.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
## π License
|
|
169
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
type SnowPresetName = "calm" | "cozy" | "storm" | "interactive";
|
|
4
|
+
type SnowStackProps = {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
preset?: SnowPresetName;
|
|
7
|
+
intensity?: number;
|
|
8
|
+
speed?: number;
|
|
9
|
+
wind?: number;
|
|
10
|
+
interactive?: boolean;
|
|
11
|
+
};
|
|
12
|
+
declare function SnowStack(props: SnowStackProps): react_jsx_runtime.JSX.Element;
|
|
13
|
+
|
|
14
|
+
type MatterConfig = {
|
|
15
|
+
gravityY: number;
|
|
16
|
+
restitution: number;
|
|
17
|
+
friction: number;
|
|
18
|
+
frictionAir: number;
|
|
19
|
+
ttlMs: number;
|
|
20
|
+
allowSideExit: boolean;
|
|
21
|
+
maxBodies: number;
|
|
22
|
+
};
|
|
23
|
+
type MatterAPI = {
|
|
24
|
+
throwChunks: (x: number, y: number, vx: number, vy: number, count: number, rBase: number) => void;
|
|
25
|
+
setConfig: (partial: Partial<MatterConfig>) => void;
|
|
26
|
+
};
|
|
27
|
+
declare function MatterSnowChunks({ zIndex, apiRef, initialConfig, }: {
|
|
28
|
+
zIndex?: number;
|
|
29
|
+
apiRef: React.MutableRefObject<MatterAPI | null>;
|
|
30
|
+
initialConfig?: Partial<MatterConfig>;
|
|
31
|
+
}): react_jsx_runtime.JSX.Element;
|
|
32
|
+
|
|
33
|
+
export { type MatterAPI, type MatterConfig, MatterSnowChunks, SnowStack, type SnowStackProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import { useRef, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
3
|
+
import { Shovel } from 'lucide-react';
|
|
4
|
+
import Matter from 'matter-js';
|
|
5
|
+
|
|
6
|
+
function useRafLoop(fn) {
|
|
7
|
+
const fnRef = useRef(fn);
|
|
8
|
+
fnRef.current = fn;
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
let raf = 0;
|
|
11
|
+
const loop = (t) => {
|
|
12
|
+
raf = requestAnimationFrame(loop);
|
|
13
|
+
fnRef.current(t);
|
|
14
|
+
};
|
|
15
|
+
raf = requestAnimationFrame(loop);
|
|
16
|
+
return () => cancelAnimationFrame(raf);
|
|
17
|
+
}, []);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/physics/physics.ts
|
|
21
|
+
function clamp(n, a, b) {
|
|
22
|
+
return Math.max(a, Math.min(b, n));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/physics/snowHeightfield.ts
|
|
26
|
+
function createHeightfield(cols, colW, w, h, maxDepth) {
|
|
27
|
+
return { cols, colW, w, h, maxDepth, heights: new Float32Array(cols) };
|
|
28
|
+
}
|
|
29
|
+
function depositAtX(model, x, amount) {
|
|
30
|
+
const i = clamp(Math.floor(x / model.colW), 0, model.cols - 1);
|
|
31
|
+
add(model, i, amount * 0.6);
|
|
32
|
+
add(model, i - 1, amount * 0.22);
|
|
33
|
+
add(model, i + 1, amount * 0.22);
|
|
34
|
+
}
|
|
35
|
+
function add(model, idx, amt) {
|
|
36
|
+
if (idx < 0 || idx >= model.cols) return;
|
|
37
|
+
model.heights[idx] = clamp(model.heights[idx] + amt, 0, model.maxDepth);
|
|
38
|
+
}
|
|
39
|
+
function smoothHeights(model, k) {
|
|
40
|
+
const h = model.heights;
|
|
41
|
+
const tmp = new Float32Array(h.length);
|
|
42
|
+
for (let i = 0; i < h.length; i++) {
|
|
43
|
+
const a = h[i - 1] ?? h[i];
|
|
44
|
+
const b = h[i];
|
|
45
|
+
const c = h[i + 1] ?? h[i];
|
|
46
|
+
const avg = (a + b + c) / 3;
|
|
47
|
+
tmp[i] = b + (avg - b) * k;
|
|
48
|
+
}
|
|
49
|
+
model.heights.set(tmp);
|
|
50
|
+
}
|
|
51
|
+
function avgHeight(model) {
|
|
52
|
+
const h = model.heights;
|
|
53
|
+
let sum = 0;
|
|
54
|
+
for (let i = 0; i < h.length; i++) sum += h[i];
|
|
55
|
+
return sum / h.length;
|
|
56
|
+
}
|
|
57
|
+
function driftGround(model, windX) {
|
|
58
|
+
const h = model.heights;
|
|
59
|
+
const tmp = new Float32Array(h.length);
|
|
60
|
+
tmp.set(h);
|
|
61
|
+
const k = Math.min(0.06, Math.abs(windX) * 0.02);
|
|
62
|
+
if (k <= 0) return;
|
|
63
|
+
if (windX > 0) {
|
|
64
|
+
for (let i = h.length - 2; i >= 0; i--) {
|
|
65
|
+
const move = tmp[i] * k;
|
|
66
|
+
tmp[i] -= move;
|
|
67
|
+
tmp[i + 1] += move;
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
for (let i = 1; i < h.length; i++) {
|
|
71
|
+
const move = tmp[i] * k;
|
|
72
|
+
tmp[i] -= move;
|
|
73
|
+
tmp[i - 1] += move;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (let i = 0; i < tmp.length; i++) tmp[i] = clamp(tmp[i], 0, model.maxDepth);
|
|
77
|
+
model.heights.set(tmp);
|
|
78
|
+
}
|
|
79
|
+
function removeSnowRect(model, x, y, w, h, maxRemove) {
|
|
80
|
+
const leftCol = clamp(Math.floor(x / model.colW), 0, model.cols - 1);
|
|
81
|
+
const rightCol = clamp(Math.floor((x + w) / model.colW), 0, model.cols - 1);
|
|
82
|
+
let removed = 0;
|
|
83
|
+
for (let i = leftCol; i <= rightCol; i++) {
|
|
84
|
+
if (removed >= maxRemove) break;
|
|
85
|
+
const surfaceY = model.h - model.heights[i];
|
|
86
|
+
const rectBottom = y + h;
|
|
87
|
+
if (rectBottom <= surfaceY) continue;
|
|
88
|
+
const penetration = rectBottom - surfaceY;
|
|
89
|
+
const take = clamp(penetration * 0.35, 0, model.heights[i]);
|
|
90
|
+
const remaining = maxRemove - removed;
|
|
91
|
+
const actual = Math.min(take, remaining);
|
|
92
|
+
model.heights[i] = clamp(model.heights[i] - actual, 0, model.maxDepth);
|
|
93
|
+
removed += actual;
|
|
94
|
+
}
|
|
95
|
+
return removed;
|
|
96
|
+
}
|
|
97
|
+
function SnowAccumulationCanvas({
|
|
98
|
+
zIndex = 2,
|
|
99
|
+
enabled,
|
|
100
|
+
windX,
|
|
101
|
+
apiRef,
|
|
102
|
+
onAvgDepth,
|
|
103
|
+
// package controls
|
|
104
|
+
pileMaxDepth = 220,
|
|
105
|
+
pileSmoothness = 0.06,
|
|
106
|
+
pileFPS = 30,
|
|
107
|
+
colW = 6
|
|
108
|
+
}) {
|
|
109
|
+
const canvasRef = useRef(null);
|
|
110
|
+
const modelRef = useRef(null);
|
|
111
|
+
const drawRef = useRef({
|
|
112
|
+
dpr: 1,
|
|
113
|
+
w: 0,
|
|
114
|
+
h: 0
|
|
115
|
+
});
|
|
116
|
+
const settings = useMemo(
|
|
117
|
+
() => ({
|
|
118
|
+
colW,
|
|
119
|
+
maxDepth: pileMaxDepth,
|
|
120
|
+
smoothK: pileSmoothness,
|
|
121
|
+
fps: pileFPS
|
|
122
|
+
}),
|
|
123
|
+
[colW, pileMaxDepth, pileSmoothness, pileFPS]
|
|
124
|
+
);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const canvas = canvasRef.current;
|
|
127
|
+
const resize = () => {
|
|
128
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
|
129
|
+
const w = window.innerWidth;
|
|
130
|
+
const h = window.innerHeight;
|
|
131
|
+
canvas.style.width = w + "px";
|
|
132
|
+
canvas.style.height = h + "px";
|
|
133
|
+
canvas.width = Math.floor(w * dpr);
|
|
134
|
+
canvas.height = Math.floor(h * dpr);
|
|
135
|
+
drawRef.current = { dpr, w, h };
|
|
136
|
+
const cols = Math.ceil(w / settings.colW);
|
|
137
|
+
modelRef.current = createHeightfield(cols, settings.colW, w, h, settings.maxDepth);
|
|
138
|
+
apiRef.current = {
|
|
139
|
+
getHeights: () => modelRef.current.heights,
|
|
140
|
+
getMeta: () => ({
|
|
141
|
+
cols: modelRef.current.cols,
|
|
142
|
+
colW: modelRef.current.colW,
|
|
143
|
+
w: modelRef.current.w,
|
|
144
|
+
h: modelRef.current.h
|
|
145
|
+
}),
|
|
146
|
+
removeRect: (x, y, rw, rh, maxRemove) => removeSnowRect(modelRef.current, x, y, rw, rh, maxRemove),
|
|
147
|
+
depositAtX: (x, amount) => {
|
|
148
|
+
const m = modelRef.current;
|
|
149
|
+
if (!m) return;
|
|
150
|
+
depositAtX(m, x, amount);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
resize();
|
|
155
|
+
window.addEventListener("resize", resize);
|
|
156
|
+
return () => window.removeEventListener("resize", resize);
|
|
157
|
+
}, [apiRef, settings]);
|
|
158
|
+
const lastFrameRef = useRef(0);
|
|
159
|
+
useRafLoop((t) => {
|
|
160
|
+
if (!enabled) return;
|
|
161
|
+
const model = modelRef.current;
|
|
162
|
+
const canvas = canvasRef.current;
|
|
163
|
+
if (!model || !canvas) return;
|
|
164
|
+
const targetMs = 1e3 / settings.fps;
|
|
165
|
+
if (t - lastFrameRef.current < targetMs) return;
|
|
166
|
+
lastFrameRef.current = t;
|
|
167
|
+
driftGround(model, windX);
|
|
168
|
+
smoothHeights(model, settings.smoothK);
|
|
169
|
+
onAvgDepth?.(avgHeight(model));
|
|
170
|
+
drawSnow(canvas, model, drawRef.current.dpr);
|
|
171
|
+
});
|
|
172
|
+
if (!enabled) return null;
|
|
173
|
+
return /* @__PURE__ */ jsx(
|
|
174
|
+
"canvas",
|
|
175
|
+
{
|
|
176
|
+
ref: canvasRef,
|
|
177
|
+
"aria-hidden": "true",
|
|
178
|
+
style: { position: "fixed", inset: 0, zIndex, pointerEvents: "none" }
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
function drawSnow(canvas, model, dpr) {
|
|
183
|
+
const ctx = canvas.getContext("2d");
|
|
184
|
+
const w = model.w;
|
|
185
|
+
const h = model.h;
|
|
186
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
187
|
+
ctx.clearRect(0, 0, w, h);
|
|
188
|
+
const heights = model.heights;
|
|
189
|
+
const colW = model.colW;
|
|
190
|
+
ctx.beginPath();
|
|
191
|
+
ctx.moveTo(0, h);
|
|
192
|
+
for (let i = 0; i < heights.length; i++) {
|
|
193
|
+
const x = i * colW;
|
|
194
|
+
const y = h - heights[i];
|
|
195
|
+
ctx.lineTo(x, y);
|
|
196
|
+
}
|
|
197
|
+
ctx.lineTo(w, h);
|
|
198
|
+
ctx.closePath();
|
|
199
|
+
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
|
200
|
+
ctx.fill();
|
|
201
|
+
ctx.strokeStyle = "rgba(255,255,255,0.65)";
|
|
202
|
+
ctx.lineWidth = 1;
|
|
203
|
+
ctx.stroke();
|
|
204
|
+
}
|
|
205
|
+
function SnowfallEngine({
|
|
206
|
+
zIndex = 1,
|
|
207
|
+
enabled,
|
|
208
|
+
getAPI,
|
|
209
|
+
windX,
|
|
210
|
+
flakeCount = 220,
|
|
211
|
+
snowfallSpeed = 1,
|
|
212
|
+
snowflakeSize = [0.9, 2.2]
|
|
213
|
+
}) {
|
|
214
|
+
const canvasRef = useRef(null);
|
|
215
|
+
const flakesRef = useRef([]);
|
|
216
|
+
const lastRef = useRef(0);
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!enabled) return;
|
|
219
|
+
const init = () => {
|
|
220
|
+
const w = window.innerWidth;
|
|
221
|
+
const h = window.innerHeight;
|
|
222
|
+
const [rMin, rMax] = snowflakeSize;
|
|
223
|
+
flakesRef.current = Array.from({ length: flakeCount }, () => ({
|
|
224
|
+
x: Math.random() * w,
|
|
225
|
+
y: Math.random() * h,
|
|
226
|
+
vx: (Math.random() - 0.5) * 0.25,
|
|
227
|
+
vy: (0.35 + Math.random() * 0.9) * snowfallSpeed,
|
|
228
|
+
r: rMin + Math.random() * (rMax - rMin)
|
|
229
|
+
}));
|
|
230
|
+
};
|
|
231
|
+
init();
|
|
232
|
+
window.addEventListener("resize", init);
|
|
233
|
+
return () => window.removeEventListener("resize", init);
|
|
234
|
+
}, [enabled, flakeCount, snowfallSpeed, snowflakeSize]);
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!enabled) return;
|
|
237
|
+
let raf = 0;
|
|
238
|
+
const loop = (t) => {
|
|
239
|
+
raf = requestAnimationFrame(loop);
|
|
240
|
+
const canvas = canvasRef.current;
|
|
241
|
+
const api = getAPI();
|
|
242
|
+
if (!canvas || !api) return;
|
|
243
|
+
const w = window.innerWidth;
|
|
244
|
+
const h = window.innerHeight;
|
|
245
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
|
246
|
+
canvas.style.width = w + "px";
|
|
247
|
+
canvas.style.height = h + "px";
|
|
248
|
+
canvas.width = Math.floor(w * dpr);
|
|
249
|
+
canvas.height = Math.floor(h * dpr);
|
|
250
|
+
const ctx = canvas.getContext("2d");
|
|
251
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
252
|
+
ctx.clearRect(0, 0, w, h);
|
|
253
|
+
const dt = Math.min(40, Math.max(10, t - (lastRef.current || t)));
|
|
254
|
+
lastRef.current = t;
|
|
255
|
+
const flakes = flakesRef.current;
|
|
256
|
+
const meta = api.getMeta();
|
|
257
|
+
const heights = api.getHeights();
|
|
258
|
+
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
|
259
|
+
for (const f of flakes) {
|
|
260
|
+
f.vx += windX * 26e-4;
|
|
261
|
+
f.vx *= 0.995;
|
|
262
|
+
f.vy += 11e-4 * (dt / 16.67) * snowfallSpeed;
|
|
263
|
+
f.x += f.vx * dt;
|
|
264
|
+
f.y += f.vy * dt;
|
|
265
|
+
if (f.x < -10) f.x = w + 10;
|
|
266
|
+
if (f.x > w + 10) f.x = -10;
|
|
267
|
+
const col = Math.max(0, Math.min(meta.cols - 1, Math.floor(f.x / meta.colW)));
|
|
268
|
+
const surfaceY = meta.h - heights[col];
|
|
269
|
+
if (f.y >= surfaceY) {
|
|
270
|
+
api.depositAtX(f.x, 0.55 + f.r * 0.28);
|
|
271
|
+
const [rMin, rMax] = snowflakeSize;
|
|
272
|
+
f.x = Math.random() * w;
|
|
273
|
+
f.y = -10 - Math.random() * 140;
|
|
274
|
+
f.vx = (Math.random() - 0.5) * 0.25;
|
|
275
|
+
f.vy = (0.35 + Math.random() * 0.95) * snowfallSpeed;
|
|
276
|
+
f.r = rMin + Math.random() * (rMax - rMin);
|
|
277
|
+
}
|
|
278
|
+
ctx.beginPath();
|
|
279
|
+
ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);
|
|
280
|
+
ctx.fill();
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
raf = requestAnimationFrame(loop);
|
|
284
|
+
return () => cancelAnimationFrame(raf);
|
|
285
|
+
}, [enabled, getAPI, windX, snowfallSpeed, snowflakeSize]);
|
|
286
|
+
if (!enabled) return null;
|
|
287
|
+
return /* @__PURE__ */ jsx(
|
|
288
|
+
"canvas",
|
|
289
|
+
{
|
|
290
|
+
ref: canvasRef,
|
|
291
|
+
"aria-hidden": "true",
|
|
292
|
+
style: { position: "fixed", inset: 0, zIndex, pointerEvents: "none" }
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
var clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
297
|
+
function ShovelWithSnow({
|
|
298
|
+
loadRatio,
|
|
299
|
+
size = 46,
|
|
300
|
+
strokeWidth = 2.2
|
|
301
|
+
}) {
|
|
302
|
+
const r = clamp01(loadRatio);
|
|
303
|
+
const snowOpacity = 0.25 + 0.75 * r;
|
|
304
|
+
const snowScale = 0.65 + 0.75 * r;
|
|
305
|
+
const snowX = 10.5;
|
|
306
|
+
const snowY = 13.2;
|
|
307
|
+
return /* @__PURE__ */ jsxs("div", { style: { position: "relative", width: size, height: size }, children: [
|
|
308
|
+
/* @__PURE__ */ jsx(Shovel, { size, strokeWidth }),
|
|
309
|
+
r > 0.03 && /* @__PURE__ */ jsx(
|
|
310
|
+
"svg",
|
|
311
|
+
{
|
|
312
|
+
width: size,
|
|
313
|
+
height: size,
|
|
314
|
+
viewBox: "0 0 24 24",
|
|
315
|
+
style: {
|
|
316
|
+
position: "absolute",
|
|
317
|
+
inset: 0,
|
|
318
|
+
pointerEvents: "none",
|
|
319
|
+
opacity: 1,
|
|
320
|
+
filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.22))"
|
|
321
|
+
},
|
|
322
|
+
children: /* @__PURE__ */ jsxs("g", { transform: `translate(${snowX}, ${snowY}) scale(${snowScale})`, children: [
|
|
323
|
+
/* @__PURE__ */ jsx(
|
|
324
|
+
"path",
|
|
325
|
+
{
|
|
326
|
+
d: "M0.4 5.8\n C1.6 3.2 5.6 2.8 7.6 4.1\n C9.9 5.5 10.1 8.1 8.3 9.6\n C6.2 11.4 3.0 11.0 1.3 9.7\n C-0.4 8.4 -0.3 7.1 0.4 5.8 Z",
|
|
327
|
+
fill: `rgba(245,248,255,${snowOpacity})`
|
|
328
|
+
}
|
|
329
|
+
),
|
|
330
|
+
/* @__PURE__ */ jsx(
|
|
331
|
+
"path",
|
|
332
|
+
{
|
|
333
|
+
d: "M1.6 5.8 C2.8 4.6 5.2 4.3 6.8 5.1",
|
|
334
|
+
stroke: `rgba(255,255,255,${0.35 + 0.5 * r})`,
|
|
335
|
+
strokeWidth: "0.7",
|
|
336
|
+
strokeLinecap: "round",
|
|
337
|
+
fill: "none"
|
|
338
|
+
}
|
|
339
|
+
),
|
|
340
|
+
r > 0.2 && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
341
|
+
/* @__PURE__ */ jsx("circle", { cx: "7.5", cy: "8.2", r: 0.35 + 0.25 * r, fill: "rgba(255,255,255,0.95)" }),
|
|
342
|
+
/* @__PURE__ */ jsx("circle", { cx: "5.8", cy: "7.1", r: 0.28 + 0.18 * r, fill: "rgba(255,255,255,0.9)" }),
|
|
343
|
+
/* @__PURE__ */ jsx("circle", { cx: "6.4", cy: "6.3", r: 0.22 + 0.14 * r, fill: "rgba(255,255,255,0.85)" })
|
|
344
|
+
] })
|
|
345
|
+
] })
|
|
346
|
+
}
|
|
347
|
+
)
|
|
348
|
+
] });
|
|
349
|
+
}
|
|
350
|
+
function isTouchDevice() {
|
|
351
|
+
if (typeof window === "undefined") return false;
|
|
352
|
+
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
353
|
+
}
|
|
354
|
+
function SnowShovel({
|
|
355
|
+
zIndex = 3,
|
|
356
|
+
visible,
|
|
357
|
+
scale,
|
|
358
|
+
getAPI,
|
|
359
|
+
getMatter
|
|
360
|
+
}) {
|
|
361
|
+
const touch = useMemo(() => isTouchDevice(), []);
|
|
362
|
+
const [armed, setArmed] = useState(false);
|
|
363
|
+
const [pos, setPos] = useState({ x: 120, y: 120 });
|
|
364
|
+
const [loadUI, setLoadUI] = useState(0);
|
|
365
|
+
const loadRef = useRef(0);
|
|
366
|
+
const draggingRef = useRef(false);
|
|
367
|
+
const lastRef = useRef({ x: 0, y: 0, t: 0 });
|
|
368
|
+
const lastCommitRef = useRef(0);
|
|
369
|
+
const shovel = useMemo(() => {
|
|
370
|
+
const headW = 92 * scale;
|
|
371
|
+
const headH = 44 * scale;
|
|
372
|
+
const capacity = 140 * scale;
|
|
373
|
+
return { headW, headH, capacity };
|
|
374
|
+
}, [scale]);
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
if (touch) return;
|
|
377
|
+
const kd = (e) => e.key === "Shift" && setArmed(true);
|
|
378
|
+
const ku = (e) => e.key === "Shift" && setArmed(false);
|
|
379
|
+
window.addEventListener("keydown", kd);
|
|
380
|
+
window.addEventListener("keyup", ku);
|
|
381
|
+
return () => {
|
|
382
|
+
window.removeEventListener("keydown", kd);
|
|
383
|
+
window.removeEventListener("keyup", ku);
|
|
384
|
+
};
|
|
385
|
+
}, [touch]);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
let raf = 0;
|
|
388
|
+
const tick = (t) => {
|
|
389
|
+
raf = requestAnimationFrame(tick);
|
|
390
|
+
if (!visible || !armed || !draggingRef.current) return;
|
|
391
|
+
const api = getAPI();
|
|
392
|
+
if (!api) return;
|
|
393
|
+
const load = loadRef.current;
|
|
394
|
+
const remaining = shovel.capacity - load;
|
|
395
|
+
if (remaining <= 0) return;
|
|
396
|
+
const bladeX = pos.x + 22 * scale;
|
|
397
|
+
const bladeY = pos.y + 16 * scale;
|
|
398
|
+
const rectX = bladeX - shovel.headW / 2;
|
|
399
|
+
const rectY = bladeY - shovel.headH / 2;
|
|
400
|
+
const taken = api.removeRect(rectX, rectY, shovel.headW, shovel.headH, remaining);
|
|
401
|
+
if (taken > 0) {
|
|
402
|
+
loadRef.current = clamp(load + taken, 0, shovel.capacity);
|
|
403
|
+
if (t - lastCommitRef.current > 50) {
|
|
404
|
+
lastCommitRef.current = t;
|
|
405
|
+
setLoadUI(loadRef.current);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
raf = requestAnimationFrame(tick);
|
|
410
|
+
return () => cancelAnimationFrame(raf);
|
|
411
|
+
}, [armed, visible, pos.x, pos.y, scale, shovel.capacity, shovel.headW, shovel.headH, getAPI]);
|
|
412
|
+
function throwSnow(x, y) {
|
|
413
|
+
const matter = getMatter();
|
|
414
|
+
if (!matter) return;
|
|
415
|
+
const now = performance.now();
|
|
416
|
+
const last = lastRef.current;
|
|
417
|
+
const dt = Math.max(16, now - last.t);
|
|
418
|
+
const dx = x - last.x;
|
|
419
|
+
const dy = y - last.y;
|
|
420
|
+
let vx = dx / (dt / 16.67);
|
|
421
|
+
let vy = dy / (dt / 16.67);
|
|
422
|
+
vx = clamp(vx, -70, 70);
|
|
423
|
+
vy = clamp(vy, -70, 70);
|
|
424
|
+
lastRef.current = { x, y, t: now };
|
|
425
|
+
const amt = loadRef.current;
|
|
426
|
+
loadRef.current = 0;
|
|
427
|
+
setLoadUI(0);
|
|
428
|
+
const count = Math.floor(clamp(amt / (7 * scale), 10, 44));
|
|
429
|
+
const rBase = 2.4 * scale;
|
|
430
|
+
matter.throwChunks(x, y, vx, vy, count, rBase);
|
|
431
|
+
}
|
|
432
|
+
if (!visible) return null;
|
|
433
|
+
const loadRatio = loadUI / shovel.capacity;
|
|
434
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
435
|
+
touch && /* @__PURE__ */ jsx(
|
|
436
|
+
"button",
|
|
437
|
+
{
|
|
438
|
+
onClick: () => setArmed((v) => !v),
|
|
439
|
+
style: {
|
|
440
|
+
position: "fixed",
|
|
441
|
+
right: 16,
|
|
442
|
+
bottom: 16,
|
|
443
|
+
zIndex: zIndex + 50,
|
|
444
|
+
padding: "10px 14px",
|
|
445
|
+
borderRadius: 999,
|
|
446
|
+
border: "1px solid rgba(255,255,255,0.22)",
|
|
447
|
+
background: armed ? "rgba(255,255,255,0.92)" : "rgba(15, 23, 42, 0.60)",
|
|
448
|
+
color: armed ? "rgba(15,23,42,0.95)" : "rgba(255,255,255,0.92)",
|
|
449
|
+
fontSize: 12,
|
|
450
|
+
fontWeight: 700,
|
|
451
|
+
cursor: "pointer"
|
|
452
|
+
},
|
|
453
|
+
children: armed ? "Shovel: ON" : "Shovel: OFF"
|
|
454
|
+
}
|
|
455
|
+
),
|
|
456
|
+
touch && armed && /* @__PURE__ */ jsx(
|
|
457
|
+
"div",
|
|
458
|
+
{
|
|
459
|
+
style: {
|
|
460
|
+
position: "fixed",
|
|
461
|
+
inset: 0,
|
|
462
|
+
zIndex: zIndex + 40,
|
|
463
|
+
background: "transparent",
|
|
464
|
+
touchAction: "none",
|
|
465
|
+
// β
stop scroll/zoom stealing drag
|
|
466
|
+
pointerEvents: "auto"
|
|
467
|
+
},
|
|
468
|
+
onPointerDown: (e) => {
|
|
469
|
+
const el = e.currentTarget;
|
|
470
|
+
el.setPointerCapture(e.pointerId);
|
|
471
|
+
draggingRef.current = true;
|
|
472
|
+
setPos({ x: e.clientX, y: e.clientY });
|
|
473
|
+
lastRef.current = { x: e.clientX, y: e.clientY, t: performance.now() };
|
|
474
|
+
},
|
|
475
|
+
onPointerMove: (e) => {
|
|
476
|
+
setPos({ x: e.clientX, y: e.clientY });
|
|
477
|
+
},
|
|
478
|
+
onPointerUp: (e) => {
|
|
479
|
+
if (loadRef.current > 1) throwSnow(e.clientX, e.clientY);
|
|
480
|
+
draggingRef.current = false;
|
|
481
|
+
},
|
|
482
|
+
onPointerCancel: () => {
|
|
483
|
+
draggingRef.current = false;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
),
|
|
487
|
+
!touch && /* @__PURE__ */ jsx(
|
|
488
|
+
DesktopPointerTracker,
|
|
489
|
+
{
|
|
490
|
+
onMove: (x, y) => setPos({ x, y }),
|
|
491
|
+
onDown: (x, y) => {
|
|
492
|
+
if (!armed) return;
|
|
493
|
+
draggingRef.current = true;
|
|
494
|
+
lastRef.current = { x, y, t: performance.now() };
|
|
495
|
+
},
|
|
496
|
+
onUp: (x, y) => {
|
|
497
|
+
if (armed && loadRef.current > 1) throwSnow(x, y);
|
|
498
|
+
draggingRef.current = false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
),
|
|
502
|
+
/* @__PURE__ */ jsxs(
|
|
503
|
+
"div",
|
|
504
|
+
{
|
|
505
|
+
style: {
|
|
506
|
+
position: "fixed",
|
|
507
|
+
left: pos.x,
|
|
508
|
+
top: pos.y,
|
|
509
|
+
zIndex,
|
|
510
|
+
pointerEvents: "none",
|
|
511
|
+
transform: `translate(-30%, -30%) rotate(${armed ? -18 : -35}deg) scale(${scale})`,
|
|
512
|
+
opacity: armed ? 1 : 0.92,
|
|
513
|
+
transition: "opacity 200ms ease, transform 200ms ease",
|
|
514
|
+
userSelect: "none",
|
|
515
|
+
filter: "drop-shadow(0 10px 18px rgba(0,0,0,0.25))"
|
|
516
|
+
},
|
|
517
|
+
children: [
|
|
518
|
+
/* @__PURE__ */ jsx(ShovelWithSnow, { loadRatio, size: 46 }),
|
|
519
|
+
/* @__PURE__ */ jsx(
|
|
520
|
+
"div",
|
|
521
|
+
{
|
|
522
|
+
style: {
|
|
523
|
+
marginTop: 6,
|
|
524
|
+
width: 120,
|
|
525
|
+
height: 8,
|
|
526
|
+
borderRadius: 999,
|
|
527
|
+
background: "rgba(255,255,255,0.25)",
|
|
528
|
+
overflow: "hidden"
|
|
529
|
+
},
|
|
530
|
+
children: /* @__PURE__ */ jsx(
|
|
531
|
+
"div",
|
|
532
|
+
{
|
|
533
|
+
style: {
|
|
534
|
+
width: `${loadRatio * 100}%`,
|
|
535
|
+
height: "100%",
|
|
536
|
+
background: "rgba(255,255,255,0.92)"
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
)
|
|
542
|
+
]
|
|
543
|
+
}
|
|
544
|
+
)
|
|
545
|
+
] });
|
|
546
|
+
}
|
|
547
|
+
function DesktopPointerTracker({
|
|
548
|
+
onMove,
|
|
549
|
+
onDown,
|
|
550
|
+
onUp
|
|
551
|
+
}) {
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
const move = (e) => onMove(e.clientX, e.clientY);
|
|
554
|
+
const down = (e) => onDown(e.clientX, e.clientY);
|
|
555
|
+
const up = (e) => onUp(e.clientX, e.clientY);
|
|
556
|
+
window.addEventListener("pointermove", move, { passive: true });
|
|
557
|
+
window.addEventListener("pointerdown", down);
|
|
558
|
+
window.addEventListener("pointerup", up);
|
|
559
|
+
return () => {
|
|
560
|
+
window.removeEventListener("pointermove", move);
|
|
561
|
+
window.removeEventListener("pointerdown", down);
|
|
562
|
+
window.removeEventListener("pointerup", up);
|
|
563
|
+
};
|
|
564
|
+
}, [onMove, onDown, onUp]);
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
var DEFAULT_CFG = {
|
|
568
|
+
gravityY: 1,
|
|
569
|
+
restitution: 0.35,
|
|
570
|
+
friction: 0.1,
|
|
571
|
+
frictionAir: 0.02,
|
|
572
|
+
ttlMs: 2500,
|
|
573
|
+
allowSideExit: true,
|
|
574
|
+
maxBodies: 200
|
|
575
|
+
};
|
|
576
|
+
function MatterSnowChunks({
|
|
577
|
+
zIndex = 4,
|
|
578
|
+
apiRef,
|
|
579
|
+
initialConfig
|
|
580
|
+
}) {
|
|
581
|
+
const canvasRef = useRef(null);
|
|
582
|
+
useEffect(() => {
|
|
583
|
+
const Engine = Matter.Engine;
|
|
584
|
+
const World = Matter.World;
|
|
585
|
+
const Bodies = Matter.Bodies;
|
|
586
|
+
const Composite = Matter.Composite;
|
|
587
|
+
const engine = Engine.create({ enableSleeping: true });
|
|
588
|
+
const cfgRef = { current: { ...DEFAULT_CFG, ...initialConfig ?? {} } };
|
|
589
|
+
const bornAt = /* @__PURE__ */ new WeakMap();
|
|
590
|
+
const order = [];
|
|
591
|
+
const canvas = canvasRef.current;
|
|
592
|
+
const ctx = canvas.getContext("2d");
|
|
593
|
+
const buildBoundaries = () => {
|
|
594
|
+
const w = window.innerWidth;
|
|
595
|
+
const h = window.innerHeight;
|
|
596
|
+
Composite.clear(engine.world, false);
|
|
597
|
+
const thick = 160;
|
|
598
|
+
const ground = Bodies.rectangle(w / 2, h + thick / 2, w + 800, thick, { isStatic: true });
|
|
599
|
+
World.add(engine.world, [ground]);
|
|
600
|
+
if (!cfgRef.current.allowSideExit) {
|
|
601
|
+
const wallT = 160;
|
|
602
|
+
const left = Bodies.rectangle(-wallT / 2, h / 2, wallT, h + 800, { isStatic: true });
|
|
603
|
+
const right = Bodies.rectangle(w + wallT / 2, h / 2, wallT, h + 800, { isStatic: true });
|
|
604
|
+
World.add(engine.world, [left, right]);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const syncSize = () => {
|
|
608
|
+
const w = window.innerWidth;
|
|
609
|
+
const h = window.innerHeight;
|
|
610
|
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
|
611
|
+
canvas.style.width = w + "px";
|
|
612
|
+
canvas.style.height = h + "px";
|
|
613
|
+
canvas.width = Math.floor(w * dpr);
|
|
614
|
+
canvas.height = Math.floor(h * dpr);
|
|
615
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
616
|
+
buildBoundaries();
|
|
617
|
+
};
|
|
618
|
+
const applyEngineCfg = () => {
|
|
619
|
+
engine.gravity.y = cfgRef.current.gravityY;
|
|
620
|
+
};
|
|
621
|
+
const removeBody = (b) => {
|
|
622
|
+
World.remove(engine.world, b);
|
|
623
|
+
const idx = order.indexOf(b);
|
|
624
|
+
if (idx >= 0) order.splice(idx, 1);
|
|
625
|
+
};
|
|
626
|
+
const enforceCap = () => {
|
|
627
|
+
const cfg = cfgRef.current;
|
|
628
|
+
let dynCount = 0;
|
|
629
|
+
for (const b of order) if (!b.isStatic) dynCount++;
|
|
630
|
+
while (dynCount > cfg.maxBodies) {
|
|
631
|
+
const oldest = order.find((b) => !b.isStatic);
|
|
632
|
+
if (!oldest) break;
|
|
633
|
+
removeBody(oldest);
|
|
634
|
+
dynCount--;
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
syncSize();
|
|
638
|
+
applyEngineCfg();
|
|
639
|
+
window.addEventListener("resize", syncSize);
|
|
640
|
+
apiRef.current = {
|
|
641
|
+
throwChunks: (x, y, vx, vy, count, rBase) => {
|
|
642
|
+
const cfg = cfgRef.current;
|
|
643
|
+
for (let i = 0; i < count; i++) {
|
|
644
|
+
const r = rBase * (0.7 + Math.random() * 0.9);
|
|
645
|
+
const b = Bodies.circle(x, y, r, {
|
|
646
|
+
restitution: cfg.restitution,
|
|
647
|
+
friction: cfg.friction,
|
|
648
|
+
frictionAir: cfg.frictionAir
|
|
649
|
+
});
|
|
650
|
+
Matter.Body.setVelocity(b, {
|
|
651
|
+
x: vx * 0.9 + (Math.random() - 0.5) * 6,
|
|
652
|
+
y: vy * 0.7 - (8 + Math.random() * 8)
|
|
653
|
+
});
|
|
654
|
+
bornAt.set(b, performance.now());
|
|
655
|
+
order.push(b);
|
|
656
|
+
World.add(engine.world, b);
|
|
657
|
+
}
|
|
658
|
+
enforceCap();
|
|
659
|
+
},
|
|
660
|
+
setConfig: (partial) => {
|
|
661
|
+
const prevAllow = cfgRef.current.allowSideExit;
|
|
662
|
+
cfgRef.current = { ...cfgRef.current, ...partial };
|
|
663
|
+
applyEngineCfg();
|
|
664
|
+
if (partial.allowSideExit !== void 0 && partial.allowSideExit !== prevAllow) {
|
|
665
|
+
buildBoundaries();
|
|
666
|
+
}
|
|
667
|
+
if (partial.maxBodies !== void 0) {
|
|
668
|
+
enforceCap();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
let raf = 0;
|
|
673
|
+
const loop = () => {
|
|
674
|
+
raf = requestAnimationFrame(loop);
|
|
675
|
+
Engine.update(engine, 1e3 / 60);
|
|
676
|
+
const w = window.innerWidth;
|
|
677
|
+
const h = window.innerHeight;
|
|
678
|
+
ctx.clearRect(0, 0, w, h);
|
|
679
|
+
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
|
680
|
+
const now = performance.now();
|
|
681
|
+
const ttl = cfgRef.current.ttlMs;
|
|
682
|
+
for (const b of [...order]) {
|
|
683
|
+
if (b.isStatic) continue;
|
|
684
|
+
const born = bornAt.get(b) ?? now;
|
|
685
|
+
const age = now - born;
|
|
686
|
+
if (age > ttl) {
|
|
687
|
+
removeBody(b);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (b.position.x < -500 || b.position.x > w + 500 || b.position.y > h + 800 || b.position.y < -800) {
|
|
691
|
+
removeBody(b);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const r = b.circleRadius || 3;
|
|
695
|
+
ctx.beginPath();
|
|
696
|
+
ctx.arc(b.position.x, b.position.y, r, 0, Math.PI * 2);
|
|
697
|
+
ctx.fill();
|
|
698
|
+
}
|
|
699
|
+
enforceCap();
|
|
700
|
+
};
|
|
701
|
+
loop();
|
|
702
|
+
return () => {
|
|
703
|
+
cancelAnimationFrame(raf);
|
|
704
|
+
window.removeEventListener("resize", syncSize);
|
|
705
|
+
apiRef.current = null;
|
|
706
|
+
};
|
|
707
|
+
}, [apiRef, initialConfig]);
|
|
708
|
+
return /* @__PURE__ */ jsx(
|
|
709
|
+
"canvas",
|
|
710
|
+
{
|
|
711
|
+
ref: canvasRef,
|
|
712
|
+
"aria-hidden": "true",
|
|
713
|
+
style: { position: "fixed", inset: 0, zIndex, pointerEvents: "none" }
|
|
714
|
+
}
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
function useWindFromScroll() {
|
|
718
|
+
const [windX, setWindX] = useState(0);
|
|
719
|
+
useEffect(() => {
|
|
720
|
+
let lastY = window.scrollY;
|
|
721
|
+
let lastT = performance.now();
|
|
722
|
+
const onScroll = () => {
|
|
723
|
+
const y = window.scrollY;
|
|
724
|
+
const t = performance.now();
|
|
725
|
+
const dt = Math.max(16, t - lastT);
|
|
726
|
+
const v = (y - lastY) / dt;
|
|
727
|
+
const w = Math.max(-2, Math.min(2, v * 20));
|
|
728
|
+
setWindX(w);
|
|
729
|
+
lastY = y;
|
|
730
|
+
lastT = t;
|
|
731
|
+
};
|
|
732
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
733
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
734
|
+
}, []);
|
|
735
|
+
return windX;
|
|
736
|
+
}
|
|
737
|
+
var PRESETS = {
|
|
738
|
+
calm: {
|
|
739
|
+
flakeCount: 120,
|
|
740
|
+
snowfallSpeed: 0.6,
|
|
741
|
+
snowflakeSize: [0.8, 1.8],
|
|
742
|
+
windStrength: 0.5,
|
|
743
|
+
pileMaxDepth: 160,
|
|
744
|
+
pileSmoothness: 0.078,
|
|
745
|
+
pileFPS: 20,
|
|
746
|
+
shovelScale: 1,
|
|
747
|
+
shovelRevealDepth: 9999,
|
|
748
|
+
chunkTtlMs: 3800,
|
|
749
|
+
chunkBounciness: 0.25,
|
|
750
|
+
chunkAirDrag: 0.03,
|
|
751
|
+
chunkAllowSideExit: true,
|
|
752
|
+
chunkGravityY: 0.9
|
|
753
|
+
},
|
|
754
|
+
cozy: {
|
|
755
|
+
flakeCount: 170,
|
|
756
|
+
snowfallSpeed: 0.9,
|
|
757
|
+
snowflakeSize: [0.9, 2.2],
|
|
758
|
+
windStrength: 0.8,
|
|
759
|
+
pileMaxDepth: 200,
|
|
760
|
+
pileSmoothness: 0.06,
|
|
761
|
+
pileFPS: 30,
|
|
762
|
+
shovelScale: 1,
|
|
763
|
+
shovelRevealDepth: 28,
|
|
764
|
+
chunkTtlMs: 4500,
|
|
765
|
+
chunkBounciness: 0.35,
|
|
766
|
+
chunkAirDrag: 0.02,
|
|
767
|
+
chunkAllowSideExit: true,
|
|
768
|
+
chunkGravityY: 1
|
|
769
|
+
},
|
|
770
|
+
storm: {
|
|
771
|
+
flakeCount: 240,
|
|
772
|
+
snowfallSpeed: 1.15,
|
|
773
|
+
snowflakeSize: [1, 2.6],
|
|
774
|
+
windStrength: 1.15,
|
|
775
|
+
pileMaxDepth: 220,
|
|
776
|
+
pileSmoothness: 0.058,
|
|
777
|
+
pileFPS: 30,
|
|
778
|
+
shovelScale: 1,
|
|
779
|
+
shovelRevealDepth: 22,
|
|
780
|
+
chunkTtlMs: 5e3,
|
|
781
|
+
chunkBounciness: 0.35,
|
|
782
|
+
chunkAirDrag: 0.02,
|
|
783
|
+
chunkAllowSideExit: true,
|
|
784
|
+
chunkGravityY: 1.05
|
|
785
|
+
},
|
|
786
|
+
interactive: {
|
|
787
|
+
flakeCount: 220,
|
|
788
|
+
snowfallSpeed: 1,
|
|
789
|
+
snowflakeSize: [0.9, 2.3],
|
|
790
|
+
windStrength: 1,
|
|
791
|
+
pileMaxDepth: 220,
|
|
792
|
+
pileSmoothness: 0.06,
|
|
793
|
+
pileFPS: 30,
|
|
794
|
+
shovelScale: 1,
|
|
795
|
+
shovelRevealDepth: 18,
|
|
796
|
+
chunkTtlMs: 4500,
|
|
797
|
+
chunkBounciness: 0.35,
|
|
798
|
+
chunkAirDrag: 0.02,
|
|
799
|
+
chunkAllowSideExit: true,
|
|
800
|
+
chunkGravityY: 1
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
var clamp012 = (v) => Math.max(0, Math.min(1, v));
|
|
804
|
+
var clampNum = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
805
|
+
function applySafety(p) {
|
|
806
|
+
return {
|
|
807
|
+
...p,
|
|
808
|
+
flakeCount: clampNum(p.flakeCount, 60, 260),
|
|
809
|
+
snowfallSpeed: clampNum(p.snowfallSpeed, 0.45, 1.2),
|
|
810
|
+
snowflakeSize: [clampNum(p.snowflakeSize[0], 0.6, 1.6), clampNum(p.snowflakeSize[1], 1.2, 3.2)],
|
|
811
|
+
windStrength: clampNum(p.windStrength, 0, 1.2),
|
|
812
|
+
pileMaxDepth: clampNum(p.pileMaxDepth, 80, 220),
|
|
813
|
+
pileSmoothness: clampNum(p.pileSmoothness, 0.055, 0.085),
|
|
814
|
+
pileFPS: clampNum(p.pileFPS, 15, 30),
|
|
815
|
+
// cap to protect CPU
|
|
816
|
+
shovelScale: clampNum(p.shovelScale, 0.85, 1.2),
|
|
817
|
+
shovelRevealDepth: clampNum(p.shovelRevealDepth, 12, 9999),
|
|
818
|
+
chunkTtlMs: clampNum(p.chunkTtlMs, 2e3, 7e3),
|
|
819
|
+
chunkBounciness: clampNum(p.chunkBounciness, 0.15, 0.55),
|
|
820
|
+
chunkAirDrag: clampNum(p.chunkAirDrag, 0.01, 0.06),
|
|
821
|
+
chunkAllowSideExit: p.chunkAllowSideExit,
|
|
822
|
+
chunkGravityY: clampNum(p.chunkGravityY, 0.7, 1.2)
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
function morphPreset(base, intensity = 0, speed = 0, wind = 0) {
|
|
826
|
+
const i = clamp012(intensity);
|
|
827
|
+
const s = clamp012(speed);
|
|
828
|
+
const w = clamp012(wind);
|
|
829
|
+
return applySafety({
|
|
830
|
+
...base,
|
|
831
|
+
flakeCount: Math.round(base.flakeCount * (0.85 + 0.35 * i)),
|
|
832
|
+
snowfallSpeed: base.snowfallSpeed * (0.9 + 0.25 * s),
|
|
833
|
+
windStrength: base.windStrength * (0.9 + 0.25 * w)
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
function SnowStack(props) {
|
|
837
|
+
const {
|
|
838
|
+
enabled = true,
|
|
839
|
+
preset = "calm",
|
|
840
|
+
intensity = 0,
|
|
841
|
+
// default calm
|
|
842
|
+
speed = 0,
|
|
843
|
+
wind = 0,
|
|
844
|
+
interactive = false
|
|
845
|
+
} = props;
|
|
846
|
+
const base = PRESETS[interactive ? "interactive" : preset];
|
|
847
|
+
const cfg = useMemo(() => morphPreset(base, intensity, speed, wind), [base, intensity, speed, wind]);
|
|
848
|
+
const apiRef = useRef(null);
|
|
849
|
+
const matterRef = useRef(null);
|
|
850
|
+
const windFromScroll = useWindFromScroll();
|
|
851
|
+
const windX = useMemo(() => clamp(windFromScroll * cfg.windStrength, -2, 2), [windFromScroll, cfg.windStrength]);
|
|
852
|
+
const [avgDepth, setAvgDepth] = useState(0);
|
|
853
|
+
const shovelVisible = enabled && interactive && avgDepth >= cfg.shovelRevealDepth;
|
|
854
|
+
const matterInit = useMemo(
|
|
855
|
+
() => ({
|
|
856
|
+
ttlMs: cfg.chunkTtlMs,
|
|
857
|
+
restitution: cfg.chunkBounciness,
|
|
858
|
+
frictionAir: cfg.chunkAirDrag,
|
|
859
|
+
allowSideExit: cfg.chunkAllowSideExit,
|
|
860
|
+
gravityY: cfg.chunkGravityY
|
|
861
|
+
}),
|
|
862
|
+
[cfg.chunkTtlMs, cfg.chunkBounciness, cfg.chunkAirDrag, cfg.chunkAllowSideExit, cfg.chunkGravityY]
|
|
863
|
+
);
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
matterRef.current?.setConfig(matterInit);
|
|
866
|
+
}, [matterInit]);
|
|
867
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
868
|
+
/* @__PURE__ */ jsx(
|
|
869
|
+
SnowfallEngine,
|
|
870
|
+
{
|
|
871
|
+
zIndex: 1,
|
|
872
|
+
enabled,
|
|
873
|
+
getAPI: () => apiRef.current,
|
|
874
|
+
windX,
|
|
875
|
+
flakeCount: cfg.flakeCount,
|
|
876
|
+
snowfallSpeed: cfg.snowfallSpeed,
|
|
877
|
+
snowflakeSize: cfg.snowflakeSize
|
|
878
|
+
}
|
|
879
|
+
),
|
|
880
|
+
/* @__PURE__ */ jsx(
|
|
881
|
+
SnowAccumulationCanvas,
|
|
882
|
+
{
|
|
883
|
+
zIndex: 2,
|
|
884
|
+
enabled,
|
|
885
|
+
windX,
|
|
886
|
+
apiRef,
|
|
887
|
+
onAvgDepth: setAvgDepth,
|
|
888
|
+
pileMaxDepth: cfg.pileMaxDepth,
|
|
889
|
+
pileSmoothness: cfg.pileSmoothness,
|
|
890
|
+
pileFPS: cfg.pileFPS
|
|
891
|
+
}
|
|
892
|
+
),
|
|
893
|
+
enabled && interactive && /* @__PURE__ */ jsx(MatterSnowChunks, { zIndex: 4, apiRef: matterRef, initialConfig: matterInit }),
|
|
894
|
+
/* @__PURE__ */ jsx(
|
|
895
|
+
SnowShovel,
|
|
896
|
+
{
|
|
897
|
+
zIndex: 3,
|
|
898
|
+
visible: shovelVisible,
|
|
899
|
+
scale: cfg.shovelScale,
|
|
900
|
+
getAPI: () => apiRef.current,
|
|
901
|
+
getMatter: () => matterRef.current
|
|
902
|
+
}
|
|
903
|
+
)
|
|
904
|
+
] });
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export { MatterSnowChunks, SnowStack };
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "snowstack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Calm, interactive snowfall with accumulation and shovel physics for React",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"react",
|
|
8
|
+
"snow",
|
|
9
|
+
"snowfall",
|
|
10
|
+
"canvas",
|
|
11
|
+
"matter-js",
|
|
12
|
+
"animation",
|
|
13
|
+
"winter",
|
|
14
|
+
"effects",
|
|
15
|
+
"ui"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/ist00dent/snowstack"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "ist00dent",
|
|
23
|
+
"homepage": "https://github.com/ist00dent/snowstack",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/ist00dent/snowstack/issues"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": "^18 || ^19",
|
|
39
|
+
"react-dom": "^18 || ^19"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"lucide-react": "^0.268.0",
|
|
43
|
+
"matter-js": "^0.19.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "rm -rf dist && tsup src/index.ts --format esm --dts --clean"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/matter-js": "^0.20.2",
|
|
50
|
+
"@types/react": "^19.2.7",
|
|
51
|
+
"tsup": "^8.5.1",
|
|
52
|
+
"typescript": "^5.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|