@basmilius/sparkle 2.1.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 (107) hide show
  1. package/dist/index.d.mts +306 -459
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +1106 -848
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +6 -2
  6. package/src/aurora/index.ts +9 -3
  7. package/src/aurora/layer.ts +57 -29
  8. package/src/balloons/index.ts +9 -3
  9. package/src/balloons/layer.ts +50 -19
  10. package/src/bubbles/index.ts +9 -3
  11. package/src/bubbles/layer.ts +30 -17
  12. package/src/canvas.ts +12 -0
  13. package/src/color.ts +11 -2
  14. package/src/confetti/index.ts +15 -3
  15. package/src/confetti/layer.ts +8 -5
  16. package/src/confetti/particle.ts +12 -11
  17. package/src/donuts/consts.ts +2 -2
  18. package/src/donuts/index.ts +9 -3
  19. package/src/donuts/layer.ts +43 -12
  20. package/src/effect.ts +107 -0
  21. package/src/fade.ts +87 -0
  22. package/src/fireflies/index.ts +9 -3
  23. package/src/fireflies/layer.ts +26 -9
  24. package/src/fireflies/particle.ts +2 -2
  25. package/src/firepit/index.ts +9 -3
  26. package/src/firepit/layer.ts +26 -7
  27. package/src/fireworks/create-explosion.ts +237 -0
  28. package/src/fireworks/explosion.ts +1 -1
  29. package/src/fireworks/index.ts +15 -3
  30. package/src/fireworks/layer.ts +55 -304
  31. package/src/fireworks/spark.ts +2 -2
  32. package/src/fireworks/types.ts +2 -2
  33. package/src/glitter/index.ts +9 -4
  34. package/src/glitter/layer.ts +15 -7
  35. package/src/glitter/types.ts +10 -0
  36. package/src/index.ts +3 -4
  37. package/src/lanterns/index.ts +9 -4
  38. package/src/lanterns/layer.ts +22 -10
  39. package/src/lanterns/types.ts +8 -0
  40. package/src/layer.ts +13 -11
  41. package/src/leaves/index.ts +9 -4
  42. package/src/leaves/layer.ts +21 -14
  43. package/src/leaves/types.ts +9 -0
  44. package/src/lightning/index.ts +9 -4
  45. package/src/lightning/layer.ts +4 -4
  46. package/src/lightning/system.ts +3 -3
  47. package/src/lightning/types.ts +10 -2
  48. package/src/matrix/index.ts +9 -4
  49. package/src/matrix/layer.ts +15 -7
  50. package/src/matrix/types.ts +9 -0
  51. package/src/orbits/index.ts +9 -4
  52. package/src/orbits/layer.ts +51 -21
  53. package/src/orbits/types.ts +12 -1
  54. package/src/particles/index.ts +9 -3
  55. package/src/particles/layer.ts +55 -12
  56. package/src/petals/index.ts +9 -3
  57. package/src/petals/layer.ts +29 -13
  58. package/src/plasma/index.ts +9 -3
  59. package/src/plasma/layer.ts +21 -6
  60. package/src/rain/index.ts +9 -3
  61. package/src/rain/layer.ts +30 -8
  62. package/src/sandstorm/index.ts +9 -3
  63. package/src/sandstorm/layer.ts +26 -9
  64. package/src/scene.ts +201 -0
  65. package/src/shooting-stars/system.ts +26 -24
  66. package/src/shooting-stars/types.ts +2 -1
  67. package/src/simulation-canvas.ts +40 -4
  68. package/src/snow/index.ts +9 -3
  69. package/src/snow/layer.ts +24 -11
  70. package/src/sparklers/index.ts +13 -3
  71. package/src/sparklers/layer.ts +61 -15
  72. package/src/stars/index.ts +9 -3
  73. package/src/stars/layer.ts +28 -22
  74. package/src/streamers/index.ts +9 -3
  75. package/src/streamers/layer.ts +18 -6
  76. package/src/streamers/types.ts +1 -1
  77. package/src/waves/index.ts +9 -3
  78. package/src/waves/layer.ts +42 -45
  79. package/src/waves/types.ts +1 -0
  80. package/src/wormhole/index.ts +9 -3
  81. package/src/wormhole/layer.ts +22 -6
  82. package/src/aurora/simulation.ts +0 -19
  83. package/src/balloons/simulation.ts +0 -19
  84. package/src/bubbles/simulation.ts +0 -20
  85. package/src/confetti/simulation.ts +0 -27
  86. package/src/donuts/simulation.ts +0 -25
  87. package/src/fireflies/simulation.ts +0 -18
  88. package/src/firepit/simulation.ts +0 -17
  89. package/src/fireworks/simulation.ts +0 -18
  90. package/src/glitter/simulation.ts +0 -19
  91. package/src/lanterns/simulation.ts +0 -17
  92. package/src/layered.ts +0 -185
  93. package/src/leaves/simulation.ts +0 -18
  94. package/src/lightning/simulation.ts +0 -17
  95. package/src/matrix/simulation.ts +0 -18
  96. package/src/orbits/simulation.ts +0 -19
  97. package/src/particles/simulation.ts +0 -26
  98. package/src/petals/simulation.ts +0 -18
  99. package/src/plasma/simulation.ts +0 -17
  100. package/src/rain/simulation.ts +0 -21
  101. package/src/sandstorm/simulation.ts +0 -18
  102. package/src/snow/simulation.ts +0 -17
  103. package/src/sparklers/simulation.ts +0 -30
  104. package/src/stars/simulation.ts +0 -22
  105. package/src/streamers/simulation.ts +0 -16
  106. package/src/waves/simulation.ts +0 -18
  107. package/src/wormhole/simulation.ts +0 -17
@@ -1,20 +1,28 @@
1
- import { SimulationLayer } from '../layer';
1
+ import { Effect } from '../effect';
2
2
  import { MULBERRY, PETAL_COLORS } from './consts';
3
- import type { PetalSimulationConfig } from './simulation';
4
3
  import type { Petal } from './types';
5
4
 
6
- export class PetalLayer extends SimulationLayer {
5
+ export interface PetalsConfig {
6
+ readonly count?: number;
7
+ readonly colors?: string[];
8
+ readonly size?: number;
9
+ readonly speed?: number;
10
+ readonly wind?: number;
11
+ readonly scale?: number;
12
+ }
13
+
14
+ export class Petals extends Effect<PetalsConfig> {
7
15
  readonly #scale: number;
8
16
  readonly #size: number;
9
- readonly #speed: number;
10
- readonly #wind: number;
17
+ #speed: number;
18
+ #wind: number;
11
19
  readonly #colors: string[];
12
20
  #maxCount: number;
13
21
  #time: number = 0;
14
22
  #petals: Petal[] = [];
15
23
  #sprites: HTMLCanvasElement[] = [];
16
24
 
17
- constructor(config: PetalSimulationConfig = {}) {
25
+ constructor(config: PetalsConfig = {}) {
18
26
  super();
19
27
 
20
28
  this.#scale = config.scale ?? 1;
@@ -35,14 +43,23 @@ export class PetalLayer extends SimulationLayer {
35
43
  }
36
44
  }
37
45
 
46
+ configure(config: Partial<PetalsConfig>): void {
47
+ if (config.speed !== undefined) {
48
+ this.#speed = config.speed;
49
+ }
50
+ if (config.wind !== undefined) {
51
+ this.#wind = config.wind;
52
+ }
53
+ }
54
+
38
55
  tick(dt: number, _width: number, height: number): void {
39
56
  const speedFactor = (height / 540) / this.#speed;
40
57
 
41
58
  this.#time += 0.012 * dt;
42
59
 
43
60
  const globalWind = Math.sin(this.#time * 0.4) * 0.3
44
- + Math.sin(this.#time * 1.1 + 1.5) * 0.15
45
- + Math.sin(this.#time * 2.7) * 0.08;
61
+ + Math.sin(this.#time * 1.1 + 1.5) * 0.15
62
+ + Math.sin(this.#time * 2.7) * 0.08;
46
63
 
47
64
  for (let index = 0; index < this.#petals.length; index++) {
48
65
  const petal = this.#petals[index];
@@ -78,11 +95,10 @@ export class PetalLayer extends SimulationLayer {
78
95
  const py = petal.y * height;
79
96
  const displaySize = petal.size * petal.depth;
80
97
  const scaleX = Math.cos(petal.flipAngle);
98
+ const cos = Math.cos(petal.rotation);
99
+ const sin = Math.sin(petal.rotation);
81
100
 
82
- ctx.save();
83
- ctx.translate(px, py);
84
- ctx.rotate(petal.rotation);
85
- ctx.scale(scaleX, 1);
101
+ ctx.setTransform(cos * scaleX, sin * scaleX, -sin, cos, px, py);
86
102
  ctx.globalAlpha = 0.4 + petal.depth * 0.6;
87
103
  ctx.drawImage(
88
104
  this.#sprites[petal.colorIndex % this.#sprites.length],
@@ -91,9 +107,9 @@ export class PetalLayer extends SimulationLayer {
91
107
  displaySize,
92
108
  displaySize
93
109
  );
94
- ctx.restore();
95
110
  }
96
111
 
112
+ ctx.resetTransform();
97
113
  ctx.globalAlpha = 1;
98
114
  }
99
115
 
@@ -1,4 +1,10 @@
1
- export { PlasmaLayer } from './layer';
2
- export { PlasmaSimulation } from './simulation';
3
- export type { PlasmaSimulationConfig } from './simulation';
1
+ import { Plasma } from './layer';
2
+ import type { PlasmaConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createPlasma(config?: PlasmaConfig): Effect<PlasmaConfig> {
6
+ return new Plasma(config);
7
+ }
8
+
9
+ export type { PlasmaConfig };
4
10
  export type { PlasmaColor } from './types';
@@ -1,7 +1,13 @@
1
- import { SimulationLayer } from '../layer';
2
- import type { PlasmaSimulationConfig } from './simulation';
1
+ import { Effect } from '../effect';
3
2
  import type { PlasmaColor } from './types';
4
3
 
4
+ export interface PlasmaConfig {
5
+ readonly speed?: number;
6
+ readonly scale?: number;
7
+ readonly resolution?: number;
8
+ readonly palette?: PlasmaColor[];
9
+ }
10
+
5
11
  const DEFAULT_PALETTE: PlasmaColor[] = [
6
12
  {r: 0, g: 255, b: 255},
7
13
  {r: 255, g: 0, b: 255},
@@ -10,9 +16,9 @@ const DEFAULT_PALETTE: PlasmaColor[] = [
10
16
  {r: 0, g: 255, b: 100}
11
17
  ];
12
18
 
13
- export class PlasmaLayer extends SimulationLayer {
14
- readonly #speed: number;
15
- readonly #scale: number;
19
+ export class Plasma extends Effect<PlasmaConfig> {
20
+ #speed: number;
21
+ #scale: number;
16
22
  readonly #resolution: number;
17
23
  readonly #palette: PlasmaColor[];
18
24
  #time: number = 0;
@@ -20,7 +26,7 @@ export class PlasmaLayer extends SimulationLayer {
20
26
  #offscreenCtx: CanvasRenderingContext2D | null = null;
21
27
  #imageData: ImageData | null = null;
22
28
 
23
- constructor(config: PlasmaSimulationConfig = {}) {
29
+ constructor(config: PlasmaConfig = {}) {
24
30
  super();
25
31
 
26
32
  this.#speed = config.speed ?? 1;
@@ -29,6 +35,15 @@ export class PlasmaLayer extends SimulationLayer {
29
35
  this.#palette = config.palette ?? DEFAULT_PALETTE;
30
36
  }
31
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
+
32
47
  tick(dt: number, _width: number, _height: number): void {
33
48
  this.#time += 0.02 * dt * this.#speed;
34
49
  }
package/src/rain/index.ts CHANGED
@@ -1,6 +1,12 @@
1
- export { RainLayer } from './layer';
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
+
2
9
  export { RaindropParticle, SplashParticle } from './particle';
3
- export { RainSimulation } from './simulation';
10
+ export type { RainConfig };
4
11
  export type { RaindropParticleConfig, SplashParticleConfig } from './particle';
5
- export type { RainSimulationConfig } from './simulation';
6
12
  export type { Raindrop, RainVariant, Splash } from './types';
package/src/rain/layer.ts CHANGED
@@ -1,21 +1,31 @@
1
1
  import { parseColor } from '../color';
2
- import { SimulationLayer } from '../layer';
2
+ import { Effect } from '../effect';
3
3
  import { MULBERRY } from './consts';
4
- import type { RainSimulationConfig } from './simulation';
5
4
  import type { Raindrop, RainVariant, Splash } from './types';
6
5
 
7
- const VARIANT_PRESETS: Record<RainVariant, {drops: number; speed: number; wind: number; splashes: boolean}> = {
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 }> = {
8
18
  drizzle: {drops: 70, speed: 0.55, wind: 0.1, splashes: false},
9
19
  downpour: {drops: 200, speed: 0.85, wind: 0.25, splashes: true},
10
20
  thunderstorm: {drops: 300, speed: 1, wind: 0.4, splashes: true}
11
21
  };
12
22
 
13
- export class RainLayer extends SimulationLayer {
23
+ export class Rain extends Effect<RainConfig> {
14
24
  readonly #scale: number;
15
- readonly #speed: number;
16
- readonly #wind: number;
25
+ #speed: number;
26
+ #wind: number;
17
27
  readonly #groundLevel: number;
18
- readonly #enableSplashes: boolean;
28
+ #enableSplashes: boolean;
19
29
  readonly #colorR: number;
20
30
  readonly #colorG: number;
21
31
  readonly #colorB: number;
@@ -23,7 +33,7 @@ export class RainLayer extends SimulationLayer {
23
33
  #drops: Raindrop[] = [];
24
34
  #splashes: Splash[] = [];
25
35
 
26
- constructor(config: RainSimulationConfig = {}) {
36
+ constructor(config: RainConfig = {}) {
27
37
  super();
28
38
 
29
39
  const variant = config.variant ?? 'downpour';
@@ -50,6 +60,18 @@ export class RainLayer extends SimulationLayer {
50
60
  }
51
61
  }
52
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
+
53
75
  tick(dt: number, width: number, height: number): void {
54
76
  // Update raindrops
55
77
  let aliveDrops = 0;
@@ -1,4 +1,10 @@
1
- export { SandstormLayer } from './layer';
2
- export { SandstormSimulation } from './simulation';
3
- export type { SandstormSimulationConfig } from './simulation';
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 };
4
10
  export type { SandGrain } from './types';
@@ -1,12 +1,20 @@
1
- import { SimulationLayer } from '../layer';
1
+ import { Effect } from '../effect';
2
2
  import { MULBERRY } from './consts';
3
- import type { SandstormSimulationConfig } from './simulation';
4
3
  import type { SandGrain } from './types';
5
4
 
6
- export class SandstormLayer extends SimulationLayer {
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> {
7
15
  readonly #scale: number;
8
- readonly #wind: number;
9
- readonly #turbulence: number;
16
+ #wind: number;
17
+ #turbulence: number;
10
18
  readonly #colorR: number;
11
19
  readonly #colorG: number;
12
20
  readonly #colorB: number;
@@ -15,7 +23,7 @@ export class SandstormLayer extends SimulationLayer {
15
23
  #time: number = 0;
16
24
  #grains: SandGrain[] = [];
17
25
 
18
- constructor(config: SandstormSimulationConfig = {}) {
26
+ constructor(config: SandstormConfig = {}) {
19
27
  super();
20
28
 
21
29
  this.#scale = config.scale ?? 1;
@@ -38,12 +46,21 @@ export class SandstormLayer extends SimulationLayer {
38
46
  }
39
47
  }
40
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
+
41
58
  tick(dt: number, width: number, height: number): void {
42
59
  this.#time += 0.02 * dt;
43
60
 
44
61
  const gustX = Math.sin(this.#time * 0.3) * 0.5
45
- + Math.sin(this.#time * 0.8 + 1) * 0.3
46
- + Math.sin(this.#time * 2.1) * 0.2;
62
+ + Math.sin(this.#time * 0.8 + 1) * 0.3
63
+ + Math.sin(this.#time * 2.1) * 0.2;
47
64
 
48
65
  const gustY = Math.sin(this.#time * 0.5 + 2) * 0.15;
49
66
 
@@ -107,7 +124,7 @@ export class SandstormLayer extends SimulationLayer {
107
124
  }
108
125
  }
109
126
 
110
- #parseColor(color: string): {r: number; g: number; b: number} {
127
+ #parseColor(color: string): { r: number; g: number; b: number } {
111
128
  const canvas = document.createElement('canvas');
112
129
  canvas.width = 1;
113
130
  canvas.height = 1;
package/src/scene.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { LimitedFrameRateCanvas } from './canvas';
2
+ import { applyEdgeFade } from './fade';
3
+ import type { SimulationLayer } from './layer';
4
+
5
+ /**
6
+ * Internal canvas runner that drives all layers in a Scene.
7
+ */
8
+ class SceneCanvas extends LimitedFrameRateCanvas {
9
+ readonly #layers: SimulationLayer[];
10
+ readonly #contextOptions: CanvasRenderingContext2DSettings;
11
+ #offscreen: HTMLCanvasElement | null = null;
12
+ #offscreenCtx: CanvasRenderingContext2D | null = null;
13
+
14
+ constructor(canvas: HTMLCanvasElement, layers: SimulationLayer[], frameRate: number, options: CanvasRenderingContext2DSettings) {
15
+ super(canvas, frameRate, options);
16
+ this.#layers = layers;
17
+ this.#contextOptions = options;
18
+
19
+ canvas.style.position = 'absolute';
20
+ canvas.style.top = '0';
21
+ canvas.style.left = '0';
22
+ canvas.style.height = '100%';
23
+ canvas.style.width = '100%';
24
+ }
25
+
26
+ start(): void {
27
+ for (const layer of this.#layers) {
28
+ layer.onMount(this.canvas);
29
+ }
30
+ super.start();
31
+ }
32
+
33
+ destroy(): void {
34
+ for (const layer of this.#layers) {
35
+ layer.onUnmount(this.canvas);
36
+ }
37
+ super.destroy();
38
+ }
39
+
40
+ draw(): void {
41
+ this.canvas.height = this.height;
42
+ this.canvas.width = this.width;
43
+
44
+ const ctx = this.context;
45
+ ctx.clearRect(0, 0, this.width, this.height);
46
+
47
+ for (const layer of this.#layers) {
48
+ if (layer.fade) {
49
+ const offCtx = this.#getOffscreenCtx(this.width, this.height);
50
+ offCtx.clearRect(0, 0, this.width, this.height);
51
+ layer.draw(offCtx, this.width, this.height);
52
+ applyEdgeFade(offCtx, this.width, this.height, layer.fade);
53
+ ctx.drawImage(this.#offscreen!, 0, 0);
54
+ } else {
55
+ ctx.save();
56
+ layer.draw(ctx, this.width, this.height);
57
+ ctx.restore();
58
+ }
59
+ }
60
+ }
61
+
62
+ tick(): void {
63
+ const dt = (this.delta > 0 && this.delta < 200 ? this.delta / (1000 / 60) : 1) * this.speed * LimitedFrameRateCanvas.globalSpeed;
64
+
65
+ for (const layer of this.#layers) {
66
+ layer.tick(dt, this.width, this.height);
67
+ }
68
+ }
69
+
70
+ onResize(): void {
71
+ super.onResize();
72
+
73
+ if (this.#offscreen) {
74
+ this.#offscreen.width = this.width;
75
+ this.#offscreen.height = this.height;
76
+ }
77
+
78
+ for (const layer of this.#layers) {
79
+ layer.onResize(this.width, this.height);
80
+ }
81
+ }
82
+
83
+ #getOffscreenCtx(width: number, height: number): CanvasRenderingContext2D {
84
+ if (!this.#offscreen) {
85
+ this.#offscreen = document.createElement('canvas');
86
+ this.#offscreen.width = width;
87
+ this.#offscreen.height = height;
88
+ this.#offscreenCtx = this.#offscreen.getContext('2d', this.#contextOptions)!;
89
+ }
90
+
91
+ return this.#offscreenCtx!;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Composable canvas that renders multiple Effect layers in order (first = bottom, last = top).
97
+ *
98
+ * @example
99
+ * const scene = new Scene()
100
+ * .mount(canvas)
101
+ * .layer(new Aurora({ bands: 5 }))
102
+ * .layer(new Stars().withFade({ bottom: 0.4 }))
103
+ * .start();
104
+ */
105
+ export class Scene {
106
+ readonly #layers: SimulationLayer[] = [];
107
+ readonly #frameRate: number;
108
+ readonly #defaultOptions: CanvasRenderingContext2DSettings;
109
+ #runner: SceneCanvas | null = null;
110
+
111
+ constructor(frameRate: number = 60, options: CanvasRenderingContext2DSettings = {colorSpace: 'display-p3'}) {
112
+ this.#frameRate = frameRate;
113
+ this.#defaultOptions = options;
114
+ }
115
+
116
+ /**
117
+ * Mount the scene to a canvas element or CSS selector.
118
+ */
119
+ mount(canvas: HTMLCanvasElement | string, options?: CanvasRenderingContext2DSettings): this {
120
+ if (typeof canvas === 'string') {
121
+ const el = document.querySelector<HTMLCanvasElement>(canvas);
122
+
123
+ if (!el) {
124
+ throw new Error(`Scene.mount(): no element found for selector "${canvas}".`);
125
+ }
126
+
127
+ canvas = el;
128
+ }
129
+
130
+ this.#runner?.destroy();
131
+ this.#runner = new SceneCanvas(canvas, this.#layers, this.#frameRate, options ?? this.#defaultOptions);
132
+ return this;
133
+ }
134
+
135
+ /**
136
+ * Add an effect layer. Layers are rendered in the order they are added.
137
+ * If the scene is already running, the layer is mounted immediately.
138
+ */
139
+ layer(effect: SimulationLayer): this {
140
+ this.#layers.push(effect);
141
+
142
+ if (this.#runner?.isTicking) {
143
+ effect.onMount(this.#runner.canvas);
144
+ }
145
+
146
+ return this;
147
+ }
148
+
149
+ /**
150
+ * Start the render loop.
151
+ */
152
+ start(): this {
153
+ this.#runner?.start();
154
+ return this;
155
+ }
156
+
157
+ /**
158
+ * Pause rendering without destroying state. Use resume() to continue.
159
+ */
160
+ pause(): this {
161
+ this.#runner?.pause();
162
+ return this;
163
+ }
164
+
165
+ /**
166
+ * Resume rendering after pause().
167
+ */
168
+ resume(): this {
169
+ this.#runner?.resume();
170
+ return this;
171
+ }
172
+
173
+ /**
174
+ * Stop and destroy all layers.
175
+ */
176
+ destroy(): void {
177
+ this.#runner?.destroy();
178
+ this.#runner = null;
179
+ }
180
+
181
+ get speed(): number {
182
+ return this.#runner?.speed ?? 1;
183
+ }
184
+
185
+ set speed(value: number) {
186
+ if (this.#runner) {
187
+ this.#runner.speed = value;
188
+ }
189
+ }
190
+
191
+ get isTicking(): boolean {
192
+ return this.#runner?.isTicking ?? false;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Factory alternative to `new Scene()`. Call .mount() and .layer() on the returned instance.
198
+ */
199
+ export function createScene(frameRate?: number, options?: CanvasRenderingContext2DSettings): Scene {
200
+ return new Scene(frameRate, options);
201
+ }
@@ -11,7 +11,6 @@ export interface ShootingStarSystemConfig {
11
11
  readonly alphaRange?: number;
12
12
  readonly decayMin?: number;
13
13
  readonly decayRange?: number;
14
- readonly verticalFade?: [number, number];
15
14
  }
16
15
 
17
16
  export class ShootingStarSystem {
@@ -25,10 +24,8 @@ export class ShootingStarSystem {
25
24
  readonly #alphaRange: number;
26
25
  readonly #decayMin: number;
27
26
  readonly #decayRange: number;
28
- readonly #verticalFade: [number, number] | null;
29
27
  readonly #rng: () => number;
30
28
  #cooldown: number;
31
- #height: number = 0;
32
29
  #stars: ShootingStar[] = [];
33
30
 
34
31
  constructor(config: ShootingStarSystemConfig, rng: () => number) {
@@ -42,13 +39,11 @@ export class ShootingStarSystem {
42
39
  this.#alphaRange = config.alphaRange ?? 0.3;
43
40
  this.#decayMin = config.decayMin ?? 0.008;
44
41
  this.#decayRange = config.decayRange ?? 0.01;
45
- this.#verticalFade = config.verticalFade ?? null;
46
42
  this.#rng = rng;
47
43
  this.#cooldown = this.#interval[0] + this.#rng() * (this.#interval[1] - this.#interval[0]);
48
44
  }
49
45
 
50
46
  tick(dt: number, width: number, height: number): void {
51
- this.#height = height;
52
47
  this.#cooldown -= dt;
53
48
 
54
49
  if (this.#cooldown <= 0) {
@@ -61,10 +56,17 @@ export class ShootingStarSystem {
61
56
  for (let i = 0; i < this.#stars.length; i++) {
62
57
  const star = this.#stars[i];
63
58
 
64
- star.trail.push({x: star.x, y: star.y});
65
-
66
- if (star.trail.length > this.#trailLength) {
67
- star.trail.shift();
59
+ const trail = star.trail;
60
+ const maxLen = this.#trailLength;
61
+
62
+ if (trail.length < maxLen) {
63
+ trail.push({x: star.x, y: star.y});
64
+ star.trailHead = trail.length - 1;
65
+ } else {
66
+ const next = (star.trailHead + 1) % maxLen;
67
+ trail[next].x = star.x;
68
+ trail[next].y = star.y;
69
+ star.trailHead = next;
68
70
  }
69
71
 
70
72
  star.x += star.vx * this.#speed * dt;
@@ -72,9 +74,8 @@ export class ShootingStarSystem {
72
74
  star.alpha -= star.decay * dt;
73
75
 
74
76
  const inBounds = star.alpha > 0 && star.x > -50 && star.x < width + 50 && star.y < height + 50;
75
- const fullyFaded = this.#verticalFade !== null && star.y / height >= this.#verticalFade[1];
76
77
 
77
- if (inBounds && !fullyFaded) {
78
+ if (inBounds) {
78
79
  this.#stars[alive++] = star;
79
80
  }
80
81
  }
@@ -88,30 +89,30 @@ export class ShootingStarSystem {
88
89
  ctx.globalCompositeOperation = 'lighter';
89
90
 
90
91
  for (const star of this.#stars) {
91
- let fadeFactor = 1;
92
-
93
- if (this.#verticalFade && this.#height > 0) {
94
- const [fadeStart, fadeEnd] = this.#verticalFade;
95
- fadeFactor = 1 - Math.max(0, Math.min(1, (star.y / this.#height - fadeStart) / (fadeEnd - fadeStart)));
96
- }
97
-
98
- for (let t = 0; t < star.trail.length; t++) {
99
- const progress = t / star.trail.length;
100
- const trailAlpha = star.alpha * progress * this.#trailAlphaFactor * fadeFactor;
92
+ const trail = star.trail;
93
+ const trailLen = trail.length;
94
+ const isFull = trailLen === this.#trailLength;
95
+ const oldest = isFull ? (star.trailHead + 1) % trailLen : 0;
96
+
97
+ for (let t = 0; t < trailLen; t++) {
98
+ const progress = t / trailLen;
99
+ const trailAlpha = star.alpha * progress * this.#trailAlphaFactor;
101
100
  const trailSize = star.size * progress * this.#scale;
102
101
 
103
102
  if (trailAlpha < 0.01) {
104
103
  continue;
105
104
  }
106
105
 
106
+ const idx = (oldest + t) % trailLen;
107
+
107
108
  ctx.globalAlpha = trailAlpha;
108
109
  ctx.beginPath();
109
- ctx.arc(star.trail[t].x, star.trail[t].y, trailSize, 0, Math.PI * 2);
110
+ ctx.arc(trail[idx].x, trail[idx].y, trailSize, 0, Math.PI * 2);
110
111
  ctx.fillStyle = `rgb(${cr}, ${cg}, ${cb})`;
111
112
  ctx.fill();
112
113
  }
113
114
 
114
- const alpha = star.alpha * fadeFactor;
115
+ const alpha = star.alpha;
115
116
  const headSize = star.size * 2 * this.#scale;
116
117
  const glow = ctx.createRadialGradient(star.x, star.y, 0, star.x, star.y, headSize);
117
118
  glow.addColorStop(0, `rgba(${cr}, ${cg}, ${cb}, ${alpha})`);
@@ -143,7 +144,8 @@ export class ShootingStarSystem {
143
144
  alpha: this.#alphaMin + this.#rng() * this.#alphaRange,
144
145
  size: 1.5 + this.#rng() * 2,
145
146
  decay: this.#decayMin + this.#rng() * this.#decayRange,
146
- trail: []
147
+ trail: [],
148
+ trailHead: 0
147
149
  };
148
150
  }
149
151
  }
@@ -6,5 +6,6 @@ export type ShootingStar = {
6
6
  alpha: number;
7
7
  size: number;
8
8
  decay: number;
9
- trail: {x: number; y: number}[];
9
+ trail: { x: number; y: number }[];
10
+ trailHead: number;
10
11
  };