@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
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@basmilius/sparkle",
3
3
  "license": "MIT",
4
- "version": "2.0.0",
4
+ "version": "2.2.0",
5
5
  "author": {
6
6
  "email": "bas@mili.us",
7
7
  "name": "Bas Milius",
8
8
  "url": "https://bas.dev"
9
9
  },
10
+ "homepage": "https://sparkle.graphics",
10
11
  "repository": {
11
12
  "type": "git",
12
13
  "url": "https://github.com/basmilius/sparkle.git"
@@ -43,11 +44,15 @@
43
44
  "@basmilius/utils": "^3.15.0"
44
45
  },
45
46
  "devDependencies": {
47
+ "@types/node": "^25.5.0",
48
+ "@vitejs/plugin-vue": "^5.2.1",
46
49
  "tsdown": "^0.21.4",
47
50
  "typescript": "^6.0.2",
48
51
  "vite": "^8.0.3",
49
52
  "vitepress": "^1.6.3",
50
53
  "vitepress-plugin-example": "^1.4.0",
51
- "vitepress-plugin-render": "^1.4.0"
54
+ "vitepress-plugin-render": "^1.4.0",
55
+ "vue": "^3.5.13",
56
+ "vue-router": "^4.5.0"
52
57
  }
53
58
  }
@@ -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 { Aurora } from './layer';
2
+ import type { AuroraConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createAurora(config?: AuroraConfig): Effect<AuroraConfig> {
6
+ return new Aurora(config);
7
+ }
8
+
9
+ export type { AuroraConfig };
10
+ export type { AuroraBand } from './types';
@@ -0,0 +1,180 @@
1
+ import { hexToRGB } from '@basmilius/utils';
2
+ import { Effect } from '../effect';
3
+ import { MULBERRY } from './consts';
4
+ import type { AuroraBand } from './types';
5
+
6
+ const DEFAULT_COLORS = ['#9922ff', '#4455ff', '#0077ee', '#00aabb', '#22ddff'];
7
+ const TOP_HUE = 265;
8
+
9
+ export interface AuroraConfig {
10
+ readonly bands?: number;
11
+ readonly colors?: string[];
12
+ readonly speed?: number;
13
+ readonly intensity?: number;
14
+ readonly waveAmplitude?: number;
15
+ readonly verticalPosition?: number;
16
+ readonly scale?: number;
17
+ }
18
+
19
+ export class Aurora extends Effect<AuroraConfig> {
20
+ #speed: number;
21
+ #intensity: number;
22
+ #waveAmplitude: number;
23
+ #verticalPosition: number;
24
+ #bands: AuroraBand[] = [];
25
+
26
+ constructor(config: AuroraConfig = {}) {
27
+ super();
28
+
29
+ const bandCount = config.bands ?? 5;
30
+ const colors = config.colors ?? DEFAULT_COLORS;
31
+ this.#speed = config.speed ?? 1;
32
+ this.#intensity = config.intensity ?? 0.8;
33
+ this.#waveAmplitude = config.waveAmplitude ?? 1;
34
+ this.#verticalPosition = config.verticalPosition ?? 0.68;
35
+
36
+ // Two loose clusters (left + right), rays within each cluster overlap into a whole
37
+ const clusterCenters = [0.35, 0.65];
38
+
39
+ for (let i = 0; i < bandCount; i++) {
40
+ const color = colors[i % colors.length];
41
+ const [r, g, b] = hexToRGB(color);
42
+ const hue = this.#rgbToHue(r, g, b);
43
+ const cluster = clusterCenters[i % clusterCenters.length];
44
+
45
+ this.#bands.push({
46
+ x: cluster + (MULBERRY.next() - 0.5) * 0.22,
47
+ baseY: (MULBERRY.next() - 0.5) * 0.08,
48
+ height: 0.5 + MULBERRY.next() * 0.3,
49
+ sigma: 160 + MULBERRY.next() * 110,
50
+ phase1: MULBERRY.next() * Math.PI * 2,
51
+ phase2: MULBERRY.next() * Math.PI * 2,
52
+ amplitude1: 0.015 + MULBERRY.next() * 0.025,
53
+ frequency1: 0.003 + MULBERRY.next() * 0.004,
54
+ speed: 0.4 + MULBERRY.next() * 0.6,
55
+ hue,
56
+ opacity: 0.5 + MULBERRY.next() * 0.3
57
+ });
58
+ }
59
+ }
60
+
61
+ configure(config: Partial<AuroraConfig>): void {
62
+ if (config.speed !== undefined) {
63
+ this.#speed = config.speed;
64
+ }
65
+ if (config.intensity !== undefined) {
66
+ this.#intensity = config.intensity;
67
+ }
68
+ if (config.waveAmplitude !== undefined) {
69
+ this.#waveAmplitude = config.waveAmplitude;
70
+ }
71
+ if (config.verticalPosition !== undefined) {
72
+ this.#verticalPosition = config.verticalPosition;
73
+ }
74
+ }
75
+
76
+ tick(dt: number, _width: number, _height: number): void {
77
+ for (const band of this.#bands) {
78
+ band.phase1 += 0.005 * band.speed * this.#speed * dt;
79
+ band.phase2 += 0.008 * band.speed * this.#speed * dt;
80
+ }
81
+ }
82
+
83
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
84
+ // Dark sky background gradient
85
+ const bg = ctx.createLinearGradient(0, 0, 0, height);
86
+ bg.addColorStop(0, '#000000');
87
+ bg.addColorStop(0.5, '#050012');
88
+ bg.addColorStop(1, '#0a0025');
89
+ ctx.fillStyle = bg;
90
+ ctx.fillRect(0, 0, width, height);
91
+
92
+ // Aurora curtain rays — vertical bands with Gaussian horizontal falloff
93
+ ctx.globalCompositeOperation = 'screen';
94
+
95
+ const step = 4;
96
+ const scale = width / 1920;
97
+
98
+ for (const band of this.#bands) {
99
+ const swayX = band.amplitude1 * width * Math.sin(band.phase1);
100
+ const cx = band.x * width + swayX;
101
+ const baseY = (this.#verticalPosition + band.baseY) * height;
102
+ const rayHeight = band.height * height * (height / 800);
103
+ const sigma = band.sigma * scale;
104
+ const cutoff = sigma * 3.5;
105
+ const sigmaSq2 = 2 * sigma * sigma;
106
+ const midHue = (band.hue + TOP_HUE) / 2;
107
+ const waveRange = height * 0.035 * this.#waveAmplitude;
108
+
109
+ const xStart = Math.max(0, Math.floor((cx - cutoff) / step) * step);
110
+ const xEnd = Math.min(width, Math.ceil((cx + cutoff) / step) * step);
111
+
112
+ const centreWave = Math.sin(band.frequency1 * cx + band.phase2) * waveRange;
113
+ const centreBase = baseY + centreWave;
114
+ const centreTop = centreBase - rayHeight;
115
+ const centreFadeBottom = centreBase + rayHeight * 0.1;
116
+
117
+ const gradient = ctx.createLinearGradient(0, centreFadeBottom, 0, centreTop);
118
+ gradient.addColorStop(0, `hsla(${band.hue}, 100%, 90%, 0)`);
119
+ gradient.addColorStop(0.04, `hsla(${band.hue}, 100%, 90%, 0.55)`);
120
+ gradient.addColorStop(0.1, `hsla(${band.hue}, 90%, 72%, 1)`);
121
+ gradient.addColorStop(0.32, `hsla(${band.hue}, 85%, 62%, 0.75)`);
122
+ gradient.addColorStop(0.62, `hsla(${midHue}, 80%, 56%, 0.35)`);
123
+ gradient.addColorStop(0.86, `hsla(${TOP_HUE}, 75%, 50%, 0.12)`);
124
+ gradient.addColorStop(1, `hsla(${TOP_HUE}, 70%, 45%, 0)`);
125
+ ctx.fillStyle = gradient;
126
+
127
+ for (let x = xStart; x < xEnd; x += step) {
128
+ const dx = x - cx;
129
+ const alpha = Math.exp(-dx * dx / sigmaSq2);
130
+
131
+ if (alpha < 0.015) {
132
+ continue;
133
+ }
134
+
135
+ const waveOffset = Math.sin(band.frequency1 * x + band.phase2) * waveRange;
136
+ const colBase = baseY + waveOffset;
137
+ const colTop = colBase - rayHeight;
138
+ const fadeBottom = colBase + rayHeight * 0.1;
139
+
140
+ ctx.globalAlpha = alpha * band.opacity * this.#intensity;
141
+ ctx.fillRect(x, colTop, step, fadeBottom - colTop + 1);
142
+ }
143
+
144
+ ctx.globalAlpha = 1;
145
+ }
146
+
147
+ ctx.globalCompositeOperation = 'source-over';
148
+ }
149
+
150
+ #rgbToHue(r: number, g: number, b: number): number {
151
+ r /= 255;
152
+ g /= 255;
153
+ b /= 255;
154
+ const max = Math.max(r, g, b);
155
+ const min = Math.min(r, g, b);
156
+ const delta = max - min;
157
+
158
+ if (delta === 0) {
159
+ return 0;
160
+ }
161
+
162
+ let hue: number;
163
+
164
+ if (max === r) {
165
+ hue = ((g - b) / delta) % 6;
166
+ } else if (max === g) {
167
+ hue = (b - r) / delta + 2;
168
+ } else {
169
+ hue = (r - g) / delta + 4;
170
+ }
171
+
172
+ hue = Math.round(hue * 60);
173
+
174
+ if (hue < 0) {
175
+ hue += 360;
176
+ }
177
+
178
+ return hue;
179
+ }
180
+ }
@@ -0,0 +1,13 @@
1
+ export type AuroraBand = {
2
+ x: number;
3
+ baseY: number;
4
+ height: number;
5
+ sigma: number;
6
+ phase1: number;
7
+ phase2: number;
8
+ amplitude1: number;
9
+ frequency1: number;
10
+ speed: number;
11
+ hue: number;
12
+ opacity: number;
13
+ };
@@ -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 { Balloons } from './layer';
2
+ import type { BalloonsConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createBalloons(config?: BalloonsConfig): Effect<BalloonsConfig> {
6
+ return new Balloons(config);
7
+ }
8
+
9
+ export { BalloonParticle } from './particle';
10
+ export type { BalloonsConfig };
11
+ export type { BalloonParticleConfig } from './particle';
12
+ export type { Balloon } from './types';
@@ -0,0 +1,169 @@
1
+ import { hexToRGB } from '@basmilius/utils';
2
+ import { Effect } from '../effect';
3
+ import { MULBERRY } from './consts';
4
+ import type { Balloon } from './types';
5
+
6
+ const DEFAULT_COLORS = ['#ff4444', '#4488ff', '#44cc44', '#ffcc00', '#ff88cc', '#8844ff'];
7
+
8
+ export interface BalloonsConfig {
9
+ readonly count?: number;
10
+ readonly colors?: string[];
11
+ readonly sizeRange?: [number, number];
12
+ readonly speed?: number;
13
+ readonly driftAmount?: number;
14
+ readonly stringLength?: number;
15
+ readonly scale?: number;
16
+ }
17
+
18
+ export class Balloons extends Effect<BalloonsConfig> {
19
+ readonly #scale: number;
20
+ #speed: number;
21
+ #driftAmount: number;
22
+ #stringLengthMul: number;
23
+ readonly #sizeRange: [number, number];
24
+ readonly #colorRGBs: [number, number, number][];
25
+ #maxCount: number;
26
+ #time: number = 0;
27
+ #balloons: Balloon[] = [];
28
+
29
+ constructor(config: BalloonsConfig = {}) {
30
+ super();
31
+
32
+ this.#scale = config.scale ?? 1;
33
+ this.#maxCount = config.count ?? 15;
34
+ this.#sizeRange = config.sizeRange ?? [25, 45];
35
+ this.#speed = config.speed ?? 1;
36
+ this.#driftAmount = config.driftAmount ?? 1;
37
+ this.#stringLengthMul = config.stringLength ?? 1;
38
+
39
+ const colors = config.colors ?? DEFAULT_COLORS;
40
+ this.#colorRGBs = colors.map(c => hexToRGB(c));
41
+
42
+ if (innerWidth < 991) {
43
+ this.#maxCount = Math.floor(this.#maxCount / 2);
44
+ }
45
+
46
+ for (let i = 0; i < this.#maxCount; ++i) {
47
+ this.#balloons.push(this.#createBalloon(true));
48
+ }
49
+ }
50
+
51
+ configure(config: Partial<BalloonsConfig>): void {
52
+ if (config.speed !== undefined) {
53
+ this.#speed = config.speed;
54
+ }
55
+ if (config.driftAmount !== undefined) {
56
+ this.#driftAmount = config.driftAmount;
57
+ }
58
+ if (config.stringLength !== undefined) {
59
+ this.#stringLengthMul = config.stringLength;
60
+ }
61
+ }
62
+
63
+ tick(dt: number, width: number, height: number): void {
64
+ this.#time += 0.015 * dt * this.#speed;
65
+
66
+ for (let i = 0; i < this.#balloons.length; i++) {
67
+ const balloon = this.#balloons[i];
68
+
69
+ balloon.y -= (balloon.riseSpeed * this.#speed * dt) / (height * 1.2);
70
+
71
+ const drift = Math.sin(this.#time * balloon.driftFreq + balloon.driftPhase) * balloon.driftAmp * this.#driftAmount;
72
+ balloon.x += drift * dt / (width * 5);
73
+
74
+ balloon.rotation = Math.sin(this.#time * balloon.rotationSpeed + balloon.driftPhase) * 0.08;
75
+
76
+ if (balloon.y < -0.2) {
77
+ this.#balloons[i] = this.#createBalloon(false);
78
+ }
79
+ }
80
+ }
81
+
82
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
83
+
84
+ for (const balloon of this.#balloons) {
85
+ const px = balloon.x * width;
86
+ const py = balloon.y * height;
87
+ const rx = balloon.radiusX * this.#scale;
88
+ const ry = balloon.radiusY * this.#scale;
89
+ const [r, g, b] = balloon.color;
90
+ const cos = Math.cos(balloon.rotation);
91
+ const sin = Math.sin(balloon.rotation);
92
+
93
+ ctx.setTransform(cos, sin, -sin, cos, px, py);
94
+
95
+ const gradient = ctx.createRadialGradient(
96
+ -rx * 0.3, -ry * 0.3, rx * 0.1,
97
+ 0, 0, Math.max(rx, ry)
98
+ );
99
+ gradient.addColorStop(0, `rgba(${Math.min(255, r + 80)}, ${Math.min(255, g + 80)}, ${Math.min(255, b + 80)}, 0.95)`);
100
+ gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.9)`);
101
+ gradient.addColorStop(1, `rgba(${Math.max(0, r - 40)}, ${Math.max(0, g - 40)}, ${Math.max(0, b - 40)}, 0.85)`);
102
+
103
+ ctx.beginPath();
104
+ ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
105
+ ctx.fillStyle = gradient;
106
+ ctx.fill();
107
+
108
+ ctx.beginPath();
109
+ ctx.ellipse(-rx * 0.25, -ry * 0.3, rx * 0.2, ry * 0.15, -0.3, 0, Math.PI * 2);
110
+ ctx.fillStyle = `rgba(255, 255, 255, 0.35)`;
111
+ ctx.fill();
112
+
113
+ const knotY = ry + 2 * this.#scale;
114
+ ctx.beginPath();
115
+ ctx.moveTo(-3 * this.#scale, knotY);
116
+ ctx.lineTo(0, knotY + 5 * this.#scale);
117
+ ctx.lineTo(3 * this.#scale, knotY);
118
+ ctx.closePath();
119
+ ctx.fillStyle = `rgba(${Math.max(0, r - 30)}, ${Math.max(0, g - 30)}, ${Math.max(0, b - 30)}, 0.9)`;
120
+ ctx.fill();
121
+
122
+ const stringLen = balloon.stringLength * this.#scale * this.#stringLengthMul;
123
+ const knotBaseY = knotY + 5 * this.#scale;
124
+ const ph = balloon.driftPhase;
125
+ const fr = balloon.driftFreq;
126
+ const swingAmt = 10 * this.#scale * this.#driftAmount;
127
+
128
+ // Each control point lags further behind the balloon's lateral oscillation,
129
+ // so the string trails the direction of movement like a real hanging string.
130
+ const midSwing = Math.sin(this.#time * fr + ph - 0.3) * swingAmt * 0.55;
131
+ const tipSwing = Math.sin(this.#time * fr + ph - 0.8) * swingAmt;
132
+ // Subtle high-frequency flutter at the tip for lightness.
133
+ const flutter = Math.sin(this.#time * fr * 2.5 + ph * 1.4 + 1.8) * 2.5 * this.#scale;
134
+
135
+ ctx.beginPath();
136
+ ctx.moveTo(0, knotBaseY);
137
+ ctx.bezierCurveTo(
138
+ midSwing * 0.35, knotBaseY + stringLen * 0.3,
139
+ midSwing + flutter * 0.5, knotBaseY + stringLen * 0.65,
140
+ tipSwing + flutter, knotBaseY + stringLen
141
+ );
142
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.4)`;
143
+ ctx.lineWidth = 1;
144
+ ctx.stroke();
145
+ }
146
+
147
+ ctx.resetTransform();
148
+ }
149
+
150
+ #createBalloon(initialSpread: boolean): Balloon {
151
+ const colorIndex = Math.floor(MULBERRY.next() * this.#colorRGBs.length);
152
+ const baseRadius = this.#sizeRange[0] + MULBERRY.next() * (this.#sizeRange[1] - this.#sizeRange[0]);
153
+
154
+ return {
155
+ x: 0.1 + MULBERRY.next() * 0.8,
156
+ y: initialSpread ? MULBERRY.next() * 1.2 : 1.2 + MULBERRY.next() * 0.2,
157
+ radiusX: baseRadius * 0.85,
158
+ radiusY: baseRadius,
159
+ color: this.#colorRGBs[colorIndex],
160
+ driftPhase: MULBERRY.next() * Math.PI * 2,
161
+ driftFreq: 0.5 + MULBERRY.next() * 1,
162
+ driftAmp: 0.3 + MULBERRY.next() * 0.7,
163
+ riseSpeed: 0.3 + MULBERRY.next() * 0.7,
164
+ rotation: 0,
165
+ rotationSpeed: 0.5 + MULBERRY.next() * 1.5,
166
+ stringLength: 30 + MULBERRY.next() * 40
167
+ };
168
+ }
169
+ }
@@ -0,0 +1,110 @@
1
+ import type { Point } from '../point';
2
+
3
+ export interface BalloonParticleConfig {
4
+ readonly driftAmp?: number;
5
+ readonly driftFreq?: number;
6
+ readonly driftPhase?: number;
7
+ readonly radiusX?: number;
8
+ readonly radiusY?: number;
9
+ readonly riseSpeed?: number;
10
+ readonly rotationSpeed?: number;
11
+ readonly scale?: number;
12
+ readonly stringLength?: number;
13
+ }
14
+
15
+ export class BalloonParticle {
16
+ readonly #color: [number, number, number];
17
+ readonly #driftAmp: number;
18
+ readonly #driftFreq: number;
19
+ readonly #driftPhase: number;
20
+ readonly #radiusX: number;
21
+ readonly #radiusY: number;
22
+ readonly #riseSpeed: number;
23
+ readonly #rotationSpeed: number;
24
+ readonly #scale: number;
25
+ readonly #stringLength: number;
26
+ #x: number;
27
+ #y: number;
28
+ #rotation: number = 0;
29
+ #time: number = 0;
30
+
31
+ get isDone(): boolean {
32
+ return this.#y < -(this.#radiusY * 2 + this.#stringLength);
33
+ }
34
+
35
+ get position(): Point {
36
+ return {x: this.#x, y: this.#y};
37
+ }
38
+
39
+ constructor(position: Point, color: [number, number, number], config: BalloonParticleConfig = {}) {
40
+ this.#x = position.x;
41
+ this.#y = position.y;
42
+ this.#color = color;
43
+ this.#driftAmp = config.driftAmp ?? (0.3 + Math.random() * 0.7);
44
+ this.#driftFreq = config.driftFreq ?? (0.5 + Math.random() * 1);
45
+ this.#driftPhase = config.driftPhase ?? (Math.random() * Math.PI * 2);
46
+ this.#radiusX = (config.radiusX ?? (25 + Math.random() * 20)) * (config.scale ?? 1);
47
+ this.#radiusY = (config.radiusY ?? (config.radiusX ? config.radiusX * (1 / 0.85) : 30 + Math.random() * 23)) * (config.scale ?? 1);
48
+ this.#riseSpeed = config.riseSpeed ?? (0.5 + Math.random() * 0.8);
49
+ this.#rotationSpeed = config.rotationSpeed ?? (0.5 + Math.random() * 1.5);
50
+ this.#scale = config.scale ?? 1;
51
+ this.#stringLength = (config.stringLength ?? (30 + Math.random() * 40)) * this.#scale;
52
+ }
53
+
54
+ draw(ctx: CanvasRenderingContext2D): void {
55
+ const [r, g, b] = this.#color;
56
+ const rx = this.#radiusX;
57
+ const ry = this.#radiusY;
58
+
59
+ ctx.save();
60
+ ctx.translate(this.#x, this.#y);
61
+ ctx.rotate(this.#rotation);
62
+
63
+ const gradient = ctx.createRadialGradient(-rx * 0.3, -ry * 0.3, rx * 0.1, 0, 0, Math.max(rx, ry));
64
+ gradient.addColorStop(0, `rgba(${Math.min(255, r + 80)}, ${Math.min(255, g + 80)}, ${Math.min(255, b + 80)}, 0.95)`);
65
+ gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.9)`);
66
+ gradient.addColorStop(1, `rgba(${Math.max(0, r - 40)}, ${Math.max(0, g - 40)}, ${Math.max(0, b - 40)}, 0.85)`);
67
+
68
+ ctx.beginPath();
69
+ ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
70
+ ctx.fillStyle = gradient;
71
+ ctx.fill();
72
+
73
+ ctx.beginPath();
74
+ ctx.ellipse(-rx * 0.25, -ry * 0.3, rx * 0.2, ry * 0.15, -0.3, 0, Math.PI * 2);
75
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
76
+ ctx.fill();
77
+
78
+ const knotY = ry + 2 * this.#scale;
79
+ ctx.beginPath();
80
+ ctx.moveTo(-3 * this.#scale, knotY);
81
+ ctx.lineTo(0, knotY + 5 * this.#scale);
82
+ ctx.lineTo(3 * this.#scale, knotY);
83
+ ctx.closePath();
84
+ ctx.fillStyle = `rgba(${Math.max(0, r - 30)}, ${Math.max(0, g - 30)}, ${Math.max(0, b - 30)}, 0.9)`;
85
+ ctx.fill();
86
+
87
+ const stringDrift = Math.sin(this.#time * 2 + this.#driftPhase) * 8 * this.#scale;
88
+ ctx.beginPath();
89
+ ctx.moveTo(0, knotY + 5 * this.#scale);
90
+ ctx.quadraticCurveTo(
91
+ stringDrift,
92
+ knotY + 5 * this.#scale + this.#stringLength * 0.5,
93
+ -stringDrift * 0.5,
94
+ knotY + 5 * this.#scale + this.#stringLength
95
+ );
96
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.4)`;
97
+ ctx.lineWidth = 1;
98
+ ctx.stroke();
99
+
100
+ ctx.restore();
101
+ }
102
+
103
+ tick(dt: number = 1): void {
104
+ this.#time += 0.015 * dt;
105
+
106
+ this.#y -= this.#riseSpeed * dt;
107
+ this.#x += Math.sin(this.#time * this.#driftFreq + this.#driftPhase) * this.#driftAmp * dt;
108
+ this.#rotation = Math.sin(this.#time * this.#rotationSpeed + this.#driftPhase) * 0.08;
109
+ }
110
+ }
@@ -0,0 +1,14 @@
1
+ export type Balloon = {
2
+ x: number;
3
+ y: number;
4
+ radiusX: number;
5
+ radiusY: number;
6
+ color: [number, number, number];
7
+ driftPhase: number;
8
+ driftFreq: number;
9
+ driftAmp: number;
10
+ riseSpeed: number;
11
+ rotation: number;
12
+ rotationSpeed: number;
13
+ stringLength: number;
14
+ };
@@ -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 { Bubbles } from './layer';
2
+ import type { BubblesConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createBubbles(config?: BubblesConfig): Effect<BubblesConfig> {
6
+ return new Bubbles(config);
7
+ }
8
+
9
+ export type { BubblesConfig };
10
+ export type { Bubble, PopParticle } from './types';