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/engine.ts
CHANGED
|
@@ -1,354 +1,354 @@
|
|
|
1
|
-
import { MAX_COIN_PIECES, MAX_CONFETTI_PIECES, SIMULATION_TIME_SCALE } from "./constants";
|
|
2
|
-
import { retargetCollectPiece, sampleCollectPiece, spawnCollectPieces } from "./physics/collect";
|
|
3
|
-
import { integrateParticle, isParticleDead, spawnParticles } from "./physics/spawn";
|
|
4
|
-
import { computeSkewX, computeWobbleScale } from "./physics/wobble";
|
|
5
|
-
import type {
|
|
6
|
-
CoinSource,
|
|
7
|
-
CoinType,
|
|
8
|
-
CollectOptions,
|
|
9
|
-
CollectPiece,
|
|
10
|
-
ConfettiOptions,
|
|
11
|
-
ConfettiOrigin,
|
|
12
|
-
Particle,
|
|
13
|
-
ParticleAppearance,
|
|
14
|
-
ParticleShape,
|
|
15
|
-
PhysicsConfig,
|
|
16
|
-
RenderCoinFn,
|
|
17
|
-
ResolvedAppearance,
|
|
18
|
-
Viewport,
|
|
19
|
-
} from "./types";
|
|
20
|
-
import { parseCollectOptions, parseConfettiOptions, warnIfScreenCapped } from "./utils/validation";
|
|
21
|
-
|
|
22
|
-
/** Static look of one mounted piece; fixed for its lifetime. */
|
|
23
|
-
export interface SlotLook {
|
|
24
|
-
readonly appearance: ParticleAppearance;
|
|
25
|
-
readonly coinType: CoinType;
|
|
26
|
-
readonly coinSource?: CoinSource;
|
|
27
|
-
readonly renderCoin?: RenderCoinFn;
|
|
28
|
-
readonly color: string;
|
|
29
|
-
readonly shape: ParticleShape;
|
|
30
|
-
readonly width: number;
|
|
31
|
-
readonly height: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Per-frame transform for one piece, centered on (x, y). */
|
|
35
|
-
export interface SlotFrame {
|
|
36
|
-
readonly x: number;
|
|
37
|
-
readonly y: number;
|
|
38
|
-
readonly rotation: number;
|
|
39
|
-
readonly scale: number;
|
|
40
|
-
readonly scaleX: number;
|
|
41
|
-
readonly skewX: number;
|
|
42
|
-
readonly opacity: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Pieces spawned by one call; settles its promise when all are gone. */
|
|
46
|
-
export interface Batch {
|
|
47
|
-
total: number;
|
|
48
|
-
remaining: number;
|
|
49
|
-
readonly resolve: () => void;
|
|
50
|
-
readonly onArrive?: (index: number, total: number) => void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface BaseSlot {
|
|
54
|
-
readonly id: number;
|
|
55
|
-
/** Index within the burst, passed to `renderCoin`. */
|
|
56
|
-
readonly index: number;
|
|
57
|
-
readonly look: SlotLook;
|
|
58
|
-
readonly batch: Batch;
|
|
59
|
-
alive: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface BurstSlot extends BaseSlot {
|
|
63
|
-
readonly kind: "burst";
|
|
64
|
-
readonly particle: Particle;
|
|
65
|
-
readonly physics: PhysicsConfig;
|
|
66
|
-
readonly originY: number;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
interface CollectSlot extends BaseSlot {
|
|
70
|
-
readonly kind: "collect";
|
|
71
|
-
readonly piece: CollectPiece;
|
|
72
|
-
readonly startedAtMs: number;
|
|
73
|
-
readonly trackTarget?: () => ConfettiOrigin | null;
|
|
74
|
-
frame: SlotFrame;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export type Slot = BurstSlot | CollectSlot;
|
|
78
|
-
|
|
79
|
-
type Request = () => void;
|
|
80
|
-
|
|
81
|
-
/** Where the host sits on screen, in window coordinates. */
|
|
82
|
-
export interface HostBounds {
|
|
83
|
-
readonly x: number;
|
|
84
|
-
readonly y: number;
|
|
85
|
-
readonly width: number;
|
|
86
|
-
readonly height: number;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const HIDDEN_FRAME: SlotFrame = { x: 0, y: 0, rotation: 0, scale: 1, scaleX: 1, skewX: 0, opacity: 0 };
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Framework-free store behind `confetti()`. The `ParticleHost` attaches to it,
|
|
93
|
-
* reports size and reduced motion, steps it every frame, and draws its slots.
|
|
94
|
-
*/
|
|
95
|
-
export class ParticleEngine {
|
|
96
|
-
private slots: Slot[] = [];
|
|
97
|
-
private nextId = 1;
|
|
98
|
-
private clockMs = 0;
|
|
99
|
-
/** Cull bounds and default origin, both in window coordinates. */
|
|
100
|
-
private viewport: Viewport = { width: 0, height: 0 };
|
|
101
|
-
private center: ConfettiOrigin = { x: 0, y: 0 };
|
|
102
|
-
private reducedMotion = false;
|
|
103
|
-
private attached = false;
|
|
104
|
-
private pending: Request[] = [];
|
|
105
|
-
private onChange: (() => void) | null = null;
|
|
106
|
-
|
|
107
|
-
/** Called by the host on mount. Flushes calls made before it existed. */
|
|
108
|
-
public attach(bounds: HostBounds, onChange: () => void): void {
|
|
109
|
-
this.attached = true;
|
|
110
|
-
this.setBounds(bounds);
|
|
111
|
-
this.onChange = onChange;
|
|
112
|
-
const queued = this.pending;
|
|
113
|
-
this.pending = [];
|
|
114
|
-
for (const request of queued) request();
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Called by the host on unmount. Clears everything on screen. */
|
|
118
|
-
public detach(): void {
|
|
119
|
-
this.reset();
|
|
120
|
-
this.attached = false;
|
|
121
|
-
this.onChange = null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Host rectangle in window coordinates. */
|
|
125
|
-
public setBounds(bounds: HostBounds): void {
|
|
126
|
-
this.viewport = { width: bounds.x + bounds.width, height: bounds.y + bounds.height };
|
|
127
|
-
this.center = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
public setReducedMotion(reduced: boolean): void {
|
|
131
|
-
this.reducedMotion = reduced;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
public get isAttached(): boolean {
|
|
135
|
-
return this.attached;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Mounted slots, including dead ones awaiting cleanup. */
|
|
139
|
-
public getSlots(): readonly Slot[] {
|
|
140
|
-
return this.slots;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
public get activeCount(): number {
|
|
144
|
-
let count = 0;
|
|
145
|
-
for (const slot of this.slots) if (slot.alive) count += 1;
|
|
146
|
-
return count;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Queues or spawns a physics burst. */
|
|
150
|
-
public burst(options: ConfettiOptions | undefined, batch: Batch): void {
|
|
151
|
-
this.run(() => {
|
|
152
|
-
const resolved = parseConfettiOptions(options, this.center);
|
|
153
|
-
if (this.skipForMotion(resolved)) return this.settle(batch, 0);
|
|
154
|
-
const particles = spawnParticles(resolved).slice(0, this.capacity(resolved.appearance));
|
|
155
|
-
warnIfScreenCapped(resolved.appearance, resolved.particleCount, particles.length);
|
|
156
|
-
this.settle(batch, particles.length);
|
|
157
|
-
this.compact();
|
|
158
|
-
particles.forEach((particle, index) => {
|
|
159
|
-
this.slots.push({
|
|
160
|
-
kind: "burst",
|
|
161
|
-
id: this.nextId++,
|
|
162
|
-
index,
|
|
163
|
-
alive: true,
|
|
164
|
-
batch,
|
|
165
|
-
particle,
|
|
166
|
-
physics: resolved.physics,
|
|
167
|
-
originY: resolved.origin.y,
|
|
168
|
-
look: lookOf(resolved, particle.color, particle.shape, particle.width, particle.height),
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
this.notify();
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Queues or spawns a collect flight. Throws synchronously on a bad target. */
|
|
176
|
-
public collect(options: CollectOptions, batch: Batch): void {
|
|
177
|
-
parseCollectOptions(options, this.center);
|
|
178
|
-
this.run(() => {
|
|
179
|
-
const resolved = parseCollectOptions(options, this.center);
|
|
180
|
-
if (this.skipForMotion(resolved)) return this.settle(batch, 0);
|
|
181
|
-
const pieces = spawnCollectPieces(resolved).slice(0, this.capacity(resolved.appearance));
|
|
182
|
-
warnIfScreenCapped(resolved.appearance, resolved.particleCount, pieces.length);
|
|
183
|
-
this.settle(batch, pieces.length);
|
|
184
|
-
this.compact();
|
|
185
|
-
pieces.forEach((piece, index) => {
|
|
186
|
-
this.slots.push({
|
|
187
|
-
kind: "collect",
|
|
188
|
-
id: this.nextId++,
|
|
189
|
-
alive: true,
|
|
190
|
-
batch,
|
|
191
|
-
piece,
|
|
192
|
-
index,
|
|
193
|
-
startedAtMs: this.clockMs,
|
|
194
|
-
...(options.trackTarget ? { trackTarget: options.trackTarget } : {}),
|
|
195
|
-
frame: HIDDEN_FRAME,
|
|
196
|
-
look: lookOf(resolved, piece.color, piece.shape, piece.size, piece.size),
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
this.notify();
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Advances every live piece by `dtSeconds` of real time. Returns true while any remain. */
|
|
204
|
-
public step(dtSeconds: number): boolean {
|
|
205
|
-
this.clockMs += dtSeconds * 1000;
|
|
206
|
-
const simDt = dtSeconds * SIMULATION_TIME_SCALE;
|
|
207
|
-
let anyAlive = false;
|
|
208
|
-
|
|
209
|
-
for (const slot of this.slots) {
|
|
210
|
-
if (!slot.alive) continue;
|
|
211
|
-
if (slot.kind === "burst") {
|
|
212
|
-
integrateParticle(slot.particle, simDt, slot.physics);
|
|
213
|
-
if (isParticleDead(slot.particle, this.viewport, slot.originY)) this.kill(slot);
|
|
214
|
-
} else {
|
|
215
|
-
const nextTarget = slot.trackTarget?.();
|
|
216
|
-
if (nextTarget !== null && nextTarget !== undefined && Number.isFinite(nextTarget.x) && Number.isFinite(nextTarget.y)) {
|
|
217
|
-
retargetCollectPiece(slot.piece, nextTarget);
|
|
218
|
-
}
|
|
219
|
-
const sample = sampleCollectPiece(slot.piece, this.clockMs - slot.startedAtMs);
|
|
220
|
-
slot.frame = {
|
|
221
|
-
x: sample.x,
|
|
222
|
-
y: sample.y,
|
|
223
|
-
rotation: 0,
|
|
224
|
-
scale: sample.scale,
|
|
225
|
-
scaleX: sample.scaleX,
|
|
226
|
-
skewX: 0,
|
|
227
|
-
opacity: sample.opacity,
|
|
228
|
-
};
|
|
229
|
-
if (sample.done) {
|
|
230
|
-
slot.batch.onArrive?.(slot.index, slot.batch.total);
|
|
231
|
-
this.kill(slot);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (slot.alive) anyAlive = true;
|
|
235
|
-
}
|
|
236
|
-
return anyAlive;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Drops every slot once nothing is alive, so the host can unmount its views. */
|
|
240
|
-
public clearIfIdle(): boolean {
|
|
241
|
-
if (this.slots.length === 0 || this.activeCount > 0) return false;
|
|
242
|
-
this.slots = [];
|
|
243
|
-
this.notify();
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** Stops everything immediately and settles all pending promises. */
|
|
248
|
-
public reset(): void {
|
|
249
|
-
const batches = new Set<Batch>();
|
|
250
|
-
for (const slot of this.slots) if (slot.alive) batches.add(slot.batch);
|
|
251
|
-
this.slots = [];
|
|
252
|
-
for (const batch of batches) {
|
|
253
|
-
batch.remaining = 0;
|
|
254
|
-
batch.resolve();
|
|
255
|
-
}
|
|
256
|
-
this.notify();
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
private run(request: Request): void {
|
|
260
|
-
if (this.attached) request();
|
|
261
|
-
else this.pending.push(request);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
private skipForMotion(look: ResolvedAppearance): boolean {
|
|
265
|
-
return this.reducedMotion && look.disableForReducedMotion;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Free views under the safety ceiling for this look. */
|
|
269
|
-
private capacity(appearance: ParticleAppearance): number {
|
|
270
|
-
const max = appearance === "coin" ? MAX_COIN_PIECES : MAX_CONFETTI_PIECES;
|
|
271
|
-
let used = 0;
|
|
272
|
-
for (const slot of this.slots) if (slot.alive && slot.look.appearance === appearance) used += 1;
|
|
273
|
-
return Math.max(0, max - used);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
private settle(batch: Batch, count: number): void {
|
|
277
|
-
batch.total = count;
|
|
278
|
-
batch.remaining = count;
|
|
279
|
-
if (count === 0) batch.resolve();
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
private kill(slot: Slot): void {
|
|
283
|
-
slot.alive = false;
|
|
284
|
-
slot.batch.remaining -= 1;
|
|
285
|
-
if (slot.batch.remaining === 0) slot.batch.resolve();
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
private compact(): void {
|
|
289
|
-
this.slots = this.slots.filter((slot) => slot.alive);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
private notify(): void {
|
|
293
|
-
this.onChange?.();
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function lookOf(
|
|
298
|
-
look: ResolvedAppearance,
|
|
299
|
-
color: string,
|
|
300
|
-
shape: ParticleShape,
|
|
301
|
-
width: number,
|
|
302
|
-
height: number,
|
|
303
|
-
): SlotLook {
|
|
304
|
-
return {
|
|
305
|
-
appearance: look.appearance,
|
|
306
|
-
coinType: look.coinType,
|
|
307
|
-
...(look.coinSource !== undefined ? { coinSource: look.coinSource } : {}),
|
|
308
|
-
...(look.renderCoin !== undefined ? { renderCoin: look.renderCoin } : {}),
|
|
309
|
-
color,
|
|
310
|
-
shape,
|
|
311
|
-
width,
|
|
312
|
-
height,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** Transform to draw a slot this frame. Dead slots are hidden. */
|
|
317
|
-
export function frameOf(slot: Slot): SlotFrame {
|
|
318
|
-
if (!slot.alive) return HIDDEN_FRAME;
|
|
319
|
-
if (slot.kind === "collect") return slot.frame;
|
|
320
|
-
const { particle, physics } = slot;
|
|
321
|
-
const isCoin = slot.look.appearance === "coin";
|
|
322
|
-
return {
|
|
323
|
-
x: particle.x,
|
|
324
|
-
y: particle.y,
|
|
325
|
-
rotation: particle.rotation,
|
|
326
|
-
scale: 1,
|
|
327
|
-
scaleX: computeWobbleScale(particle.wobblePhase, physics.minFlatness),
|
|
328
|
-
skewX: isCoin ? 0 : computeSkewX(particle.wobblePhase, physics.skewFactor),
|
|
329
|
-
opacity: particle.opacity,
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Creates a batch with its promise. */
|
|
334
|
-
export function createBatch(onArrive?: (index: number, total: number) => void): {
|
|
335
|
-
batch: Batch;
|
|
336
|
-
promise: Promise<void>;
|
|
337
|
-
} {
|
|
338
|
-
let resolve: () => void = () => undefined;
|
|
339
|
-
const promise = new Promise<void>((done) => {
|
|
340
|
-
resolve = done;
|
|
341
|
-
});
|
|
342
|
-
let settled = false;
|
|
343
|
-
const batch: Batch = {
|
|
344
|
-
total: 0,
|
|
345
|
-
remaining: -1,
|
|
346
|
-
resolve: () => {
|
|
347
|
-
if (settled) return;
|
|
348
|
-
settled = true;
|
|
349
|
-
resolve();
|
|
350
|
-
},
|
|
351
|
-
...(onArrive ? { onArrive } : {}),
|
|
352
|
-
};
|
|
353
|
-
return { batch, promise };
|
|
354
|
-
}
|
|
1
|
+
import { MAX_COIN_PIECES, MAX_CONFETTI_PIECES, SIMULATION_TIME_SCALE } from "./constants";
|
|
2
|
+
import { retargetCollectPiece, sampleCollectPiece, spawnCollectPieces } from "./physics/collect";
|
|
3
|
+
import { integrateParticle, isParticleDead, spawnParticles } from "./physics/spawn";
|
|
4
|
+
import { computeSkewX, computeWobbleScale } from "./physics/wobble";
|
|
5
|
+
import type {
|
|
6
|
+
CoinSource,
|
|
7
|
+
CoinType,
|
|
8
|
+
CollectOptions,
|
|
9
|
+
CollectPiece,
|
|
10
|
+
ConfettiOptions,
|
|
11
|
+
ConfettiOrigin,
|
|
12
|
+
Particle,
|
|
13
|
+
ParticleAppearance,
|
|
14
|
+
ParticleShape,
|
|
15
|
+
PhysicsConfig,
|
|
16
|
+
RenderCoinFn,
|
|
17
|
+
ResolvedAppearance,
|
|
18
|
+
Viewport,
|
|
19
|
+
} from "./types";
|
|
20
|
+
import { parseCollectOptions, parseConfettiOptions, warnIfScreenCapped } from "./utils/validation";
|
|
21
|
+
|
|
22
|
+
/** Static look of one mounted piece; fixed for its lifetime. */
|
|
23
|
+
export interface SlotLook {
|
|
24
|
+
readonly appearance: ParticleAppearance;
|
|
25
|
+
readonly coinType: CoinType;
|
|
26
|
+
readonly coinSource?: CoinSource;
|
|
27
|
+
readonly renderCoin?: RenderCoinFn;
|
|
28
|
+
readonly color: string;
|
|
29
|
+
readonly shape: ParticleShape;
|
|
30
|
+
readonly width: number;
|
|
31
|
+
readonly height: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Per-frame transform for one piece, centered on (x, y). */
|
|
35
|
+
export interface SlotFrame {
|
|
36
|
+
readonly x: number;
|
|
37
|
+
readonly y: number;
|
|
38
|
+
readonly rotation: number;
|
|
39
|
+
readonly scale: number;
|
|
40
|
+
readonly scaleX: number;
|
|
41
|
+
readonly skewX: number;
|
|
42
|
+
readonly opacity: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pieces spawned by one call; settles its promise when all are gone. */
|
|
46
|
+
export interface Batch {
|
|
47
|
+
total: number;
|
|
48
|
+
remaining: number;
|
|
49
|
+
readonly resolve: () => void;
|
|
50
|
+
readonly onArrive?: (index: number, total: number) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface BaseSlot {
|
|
54
|
+
readonly id: number;
|
|
55
|
+
/** Index within the burst, passed to `renderCoin`. */
|
|
56
|
+
readonly index: number;
|
|
57
|
+
readonly look: SlotLook;
|
|
58
|
+
readonly batch: Batch;
|
|
59
|
+
alive: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface BurstSlot extends BaseSlot {
|
|
63
|
+
readonly kind: "burst";
|
|
64
|
+
readonly particle: Particle;
|
|
65
|
+
readonly physics: PhysicsConfig;
|
|
66
|
+
readonly originY: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface CollectSlot extends BaseSlot {
|
|
70
|
+
readonly kind: "collect";
|
|
71
|
+
readonly piece: CollectPiece;
|
|
72
|
+
readonly startedAtMs: number;
|
|
73
|
+
readonly trackTarget?: () => ConfettiOrigin | null;
|
|
74
|
+
frame: SlotFrame;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type Slot = BurstSlot | CollectSlot;
|
|
78
|
+
|
|
79
|
+
type Request = () => void;
|
|
80
|
+
|
|
81
|
+
/** Where the host sits on screen, in window coordinates. */
|
|
82
|
+
export interface HostBounds {
|
|
83
|
+
readonly x: number;
|
|
84
|
+
readonly y: number;
|
|
85
|
+
readonly width: number;
|
|
86
|
+
readonly height: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const HIDDEN_FRAME: SlotFrame = { x: 0, y: 0, rotation: 0, scale: 1, scaleX: 1, skewX: 0, opacity: 0 };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Framework-free store behind `confetti()`. The `ParticleHost` attaches to it,
|
|
93
|
+
* reports size and reduced motion, steps it every frame, and draws its slots.
|
|
94
|
+
*/
|
|
95
|
+
export class ParticleEngine {
|
|
96
|
+
private slots: Slot[] = [];
|
|
97
|
+
private nextId = 1;
|
|
98
|
+
private clockMs = 0;
|
|
99
|
+
/** Cull bounds and default origin, both in window coordinates. */
|
|
100
|
+
private viewport: Viewport = { width: 0, height: 0 };
|
|
101
|
+
private center: ConfettiOrigin = { x: 0, y: 0 };
|
|
102
|
+
private reducedMotion = false;
|
|
103
|
+
private attached = false;
|
|
104
|
+
private pending: Request[] = [];
|
|
105
|
+
private onChange: (() => void) | null = null;
|
|
106
|
+
|
|
107
|
+
/** Called by the host on mount. Flushes calls made before it existed. */
|
|
108
|
+
public attach(bounds: HostBounds, onChange: () => void): void {
|
|
109
|
+
this.attached = true;
|
|
110
|
+
this.setBounds(bounds);
|
|
111
|
+
this.onChange = onChange;
|
|
112
|
+
const queued = this.pending;
|
|
113
|
+
this.pending = [];
|
|
114
|
+
for (const request of queued) request();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Called by the host on unmount. Clears everything on screen. */
|
|
118
|
+
public detach(): void {
|
|
119
|
+
this.reset();
|
|
120
|
+
this.attached = false;
|
|
121
|
+
this.onChange = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Host rectangle in window coordinates. */
|
|
125
|
+
public setBounds(bounds: HostBounds): void {
|
|
126
|
+
this.viewport = { width: bounds.x + bounds.width, height: bounds.y + bounds.height };
|
|
127
|
+
this.center = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public setReducedMotion(reduced: boolean): void {
|
|
131
|
+
this.reducedMotion = reduced;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public get isAttached(): boolean {
|
|
135
|
+
return this.attached;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Mounted slots, including dead ones awaiting cleanup. */
|
|
139
|
+
public getSlots(): readonly Slot[] {
|
|
140
|
+
return this.slots;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public get activeCount(): number {
|
|
144
|
+
let count = 0;
|
|
145
|
+
for (const slot of this.slots) if (slot.alive) count += 1;
|
|
146
|
+
return count;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Queues or spawns a physics burst. */
|
|
150
|
+
public burst(options: ConfettiOptions | undefined, batch: Batch): void {
|
|
151
|
+
this.run(() => {
|
|
152
|
+
const resolved = parseConfettiOptions(options, this.center);
|
|
153
|
+
if (this.skipForMotion(resolved)) return this.settle(batch, 0);
|
|
154
|
+
const particles = spawnParticles(resolved).slice(0, this.capacity(resolved.appearance));
|
|
155
|
+
warnIfScreenCapped(resolved.appearance, resolved.particleCount, particles.length);
|
|
156
|
+
this.settle(batch, particles.length);
|
|
157
|
+
this.compact();
|
|
158
|
+
particles.forEach((particle, index) => {
|
|
159
|
+
this.slots.push({
|
|
160
|
+
kind: "burst",
|
|
161
|
+
id: this.nextId++,
|
|
162
|
+
index,
|
|
163
|
+
alive: true,
|
|
164
|
+
batch,
|
|
165
|
+
particle,
|
|
166
|
+
physics: resolved.physics,
|
|
167
|
+
originY: resolved.origin.y,
|
|
168
|
+
look: lookOf(resolved, particle.color, particle.shape, particle.width, particle.height),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
this.notify();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Queues or spawns a collect flight. Throws synchronously on a bad target. */
|
|
176
|
+
public collect(options: CollectOptions, batch: Batch): void {
|
|
177
|
+
parseCollectOptions(options, this.center);
|
|
178
|
+
this.run(() => {
|
|
179
|
+
const resolved = parseCollectOptions(options, this.center);
|
|
180
|
+
if (this.skipForMotion(resolved)) return this.settle(batch, 0);
|
|
181
|
+
const pieces = spawnCollectPieces(resolved).slice(0, this.capacity(resolved.appearance));
|
|
182
|
+
warnIfScreenCapped(resolved.appearance, resolved.particleCount, pieces.length);
|
|
183
|
+
this.settle(batch, pieces.length);
|
|
184
|
+
this.compact();
|
|
185
|
+
pieces.forEach((piece, index) => {
|
|
186
|
+
this.slots.push({
|
|
187
|
+
kind: "collect",
|
|
188
|
+
id: this.nextId++,
|
|
189
|
+
alive: true,
|
|
190
|
+
batch,
|
|
191
|
+
piece,
|
|
192
|
+
index,
|
|
193
|
+
startedAtMs: this.clockMs,
|
|
194
|
+
...(options.trackTarget ? { trackTarget: options.trackTarget } : {}),
|
|
195
|
+
frame: HIDDEN_FRAME,
|
|
196
|
+
look: lookOf(resolved, piece.color, piece.shape, piece.size, piece.size),
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
this.notify();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Advances every live piece by `dtSeconds` of real time. Returns true while any remain. */
|
|
204
|
+
public step(dtSeconds: number): boolean {
|
|
205
|
+
this.clockMs += dtSeconds * 1000;
|
|
206
|
+
const simDt = dtSeconds * SIMULATION_TIME_SCALE;
|
|
207
|
+
let anyAlive = false;
|
|
208
|
+
|
|
209
|
+
for (const slot of this.slots) {
|
|
210
|
+
if (!slot.alive) continue;
|
|
211
|
+
if (slot.kind === "burst") {
|
|
212
|
+
integrateParticle(slot.particle, simDt, slot.physics);
|
|
213
|
+
if (isParticleDead(slot.particle, this.viewport, slot.originY)) this.kill(slot);
|
|
214
|
+
} else {
|
|
215
|
+
const nextTarget = slot.trackTarget?.();
|
|
216
|
+
if (nextTarget !== null && nextTarget !== undefined && Number.isFinite(nextTarget.x) && Number.isFinite(nextTarget.y)) {
|
|
217
|
+
retargetCollectPiece(slot.piece, nextTarget);
|
|
218
|
+
}
|
|
219
|
+
const sample = sampleCollectPiece(slot.piece, this.clockMs - slot.startedAtMs);
|
|
220
|
+
slot.frame = {
|
|
221
|
+
x: sample.x,
|
|
222
|
+
y: sample.y,
|
|
223
|
+
rotation: 0,
|
|
224
|
+
scale: sample.scale,
|
|
225
|
+
scaleX: sample.scaleX,
|
|
226
|
+
skewX: 0,
|
|
227
|
+
opacity: sample.opacity,
|
|
228
|
+
};
|
|
229
|
+
if (sample.done) {
|
|
230
|
+
slot.batch.onArrive?.(slot.index, slot.batch.total);
|
|
231
|
+
this.kill(slot);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (slot.alive) anyAlive = true;
|
|
235
|
+
}
|
|
236
|
+
return anyAlive;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Drops every slot once nothing is alive, so the host can unmount its views. */
|
|
240
|
+
public clearIfIdle(): boolean {
|
|
241
|
+
if (this.slots.length === 0 || this.activeCount > 0) return false;
|
|
242
|
+
this.slots = [];
|
|
243
|
+
this.notify();
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Stops everything immediately and settles all pending promises. */
|
|
248
|
+
public reset(): void {
|
|
249
|
+
const batches = new Set<Batch>();
|
|
250
|
+
for (const slot of this.slots) if (slot.alive) batches.add(slot.batch);
|
|
251
|
+
this.slots = [];
|
|
252
|
+
for (const batch of batches) {
|
|
253
|
+
batch.remaining = 0;
|
|
254
|
+
batch.resolve();
|
|
255
|
+
}
|
|
256
|
+
this.notify();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private run(request: Request): void {
|
|
260
|
+
if (this.attached) request();
|
|
261
|
+
else this.pending.push(request);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private skipForMotion(look: ResolvedAppearance): boolean {
|
|
265
|
+
return this.reducedMotion && look.disableForReducedMotion;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Free views under the safety ceiling for this look. */
|
|
269
|
+
private capacity(appearance: ParticleAppearance): number {
|
|
270
|
+
const max = appearance === "coin" ? MAX_COIN_PIECES : MAX_CONFETTI_PIECES;
|
|
271
|
+
let used = 0;
|
|
272
|
+
for (const slot of this.slots) if (slot.alive && slot.look.appearance === appearance) used += 1;
|
|
273
|
+
return Math.max(0, max - used);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private settle(batch: Batch, count: number): void {
|
|
277
|
+
batch.total = count;
|
|
278
|
+
batch.remaining = count;
|
|
279
|
+
if (count === 0) batch.resolve();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private kill(slot: Slot): void {
|
|
283
|
+
slot.alive = false;
|
|
284
|
+
slot.batch.remaining -= 1;
|
|
285
|
+
if (slot.batch.remaining === 0) slot.batch.resolve();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private compact(): void {
|
|
289
|
+
this.slots = this.slots.filter((slot) => slot.alive);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private notify(): void {
|
|
293
|
+
this.onChange?.();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function lookOf(
|
|
298
|
+
look: ResolvedAppearance,
|
|
299
|
+
color: string,
|
|
300
|
+
shape: ParticleShape,
|
|
301
|
+
width: number,
|
|
302
|
+
height: number,
|
|
303
|
+
): SlotLook {
|
|
304
|
+
return {
|
|
305
|
+
appearance: look.appearance,
|
|
306
|
+
coinType: look.coinType,
|
|
307
|
+
...(look.coinSource !== undefined ? { coinSource: look.coinSource } : {}),
|
|
308
|
+
...(look.renderCoin !== undefined ? { renderCoin: look.renderCoin } : {}),
|
|
309
|
+
color,
|
|
310
|
+
shape,
|
|
311
|
+
width,
|
|
312
|
+
height,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Transform to draw a slot this frame. Dead slots are hidden. */
|
|
317
|
+
export function frameOf(slot: Slot): SlotFrame {
|
|
318
|
+
if (!slot.alive) return HIDDEN_FRAME;
|
|
319
|
+
if (slot.kind === "collect") return slot.frame;
|
|
320
|
+
const { particle, physics } = slot;
|
|
321
|
+
const isCoin = slot.look.appearance === "coin";
|
|
322
|
+
return {
|
|
323
|
+
x: particle.x,
|
|
324
|
+
y: particle.y,
|
|
325
|
+
rotation: particle.rotation,
|
|
326
|
+
scale: 1,
|
|
327
|
+
scaleX: computeWobbleScale(particle.wobblePhase, physics.minFlatness),
|
|
328
|
+
skewX: isCoin ? 0 : computeSkewX(particle.wobblePhase, physics.skewFactor),
|
|
329
|
+
opacity: particle.opacity,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Creates a batch with its promise. */
|
|
334
|
+
export function createBatch(onArrive?: (index: number, total: number) => void): {
|
|
335
|
+
batch: Batch;
|
|
336
|
+
promise: Promise<void>;
|
|
337
|
+
} {
|
|
338
|
+
let resolve: () => void = () => undefined;
|
|
339
|
+
const promise = new Promise<void>((done) => {
|
|
340
|
+
resolve = done;
|
|
341
|
+
});
|
|
342
|
+
let settled = false;
|
|
343
|
+
const batch: Batch = {
|
|
344
|
+
total: 0,
|
|
345
|
+
remaining: -1,
|
|
346
|
+
resolve: () => {
|
|
347
|
+
if (settled) return;
|
|
348
|
+
settled = true;
|
|
349
|
+
resolve();
|
|
350
|
+
},
|
|
351
|
+
...(onArrive ? { onArrive } : {}),
|
|
352
|
+
};
|
|
353
|
+
return { batch, promise };
|
|
354
|
+
}
|