@basmilius/sparkle 2.4.0 → 2.5.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 (126) hide show
  1. package/dist/index.d.mts +637 -1
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +6964 -2577
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +1 -1
  6. package/src/balloons/layer.ts +4 -3
  7. package/src/black-hole/consts.ts +3 -0
  8. package/src/black-hole/index.ts +10 -0
  9. package/src/black-hole/layer.ts +193 -0
  10. package/src/black-hole/types.ts +8 -0
  11. package/src/boids/consts.ts +8 -0
  12. package/src/boids/index.ts +9 -0
  13. package/src/boids/layer.ts +245 -0
  14. package/src/boids/types.ts +7 -0
  15. package/src/butterflies/consts.ts +3 -0
  16. package/src/butterflies/index.ts +9 -0
  17. package/src/butterflies/layer.ts +246 -0
  18. package/src/butterflies/types.ts +23 -0
  19. package/src/caustics/consts.ts +3 -0
  20. package/src/caustics/index.ts +9 -0
  21. package/src/caustics/layer.ts +107 -0
  22. package/src/clouds/consts.ts +3 -0
  23. package/src/clouds/index.ts +9 -0
  24. package/src/clouds/layer.ts +167 -0
  25. package/src/clouds/types.ts +9 -0
  26. package/src/confetti/layer.ts +3 -2
  27. package/src/constellation/consts.ts +3 -0
  28. package/src/constellation/index.ts +10 -0
  29. package/src/constellation/layer.ts +256 -0
  30. package/src/constellation/types.ts +11 -0
  31. package/src/coral-reef/consts.ts +3 -0
  32. package/src/coral-reef/index.ts +10 -0
  33. package/src/coral-reef/layer.ts +276 -0
  34. package/src/coral-reef/types.ts +31 -0
  35. package/src/crystallization/consts.ts +3 -0
  36. package/src/crystallization/index.ts +10 -0
  37. package/src/crystallization/layer.ts +318 -0
  38. package/src/crystallization/types.ts +25 -0
  39. package/src/digital-rain/consts.ts +7 -0
  40. package/src/digital-rain/index.ts +10 -0
  41. package/src/digital-rain/layer.ts +195 -0
  42. package/src/digital-rain/types.ts +10 -0
  43. package/src/donuts/layer.ts +5 -3
  44. package/src/glitch/consts.ts +3 -0
  45. package/src/glitch/index.ts +9 -0
  46. package/src/glitch/layer.ts +231 -0
  47. package/src/glitch/types.ts +28 -0
  48. package/src/gradient-flow/consts.ts +3 -0
  49. package/src/gradient-flow/index.ts +9 -0
  50. package/src/gradient-flow/layer.ts +134 -0
  51. package/src/gradient-flow/types.ts +8 -0
  52. package/src/hologram/consts.ts +5 -0
  53. package/src/hologram/index.ts +9 -0
  54. package/src/hologram/layer.ts +205 -0
  55. package/src/hologram/types.ts +20 -0
  56. package/src/hyper-space/consts.ts +3 -0
  57. package/src/hyper-space/index.ts +10 -0
  58. package/src/hyper-space/layer.ts +167 -0
  59. package/src/hyper-space/types.ts +8 -0
  60. package/src/index.ts +29 -0
  61. package/src/interference/consts.ts +9 -0
  62. package/src/interference/index.ts +9 -0
  63. package/src/interference/layer.ts +129 -0
  64. package/src/kaleidoscope/consts.ts +12 -0
  65. package/src/kaleidoscope/index.ts +9 -0
  66. package/src/kaleidoscope/layer.ts +213 -0
  67. package/src/kaleidoscope/types.ts +19 -0
  68. package/src/lanterns/layer.ts +3 -2
  69. package/src/lava/consts.ts +3 -0
  70. package/src/lava/index.ts +9 -0
  71. package/src/lava/layer.ts +152 -0
  72. package/src/lava/types.ts +13 -0
  73. package/src/leaves/layer.ts +3 -2
  74. package/src/murmuration/consts.ts +3 -0
  75. package/src/murmuration/index.ts +10 -0
  76. package/src/murmuration/layer.ts +279 -0
  77. package/src/murmuration/types.ts +7 -0
  78. package/src/nebula/consts.ts +3 -0
  79. package/src/nebula/index.ts +10 -0
  80. package/src/nebula/layer.ts +150 -0
  81. package/src/nebula/types.ts +20 -0
  82. package/src/neon/consts.ts +5 -0
  83. package/src/neon/index.ts +9 -0
  84. package/src/neon/layer.ts +213 -0
  85. package/src/neon/types.ts +18 -0
  86. package/src/petals/layer.ts +3 -2
  87. package/src/pollen/consts.ts +3 -0
  88. package/src/pollen/index.ts +10 -0
  89. package/src/pollen/layer.ts +181 -0
  90. package/src/pollen/types.ts +10 -0
  91. package/src/popcorn/consts.ts +3 -0
  92. package/src/popcorn/index.ts +10 -0
  93. package/src/popcorn/layer.ts +218 -0
  94. package/src/popcorn/types.ts +13 -0
  95. package/src/portal/consts.ts +3 -0
  96. package/src/portal/index.ts +10 -0
  97. package/src/portal/layer.ts +251 -0
  98. package/src/portal/types.ts +10 -0
  99. package/src/pulse-grid/consts.ts +3 -0
  100. package/src/pulse-grid/index.ts +10 -0
  101. package/src/pulse-grid/layer.ts +185 -0
  102. package/src/pulse-grid/types.ts +8 -0
  103. package/src/roots/consts.ts +3 -0
  104. package/src/roots/index.ts +9 -0
  105. package/src/roots/layer.ts +218 -0
  106. package/src/roots/types.ts +23 -0
  107. package/src/smoke/consts.ts +3 -0
  108. package/src/smoke/index.ts +9 -0
  109. package/src/smoke/layer.ts +182 -0
  110. package/src/smoke/types.ts +14 -0
  111. package/src/snow/layer.ts +3 -2
  112. package/src/topography/consts.ts +3 -0
  113. package/src/topography/index.ts +9 -0
  114. package/src/topography/layer.ts +141 -0
  115. package/src/tornado/consts.ts +3 -0
  116. package/src/tornado/index.ts +10 -0
  117. package/src/tornado/layer.ts +271 -0
  118. package/src/tornado/types.ts +22 -0
  119. package/src/volcano/consts.ts +3 -0
  120. package/src/volcano/index.ts +10 -0
  121. package/src/volcano/layer.ts +261 -0
  122. package/src/volcano/types.ts +10 -0
  123. package/src/voronoi/consts.ts +3 -0
  124. package/src/voronoi/index.ts +10 -0
  125. package/src/voronoi/layer.ts +197 -0
  126. package/src/voronoi/types.ts +7 -0
@@ -0,0 +1,213 @@
1
+ import { parseColor } from '../color';
2
+ import { Effect } from '../effect';
3
+ import { DEFAULT_COLORS, MULBERRY } from './consts';
4
+ import type { NeonTube, NeonTubeShape } from './types';
5
+
6
+ export interface NeonConfig {
7
+ readonly count?: number;
8
+ readonly speed?: number;
9
+ readonly colors?: string[];
10
+ readonly flicker?: boolean;
11
+ readonly scale?: number;
12
+ }
13
+
14
+ export class Neon extends Effect<NeonConfig> {
15
+ readonly #scale: number;
16
+ #speed: number;
17
+ #flicker: boolean;
18
+ readonly #colors: string[];
19
+ #tubes: NeonTube[] = [];
20
+ #time: number = 0;
21
+ #initialized: boolean = false;
22
+ readonly #count: number;
23
+
24
+ constructor(config: NeonConfig = {}) {
25
+ super();
26
+
27
+ this.#scale = config.scale ?? 1;
28
+ this.#speed = config.speed ?? 1;
29
+ this.#flicker = config.flicker ?? true;
30
+ this.#colors = config.colors ?? DEFAULT_COLORS;
31
+ this.#count = config.count ?? 8;
32
+ }
33
+
34
+ configure(config: Partial<NeonConfig>): void {
35
+ if (config.speed !== undefined) {
36
+ this.#speed = config.speed;
37
+ }
38
+ if (config.flicker !== undefined) {
39
+ this.#flicker = config.flicker;
40
+ }
41
+ }
42
+
43
+ onResize(width: number, height: number): void {
44
+ if (!this.#initialized && width > 0 && height > 0) {
45
+ this.#initialized = true;
46
+ this.#tubes = [];
47
+ for (let i = 0; i < this.#count; i++) {
48
+ this.#tubes.push(this.#createTube());
49
+ }
50
+ }
51
+ }
52
+
53
+ tick(dt: number, _width: number, _height: number): void {
54
+ this.#time += 0.01 * dt * this.#speed;
55
+
56
+ for (const tube of this.#tubes) {
57
+ tube.angle += tube.rotationSpeed * dt * this.#speed;
58
+
59
+ if (this.#flicker) {
60
+ if (!tube.flickering && MULBERRY.next() < 0.002) {
61
+ tube.flickering = true;
62
+ tube.flickerTarget = 0.15 + MULBERRY.next() * 0.25;
63
+ tube.flickerProgress = 0;
64
+ }
65
+
66
+ if (tube.flickering) {
67
+ tube.flickerProgress += dt * 0.008;
68
+
69
+ if (tube.flickerProgress < 0.3) {
70
+ tube.flickerAlpha = 1 - (1 - tube.flickerTarget) * (tube.flickerProgress / 0.3);
71
+ } else if (tube.flickerProgress < 0.6) {
72
+ tube.flickerAlpha = tube.flickerTarget;
73
+ } else if (tube.flickerProgress < 1) {
74
+ tube.flickerAlpha = tube.flickerTarget + (1 - tube.flickerTarget) * ((tube.flickerProgress - 0.6) / 0.4);
75
+ } else {
76
+ tube.flickerAlpha = 1;
77
+ tube.flickering = false;
78
+ tube.flickerProgress = 0;
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
86
+ ctx.globalCompositeOperation = 'lighter';
87
+
88
+ for (const tube of this.#tubes) {
89
+ const alpha = tube.flickerAlpha;
90
+ const parsed = parseColor(tube.color);
91
+ const r = parsed.r;
92
+ const g = parsed.g;
93
+ const b = parsed.b;
94
+
95
+ ctx.save();
96
+ ctx.translate(tube.x * width, tube.y * height);
97
+ ctx.rotate(tube.angle);
98
+
99
+ const canvasScale = Math.min(width, height) / 600;
100
+ const size = tube.size * this.#scale * canvasScale;
101
+
102
+ // Outer blurry glow
103
+ ctx.lineWidth = size * 0.18;
104
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.06 * alpha})`;
105
+ ctx.lineJoin = 'round';
106
+ ctx.lineCap = 'round';
107
+ this.#drawTubeShape(ctx, tube, size, this.#time);
108
+
109
+ // Medium glow
110
+ ctx.lineWidth = size * 0.1;
111
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.25 * alpha})`;
112
+ this.#drawTubeShape(ctx, tube, size, this.#time);
113
+
114
+ // Inner glow
115
+ ctx.lineWidth = size * 0.055;
116
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.55 * alpha})`;
117
+ this.#drawTubeShape(ctx, tube, size, this.#time);
118
+
119
+ // Bright core
120
+ ctx.lineWidth = size * 0.022;
121
+ ctx.strokeStyle = `rgba(255, 255, 255, ${0.9 * alpha})`;
122
+ this.#drawTubeShape(ctx, tube, size, this.#time);
123
+
124
+ ctx.restore();
125
+ }
126
+
127
+ ctx.globalCompositeOperation = 'source-over';
128
+ ctx.globalAlpha = 1;
129
+ ctx.resetTransform();
130
+ }
131
+
132
+ #drawTubeShape(ctx: CanvasRenderingContext2D, tube: NeonTube, size: number, time: number): void {
133
+ ctx.beginPath();
134
+
135
+ switch (tube.shape) {
136
+ case 'circle': {
137
+ const cx = Math.cos(time * 0.3 + tube.phaseOffset) * size * 0.08;
138
+ const cy = Math.sin(time * 0.2 + tube.phaseOffset) * size * 0.08;
139
+ ctx.arc(cx, cy, size * 0.45, 0, Math.PI * 2);
140
+ break;
141
+ }
142
+
143
+ case 'wave': {
144
+ const steps = 32;
145
+ const halfW = size * 0.55;
146
+ for (let i = 0; i <= steps; i++) {
147
+ const tx = (i / steps) * halfW * 2 - halfW;
148
+ const ty = Math.sin((i / steps) * Math.PI * 2 * tube.frequency + time * tube.frequency * 0.5 + tube.phaseOffset) * size * tube.amplitude;
149
+ if (i === 0) {
150
+ ctx.moveTo(tx, ty);
151
+ } else {
152
+ ctx.lineTo(tx, ty);
153
+ }
154
+ }
155
+ break;
156
+ }
157
+
158
+ case 'zigzag': {
159
+ const zigSteps = 8;
160
+ const zigW = size * 0.55;
161
+ for (let i = 0; i <= zigSteps; i++) {
162
+ const tx = (i / zigSteps) * zigW * 2 - zigW;
163
+ const ty = (i % 2 === 0 ? 1 : -1) * size * 0.28;
164
+ if (i === 0) {
165
+ ctx.moveTo(tx, ty);
166
+ } else {
167
+ ctx.lineTo(tx, ty);
168
+ }
169
+ }
170
+ break;
171
+ }
172
+
173
+ case 'curve': {
174
+ const cx1 = -size * 0.55;
175
+ const cy1 = Math.sin(time * 0.5 + tube.phaseOffset) * size * 0.35;
176
+ const cx2 = size * 0.55;
177
+ const cy2 = Math.sin(time * 0.5 + tube.phaseOffset + Math.PI) * size * 0.35;
178
+ const cp1x = -size * 0.2;
179
+ const cp1y = size * 0.4 * tube.amplitude;
180
+ const cp2x = size * 0.2;
181
+ const cp2y = -size * 0.4 * tube.amplitude;
182
+ ctx.moveTo(cx1, cy1);
183
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, cx2, cy2);
184
+ break;
185
+ }
186
+ }
187
+
188
+ ctx.stroke();
189
+ }
190
+
191
+ #createTube(): NeonTube {
192
+ const shapes: NeonTubeShape[] = ['circle', 'wave', 'zigzag', 'curve'];
193
+ const shape = shapes[Math.floor(MULBERRY.next() * shapes.length)];
194
+ const color = this.#colors[Math.floor(MULBERRY.next() * this.#colors.length)];
195
+
196
+ return {
197
+ shape,
198
+ color,
199
+ x: 0.1 + MULBERRY.next() * 0.8,
200
+ y: 0.1 + MULBERRY.next() * 0.8,
201
+ size: 80 + MULBERRY.next() * 120,
202
+ angle: MULBERRY.next() * Math.PI * 2,
203
+ rotationSpeed: (MULBERRY.next() - 0.5) * 0.0008,
204
+ phaseOffset: MULBERRY.next() * Math.PI * 2,
205
+ flickerAlpha: 1,
206
+ flickering: false,
207
+ flickerTarget: 1,
208
+ flickerProgress: 0,
209
+ amplitude: 0.2 + MULBERRY.next() * 0.25,
210
+ frequency: 1 + Math.floor(MULBERRY.next() * 3)
211
+ };
212
+ }
213
+ }
@@ -0,0 +1,18 @@
1
+ export type NeonTubeShape = 'circle' | 'wave' | 'zigzag' | 'curve';
2
+
3
+ export type NeonTube = {
4
+ shape: NeonTubeShape;
5
+ color: string;
6
+ x: number;
7
+ y: number;
8
+ size: number;
9
+ angle: number;
10
+ rotationSpeed: number;
11
+ phaseOffset: number;
12
+ flickerAlpha: number;
13
+ flickering: boolean;
14
+ flickerTarget: number;
15
+ flickerProgress: number;
16
+ amplitude: number;
17
+ frequency: number;
18
+ };
@@ -98,7 +98,8 @@ export class Petals extends Effect<PetalsConfig> {
98
98
  const cos = Math.cos(petal.rotation);
99
99
  const sin = Math.sin(petal.rotation);
100
100
 
101
- ctx.setTransform(cos * scaleX, sin * scaleX, -sin, cos, px, py);
101
+ ctx.save();
102
+ ctx.transform(cos * scaleX, sin * scaleX, -sin, cos, px, py);
102
103
  ctx.globalAlpha = 0.4 + petal.depth * 0.6;
103
104
  ctx.drawImage(
104
105
  this.#sprites[petal.colorIndex % this.#sprites.length],
@@ -107,9 +108,9 @@ export class Petals extends Effect<PetalsConfig> {
107
108
  displaySize,
108
109
  displaySize
109
110
  );
111
+ ctx.restore();
110
112
  }
111
113
 
112
- ctx.resetTransform();
113
114
  ctx.globalAlpha = 1;
114
115
  }
115
116
 
@@ -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 { Pollen } from './layer';
2
+ import type { PollenConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createPollen(config?: PollenConfig): Effect<PollenConfig> {
6
+ return new Pollen(config);
7
+ }
8
+
9
+ export type { PollenConfig };
10
+ export type { PollenParticle } from './types';
@@ -0,0 +1,181 @@
1
+ import { Effect } from '../effect';
2
+ import { MULBERRY } from './consts';
3
+ import type { PollenParticle } from './types';
4
+
5
+ const TAU = Math.PI * 2;
6
+ const SPRITE_SIZE = 64;
7
+ const SPRITE_CENTER = SPRITE_SIZE / 2;
8
+ const SPRITE_RADIUS = SPRITE_SIZE / 2;
9
+
10
+ export interface PollenConfig {
11
+ readonly count?: number;
12
+ readonly speed?: number;
13
+ readonly size?: number;
14
+ readonly color?: string;
15
+ readonly glowSize?: number;
16
+ readonly wind?: number;
17
+ readonly scale?: number;
18
+ }
19
+
20
+ export class Pollen extends Effect<PollenConfig> {
21
+ readonly #scale: number;
22
+ readonly #size: number;
23
+ readonly #glowSize: number;
24
+ #speed: number;
25
+ #wind: number;
26
+ #maxCount: number;
27
+ #time: number = 0;
28
+ #particles: PollenParticle[] = [];
29
+ #sprite: HTMLCanvasElement;
30
+ #width: number = 0;
31
+ #height: number = 0;
32
+
33
+ constructor(config: PollenConfig = {}) {
34
+ super();
35
+
36
+ this.#scale = config.scale ?? 1;
37
+ this.#maxCount = config.count ?? 40;
38
+ this.#size = (config.size ?? 3) * this.#scale;
39
+ this.#speed = config.speed ?? 0.5;
40
+ this.#wind = config.wind ?? 0.3;
41
+ this.#glowSize = config.glowSize ?? 2;
42
+
43
+ if (innerWidth < 991) {
44
+ this.#maxCount = Math.floor(this.#maxCount / 2);
45
+ }
46
+
47
+ const {r, g, b} = this.#parseColor(config.color ?? '#fff8e1');
48
+ this.#sprite = this.#createSprite(r, g, b);
49
+ }
50
+
51
+ configure(config: Partial<PollenConfig>): void {
52
+ if (config.speed !== undefined) {
53
+ this.#speed = config.speed;
54
+ }
55
+ if (config.wind !== undefined) {
56
+ this.#wind = config.wind;
57
+ }
58
+ }
59
+
60
+ onResize(width: number, height: number): void {
61
+ this.#width = width;
62
+ this.#height = height;
63
+
64
+ this.#particles = [];
65
+
66
+ for (let idx = 0; idx < this.#maxCount; ++idx) {
67
+ this.#particles.push(this.#createParticle(
68
+ MULBERRY.next() * width,
69
+ MULBERRY.next() * height
70
+ ));
71
+ }
72
+ }
73
+
74
+ tick(dt: number, width: number, height: number): void {
75
+ this.#time += 0.02 * dt * this.#speed;
76
+
77
+ const windForce = this.#wind * this.#speed;
78
+
79
+ for (const particle of this.#particles) {
80
+ // Brownian-like motion with gentle drift.
81
+ const brownianX = Math.sin(this.#time * 3 + particle.phase) * 0.4;
82
+ const brownianY = Math.cos(this.#time * 2.5 + particle.phase * 1.3) * 0.3;
83
+
84
+ particle.x += (windForce + brownianX) * particle.drift * dt * 0.5;
85
+ particle.y += (-0.2 * particle.speed * this.#speed + brownianY) * dt * 0.5;
86
+
87
+ // Pulsing opacity (catching sunlight).
88
+ particle.opacity = 0.3 + 0.7 * Math.abs(Math.sin(this.#time * particle.phaseSpeed + particle.phase));
89
+
90
+ // Recycle particles that leave bounds.
91
+ if (particle.x > width + 20) {
92
+ particle.x = -10;
93
+ particle.y = MULBERRY.next() * height;
94
+ } else if (particle.x < -20) {
95
+ particle.x = width + 10;
96
+ particle.y = MULBERRY.next() * height;
97
+ }
98
+
99
+ if (particle.y < -20) {
100
+ particle.y = height + 10;
101
+ particle.x = MULBERRY.next() * width;
102
+ } else if (particle.y > height + 20) {
103
+ particle.y = -10;
104
+ particle.x = MULBERRY.next() * width;
105
+ }
106
+ }
107
+ }
108
+
109
+ draw(ctx: CanvasRenderingContext2D, _width: number, _height: number): void {
110
+ ctx.globalCompositeOperation = 'lighter';
111
+
112
+ for (const particle of this.#particles) {
113
+ if (particle.opacity < 0.05) {
114
+ continue;
115
+ }
116
+
117
+ const displaySize = particle.size * this.#glowSize * 2;
118
+
119
+ ctx.globalAlpha = particle.opacity;
120
+ ctx.drawImage(
121
+ this.#sprite,
122
+ particle.x - displaySize / 2,
123
+ particle.y - displaySize / 2,
124
+ displaySize,
125
+ displaySize
126
+ );
127
+ }
128
+
129
+ ctx.globalCompositeOperation = 'source-over';
130
+ ctx.globalAlpha = 1;
131
+ }
132
+
133
+ #parseColor(color: string): { r: number; g: number; b: number } {
134
+ const canvas = document.createElement('canvas');
135
+ canvas.width = 1;
136
+ canvas.height = 1;
137
+ const ctx = canvas.getContext('2d')!;
138
+ ctx.fillStyle = color;
139
+ ctx.fillRect(0, 0, 1, 1);
140
+ const data = ctx.getImageData(0, 0, 1, 1).data;
141
+ return {r: data[0], g: data[1], b: data[2]};
142
+ }
143
+
144
+ #createSprite(r: number, g: number, b: number): HTMLCanvasElement {
145
+ const canvas = document.createElement('canvas');
146
+ canvas.width = SPRITE_SIZE;
147
+ canvas.height = SPRITE_SIZE;
148
+ const ctx = canvas.getContext('2d')!;
149
+
150
+ const gradient = ctx.createRadialGradient(
151
+ SPRITE_CENTER, SPRITE_CENTER, 0,
152
+ SPRITE_CENTER, SPRITE_CENTER, SPRITE_RADIUS
153
+ );
154
+
155
+ gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`);
156
+ gradient.addColorStop(0.15, `rgba(${r}, ${g}, ${b}, 0.6)`);
157
+ gradient.addColorStop(0.4, `rgba(${r}, ${g}, ${b}, 0.2)`);
158
+ gradient.addColorStop(0.7, `rgba(${r}, ${g}, ${b}, 0.05)`);
159
+ gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
160
+
161
+ ctx.fillStyle = gradient;
162
+ ctx.beginPath();
163
+ ctx.arc(SPRITE_CENTER, SPRITE_CENTER, SPRITE_RADIUS, 0, TAU);
164
+ ctx.fill();
165
+
166
+ return canvas;
167
+ }
168
+
169
+ #createParticle(px: number, py: number): PollenParticle {
170
+ return {
171
+ x: px,
172
+ y: py,
173
+ size: (MULBERRY.next() * 0.6 + 0.7) * this.#size,
174
+ speed: 0.3 + MULBERRY.next() * 0.7,
175
+ phase: MULBERRY.next() * TAU,
176
+ phaseSpeed: 0.5 + MULBERRY.next() * 1.5,
177
+ opacity: MULBERRY.next(),
178
+ drift: 0.5 + MULBERRY.next() * 1
179
+ };
180
+ }
181
+ }
@@ -0,0 +1,10 @@
1
+ export type PollenParticle = {
2
+ x: number;
3
+ y: number;
4
+ size: number;
5
+ speed: number;
6
+ phase: number;
7
+ phaseSpeed: number;
8
+ opacity: number;
9
+ drift: 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 { Popcorn } from './layer';
2
+ import type { PopcornConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createPopcorn(config?: PopcornConfig): Effect<PopcornConfig> {
6
+ return new Popcorn(config);
7
+ }
8
+
9
+ export type { PopcornConfig };
10
+ export type { PopcornKernel } from './types';
@@ -0,0 +1,218 @@
1
+ import { hexToRGB } from '@basmilius/utils';
2
+ import { Effect } from '../effect';
3
+ import { MULBERRY } from './consts';
4
+ import type { PopcornKernel } from './types';
5
+
6
+ export interface PopcornConfig {
7
+ readonly count?: number;
8
+ readonly speed?: number;
9
+ readonly gravity?: number;
10
+ readonly bounciness?: number;
11
+ readonly color?: string;
12
+ readonly popRate?: number;
13
+ readonly scale?: number;
14
+ }
15
+
16
+ export class Popcorn extends Effect<PopcornConfig> {
17
+ readonly #scale: number;
18
+ readonly #maxCount: number;
19
+ readonly #bounciness: number;
20
+ readonly #colorRGB: [number, number, number];
21
+ #speed: number;
22
+ #gravity: number;
23
+ #popRate: number;
24
+ #kernels: PopcornKernel[] = [];
25
+ #spawnAccumulator: number = 0;
26
+
27
+ constructor(config: PopcornConfig = {}) {
28
+ super();
29
+
30
+ this.#scale = config.scale ?? 1;
31
+ this.#maxCount = config.count ?? 25;
32
+ this.#speed = config.speed ?? 1;
33
+ this.#gravity = config.gravity ?? 1;
34
+ this.#bounciness = config.bounciness ?? 0.6;
35
+ this.#popRate = config.popRate ?? 2;
36
+ this.#colorRGB = hexToRGB(config.color ?? '#fff8dc');
37
+ }
38
+
39
+ configure(config: Partial<PopcornConfig>): void {
40
+ if (config.speed !== undefined) {
41
+ this.#speed = config.speed;
42
+ }
43
+ if (config.gravity !== undefined) {
44
+ this.#gravity = config.gravity;
45
+ }
46
+ if (config.popRate !== undefined) {
47
+ this.#popRate = config.popRate;
48
+ }
49
+ }
50
+
51
+ tick(dt: number, width: number, height: number): void {
52
+ const speed = this.#speed;
53
+ const gravity = this.#gravity;
54
+ const bounciness = this.#bounciness;
55
+ const groundY = height * 0.95;
56
+ const scale = this.#scale;
57
+
58
+ this.#spawnAccumulator += this.#popRate * speed * dt / 60;
59
+
60
+ while (this.#spawnAccumulator >= 1 && this.#kernels.length < this.#maxCount) {
61
+ this.#spawnAccumulator -= 1;
62
+ this.#kernels.push(this.#createKernel(width, height));
63
+ }
64
+
65
+ if (this.#spawnAccumulator >= 1) {
66
+ this.#spawnAccumulator = 0;
67
+ }
68
+
69
+ let alive = 0;
70
+
71
+ for (let idx = 0; idx < this.#kernels.length; idx++) {
72
+ const kernel = this.#kernels[idx];
73
+
74
+ if (kernel.settled) {
75
+ kernel.opacity -= 0.005 * speed * dt;
76
+
77
+ if (kernel.opacity <= 0) {
78
+ continue;
79
+ }
80
+
81
+ this.#kernels[alive++] = kernel;
82
+ continue;
83
+ }
84
+
85
+ kernel.vy += 0.15 * gravity * scale * speed * dt;
86
+ kernel.x += kernel.vx * speed * dt;
87
+ kernel.y += kernel.vy * speed * dt;
88
+ kernel.rotation += kernel.rotationSpeed * speed * dt;
89
+
90
+ if (!kernel.popped && kernel.vy < 0) {
91
+ kernel.popped = true;
92
+ kernel.size *= 2.2;
93
+ }
94
+
95
+ if (kernel.y >= groundY) {
96
+ kernel.y = groundY;
97
+ kernel.vy = -Math.abs(kernel.vy) * bounciness;
98
+ kernel.vx *= 0.8;
99
+ kernel.bounces++;
100
+
101
+ if (kernel.bounces > 3) {
102
+ kernel.settled = true;
103
+ kernel.vy = 0;
104
+ kernel.vx = 0;
105
+ }
106
+ }
107
+
108
+ if (kernel.x < -50 || kernel.x > width + 50) {
109
+ continue;
110
+ }
111
+
112
+ this.#kernels[alive++] = kernel;
113
+ }
114
+
115
+ this.#kernels.length = alive;
116
+ }
117
+
118
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
119
+ const [red, green, blue] = this.#colorRGB;
120
+
121
+ for (let idx = 0; idx < this.#kernels.length; idx++) {
122
+ const kernel = this.#kernels[idx];
123
+ const alpha = kernel.opacity;
124
+
125
+ if (alpha <= 0) {
126
+ continue;
127
+ }
128
+
129
+ ctx.globalAlpha = alpha;
130
+
131
+ if (!kernel.popped) {
132
+ this.#drawUnpopped(ctx, kernel, red, green, blue);
133
+ } else {
134
+ this.#drawPopped(ctx, kernel, red, green, blue);
135
+ }
136
+ }
137
+
138
+ ctx.globalAlpha = 1;
139
+ }
140
+
141
+ #drawUnpopped(ctx: CanvasRenderingContext2D, kernel: PopcornKernel, red: number, green: number, blue: number): void {
142
+ const size = kernel.size;
143
+ const cos = Math.cos(kernel.rotation);
144
+ const sin = Math.sin(kernel.rotation);
145
+
146
+ ctx.save();
147
+ ctx.transform(cos, sin, -sin, cos, kernel.x, kernel.y);
148
+ ctx.beginPath();
149
+ ctx.arc(0, 0, size, 0, Math.PI * 2);
150
+ ctx.fillStyle = `rgb(${(red * 0.7) | 0}, ${(green * 0.7) | 0}, ${(blue * 0.5) | 0})`;
151
+ ctx.fill();
152
+ ctx.restore();
153
+ }
154
+
155
+ #drawPopped(ctx: CanvasRenderingContext2D, kernel: PopcornKernel, red: number, green: number, blue: number): void {
156
+ const size = kernel.size;
157
+ const cos = Math.cos(kernel.rotation);
158
+ const sin = Math.sin(kernel.rotation);
159
+
160
+ ctx.save();
161
+ ctx.transform(cos, sin, -sin, cos, kernel.x, kernel.y);
162
+ ctx.beginPath();
163
+
164
+ const lobes = 5;
165
+
166
+ for (let idx = 0; idx < lobes; idx++) {
167
+ const angle = (idx / lobes) * Math.PI * 2;
168
+ const lumpRadius = size * (0.7 + 0.3 * Math.sin(idx * 2.7 + kernel.rotation));
169
+ const lx = Math.cos(angle) * lumpRadius;
170
+ const ly = Math.sin(angle) * lumpRadius;
171
+ const cpAngle = ((idx + 0.5) / lobes) * Math.PI * 2;
172
+ const cpRadius = size * 1.1;
173
+ const cpx = Math.cos(cpAngle) * cpRadius;
174
+ const cpy = Math.sin(cpAngle) * cpRadius;
175
+
176
+ if (idx === 0) {
177
+ ctx.moveTo(lx, ly);
178
+ }
179
+
180
+ const nextIdx = (idx + 1) % lobes;
181
+ const nextAngle = (nextIdx / lobes) * Math.PI * 2;
182
+ const nextLumpRadius = size * (0.7 + 0.3 * Math.sin(nextIdx * 2.7 + kernel.rotation));
183
+ const nlx = Math.cos(nextAngle) * nextLumpRadius;
184
+ const nly = Math.sin(nextAngle) * nextLumpRadius;
185
+
186
+ ctx.quadraticCurveTo(cpx, cpy, nlx, nly);
187
+ }
188
+
189
+ ctx.closePath();
190
+ ctx.fillStyle = `rgb(${red}, ${green}, ${blue})`;
191
+ ctx.fill();
192
+
193
+ ctx.beginPath();
194
+ ctx.arc(-size * 0.2, -size * 0.2, size * 0.3, 0, Math.PI * 2);
195
+ ctx.fillStyle = `rgba(255, 255, 255, 0.3)`;
196
+ ctx.fill();
197
+
198
+ ctx.restore();
199
+ }
200
+
201
+ #createKernel(width: number, height: number): PopcornKernel {
202
+ const scale = this.#scale;
203
+
204
+ return {
205
+ x: width * (0.15 + MULBERRY.next() * 0.7),
206
+ y: height * (0.7 + MULBERRY.next() * 0.25),
207
+ vx: (MULBERRY.next() - 0.5) * 2 * scale,
208
+ vy: -(3 + MULBERRY.next() * 5) * scale,
209
+ size: (3 + MULBERRY.next() * 3) * scale,
210
+ rotation: MULBERRY.next() * Math.PI * 2,
211
+ rotationSpeed: (MULBERRY.next() - 0.5) * 0.15,
212
+ popped: false,
213
+ opacity: 1,
214
+ settled: false,
215
+ bounces: 0
216
+ };
217
+ }
218
+ }