@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,104 @@
1
+ import type { Shape } from './types';
2
+
3
+ const TWO_PI = Math.PI * 2;
4
+
5
+ export const SHAPE_PATHS: Record<Shape, Path2D> = {
6
+ bowtie: (() => {
7
+ const path = new Path2D();
8
+ path.moveTo(-1, -0.7);
9
+ path.lineTo(0, 0);
10
+ path.lineTo(-1, 0.7);
11
+ path.closePath();
12
+ path.moveTo(1, -0.7);
13
+ path.lineTo(0, 0);
14
+ path.lineTo(1, 0.7);
15
+ path.closePath();
16
+ return path;
17
+ })(),
18
+ circle: (() => {
19
+ const path = new Path2D();
20
+ path.ellipse(0, 0, 0.6, 1, 0, 0, TWO_PI);
21
+ return path;
22
+ })(),
23
+ crescent: (() => {
24
+ const path = new Path2D();
25
+ path.arc(0, 0, 1, 0, TWO_PI, false);
26
+ path.arc(0.45, 0, 0.9, 0, TWO_PI, true);
27
+ return path;
28
+ })(),
29
+ diamond: (() => {
30
+ const path = new Path2D();
31
+ path.moveTo(0, -1);
32
+ path.lineTo(0.6, 0);
33
+ path.lineTo(0, 1);
34
+ path.lineTo(-0.6, 0);
35
+ path.closePath();
36
+ return path;
37
+ })(),
38
+ heart: (() => {
39
+ const path = new Path2D();
40
+ path.moveTo(0, 1);
41
+ path.bezierCurveTo(-0.4, 0.55, -1, 0.1, -1, -0.35);
42
+ path.bezierCurveTo(-1, -0.8, -0.5, -1, 0, -0.6);
43
+ path.bezierCurveTo(0.5, -1, 1, -0.8, 1, -0.35);
44
+ path.bezierCurveTo(1, 0.1, 0.4, 0.55, 0, 1);
45
+ path.closePath();
46
+ return path;
47
+ })(),
48
+ hexagon: (() => {
49
+ const path = new Path2D();
50
+ for (let i = 0; i < 6; i++) {
51
+ const angle = (i * Math.PI / 3) - Math.PI / 2;
52
+ if (i === 0) {
53
+ path.moveTo(Math.cos(angle), Math.sin(angle));
54
+ } else {
55
+ path.lineTo(Math.cos(angle), Math.sin(angle));
56
+ }
57
+ }
58
+ path.closePath();
59
+ return path;
60
+ })(),
61
+ ribbon: (() => {
62
+ const path = new Path2D();
63
+ path.rect(-0.2, -1, 0.4, 2);
64
+ return path;
65
+ })(),
66
+ ring: (() => {
67
+ const path = new Path2D();
68
+ path.arc(0, 0, 1, 0, TWO_PI, false);
69
+ path.arc(0, 0, 0.55, 0, TWO_PI, true);
70
+ return path;
71
+ })(),
72
+ square: (() => {
73
+ const path = new Path2D();
74
+ path.rect(-0.7, -0.7, 1.4, 1.4);
75
+ return path;
76
+ })(),
77
+ star: (() => {
78
+ const path = new Path2D();
79
+ for (let i = 0; i < 10; i++) {
80
+ const r = i % 2 === 0 ? 1 : 0.42;
81
+ const angle = (i * Math.PI / 5) - Math.PI / 2;
82
+ if (i === 0) {
83
+ path.moveTo(r * Math.cos(angle), r * Math.sin(angle));
84
+ } else {
85
+ path.lineTo(r * Math.cos(angle), r * Math.sin(angle));
86
+ }
87
+ }
88
+ path.closePath();
89
+ return path;
90
+ })(),
91
+ triangle: (() => {
92
+ const path = new Path2D();
93
+ for (let i = 0; i < 3; i++) {
94
+ const angle = (i * 2 * Math.PI / 3) - Math.PI / 2;
95
+ if (i === 0) {
96
+ path.moveTo(Math.cos(angle), Math.sin(angle));
97
+ } else {
98
+ path.lineTo(Math.cos(angle), Math.sin(angle));
99
+ }
100
+ }
101
+ path.closePath();
102
+ return path;
103
+ })()
104
+ };
@@ -3,6 +3,7 @@ export type Config = {
3
3
  readonly colors: string[];
4
4
  readonly decay: number;
5
5
  readonly gravity: number;
6
+ readonly palette: Palette;
6
7
  readonly particles: number;
7
8
  readonly shapes: Shape[];
8
9
  readonly spread: number;
@@ -48,6 +49,8 @@ export type ParticleConfig = {
48
49
  readonly y: number;
49
50
  };
50
51
 
52
+ export type Palette = 'classic' | 'pastel' | 'vibrant' | 'warm';
53
+
51
54
  export type RGB = [r: number, g: number, b: number];
52
55
 
53
- export type Shape = 'circle' | 'diamond' | 'ribbon' | 'square' | 'star' | 'triangle';
56
+ export type Shape = 'bowtie' | 'circle' | 'crescent' | 'diamond' | 'heart' | 'hexagon' | 'ribbon' | 'ring' | 'square' | 'star' | 'triangle';
package/src/distance.ts CHANGED
@@ -4,5 +4,5 @@ export function distance(a: Point, b: Point): number {
4
4
  let x = a.x - b.x;
5
5
  let y = a.y - b.y;
6
6
 
7
- return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
7
+ return Math.sqrt(x * x + y * y);
8
8
  }
@@ -0,0 +1,19 @@
1
+ import { type Mulberry32, mulberry32 } from '@basmilius/utils';
2
+ import type { DonutsConfig } from './layer';
3
+
4
+ export const MULBERRY: Mulberry32 = mulberry32(13);
5
+
6
+ export const DEFAULT_CONFIG: DonutsConfig = {
7
+ background: '#a51955',
8
+ collisionPadding: 20,
9
+ colors: ['#bd1961', '#da287c'],
10
+ count: 12,
11
+ mouseAvoidance: false,
12
+ mouseAvoidanceRadius: 150,
13
+ mouseAvoidanceStrength: 0.03,
14
+ radiusRange: [60, 90],
15
+ repulsionStrength: 0.02,
16
+ rotationSpeedRange: [0.0005, 0.002],
17
+ speedRange: [0.15, 0.6],
18
+ thickness: 0.39
19
+ };
@@ -0,0 +1,12 @@
1
+ export type Donut = {
2
+ outerRadius: number;
3
+ innerRadius: number;
4
+ x: number;
5
+ y: number;
6
+ angle: number;
7
+ speed: number;
8
+ rotationSpeed: number;
9
+ color: string;
10
+ vx: number;
11
+ vy: number;
12
+ };
@@ -0,0 +1,9 @@
1
+ import { Donuts } from './layer';
2
+ import type { DonutsConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createDonuts(config?: DonutsConfig): Effect<DonutsConfig> {
6
+ return new Donuts(config);
7
+ }
8
+
9
+ export type { DonutsConfig };
@@ -0,0 +1,301 @@
1
+ import { Effect } from '../effect';
2
+ import { DEFAULT_CONFIG, MULBERRY } from './consts';
3
+ import type { Donut } from './donut';
4
+
5
+ export interface DonutsConfig {
6
+ readonly background?: string;
7
+ readonly collisionPadding?: number;
8
+ readonly colors?: string[];
9
+ readonly count?: number;
10
+ readonly mouseAvoidance?: boolean;
11
+ readonly mouseAvoidanceRadius?: number;
12
+ readonly mouseAvoidanceStrength?: number;
13
+ readonly radiusRange?: [number, number];
14
+ readonly repulsionStrength?: number;
15
+ readonly rotationSpeedRange?: [number, number];
16
+ readonly scale?: number;
17
+ readonly speedRange?: [number, number];
18
+ readonly thickness?: number;
19
+ }
20
+
21
+ export class Donuts extends Effect<DonutsConfig> {
22
+ readonly #background: string;
23
+ readonly #collisionPadding: number;
24
+ readonly #colors: string[];
25
+ readonly #count: number;
26
+ #mouseAvoidance: boolean;
27
+ #mouseAvoidanceRadius: number;
28
+ #mouseAvoidanceStrength: number;
29
+ readonly #radiusRange: [number, number];
30
+ #repulsionStrength: number;
31
+ readonly #rotationSpeedRange: [number, number];
32
+ readonly #scale: number;
33
+ readonly #speedRange: [number, number];
34
+ readonly #thickness: number;
35
+ readonly #onMouseMoveBound: (event: MouseEvent) => void;
36
+ readonly #onMouseLeaveBound: () => void;
37
+ #donuts: Donut[] = [];
38
+ #mouseX: number = -1;
39
+ #mouseY: number = -1;
40
+ #mouseOnCanvas: boolean = false;
41
+ #width: number = 960;
42
+ #height: number = 540;
43
+ #initialized: boolean = false;
44
+
45
+ constructor(config: DonutsConfig = {}) {
46
+ super();
47
+
48
+ const scale = config.scale ?? 1;
49
+
50
+ this.#background = config.background ?? DEFAULT_CONFIG.background!;
51
+ this.#collisionPadding = (config.collisionPadding ?? DEFAULT_CONFIG.collisionPadding!) * scale;
52
+ this.#colors = config.colors ?? DEFAULT_CONFIG.colors!;
53
+ this.#count = config.count ?? DEFAULT_CONFIG.count!;
54
+ this.#mouseAvoidance = config.mouseAvoidance ?? DEFAULT_CONFIG.mouseAvoidance!;
55
+ this.#mouseAvoidanceRadius = (config.mouseAvoidanceRadius ?? DEFAULT_CONFIG.mouseAvoidanceRadius!) * scale;
56
+ this.#mouseAvoidanceStrength = config.mouseAvoidanceStrength ?? DEFAULT_CONFIG.mouseAvoidanceStrength!;
57
+ this.#radiusRange = [
58
+ (config.radiusRange ?? DEFAULT_CONFIG.radiusRange!)[0] * scale,
59
+ (config.radiusRange ?? DEFAULT_CONFIG.radiusRange!)[1] * scale
60
+ ];
61
+ this.#repulsionStrength = config.repulsionStrength ?? DEFAULT_CONFIG.repulsionStrength!;
62
+ this.#rotationSpeedRange = config.rotationSpeedRange ?? DEFAULT_CONFIG.rotationSpeedRange!;
63
+ this.#scale = scale;
64
+ this.#speedRange = [
65
+ (config.speedRange ?? DEFAULT_CONFIG.speedRange!)[0] * scale,
66
+ (config.speedRange ?? DEFAULT_CONFIG.speedRange!)[1] * scale
67
+ ];
68
+ this.#thickness = config.thickness ?? DEFAULT_CONFIG.thickness!;
69
+
70
+ this.#onMouseMoveBound = (event: MouseEvent) => this.#onMouseMove(event);
71
+ this.#onMouseLeaveBound = () => this.#onMouseLeave();
72
+ }
73
+
74
+ onResize(width: number, height: number): void {
75
+ this.#width = width;
76
+ this.#height = height;
77
+
78
+ if (!this.#initialized) {
79
+ this.#initialized = true;
80
+ this.#donuts = [];
81
+
82
+ for (let i = 0; i < this.#count; i++) {
83
+ this.#donuts.push(this.#createNonOverlapping());
84
+ }
85
+ }
86
+ }
87
+
88
+ onMount(canvas: HTMLCanvasElement): void {
89
+ if (this.#mouseAvoidance) {
90
+ canvas.addEventListener('mousemove', this.#onMouseMoveBound, {passive: true});
91
+ canvas.addEventListener('mouseleave', this.#onMouseLeaveBound, {passive: true});
92
+ }
93
+ }
94
+
95
+ onUnmount(canvas: HTMLCanvasElement): void {
96
+ canvas.removeEventListener('mousemove', this.#onMouseMoveBound);
97
+ canvas.removeEventListener('mouseleave', this.#onMouseLeaveBound);
98
+ }
99
+
100
+ configure(config: Partial<DonutsConfig>): void {
101
+ if (config.mouseAvoidance !== undefined) {
102
+ this.#mouseAvoidance = config.mouseAvoidance;
103
+ }
104
+ if (config.mouseAvoidanceRadius !== undefined) {
105
+ this.#mouseAvoidanceRadius = config.mouseAvoidanceRadius;
106
+ }
107
+ if (config.mouseAvoidanceStrength !== undefined) {
108
+ this.#mouseAvoidanceStrength = config.mouseAvoidanceStrength;
109
+ }
110
+ if (config.repulsionStrength !== undefined) {
111
+ this.#repulsionStrength = config.repulsionStrength;
112
+ }
113
+ }
114
+
115
+ tick(dt: number, width: number, height: number): void {
116
+ this.#width = width;
117
+ this.#height = height;
118
+
119
+ this.#resolveCollisions(dt);
120
+
121
+ if (this.#mouseAvoidance && this.#mouseOnCanvas) {
122
+ this.#resolveMouseAvoidance(dt);
123
+ }
124
+
125
+ for (const donut of this.#donuts) {
126
+ this.#updateDonut(donut, dt);
127
+ }
128
+ }
129
+
130
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
131
+ ctx.globalAlpha = 1;
132
+ ctx.fillStyle = this.#background;
133
+ ctx.fillRect(0, 0, width, height);
134
+
135
+ for (const donut of this.#donuts) {
136
+ const cos = Math.cos(donut.angle);
137
+ const sin = Math.sin(donut.angle);
138
+ ctx.setTransform(cos, sin, -sin, cos, donut.x, donut.y);
139
+
140
+ ctx.beginPath();
141
+ ctx.arc(0, 0, donut.outerRadius, 0, Math.PI * 2);
142
+ ctx.arc(0, 0, donut.innerRadius, 0, Math.PI * 2, true);
143
+ ctx.closePath();
144
+
145
+ ctx.fillStyle = donut.color;
146
+ ctx.fill();
147
+ }
148
+
149
+ ctx.resetTransform();
150
+ }
151
+
152
+ #updateDonut(donut: Donut, dt: number): void {
153
+ const currentSpeed = Math.sqrt(donut.vx * donut.vx + donut.vy * donut.vy);
154
+
155
+ if (currentSpeed > donut.speed) {
156
+ const damping = Math.pow(0.995, dt);
157
+ donut.vx *= damping;
158
+ donut.vy *= damping;
159
+ }
160
+
161
+ donut.x += donut.vx * dt;
162
+ donut.y += donut.vy * dt;
163
+ donut.angle += donut.rotationSpeed * dt;
164
+
165
+ const limit = donut.outerRadius * 0.5;
166
+ const width = this.#width;
167
+ const height = this.#height;
168
+
169
+ if (donut.x < -limit) {
170
+ donut.x = -limit;
171
+ donut.vx = Math.abs(donut.vx);
172
+ }
173
+
174
+ if (donut.x > width + limit) {
175
+ donut.x = width + limit;
176
+ donut.vx = -Math.abs(donut.vx);
177
+ }
178
+
179
+ if (donut.y < -limit) {
180
+ donut.y = -limit;
181
+ donut.vy = Math.abs(donut.vy);
182
+ }
183
+
184
+ if (donut.y > height + limit) {
185
+ donut.y = height + limit;
186
+ donut.vy = -Math.abs(donut.vy);
187
+ }
188
+ }
189
+
190
+ #onMouseMove(event: MouseEvent): void {
191
+ const target = event.currentTarget as HTMLCanvasElement;
192
+ const rect = target.getBoundingClientRect();
193
+ this.#mouseX = event.clientX - rect.left;
194
+ this.#mouseY = event.clientY - rect.top;
195
+ this.#mouseOnCanvas = true;
196
+ }
197
+
198
+ #onMouseLeave(): void {
199
+ this.#mouseOnCanvas = false;
200
+ }
201
+
202
+ #resolveMouseAvoidance(dt: number): void {
203
+ const radius = this.#mouseAvoidanceRadius;
204
+ const strength = this.#mouseAvoidanceStrength;
205
+ const mx = this.#mouseX;
206
+ const my = this.#mouseY;
207
+
208
+ for (const donut of this.#donuts) {
209
+ const dx = donut.x - mx;
210
+ const dy = donut.y - my;
211
+ const dist = Math.sqrt(dx * dx + dy * dy);
212
+ const minDist = donut.outerRadius + radius;
213
+
214
+ if (dist < minDist && dist > 0) {
215
+ const overlap = minDist - dist;
216
+ const nx = dx / dist;
217
+ const ny = dy / dist;
218
+ const force = overlap * strength * dt;
219
+
220
+ donut.vx += nx * force;
221
+ donut.vy += ny * force;
222
+ }
223
+ }
224
+ }
225
+
226
+ #resolveCollisions(dt: number): void {
227
+ const padding = this.#collisionPadding;
228
+ const strength = this.#repulsionStrength;
229
+
230
+ for (let i = 0; i < this.#donuts.length; i++) {
231
+ for (let j = i + 1; j < this.#donuts.length; j++) {
232
+ const a = this.#donuts[i];
233
+ const b = this.#donuts[j];
234
+ const dx = b.x - a.x;
235
+ const dy = b.y - a.y;
236
+ const dist = Math.sqrt(dx * dx + dy * dy);
237
+ const minDist = a.outerRadius + b.outerRadius + padding;
238
+
239
+ if (dist < minDist && dist > 0) {
240
+ const overlap = minDist - dist;
241
+ const nx = dx / dist;
242
+ const ny = dy / dist;
243
+ const force = overlap * strength * dt;
244
+
245
+ a.vx -= nx * force;
246
+ a.vy -= ny * force;
247
+ b.vx += nx * force;
248
+ b.vy += ny * force;
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ #createDonut(): Donut {
255
+ const outerRadius = this.#rand(this.#radiusRange[0], this.#radiusRange[1]);
256
+ const innerRadius = outerRadius * (1 - this.#thickness);
257
+ const speed = this.#rand(this.#speedRange[0], this.#speedRange[1]);
258
+ const direction = MULBERRY.next() * Math.PI * 2;
259
+
260
+ return {
261
+ outerRadius,
262
+ innerRadius,
263
+ x: this.#rand(-outerRadius, this.#width + outerRadius),
264
+ y: this.#rand(-outerRadius, this.#height + outerRadius),
265
+ angle: MULBERRY.next() * Math.PI * 2,
266
+ speed,
267
+ rotationSpeed: this.#rand(this.#rotationSpeedRange[0], this.#rotationSpeedRange[1]) * (MULBERRY.next() > 0.5 ? 1 : -1),
268
+ color: this.#colors[Math.floor(MULBERRY.next() * this.#colors.length)],
269
+ vx: Math.cos(direction) * speed,
270
+ vy: Math.sin(direction) * speed
271
+ };
272
+ }
273
+
274
+ #createNonOverlapping(maxAttempts: number = 200): Donut {
275
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
276
+ const donut = this.#createDonut();
277
+
278
+ if (!this.#overlapsAny(donut)) {
279
+ return donut;
280
+ }
281
+ }
282
+
283
+ return this.#createDonut();
284
+ }
285
+
286
+ #overlapsAny(donut: Donut): boolean {
287
+ const minDist = this.#collisionPadding;
288
+
289
+ return this.#donuts.some((other) => {
290
+ const dx = donut.x - other.x;
291
+ const dy = donut.y - other.y;
292
+ const dist = Math.sqrt(dx * dx + dy * dy);
293
+
294
+ return dist < donut.outerRadius + other.outerRadius + minDist;
295
+ });
296
+ }
297
+
298
+ #rand(min: number, max: number): number {
299
+ return MULBERRY.next() * (max - min) + min;
300
+ }
301
+ }
package/src/effect.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { SimulationCanvas } from './simulation-canvas';
2
+ import type { EdgeFade, EdgeFadeSide, SimulationLayer } from './layer';
3
+
4
+ export type { EdgeFade, EdgeFadeSide };
5
+
6
+ /**
7
+ * Base class for all visual effects. Implements the internal SimulationLayer interface
8
+ * so that effects can be used both standalone (via mount()) and composed in a Scene.
9
+ *
10
+ * @example Standalone usage
11
+ * const snow = new Snow({ particles: 200 });
12
+ * snow.mount(canvas).start();
13
+ *
14
+ * @example Scene composition
15
+ * const scene = new Scene()
16
+ * .mount(canvas)
17
+ * .layer(new Aurora())
18
+ * .layer(new Snow())
19
+ * .start();
20
+ */
21
+ export abstract class Effect<TConfig = Record<string, unknown>> implements SimulationLayer {
22
+ #canvas: SimulationCanvas | null = null;
23
+ fade: EdgeFade | null = null;
24
+
25
+ abstract tick(dt: number, width: number, height: number): void;
26
+
27
+ abstract draw(ctx: CanvasRenderingContext2D, width: number, height: number): void;
28
+
29
+ configure(_config: Partial<TConfig>): void {
30
+ }
31
+
32
+ onResize(_width: number, _height: number): void {
33
+ }
34
+
35
+ onMount(_canvas: HTMLCanvasElement): void {
36
+ }
37
+
38
+ onUnmount(_canvas: HTMLCanvasElement): void {
39
+ }
40
+
41
+ /**
42
+ * Apply an edge fade mask when rendering this effect standalone or in a Scene.
43
+ */
44
+ withFade(fade: EdgeFade): this {
45
+ this.fade = fade;
46
+ return this;
47
+ }
48
+
49
+ /**
50
+ * Mount this effect to a canvas element or CSS selector, creating the render loop.
51
+ * Must be called before start().
52
+ */
53
+ mount(canvas: HTMLCanvasElement | string, options: CanvasRenderingContext2DSettings = {colorSpace: 'display-p3'}): this {
54
+ if (typeof canvas === 'string') {
55
+ const el = document.querySelector<HTMLCanvasElement>(canvas);
56
+
57
+ if (!el) {
58
+ throw new Error(`Effect.mount(): no element found for selector "${canvas}".`);
59
+ }
60
+
61
+ canvas = el;
62
+ }
63
+
64
+ this.#canvas = new SimulationCanvas(canvas, this as unknown as SimulationLayer, 60, options);
65
+ return this;
66
+ }
67
+
68
+ /**
69
+ * Remove this effect from its canvas and clean up the render loop.
70
+ */
71
+ unmount(): this {
72
+ this.#canvas?.destroy();
73
+ this.#canvas = null;
74
+ return this;
75
+ }
76
+
77
+ /**
78
+ * Start the render loop. Call mount() first.
79
+ */
80
+ start(): this {
81
+ this.#canvas?.start();
82
+ return this;
83
+ }
84
+
85
+ /**
86
+ * Pause rendering without destroying state. Use resume() to continue.
87
+ */
88
+ pause(): this {
89
+ this.#canvas?.pause();
90
+ return this;
91
+ }
92
+
93
+ /**
94
+ * Resume rendering after a pause().
95
+ */
96
+ resume(): this {
97
+ this.#canvas?.resume();
98
+ return this;
99
+ }
100
+
101
+ /**
102
+ * Stop rendering and call onUnmount(). Safe to call multiple times.
103
+ */
104
+ destroy(): void {
105
+ this.unmount();
106
+ }
107
+ }
package/src/fade.ts ADDED
@@ -0,0 +1,87 @@
1
+ import type { EdgeFade, EdgeFadeSide } from './layer';
2
+
3
+ function parseSide(side: EdgeFadeSide): [number, number] {
4
+ return typeof side === 'number' ? [0, side] : side;
5
+ }
6
+
7
+ export function applyEdgeFade(ctx: CanvasRenderingContext2D, width: number, height: number, fade: EdgeFade): void {
8
+ ctx.globalCompositeOperation = 'destination-out';
9
+
10
+ if (fade.top !== undefined) {
11
+ const [near, far] = parseSide(fade.top);
12
+ const nearPx = near * height;
13
+ const farPx = far * height;
14
+
15
+ if (nearPx > 0) {
16
+ ctx.fillStyle = 'rgba(0,0,0,1)';
17
+ ctx.fillRect(0, 0, width, nearPx);
18
+ }
19
+
20
+ if (farPx > nearPx) {
21
+ const gradient = ctx.createLinearGradient(0, nearPx, 0, farPx);
22
+ gradient.addColorStop(0, 'rgba(0,0,0,1)');
23
+ gradient.addColorStop(1, 'rgba(0,0,0,0)');
24
+ ctx.fillStyle = gradient;
25
+ ctx.fillRect(0, nearPx, width, farPx - nearPx);
26
+ }
27
+ }
28
+
29
+ if (fade.bottom !== undefined) {
30
+ const [near, far] = parseSide(fade.bottom);
31
+ const nearPx = near * height;
32
+ const farPx = far * height;
33
+
34
+ if (nearPx > 0) {
35
+ ctx.fillStyle = 'rgba(0,0,0,1)';
36
+ ctx.fillRect(0, height - nearPx, width, nearPx);
37
+ }
38
+
39
+ if (farPx > nearPx) {
40
+ const gradient = ctx.createLinearGradient(0, height - farPx, 0, height - nearPx);
41
+ gradient.addColorStop(0, 'rgba(0,0,0,0)');
42
+ gradient.addColorStop(1, 'rgba(0,0,0,1)');
43
+ ctx.fillStyle = gradient;
44
+ ctx.fillRect(0, height - farPx, width, farPx - nearPx);
45
+ }
46
+ }
47
+
48
+ if (fade.left !== undefined) {
49
+ const [near, far] = parseSide(fade.left);
50
+ const nearPx = near * width;
51
+ const farPx = far * width;
52
+
53
+ if (nearPx > 0) {
54
+ ctx.fillStyle = 'rgba(0,0,0,1)';
55
+ ctx.fillRect(0, 0, nearPx, height);
56
+ }
57
+
58
+ if (farPx > nearPx) {
59
+ const gradient = ctx.createLinearGradient(nearPx, 0, farPx, 0);
60
+ gradient.addColorStop(0, 'rgba(0,0,0,1)');
61
+ gradient.addColorStop(1, 'rgba(0,0,0,0)');
62
+ ctx.fillStyle = gradient;
63
+ ctx.fillRect(nearPx, 0, farPx - nearPx, height);
64
+ }
65
+ }
66
+
67
+ if (fade.right !== undefined) {
68
+ const [near, far] = parseSide(fade.right);
69
+ const nearPx = near * width;
70
+ const farPx = far * width;
71
+
72
+ if (nearPx > 0) {
73
+ ctx.fillStyle = 'rgba(0,0,0,1)';
74
+ ctx.fillRect(width - nearPx, 0, nearPx, height);
75
+ }
76
+
77
+ if (farPx > nearPx) {
78
+ const gradient = ctx.createLinearGradient(width - farPx, 0, width - nearPx, 0);
79
+ gradient.addColorStop(0, 'rgba(0,0,0,0)');
80
+ gradient.addColorStop(1, 'rgba(0,0,0,1)');
81
+ ctx.fillStyle = gradient;
82
+ ctx.fillRect(width - farPx, 0, farPx - nearPx, height);
83
+ }
84
+ }
85
+
86
+ ctx.globalCompositeOperation = 'source-over';
87
+ }
@@ -0,0 +1,3 @@
1
+ import { type Mulberry32, mulberry32 } from '@basmilius/utils';
2
+
3
+ export const MULBERRY: Mulberry32 = mulberry32(13);
@@ -0,0 +1,12 @@
1
+ import { Fireflies } from './layer';
2
+ import type { FirefliesConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createFireflies(config?: FirefliesConfig): Effect<FirefliesConfig> {
6
+ return new Fireflies(config);
7
+ }
8
+
9
+ export { FireflyParticle, createFireflySprite } from './particle';
10
+ export type { FirefliesConfig };
11
+ export type { FireflyParticleConfig } from './particle';
12
+ export type { Firefly } from './types';