@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,53 @@
1
+ export type Config = {
2
+ readonly angle: number;
3
+ readonly colors: string[];
4
+ readonly decay: number;
5
+ readonly gravity: number;
6
+ readonly particles: number;
7
+ readonly shapes: Shape[];
8
+ readonly spread: number;
9
+ readonly ticks: number;
10
+ readonly startVelocity: number;
11
+ readonly x: number;
12
+ readonly y: number;
13
+ };
14
+
15
+ export type Particle = {
16
+ colorStr: string;
17
+ decay: number;
18
+ flipAngle: number;
19
+ flipSpeed: number;
20
+ gravity: number;
21
+ rotAngle: number;
22
+ rotCos: number;
23
+ rotSin: number;
24
+ rotSpeed: number;
25
+ shape: Shape;
26
+ size: number;
27
+ swing: number;
28
+ swingAmp: number;
29
+ swingSpeed: number;
30
+ tick: number;
31
+ totalTicks: number;
32
+ vx: number;
33
+ vy: number;
34
+ x: number;
35
+ y: number;
36
+ };
37
+
38
+ export type ParticleConfig = {
39
+ readonly angle: number;
40
+ readonly color: RGB;
41
+ readonly decay: number;
42
+ readonly gravity: number;
43
+ readonly shape: Shape;
44
+ readonly spread: number;
45
+ readonly startVelocity: number;
46
+ readonly ticks: number;
47
+ readonly x: number;
48
+ readonly y: number;
49
+ };
50
+
51
+ export type RGB = [r: number, g: number, b: number];
52
+
53
+ export type Shape = 'circle' | 'diamond' | 'ribbon' | 'square' | 'star' | 'triangle';
@@ -0,0 +1,8 @@
1
+ import type { Point } from './point';
2
+
3
+ export function distance(a: Point, b: Point): number {
4
+ let x = a.x - b.x;
5
+ let y = a.y - b.y;
6
+
7
+ return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
8
+ }
@@ -0,0 +1,4 @@
1
+ import { mulberry32, type Mulberry32 } from '@basmilius/utils';
2
+
3
+ export const MULBERRY: Mulberry32 = mulberry32(13);
4
+ export const FIREWORK_TRAIL_MEMORY = 6;
@@ -0,0 +1,227 @@
1
+ import type { Point } from '../point';
2
+ import { MULBERRY } from './consts';
3
+ import { EXPLOSION_CONFIGS, type ExplosionConfig, type ExplosionType, type ParticleShape } from './types';
4
+
5
+ const PERSPECTIVE = 800;
6
+
7
+ export class Explosion {
8
+ readonly #position: Point;
9
+ readonly #angle: number;
10
+ readonly #brightness: number;
11
+ readonly #config: ExplosionConfig;
12
+ readonly #decay: number;
13
+ readonly #hue: number;
14
+ readonly #lineWidth: number;
15
+ readonly #shape: ParticleShape;
16
+ readonly #trail: Point[] = [];
17
+ readonly #type: ExplosionType;
18
+ #alpha: number = 1;
19
+ #depthScale: number = 1;
20
+ #hasCrackled: boolean = false;
21
+ #hasSplit: boolean = false;
22
+ #speed: number;
23
+ #sparkleTimer: number = 0;
24
+ #vz: number;
25
+ #z: number = 0;
26
+
27
+ get angle(): number {
28
+ return this.#angle;
29
+ }
30
+
31
+ get hue(): number {
32
+ return this.#hue;
33
+ }
34
+
35
+ get isDead(): boolean {
36
+ return this.#alpha <= 0;
37
+ }
38
+
39
+ get position(): Point {
40
+ return this.#position;
41
+ }
42
+
43
+ get type(): ExplosionType {
44
+ return this.#type;
45
+ }
46
+
47
+ constructor(position: Point, hue: number, lineWidth: number, type: ExplosionType, scale: number = 1, angle?: number, speed?: number, vz?: number) {
48
+ const config = EXPLOSION_CONFIGS[type];
49
+
50
+ this.#config = config;
51
+ this.#type = type;
52
+ this.#shape = config.shape;
53
+ this.#position = {...position};
54
+ this.#alpha = 1;
55
+ this.#angle = angle ?? MULBERRY.nextBetween(0, Math.PI * 2);
56
+ this.#brightness = MULBERRY.nextBetween(config.brightness[0], config.brightness[1]);
57
+ this.#decay = MULBERRY.nextBetween(config.decay[0], config.decay[1]);
58
+ this.#hue = hue + MULBERRY.nextBetween(-config.hueVariation, config.hueVariation);
59
+ this.#lineWidth = lineWidth * config.lineWidthScale;
60
+ this.#speed = (speed ?? MULBERRY.nextBetween(config.speed[0], config.speed[1])) * scale;
61
+
62
+ if (vz !== undefined) {
63
+ this.#vz = vz * scale;
64
+ } else if (config.spread3d) {
65
+ this.#vz = MULBERRY.nextBetween(-this.#speed * 0.5, this.#speed * 0.5);
66
+ } else {
67
+ this.#vz = 0;
68
+ }
69
+
70
+ for (let i = 0; i < config.trailMemory; i++) {
71
+ this.#trail.push({...position});
72
+ }
73
+ }
74
+
75
+ checkCrackle(): boolean {
76
+ if (this.#type !== 'crackle' || this.#hasCrackled) {
77
+ return false;
78
+ }
79
+
80
+ if (this.#alpha <= this.#decay * 3) {
81
+ this.#hasCrackled = true;
82
+ return true;
83
+ }
84
+
85
+ return false;
86
+ }
87
+
88
+ checkSplit(): boolean {
89
+ if (this.#type !== 'crossette' || this.#hasSplit) {
90
+ return false;
91
+ }
92
+
93
+ if (this.#alpha < 0.5) {
94
+ this.#hasSplit = true;
95
+ return true;
96
+ }
97
+
98
+ return false;
99
+ }
100
+
101
+ draw(ctx: CanvasRenderingContext2D): void {
102
+ if (this.#config.strobe && this.#sparkleTimer % 6 < 3) {
103
+ return;
104
+ }
105
+
106
+ const ds = this.#depthScale;
107
+ const trailEnd = this.#trail[this.#trail.length - 1];
108
+ const effectiveWidth = this.#shape === 'line' ? this.#lineWidth * ds : this.#lineWidth * 0.4 * ds;
109
+ const effectiveAlpha = this.#alpha * Math.min(ds, 1.2);
110
+
111
+ ctx.save();
112
+ ctx.lineCap = 'round';
113
+
114
+ if (this.#trail.length > 2) {
115
+ for (let i = this.#trail.length - 1; i > 0; i--) {
116
+ const progress = i / this.#trail.length;
117
+ const alpha = (1 - progress) * effectiveAlpha * 0.5;
118
+ const width = effectiveWidth * (1 - progress * 0.4);
119
+
120
+ ctx.beginPath();
121
+ ctx.moveTo(this.#trail[i].x, this.#trail[i].y);
122
+ ctx.lineTo(this.#trail[i - 1].x, this.#trail[i - 1].y);
123
+ ctx.lineWidth = width;
124
+ ctx.strokeStyle = `hsla(${this.#hue}, 100%, ${this.#brightness * 0.7}%, ${alpha})`;
125
+ ctx.stroke();
126
+ }
127
+ }
128
+
129
+ ctx.beginPath();
130
+ ctx.moveTo(trailEnd.x, trailEnd.y);
131
+ ctx.lineTo(this.#position.x, this.#position.y);
132
+ ctx.lineWidth = effectiveWidth;
133
+ ctx.strokeStyle = `hsla(${this.#hue}, 100%, ${this.#brightness}%, ${effectiveAlpha})`;
134
+ ctx.stroke();
135
+
136
+ if (this.#shape !== 'line') {
137
+ this.#drawShape(ctx, ds, effectiveAlpha);
138
+ }
139
+
140
+ if (this.#config.sparkle && this.#sparkleTimer % 4 < 2) {
141
+ ctx.beginPath();
142
+ ctx.arc(this.#position.x, this.#position.y, this.#lineWidth * 0.8 * ds, 0, Math.PI * 2);
143
+ ctx.fillStyle = `hsla(${this.#hue}, 30%, 95%, ${effectiveAlpha * 0.9})`;
144
+ ctx.fill();
145
+ }
146
+
147
+ ctx.restore();
148
+ }
149
+
150
+ tick(): void {
151
+ this.#trail.pop();
152
+ this.#trail.unshift({...this.#position});
153
+
154
+ this.#speed *= this.#config.friction;
155
+ this.#vz *= this.#config.friction;
156
+
157
+ this.#position.x += Math.cos(this.#angle) * this.#speed;
158
+ this.#position.y += Math.sin(this.#angle) * this.#speed + this.#config.gravity;
159
+ this.#z += this.#vz;
160
+
161
+ this.#depthScale = PERSPECTIVE / (PERSPECTIVE + this.#z);
162
+
163
+ this.#alpha -= this.#decay;
164
+ this.#sparkleTimer++;
165
+ }
166
+
167
+ #drawShape(ctx: CanvasRenderingContext2D, ds: number, alpha: number): void {
168
+ const size = this.#lineWidth * 1.2 * ds;
169
+ const color = `hsla(${this.#hue}, 100%, ${this.#brightness}%, ${alpha})`;
170
+
171
+ switch (this.#shape) {
172
+ case 'circle':
173
+ ctx.beginPath();
174
+ ctx.arc(this.#position.x, this.#position.y, size * 0.5, 0, Math.PI * 2);
175
+ ctx.fillStyle = color;
176
+ ctx.fill();
177
+ break;
178
+
179
+ case 'star':
180
+ this.#drawStarPath(ctx, this.#position.x, this.#position.y, size * 0.7, 4, this.#sparkleTimer * 0.15);
181
+ ctx.fillStyle = `hsla(${this.#hue}, 60%, ${Math.min(this.#brightness + 10, 100)}%, ${alpha})`;
182
+ ctx.fill();
183
+ break;
184
+
185
+ case 'diamond':
186
+ this.#drawDiamondPath(ctx, this.#position.x, this.#position.y, size * 0.6, this.#angle);
187
+ ctx.fillStyle = color;
188
+ ctx.fill();
189
+ break;
190
+ }
191
+ }
192
+
193
+ #drawStarPath(ctx: CanvasRenderingContext2D, cx: number, cy: number, radius: number, points: number, rotation: number): void {
194
+ const innerRadius = radius * 0.4;
195
+ const totalPoints = points * 2;
196
+
197
+ ctx.beginPath();
198
+
199
+ for (let i = 0; i < totalPoints; i++) {
200
+ const r = i % 2 === 0 ? radius : innerRadius;
201
+ const angle = (i * Math.PI / points) + rotation;
202
+ const px = cx + Math.cos(angle) * r;
203
+ const py = cy + Math.sin(angle) * r;
204
+
205
+ if (i === 0) {
206
+ ctx.moveTo(px, py);
207
+ } else {
208
+ ctx.lineTo(px, py);
209
+ }
210
+ }
211
+
212
+ ctx.closePath();
213
+ }
214
+
215
+ #drawDiamondPath(ctx: CanvasRenderingContext2D, cx: number, cy: number, size: number, rotation: number): void {
216
+ const cos = Math.cos(rotation);
217
+ const sin = Math.sin(rotation);
218
+ const hw = size * 0.5;
219
+
220
+ ctx.beginPath();
221
+ ctx.moveTo(cx + cos * size - sin * 0, cy + sin * size + cos * 0);
222
+ ctx.lineTo(cx + cos * 0 - sin * hw, cy + sin * 0 + cos * hw);
223
+ ctx.lineTo(cx + cos * -size - sin * 0, cy + sin * -size + cos * 0);
224
+ ctx.lineTo(cx + cos * 0 - sin * -hw, cy + sin * 0 + cos * -hw);
225
+ ctx.closePath();
226
+ }
227
+ }
@@ -0,0 +1,123 @@
1
+ import { distance } from '../distance';
2
+ import type { Point } from '../point';
3
+ import { FIREWORK_TRAIL_MEMORY, MULBERRY } from './consts';
4
+ import { Spark } from './spark';
5
+
6
+ export class Firework extends EventTarget {
7
+ readonly #position: Point;
8
+ readonly #startPosition: Point;
9
+ readonly #acceleration: number = 1.05;
10
+ readonly #angle: number;
11
+ readonly #baseSize: number;
12
+ readonly #brightness: number = MULBERRY.nextBetween(55, 75);
13
+ readonly #distance: number;
14
+ readonly #hue: number;
15
+ readonly #tailWidth: number;
16
+ readonly #trail: Point[] = [];
17
+ #distanceTraveled: number = 0;
18
+ #speed: number = 1;
19
+ #sparkTimer: number = 0;
20
+ #pendingSparks: Spark[] = [];
21
+
22
+ get position(): Point {
23
+ return this.#position;
24
+ }
25
+
26
+ get hue(): number {
27
+ return this.#hue;
28
+ }
29
+
30
+ constructor(start: Point, target: Point, hue: number, tailWidth: number, baseSize: number) {
31
+ super();
32
+
33
+ this.#hue = hue;
34
+ this.#tailWidth = tailWidth;
35
+ this.#baseSize = baseSize;
36
+ this.#position = {...start};
37
+ this.#startPosition = {...start};
38
+ this.#angle = Math.atan2(target.y - start.y, target.x - start.x);
39
+ this.#distance = distance(start, target);
40
+
41
+ for (let i = 0; i < FIREWORK_TRAIL_MEMORY; i++) {
42
+ this.#trail.push({...start});
43
+ }
44
+ }
45
+
46
+ collectSparks(): Spark[] {
47
+ const sparks = this.#pendingSparks;
48
+ this.#pendingSparks = [];
49
+ return sparks;
50
+ }
51
+
52
+ draw(ctx: CanvasRenderingContext2D): void {
53
+ ctx.save();
54
+ ctx.lineCap = 'round';
55
+
56
+ for (let i = this.#trail.length - 1; i > 0; i--) {
57
+ const progress = i / this.#trail.length;
58
+ const alpha = (1 - progress) * 0.8;
59
+ const width = this.#tailWidth * (1 - progress * 0.5);
60
+ const hue = this.#hue + MULBERRY.nextBetween(-15, 15);
61
+
62
+ ctx.beginPath();
63
+ ctx.moveTo(this.#trail[i].x, this.#trail[i].y);
64
+ ctx.lineTo(this.#trail[i - 1].x, this.#trail[i - 1].y);
65
+ ctx.lineWidth = width;
66
+ ctx.strokeStyle = `hsla(${hue}, 100%, ${this.#brightness}%, ${alpha})`;
67
+ ctx.stroke();
68
+ }
69
+
70
+ ctx.shadowBlur = this.#baseSize * 4;
71
+ ctx.shadowColor = `hsl(${this.#hue}, 100%, 60%)`;
72
+
73
+ ctx.beginPath();
74
+ ctx.moveTo(this.#trail[0].x, this.#trail[0].y);
75
+ ctx.lineTo(this.#position.x, this.#position.y);
76
+ ctx.lineWidth = this.#tailWidth;
77
+ ctx.strokeStyle = `hsl(${this.#hue}, 100%, ${this.#brightness}%)`;
78
+ ctx.stroke();
79
+
80
+ ctx.shadowBlur = this.#baseSize * 6;
81
+ ctx.shadowColor = `hsl(${this.#hue}, 80%, 80%)`;
82
+ ctx.beginPath();
83
+ ctx.arc(this.#position.x, this.#position.y, this.#tailWidth * 0.6, 0, Math.PI * 2);
84
+ ctx.fillStyle = `hsl(${this.#hue}, 20%, 92%)`;
85
+ ctx.fill();
86
+
87
+ ctx.restore();
88
+ }
89
+
90
+ tick(): void {
91
+ this.#trail.pop();
92
+ this.#trail.unshift({...this.#position});
93
+
94
+ this.#speed *= this.#acceleration;
95
+
96
+ const vx = Math.cos(this.#angle) * this.#speed;
97
+ const vy = Math.sin(this.#angle) * this.#speed;
98
+
99
+ this.#distanceTraveled = distance(this.#startPosition, {
100
+ x: this.#position.x + vx,
101
+ y: this.#position.y + vy
102
+ });
103
+
104
+ if (this.#distanceTraveled >= this.#distance) {
105
+ this.dispatchEvent(new CustomEvent('remove'));
106
+ return;
107
+ }
108
+
109
+ this.#position.x += vx;
110
+ this.#position.y += vy;
111
+
112
+ this.#sparkTimer++;
113
+
114
+ if (this.#sparkTimer % 3 === 0) {
115
+ this.#pendingSparks.push(new Spark(
116
+ this.#position,
117
+ this.#hue,
118
+ -vx * 0.1,
119
+ -vy * 0.1
120
+ ));
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,3 @@
1
+ export * from './simulation';
2
+ export { FIREWORK_VARIANTS } from './types';
3
+ export type { ExplosionType, FireworkSimulationConfig, FireworkVariant, ParticleShape } from './types';