@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,107 @@
1
+ import { Effect } from '../effect';
2
+ import type { PlasmaColor } from './types';
3
+
4
+ export interface PlasmaConfig {
5
+ readonly speed?: number;
6
+ readonly scale?: number;
7
+ readonly resolution?: number;
8
+ readonly palette?: PlasmaColor[];
9
+ }
10
+
11
+ const DEFAULT_PALETTE: PlasmaColor[] = [
12
+ {r: 0, g: 255, b: 255},
13
+ {r: 255, g: 0, b: 255},
14
+ {r: 255, g: 255, b: 0},
15
+ {r: 0, g: 100, b: 255},
16
+ {r: 0, g: 255, b: 100}
17
+ ];
18
+
19
+ export class Plasma extends Effect<PlasmaConfig> {
20
+ #speed: number;
21
+ #scale: number;
22
+ readonly #resolution: number;
23
+ readonly #palette: PlasmaColor[];
24
+ #time: number = 0;
25
+ #offscreen: HTMLCanvasElement | null = null;
26
+ #offscreenCtx: CanvasRenderingContext2D | null = null;
27
+ #imageData: ImageData | null = null;
28
+
29
+ constructor(config: PlasmaConfig = {}) {
30
+ super();
31
+
32
+ this.#speed = config.speed ?? 1;
33
+ this.#scale = config.scale ?? 1;
34
+ this.#resolution = config.resolution ?? 4;
35
+ this.#palette = config.palette ?? DEFAULT_PALETTE;
36
+ }
37
+
38
+ configure(config: Partial<PlasmaConfig>): void {
39
+ if (config.speed !== undefined) {
40
+ this.#speed = config.speed;
41
+ }
42
+ if (config.scale !== undefined) {
43
+ this.#scale = config.scale;
44
+ }
45
+ }
46
+
47
+ tick(dt: number, _width: number, _height: number): void {
48
+ this.#time += 0.02 * dt * this.#speed;
49
+ }
50
+
51
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
52
+ const resolution = this.#resolution;
53
+ const offWidth = Math.ceil(width / resolution);
54
+ const offHeight = Math.ceil(height / resolution);
55
+
56
+ if (!this.#offscreen || this.#offscreen.width !== offWidth || this.#offscreen.height !== offHeight) {
57
+ this.#offscreen = document.createElement('canvas');
58
+ this.#offscreen.width = offWidth;
59
+ this.#offscreen.height = offHeight;
60
+ this.#offscreenCtx = this.#offscreen.getContext('2d');
61
+ this.#imageData = this.#offscreenCtx!.createImageData(offWidth, offHeight);
62
+ }
63
+
64
+ const data = this.#imageData!.data;
65
+ const time = this.#time;
66
+ const scale = this.#scale;
67
+ const freq = 50 * scale;
68
+ const palette = this.#palette;
69
+ const paletteLen = palette.length;
70
+
71
+ for (let py = 0; py < offHeight; py++) {
72
+ const worldY = py * resolution;
73
+
74
+ for (let px = 0; px < offWidth; px++) {
75
+ const worldX = px * resolution;
76
+
77
+ const value = Math.sin(worldX / freq + time)
78
+ + Math.sin(worldY / freq + time * 0.7)
79
+ + Math.sin((worldX + worldY) / (freq * 1.3) + time * 1.3)
80
+ + Math.sin(Math.sqrt(worldX * worldX + worldY * worldY) / freq + time * 0.5);
81
+
82
+ const normalized = (value + 4) / 8;
83
+ const mapped = normalized * (paletteLen - 1);
84
+ const index = Math.floor(mapped);
85
+ const frac = mapped - index;
86
+
87
+ const colorA = palette[index];
88
+ const colorB = palette[Math.min(index + 1, paletteLen - 1)];
89
+
90
+ const red = colorA.r + (colorB.r - colorA.r) * frac;
91
+ const green = colorA.g + (colorB.g - colorA.g) * frac;
92
+ const blue = colorA.b + (colorB.b - colorA.b) * frac;
93
+
94
+ const offset = (py * offWidth + px) * 4;
95
+ data[offset] = red;
96
+ data[offset + 1] = green;
97
+ data[offset + 2] = blue;
98
+ data[offset + 3] = 255;
99
+ }
100
+ }
101
+
102
+ this.#offscreenCtx!.putImageData(this.#imageData!, 0, 0);
103
+
104
+ ctx.imageSmoothingEnabled = true;
105
+ ctx.drawImage(this.#offscreen!, 0, 0, width, height);
106
+ }
107
+ }
@@ -0,0 +1,5 @@
1
+ export type PlasmaColor = {
2
+ r: number;
3
+ g: number;
4
+ b: number;
5
+ };
@@ -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 { Rain } from './layer';
2
+ import type { RainConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createRain(config?: RainConfig): Effect<RainConfig> {
6
+ return new Rain(config);
7
+ }
8
+
9
+ export { RaindropParticle, SplashParticle } from './particle';
10
+ export type { RainConfig };
11
+ export type { RaindropParticleConfig, SplashParticleConfig } from './particle';
12
+ export type { Raindrop, RainVariant, Splash } from './types';
@@ -0,0 +1,194 @@
1
+ import { parseColor } from '../color';
2
+ import { Effect } from '../effect';
3
+ import { MULBERRY } from './consts';
4
+ import type { Raindrop, RainVariant, Splash } from './types';
5
+
6
+ export interface RainConfig {
7
+ readonly variant?: RainVariant;
8
+ readonly drops?: number;
9
+ readonly wind?: number;
10
+ readonly speed?: number;
11
+ readonly splashes?: boolean;
12
+ readonly color?: string;
13
+ readonly groundLevel?: number;
14
+ readonly scale?: number;
15
+ }
16
+
17
+ const VARIANT_PRESETS: Record<RainVariant, { drops: number; speed: number; wind: number; splashes: boolean }> = {
18
+ drizzle: {drops: 70, speed: 0.55, wind: 0.1, splashes: false},
19
+ downpour: {drops: 200, speed: 0.85, wind: 0.25, splashes: true},
20
+ thunderstorm: {drops: 300, speed: 1, wind: 0.4, splashes: true}
21
+ };
22
+
23
+ export class Rain extends Effect<RainConfig> {
24
+ readonly #scale: number;
25
+ #speed: number;
26
+ #wind: number;
27
+ readonly #groundLevel: number;
28
+ #enableSplashes: boolean;
29
+ readonly #colorR: number;
30
+ readonly #colorG: number;
31
+ readonly #colorB: number;
32
+ #maxDrops: number;
33
+ #drops: Raindrop[] = [];
34
+ #splashes: Splash[] = [];
35
+
36
+ constructor(config: RainConfig = {}) {
37
+ super();
38
+
39
+ const variant = config.variant ?? 'downpour';
40
+ const preset = VARIANT_PRESETS[variant];
41
+
42
+ this.#scale = config.scale ?? 1;
43
+ this.#maxDrops = config.drops ?? preset.drops;
44
+ this.#speed = config.speed ?? preset.speed;
45
+ this.#wind = config.wind ?? preset.wind;
46
+ this.#groundLevel = config.groundLevel ?? 1.0;
47
+ this.#enableSplashes = config.splashes ?? preset.splashes;
48
+
49
+ const {r, g, b} = parseColor(config.color ?? 'rgba(174, 194, 224, 0.6)');
50
+ this.#colorR = r;
51
+ this.#colorG = g;
52
+ this.#colorB = b;
53
+
54
+ if (innerWidth < 991) {
55
+ this.#maxDrops = Math.floor(this.#maxDrops / 2);
56
+ }
57
+
58
+ for (let i = 0; i < this.#maxDrops; ++i) {
59
+ this.#drops.push(this.#createDrop(true));
60
+ }
61
+ }
62
+
63
+ configure(config: Partial<RainConfig>): void {
64
+ if (config.speed !== undefined) {
65
+ this.#speed = config.speed;
66
+ }
67
+ if (config.wind !== undefined) {
68
+ this.#wind = config.wind;
69
+ }
70
+ if (config.splashes !== undefined) {
71
+ this.#enableSplashes = config.splashes;
72
+ }
73
+ }
74
+
75
+ tick(dt: number, width: number, height: number): void {
76
+ // Update raindrops
77
+ let aliveDrops = 0;
78
+
79
+ for (let i = 0; i < this.#drops.length; i++) {
80
+ const drop = this.#drops[i];
81
+
82
+ drop.x += (drop.vx * dt) / width;
83
+ drop.y += (drop.vy * dt) / height;
84
+
85
+ if (drop.y >= this.#groundLevel) {
86
+ if (this.#enableSplashes) {
87
+ this.#createSplashBurst(drop.x, this.#groundLevel);
88
+ }
89
+
90
+ this.#drops[aliveDrops++] = this.#createDrop(false);
91
+ } else {
92
+ this.#drops[aliveDrops++] = drop;
93
+ }
94
+ }
95
+
96
+ this.#drops.length = aliveDrops;
97
+
98
+ // Refill drops
99
+ while (this.#drops.length < this.#maxDrops) {
100
+ this.#drops.push(this.#createDrop(false));
101
+ }
102
+
103
+ // Update splashes
104
+ let aliveSplashes = 0;
105
+
106
+ for (let i = 0; i < this.#splashes.length; i++) {
107
+ const splash = this.#splashes[i];
108
+
109
+ splash.x += (splash.vx * dt) / width;
110
+ splash.y += (splash.vy * dt) / height;
111
+ splash.vy += splash.gravity * dt;
112
+ splash.alpha -= 0.04 * dt;
113
+
114
+ if (splash.alpha > 0) {
115
+ this.#splashes[aliveSplashes++] = splash;
116
+ }
117
+ }
118
+
119
+ this.#splashes.length = aliveSplashes;
120
+ }
121
+
122
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
123
+ // Raindrops
124
+ ctx.lineCap = 'round';
125
+ for (const drop of this.#drops) {
126
+ const px = drop.x * width;
127
+ const py = drop.y * height;
128
+ const lineWidth = (0.4 + drop.depth * 1) * this.#scale;
129
+
130
+ // Draw line along velocity direction
131
+ const speed = Math.sqrt(drop.vx * drop.vx + drop.vy * drop.vy);
132
+ const nx = speed > 0 ? drop.vx / speed : 0;
133
+ const ny = speed > 0 ? drop.vy / speed : -1;
134
+ const tailLength = drop.length * drop.depth;
135
+
136
+ ctx.beginPath();
137
+ ctx.moveTo(px, py);
138
+ ctx.lineTo(px - nx * tailLength, py - ny * tailLength);
139
+ ctx.strokeStyle = `rgba(${this.#colorR}, ${this.#colorG}, ${this.#colorB}, ${drop.opacity * drop.depth})`;
140
+ ctx.lineWidth = lineWidth;
141
+ ctx.stroke();
142
+ }
143
+
144
+ // Splashes
145
+ for (const splash of this.#splashes) {
146
+ const px = splash.x * width;
147
+ const py = splash.y * height;
148
+
149
+ ctx.beginPath();
150
+ ctx.arc(px, py, splash.size * this.#scale, 0, Math.PI * 2);
151
+ ctx.fillStyle = `rgba(${this.#colorR}, ${this.#colorG}, ${this.#colorB}, ${splash.alpha})`;
152
+ ctx.fill();
153
+ }
154
+ }
155
+
156
+ #createDrop(initialSpread: boolean): Raindrop {
157
+ const depth = 0.3 + MULBERRY.next() * 0.7;
158
+ const fallSpeed = (3.5 + MULBERRY.next() * 5) * depth * this.#speed;
159
+
160
+ // Extend spawn range upstream of wind direction so the windward edge stays covered.
161
+ // Drops blowing right need to also spawn left of the canvas (negative x), and vice versa.
162
+ const windOffset = Math.abs(this.#wind) * 0.5;
163
+ const xMin = this.#wind > 0 ? -windOffset : 0;
164
+ const xMax = this.#wind < 0 ? 1 + windOffset : 1;
165
+
166
+ return {
167
+ x: xMin + MULBERRY.next() * (xMax - xMin),
168
+ y: initialSpread ? MULBERRY.next() * this.#groundLevel : -MULBERRY.next() * 0.1,
169
+ vx: this.#wind * fallSpeed * 0.6,
170
+ vy: fallSpeed,
171
+ length: (8 + MULBERRY.next() * 15) * this.#scale,
172
+ speed: fallSpeed,
173
+ depth,
174
+ opacity: 0.3 + MULBERRY.next() * 0.4
175
+ };
176
+ }
177
+
178
+ #createSplashBurst(x: number, y: number): void {
179
+ const count = 2 + Math.floor(MULBERRY.next() * 2);
180
+
181
+ for (let i = 0; i < count; i++) {
182
+ this.#splashes.push({
183
+ x,
184
+ y,
185
+ vx: (MULBERRY.next() - 0.5) * 2,
186
+ vy: -(1 + MULBERRY.next() * 2),
187
+ alpha: 0.5 + MULBERRY.next() * 0.3,
188
+ size: 1 + MULBERRY.next() * 2,
189
+ gravity: 0.15
190
+ });
191
+ }
192
+ }
193
+
194
+ }
@@ -0,0 +1,132 @@
1
+ import type { Point } from '../point';
2
+
3
+ export interface RaindropParticleConfig {
4
+ readonly depth?: number;
5
+ readonly groundY?: number;
6
+ readonly length?: number;
7
+ readonly scale?: number;
8
+ }
9
+
10
+ export interface SplashParticleConfig {
11
+ readonly gravity?: number;
12
+ readonly scale?: number;
13
+ readonly size?: number;
14
+ }
15
+
16
+ export class RaindropParticle {
17
+ readonly #color: [number, number, number];
18
+ readonly #depth: number;
19
+ readonly #length: number;
20
+ readonly #groundY: number;
21
+ readonly #scale: number;
22
+ readonly #opacity: number;
23
+ readonly #vx: number;
24
+ readonly #vy: number;
25
+ #x: number;
26
+ #y: number;
27
+
28
+ get isDead(): boolean {
29
+ return this.#y >= this.#groundY;
30
+ }
31
+
32
+ get position(): Point {
33
+ return {x: this.#x, y: this.#y};
34
+ }
35
+
36
+ constructor(position: Point, velocity: Point, color: [number, number, number], config: RaindropParticleConfig = {}) {
37
+ this.#x = position.x;
38
+ this.#y = position.y;
39
+ this.#vx = velocity.x;
40
+ this.#vy = velocity.y;
41
+ this.#color = color;
42
+ this.#depth = config.depth ?? (0.3 + Math.random() * 0.7);
43
+ this.#groundY = config.groundY ?? Number.POSITIVE_INFINITY;
44
+ this.#length = (config.length ?? (8 + Math.random() * 15)) * (config.scale ?? 1);
45
+ this.#opacity = 0.3 + Math.random() * 0.4;
46
+ this.#scale = config.scale ?? 1;
47
+ }
48
+
49
+ draw(ctx: CanvasRenderingContext2D): void {
50
+ const [r, g, b] = this.#color;
51
+ const speed = Math.sqrt(this.#vx * this.#vx + this.#vy * this.#vy);
52
+ const nx = speed > 0 ? this.#vx / speed : 0;
53
+ const ny = speed > 0 ? this.#vy / speed : -1;
54
+
55
+ ctx.beginPath();
56
+ ctx.moveTo(this.#x, this.#y);
57
+ ctx.lineTo(this.#x - nx * this.#length * this.#depth, this.#y - ny * this.#length * this.#depth);
58
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${this.#opacity * this.#depth})`;
59
+ ctx.lineWidth = (0.4 + this.#depth * 1) * this.#scale;
60
+ ctx.lineCap = 'round';
61
+ ctx.stroke();
62
+ }
63
+
64
+ tick(dt: number = 1): void {
65
+ this.#x += this.#vx * dt;
66
+ this.#y += this.#vy * dt;
67
+ }
68
+ }
69
+
70
+ export class SplashParticle {
71
+ readonly #color: [number, number, number];
72
+ readonly #gravity: number;
73
+ readonly #size: number;
74
+ readonly #scale: number;
75
+ #x: number;
76
+ #y: number;
77
+ #vx: number;
78
+ #vy: number;
79
+ #alpha: number;
80
+
81
+ get isDead(): boolean {
82
+ return this.#alpha <= 0;
83
+ }
84
+
85
+ get position(): Point {
86
+ return {x: this.#x, y: this.#y};
87
+ }
88
+
89
+ constructor(position: Point, velocity: Point, color: [number, number, number], config: SplashParticleConfig = {}) {
90
+ this.#x = position.x;
91
+ this.#y = position.y;
92
+ this.#vx = velocity.x;
93
+ this.#vy = velocity.y;
94
+ this.#color = color;
95
+ this.#alpha = 0.5 + Math.random() * 0.3;
96
+ this.#gravity = config.gravity ?? 0.15;
97
+ this.#size = config.size ?? (1 + Math.random() * 2);
98
+ this.#scale = config.scale ?? 1;
99
+ }
100
+
101
+ static burst(position: Point, color: [number, number, number], config?: SplashParticleConfig): SplashParticle[] {
102
+ const count = 2 + Math.floor(Math.random() * 3);
103
+ const particles: SplashParticle[] = [];
104
+
105
+ for (let i = 0; i < count; i++) {
106
+ particles.push(new SplashParticle(
107
+ position,
108
+ {x: (Math.random() - 0.5) * 4, y: -(1 + Math.random() * 3)},
109
+ color,
110
+ config
111
+ ));
112
+ }
113
+
114
+ return particles;
115
+ }
116
+
117
+ draw(ctx: CanvasRenderingContext2D): void {
118
+ const [r, g, b] = this.#color;
119
+
120
+ ctx.beginPath();
121
+ ctx.arc(this.#x, this.#y, this.#size * this.#scale, 0, Math.PI * 2);
122
+ ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${this.#alpha})`;
123
+ ctx.fill();
124
+ }
125
+
126
+ tick(dt: number = 1): void {
127
+ this.#x += this.#vx * dt;
128
+ this.#y += this.#vy * dt;
129
+ this.#vy += this.#gravity * dt;
130
+ this.#alpha -= 0.04 * dt;
131
+ }
132
+ }
@@ -0,0 +1,22 @@
1
+ export type RainVariant = 'drizzle' | 'downpour' | 'thunderstorm';
2
+
3
+ export type Raindrop = {
4
+ x: number;
5
+ y: number;
6
+ vx: number;
7
+ vy: number;
8
+ length: number;
9
+ speed: number;
10
+ depth: number;
11
+ opacity: number;
12
+ };
13
+
14
+ export type Splash = {
15
+ x: number;
16
+ y: number;
17
+ vx: number;
18
+ vy: number;
19
+ alpha: number;
20
+ size: number;
21
+ gravity: number;
22
+ };
@@ -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 { Sandstorm } from './layer';
2
+ import type { SandstormConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createSandstorm(config?: SandstormConfig): Effect<SandstormConfig> {
6
+ return new Sandstorm(config);
7
+ }
8
+
9
+ export type { SandstormConfig };
10
+ export type { SandGrain } from './types';
@@ -0,0 +1,152 @@
1
+ import { Effect } from '../effect';
2
+ import { MULBERRY } from './consts';
3
+ import type { SandGrain } from './types';
4
+
5
+ export interface SandstormConfig {
6
+ readonly count?: number;
7
+ readonly wind?: number;
8
+ readonly turbulence?: number;
9
+ readonly color?: string;
10
+ readonly hazeOpacity?: number;
11
+ readonly scale?: number;
12
+ }
13
+
14
+ export class Sandstorm extends Effect<SandstormConfig> {
15
+ readonly #scale: number;
16
+ #wind: number;
17
+ #turbulence: number;
18
+ readonly #colorR: number;
19
+ readonly #colorG: number;
20
+ readonly #colorB: number;
21
+ readonly #hazeOpacity: number;
22
+ #maxCount: number;
23
+ #time: number = 0;
24
+ #grains: SandGrain[] = [];
25
+
26
+ constructor(config: SandstormConfig = {}) {
27
+ super();
28
+
29
+ this.#scale = config.scale ?? 1;
30
+ this.#maxCount = config.count ?? 300;
31
+ this.#wind = config.wind ?? 1;
32
+ this.#turbulence = config.turbulence ?? 1;
33
+ this.#hazeOpacity = config.hazeOpacity ?? 0.15;
34
+
35
+ const {r, g, b} = this.#parseColor(config.color ?? '#c2956b');
36
+ this.#colorR = r;
37
+ this.#colorG = g;
38
+ this.#colorB = b;
39
+
40
+ if (innerWidth < 991) {
41
+ this.#maxCount = Math.floor(this.#maxCount / 2);
42
+ }
43
+
44
+ for (let i = 0; i < this.#maxCount; ++i) {
45
+ this.#grains.push(this.#createGrain(true));
46
+ }
47
+ }
48
+
49
+ configure(config: Partial<SandstormConfig>): void {
50
+ if (config.wind !== undefined) {
51
+ this.#wind = config.wind;
52
+ }
53
+ if (config.turbulence !== undefined) {
54
+ this.#turbulence = config.turbulence;
55
+ }
56
+ }
57
+
58
+ tick(dt: number, width: number, height: number): void {
59
+ this.#time += 0.02 * dt;
60
+
61
+ const gustX = Math.sin(this.#time * 0.3) * 0.5
62
+ + Math.sin(this.#time * 0.8 + 1) * 0.3
63
+ + Math.sin(this.#time * 2.1) * 0.2;
64
+
65
+ const gustY = Math.sin(this.#time * 0.5 + 2) * 0.15;
66
+
67
+ const baseWindX = (3 + gustX * 2) * this.#wind;
68
+ const baseWindY = gustY * this.#turbulence;
69
+
70
+ for (let index = 0; index < this.#grains.length; index++) {
71
+ const grain = this.#grains[index];
72
+
73
+ const turbX = Math.sin(this.#time * 3 + grain.turbulenceOffset) * this.#turbulence * 0.5;
74
+ const turbY = Math.cos(this.#time * 2.5 + grain.turbulenceOffset * 1.3) * this.#turbulence * 0.3;
75
+
76
+ grain.vx = (baseWindX + turbX) * grain.depth;
77
+ grain.vy = (baseWindY + turbY) * grain.depth + 0.3 * grain.depth;
78
+
79
+ grain.x += (grain.vx * dt) / width;
80
+ grain.y += (grain.vy * dt) / height;
81
+
82
+ if (grain.x > 1.1 || grain.x < -0.1 || grain.y > 1.1 || grain.y < -0.1) {
83
+ const recycled = this.#createGrain(false);
84
+
85
+ if (baseWindX > 0) {
86
+ recycled.x = -0.1;
87
+ } else {
88
+ recycled.x = 1.1;
89
+ }
90
+
91
+ recycled.y = MULBERRY.next();
92
+ this.#grains[index] = recycled;
93
+ }
94
+ }
95
+ }
96
+
97
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
98
+
99
+ const hazeFlicker = 0.9 + Math.sin(this.#time * 0.7) * 0.1;
100
+ ctx.fillStyle = `rgba(${this.#colorR}, ${this.#colorG}, ${this.#colorB}, ${this.#hazeOpacity * hazeFlicker})`;
101
+ ctx.fillRect(0, 0, width, height);
102
+
103
+ for (const grain of this.#grains) {
104
+ const px = grain.x * width;
105
+ const py = grain.y * height;
106
+ const size = grain.size * grain.depth * this.#scale;
107
+
108
+ if (size < 0.3) {
109
+ continue;
110
+ }
111
+
112
+ const speed = Math.sqrt(grain.vx * grain.vx + grain.vy * grain.vy);
113
+ const nx = speed > 0 ? grain.vx / speed : 1;
114
+ const ny = speed > 0 ? grain.vy / speed : 0;
115
+ const streakLength = size * 2 + speed * 3;
116
+
117
+ ctx.beginPath();
118
+ ctx.moveTo(px, py);
119
+ ctx.lineTo(px - nx * streakLength, py - ny * streakLength);
120
+ ctx.strokeStyle = `rgba(${this.#colorR}, ${this.#colorG}, ${this.#colorB}, ${grain.opacity * grain.depth})`;
121
+ ctx.lineWidth = size;
122
+ ctx.lineCap = 'round';
123
+ ctx.stroke();
124
+ }
125
+ }
126
+
127
+ #parseColor(color: string): { r: number; g: number; b: number } {
128
+ const canvas = document.createElement('canvas');
129
+ canvas.width = 1;
130
+ canvas.height = 1;
131
+ const ctx = canvas.getContext('2d')!;
132
+ ctx.fillStyle = color;
133
+ ctx.fillRect(0, 0, 1, 1);
134
+ const data = ctx.getImageData(0, 0, 1, 1).data;
135
+ return {r: data[0], g: data[1], b: data[2]};
136
+ }
137
+
138
+ #createGrain(initialSpread: boolean): SandGrain {
139
+ const depth = 0.2 + MULBERRY.next() * 0.8;
140
+
141
+ return {
142
+ x: initialSpread ? MULBERRY.next() : -0.1,
143
+ y: MULBERRY.next(),
144
+ vx: 0,
145
+ vy: 0,
146
+ size: (0.5 + MULBERRY.next() * 2) * this.#scale,
147
+ depth,
148
+ opacity: 0.3 + MULBERRY.next() * 0.5,
149
+ turbulenceOffset: MULBERRY.next() * Math.PI * 2
150
+ };
151
+ }
152
+ }
@@ -0,0 +1,10 @@
1
+ export type SandGrain = {
2
+ x: number;
3
+ y: number;
4
+ vx: number;
5
+ vy: number;
6
+ size: number;
7
+ depth: number;
8
+ opacity: number;
9
+ turbulenceOffset: number;
10
+ };