@basmilius/sparkle 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.
@@ -0,0 +1,260 @@
1
+ export type ExplosionType = 'peony' | 'chrysanthemum' | 'willow' | 'ring' | 'palm' | 'crackle' | 'crossette' | 'dahlia' | 'brocade' | 'horsetail' | 'strobe' | 'heart' | 'spiral' | 'flower';
2
+
3
+ export type FireworkVariant = ExplosionType | 'saturn' | 'concentric';
4
+
5
+ export type ParticleShape = 'line' | 'circle' | 'star' | 'diamond';
6
+
7
+ export interface ExplosionConfig {
8
+ readonly particleCount: [number, number];
9
+ readonly speed: [number, number];
10
+ readonly friction: number;
11
+ readonly gravity: number;
12
+ readonly decay: [number, number];
13
+ readonly trailMemory: number;
14
+ readonly hueVariation: number;
15
+ readonly brightness: [number, number];
16
+ readonly lineWidthScale: number;
17
+ readonly shape: ParticleShape;
18
+ readonly sparkle: boolean;
19
+ readonly strobe: boolean;
20
+ readonly spread3d: boolean;
21
+ readonly glowSize: number;
22
+ }
23
+
24
+ export interface FireworkSimulationConfig {
25
+ readonly scale?: number;
26
+ readonly autoSpawn?: boolean;
27
+ readonly canvasOptions?: CanvasRenderingContext2DSettings;
28
+ }
29
+
30
+ export const FIREWORK_VARIANTS: FireworkVariant[] = [
31
+ 'peony', 'chrysanthemum', 'willow', 'ring', 'palm', 'crackle', 'crossette',
32
+ 'saturn', 'dahlia', 'brocade', 'horsetail', 'strobe', 'heart', 'spiral', 'flower', 'concentric'
33
+ ];
34
+
35
+ export const EXPLOSION_CONFIGS: Record<ExplosionType, ExplosionConfig> = {
36
+ peony: {
37
+ particleCount: [50, 70],
38
+ speed: [2, 10],
39
+ friction: 0.96,
40
+ gravity: 0.8,
41
+ decay: [0.012, 0.025],
42
+ trailMemory: 3,
43
+ hueVariation: 30,
44
+ brightness: [50, 80],
45
+ lineWidthScale: 0.8,
46
+ shape: 'circle',
47
+ sparkle: false,
48
+ strobe: false,
49
+ spread3d: true,
50
+ glowSize: 12
51
+ },
52
+ chrysanthemum: {
53
+ particleCount: [80, 120],
54
+ speed: [3, 12],
55
+ friction: 0.975,
56
+ gravity: 0.5,
57
+ decay: [0.006, 0.012],
58
+ trailMemory: 6,
59
+ hueVariation: 20,
60
+ brightness: [55, 85],
61
+ lineWidthScale: 0.5,
62
+ shape: 'line',
63
+ sparkle: true,
64
+ strobe: false,
65
+ spread3d: true,
66
+ glowSize: 15
67
+ },
68
+ willow: {
69
+ particleCount: [50, 70],
70
+ speed: [3, 10],
71
+ friction: 0.988,
72
+ gravity: 1.5,
73
+ decay: [0.004, 0.008],
74
+ trailMemory: 10,
75
+ hueVariation: 15,
76
+ brightness: [60, 90],
77
+ lineWidthScale: 0.4,
78
+ shape: 'line',
79
+ sparkle: false,
80
+ strobe: false,
81
+ spread3d: false,
82
+ glowSize: 10
83
+ },
84
+ ring: {
85
+ particleCount: [40, 60],
86
+ speed: [6, 8],
87
+ friction: 0.96,
88
+ gravity: 0.4,
89
+ decay: [0.012, 0.022],
90
+ trailMemory: 4,
91
+ hueVariation: 10,
92
+ brightness: [55, 80],
93
+ lineWidthScale: 0.7,
94
+ shape: 'diamond',
95
+ sparkle: false,
96
+ strobe: false,
97
+ spread3d: false,
98
+ glowSize: 14
99
+ },
100
+ palm: {
101
+ particleCount: [20, 30],
102
+ speed: [5, 12],
103
+ friction: 0.97,
104
+ gravity: 1.2,
105
+ decay: [0.006, 0.014],
106
+ trailMemory: 6,
107
+ hueVariation: 20,
108
+ brightness: [55, 85],
109
+ lineWidthScale: 0.6,
110
+ shape: 'line',
111
+ sparkle: false,
112
+ strobe: false,
113
+ spread3d: false,
114
+ glowSize: 12
115
+ },
116
+ crackle: {
117
+ particleCount: [40, 55],
118
+ speed: [2, 8],
119
+ friction: 0.955,
120
+ gravity: 0.8,
121
+ decay: [0.012, 0.025],
122
+ trailMemory: 2,
123
+ hueVariation: 25,
124
+ brightness: [60, 90],
125
+ lineWidthScale: 0.6,
126
+ shape: 'star',
127
+ sparkle: false,
128
+ strobe: false,
129
+ spread3d: true,
130
+ glowSize: 8
131
+ },
132
+ crossette: {
133
+ particleCount: [16, 20],
134
+ speed: [5, 9],
135
+ friction: 0.965,
136
+ gravity: 0.6,
137
+ decay: [0.006, 0.014],
138
+ trailMemory: 4,
139
+ hueVariation: 15,
140
+ brightness: [55, 85],
141
+ lineWidthScale: 0.7,
142
+ shape: 'circle',
143
+ sparkle: false,
144
+ strobe: false,
145
+ spread3d: true,
146
+ glowSize: 12
147
+ },
148
+ dahlia: {
149
+ particleCount: [48, 80],
150
+ speed: [3, 9],
151
+ friction: 0.965,
152
+ gravity: 0.7,
153
+ decay: [0.010, 0.020],
154
+ trailMemory: 4,
155
+ hueVariation: 5,
156
+ brightness: [55, 85],
157
+ lineWidthScale: 0.7,
158
+ shape: 'circle',
159
+ sparkle: false,
160
+ strobe: false,
161
+ spread3d: true,
162
+ glowSize: 12
163
+ },
164
+ brocade: {
165
+ particleCount: [60, 80],
166
+ speed: [3, 9],
167
+ friction: 0.98,
168
+ gravity: 1.3,
169
+ decay: [0.004, 0.010],
170
+ trailMemory: 10,
171
+ hueVariation: 10,
172
+ brightness: [60, 90],
173
+ lineWidthScale: 0.4,
174
+ shape: 'line',
175
+ sparkle: true,
176
+ strobe: false,
177
+ spread3d: false,
178
+ glowSize: 10
179
+ },
180
+ horsetail: {
181
+ particleCount: [30, 40],
182
+ speed: [8, 14],
183
+ friction: 0.975,
184
+ gravity: 2.0,
185
+ decay: [0.004, 0.010],
186
+ trailMemory: 12,
187
+ hueVariation: 15,
188
+ brightness: [60, 90],
189
+ lineWidthScale: 0.5,
190
+ shape: 'line',
191
+ sparkle: false,
192
+ strobe: false,
193
+ spread3d: false,
194
+ glowSize: 10
195
+ },
196
+ strobe: {
197
+ particleCount: [40, 55],
198
+ speed: [2, 8],
199
+ friction: 0.96,
200
+ gravity: 0.7,
201
+ decay: [0.010, 0.020],
202
+ trailMemory: 2,
203
+ hueVariation: 10,
204
+ brightness: [75, 95],
205
+ lineWidthScale: 0.6,
206
+ shape: 'circle',
207
+ sparkle: false,
208
+ strobe: true,
209
+ spread3d: true,
210
+ glowSize: 10
211
+ },
212
+ heart: {
213
+ particleCount: [60, 80],
214
+ speed: [3, 5],
215
+ friction: 0.965,
216
+ gravity: 0.3,
217
+ decay: [0.008, 0.016],
218
+ trailMemory: 4,
219
+ hueVariation: 15,
220
+ brightness: [55, 85],
221
+ lineWidthScale: 0.7,
222
+ shape: 'circle',
223
+ sparkle: false,
224
+ strobe: false,
225
+ spread3d: false,
226
+ glowSize: 12
227
+ },
228
+ spiral: {
229
+ particleCount: [45, 60],
230
+ speed: [2, 10],
231
+ friction: 0.97,
232
+ gravity: 0.4,
233
+ decay: [0.008, 0.016],
234
+ trailMemory: 5,
235
+ hueVariation: 10,
236
+ brightness: [55, 85],
237
+ lineWidthScale: 0.6,
238
+ shape: 'circle',
239
+ sparkle: false,
240
+ strobe: false,
241
+ spread3d: false,
242
+ glowSize: 12
243
+ },
244
+ flower: {
245
+ particleCount: [70, 90],
246
+ speed: [3, 7],
247
+ friction: 0.965,
248
+ gravity: 0.3,
249
+ decay: [0.008, 0.016],
250
+ trailMemory: 4,
251
+ hueVariation: 20,
252
+ brightness: [55, 85],
253
+ lineWidthScale: 0.7,
254
+ shape: 'circle',
255
+ sparkle: false,
256
+ strobe: false,
257
+ spread3d: false,
258
+ glowSize: 12
259
+ }
260
+ };
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './canvas';
2
+ export * from './confetti';
3
+ export * from './fireworks';
4
+ export * from './snow';
package/src/point.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type Point = {
2
+ x: number;
3
+ y: number;
4
+ };
@@ -0,0 +1,3 @@
1
+ import { mulberry32 } from '@basmilius/utils';
2
+
3
+ export const MULBERRY = mulberry32(13);
@@ -0,0 +1,2 @@
1
+ export { SnowSimulation } from './simulation';
2
+ export type { SnowSimulationConfig } from './simulation';
@@ -0,0 +1,301 @@
1
+ import { LimitedFrameRateCanvas } from '../canvas';
2
+ import { MULBERRY } from './consts';
3
+ import type { Snowflake } from './snowflake';
4
+
5
+ export interface SnowSimulationConfig {
6
+ readonly fillStyle?: string;
7
+ readonly particles?: number;
8
+ readonly scale?: number;
9
+ readonly size?: number;
10
+ readonly speed?: number;
11
+ readonly canvasOptions?: CanvasRenderingContext2DSettings;
12
+ }
13
+
14
+ const SPRITE_SIZE = 64;
15
+ const SPRITE_CENTER = SPRITE_SIZE / 2;
16
+ const SPRITE_RADIUS = SPRITE_SIZE / 2;
17
+
18
+ export class SnowSimulation extends LimitedFrameRateCanvas {
19
+ readonly #scale: number;
20
+ readonly #size: number;
21
+ readonly #speed: number;
22
+ readonly #baseOpacity: number;
23
+ #maxParticles: number;
24
+ #time: number = 0;
25
+ #ratio: number = 1;
26
+ #snowflakes: Snowflake[] = [];
27
+ #sprites: HTMLCanvasElement[] = [];
28
+
29
+ constructor(canvas: HTMLCanvasElement, config: SnowSimulationConfig = {}) {
30
+ super(canvas, 60, config.canvasOptions ?? {colorSpace: 'display-p3'});
31
+
32
+ this.#scale = config.scale ?? 1;
33
+ this.#maxParticles = config.particles ?? 200;
34
+ this.#size = (config.size ?? 9) * this.#scale;
35
+ this.#speed = config.speed ?? 2;
36
+
37
+ const {r, g, b, a} = this.#parseColor(config.fillStyle ?? 'rgb(255 255 255 / .75)');
38
+ this.#baseOpacity = a;
39
+
40
+ this.canvas.style.position = 'absolute';
41
+ this.canvas.style.top = '0';
42
+ this.canvas.style.left = '0';
43
+ this.canvas.style.height = '100%';
44
+ this.canvas.style.width = '100%';
45
+
46
+ if (this.isSmall) {
47
+ this.#maxParticles = Math.floor(this.#maxParticles / 2);
48
+ }
49
+
50
+ this.#sprites = this.#createSprites(r, g, b);
51
+
52
+ for (let i = 0; i < this.#maxParticles; ++i) {
53
+ this.#snowflakes.push(this.#createSnowflake(true));
54
+ }
55
+ }
56
+
57
+ draw(): void {
58
+ this.canvas.height = this.height;
59
+ this.canvas.width = this.width;
60
+
61
+ const ctx = this.context;
62
+ ctx.clearRect(0, 0, this.width, this.height);
63
+
64
+ for (const snowflake of this.#snowflakes) {
65
+ const px = snowflake.x * this.width;
66
+ const py = snowflake.y * this.height;
67
+ const displayRadius = snowflake.radius * snowflake.depth * this.#ratio;
68
+ const displaySize = displayRadius * 2;
69
+
70
+ if (displaySize < 0.5) {
71
+ continue;
72
+ }
73
+
74
+ ctx.globalAlpha = this.#baseOpacity * (0.15 + snowflake.depth * 0.85);
75
+
76
+ if (snowflake.spriteIndex === 3) {
77
+ ctx.save();
78
+ ctx.translate(px, py);
79
+ ctx.rotate(snowflake.rotation);
80
+ ctx.drawImage(
81
+ this.#sprites[snowflake.spriteIndex],
82
+ -displayRadius,
83
+ -displayRadius,
84
+ displaySize,
85
+ displaySize
86
+ );
87
+ ctx.restore();
88
+ } else {
89
+ ctx.drawImage(
90
+ this.#sprites[snowflake.spriteIndex],
91
+ px - displayRadius,
92
+ py - displayRadius,
93
+ displaySize,
94
+ displaySize
95
+ );
96
+ }
97
+ }
98
+
99
+ ctx.globalAlpha = 1;
100
+ }
101
+
102
+ tick(): void {
103
+ const speedFactor = (this.height / (420 * this.#ratio) / this.#speed) * this.deltaFactor;
104
+
105
+ this.#time += 0.015 * speedFactor;
106
+
107
+ // Multi-frequency wind for organic movement
108
+ const wind = Math.sin(this.#time * 0.7) * 0.5
109
+ + Math.sin(this.#time * 1.9 + 3) * 0.25
110
+ + Math.sin(this.#time * 4.3 + 1) * 0.1;
111
+
112
+ for (let index = 0; index < this.#snowflakes.length; index++) {
113
+ const snowflake = this.#snowflakes[index];
114
+
115
+ // Individual swing oscillation
116
+ const swing = Math.sin(this.#time * snowflake.swingFrequency + snowflake.swingOffset) * snowflake.swingAmplitude;
117
+
118
+ // Horizontal: personal swing + global wind (deeper = more wind influence)
119
+ snowflake.x += (swing + wind * snowflake.depth * 2) / (4000 * speedFactor);
120
+
121
+ // Vertical: individual speed + depth + size influence
122
+ snowflake.y += (snowflake.fallSpeed * 2 + snowflake.depth + snowflake.radius * 0.15) / (700 * speedFactor);
123
+
124
+ // Rotation (only visually relevant for crystal sprites)
125
+ snowflake.rotation += snowflake.rotationSpeed / speedFactor;
126
+
127
+ // Recycle out-of-bounds particles
128
+ if (snowflake.x > 1.15 || snowflake.x < -0.15 || snowflake.y > 1.05) {
129
+ const recycled = this.#createSnowflake(false);
130
+
131
+ if (index % 3 > 0) {
132
+ recycled.x = MULBERRY.next();
133
+ recycled.y = -0.05 - MULBERRY.next() * 0.15;
134
+ } else if (wind > 0.2) {
135
+ recycled.x = -0.15;
136
+ recycled.y = MULBERRY.next() * 0.8;
137
+ } else if (wind < -0.2) {
138
+ recycled.x = 1.15;
139
+ recycled.y = MULBERRY.next() * 0.8;
140
+ } else {
141
+ recycled.x = MULBERRY.next();
142
+ recycled.y = -0.05 - MULBERRY.next() * 0.15;
143
+ }
144
+
145
+ this.#snowflakes[index] = recycled;
146
+ }
147
+ }
148
+ }
149
+
150
+ #parseColor(fillStyle: string): {r: number; g: number; b: number; a: number} {
151
+ const canvas = document.createElement('canvas');
152
+ canvas.width = 1;
153
+ canvas.height = 1;
154
+ const ctx = canvas.getContext('2d')!;
155
+ ctx.fillStyle = fillStyle;
156
+ ctx.fillRect(0, 0, 1, 1);
157
+ const data = ctx.getImageData(0, 0, 1, 1).data;
158
+ return {r: data[0], g: data[1], b: data[2], a: data[3] / 255};
159
+ }
160
+
161
+ #createSprites(r: number, g: number, b: number): HTMLCanvasElement[] {
162
+ const sprites: HTMLCanvasElement[] = [];
163
+
164
+ const gradientProfiles: [number, number][][] = [
165
+ // 0: Soft glow
166
+ [[0, 0.8], [0.3, 0.4], [0.7, 0.1], [1, 0]],
167
+ // 1: Bright center
168
+ [[0, 1], [0.15, 0.7], [0.5, 0.2], [1, 0]],
169
+ // 2: Compact dot
170
+ [[0, 0.9], [0.25, 0.5], [0.5, 0.1], [1, 0]]
171
+ ];
172
+
173
+ for (const profile of gradientProfiles) {
174
+ const canvas = document.createElement('canvas');
175
+ canvas.width = SPRITE_SIZE;
176
+ canvas.height = SPRITE_SIZE;
177
+ const ctx = canvas.getContext('2d')!;
178
+
179
+ const gradient = ctx.createRadialGradient(
180
+ SPRITE_CENTER, SPRITE_CENTER, 0,
181
+ SPRITE_CENTER, SPRITE_CENTER, SPRITE_RADIUS
182
+ );
183
+
184
+ for (const [stop, alpha] of profile) {
185
+ gradient.addColorStop(stop, `rgba(${r}, ${g}, ${b}, ${alpha})`);
186
+ }
187
+
188
+ ctx.fillStyle = gradient;
189
+ ctx.beginPath();
190
+ ctx.arc(SPRITE_CENTER, SPRITE_CENTER, SPRITE_RADIUS, 0, Math.PI * 2);
191
+ ctx.fill();
192
+
193
+ sprites.push(canvas);
194
+ }
195
+
196
+ // 3: Crystal snowflake
197
+ sprites.push(this.#createCrystalSprite(r, g, b));
198
+
199
+ return sprites;
200
+ }
201
+
202
+ #createCrystalSprite(r: number, g: number, b: number): HTMLCanvasElement {
203
+ const canvas = document.createElement('canvas');
204
+ canvas.width = SPRITE_SIZE;
205
+ canvas.height = SPRITE_SIZE;
206
+ const ctx = canvas.getContext('2d')!;
207
+
208
+ // Soft glow base
209
+ const glow = ctx.createRadialGradient(
210
+ SPRITE_CENTER, SPRITE_CENTER, 0,
211
+ SPRITE_CENTER, SPRITE_CENTER, SPRITE_RADIUS
212
+ );
213
+ glow.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.6)`);
214
+ glow.addColorStop(0.25, `rgba(${r}, ${g}, ${b}, 0.25)`);
215
+ glow.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.05)`);
216
+ glow.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
217
+ ctx.fillStyle = glow;
218
+ ctx.beginPath();
219
+ ctx.arc(SPRITE_CENTER, SPRITE_CENTER, SPRITE_RADIUS, 0, Math.PI * 2);
220
+ ctx.fill();
221
+
222
+ // Crystal arms
223
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.7)`;
224
+ ctx.lineWidth = 1.5;
225
+ ctx.lineCap = 'round';
226
+
227
+ const armLength = SPRITE_RADIUS * 0.75;
228
+
229
+ for (let arm = 0; arm < 6; arm++) {
230
+ const angle = (arm / 6) * Math.PI * 2 - Math.PI / 2;
231
+ const tipX = SPRITE_CENTER + Math.cos(angle) * armLength;
232
+ const tipY = SPRITE_CENTER + Math.sin(angle) * armLength;
233
+
234
+ // Main arm
235
+ ctx.beginPath();
236
+ ctx.moveTo(SPRITE_CENTER, SPRITE_CENTER);
237
+ ctx.lineTo(tipX, tipY);
238
+ ctx.stroke();
239
+
240
+ // Side branches at 40% and 65% along the arm
241
+ for (const position of [0.4, 0.65]) {
242
+ const branchX = SPRITE_CENTER + Math.cos(angle) * armLength * position;
243
+ const branchY = SPRITE_CENTER + Math.sin(angle) * armLength * position;
244
+ const branchLength = armLength * (0.4 - position * 0.3);
245
+
246
+ for (const side of [-1, 1]) {
247
+ const branchAngle = angle + side * Math.PI / 3;
248
+ ctx.beginPath();
249
+ ctx.moveTo(branchX, branchY);
250
+ ctx.lineTo(
251
+ branchX + Math.cos(branchAngle) * branchLength,
252
+ branchY + Math.sin(branchAngle) * branchLength
253
+ );
254
+ ctx.stroke();
255
+ }
256
+ }
257
+ }
258
+
259
+ // Center dot
260
+ const centerGlow = ctx.createRadialGradient(
261
+ SPRITE_CENTER, SPRITE_CENTER, 0,
262
+ SPRITE_CENTER, SPRITE_CENTER, SPRITE_RADIUS * 0.12
263
+ );
264
+ centerGlow.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.9)`);
265
+ centerGlow.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
266
+ ctx.fillStyle = centerGlow;
267
+ ctx.beginPath();
268
+ ctx.arc(SPRITE_CENTER, SPRITE_CENTER, SPRITE_RADIUS * 0.12, 0, Math.PI * 2);
269
+ ctx.fill();
270
+
271
+ return canvas;
272
+ }
273
+
274
+ #createSnowflake(initialSpread: boolean): Snowflake {
275
+ const depth = 0.3 + MULBERRY.next() * 0.7;
276
+ const radius = MULBERRY.next() * this.#size + 2 * this.#scale;
277
+
278
+ let spriteIndex: number;
279
+ if (depth > 0.85 && radius > this.#size * 0.6 && MULBERRY.next() > 0.65) {
280
+ spriteIndex = 3;
281
+ } else if (depth < 0.45) {
282
+ spriteIndex = 2;
283
+ } else {
284
+ spriteIndex = MULBERRY.next() > 0.5 ? 0 : 1;
285
+ }
286
+
287
+ return {
288
+ x: MULBERRY.next(),
289
+ y: initialSpread ? MULBERRY.next() * 2 - 1 : -0.05 - MULBERRY.next() * 0.15,
290
+ depth,
291
+ radius,
292
+ rotation: MULBERRY.next() * Math.PI * 2,
293
+ rotationSpeed: (MULBERRY.next() - 0.5) * 0.03,
294
+ swingAmplitude: 0.3 + MULBERRY.next() * 0.7,
295
+ swingFrequency: 0.5 + MULBERRY.next() * 1.5,
296
+ swingOffset: MULBERRY.next() * Math.PI * 2,
297
+ fallSpeed: 0.5 + MULBERRY.next() * 0.5,
298
+ spriteIndex
299
+ };
300
+ }
301
+ }
@@ -0,0 +1,13 @@
1
+ export type Snowflake = {
2
+ x: number;
3
+ y: number;
4
+ depth: number;
5
+ radius: number;
6
+ rotation: number;
7
+ rotationSpeed: number;
8
+ swingAmplitude: number;
9
+ swingFrequency: number;
10
+ swingOffset: number;
11
+ fallSpeed: number;
12
+ spriteIndex: number;
13
+ };