micro-canvas-confetti-native 0.1.0 → 1.0.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/README.md +158 -142
- package/lib/commonjs/api.js +3 -3
- package/lib/commonjs/api.js.map +1 -1
- package/lib/commonjs/coins.js.map +1 -1
- package/lib/commonjs/constants.js +6 -6
- package/lib/commonjs/constants.js.map +1 -1
- package/lib/commonjs/core.js.map +1 -1
- package/lib/commonjs/engine.js +3 -3
- package/lib/commonjs/engine.js.map +1 -1
- package/lib/commonjs/hooks/useReducedMotion.js +3 -3
- package/lib/commonjs/hooks/useReducedMotion.js.map +1 -1
- package/lib/commonjs/host/ParticleHost.js +3 -3
- package/lib/commonjs/host/ParticleHost.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/physics/collect.js.map +1 -1
- package/lib/commonjs/physics/drag.js.map +1 -1
- package/lib/commonjs/physics/spawn.js.map +1 -1
- package/lib/commonjs/physics/wobble.js.map +1 -1
- package/lib/commonjs/sequence.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/utils/math.js.map +1 -1
- package/lib/commonjs/utils/validation.js +3 -3
- package/lib/commonjs/utils/validation.js.map +1 -1
- package/lib/module/api.js +3 -3
- package/lib/module/api.js.map +1 -1
- package/lib/module/coins.js.map +1 -1
- package/lib/module/constants.js +6 -6
- package/lib/module/constants.js.map +1 -1
- package/lib/module/core.js +3 -3
- package/lib/module/core.js.map +1 -1
- package/lib/module/engine.js +3 -3
- package/lib/module/engine.js.map +1 -1
- package/lib/module/hooks/useReducedMotion.js +3 -3
- package/lib/module/hooks/useReducedMotion.js.map +1 -1
- package/lib/module/host/ParticleHost.js +3 -3
- package/lib/module/host/ParticleHost.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/physics/collect.js.map +1 -1
- package/lib/module/physics/drag.js.map +1 -1
- package/lib/module/physics/spawn.js.map +1 -1
- package/lib/module/physics/wobble.js.map +1 -1
- package/lib/module/sequence.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/utils/math.js.map +1 -1
- package/lib/module/utils/validation.js +3 -3
- package/lib/module/utils/validation.js.map +1 -1
- package/package.json +118 -118
- package/src/api.ts +78 -78
- package/src/coins.ts +8 -8
- package/src/constants.ts +187 -187
- package/src/core.ts +18 -18
- package/src/engine.ts +354 -354
- package/src/hooks/useReducedMotion.ts +26 -26
- package/src/host/ParticleHost.tsx +181 -181
- package/src/index.ts +33 -33
- package/src/physics/collect.ts +100 -100
- package/src/physics/drag.ts +15 -15
- package/src/physics/spawn.ts +139 -139
- package/src/physics/wobble.ts +11 -11
- package/src/sequence.ts +39 -39
- package/src/types.ts +243 -243
- package/src/utils/math.ts +49 -49
- package/src/utils/validation.ts +203 -203
package/src/physics/collect.ts
CHANGED
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
import {
|
|
2
|
-
COIN_SIZE,
|
|
3
|
-
COLLECT_ARC_BEND,
|
|
4
|
-
COLLECT_END_SCALE,
|
|
5
|
-
COLLECT_FADE_START,
|
|
6
|
-
COLLECT_FLIGHT_JITTER_MS,
|
|
7
|
-
COLLECT_ORIGIN_SCATTER,
|
|
8
|
-
COLLECT_SPIN_RANGE,
|
|
9
|
-
COLLECT_STAGGER_JITTER_MS,
|
|
10
|
-
COLLECT_START_SCALE,
|
|
11
|
-
CONFETTI_WIDTH_RANGE,
|
|
12
|
-
MIN_FLATNESS,
|
|
13
|
-
} from "../constants";
|
|
14
|
-
import type { CollectFrame, CollectPiece, ConfettiOrigin, ResolvedCollectOptions } from "../types";
|
|
15
|
-
import { easeInOutCubic, easeInQuad, lerp, pickRandom, randomInRange } from "../utils/math";
|
|
16
|
-
import { computeWobbleScale } from "./wobble";
|
|
17
|
-
|
|
18
|
-
/** Creates the pieces of a collect flight from `origin` to `target`. */
|
|
19
|
-
export function spawnCollectPieces(options: ResolvedCollectOptions): CollectPiece[] {
|
|
20
|
-
const { origin, target } = options;
|
|
21
|
-
const dx = target.x - origin.x;
|
|
22
|
-
const dy = target.y - origin.y;
|
|
23
|
-
const distance = Math.hypot(dx, dy);
|
|
24
|
-
// Unit normal to the flight line; arcs bow to either side of it.
|
|
25
|
-
const normalX = distance > 0 ? -dy / distance : 0;
|
|
26
|
-
const normalY = distance > 0 ? dx / distance : 0;
|
|
27
|
-
const size =
|
|
28
|
-
options.appearance === "coin"
|
|
29
|
-
? COIN_SIZE * options.scalar
|
|
30
|
-
: CONFETTI_WIDTH_RANGE[1] * options.scalar;
|
|
31
|
-
|
|
32
|
-
const pieces: CollectPiece[] = [];
|
|
33
|
-
let delayMs = 0;
|
|
34
|
-
for (let i = 0; i < options.particleCount; i++) {
|
|
35
|
-
const scatterAngle = randomInRange(0, Math.PI * 2);
|
|
36
|
-
const scatter = randomInRange(0, COLLECT_ORIGIN_SCATTER);
|
|
37
|
-
const startX = origin.x + Math.cos(scatterAngle) * scatter;
|
|
38
|
-
const startY = origin.y + Math.sin(scatterAngle) * scatter;
|
|
39
|
-
const bend = randomInRange(-COLLECT_ARC_BEND, COLLECT_ARC_BEND) * distance;
|
|
40
|
-
|
|
41
|
-
pieces.push({
|
|
42
|
-
startX,
|
|
43
|
-
startY,
|
|
44
|
-
controlX: (startX + target.x) / 2 + normalX * bend,
|
|
45
|
-
controlY: (startY + target.y) / 2 + normalY * bend,
|
|
46
|
-
endX: target.x,
|
|
47
|
-
endY: target.y,
|
|
48
|
-
delayMs,
|
|
49
|
-
flightMs: Math.max(
|
|
50
|
-
1,
|
|
51
|
-
options.flightMs + randomInRange(-COLLECT_FLIGHT_JITTER_MS, COLLECT_FLIGHT_JITTER_MS),
|
|
52
|
-
),
|
|
53
|
-
size,
|
|
54
|
-
color: pickRandom(options.colors),
|
|
55
|
-
shape: pickRandom(options.shapes),
|
|
56
|
-
wobblePhase: randomInRange(0, Math.PI * 2),
|
|
57
|
-
wobbleSpeed: randomInRange(...COLLECT_SPIN_RANGE),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
if (options.staggerMs > 0) {
|
|
61
|
-
delayMs += Math.max(
|
|
62
|
-
0,
|
|
63
|
-
options.staggerMs + randomInRange(-COLLECT_STAGGER_JITTER_MS, COLLECT_STAGGER_JITTER_MS),
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return pieces;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Point the rest of the flight at a new window position, keeping the arc shape. */
|
|
71
|
-
export function retargetCollectPiece(piece: CollectPiece, target: ConfettiOrigin): void {
|
|
72
|
-
const dx = target.x - piece.endX;
|
|
73
|
-
const dy = target.y - piece.endY;
|
|
74
|
-
if (dx === 0 && dy === 0) return;
|
|
75
|
-
piece.endX = target.x;
|
|
76
|
-
piece.endY = target.y;
|
|
77
|
-
piece.controlX += dx;
|
|
78
|
-
piece.controlY += dy;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Position, size, and opacity of a collect piece `elapsedMs` after the flight started. */
|
|
82
|
-
export function sampleCollectPiece(piece: CollectPiece, elapsedMs: number): CollectFrame {
|
|
83
|
-
const local = elapsedMs - piece.delayMs;
|
|
84
|
-
const t = Math.min(1, Math.max(0, local / piece.flightMs));
|
|
85
|
-
const path = easeInOutCubic(t);
|
|
86
|
-
const inv = 1 - path;
|
|
87
|
-
const x = inv * inv * piece.startX + 2 * inv * path * piece.controlX + path * path * piece.endX;
|
|
88
|
-
const y = inv * inv * piece.startY + 2 * inv * path * piece.controlY + path * path * piece.endY;
|
|
89
|
-
const fade = t <= COLLECT_FADE_START ? 1 : 1 - (t - COLLECT_FADE_START) / (1 - COLLECT_FADE_START);
|
|
90
|
-
const spinSeconds = Math.max(0, local) / 1000;
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
x,
|
|
94
|
-
y,
|
|
95
|
-
scale: lerp(COLLECT_START_SCALE, COLLECT_END_SCALE, easeInQuad(t)),
|
|
96
|
-
scaleX: computeWobbleScale(piece.wobblePhase + piece.wobbleSpeed * spinSeconds, MIN_FLATNESS),
|
|
97
|
-
opacity: local < 0 ? 0 : Math.max(0, fade),
|
|
98
|
-
done: t >= 1,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
COIN_SIZE,
|
|
3
|
+
COLLECT_ARC_BEND,
|
|
4
|
+
COLLECT_END_SCALE,
|
|
5
|
+
COLLECT_FADE_START,
|
|
6
|
+
COLLECT_FLIGHT_JITTER_MS,
|
|
7
|
+
COLLECT_ORIGIN_SCATTER,
|
|
8
|
+
COLLECT_SPIN_RANGE,
|
|
9
|
+
COLLECT_STAGGER_JITTER_MS,
|
|
10
|
+
COLLECT_START_SCALE,
|
|
11
|
+
CONFETTI_WIDTH_RANGE,
|
|
12
|
+
MIN_FLATNESS,
|
|
13
|
+
} from "../constants";
|
|
14
|
+
import type { CollectFrame, CollectPiece, ConfettiOrigin, ResolvedCollectOptions } from "../types";
|
|
15
|
+
import { easeInOutCubic, easeInQuad, lerp, pickRandom, randomInRange } from "../utils/math";
|
|
16
|
+
import { computeWobbleScale } from "./wobble";
|
|
17
|
+
|
|
18
|
+
/** Creates the pieces of a collect flight from `origin` to `target`. */
|
|
19
|
+
export function spawnCollectPieces(options: ResolvedCollectOptions): CollectPiece[] {
|
|
20
|
+
const { origin, target } = options;
|
|
21
|
+
const dx = target.x - origin.x;
|
|
22
|
+
const dy = target.y - origin.y;
|
|
23
|
+
const distance = Math.hypot(dx, dy);
|
|
24
|
+
// Unit normal to the flight line; arcs bow to either side of it.
|
|
25
|
+
const normalX = distance > 0 ? -dy / distance : 0;
|
|
26
|
+
const normalY = distance > 0 ? dx / distance : 0;
|
|
27
|
+
const size =
|
|
28
|
+
options.appearance === "coin"
|
|
29
|
+
? COIN_SIZE * options.scalar
|
|
30
|
+
: CONFETTI_WIDTH_RANGE[1] * options.scalar;
|
|
31
|
+
|
|
32
|
+
const pieces: CollectPiece[] = [];
|
|
33
|
+
let delayMs = 0;
|
|
34
|
+
for (let i = 0; i < options.particleCount; i++) {
|
|
35
|
+
const scatterAngle = randomInRange(0, Math.PI * 2);
|
|
36
|
+
const scatter = randomInRange(0, COLLECT_ORIGIN_SCATTER);
|
|
37
|
+
const startX = origin.x + Math.cos(scatterAngle) * scatter;
|
|
38
|
+
const startY = origin.y + Math.sin(scatterAngle) * scatter;
|
|
39
|
+
const bend = randomInRange(-COLLECT_ARC_BEND, COLLECT_ARC_BEND) * distance;
|
|
40
|
+
|
|
41
|
+
pieces.push({
|
|
42
|
+
startX,
|
|
43
|
+
startY,
|
|
44
|
+
controlX: (startX + target.x) / 2 + normalX * bend,
|
|
45
|
+
controlY: (startY + target.y) / 2 + normalY * bend,
|
|
46
|
+
endX: target.x,
|
|
47
|
+
endY: target.y,
|
|
48
|
+
delayMs,
|
|
49
|
+
flightMs: Math.max(
|
|
50
|
+
1,
|
|
51
|
+
options.flightMs + randomInRange(-COLLECT_FLIGHT_JITTER_MS, COLLECT_FLIGHT_JITTER_MS),
|
|
52
|
+
),
|
|
53
|
+
size,
|
|
54
|
+
color: pickRandom(options.colors),
|
|
55
|
+
shape: pickRandom(options.shapes),
|
|
56
|
+
wobblePhase: randomInRange(0, Math.PI * 2),
|
|
57
|
+
wobbleSpeed: randomInRange(...COLLECT_SPIN_RANGE),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (options.staggerMs > 0) {
|
|
61
|
+
delayMs += Math.max(
|
|
62
|
+
0,
|
|
63
|
+
options.staggerMs + randomInRange(-COLLECT_STAGGER_JITTER_MS, COLLECT_STAGGER_JITTER_MS),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return pieces;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Point the rest of the flight at a new window position, keeping the arc shape. */
|
|
71
|
+
export function retargetCollectPiece(piece: CollectPiece, target: ConfettiOrigin): void {
|
|
72
|
+
const dx = target.x - piece.endX;
|
|
73
|
+
const dy = target.y - piece.endY;
|
|
74
|
+
if (dx === 0 && dy === 0) return;
|
|
75
|
+
piece.endX = target.x;
|
|
76
|
+
piece.endY = target.y;
|
|
77
|
+
piece.controlX += dx;
|
|
78
|
+
piece.controlY += dy;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Position, size, and opacity of a collect piece `elapsedMs` after the flight started. */
|
|
82
|
+
export function sampleCollectPiece(piece: CollectPiece, elapsedMs: number): CollectFrame {
|
|
83
|
+
const local = elapsedMs - piece.delayMs;
|
|
84
|
+
const t = Math.min(1, Math.max(0, local / piece.flightMs));
|
|
85
|
+
const path = easeInOutCubic(t);
|
|
86
|
+
const inv = 1 - path;
|
|
87
|
+
const x = inv * inv * piece.startX + 2 * inv * path * piece.controlX + path * path * piece.endX;
|
|
88
|
+
const y = inv * inv * piece.startY + 2 * inv * path * piece.controlY + path * path * piece.endY;
|
|
89
|
+
const fade = t <= COLLECT_FADE_START ? 1 : 1 - (t - COLLECT_FADE_START) / (1 - COLLECT_FADE_START);
|
|
90
|
+
const spinSeconds = Math.max(0, local) / 1000;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
x,
|
|
94
|
+
y,
|
|
95
|
+
scale: lerp(COLLECT_START_SCALE, COLLECT_END_SCALE, easeInQuad(t)),
|
|
96
|
+
scaleX: computeWobbleScale(piece.wobblePhase + piece.wobbleSpeed * spinSeconds, MIN_FLATNESS),
|
|
97
|
+
opacity: local < 0 ? 0 : Math.max(0, fade),
|
|
98
|
+
done: t >= 1,
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/physics/drag.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
/** Applies exponential drag to a scalar velocity. */
|
|
2
|
-
export function applyDrag(velocity: number, drag: number, dt: number): number {
|
|
3
|
-
return velocity * Math.exp(-drag * dt);
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
/** Applies exponential drag to a 2D velocity vector. */
|
|
7
|
-
export function applyDrag2D(
|
|
8
|
-
vx: number,
|
|
9
|
-
vy: number,
|
|
10
|
-
drag: number,
|
|
11
|
-
dt: number,
|
|
12
|
-
): { vx: number; vy: number } {
|
|
13
|
-
const dragDecay = Math.exp(-drag * dt);
|
|
14
|
-
return { vx: vx * dragDecay, vy: vy * dragDecay };
|
|
15
|
-
}
|
|
1
|
+
/** Applies exponential drag to a scalar velocity. */
|
|
2
|
+
export function applyDrag(velocity: number, drag: number, dt: number): number {
|
|
3
|
+
return velocity * Math.exp(-drag * dt);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Applies exponential drag to a 2D velocity vector. */
|
|
7
|
+
export function applyDrag2D(
|
|
8
|
+
vx: number,
|
|
9
|
+
vy: number,
|
|
10
|
+
drag: number,
|
|
11
|
+
dt: number,
|
|
12
|
+
): { vx: number; vy: number } {
|
|
13
|
+
const dragDecay = Math.exp(-drag * dt);
|
|
14
|
+
return { vx: vx * dragDecay, vy: vy * dragDecay };
|
|
15
|
+
}
|
package/src/physics/spawn.ts
CHANGED
|
@@ -1,139 +1,139 @@
|
|
|
1
|
-
import {
|
|
2
|
-
COIN_ROTATION_FACTOR,
|
|
3
|
-
COIN_SIZE,
|
|
4
|
-
CONFETTI_HEIGHT_RANGE,
|
|
5
|
-
CONFETTI_WIDTH_RANGE,
|
|
6
|
-
CULL_MARGIN,
|
|
7
|
-
SETTLE_VELOCITY_THRESHOLD,
|
|
8
|
-
SETTLE_Y_OFFSET,
|
|
9
|
-
} from "../constants";
|
|
10
|
-
import type { Particle, PhysicsConfig, ResolvedConfettiOptions, Viewport } from "../types";
|
|
11
|
-
import { degreesToRadians, pickRandom, randomInRange } from "../utils/math";
|
|
12
|
-
import { applyDrag2D } from "./drag";
|
|
13
|
-
|
|
14
|
-
/** Builds one piece with randomized size, color, and initial velocity. */
|
|
15
|
-
function createBaseParticle(
|
|
16
|
-
options: ResolvedConfettiOptions,
|
|
17
|
-
theta: number,
|
|
18
|
-
speed: number,
|
|
19
|
-
reachLimit: number,
|
|
20
|
-
): Particle {
|
|
21
|
-
const isCoin = options.appearance === "coin";
|
|
22
|
-
const coinSize = COIN_SIZE * options.scalar;
|
|
23
|
-
const ticksMax = options.ticks > 0 ? options.ticks : 0;
|
|
24
|
-
const rotationFactor = isCoin ? COIN_ROTATION_FACTOR : 1;
|
|
25
|
-
|
|
26
|
-
return {
|
|
27
|
-
x: options.origin.x,
|
|
28
|
-
y: options.origin.y,
|
|
29
|
-
vx: Math.cos(theta) * speed,
|
|
30
|
-
vy: Math.sin(theta) * speed,
|
|
31
|
-
width: isCoin ? coinSize : randomInRange(...CONFETTI_WIDTH_RANGE) * options.scalar,
|
|
32
|
-
height: isCoin ? coinSize : randomInRange(...CONFETTI_HEIGHT_RANGE) * options.scalar,
|
|
33
|
-
color: pickRandom(options.colors),
|
|
34
|
-
shape: pickRandom(options.shapes),
|
|
35
|
-
rotation: isCoin ? 0 : randomInRange(0, Math.PI * 2),
|
|
36
|
-
rotationSpeed: randomInRange(-Math.PI, Math.PI) * options.rotationSpeed * rotationFactor,
|
|
37
|
-
wobblePhase: randomInRange(0, Math.PI * 2),
|
|
38
|
-
wobbleSpeed: randomInRange(2, 6) * options.wobbleSpeed,
|
|
39
|
-
opacity: 1,
|
|
40
|
-
ticks: ticksMax > 0 ? ticksMax : Number.MAX_SAFE_INTEGER,
|
|
41
|
-
ticksMax,
|
|
42
|
-
spawnX: options.origin.x,
|
|
43
|
-
spawnY: options.origin.y,
|
|
44
|
-
burstRadius: reachLimit,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Per-piece speed spread — slower pieces stay nearer the origin when reach is limited. */
|
|
49
|
-
function resolveSpawnSpeed(options: ResolvedConfettiOptions): number {
|
|
50
|
-
const minFactor = options.burstRadius > 0 ? 0.15 : 0.55;
|
|
51
|
-
return randomInRange(options.startVelocity * minFactor, options.startVelocity);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Slightly varies reach per piece so the boundary is not a perfect ring. */
|
|
55
|
-
function resolveReachLimit(options: ResolvedConfettiOptions): number {
|
|
56
|
-
if (options.burstRadius <= 0) return 0;
|
|
57
|
-
return options.burstRadius * randomInRange(0.82, 1);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Creates all pieces for a burst from resolved options. */
|
|
61
|
-
export function spawnParticles(options: ResolvedConfettiOptions): Particle[] {
|
|
62
|
-
const particles: Particle[] = [];
|
|
63
|
-
const baseAngleRad = degreesToRadians(options.angle);
|
|
64
|
-
const spreadRad = degreesToRadians(Math.min(options.spread, 360));
|
|
65
|
-
|
|
66
|
-
for (let i = 0; i < options.particleCount; i++) {
|
|
67
|
-
const angleOffset =
|
|
68
|
-
options.spread >= 360
|
|
69
|
-
? randomInRange(0, Math.PI * 2)
|
|
70
|
-
: randomInRange(-spreadRad / 2, spreadRad / 2);
|
|
71
|
-
const theta = baseAngleRad + angleOffset;
|
|
72
|
-
let particle = createBaseParticle(
|
|
73
|
-
options,
|
|
74
|
-
theta,
|
|
75
|
-
resolveSpawnSpeed(options),
|
|
76
|
-
resolveReachLimit(options),
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
if (options.createParticle) {
|
|
80
|
-
const overrides = options.createParticle(i, particle);
|
|
81
|
-
particle = {
|
|
82
|
-
...particle,
|
|
83
|
-
...overrides,
|
|
84
|
-
spawnX: particle.spawnX,
|
|
85
|
-
spawnY: particle.spawnY,
|
|
86
|
-
burstRadius: particle.burstRadius,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
particles.push(particle);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return particles;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Advances one piece by a single simulation step. */
|
|
97
|
-
export function integrateParticle(particle: Particle, dt: number, config: PhysicsConfig): void {
|
|
98
|
-
particle.vy += config.gravity * dt;
|
|
99
|
-
|
|
100
|
-
const dragged = applyDrag2D(particle.vx, particle.vy, config.drag, dt);
|
|
101
|
-
particle.vx = dragged.vx;
|
|
102
|
-
particle.vy = dragged.vy;
|
|
103
|
-
|
|
104
|
-
particle.x += particle.vx * dt;
|
|
105
|
-
particle.y += particle.vy * dt;
|
|
106
|
-
|
|
107
|
-
particle.rotation += particle.rotationSpeed * dt;
|
|
108
|
-
particle.rotationSpeed *= Math.exp(-config.rotationDrag * dt);
|
|
109
|
-
|
|
110
|
-
particle.wobblePhase += particle.wobbleSpeed * dt;
|
|
111
|
-
particle.wobbleSpeed *= Math.exp(-config.wobbleDrag * dt);
|
|
112
|
-
|
|
113
|
-
if (particle.ticksMax > 0) {
|
|
114
|
-
const lifeRatio = Math.max(0, particle.ticks / particle.ticksMax);
|
|
115
|
-
particle.opacity = lifeRatio * (1 - config.decay);
|
|
116
|
-
} else if (config.decay > 0) {
|
|
117
|
-
particle.opacity = Math.max(0, particle.opacity - config.decay * dt * 60);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
particle.ticks -= 1;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Returns true when a piece should be removed from the simulation. */
|
|
124
|
-
export function isParticleDead(particle: Particle, viewport: Viewport, originY: number): boolean {
|
|
125
|
-
if (particle.ticks <= 0) return true;
|
|
126
|
-
if (particle.opacity <= 0) return true;
|
|
127
|
-
if (particle.burstRadius > 0) {
|
|
128
|
-
const dx = particle.x - particle.spawnX;
|
|
129
|
-
const dy = particle.y - particle.spawnY;
|
|
130
|
-
if (dx * dx + dy * dy > particle.burstRadius * particle.burstRadius) {
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
if (particle.y > viewport.height + CULL_MARGIN) return true;
|
|
135
|
-
if (particle.x < -CULL_MARGIN || particle.x > viewport.width + CULL_MARGIN) return true;
|
|
136
|
-
|
|
137
|
-
const speed = Math.abs(particle.vx) + Math.abs(particle.vy);
|
|
138
|
-
return speed < SETTLE_VELOCITY_THRESHOLD && particle.y > originY + SETTLE_Y_OFFSET;
|
|
139
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
COIN_ROTATION_FACTOR,
|
|
3
|
+
COIN_SIZE,
|
|
4
|
+
CONFETTI_HEIGHT_RANGE,
|
|
5
|
+
CONFETTI_WIDTH_RANGE,
|
|
6
|
+
CULL_MARGIN,
|
|
7
|
+
SETTLE_VELOCITY_THRESHOLD,
|
|
8
|
+
SETTLE_Y_OFFSET,
|
|
9
|
+
} from "../constants";
|
|
10
|
+
import type { Particle, PhysicsConfig, ResolvedConfettiOptions, Viewport } from "../types";
|
|
11
|
+
import { degreesToRadians, pickRandom, randomInRange } from "../utils/math";
|
|
12
|
+
import { applyDrag2D } from "./drag";
|
|
13
|
+
|
|
14
|
+
/** Builds one piece with randomized size, color, and initial velocity. */
|
|
15
|
+
function createBaseParticle(
|
|
16
|
+
options: ResolvedConfettiOptions,
|
|
17
|
+
theta: number,
|
|
18
|
+
speed: number,
|
|
19
|
+
reachLimit: number,
|
|
20
|
+
): Particle {
|
|
21
|
+
const isCoin = options.appearance === "coin";
|
|
22
|
+
const coinSize = COIN_SIZE * options.scalar;
|
|
23
|
+
const ticksMax = options.ticks > 0 ? options.ticks : 0;
|
|
24
|
+
const rotationFactor = isCoin ? COIN_ROTATION_FACTOR : 1;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
x: options.origin.x,
|
|
28
|
+
y: options.origin.y,
|
|
29
|
+
vx: Math.cos(theta) * speed,
|
|
30
|
+
vy: Math.sin(theta) * speed,
|
|
31
|
+
width: isCoin ? coinSize : randomInRange(...CONFETTI_WIDTH_RANGE) * options.scalar,
|
|
32
|
+
height: isCoin ? coinSize : randomInRange(...CONFETTI_HEIGHT_RANGE) * options.scalar,
|
|
33
|
+
color: pickRandom(options.colors),
|
|
34
|
+
shape: pickRandom(options.shapes),
|
|
35
|
+
rotation: isCoin ? 0 : randomInRange(0, Math.PI * 2),
|
|
36
|
+
rotationSpeed: randomInRange(-Math.PI, Math.PI) * options.rotationSpeed * rotationFactor,
|
|
37
|
+
wobblePhase: randomInRange(0, Math.PI * 2),
|
|
38
|
+
wobbleSpeed: randomInRange(2, 6) * options.wobbleSpeed,
|
|
39
|
+
opacity: 1,
|
|
40
|
+
ticks: ticksMax > 0 ? ticksMax : Number.MAX_SAFE_INTEGER,
|
|
41
|
+
ticksMax,
|
|
42
|
+
spawnX: options.origin.x,
|
|
43
|
+
spawnY: options.origin.y,
|
|
44
|
+
burstRadius: reachLimit,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Per-piece speed spread — slower pieces stay nearer the origin when reach is limited. */
|
|
49
|
+
function resolveSpawnSpeed(options: ResolvedConfettiOptions): number {
|
|
50
|
+
const minFactor = options.burstRadius > 0 ? 0.15 : 0.55;
|
|
51
|
+
return randomInRange(options.startVelocity * minFactor, options.startVelocity);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Slightly varies reach per piece so the boundary is not a perfect ring. */
|
|
55
|
+
function resolveReachLimit(options: ResolvedConfettiOptions): number {
|
|
56
|
+
if (options.burstRadius <= 0) return 0;
|
|
57
|
+
return options.burstRadius * randomInRange(0.82, 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Creates all pieces for a burst from resolved options. */
|
|
61
|
+
export function spawnParticles(options: ResolvedConfettiOptions): Particle[] {
|
|
62
|
+
const particles: Particle[] = [];
|
|
63
|
+
const baseAngleRad = degreesToRadians(options.angle);
|
|
64
|
+
const spreadRad = degreesToRadians(Math.min(options.spread, 360));
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < options.particleCount; i++) {
|
|
67
|
+
const angleOffset =
|
|
68
|
+
options.spread >= 360
|
|
69
|
+
? randomInRange(0, Math.PI * 2)
|
|
70
|
+
: randomInRange(-spreadRad / 2, spreadRad / 2);
|
|
71
|
+
const theta = baseAngleRad + angleOffset;
|
|
72
|
+
let particle = createBaseParticle(
|
|
73
|
+
options,
|
|
74
|
+
theta,
|
|
75
|
+
resolveSpawnSpeed(options),
|
|
76
|
+
resolveReachLimit(options),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (options.createParticle) {
|
|
80
|
+
const overrides = options.createParticle(i, particle);
|
|
81
|
+
particle = {
|
|
82
|
+
...particle,
|
|
83
|
+
...overrides,
|
|
84
|
+
spawnX: particle.spawnX,
|
|
85
|
+
spawnY: particle.spawnY,
|
|
86
|
+
burstRadius: particle.burstRadius,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
particles.push(particle);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return particles;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Advances one piece by a single simulation step. */
|
|
97
|
+
export function integrateParticle(particle: Particle, dt: number, config: PhysicsConfig): void {
|
|
98
|
+
particle.vy += config.gravity * dt;
|
|
99
|
+
|
|
100
|
+
const dragged = applyDrag2D(particle.vx, particle.vy, config.drag, dt);
|
|
101
|
+
particle.vx = dragged.vx;
|
|
102
|
+
particle.vy = dragged.vy;
|
|
103
|
+
|
|
104
|
+
particle.x += particle.vx * dt;
|
|
105
|
+
particle.y += particle.vy * dt;
|
|
106
|
+
|
|
107
|
+
particle.rotation += particle.rotationSpeed * dt;
|
|
108
|
+
particle.rotationSpeed *= Math.exp(-config.rotationDrag * dt);
|
|
109
|
+
|
|
110
|
+
particle.wobblePhase += particle.wobbleSpeed * dt;
|
|
111
|
+
particle.wobbleSpeed *= Math.exp(-config.wobbleDrag * dt);
|
|
112
|
+
|
|
113
|
+
if (particle.ticksMax > 0) {
|
|
114
|
+
const lifeRatio = Math.max(0, particle.ticks / particle.ticksMax);
|
|
115
|
+
particle.opacity = lifeRatio * (1 - config.decay);
|
|
116
|
+
} else if (config.decay > 0) {
|
|
117
|
+
particle.opacity = Math.max(0, particle.opacity - config.decay * dt * 60);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
particle.ticks -= 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Returns true when a piece should be removed from the simulation. */
|
|
124
|
+
export function isParticleDead(particle: Particle, viewport: Viewport, originY: number): boolean {
|
|
125
|
+
if (particle.ticks <= 0) return true;
|
|
126
|
+
if (particle.opacity <= 0) return true;
|
|
127
|
+
if (particle.burstRadius > 0) {
|
|
128
|
+
const dx = particle.x - particle.spawnX;
|
|
129
|
+
const dy = particle.y - particle.spawnY;
|
|
130
|
+
if (dx * dx + dy * dy > particle.burstRadius * particle.burstRadius) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (particle.y > viewport.height + CULL_MARGIN) return true;
|
|
135
|
+
if (particle.x < -CULL_MARGIN || particle.x > viewport.width + CULL_MARGIN) return true;
|
|
136
|
+
|
|
137
|
+
const speed = Math.abs(particle.vx) + Math.abs(particle.vy);
|
|
138
|
+
return speed < SETTLE_VELOCITY_THRESHOLD && particle.y > originY + SETTLE_Y_OFFSET;
|
|
139
|
+
}
|
package/src/physics/wobble.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { MIN_FLATNESS } from "../constants";
|
|
2
|
-
|
|
3
|
-
/** Returns the horizontal scale for 3D wobble on a rotating piece. */
|
|
4
|
-
export function computeWobbleScale(phase: number, minFlatness: number = MIN_FLATNESS): number {
|
|
5
|
-
return Math.max(minFlatness, Math.abs(Math.cos(phase)));
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/** Returns the skew applied to simulate depth during wobble. */
|
|
9
|
-
export function computeSkewX(phase: number, skewFactor: number): number {
|
|
10
|
-
return Math.sin(phase) * skewFactor;
|
|
11
|
-
}
|
|
1
|
+
import { MIN_FLATNESS } from "../constants";
|
|
2
|
+
|
|
3
|
+
/** Returns the horizontal scale for 3D wobble on a rotating piece. */
|
|
4
|
+
export function computeWobbleScale(phase: number, minFlatness: number = MIN_FLATNESS): number {
|
|
5
|
+
return Math.max(minFlatness, Math.abs(Math.cos(phase)));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Returns the skew applied to simulate depth during wobble. */
|
|
9
|
+
export function computeSkewX(phase: number, skewFactor: number): number {
|
|
10
|
+
return Math.sin(phase) * skewFactor;
|
|
11
|
+
}
|
package/src/sequence.ts
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import type { ConfettiOptions, ConfettiSequenceHandle, ConfettiSequenceStep } from "./types";
|
|
2
|
-
|
|
3
|
-
type BurstFn = (options?: ConfettiOptions) => unknown;
|
|
4
|
-
|
|
5
|
-
/** Builds a sequence runner that schedules bursts through the given trigger function. */
|
|
6
|
-
export function createSequenceRunner(
|
|
7
|
-
burst: BurstFn,
|
|
8
|
-
): (steps: readonly ConfettiSequenceStep[]) => ConfettiSequenceHandle {
|
|
9
|
-
return (steps) => {
|
|
10
|
-
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
11
|
-
let cancelled = false;
|
|
12
|
-
|
|
13
|
-
const promise = new Promise<void>((resolve) => {
|
|
14
|
-
if (steps.length === 0) {
|
|
15
|
-
resolve();
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
18
|
-
let completed = 0;
|
|
19
|
-
for (const step of steps) {
|
|
20
|
-
timers.push(
|
|
21
|
-
setTimeout(() => {
|
|
22
|
-
if (cancelled) return;
|
|
23
|
-
burst(step.options);
|
|
24
|
-
completed += 1;
|
|
25
|
-
if (completed >= steps.length) resolve();
|
|
26
|
-
}, step.delay),
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
cancel: () => {
|
|
33
|
-
cancelled = true;
|
|
34
|
-
for (const timer of timers) clearTimeout(timer);
|
|
35
|
-
},
|
|
36
|
-
promise,
|
|
37
|
-
};
|
|
38
|
-
};
|
|
39
|
-
}
|
|
1
|
+
import type { ConfettiOptions, ConfettiSequenceHandle, ConfettiSequenceStep } from "./types";
|
|
2
|
+
|
|
3
|
+
type BurstFn = (options?: ConfettiOptions) => unknown;
|
|
4
|
+
|
|
5
|
+
/** Builds a sequence runner that schedules bursts through the given trigger function. */
|
|
6
|
+
export function createSequenceRunner(
|
|
7
|
+
burst: BurstFn,
|
|
8
|
+
): (steps: readonly ConfettiSequenceStep[]) => ConfettiSequenceHandle {
|
|
9
|
+
return (steps) => {
|
|
10
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
11
|
+
let cancelled = false;
|
|
12
|
+
|
|
13
|
+
const promise = new Promise<void>((resolve) => {
|
|
14
|
+
if (steps.length === 0) {
|
|
15
|
+
resolve();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let completed = 0;
|
|
19
|
+
for (const step of steps) {
|
|
20
|
+
timers.push(
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
if (cancelled) return;
|
|
23
|
+
burst(step.options);
|
|
24
|
+
completed += 1;
|
|
25
|
+
if (completed >= steps.length) resolve();
|
|
26
|
+
}, step.delay),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
cancel: () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
for (const timer of timers) clearTimeout(timer);
|
|
35
|
+
},
|
|
36
|
+
promise,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|