@basmilius/sparkle 2.0.0 → 2.2.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.
Files changed (129) hide show
  1. package/dist/index.d.mts +1053 -28
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +4840 -400
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +7 -2
  6. package/src/aurora/consts.ts +3 -0
  7. package/src/aurora/index.ts +10 -0
  8. package/src/aurora/layer.ts +180 -0
  9. package/src/aurora/types.ts +13 -0
  10. package/src/balloons/consts.ts +3 -0
  11. package/src/balloons/index.ts +12 -0
  12. package/src/balloons/layer.ts +169 -0
  13. package/src/balloons/particle.ts +110 -0
  14. package/src/balloons/types.ts +14 -0
  15. package/src/bubbles/consts.ts +3 -0
  16. package/src/bubbles/index.ts +10 -0
  17. package/src/bubbles/layer.ts +246 -0
  18. package/src/bubbles/types.ts +21 -0
  19. package/src/canvas.ts +32 -1
  20. package/src/color.ts +19 -0
  21. package/src/confetti/consts.ts +13 -13
  22. package/src/confetti/index.ts +20 -2
  23. package/src/confetti/layer.ts +155 -0
  24. package/src/confetti/particle.ts +106 -0
  25. package/src/confetti/shapes.ts +104 -0
  26. package/src/confetti/types.ts +4 -1
  27. package/src/distance.ts +1 -1
  28. package/src/donuts/consts.ts +19 -0
  29. package/src/donuts/donut.ts +12 -0
  30. package/src/donuts/index.ts +9 -0
  31. package/src/donuts/layer.ts +301 -0
  32. package/src/effect.ts +107 -0
  33. package/src/fade.ts +87 -0
  34. package/src/fireflies/consts.ts +3 -0
  35. package/src/fireflies/index.ts +12 -0
  36. package/src/fireflies/layer.ts +169 -0
  37. package/src/fireflies/particle.ts +124 -0
  38. package/src/fireflies/types.ts +17 -0
  39. package/src/firepit/consts.ts +3 -0
  40. package/src/firepit/index.ts +10 -0
  41. package/src/firepit/layer.ts +193 -0
  42. package/src/firepit/types.ts +20 -0
  43. package/src/fireworks/create-explosion.ts +237 -0
  44. package/src/fireworks/explosion.ts +9 -9
  45. package/src/fireworks/firework.ts +9 -8
  46. package/src/fireworks/index.ts +19 -3
  47. package/src/fireworks/layer.ts +203 -0
  48. package/src/fireworks/spark.ts +9 -9
  49. package/src/fireworks/types.ts +2 -2
  50. package/src/glitter/consts.ts +13 -0
  51. package/src/glitter/index.ts +9 -0
  52. package/src/glitter/layer.ts +181 -0
  53. package/src/glitter/types.ts +33 -0
  54. package/src/index.ts +27 -0
  55. package/src/lanterns/consts.ts +13 -0
  56. package/src/lanterns/index.ts +9 -0
  57. package/src/lanterns/layer.ts +178 -0
  58. package/src/lanterns/types.ts +22 -0
  59. package/src/layer.ts +26 -0
  60. package/src/leaves/consts.ts +16 -0
  61. package/src/leaves/index.ts +9 -0
  62. package/src/leaves/layer.ts +258 -0
  63. package/src/leaves/types.ts +25 -0
  64. package/src/lightning/consts.ts +3 -0
  65. package/src/lightning/index.ts +11 -0
  66. package/src/lightning/layer.ts +41 -0
  67. package/src/lightning/system.ts +196 -0
  68. package/src/lightning/types.ts +20 -0
  69. package/src/matrix/consts.ts +5 -0
  70. package/src/matrix/index.ts +9 -0
  71. package/src/matrix/layer.ts +154 -0
  72. package/src/matrix/types.ts +17 -0
  73. package/src/orbits/consts.ts +13 -0
  74. package/src/orbits/index.ts +9 -0
  75. package/src/orbits/layer.ts +213 -0
  76. package/src/orbits/types.ts +27 -0
  77. package/src/particles/consts.ts +3 -0
  78. package/src/particles/index.ts +10 -0
  79. package/src/particles/layer.ts +360 -0
  80. package/src/particles/types.ts +10 -0
  81. package/src/petals/consts.ts +13 -0
  82. package/src/petals/index.ts +10 -0
  83. package/src/petals/layer.ts +174 -0
  84. package/src/petals/types.ts +15 -0
  85. package/src/plasma/consts.ts +3 -0
  86. package/src/plasma/index.ts +10 -0
  87. package/src/plasma/layer.ts +107 -0
  88. package/src/plasma/types.ts +5 -0
  89. package/src/rain/consts.ts +3 -0
  90. package/src/rain/index.ts +12 -0
  91. package/src/rain/layer.ts +194 -0
  92. package/src/rain/particle.ts +132 -0
  93. package/src/rain/types.ts +22 -0
  94. package/src/sandstorm/consts.ts +3 -0
  95. package/src/sandstorm/index.ts +10 -0
  96. package/src/sandstorm/layer.ts +152 -0
  97. package/src/sandstorm/types.ts +10 -0
  98. package/src/scene.ts +201 -0
  99. package/src/shooting-stars/index.ts +3 -0
  100. package/src/shooting-stars/system.ts +151 -0
  101. package/src/shooting-stars/types.ts +11 -0
  102. package/src/simulation-canvas.ts +83 -0
  103. package/src/snow/consts.ts +2 -2
  104. package/src/snow/index.ts +9 -2
  105. package/src/snow/{simulation.ts → layer.ts} +64 -89
  106. package/src/sparklers/consts.ts +3 -0
  107. package/src/sparklers/index.ts +16 -0
  108. package/src/sparklers/layer.ts +220 -0
  109. package/src/sparklers/particle.ts +89 -0
  110. package/src/sparklers/types.ts +13 -0
  111. package/src/stars/consts.ts +3 -0
  112. package/src/stars/index.ts +10 -0
  113. package/src/stars/layer.ts +139 -0
  114. package/src/stars/types.ts +12 -0
  115. package/src/streamers/consts.ts +14 -0
  116. package/src/streamers/index.ts +10 -0
  117. package/src/streamers/layer.ts +223 -0
  118. package/src/streamers/types.ts +14 -0
  119. package/src/trail.ts +140 -0
  120. package/src/waves/consts.ts +3 -0
  121. package/src/waves/index.ts +10 -0
  122. package/src/waves/layer.ts +164 -0
  123. package/src/waves/types.ts +10 -0
  124. package/src/wormhole/consts.ts +3 -0
  125. package/src/wormhole/index.ts +10 -0
  126. package/src/wormhole/layer.ts +197 -0
  127. package/src/wormhole/types.ts +10 -0
  128. package/src/confetti/simulation.ts +0 -221
  129. package/src/fireworks/simulation.ts +0 -493
@@ -0,0 +1,164 @@
1
+ import { hexToRGB } from '@basmilius/utils';
2
+ import { Effect } from '../effect';
3
+ import { MULBERRY } from './consts';
4
+ import type { Wave } from './types';
5
+
6
+ const DEFAULT_COLORS = ['#0a3d6b', '#0e5a8a', '#1a7ab5', '#3399cc', '#66c2e0'];
7
+
8
+ export interface WavesConfig {
9
+ readonly layers?: number;
10
+ readonly speed?: number;
11
+ readonly colors?: string[];
12
+ readonly foamColor?: string;
13
+ readonly foamAmount?: number;
14
+ readonly scale?: number;
15
+ }
16
+
17
+ export class Waves extends Effect<WavesConfig> {
18
+ #speed: number;
19
+ #foamAmount: number;
20
+ #scale: number;
21
+ readonly #foamRGB: [number, number, number];
22
+ #waves: Wave[] = [];
23
+ #foamParticles: { x: number; y: number; alpha: number; size: number }[] = [];
24
+ #maxFoamParticles: number;
25
+
26
+ constructor(config: WavesConfig = {}) {
27
+ super();
28
+
29
+ const layers = config.layers ?? 5;
30
+ const colors = config.colors ?? DEFAULT_COLORS;
31
+ this.#speed = config.speed ?? 1;
32
+ this.#foamAmount = config.foamAmount ?? 0.4;
33
+ this.#scale = config.scale ?? 1;
34
+ this.#maxFoamParticles = 120;
35
+ this.#foamRGB = hexToRGB(config.foamColor ?? '#ffffff');
36
+
37
+ if (innerWidth < 991) {
38
+ this.#maxFoamParticles = Math.floor(this.#maxFoamParticles / 2);
39
+ }
40
+
41
+ for (let i = 0; i < layers; i++) {
42
+ const depth = i / Math.max(layers - 1, 1);
43
+ const color = colors[i % colors.length];
44
+
45
+ this.#waves.push({
46
+ amplitude: (20 + MULBERRY.next() * 30) * (1 - depth * 0.4),
47
+ frequency: 0.005 + MULBERRY.next() * 0.008 + depth * 0.002,
48
+ speed: 0.4 + MULBERRY.next() * 0.6 + depth * 0.3,
49
+ phase: MULBERRY.next() * Math.PI * 2,
50
+ baseY: 0.35 + depth * 0.13,
51
+ color,
52
+ foamThreshold: 0.6 + MULBERRY.next() * 0.3,
53
+ rgb: hexToRGB(color)
54
+ });
55
+ }
56
+ }
57
+
58
+ configure(config: Partial<WavesConfig>): void {
59
+ if (config.speed !== undefined) {
60
+ this.#speed = config.speed;
61
+ }
62
+ if (config.foamAmount !== undefined) {
63
+ this.#foamAmount = config.foamAmount;
64
+ }
65
+ if (config.scale !== undefined) {
66
+ this.#scale = config.scale;
67
+ }
68
+ }
69
+
70
+ tick(dt: number, width: number, height: number): void {
71
+ for (const wave of this.#waves) {
72
+ wave.phase += 0.015 * wave.speed * this.#speed * dt;
73
+ }
74
+
75
+ let aliveFoam = 0;
76
+
77
+ for (let i = 0; i < this.#foamParticles.length; i++) {
78
+ const foam = this.#foamParticles[i];
79
+ foam.alpha -= (0.008 + MULBERRY.next() * 0.006) * dt;
80
+ foam.x += (MULBERRY.next() - 0.5) * 0.5 * dt;
81
+ foam.y += (MULBERRY.next() - 0.5) * 0.3 * dt;
82
+
83
+ if (foam.alpha > 0) {
84
+ this.#foamParticles[aliveFoam++] = foam;
85
+ }
86
+ }
87
+
88
+ this.#foamParticles.length = aliveFoam;
89
+
90
+ if (this.#foamAmount > 0 && width > 0 && height > 0) {
91
+ const spawnCount = Math.ceil(2 * this.#foamAmount * dt);
92
+
93
+ for (let s = 0; s < spawnCount && this.#foamParticles.length < this.#maxFoamParticles; s++) {
94
+ const waveIndex = Math.floor(MULBERRY.next() * this.#waves.length);
95
+ const wave = this.#waves[waveIndex];
96
+ const x = MULBERRY.next() * width;
97
+ const centerY = wave.baseY * height;
98
+ const primary = wave.amplitude * Math.sin(wave.frequency * x + wave.phase);
99
+ const secondary = wave.amplitude * 0.4 * Math.sin(wave.frequency * 2.3 * x + wave.phase * 1.7 + 1.3);
100
+ const waveY = centerY + (primary + secondary) * this.#scale;
101
+
102
+ const slopeCheck = Math.cos(wave.frequency * x + wave.phase);
103
+
104
+ if (slopeCheck > wave.foamThreshold - 1) {
105
+ this.#foamParticles.push({
106
+ x,
107
+ y: waveY - MULBERRY.next() * 4 * this.#scale,
108
+ alpha: 0.4 + MULBERRY.next() * 0.6,
109
+ size: 1 + MULBERRY.next() * 3
110
+ });
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
117
+
118
+ const step = 2;
119
+
120
+ for (let wi = 0; wi < this.#waves.length; wi++) {
121
+ const wave = this.#waves[wi];
122
+ const [wr, wg, wb] = wave.rgb;
123
+ const centerY = wave.baseY * height;
124
+
125
+ ctx.beginPath();
126
+ ctx.moveTo(0, height);
127
+
128
+ for (let x = 0; x <= width; x += step) {
129
+ const primary = wave.amplitude * Math.sin(wave.frequency * x + wave.phase);
130
+ const secondary = wave.amplitude * 0.4 * Math.sin(wave.frequency * 2.3 * x + wave.phase * 1.7 + 1.3);
131
+ const tertiary = wave.amplitude * 0.15 * Math.sin(wave.frequency * 4.1 * x + wave.phase * 0.6 + 2.8);
132
+ const waveY = centerY + (primary + secondary + tertiary) * this.#scale;
133
+
134
+ ctx.lineTo(x, waveY);
135
+ }
136
+
137
+ ctx.lineTo(width, height);
138
+ ctx.closePath();
139
+
140
+ const gradient = ctx.createLinearGradient(0, centerY - wave.amplitude * this.#scale, 0, height);
141
+ gradient.addColorStop(0, `rgba(${wr}, ${wg}, ${wb}, 0.85)`);
142
+ gradient.addColorStop(0.4, `rgb(${wr}, ${wg}, ${wb})`);
143
+ gradient.addColorStop(1, `rgb(${Math.floor(wr * 0.6)}, ${Math.floor(wg * 0.6)}, ${Math.floor(wb * 0.6)})`);
144
+
145
+ ctx.fillStyle = gradient;
146
+ ctx.fill();
147
+ }
148
+
149
+ if (this.#foamAmount > 0) {
150
+ const [fr, fg, fb] = this.#foamRGB;
151
+
152
+ for (const foam of this.#foamParticles) {
153
+ if (foam.alpha <= 0) {
154
+ continue;
155
+ }
156
+
157
+ ctx.beginPath();
158
+ ctx.arc(foam.x, foam.y, foam.size * this.#scale, 0, Math.PI * 2);
159
+ ctx.fillStyle = `rgba(${fr}, ${fg}, ${fb}, ${foam.alpha * this.#foamAmount})`;
160
+ ctx.fill();
161
+ }
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,10 @@
1
+ export type Wave = {
2
+ amplitude: number;
3
+ frequency: number;
4
+ speed: number;
5
+ phase: number;
6
+ baseY: number;
7
+ color: string;
8
+ foamThreshold: number;
9
+ rgb: [number, number, number];
10
+ };
@@ -0,0 +1,3 @@
1
+ import { type Mulberry32, mulberry32 } from '@basmilius/utils';
2
+
3
+ export const MULBERRY: Mulberry32 = mulberry32(13);
@@ -0,0 +1,10 @@
1
+ import { Wormhole } from './layer';
2
+ import type { WormholeConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createWormhole(config?: WormholeConfig): Effect<WormholeConfig> {
6
+ return new Wormhole(config);
7
+ }
8
+
9
+ export type { WormholeConfig };
10
+ export type { WormholeDirection, WormholeParticle } from './types';
@@ -0,0 +1,197 @@
1
+ import { hexToRGB } from '@basmilius/utils';
2
+ import { Effect } from '../effect';
3
+ import { MULBERRY } from './consts';
4
+ import type { WormholeDirection, WormholeParticle } from './types';
5
+
6
+ export interface WormholeConfig {
7
+ readonly count?: number;
8
+ readonly speed?: number;
9
+ readonly color?: string;
10
+ readonly direction?: WormholeDirection;
11
+ readonly scale?: number;
12
+ }
13
+
14
+ export class Wormhole extends Effect<WormholeConfig> {
15
+ #speed: number;
16
+ readonly #colorRGB: [number, number, number];
17
+ readonly #direction: WormholeDirection;
18
+ #scale: number;
19
+ #count: number;
20
+ #particles: WormholeParticle[] = [];
21
+ #width: number = 960;
22
+ #height: number = 540;
23
+ #initialized: boolean = false;
24
+
25
+ constructor(config: WormholeConfig = {}) {
26
+ super();
27
+
28
+ let count = config.count ?? 200;
29
+
30
+ this.#speed = config.speed ?? 1;
31
+ this.#colorRGB = hexToRGB(config.color ?? '#6699ff');
32
+ this.#direction = config.direction ?? 'inward';
33
+ this.#scale = config.scale ?? 1;
34
+
35
+ if (innerWidth < 991) {
36
+ count = Math.floor(count / 2);
37
+ }
38
+
39
+ this.#count = count;
40
+ }
41
+
42
+ onResize(width: number, height: number): void {
43
+ this.#width = width;
44
+ this.#height = height;
45
+
46
+ if (!this.#initialized) {
47
+ this.#initialized = true;
48
+ this.#particles = [];
49
+
50
+ for (let i = 0; i < this.#count; ++i) {
51
+ this.#particles.push(this.#createParticle(true));
52
+ }
53
+ }
54
+ }
55
+
56
+ configure(config: Partial<WormholeConfig>): void {
57
+ if (config.speed !== undefined) {
58
+ this.#speed = config.speed;
59
+ }
60
+ if (config.scale !== undefined) {
61
+ this.#scale = config.scale;
62
+ }
63
+ }
64
+
65
+ tick(dt: number, width: number, height: number): void {
66
+ this.#width = width;
67
+ this.#height = height;
68
+
69
+ const maxRadius = Math.sqrt((width / 2) ** 2 + (height / 2) ** 2);
70
+
71
+ let alive = 0;
72
+
73
+ for (let i = 0; i < this.#particles.length; ++i) {
74
+ const particle = this.#particles[i];
75
+
76
+ if (this.#direction === 'inward') {
77
+ const normalizedDistance = particle.distance / maxRadius;
78
+ const acceleration = 1 + (1 - normalizedDistance) * 3;
79
+ particle.distance -= particle.speed * this.#speed * acceleration * dt * this.#scale;
80
+
81
+ particle.trail = 5 + (1 - normalizedDistance) * 25;
82
+
83
+ if (particle.distance > 0) {
84
+ this.#particles[alive++] = particle;
85
+ } else {
86
+ this.#particles[alive++] = this.#createParticle(false);
87
+ }
88
+ } else {
89
+ const normalizedDistance = particle.distance / maxRadius;
90
+ const acceleration = 1 + normalizedDistance * 3;
91
+ particle.distance += particle.speed * this.#speed * acceleration * dt * this.#scale;
92
+
93
+ particle.trail = 5 + normalizedDistance * 25;
94
+
95
+ if (particle.distance < maxRadius + 20) {
96
+ this.#particles[alive++] = particle;
97
+ } else {
98
+ this.#particles[alive++] = this.#createParticle(false);
99
+ }
100
+ }
101
+
102
+ particle.angle += (MULBERRY.next() - 0.5) * 0.002 * dt;
103
+ }
104
+
105
+ this.#particles.length = alive;
106
+ }
107
+
108
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
109
+ const cx = width / 2;
110
+ const cy = height / 2;
111
+ const maxRadius = Math.sqrt(cx * cx + cy * cy);
112
+ const [cr, cg, cb] = this.#colorRGB;
113
+
114
+
115
+ const glowRadius = 40 * this.#scale;
116
+ const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowRadius);
117
+ glow.addColorStop(0, `rgba(${cr}, ${cg}, ${cb}, 0.25)`);
118
+ glow.addColorStop(0.4, `rgba(${cr}, ${cg}, ${cb}, 0.08)`);
119
+ glow.addColorStop(1, `rgba(${cr}, ${cg}, ${cb}, 0)`);
120
+
121
+ ctx.globalCompositeOperation = 'lighter';
122
+ ctx.globalAlpha = 1;
123
+ ctx.beginPath();
124
+ ctx.arc(cx, cy, glowRadius, 0, Math.PI * 2);
125
+ ctx.fillStyle = glow;
126
+ ctx.fill();
127
+
128
+ for (const particle of this.#particles) {
129
+ const normalizedDistance = particle.distance / maxRadius;
130
+ const px = cx + Math.cos(particle.angle) * particle.distance;
131
+ const py = cy + Math.sin(particle.angle) * particle.distance;
132
+
133
+ const trailFactor = this.#direction === 'inward' ? 1 : -1;
134
+ const trailLength = particle.trail * this.#scale;
135
+ const tx = px + Math.cos(particle.angle) * trailLength * trailFactor;
136
+ const ty = py + Math.sin(particle.angle) * trailLength * trailFactor;
137
+
138
+ let intensity: number;
139
+
140
+ if (this.#direction === 'inward') {
141
+ intensity = particle.brightness * (1 - normalizedDistance);
142
+ } else {
143
+ intensity = particle.brightness * normalizedDistance;
144
+ }
145
+
146
+ const alpha = Math.max(0.05, Math.min(1, intensity));
147
+ const lineWidth = Math.max(0.5, particle.size * this.#scale * (0.5 + intensity * 0.5));
148
+
149
+ const gradient = ctx.createLinearGradient(px, py, tx, ty);
150
+ gradient.addColorStop(0, `rgba(${cr}, ${cg}, ${cb}, ${alpha})`);
151
+ gradient.addColorStop(1, `rgba(${cr}, ${cg}, ${cb}, 0)`);
152
+
153
+ ctx.globalAlpha = 1;
154
+ ctx.beginPath();
155
+ ctx.moveTo(px, py);
156
+ ctx.lineTo(tx, ty);
157
+ ctx.strokeStyle = gradient;
158
+ ctx.lineWidth = lineWidth;
159
+ ctx.stroke();
160
+
161
+ ctx.globalAlpha = alpha;
162
+ ctx.beginPath();
163
+ ctx.arc(px, py, lineWidth * 0.6, 0, Math.PI * 2);
164
+ ctx.fillStyle = `rgb(${cr}, ${cg}, ${cb})`;
165
+ ctx.fill();
166
+ }
167
+
168
+ ctx.globalCompositeOperation = 'source-over';
169
+ ctx.globalAlpha = 1;
170
+ }
171
+
172
+ #createParticle(spread: boolean): WormholeParticle {
173
+ const maxRadius = Math.sqrt((this.#width / 2) ** 2 + (this.#height / 2) ** 2);
174
+ const angle = MULBERRY.next() * Math.PI * 2;
175
+
176
+ let distance: number;
177
+
178
+ if (this.#direction === 'inward') {
179
+ distance = spread
180
+ ? MULBERRY.next() * maxRadius
181
+ : maxRadius * (0.8 + MULBERRY.next() * 0.2);
182
+ } else {
183
+ distance = spread
184
+ ? MULBERRY.next() * maxRadius
185
+ : MULBERRY.next() * maxRadius * 0.1;
186
+ }
187
+
188
+ return {
189
+ angle,
190
+ distance,
191
+ speed: 0.5 + MULBERRY.next() * 1.5,
192
+ size: 0.8 + MULBERRY.next() * 2.2,
193
+ brightness: 0.4 + MULBERRY.next() * 0.6,
194
+ trail: 5
195
+ };
196
+ }
197
+ }
@@ -0,0 +1,10 @@
1
+ export type WormholeDirection = 'inward' | 'outward';
2
+
3
+ export type WormholeParticle = {
4
+ angle: number;
5
+ distance: number;
6
+ speed: number;
7
+ size: number;
8
+ brightness: number;
9
+ trail: number;
10
+ };
@@ -1,221 +0,0 @@
1
- import { hexToRGB } from '@basmilius/utils';
2
- import { LimitedFrameRateCanvas } from '../canvas';
3
- import { DEFAULT_CONFIG, MULBERRY } from './consts';
4
- import type { Config, Particle, ParticleConfig, Shape } from './types';
5
-
6
- const TWO_PI = Math.PI * 2;
7
-
8
- // Precomputed unit-size (size=1) Path2D objects per shape.
9
- // Size is encoded into the context transform, so each path is drawn once and reused every frame.
10
- const SHAPE_PATHS: Record<Shape, Path2D> = {
11
- circle: (() => {
12
- const path = new Path2D();
13
- path.ellipse(0, 0, 0.6, 1, 0, 0, TWO_PI);
14
- return path;
15
- })(),
16
- diamond: (() => {
17
- const path = new Path2D();
18
- path.moveTo(0, -1);
19
- path.lineTo(0.6, 0);
20
- path.lineTo(0, 1);
21
- path.lineTo(-0.6, 0);
22
- path.closePath();
23
- return path;
24
- })(),
25
- ribbon: (() => {
26
- const path = new Path2D();
27
- path.rect(-0.2, -1, 0.4, 2);
28
- return path;
29
- })(),
30
- square: (() => {
31
- const path = new Path2D();
32
- path.rect(-0.7, -0.7, 1.4, 1.4);
33
- return path;
34
- })(),
35
- star: (() => {
36
- const path = new Path2D();
37
- for (let i = 0; i < 10; i++) {
38
- const r = i % 2 === 0 ? 1 : 0.42;
39
- const angle = (i * Math.PI / 5) - Math.PI / 2;
40
- if (i === 0) path.moveTo(r * Math.cos(angle), r * Math.sin(angle));
41
- else path.lineTo(r * Math.cos(angle), r * Math.sin(angle));
42
- }
43
- path.closePath();
44
- return path;
45
- })(),
46
- triangle: (() => {
47
- const path = new Path2D();
48
- for (let i = 0; i < 3; i++) {
49
- const angle = (i * 2 * Math.PI / 3) - Math.PI / 2;
50
- if (i === 0) path.moveTo(Math.cos(angle), Math.sin(angle));
51
- else path.lineTo(Math.cos(angle), Math.sin(angle));
52
- }
53
- path.closePath();
54
- return path;
55
- })()
56
- };
57
-
58
- export interface ConfettiSimulationConfig {
59
- readonly scale?: number;
60
- readonly canvasOptions?: CanvasRenderingContext2DSettings;
61
- }
62
-
63
- export class ConfettiSimulation extends LimitedFrameRateCanvas {
64
-
65
- readonly #scale: number;
66
- #particles: Particle[] = [];
67
-
68
- constructor(canvas: HTMLCanvasElement, config: ConfettiSimulationConfig = {}) {
69
- super(canvas, 60, config.canvasOptions ?? {colorSpace: 'display-p3'});
70
-
71
- this.#scale = config.scale ?? 1;
72
-
73
- this.canvas.style.position = 'absolute';
74
- this.canvas.style.top = '0';
75
- this.canvas.style.left = '0';
76
- this.canvas.style.height = '100%';
77
- this.canvas.style.width = '100%';
78
- }
79
-
80
- fire(config: Partial<Config>): void {
81
- this.onResize();
82
- this.draw();
83
-
84
- const resolved = { ...DEFAULT_CONFIG, ...config };
85
- const { angle, colors, decay, gravity, shapes, spread, startVelocity, ticks, x, y } = resolved;
86
- const numberOfParticles = Math.max(1, resolved.particles);
87
-
88
- for (let i = 0; i < numberOfParticles; i++) {
89
- const particle = this.#createParticle({
90
- angle,
91
- color: hexToRGB(colors[Math.floor(MULBERRY.next() * colors.length)]),
92
- decay,
93
- gravity: gravity * this.#scale,
94
- shape: shapes[Math.floor(MULBERRY.next() * shapes.length)],
95
- spread,
96
- startVelocity: startVelocity * this.#scale,
97
- ticks,
98
- x: this.width * x,
99
- y: this.height * y
100
- });
101
-
102
- this.#tickParticle(particle);
103
- this.#particles.push(particle);
104
- }
105
-
106
- if (!this.isTicking) {
107
- this.start();
108
- }
109
- }
110
-
111
- draw(): void {
112
- const { context, width, height } = this;
113
- context.clearRect(0, 0, width, height);
114
-
115
- const particles = this.#particles;
116
-
117
- for (let i = 0; i < particles.length; i++) {
118
- const p = particles[i];
119
- const flipCos = Math.cos(p.flipAngle);
120
- const size = p.size;
121
-
122
- // Encode translate + rotate + scale(flipCos, 1) + scale(size, size) in a single setTransform call,
123
- // avoiding save()/translate()/rotate()/scale()/restore() — 5 API calls replaced by 1.
124
- context.setTransform(
125
- p.rotCos * flipCos * size,
126
- p.rotSin * flipCos * size,
127
- -p.rotSin * size,
128
- p.rotCos * size,
129
- p.x,
130
- p.y
131
- );
132
- context.globalAlpha = 1 - p.tick / p.totalTicks;
133
- context.fillStyle = p.colorStr;
134
- context.fill(SHAPE_PATHS[p.shape]);
135
- }
136
-
137
- context.resetTransform();
138
- }
139
-
140
- tick(): void {
141
- const particles = this.#particles;
142
- let alive = 0;
143
-
144
- // Normalize to 60fps-equivalent units so physics is frame-rate independent.
145
- // dt ≈ 1.0 at 60fps, 0.5 at 120fps, 2.0 at 30fps.
146
- // Cap at 200ms to avoid large jumps after tab switches or dropped frames.
147
- const dt = this.delta > 0 && this.delta < 200 ? this.delta / (1000 / 60) : 1;
148
-
149
- // Single pass: tick live particles and compact the array in-place.
150
- // Avoids filter() allocation and a separate forEach pass.
151
- for (let i = 0; i < particles.length; i++) {
152
- const p = particles[i];
153
-
154
- if (p.tick < p.totalTicks) {
155
- this.#tickParticle(p, dt);
156
- particles[alive++] = p;
157
- }
158
- }
159
-
160
- particles.length = alive;
161
-
162
- if (alive === 0) {
163
- this.stop();
164
- }
165
- }
166
-
167
- onResize(): void {
168
- super.onResize();
169
- this.canvas.width = this.width;
170
- this.canvas.height = this.height;
171
- }
172
-
173
- #createParticle(config: ParticleConfig): Particle {
174
- const launchAngle = -(config.angle * Math.PI / 180)
175
- + (0.5 * config.spread * Math.PI / 180)
176
- - (MULBERRY.next() * config.spread * Math.PI / 180);
177
-
178
- const speed = config.startVelocity * (0.5 + MULBERRY.next());
179
- const rotAngle = MULBERRY.next() * TWO_PI;
180
-
181
- return {
182
- colorStr: `rgb(${config.color[0]}, ${config.color[1]}, ${config.color[2]})`,
183
- decay: config.decay - 0.05 + MULBERRY.next() * 0.1,
184
- flipAngle: MULBERRY.next() * TWO_PI,
185
- flipSpeed: 0.03 + MULBERRY.next() * 0.05,
186
- gravity: config.gravity,
187
- rotAngle,
188
- rotCos: Math.cos(rotAngle),
189
- rotSin: Math.sin(rotAngle),
190
- rotSpeed: (MULBERRY.next() - 0.5) * 0.06,
191
- shape: config.shape,
192
- size: (5 + MULBERRY.next() * 5) * this.#scale,
193
- swing: MULBERRY.next() * TWO_PI,
194
- swingAmp: 0.5 + MULBERRY.next() * 1.5,
195
- swingSpeed: 0.025 + MULBERRY.next() * 0.035,
196
- tick: 0,
197
- totalTicks: config.ticks,
198
- vx: Math.cos(launchAngle) * speed,
199
- vy: Math.sin(launchAngle) * speed,
200
- x: config.x,
201
- y: config.y
202
- };
203
- }
204
-
205
- // dt defaults to 1 (60fps-equivalent) for the initial kick in fire() before the loop starts.
206
- #tickParticle(particle: Particle, dt: number = 1): void {
207
- const decayFactor = Math.pow(particle.decay, dt);
208
- particle.vx *= decayFactor;
209
- particle.vy *= decayFactor;
210
- particle.vy += particle.gravity * 0.35 * dt;
211
- particle.swing += particle.swingSpeed * dt;
212
- particle.x += (particle.vx + particle.swingAmp * Math.cos(particle.swing)) * dt;
213
- particle.y += particle.vy * dt;
214
- particle.rotAngle += particle.rotSpeed * dt;
215
- particle.rotCos = Math.cos(particle.rotAngle);
216
- particle.rotSin = Math.sin(particle.rotAngle);
217
- particle.flipAngle += particle.flipSpeed * dt;
218
- particle.tick += dt;
219
- }
220
-
221
- }