@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,231 @@
1
+ import { hexToRGB } from '@basmilius/utils';
2
+ import { Effect } from '../effect';
3
+ import { MULBERRY } from './consts';
4
+ import type { GlitchBlock, GlitchConfig, GlitchSlice } from './types';
5
+
6
+ const BURST_COLORS = [
7
+ '#ff0000',
8
+ '#00ff00',
9
+ '#0000ff',
10
+ '#ff00ff',
11
+ '#00ffff',
12
+ '#ffff00',
13
+ '#ffffff'
14
+ ];
15
+
16
+ export class Glitch extends Effect<GlitchConfig> {
17
+ readonly #scale: number;
18
+ #intensity: number;
19
+ #speed: number;
20
+ #rgbSplit: number;
21
+ readonly #scanlines: boolean;
22
+ readonly #noiseBlocks: boolean;
23
+ readonly #sliceDisplacement: boolean;
24
+ readonly #colorRGB: [number, number, number];
25
+
26
+ #slices: GlitchSlice[] = [];
27
+ #blocks: GlitchBlock[] = [];
28
+ #time: number = 0;
29
+ #burstTimer: number = 0;
30
+ #burstDuration: number = 0;
31
+ #isBursting: boolean = false;
32
+ #nextBurstIn: number = 0;
33
+ #width: number = 960;
34
+ #height: number = 540;
35
+
36
+ constructor(config: GlitchConfig = {}) {
37
+ super();
38
+
39
+ this.#scale = config.scale ?? 1;
40
+ this.#intensity = config.intensity ?? 0.5;
41
+ this.#speed = config.speed ?? 1;
42
+ this.#rgbSplit = (config.rgbSplit ?? 3) * this.#scale;
43
+ this.#scanlines = config.scanlines ?? true;
44
+ this.#noiseBlocks = config.noiseBlocks ?? true;
45
+ this.#sliceDisplacement = config.sliceDisplacement ?? true;
46
+ this.#colorRGB = hexToRGB(config.color ?? '#00ff41');
47
+ this.#nextBurstIn = 30 + MULBERRY.next() * 60;
48
+ }
49
+
50
+ configure(config: Partial<GlitchConfig>): void {
51
+ if (config.intensity !== undefined) {
52
+ this.#intensity = config.intensity;
53
+ }
54
+ if (config.speed !== undefined) {
55
+ this.#speed = config.speed;
56
+ }
57
+ if (config.rgbSplit !== undefined) {
58
+ this.#rgbSplit = config.rgbSplit * this.#scale;
59
+ }
60
+ }
61
+
62
+ onResize(width: number, height: number): void {
63
+ this.#width = width;
64
+ this.#height = height;
65
+ }
66
+
67
+ tick(dt: number, width: number, height: number): void {
68
+ this.#width = width;
69
+ this.#height = height;
70
+
71
+ const speedDt = dt * this.#speed;
72
+ this.#time += speedDt;
73
+
74
+ // Update existing slices.
75
+ let writeIndex = 0;
76
+ for (let index = 0; index < this.#slices.length; ++index) {
77
+ const slice = this.#slices[index];
78
+ slice.life -= speedDt;
79
+
80
+ if (slice.life > 0) {
81
+ this.#slices[writeIndex++] = slice;
82
+ }
83
+ }
84
+ this.#slices.length = writeIndex;
85
+
86
+ // Update existing blocks.
87
+ writeIndex = 0;
88
+ for (let index = 0; index < this.#blocks.length; ++index) {
89
+ const block = this.#blocks[index];
90
+ block.life -= speedDt;
91
+
92
+ if (block.life > 0) {
93
+ this.#blocks[writeIndex++] = block;
94
+ }
95
+ }
96
+ this.#blocks.length = writeIndex;
97
+
98
+ // Burst logic.
99
+ if (this.#isBursting) {
100
+ this.#burstTimer -= speedDt;
101
+
102
+ // Spawn slices during burst.
103
+ if (this.#sliceDisplacement && MULBERRY.next() < 0.4 * this.#intensity) {
104
+ const sliceCount = 1 + Math.floor(MULBERRY.next() * 3 * this.#intensity);
105
+
106
+ for (let index = 0; index < sliceCount; ++index) {
107
+ this.#slices.push(this.#createSlice(width, height));
108
+ }
109
+ }
110
+
111
+ // Spawn noise blocks during burst.
112
+ if (this.#noiseBlocks && MULBERRY.next() < 0.3 * this.#intensity) {
113
+ const blockCount = 1 + Math.floor(MULBERRY.next() * 4 * this.#intensity);
114
+
115
+ for (let index = 0; index < blockCount; ++index) {
116
+ this.#blocks.push(this.#createBlock(width, height));
117
+ }
118
+ }
119
+
120
+ if (this.#burstTimer <= 0) {
121
+ this.#isBursting = false;
122
+ this.#nextBurstIn = (20 + MULBERRY.next() * 80) * (1 - this.#intensity * 0.7);
123
+ }
124
+ } else {
125
+ this.#nextBurstIn -= speedDt;
126
+
127
+ if (this.#nextBurstIn <= 0) {
128
+ this.#isBursting = true;
129
+ this.#burstDuration = 5 + MULBERRY.next() * 15 * this.#intensity;
130
+ this.#burstTimer = this.#burstDuration;
131
+ }
132
+ }
133
+ }
134
+
135
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
136
+ const [cr, cg, cb] = this.#colorRGB;
137
+
138
+ // Scanlines (always subtle, more visible during burst).
139
+ if (this.#scanlines) {
140
+ const scanlineAlpha = this.#isBursting ? 0.08 + this.#intensity * 0.07 : 0.03;
141
+ ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${scanlineAlpha})`;
142
+
143
+ const spacing = 3 * this.#scale;
144
+
145
+ for (let scanY = 0; scanY < height; scanY += spacing) {
146
+ ctx.fillRect(0, scanY, width, 1);
147
+ }
148
+ }
149
+
150
+ // Slice displacement.
151
+ if (this.#sliceDisplacement) {
152
+ for (const slice of this.#slices) {
153
+ const lifeRatio = slice.life / slice.maxLife;
154
+ const alpha = lifeRatio * 0.3 * this.#intensity;
155
+
156
+ // Red channel shifted left.
157
+ ctx.fillStyle = `rgba(255, 0, 0, ${alpha * 0.6})`;
158
+ ctx.fillRect(slice.offset - this.#rgbSplit, slice.y, width, slice.height);
159
+
160
+ // Blue channel shifted right.
161
+ ctx.fillStyle = `rgba(0, 0, 255, ${alpha * 0.6})`;
162
+ ctx.fillRect(slice.offset + this.#rgbSplit, slice.y, width, slice.height);
163
+
164
+ // Main slice color.
165
+ ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${alpha})`;
166
+ ctx.fillRect(slice.offset, slice.y, width, slice.height);
167
+ }
168
+ }
169
+
170
+ // RGB split overlay during burst.
171
+ if (this.#isBursting && this.#rgbSplit > 0) {
172
+ const burstProgress = this.#burstTimer / this.#burstDuration;
173
+ const splitAlpha = burstProgress * 0.04 * this.#intensity;
174
+
175
+ ctx.fillStyle = `rgba(255, 0, 0, ${splitAlpha})`;
176
+ ctx.fillRect(-this.#rgbSplit, 0, width + this.#rgbSplit * 2, height);
177
+
178
+ ctx.fillStyle = `rgba(0, 0, 255, ${splitAlpha})`;
179
+ ctx.fillRect(this.#rgbSplit, 0, width + this.#rgbSplit * 2, height);
180
+ }
181
+
182
+ // Noise blocks.
183
+ if (this.#noiseBlocks) {
184
+ for (const block of this.#blocks) {
185
+ const lifeRatio = block.life / block.maxLife;
186
+ const alpha = lifeRatio * 0.5 * this.#intensity;
187
+
188
+ ctx.fillStyle = block.color;
189
+ ctx.globalAlpha = alpha;
190
+ ctx.fillRect(block.x, block.y, block.width, block.height);
191
+ }
192
+ ctx.globalAlpha = 1;
193
+ }
194
+
195
+ // Horizontal scan bar during burst.
196
+ if (this.#isBursting) {
197
+ const barY = (this.#time * 3 % (height + 20)) - 10;
198
+ const barHeight = 2 + MULBERRY.next() * 4 * this.#scale;
199
+ const barAlpha = 0.15 * this.#intensity;
200
+
201
+ ctx.fillStyle = `rgba(255, 255, 255, ${barAlpha})`;
202
+ ctx.fillRect(0, barY, width, barHeight);
203
+ }
204
+ }
205
+
206
+ #createSlice(width: number, height: number): GlitchSlice {
207
+ const maxLife = 2 + MULBERRY.next() * 6;
208
+
209
+ return {
210
+ y: MULBERRY.next() * height,
211
+ height: 1 + MULBERRY.next() * 30 * this.#scale,
212
+ offset: (MULBERRY.next() - 0.5) * width * 0.15 * this.#intensity,
213
+ life: maxLife,
214
+ maxLife
215
+ };
216
+ }
217
+
218
+ #createBlock(width: number, height: number): GlitchBlock {
219
+ const maxLife = 1 + MULBERRY.next() * 4;
220
+
221
+ return {
222
+ x: MULBERRY.next() * width,
223
+ y: MULBERRY.next() * height,
224
+ width: 5 + MULBERRY.next() * 60 * this.#scale,
225
+ height: 2 + MULBERRY.next() * 20 * this.#scale,
226
+ life: maxLife,
227
+ maxLife,
228
+ color: BURST_COLORS[Math.floor(MULBERRY.next() * BURST_COLORS.length)]
229
+ };
230
+ }
231
+ }
@@ -0,0 +1,28 @@
1
+ export interface GlitchConfig {
2
+ readonly intensity?: number;
3
+ readonly speed?: number;
4
+ readonly rgbSplit?: number;
5
+ readonly scanlines?: boolean;
6
+ readonly noiseBlocks?: boolean;
7
+ readonly sliceDisplacement?: boolean;
8
+ readonly color?: string;
9
+ readonly scale?: number;
10
+ }
11
+
12
+ export type GlitchSlice = {
13
+ y: number;
14
+ height: number;
15
+ offset: number;
16
+ life: number;
17
+ maxLife: number;
18
+ };
19
+
20
+ export type GlitchBlock = {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ life: number;
26
+ maxLife: number;
27
+ color: string;
28
+ };
@@ -0,0 +1,3 @@
1
+ import { type Mulberry32, mulberry32 } from '@basmilius/utils';
2
+
3
+ export const MULBERRY: Mulberry32 = mulberry32(13);
@@ -0,0 +1,9 @@
1
+ import { GradientFlow } from './layer';
2
+ import type { GradientFlowConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createGradientFlow(config?: GradientFlowConfig): Effect<GradientFlowConfig> {
6
+ return new GradientFlow(config);
7
+ }
8
+
9
+ export type { GradientFlowConfig };
@@ -0,0 +1,134 @@
1
+ import { hexToRGB } from '@basmilius/utils';
2
+ import { Effect } from '../effect';
3
+ import { MULBERRY } from './consts';
4
+ import type { GradientBlob } from './types';
5
+
6
+ export interface GradientFlowConfig {
7
+ readonly speed?: number;
8
+ readonly scale?: number;
9
+ readonly colors?: string[];
10
+ readonly blobs?: number;
11
+ readonly resolution?: number;
12
+ }
13
+
14
+ const DEFAULT_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#f9ca24', '#6c5ce7'];
15
+
16
+ export class GradientFlow extends Effect<GradientFlowConfig> {
17
+ readonly #scale: number;
18
+ #speed: number;
19
+ readonly #resolution: number;
20
+ #blobs: GradientBlob[] = [];
21
+ #offscreen: HTMLCanvasElement | null = null;
22
+ #offscreenCtx: CanvasRenderingContext2D | null = null;
23
+ #imageData: ImageData | null = null;
24
+
25
+ constructor(config: GradientFlowConfig = {}) {
26
+ super();
27
+
28
+ this.#scale = config.scale ?? 1;
29
+ this.#speed = config.speed ?? 0.5;
30
+ this.#resolution = config.resolution ?? 6;
31
+
32
+ const colors = config.colors ?? [...DEFAULT_COLORS];
33
+ const blobCount = config.blobs ?? 5;
34
+
35
+ for (let index = 0; index < blobCount; index++) {
36
+ const colorHex = colors[index % colors.length];
37
+ const [r, g, b] = hexToRGB(colorHex);
38
+
39
+ this.#blobs.push({
40
+ x: MULBERRY.next(),
41
+ y: MULBERRY.next(),
42
+ vx: (MULBERRY.next() - 0.5) * 0.01,
43
+ vy: (MULBERRY.next() - 0.5) * 0.01,
44
+ radius: (0.15 + MULBERRY.next() * 0.2) * this.#scale,
45
+ color: [r, g, b]
46
+ });
47
+ }
48
+ }
49
+
50
+ configure(config: Partial<GradientFlowConfig>): void {
51
+ if (config.speed !== undefined) {
52
+ this.#speed = config.speed;
53
+ }
54
+ }
55
+
56
+ tick(dt: number, _width: number, _height: number): void {
57
+ const speed = this.#speed;
58
+
59
+ for (const blob of this.#blobs) {
60
+ blob.x += blob.vx * dt * speed;
61
+ blob.y += blob.vy * dt * speed;
62
+
63
+ if (blob.x < 0.05) {
64
+ blob.vx = Math.abs(blob.vx);
65
+ } else if (blob.x > 0.95) {
66
+ blob.vx = -Math.abs(blob.vx);
67
+ }
68
+
69
+ if (blob.y < 0.05) {
70
+ blob.vy = Math.abs(blob.vy);
71
+ } else if (blob.y > 0.95) {
72
+ blob.vy = -Math.abs(blob.vy);
73
+ }
74
+ }
75
+ }
76
+
77
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
78
+ const resolution = this.#resolution;
79
+ const offWidth = Math.ceil(width / resolution);
80
+ const offHeight = Math.ceil(height / resolution);
81
+
82
+ if (!this.#offscreen || this.#offscreen.width !== offWidth || this.#offscreen.height !== offHeight) {
83
+ this.#offscreen = document.createElement('canvas');
84
+ this.#offscreen.width = offWidth;
85
+ this.#offscreen.height = offHeight;
86
+ this.#offscreenCtx = this.#offscreen.getContext('2d');
87
+ this.#imageData = this.#offscreenCtx!.createImageData(offWidth, offHeight);
88
+ }
89
+
90
+ const data = this.#imageData!.data;
91
+ const blobs = this.#blobs;
92
+ const blobCount = blobs.length;
93
+
94
+ for (let py = 0; py < offHeight; py++) {
95
+ const normY = py / offHeight;
96
+
97
+ for (let px = 0; px < offWidth; px++) {
98
+ const normX = px / offWidth;
99
+
100
+ let totalWeight = 0;
101
+ let red = 0;
102
+ let green = 0;
103
+ let blue = 0;
104
+
105
+ for (let bi = 0; bi < blobCount; bi++) {
106
+ const blob = blobs[bi];
107
+ const dx = normX - blob.x;
108
+ const dy = normY - blob.y;
109
+ const distSq = dx * dx + dy * dy;
110
+ const radiusSq = blob.radius * blob.radius;
111
+ const weight = radiusSq / (distSq + 0.001);
112
+
113
+ totalWeight += weight;
114
+ red += blob.color[0] * weight;
115
+ green += blob.color[1] * weight;
116
+ blue += blob.color[2] * weight;
117
+ }
118
+
119
+ const invWeight = 1 / totalWeight;
120
+
121
+ const offset = (py * offWidth + px) * 4;
122
+ data[offset] = Math.min(255, red * invWeight);
123
+ data[offset + 1] = Math.min(255, green * invWeight);
124
+ data[offset + 2] = Math.min(255, blue * invWeight);
125
+ data[offset + 3] = 255;
126
+ }
127
+ }
128
+
129
+ this.#offscreenCtx!.putImageData(this.#imageData!, 0, 0);
130
+
131
+ ctx.imageSmoothingEnabled = true;
132
+ ctx.drawImage(this.#offscreen!, 0, 0, width, height);
133
+ }
134
+ }
@@ -0,0 +1,8 @@
1
+ export type GradientBlob = {
2
+ x: number;
3
+ y: number;
4
+ vx: number;
5
+ vy: number;
6
+ radius: number;
7
+ color: [number, number, number];
8
+ };
@@ -0,0 +1,5 @@
1
+ import { type Mulberry32, mulberry32 } from '@basmilius/utils';
2
+
3
+ export const MULBERRY: Mulberry32 = mulberry32(13);
4
+
5
+ export const HEX_CHARS = '0123456789ABCDEF';
@@ -0,0 +1,9 @@
1
+ import { Hologram } from './layer';
2
+ import type { HologramConfig } from './types';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createHologram(config?: HologramConfig): Effect<HologramConfig> {
6
+ return new Hologram(config);
7
+ }
8
+
9
+ export type { HologramConfig, HologramFragment } from './types';
@@ -0,0 +1,205 @@
1
+ import { hexToRGB } from '@basmilius/utils';
2
+ import { Effect } from '../effect';
3
+ import { HEX_CHARS, MULBERRY } from './consts';
4
+ import type { HologramConfig, HologramFragment } from './types';
5
+
6
+ export class Hologram extends Effect<HologramConfig> {
7
+ readonly #scale: number;
8
+ #speed: number;
9
+ readonly #scanlineSpacing: number;
10
+ #flickerIntensity: number;
11
+ readonly #maxFragments: number;
12
+ readonly #colorRGB: [number, number, number];
13
+
14
+ #fragments: HologramFragment[] = [];
15
+ #time: number = 0;
16
+ #scanOffset: number = 0;
17
+ #scanBarY: number = -100;
18
+ #scanBarSpeed: number = 0;
19
+ #scanBarActive: boolean = false;
20
+ #flickerAlpha: number = 1;
21
+ #nextDropout: number = 0;
22
+ #dropoutTimer: number = 0;
23
+ #width: number = 960;
24
+ #height: number = 540;
25
+ #initialized: boolean = false;
26
+
27
+ constructor(config: HologramConfig = {}) {
28
+ super();
29
+
30
+ this.#scale = config.scale ?? 1;
31
+ this.#speed = config.speed ?? 1;
32
+ this.#scanlineSpacing = (config.scanlineSpacing ?? 3) * this.#scale;
33
+ this.#flickerIntensity = config.flickerIntensity ?? 0.3;
34
+ this.#maxFragments = config.dataFragments ?? 15;
35
+ this.#colorRGB = hexToRGB(config.color ?? '#00ccff');
36
+ this.#nextDropout = 40 + MULBERRY.next() * 80;
37
+ }
38
+
39
+ configure(config: Partial<HologramConfig>): void {
40
+ if (config.speed !== undefined) {
41
+ this.#speed = config.speed;
42
+ }
43
+ if (config.flickerIntensity !== undefined) {
44
+ this.#flickerIntensity = config.flickerIntensity;
45
+ }
46
+ }
47
+
48
+ onResize(width: number, height: number): void {
49
+ this.#width = width;
50
+ this.#height = height;
51
+
52
+ if (!this.#initialized) {
53
+ this.#initialized = true;
54
+
55
+ for (let index = 0; index < this.#maxFragments; ++index) {
56
+ this.#fragments.push(this.#createFragment(width, height));
57
+ }
58
+ }
59
+ }
60
+
61
+ tick(dt: number, width: number, height: number): void {
62
+ this.#width = width;
63
+ this.#height = height;
64
+
65
+ const speedDt = dt * this.#speed;
66
+ this.#time += speedDt * 0.01;
67
+
68
+ // Scrolling scanlines.
69
+ this.#scanOffset = (this.#scanOffset + speedDt * 0.05) % this.#scanlineSpacing;
70
+
71
+ // Flicker oscillation.
72
+ const baseFlicker = 1 - this.#flickerIntensity * 0.3;
73
+ const oscillation = Math.sin(this.#time * 12) * 0.05 + Math.sin(this.#time * 31) * 0.03;
74
+ this.#flickerAlpha = baseFlicker + oscillation * this.#flickerIntensity;
75
+
76
+ // Occasional dropout.
77
+ if (this.#dropoutTimer > 0) {
78
+ this.#dropoutTimer -= speedDt;
79
+ this.#flickerAlpha = 0.05 + MULBERRY.next() * 0.1;
80
+ } else {
81
+ this.#nextDropout -= speedDt;
82
+
83
+ if (this.#nextDropout <= 0) {
84
+ this.#dropoutTimer = 1 + MULBERRY.next() * 3;
85
+ this.#nextDropout = 40 + MULBERRY.next() * 120;
86
+ }
87
+ }
88
+
89
+ // Scan bar.
90
+ if (this.#scanBarActive) {
91
+ this.#scanBarY -= this.#scanBarSpeed * speedDt;
92
+
93
+ if (this.#scanBarY < -20) {
94
+ this.#scanBarActive = false;
95
+ }
96
+ } else if (MULBERRY.next() < 0.002 * this.#speed) {
97
+ this.#scanBarActive = true;
98
+ this.#scanBarY = height + 10;
99
+ this.#scanBarSpeed = 2 + MULBERRY.next() * 4;
100
+ }
101
+
102
+ // Update fragments.
103
+ for (let index = 0; index < this.#fragments.length; ++index) {
104
+ const fragment = this.#fragments[index];
105
+ fragment.life -= speedDt;
106
+ fragment.y -= fragment.speed * speedDt;
107
+
108
+ if (fragment.life <= 0 || fragment.y + fragment.height < 0) {
109
+ this.#fragments[index] = this.#createFragment(width, height);
110
+ }
111
+ }
112
+ }
113
+
114
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
115
+ const [cr, cg, cb] = this.#colorRGB;
116
+ const prevComposite = ctx.globalCompositeOperation;
117
+
118
+ ctx.globalAlpha = this.#flickerAlpha;
119
+ ctx.globalCompositeOperation = 'lighter';
120
+
121
+ // Scanlines.
122
+ ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, 0.04)`;
123
+
124
+ for (let scanY = -this.#scanOffset; scanY < height; scanY += this.#scanlineSpacing) {
125
+ ctx.fillRect(0, scanY, width, 1);
126
+ }
127
+
128
+ // Data fragments.
129
+ for (const fragment of this.#fragments) {
130
+ const lifeRatio = fragment.life / fragment.maxLife;
131
+ const fadeIn = Math.min(lifeRatio * 5, 1);
132
+ const fadeOut = Math.min((1 - lifeRatio) * 5, 1);
133
+ const alpha = fragment.opacity * Math.min(fadeIn, fadeOut);
134
+
135
+ // Fragment background.
136
+ ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${alpha * 0.15})`;
137
+ ctx.fillRect(fragment.x, fragment.y, fragment.width, fragment.height);
138
+
139
+ // Fragment border.
140
+ ctx.strokeStyle = `rgba(${cr}, ${cg}, ${cb}, ${alpha * 0.3})`;
141
+ ctx.lineWidth = 1;
142
+ ctx.strokeRect(fragment.x, fragment.y, fragment.width, fragment.height);
143
+
144
+ // Simulated text rows (small horizontal lines).
145
+ ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${alpha * 0.4})`;
146
+ const lineHeight = 4 * this.#scale;
147
+ const padding = 3 * this.#scale;
148
+ const textWidth = fragment.width - padding * 2;
149
+
150
+ if (textWidth > 0) {
151
+ for (let lineY = fragment.y + padding; lineY < fragment.y + fragment.height - padding; lineY += lineHeight + 2) {
152
+ const rowWidth = textWidth * (0.4 + MULBERRY.next() * 0.6);
153
+ ctx.fillRect(fragment.x + padding, lineY, rowWidth, lineHeight * 0.5);
154
+ }
155
+ }
156
+ }
157
+
158
+ // Scan bar.
159
+ if (this.#scanBarActive) {
160
+ const barHeight = 6 * this.#scale;
161
+ const gradient = ctx.createLinearGradient(0, this.#scanBarY - barHeight, 0, this.#scanBarY + barHeight);
162
+ gradient.addColorStop(0, `rgba(${cr}, ${cg}, ${cb}, 0)`);
163
+ gradient.addColorStop(0.4, `rgba(${cr}, ${cg}, ${cb}, 0.2)`);
164
+ gradient.addColorStop(0.5, `rgba(${cr}, ${cg}, ${cb}, 0.4)`);
165
+ gradient.addColorStop(0.6, `rgba(${cr}, ${cg}, ${cb}, 0.2)`);
166
+ gradient.addColorStop(1, `rgba(${cr}, ${cg}, ${cb}, 0)`);
167
+
168
+ ctx.fillStyle = gradient;
169
+ ctx.fillRect(0, this.#scanBarY - barHeight, width, barHeight * 2);
170
+ }
171
+
172
+ // Subtle overall vignette glow at edges.
173
+ ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, 0.02)`;
174
+ ctx.fillRect(0, 0, width, height);
175
+
176
+ ctx.globalAlpha = 1;
177
+ ctx.globalCompositeOperation = prevComposite;
178
+ }
179
+
180
+ #createFragment(width: number, height: number): HologramFragment {
181
+ const maxLife = 30 + MULBERRY.next() * 90;
182
+ const fragmentWidth = (20 + MULBERRY.next() * 80) * this.#scale;
183
+ const fragmentHeight = (12 + MULBERRY.next() * 40) * this.#scale;
184
+
185
+ // Generate random hex string.
186
+ let text = '';
187
+ const textLength = 4 + Math.floor(MULBERRY.next() * 8);
188
+
189
+ for (let index = 0; index < textLength; ++index) {
190
+ text += HEX_CHARS[Math.floor(MULBERRY.next() * HEX_CHARS.length)];
191
+ }
192
+
193
+ return {
194
+ x: MULBERRY.next() * (width - fragmentWidth),
195
+ y: MULBERRY.next() * height + height * 0.1,
196
+ width: fragmentWidth,
197
+ height: fragmentHeight,
198
+ opacity: 0.3 + MULBERRY.next() * 0.7,
199
+ speed: 0.1 + MULBERRY.next() * 0.5,
200
+ text,
201
+ life: maxLife,
202
+ maxLife
203
+ };
204
+ }
205
+ }
@@ -0,0 +1,20 @@
1
+ export interface HologramConfig {
2
+ readonly speed?: number;
3
+ readonly scanlineSpacing?: number;
4
+ readonly flickerIntensity?: number;
5
+ readonly dataFragments?: number;
6
+ readonly color?: string;
7
+ readonly scale?: number;
8
+ }
9
+
10
+ export type HologramFragment = {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ opacity: number;
16
+ speed: number;
17
+ text: string;
18
+ life: number;
19
+ maxLife: number;
20
+ };
@@ -0,0 +1,3 @@
1
+ import { type Mulberry32, mulberry32 } from '@basmilius/utils';
2
+
3
+ export const MULBERRY: Mulberry32 = mulberry32(31);
@@ -0,0 +1,10 @@
1
+ import { HyperSpace } from './layer';
2
+ import type { HyperSpaceConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createHyperSpace(config?: HyperSpaceConfig): Effect<HyperSpaceConfig> {
6
+ return new HyperSpace(config);
7
+ }
8
+
9
+ export type { HyperSpaceConfig };
10
+ export type { HyperSpaceStar } from './types';