@ciphore/radiocli 0.2.0 → 0.2.1

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 (57) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +107 -411
  3. package/SECURITY.md +6 -3
  4. package/dist/activity/stats.js +14 -2
  5. package/dist/cli.js +36 -2
  6. package/dist/player/airplay-discovery.js +16 -4
  7. package/dist/player/airplay-worker-protocol.js +1 -0
  8. package/dist/player/airplay-worker.js +4 -1
  9. package/dist/player/command.js +37 -14
  10. package/dist/player/mpv-ipc-client.js +194 -0
  11. package/dist/player/player-controller.js +169 -103
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +26 -6
  14. package/dist/providers/radio-browser.js +139 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +253 -152
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +251 -47
  20. package/dist/ui/AppContent.js +9 -10
  21. package/dist/ui/app-state.js +26 -16
  22. package/dist/ui/ascii.js +42 -1
  23. package/dist/ui/components/Logo.js +6 -1
  24. package/dist/ui/components/ScreenHeader.js +2 -2
  25. package/dist/ui/components/StationList.js +8 -6
  26. package/dist/ui/components/TopTabs.js +8 -7
  27. package/dist/ui/cosmo-land-data.js +1 -1
  28. package/dist/ui/exit-confirmation.js +7 -0
  29. package/dist/ui/explore-map-layout.js +3 -1
  30. package/dist/ui/format.js +56 -2
  31. package/dist/ui/help-content.js +9 -2
  32. package/dist/ui/layout.js +26 -9
  33. package/dist/ui/page-footer.js +36 -13
  34. package/dist/ui/playback-footer.js +12 -1
  35. package/dist/ui/screen-items.js +60 -22
  36. package/dist/ui/screen-meta.js +35 -0
  37. package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
  38. package/dist/ui/screens/CountriesScreen.js +8 -5
  39. package/dist/ui/screens/ExploreScreen.js +1 -1
  40. package/dist/ui/screens/HelpScreen.js +17 -3
  41. package/dist/ui/screens/HomeScreen.js +2 -3
  42. package/dist/ui/screens/MapScreen.js +2 -2
  43. package/dist/ui/screens/NowPlayingScreen.js +31 -51
  44. package/dist/ui/screens/SearchScreen.js +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +93 -21
  46. package/dist/ui/screens/StationScreen.js +14 -1
  47. package/dist/ui/screens/StatsScreen.js +35 -44
  48. package/dist/ui/system-actions.js +10 -3
  49. package/dist/ui/terminal-mouse.js +29 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +24 -7
  52. package/dist/ui/use-command-executor.js +28 -0
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  56. package/package.json +2 -2
  57. package/dist/ui/screens/screen-render.test.js +0 -235
@@ -1,162 +1,160 @@
1
1
  import { themeAccent, themeContributionColors } from '../theme.js';
2
- export function buildVisualizer(style, pulse, width, height, _station, playback, theme) {
3
- if (!playbackHasSignal(playback)) {
2
+ import { receiverStyleMetadata } from './receiver-style-registry.js';
3
+ const receiverStyleBuilders = {
4
+ ultracode: buildUltracode,
5
+ 'motion-contour': buildMotionContour,
6
+ leds: buildLeds,
7
+ matrix: buildMatrix,
8
+ hologram: buildHologram,
9
+ cube: buildAsciiCube,
10
+ fire: buildAsciiFire,
11
+ fireworks: buildAsciiFireworks,
12
+ 'spinning-donut': buildAsciiDonut,
13
+ starfield: buildAsciiStarfield,
14
+ mesh: buildMesh,
15
+ ribbon: buildRibbon,
16
+ mirror: buildMirror,
17
+ tunnel: buildTunnel,
18
+ kaleidoscope: buildKaleidoscope,
19
+ constellation: buildConstellation,
20
+ 'pulse-grid': buildPulseGrid,
21
+ lissajous: buildLissajous,
22
+ 'braille-wave': buildBrailleWave,
23
+ 'radial-eq': buildRadialEq,
24
+ spectrogram: buildSpectrogram,
25
+ nebula: buildNebula,
26
+ silk: buildSilk,
27
+ 'ripple-tank': buildRippleTank,
28
+ phyllotaxis: buildPhyllotaxis,
29
+ harmonograph: buildHarmonograph,
30
+ moire: buildMoire,
31
+ galaxy: buildGalaxy,
32
+ caustics: buildCaustics,
33
+ lorenz: buildLorenz,
34
+ fern: buildFern,
35
+ chladni: buildChladni,
36
+ tesseract: buildTesseract,
37
+ 'torus-knot': buildTorusKnot,
38
+ rotozoomer: buildRotozoomer,
39
+ 'fractal-tree': buildFractalTree,
40
+ julia: buildJulia,
41
+ goniometer: buildGoniometer,
42
+ 'copper-bars': buildCopperBars,
43
+ twister: buildTwister,
44
+ cyclone: buildCyclone,
45
+ 'lava-lamp': buildLavaLamp,
46
+ newton: buildNewton,
47
+ aurora: buildAurora,
48
+ 'xor-texture': buildXorTexture,
49
+ 'hex-pulse': buildHexPulse,
50
+ spirograph: buildSpirograph,
51
+ liftoff: buildLiftoff,
52
+ 'neural-net': buildNeuralNet,
53
+ flyover: buildFlyover,
54
+ skyline: buildSkyline,
55
+ 'golden-gate': buildGoldenGate,
56
+ manhattan: buildManhattan,
57
+ alpine: buildAlpine,
58
+ 'ordered-dither': buildOrderedDither,
59
+ 'pixel-crush': buildPixelCrush,
60
+ stormfront: buildStormfront,
61
+ cascade: buildCascade,
62
+ phosphene: buildPhosphene,
63
+ planetarium: buildPlanetarium,
64
+ 'sumi-ocean': buildSumiOcean
65
+ };
66
+ export function buildVisualizer(style, pulse, width, height, _station, playback, theme, viewport = 'standard') {
67
+ if (!playbackHasVisualSignal(style, playback)) {
4
68
  return buildZeroSignalVisualizer(width, height, theme);
5
69
  }
6
- if (style === 'ultracode') {
7
- return buildUltracode(pulse, width, height, theme);
70
+ if (viewport === 'micro') {
71
+ return buildMicroVisualizer(style, pulse, width, height, theme);
8
72
  }
9
- if (style === 'motion-blob') {
10
- return buildMotionBlob(pulse, width, height, theme);
11
- }
12
- if (style === 'motion-area') {
13
- return buildMotionArea(pulse, width, height, theme);
14
- }
15
- if (style === 'motion-contour') {
16
- return buildMotionContour(pulse, width, height, theme);
17
- }
18
- if (style === 'leds') {
19
- return buildLeds(pulse, width, height, theme);
20
- }
21
- if (style === 'matrix') {
22
- return buildMatrix(pulse, width, height, theme);
23
- }
24
- if (style === 'hologram') {
25
- return buildHologram(pulse, width, height, theme);
26
- }
27
- if (style === 'cube') {
28
- return buildAsciiCube(pulse, width, height, theme);
29
- }
30
- if (style === 'fire') {
31
- return buildAsciiFire(pulse, width, height, theme);
32
- }
33
- if (style === 'fireworks') {
34
- return buildAsciiFireworks(pulse, width, height, theme);
35
- }
36
- if (style === 'plasma') {
37
- return buildAsciiPlasma(pulse, width, height, theme);
38
- }
39
- if (style === 'spinning-donut') {
40
- return buildAsciiDonut(pulse, width, height, theme);
41
- }
42
- if (style === 'starfield') {
43
- return buildAsciiStarfield(pulse, width, height, theme);
44
- }
45
- if (style === 'mesh') {
46
- return buildMesh(pulse, width, height, theme);
47
- }
48
- if (style === 'ribbon') {
49
- return buildRibbon(pulse, width, height, theme);
50
- }
51
- if (style === 'orbits') {
52
- return buildOrbits(pulse, width, height, theme);
53
- }
54
- if (style === 'mirror') {
55
- return buildMirror(pulse, width, height, theme);
56
- }
57
- if (style === 'tunnel') {
58
- return buildTunnel(pulse, width, height, theme);
59
- }
60
- if (style === 'kaleidoscope') {
61
- return buildKaleidoscope(pulse, width, height, theme);
62
- }
63
- if (style === 'constellation') {
64
- return buildConstellation(pulse, width, height, theme);
65
- }
66
- if (style === 'pulse-grid') {
67
- return buildPulseGrid(pulse, width, height, theme);
68
- }
69
- if (style === 'lissajous') {
70
- return buildLissajous(pulse, width, height, theme);
71
- }
72
- if (style === 'braille-wave') {
73
- return buildBrailleWave(pulse, width, height, theme);
74
- }
75
- if (style === 'radial-eq') {
76
- return buildRadialEq(pulse, width, height, theme);
77
- }
78
- if (style === 'spectrogram') {
79
- return buildSpectrogram(pulse, width, height, theme);
80
- }
81
- if (style === 'nebula') {
82
- return buildNebula(pulse, width, height, theme);
83
- }
84
- if (style === 'silk') {
85
- return buildSilk(pulse, width, height, theme);
86
- }
87
- if (style === 'ripple-tank') {
88
- return buildRippleTank(pulse, width, height, theme);
89
- }
90
- if (style === 'phyllotaxis') {
91
- return buildPhyllotaxis(pulse, width, height, theme);
92
- }
93
- if (style === 'harmonograph') {
94
- return buildHarmonograph(pulse, width, height, theme);
95
- }
96
- if (style === 'bloom-bars') {
97
- return buildBloomBars(pulse, width, height, theme);
98
- }
99
- if (style === 'moire') {
100
- return buildMoire(pulse, width, height, theme);
101
- }
102
- if (style === 'galaxy') {
103
- return buildGalaxy(pulse, width, height, theme);
104
- }
105
- if (style === 'caustics') {
106
- return buildCaustics(pulse, width, height, theme);
107
- }
108
- if (style === 'lorenz') {
109
- return buildLorenz(pulse, width, height, theme);
110
- }
111
- if (style === 'fern') {
112
- return buildFern(pulse, width, height, theme);
113
- }
114
- if (style === 'chladni') {
115
- return buildChladni(pulse, width, height, theme);
116
- }
117
- if (style === 'tesseract') {
118
- return buildTesseract(pulse, width, height, theme);
119
- }
120
- if (style === 'torus-knot') {
121
- return buildTorusKnot(pulse, width, height, theme);
122
- }
123
- if (style === 'rotozoomer') {
124
- return buildRotozoomer(pulse, width, height, theme);
125
- }
126
- if (style === 'fractal-tree') {
127
- return buildFractalTree(pulse, width, height, theme);
128
- }
129
- if (style === 'julia') {
130
- return buildJulia(pulse, width, height, theme);
131
- }
132
- if (style === 'clifford') {
133
- return buildClifford(pulse, width, height, theme);
134
- }
135
- if (style === 'goniometer') {
136
- return buildGoniometer(pulse, width, height, theme);
137
- }
138
- if (style === 'copper-bars') {
139
- return buildCopperBars(pulse, width, height, theme);
140
- }
141
- if (style === 'twister') {
142
- return buildTwister(pulse, width, height, theme);
73
+ return receiverStyleBuilders[style](pulse, width, height, theme);
74
+ }
75
+ function buildMicroVisualizer(style, pulse, width, height, theme) {
76
+ const targetWidth = Math.max(1, width);
77
+ const targetHeight = Math.max(1, height);
78
+ if (microNativeScaleStyles.has(style)) {
79
+ return receiverStyleBuilders[style](pulse, targetWidth, targetHeight, theme);
143
80
  }
144
- if (style === 'coral') {
145
- return buildCoral(pulse, width, height, theme);
81
+ const sourceHeight = Math.max(targetHeight, receiverStyleMetadata[style].minRows);
82
+ const scale = sourceHeight / targetHeight;
83
+ const sourceWidth = Math.max(targetWidth, Math.min(96, Math.round(targetWidth * scale)));
84
+ const sourceRows = receiverStyleBuilders[style](pulse, sourceWidth, sourceHeight, theme);
85
+ return resampleVisualizer(sourceRows, sourceWidth, sourceHeight, targetWidth, targetHeight, themeAccent(theme));
86
+ }
87
+ // These procedural fields already derive every coordinate from the requested
88
+ // viewport. Rendering them natively preserves their fine geometry (especially
89
+ // the circular braille rings) while fixed-size and minimum-size scenes use the
90
+ // supersampled fit path below.
91
+ const microNativeScaleStyles = new Set([
92
+ 'pulse-grid',
93
+ 'hex-pulse',
94
+ 'galaxy',
95
+ 'cyclone',
96
+ 'fire',
97
+ 'stormfront'
98
+ ]);
99
+ function resampleVisualizer(rows, sourceWidth, sourceHeight, targetWidth, targetHeight, fallbackColor) {
100
+ const source = Array.from({ length: sourceHeight }, (_, rowIndex) => visualCellsForLine(rows[rowIndex], sourceWidth, fallbackColor));
101
+ return Array.from({ length: targetHeight }, (_, targetY) => {
102
+ const startY = Math.floor((targetY * sourceHeight) / targetHeight);
103
+ const endY = Math.max(startY + 1, Math.ceil(((targetY + 1) * sourceHeight) / targetHeight));
104
+ const cells = Array.from({ length: targetWidth }, (_, targetX) => {
105
+ const startX = Math.floor((targetX * sourceWidth) / targetWidth);
106
+ const endX = Math.max(startX + 1, Math.ceil(((targetX + 1) * sourceWidth) / targetWidth));
107
+ return strongestVisualCell(source, startX, endX, startY, endY, fallbackColor);
108
+ });
109
+ return lineFromCells(cells, fallbackColor);
110
+ });
111
+ }
112
+ function visualCellsForLine(line, width, fallbackColor) {
113
+ const cells = [];
114
+ if (line?.segments) {
115
+ for (const segment of line.segments) {
116
+ for (const text of Array.from(segment.text)) {
117
+ cells.push({ text, color: segment.color, backgroundColor: segment.backgroundColor, bold: segment.bold });
118
+ }
119
+ }
146
120
  }
147
- if (style === 'cyclone') {
148
- return buildCyclone(pulse, width, height, theme);
121
+ else if (line) {
122
+ for (const text of Array.from(line.text)) {
123
+ cells.push({ text, color: line.color });
124
+ }
149
125
  }
150
- if (style === 'lava-lamp') {
151
- return buildLavaLamp(pulse, width, height, theme);
126
+ while (cells.length < width) {
127
+ cells.push({ text: ' ', color: fallbackColor });
152
128
  }
153
- if (style === 'newton') {
154
- return buildNewton(pulse, width, height, theme);
129
+ return cells.slice(0, width);
130
+ }
131
+ function strongestVisualCell(source, startX, endX, startY, endY, fallbackColor) {
132
+ let selected = { text: ' ', color: fallbackColor };
133
+ let selectedScore = -1;
134
+ let selectedDistance = Number.POSITIVE_INFINITY;
135
+ const centerX = (startX + endX - 1) / 2;
136
+ const centerY = (startY + endY - 1) / 2;
137
+ for (let y = startY; y < endY; y += 1) {
138
+ for (let x = startX; x < endX; x += 1) {
139
+ const cell = source[y]?.[x];
140
+ if (!cell)
141
+ continue;
142
+ const score = cell.text.trim() ? (cell.bold ? 5 : 4) : cell.backgroundColor ? 2 : 0;
143
+ const distance = Math.abs(x - centerX) + Math.abs(y - centerY);
144
+ if (score > selectedScore || (score === selectedScore && distance < selectedDistance)) {
145
+ selected = cell;
146
+ selectedScore = score;
147
+ selectedDistance = distance;
148
+ }
149
+ }
155
150
  }
156
- return buildUltracode(pulse, width, height, theme);
151
+ return selected;
157
152
  }
158
- function playbackHasSignal(playback) {
159
- return playback.state === 'playing' && playback.ready;
153
+ function playbackHasVisualSignal(style, playback) {
154
+ if (!playback.ready) {
155
+ return false;
156
+ }
157
+ return playback.state === 'playing' || (playback.state === 'paused' && style !== 'ultracode');
160
158
  }
161
159
  function buildZeroSignalVisualizer(width, height, theme) {
162
160
  return buildFlatZeroSignal(width, height, theme);
@@ -246,75 +244,6 @@ function rgbToHex([red, green, blue]) {
246
244
  function clampColor(value) {
247
245
  return Math.max(0, Math.min(255, Math.round(value)));
248
246
  }
249
- function buildMotionBlob(pulse, width, height, theme) {
250
- const h = Math.max(7, height);
251
- const mid = (h - 1) / 2;
252
- const half = Math.max(1, h / 2 - 1);
253
- const accent = themeAccent(theme);
254
- return Array.from({ length: h }, (_, y) => {
255
- const normalizedY = Math.abs((y - mid) / half);
256
- const cells = Array.from({ length: width }, (_, x) => {
257
- const position = x / Math.max(1, width - 1);
258
- const fade = 1 - Math.pow(position, 1.35) * 0.56;
259
- const outer = clampNumber(motionEnvelope(position, pulse, 1.1) * fade, 0, 1);
260
- const inner = clampNumber(outer * (0.42 + 0.16 * Math.sin(position * 17 - pulse * 0.3)), 0, 1);
261
- if (normalizedY <= inner) {
262
- return { text: '█', color: position > 0.56 ? '#5ab7ff' : '#a36bff' };
263
- }
264
- if (normalizedY <= outer) {
265
- return { text: '▓', color: position > 0.68 ? '#4bc9d9' : '#ff3f8e' };
266
- }
267
- if (normalizedY <= outer + 0.08 && x % 2 === 0) {
268
- return { text: '░', color: dimMotionColorAt(position, theme) };
269
- }
270
- if (Math.round(y) === Math.round(mid) && x % 7 === 0) {
271
- return { text: '·', color: accent };
272
- }
273
- return { text: ' ', color: accent };
274
- });
275
- return lineFromCells(cells, accent);
276
- }).slice(0, height);
277
- }
278
- function buildMotionArea(pulse, width, height, theme) {
279
- const h = Math.max(8, height);
280
- const mid = Math.floor(h / 2);
281
- const accent = themeAccent(theme);
282
- const topLevels = Array.from({ length: width }, (_, x) => {
283
- const position = x / Math.max(1, width - 1);
284
- return clampNumber(0.2 + motionEnvelope(position, pulse, 2.0) * 0.74, 0.1, 1);
285
- });
286
- const bottomLevels = Array.from({ length: width }, (_, x) => {
287
- const position = x / Math.max(1, width - 1);
288
- return clampNumber(0.14 + motionEnvelope(1 - position, pulse + 37, 0.8) * 0.7, 0.08, 0.92);
289
- });
290
- return Array.from({ length: h }, (_, y) => {
291
- const cells = Array.from({ length: width }, (_, x) => {
292
- const position = x / Math.max(1, width - 1);
293
- const top = mid - Math.round((topLevels[x] ?? 0) * Math.max(1, mid - 1));
294
- const bottom = mid + Math.round((bottomLevels[x] ?? 0) * Math.max(1, h - mid - 2));
295
- if (y === mid) {
296
- return { text: x % 6 === 0 ? '━' : '─', color: accent };
297
- }
298
- if (y < mid && y >= top) {
299
- return {
300
- text: y === top ? '▄' : '█',
301
- color: position < 0.58 ? '#5fe6e0' : '#314c68'
302
- };
303
- }
304
- if (y > mid && y <= bottom) {
305
- return {
306
- text: y === bottom ? '▀' : '█',
307
- color: position < 0.55 ? '#ff3f8e' : '#a6346f'
308
- };
309
- }
310
- if ((y === top - 1 || y === bottom + 1) && x % 4 === 0) {
311
- return { text: '·', color: dimMotionColorAt(position, theme) };
312
- }
313
- return { text: ' ', color: accent };
314
- });
315
- return lineFromCells(cells, accent);
316
- }).slice(0, height);
317
- }
318
247
  function buildMotionContour(pulse, width, height, theme) {
319
248
  const h = Math.max(9, height);
320
249
  const cx = (width - 1) / 2;
@@ -365,16 +294,13 @@ function buildLeds(pulse, width, height, theme) {
365
294
  const emptyBar = '□'.repeat(emptyCount);
366
295
  const text = `${label} │ [${filledBar}${emptyBar}]`.padEnd(width).slice(0, width);
367
296
  const colors = themeContributionColors(theme);
368
- let color = colors[2] ?? accent;
369
- if (y === 0 || y === 1) {
370
- color = '#ff5f87';
371
- }
372
- else if (y < 4) {
373
- color = colors[4] ?? accent;
374
- }
375
- else {
376
- color = colors[3] ?? accent;
377
- }
297
+ // Low bands warmer/hotter within the theme ramp; highs use the bright accent end.
298
+ // Never hardcode a foreign hue every band follows the display theme.
299
+ const bandT = height <= 1 ? 1 : y / (height - 1);
300
+ const colorIndex = Math.min(colors.length - 1, Math.max(1, Math.round(1 + bandT * (colors.length - 2))));
301
+ // Peak levels brighten toward the top of the ramp
302
+ const peakBoost = lvl > 0.85 ? 1 : 0;
303
+ const color = colors[Math.min(colors.length - 1, colorIndex + peakBoost)] ?? accent;
378
304
  rows.push({ text, color });
379
305
  }
380
306
  return rows;
@@ -448,7 +374,7 @@ function buildHologram(pulse, width, height, theme) {
448
374
  const wave1 = Math.sin(r * 10 - pulse * 0.5);
449
375
  const wave2 = Math.cos(theta * 4 + pulse * 0.3);
450
376
  const interference = (wave1 + wave2) * 0.5;
451
- const glitch = Math.random() > 0.98 ? 1 : 0;
377
+ const glitch = hashNoise(x, y, Math.floor(pulse / 2)) > 0.98 ? 1 : 0;
452
378
  const scanline = Math.abs(y - ((pulse * 2) % height)) < 1 ? 1 : 0;
453
379
  const intensity = Math.max(0, interference) + scanline * 0.5 + glitch;
454
380
  if (intensity > 1.2)
@@ -479,7 +405,9 @@ function buildAsciiCube(pulse, width, height, theme) {
479
405
  const angleY = pulse * 0.13;
480
406
  const angleX = 0.62 + Math.sin(pulse * 0.045) * 0.28;
481
407
  const angleZ = Math.sin(pulse * 0.035) * 0.16;
482
- const scaleY = Math.max(2, Math.min((height - 2) / 3.0, width / 12));
408
+ // Pull the camera back another notch so the cube reads as an object in space
409
+ // instead of pressing against its viewport.
410
+ const scaleY = Math.max(1.45, Math.min((height - 2) / 3.0, width / 12) * 0.7);
483
411
  const scaleX = scaleY * 2.35;
484
412
  drawCubeSurfaces(grid, zBuffer, angleX, angleY, angleZ, scaleX, scaleY, width, height);
485
413
  const vertices = cubeVertices().map(point => projectRotatedCubePoint(rotateCubePoint(point, angleX, angleY, angleZ), scaleX, scaleY, width, height));
@@ -508,101 +436,249 @@ function buildAsciiCube(pulse, width, height, theme) {
508
436
  for (const point of vertices) {
509
437
  writeCubeSurfaceGlyph(grid, zBuffer, point.x, point.y, point.z + 0.14, cubeCornerGlyph(point.z));
510
438
  }
439
+ // Per-glyph theme shading — always tracks display theme (skip dark empty slot).
511
440
  const accent = themeAccent(theme);
512
- return grid.map((cells, rowIndex) => {
513
- const text = cells.join('').slice(0, width);
514
- const hasCube = /[.:;=+*#%@/\\|_\-]/.test(text);
515
- let color = '#ff5f87';
516
- if (!hasCube) {
517
- color = '#767676';
518
- }
519
- else if (rowIndex < height * 0.25) {
520
- color = '#ff9ab3';
521
- }
522
- else if (rowIndex > height * 0.76) {
523
- color = '#c06cff';
524
- }
525
- else if (theme === 'mono') {
526
- color = accent;
527
- }
528
- return { text, color };
441
+ const ramp = themeContributionColors(theme).filter((_, i) => i > 0);
442
+ const litRamp = ramp.length > 0 ? ramp : [accent];
443
+ const voidColor = theme === 'mono' ? '#1a1a1a' : '#0c1018';
444
+ const glyphOrder = ' .:-+=*#%@/\\|_;,!';
445
+ return Array.from({ length: height }, (_, y) => {
446
+ const cells = Array.from({ length: width }, (_, x) => {
447
+ const ch = grid[y][x] ?? ' ';
448
+ if (ch === ' ') {
449
+ return { text: ' ', color: voidColor };
450
+ }
451
+ // Brightness from glyph density + slight depth cue from row
452
+ const gi = Math.max(0, glyphOrder.indexOf(ch));
453
+ const density = gi <= 0 ? 0.35 : clampNumber(gi / Math.max(1, glyphOrder.length - 1), 0.15, 1);
454
+ const depthBoost = clampNumber(0.15 + (zBuffer[y][x] ?? 0) * 0.35, 0, 0.35);
455
+ const lit = clampNumber(density * 0.75 + depthBoost + 0.15, 0.12, 1);
456
+ const colorIndex = Math.min(litRamp.length - 1, Math.floor(lit * (litRamp.length - 0.01)));
457
+ // Edges/corners punch to pure accent
458
+ const isEdge = /[/\\|_\-=]/.test(ch);
459
+ const color = isEdge ? accent : litRamp[colorIndex] ?? accent;
460
+ return {
461
+ text: ch,
462
+ color: lit > 0.85 ? (theme === 'mono' ? '#ffffff' : accent) : color,
463
+ bold: lit > 0.8 || isEdge
464
+ };
465
+ });
466
+ return lineFromCells(cells, accent);
529
467
  });
530
468
  }
469
+ /** A compact fireplace: overlapping turbulent plumes rise from one shared ember bed. */
531
470
  function buildAsciiFire(pulse, width, height, theme) {
532
- const chars = [' ', '.', ':', '^', '~', '*', 'x', 'X', '#', '@'];
533
- const colors = ['#2d1218', '#743127', '#b9482d', '#f26f35', '#ffc857', '#fff1a8'];
534
- const rows = [];
471
+ const accent = themeAccent(theme);
472
+ const flameRamp = theme === 'mono'
473
+ ? ['#161616', '#343434', '#5a5a5a', '#858585', '#b0b0b0', '#d8d8d8', '#ffffff']
474
+ : ['#180300', '#3d0900', '#721500', '#ad2800', '#e84b08', '#ff7b16', '#ffb52e', '#ffe08a', '#fff7ce'];
475
+ const voidColor = theme === 'mono' ? '#080808' : '#080304';
476
+ const grid = emptyMotionGrid(width, height, voidColor);
477
+ const bass = receiverBandEnergy(pulse, 2);
478
+ const fireLeft = 0.13;
479
+ const fireRight = 0.87;
480
+ const fireSpan = fireRight - fireLeft;
481
+ // A few additional overlapping plumes keep the center lively without
482
+ // widening the compact hearth silhouette.
483
+ const plumeCount = Math.max(7, Math.min(11, Math.round(width / 13)));
484
+ const embers = Array.from({ length: Math.max(4, Math.round(width / 18)) }, (_, index) => {
485
+ const cycle = height + 5 + Math.floor(hashNoise(index, 18, 0) * height);
486
+ const travel = (pulse * (0.13 + hashNoise(index, 19, 0) * 0.1) + hashNoise(index, 20, 0) * cycle) % cycle;
487
+ return {
488
+ x: Math.round((fireLeft + hashNoise(index, 21, 0) * fireSpan) * Math.max(0, width - 1) + Math.sin(pulse * 0.04 + index) * 1.5),
489
+ y: Math.round(height - 3 - travel),
490
+ bright: hashNoise(index, 22, 0) > 0.56
491
+ };
492
+ });
535
493
  for (let y = 0; y < height; y += 1) {
536
- const positionY = y / Math.max(1, height - 1);
537
- const heatBase = Math.pow(positionY, 1.12);
538
- const cells = [];
494
+ const rise = (height - 1 - y) / Math.max(1, height - 1);
539
495
  for (let x = 0; x < width; x += 1) {
540
- const centerPull = 1 - Math.abs((x / Math.max(1, width - 1)) - 0.5) * 0.38;
541
- const upwardTime = pulse * 0.58 + (height - y) * 0.62;
542
- const lick = Math.sin(x * 0.18 + upwardTime - y * 0.32) * 0.2 +
543
- Math.sin(x * 0.07 - upwardTime * 0.7 + y * 0.46) * 0.15 +
544
- Math.cos(x * 0.31 + upwardTime * 0.42) * 0.08;
545
- const sparks = hashNoise(x, y, pulse * 2) > 0.968 && positionY < 0.46 ? 0.42 : 0;
546
- const heat = clampNumber(heatBase * centerPull + lick + sparks - (1 - positionY) * 0.28, 0, 1);
547
- const index = Math.min(chars.length - 1, Math.floor(heat * chars.length));
548
- const colorIndex = Math.min(colors.length - 1, Math.floor(heat * colors.length));
549
- cells.push({ text: chars[index] ?? ' ', color: colors[colorIndex] ?? themeAccent(theme) });
550
- }
551
- rows.push(lineFromCells(cells, themeAccent(theme)));
496
+ const nx = x / Math.max(1, width - 1);
497
+ let heat = 0;
498
+ for (let index = 0; index < plumeCount; index += 1) {
499
+ const phase = hashNoise(index, 3, 0) * Math.PI * 2;
500
+ const baseCenter = fireLeft + ((index + 0.5) / plumeCount) * fireSpan;
501
+ const flameHeight = 0.46 + hashNoise(index, 4, 0) * 0.39 + bass * 0.045 + Math.sin(pulse * 0.052 + phase) * 0.055;
502
+ if (rise > flameHeight) {
503
+ continue;
504
+ }
505
+ const vertical = rise / Math.max(0.01, flameHeight);
506
+ const sway = Math.sin(pulse * (0.038 + hashNoise(index, 5, 0) * 0.02) + phase + vertical * 3.4) *
507
+ (0.005 + vertical * 0.026) +
508
+ Math.sin(vertical * 8.5 - pulse * 0.09 + index) * vertical * 0.008;
509
+ const center = baseCenter + sway;
510
+ const baseHalfWidth = fireSpan * (0.095 + hashNoise(index, 6, 0) * 0.035);
511
+ const halfWidth = Math.max(0.002, baseHalfWidth * (0.18 + 0.82 * Math.pow(1 - vertical, 0.7)));
512
+ const lateral = Math.abs(nx - center) / halfWidth;
513
+ if (lateral >= 1.15) {
514
+ continue;
515
+ }
516
+ const body = Math.exp(-lateral * lateral * 1.7) * (1 - vertical * 0.3);
517
+ const upwardCurl = Math.sin(x * 0.31 + rise * 13 - pulse * 0.17 + phase) * 0.1 +
518
+ Math.sin(x * 0.67 - rise * 19 - pulse * 0.11) * 0.055;
519
+ const breakup = hashNoise(Math.floor(x / 2), Math.floor(y / 2), index) * 0.07;
520
+ const contribution = Math.max(0, body + upwardCurl + breakup - vertical * 0.1);
521
+ heat += contribution * 0.68;
522
+ }
523
+ const sideEnvelope = Math.sin(clampNumber((nx - fireLeft) / fireSpan, 0, 1) * Math.PI);
524
+ heat = clampNumber(heat * Math.pow(Math.max(0, sideEnvelope), 0.28), 0, 1.2);
525
+ if (heat > 0.24) {
526
+ const verticalGlow = 0.56 + (1 - rise) * 0.5;
527
+ const value = clampNumber((heat - 0.16) * verticalGlow, 0, 1);
528
+ const level = Math.min(flameRamp.length - 1, Math.floor(value * (flameRamp.length - 0.01)));
529
+ const edgePhase = Math.sin(x * 0.5 + y * 1.4 - pulse * 0.13);
530
+ const glyph = value > 0.78 ? '█' : value > 0.58 ? '▓' : value > 0.38 ? '▒' : edgePhase > 0 ? '▄' : '░';
531
+ grid[y][x] = {
532
+ text: glyph,
533
+ color: flameRamp[level] ?? accent,
534
+ bold: value > 0.88
535
+ };
536
+ }
537
+ }
552
538
  }
553
- return rows;
539
+ // The fire occupies one hearth instead of spreading edge-to-edge across the receiver.
540
+ const hearthStart = Math.round(width * fireLeft);
541
+ const hearthEnd = Math.round(width * fireRight);
542
+ for (let x = hearthStart; x <= hearthEnd; x += 1) {
543
+ if (height >= 2) {
544
+ const logGlow = 0.3 + 0.7 * Math.abs(Math.sin(x * 0.17 + pulse * 0.018));
545
+ const colorIndex = Math.min(flameRamp.length - 1, 2 + Math.floor(logGlow * 3));
546
+ paintCell(grid, x, height - 1, x % 11 < 8 ? '▄' : '▓', flameRamp[colorIndex] ?? accent);
547
+ }
548
+ if (height >= 3 && (x + Math.floor(width * 0.18)) % 13 < 9) {
549
+ paintCell(grid, x, height - 2, x % 5 === 0 ? '█' : '▀', flameRamp[x % 5 === 0 ? 5 : 3] ?? accent);
550
+ }
551
+ }
552
+ for (const ember of embers) {
553
+ if (ember.y > 0 && ember.y < height - 2 && ember.x >= 0 && ember.x < width) {
554
+ paintCell(grid, ember.x, ember.y, ember.bright ? '•' : '·', flameRamp[ember.bright ? 6 : 4] ?? accent);
555
+ }
556
+ }
557
+ return grid.map(row => lineFromCells(row, accent));
554
558
  }
555
559
  function buildAsciiFireworks(pulse, width, height, theme) {
556
- const grid = emptyMotionGrid(width, height, '#767676');
557
- const palette = ['#ff5f87', '#ffd166', '#53a8ff', '#8df084', '#c06cff', '#ffffff'];
558
- const bursts = Math.max(3, Math.min(7, Math.floor(width / 26)));
559
- for (let burst = 0; burst < bursts; burst += 1) {
560
- const seed = burst * 97 + 19;
561
- const cycle = (pulse * 1.55 + burst * 17) % 44;
562
- const radius = cycle * 0.22;
563
- const centerX = Math.round(width * (0.18 + ((seed * 37) % 64) / 100));
564
- const centerY = Math.round(height * (0.18 + ((seed * 23) % 42) / 100));
565
- const color = palette[burst % palette.length] ?? themeAccent(theme);
566
- const rays = 14 + (burst % 5) * 4;
567
- if (cycle < 6) {
568
- const launchY = height - 1 - Math.round(cycle * 1.25);
569
- setMotionCell(grid, centerX, launchY, '|', color);
570
- setMotionCell(grid, centerX, launchY - 1, '.', color);
560
+ const accent = themeAccent(theme);
561
+ const grid = emptyMotionGrid(width, height, '#06060e');
562
+ const palette = theme === 'mono'
563
+ ? ['#888888', '#b0b0b0', '#d8d8d8', '#ffffff']
564
+ : ['#ff5f87', '#ffd166', '#53a8ff', '#8df084', '#c06cff', '#ff9345', '#5eead4', '#ffffff'];
565
+ // Dim star dust so the sky isn't empty between bursts
566
+ for (let s = 0; s < Math.floor((width * height) / 28); s += 1) {
567
+ const sx = Math.floor(hashNoise(s, 1, 0) * width);
568
+ const sy = Math.floor(hashNoise(s, 2, 0) * height);
569
+ if (hashNoise(s, 3, Math.floor(pulse / 20)) > 0.55) {
570
+ paintCell(grid, sx, sy, '·', '#2a2a40');
571
+ }
572
+ }
573
+ const showCount = Math.max(5, Math.min(10, Math.floor(width / 14)));
574
+ // Slower overall tempo launches and blooms linger longer
575
+ const tempo = 0.78;
576
+ // Staggered generations so bursts appear at different places over time
577
+ for (let show = 0; show < showCount; show += 1) {
578
+ const period = 52 + (show % 5) * 9;
579
+ const phaseOffset = show * 14.5 + hashNoise(show, 7, 0) * 22;
580
+ const t = ((pulse * tempo + phaseOffset) % period + period) % period;
581
+ // Re-roll burst origin each generation so they don't always fire in the same spot
582
+ const gen = Math.floor((pulse * tempo + phaseOffset) / period);
583
+ const originX = Math.floor(width * (0.1 + hashNoise(show, gen, 11) * 0.8));
584
+ const peakY = Math.floor(height * (0.12 + hashNoise(show, gen, 22) * 0.42));
585
+ const color = palette[Math.floor(hashNoise(show, gen, 33) * palette.length) % palette.length] ?? accent;
586
+ const secondary = palette[(show + gen + 2) % palette.length] ?? color;
587
+ // Launch trail climbing from ground
588
+ const launchLen = 14;
589
+ if (t < launchLen) {
590
+ const progress = t / launchLen;
591
+ const y = Math.round(height - 1 - progress * (height - 1 - peakY));
592
+ const trailLen = 2 + Math.floor(progress * 4);
593
+ for (let k = 0; k < trailLen; k += 1) {
594
+ const fade = 1 - k / trailLen;
595
+ const glyph = k === 0 ? (progress > 0.85 ? '●' : '│') : k < 2 ? '│' : '┊';
596
+ const c = fade > 0.7 ? '#ffffff' : fade > 0.4 ? color : '#555566';
597
+ paintCell(grid, originX, y + k, glyph, c);
598
+ if (k === 0 && progress > 0.5) {
599
+ paintCell(grid, originX - 1, y, '·', color);
600
+ paintCell(grid, originX + 1, y, '·', color);
601
+ }
602
+ }
571
603
  continue;
572
604
  }
573
- for (let ray = 0; ray < rays; ray += 1) {
574
- const angle = (ray / rays) * Math.PI * 2 + burst * 0.33;
575
- const x = Math.round(centerX + Math.cos(angle) * radius * (1.7 + (ray % 3) * 0.34));
576
- const y = Math.round(centerY + Math.sin(angle) * radius * 0.72);
577
- const tailX = Math.round(centerX + Math.cos(angle) * Math.max(0, radius - 1.2) * 1.5);
578
- const tailY = Math.round(centerY + Math.sin(angle) * Math.max(0, radius - 1.2) * 0.66);
579
- const glyph = cycle < 16 ? ['*', '+', 'x', 'o'][ray % 4] : ['.', ':', "'", '`'][ray % 4];
580
- setMotionCell(grid, tailX, tailY, '.', '#767676');
581
- setMotionCell(grid, x, y, glyph, color);
605
+ // Explosion life: expand, glitter, then fade with gravity droop
606
+ const age = t - launchLen;
607
+ const life = 30 + (show % 4) * 4;
608
+ if (age > life) {
609
+ continue;
582
610
  }
583
- }
584
- return grid.map(row => lineFromCells(row, themeAccent(theme)));
585
- }
586
- function buildAsciiPlasma(pulse, width, height, theme) {
587
- const chars = [' ', '.', ':', ';', '-', '=', '+', '*', '#', '%', '@'];
588
- const palette = ['#53a8ff', '#6ee7f2', '#8df084', '#ffd166', '#ff5f87', '#c06cff'];
589
- const rows = [];
590
- for (let y = 0; y < height; y += 1) {
591
- const cells = [];
592
- for (let x = 0; x < width; x += 1) {
593
- const nx = x / Math.max(1, width - 1);
594
- const ny = y / Math.max(1, height - 1);
595
- const value = Math.sin(nx * 14 + pulse * 0.16) +
596
- Math.sin((nx * 7 + ny * 9) + pulse * 0.11) +
597
- Math.cos(Math.sqrt(Math.pow(nx - 0.5, 2) + Math.pow(ny - 0.5, 2)) * 18 - pulse * 0.22);
598
- const normalized = clampNumber((value + 3) / 6, 0, 1);
599
- const char = chars[Math.min(chars.length - 1, Math.floor(normalized * chars.length))] ?? ' ';
600
- const color = palette[Math.min(palette.length - 1, Math.floor(((normalized + pulse * 0.01) % 1) * palette.length))] ?? themeAccent(theme);
601
- cells.push({ text: char, color });
611
+ const lifeT = age / life;
612
+ // Expand then slow drift
613
+ const expand = 1 - Math.pow(1 - Math.min(1, lifeT * 1.2), 2.2);
614
+ const maxR = (2.2 + hashNoise(show, gen, 44) * 3.5 + (show % 3)) * Math.min(width, height * 2.2) * 0.08;
615
+ const radius = expand * maxR;
616
+ const gravity = lifeT * lifeT * height * 0.22;
617
+ const fade = clampNumber(1 - lifeT * 1.05, 0, 1);
618
+ if (fade < 0.04) {
619
+ continue;
620
+ }
621
+ const rays = 18 + (show % 6) * 4 + Math.floor(hashNoise(show, gen, 55) * 8);
622
+ const rings = lifeT < 0.25 ? 1 : lifeT < 0.55 ? 2 : 3;
623
+ for (let ring = 0; ring < rings; ring += 1) {
624
+ const ringScale = 1 - ring * 0.28;
625
+ const ringR = radius * ringScale;
626
+ for (let ray = 0; ray < rays; ray += 1) {
627
+ const angle = (ray / rays) * Math.PI * 2 +
628
+ show * 0.4 +
629
+ gen * 0.15 +
630
+ Math.sin(ray + gen) * 0.08;
631
+ // Slight ellipse + asymmetric petal lengths
632
+ const petal = 0.75 + 0.45 * hashNoise(ray, show, gen);
633
+ const px = originX + Math.cos(angle) * ringR * petal * 1.85;
634
+ const py = peakY + Math.sin(angle) * ringR * petal * 0.72 + gravity * (0.6 + ring * 0.2);
635
+ // Sparkle trail behind each particle
636
+ const trailSteps = lifeT < 0.5 ? 3 : 2;
637
+ for (let s = 0; s < trailSteps; s += 1) {
638
+ const back = s * 0.35;
639
+ const tx = px - Math.cos(angle) * back * petal;
640
+ const ty = py - Math.sin(angle) * back * petal * 0.5 + s * 0.15;
641
+ const sparkFade = fade * (1 - s / trailSteps) * (1 - ring * 0.15);
642
+ if (sparkFade < 0.08) {
643
+ continue;
644
+ }
645
+ let glyph;
646
+ if (lifeT < 0.2 && s === 0) {
647
+ glyph = ['✦', '★', '*', '✸'][ray % 4];
648
+ }
649
+ else if (lifeT < 0.45) {
650
+ glyph = s === 0 ? ['*', '+', 'x', '●'][ray % 4] : '·';
651
+ }
652
+ else if (lifeT < 0.7) {
653
+ glyph = s === 0 ? [':', '+', '.', '°'][ray % 4] : '·';
654
+ }
655
+ else {
656
+ glyph = sparkFade > 0.35 ? '·' : '˙';
657
+ }
658
+ const c = sparkFade > 0.75
659
+ ? '#ffffff'
660
+ : sparkFade > 0.4
661
+ ? ring === 0
662
+ ? color
663
+ : secondary
664
+ : '#666680';
665
+ paintCell(grid, Math.round(tx), Math.round(ty), glyph, c);
666
+ }
667
+ }
668
+ }
669
+ // Bright flash core at detonation
670
+ if (lifeT < 0.12) {
671
+ const core = ['✸', '✦', '●'];
672
+ for (let dy = -1; dy <= 1; dy += 1) {
673
+ for (let dx = -1; dx <= 1; dx += 1) {
674
+ if (dx * dx + dy * dy <= 2) {
675
+ paintCell(grid, originX + dx, peakY + dy, core[(dx + dy + 2) % 3], '#ffffff');
676
+ }
677
+ }
678
+ }
602
679
  }
603
- rows.push(lineFromCells(cells, themeAccent(theme)));
604
680
  }
605
- return rows;
681
+ return grid.map(row => lineFromCells(row, accent));
606
682
  }
607
683
  function buildAsciiDonut(pulse, width, height, theme) {
608
684
  const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '));
@@ -612,9 +688,10 @@ function buildAsciiDonut(pulse, width, height, theme) {
612
688
  const angleB = pulse * 0.043;
613
689
  const cx = width / 2;
614
690
  const cy = height / 2;
615
- const scale = Math.min(width * 0.22, height * 1.15);
616
- for (let theta = 0; theta < Math.PI * 2; theta += 0.11) {
617
- for (let phi = 0; phi < Math.PI * 2; phi += 0.18) {
691
+ // Slightly zoomed out so the full torus stays in frame with margin
692
+ const scale = Math.min(width * 0.165, height * 0.88);
693
+ for (let theta = 0; theta < Math.PI * 2; theta += 0.1) {
694
+ for (let phi = 0; phi < Math.PI * 2; phi += 0.16) {
618
695
  const circleX = 2 + Math.cos(theta);
619
696
  const circleY = Math.sin(theta);
620
697
  const x = circleX * (Math.cos(angleB) * Math.cos(phi) + Math.sin(angleA) * Math.sin(angleB) * Math.sin(phi)) -
@@ -623,8 +700,8 @@ function buildAsciiDonut(pulse, width, height, theme) {
623
700
  circleY * Math.cos(angleA) * Math.cos(angleB);
624
701
  const z = Math.cos(angleA) * circleX * Math.sin(phi) + circleY * Math.sin(angleA) + 5;
625
702
  const inverseZ = 1 / z;
626
- const screenX = Math.round(cx + scale * inverseZ * x * 2.1);
627
- const screenY = Math.round(cy + scale * inverseZ * y * 0.86);
703
+ const screenX = Math.round(cx + scale * inverseZ * x * 1.85);
704
+ const screenY = Math.round(cy + scale * inverseZ * y * 0.78);
628
705
  const luminance = Math.cos(phi) * Math.cos(theta) * Math.sin(angleB) -
629
706
  Math.cos(angleA) * Math.cos(theta) * Math.sin(phi) -
630
707
  Math.sin(angleA) * Math.sin(theta) +
@@ -637,31 +714,103 @@ function buildAsciiDonut(pulse, width, height, theme) {
637
714
  zBuffer[screenY][screenX] = inverseZ;
638
715
  }
639
716
  }
640
- const colors = themeContributionColors(theme);
641
- return grid.map((cells, rowIndex) => ({
717
+ // Single display-tone coloring — shading comes from the glyphs only, no white dual-tone.
718
+ const accent = themeAccent(theme);
719
+ return grid.map(cells => ({
642
720
  text: cells.join('').slice(0, width),
643
- color: colors[Math.min(colors.length - 1, Math.floor((rowIndex / Math.max(1, height - 1)) * colors.length))] ?? themeAccent(theme)
721
+ color: accent
644
722
  }));
645
723
  }
646
724
  function buildAsciiStarfield(pulse, width, height, theme) {
647
- const grid = emptyMotionGrid(width, height, '#767676');
648
- const count = Math.max(45, Math.min(180, Math.floor((width * height) / 10)));
649
- const cx = width / 2;
650
- const cy = height / 2;
651
- for (let star = 0; star < count; star += 1) {
652
- const seed = star * 9283 + 17;
653
- const angle = (hashNoise(seed, 0, 0) * Math.PI * 2);
654
- const lane = 0.08 + hashNoise(seed, 9, 0) * 0.92;
655
- const speed = 0.2 + hashNoise(seed, 17, 0) * 0.9;
656
- const depth = ((pulse * speed + seed) % 90) / 90;
657
- const radius = Math.pow(depth, 1.75) * Math.max(width * 0.56, height * 2.6) * lane;
658
- const x = Math.round(cx + Math.cos(angle) * radius);
659
- const y = Math.round(cy + Math.sin(angle) * radius * 0.34);
660
- const glyph = depth > 0.82 ? '@' : depth > 0.64 ? '*' : depth > 0.42 ? '+' : '.';
661
- const color = depth > 0.75 ? '#ffffff' : depth > 0.48 ? themeAccent(theme) : '#767676';
662
- setMotionCell(grid, x, y, glyph, color);
663
- }
664
- return grid.map(row => lineFromCells(row, themeAccent(theme)));
725
+ const accent = themeAccent(theme);
726
+ const ramp = themeContributionColors(theme);
727
+ const cx = (width - 1) / 2;
728
+ const cy = (height - 1) / 2;
729
+ // Dense multi-layer warp field: dust nebula + mid stars + near streaks + flares
730
+ return Array.from({ length: height }, (_, y) => {
731
+ const cells = Array.from({ length: width }, (_, x) => {
732
+ const dx = x - cx;
733
+ const dy = (y - cy) * 2.05;
734
+ const angle = Math.atan2(dy, dx);
735
+ const dist = Math.sqrt(dx * dx + dy * dy) + 0.001;
736
+ const maxDist = Math.sqrt(cx * cx + (cy * 2.05) ** 2);
737
+ // Soft nebula dust so the void isn't empty
738
+ const nebula = 0.22 * Math.sin(angle * 3 + pulse * 0.03) +
739
+ 0.18 * Math.sin(dist * 0.12 - pulse * 0.04 + angle * 2) +
740
+ 0.12 * Math.cos(x * 0.08 + y * 0.15 + pulse * 0.02);
741
+ const dust = clampNumber(0.18 + nebula * 0.5, 0, 0.55);
742
+ // Multiple star layers with different speeds (true parallax warp)
743
+ let best = dust * 0.35;
744
+ let bestGlyph = dust > 0.28 ? '·' : ' ';
745
+ let bestColor = ramp[1] ?? '#2a2a40';
746
+ let bold = false;
747
+ for (let layer = 0; layer < 4; layer += 1) {
748
+ const layerSpeed = 0.35 + layer * 0.55;
749
+ // Quantize angle into star "lanes" so stars feel discrete
750
+ const laneCount = 48 + layer * 36;
751
+ const lane = Math.floor(((angle + Math.PI) / (Math.PI * 2)) * laneCount);
752
+ const laneAngle = (lane / laneCount) * Math.PI * 2 - Math.PI;
753
+ const angDiff = Math.abs(Math.atan2(Math.sin(angle - laneAngle), Math.cos(angle - laneAngle)));
754
+ if (angDiff > 0.04 + layer * 0.01) {
755
+ continue;
756
+ }
757
+ const seed = lane * 17 + layer * 91;
758
+ const phase = hashNoise(seed, layer, 0);
759
+ const depth = ((pulse * layerSpeed * 0.45 + phase * 40 + seed) % 100) / 100;
760
+ const starDist = Math.pow(depth, 1.55) * maxDist * (0.15 + phase * 0.85);
761
+ const along = Math.abs(dist - starDist);
762
+ // Near stars elongate into streaks
763
+ const streak = layer >= 2 ? 0.8 + depth * 4.5 : 0.35 + depth * 1.2;
764
+ const hit = along < streak;
765
+ if (!hit) {
766
+ continue;
767
+ }
768
+ const intensity = clampNumber((1 - along / streak) * (0.35 + depth * 0.75) * (0.5 + layer * 0.2), 0, 1);
769
+ if (intensity <= best) {
770
+ continue;
771
+ }
772
+ best = intensity;
773
+ if (layer >= 3 && depth > 0.7) {
774
+ bestGlyph = intensity > 0.85 ? '✦' : intensity > 0.65 ? '*' : '+';
775
+ bestColor = '#ffffff';
776
+ bold = true;
777
+ }
778
+ else if (layer >= 2) {
779
+ bestGlyph = depth > 0.6 ? (along < 0.5 ? '━' : '*') : depth > 0.35 ? '+' : '·';
780
+ bestColor = depth > 0.55 ? '#ffffff' : accent;
781
+ }
782
+ else if (layer === 1) {
783
+ bestGlyph = depth > 0.55 ? '*' : depth > 0.3 ? '+' : '·';
784
+ bestColor = ramp[Math.min(ramp.length - 1, 2 + Math.floor(depth * 3))] ?? accent;
785
+ }
786
+ else {
787
+ bestGlyph = depth > 0.5 ? '·' : '˙';
788
+ bestColor = ramp[1] ?? '#555566';
789
+ }
790
+ }
791
+ // Occasional cross-flare on the brightest stars
792
+ if (best > 0.88) {
793
+ // already bright center
794
+ }
795
+ if (best < 0.08) {
796
+ return { text: ' ', color: accent };
797
+ }
798
+ // Fill nebula with faint background tint
799
+ const bg = dust > 0.32 && best < 0.5
800
+ ? theme === 'mono'
801
+ ? '#121218'
802
+ : ramp[0] ?? '#0a0a14'
803
+ : undefined;
804
+ return {
805
+ text: bestGlyph,
806
+ color: bestColor,
807
+ backgroundColor: bg,
808
+ bold
809
+ };
810
+ });
811
+ // Add a few hard cross-flares as post pass via bright neighbors — done per-cell above
812
+ return lineFromCells(cells, accent);
813
+ });
665
814
  }
666
815
  function buildMesh(pulse, width, height, theme) {
667
816
  const grid = emptyMotionGrid(width, height, '#0a0e16');
@@ -712,31 +861,6 @@ function buildRibbon(pulse, width, height, theme) {
712
861
  }
713
862
  return grid.map(row => lineFromCells(row, accent));
714
863
  }
715
- function buildOrbits(pulse, width, height, theme) {
716
- const grid = emptyMotionGrid(width, height, '#080b12');
717
- const accent = themeAccent(theme);
718
- const cx = (width - 1) / 2;
719
- const cy = (height - 1) / 2;
720
- const rx = width * 0.42;
721
- const ry = height * 0.42;
722
- const palette = ['#6ee7f2', '#8df084', '#ffd166', '#ff5f87', '#c06cff'];
723
- const particles = 5;
724
- for (let particle = 0; particle < particles; particle += 1) {
725
- const a = 2 + particle * 0.5;
726
- const b = 3 + particle * 0.4;
727
- const phase = particle * 1.3;
728
- const headColor = theme === 'mono' ? accent : palette[particle % palette.length] ?? accent;
729
- for (let trail = 11; trail >= 0; trail -= 1) {
730
- const t = pulse * 0.08 - trail * 0.085;
731
- const x = cx + Math.sin(a * t + phase) * rx;
732
- const y = cy + Math.sin(b * t) * ry;
733
- const glyph = trail === 0 ? '◉' : trail < 3 ? '●' : trail < 6 ? '•' : '·';
734
- const color = trail < 3 ? headColor : dimMotionColorAt(particle / particles, theme);
735
- paintCell(grid, x, y, glyph, color);
736
- }
737
- }
738
- return grid.map(row => lineFromCells(row, accent));
739
- }
740
864
  function buildMirror(pulse, width, height, theme) {
741
865
  const grid = emptyMotionGrid(width, height, '#0a0e16');
742
866
  const colors = themeContributionColors(theme);
@@ -838,6 +962,7 @@ function buildKaleidoscope(pulse, width, height, theme) {
838
962
  function buildConstellation(pulse, width, height, theme) {
839
963
  const grid = emptyMotionGrid(width, height, '#06090f');
840
964
  const accent = themeAccent(theme);
965
+ const linkColor = theme === 'mono' ? '#b8b8b8' : interpolateHex(accent, '#ffffff', 0.48);
841
966
  const count = Math.max(8, Math.min(18, Math.floor(width / 6)));
842
967
  const nodes = [];
843
968
  for (let i = 0; i < count; i += 1) {
@@ -860,7 +985,7 @@ function buildConstellation(pulse, width, height, theme) {
860
985
  const dx = a.x - b.x;
861
986
  const dy = (a.y - b.y) * 2;
862
987
  if (Math.sqrt(dx * dx + dy * dy) < linkDistance) {
863
- drawLineCells(grid, a.x, a.y, b.x, b.y, '·', '#2c3340');
988
+ drawLineCells(grid, a.x, a.y, b.x, b.y, '·', linkColor);
864
989
  }
865
990
  }
866
991
  }
@@ -870,29 +995,30 @@ function buildConstellation(pulse, width, height, theme) {
870
995
  return grid.map(row => lineFromCells(row, accent));
871
996
  }
872
997
  function buildPulseGrid(pulse, width, height, theme) {
873
- const grid = emptyMotionGrid(width, height, '#0a0e16');
998
+ const canvas = createBraille(width, height);
874
999
  const accent = themeAccent(theme);
875
1000
  const colors = themeContributionColors(theme);
876
- const cx = (width - 1) / 2;
877
- const cy = (height - 1) / 2;
878
- const levels = ['·', '∘', '•', '●', '◉'];
879
- for (let y = 0; y < height; y += 1) {
880
- for (let x = 0; x < width; x += 1) {
881
- if (x % 2 === 1) {
1001
+ const dotsWide = Math.max(1, width * 2);
1002
+ const dotsHigh = Math.max(1, height * 4);
1003
+ const cx = (dotsWide - 1) / 2;
1004
+ const cy = (dotsHigh - 1) / 2;
1005
+ const ringGap = Math.max(5, Math.min(dotsWide, dotsHigh) * 0.24);
1006
+ const travel = (pulse * 0.65) % ringGap;
1007
+ const stroke = Math.max(0.65, ringGap * 0.1);
1008
+ for (let y = 0; y < dotsHigh; y += 1) {
1009
+ for (let x = 0; x < dotsWide; x += 1) {
1010
+ const radius = Math.hypot(x - cx, y - cy);
1011
+ const phase = ((radius - travel) % ringGap + ringGap) % ringGap;
1012
+ const distanceToRing = Math.min(phase, ringGap - phase);
1013
+ if (distanceToRing > stroke)
882
1014
  continue;
883
- }
884
- const dx = (x - cx) / 2.0;
885
- const dy = y - cy;
886
- const r = Math.sqrt(dx * dx + dy * dy);
887
- const wave = Math.sin(r * 0.7 - pulse * 0.3) * 0.5 + 0.5;
888
- const sway = Math.sin(x * 0.2 + y * 0.3 + pulse * 0.1) * 0.2;
889
- const value = clampNumber(wave + sway, 0, 1);
890
- const levelIndex = Math.min(levels.length - 1, Math.floor(value * levels.length));
891
- const colorIndex = Math.min(colors.length - 1, Math.max(1, Math.round(value * (colors.length - 1))));
892
- paintCell(grid, x, y, levels[levelIndex] ?? '·', value > 0.16 ? colors[colorIndex] ?? accent : '#1a2029');
1015
+ const ring = Math.floor((radius + ringGap - travel) / ringGap);
1016
+ const intensity = 1 - distanceToRing / stroke;
1017
+ const colorIndex = Math.min(colors.length - 1, Math.max(1, colors.length - 1 - (ring % Math.max(1, colors.length - 1))));
1018
+ brailleSet(canvas, x, y, intensity > 0.82 ? '#ffffff' : colors[colorIndex] ?? accent);
893
1019
  }
894
1020
  }
895
- return grid.map(row => lineFromCells(row, accent));
1021
+ return brailleToLines(canvas, accent);
896
1022
  }
897
1023
  function buildLissajous(pulse, width, height, theme) {
898
1024
  const grid = emptyMotionGrid(width, height, '#04070d');
@@ -1211,54 +1337,23 @@ function buildHarmonograph(pulse, width, height, theme) {
1211
1337
  }
1212
1338
  return brailleToLines(canvas, accent);
1213
1339
  }
1214
- function buildBloomBars(pulse, width, height, theme) {
1215
- const grid = emptyMotionGrid(width, height, '#070b12');
1216
- const ramp = themeContributionColors(theme);
1217
- const accent = themeAccent(theme);
1218
- const eighths = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
1219
- const bandWidth = 2;
1220
- const bands = Math.ceil(width / bandWidth);
1221
- for (let band = 0; band < bands; band += 1) {
1222
- const x = band * bandWidth;
1223
- const level = spectrumSample(band, pulse);
1224
- const filled = level * height;
1225
- const fullCells = Math.floor(filled);
1226
- const remainder = filled - fullCells;
1227
- let peak = 0;
1228
- for (let k = 0; k < 14; k += 1) {
1229
- peak = Math.max(peak, spectrumSample(band, pulse - k));
1230
- }
1231
- for (let i = 0; i < fullCells; i += 1) {
1232
- const y = height - 1 - i;
1233
- const colorIndex = Math.min(ramp.length - 1, 1 + Math.round((i / Math.max(1, height - 1)) * (ramp.length - 2)));
1234
- paintCell(grid, x, y, '█', ramp[colorIndex] ?? accent);
1235
- }
1236
- if (remainder > 0.08 && fullCells < height) {
1237
- const colorIndex = Math.min(ramp.length - 1, 1 + Math.round((fullCells / Math.max(1, height - 1)) * (ramp.length - 2)));
1238
- paintCell(grid, x, height - 1 - fullCells, eighths[Math.min(7, Math.floor(remainder * 8))] ?? '▁', ramp[colorIndex] ?? accent);
1239
- }
1240
- const tipY = height - 1 - fullCells;
1241
- if (tipY - 1 >= 0) {
1242
- paintCell(grid, x, tipY - 1, '░', '#2b3340');
1243
- }
1244
- const peakY = height - 1 - Math.round(peak * (height - 1));
1245
- if (peakY >= 0 && peakY < height) {
1246
- paintCell(grid, x, peakY, '▔', '#ffffff');
1247
- }
1248
- }
1249
- return grid.map(row => lineFromCells(row, accent));
1250
- }
1251
1340
  function buildMoire(pulse, width, height, theme) {
1252
1341
  const grid = emptyMotionGrid(width, height, '#05060c');
1253
1342
  const ramp = themeContributionColors(theme);
1254
1343
  const accent = themeAccent(theme);
1255
- const centerA = { x: width * 0.4 + Math.sin(pulse * 0.02) * width * 0.1, y: height * 0.5 };
1256
- const centerB = { x: width * 0.6 + Math.cos(pulse * 0.018) * width * 0.1, y: height * 0.5 };
1344
+ // Panel-centered foci (char aspect ~2:1); gentle orbit stays symmetric about mid
1345
+ const midX = (width - 1) / 2;
1346
+ const midY = (height - 1) / 2;
1347
+ const separation = Math.min(width, height * 2) * 0.14;
1348
+ const drift = Math.sin(pulse * 0.02) * separation * 0.35;
1349
+ const centerA = { x: midX - separation + drift, y: midY + Math.cos(pulse * 0.018) * height * 0.04 };
1350
+ const centerB = { x: midX + separation - drift, y: midY - Math.sin(pulse * 0.016) * height * 0.04 };
1257
1351
  const shades = [' ', '·', '░', '▒', '▓', '█'];
1352
+ const aspect = 2.05; // horizontal compress so rings read as circles
1258
1353
  for (let y = 0; y < height; y += 1) {
1259
1354
  for (let x = 0; x < width; x += 1) {
1260
- const da = Math.sqrt(((x - centerA.x) / 2) ** 2 + (y - centerA.y) ** 2);
1261
- const db = Math.sqrt(((x - centerB.x) / 2) ** 2 + (y - centerB.y) ** 2);
1355
+ const da = Math.sqrt(((x - centerA.x) / aspect) ** 2 + (y - centerA.y) ** 2);
1356
+ const db = Math.sqrt(((x - centerB.x) / aspect) ** 2 + (y - centerB.y) ** 2);
1262
1357
  const value = Math.sin(da * 1.1 - pulse * 0.1) * Math.sin(db * 1.1 + pulse * 0.08);
1263
1358
  const normalized = (value + 1) / 2;
1264
1359
  const shadeIndex = Math.min(shades.length - 1, Math.floor(normalized * shades.length));
@@ -1273,37 +1368,95 @@ function buildMoire(pulse, width, height, theme) {
1273
1368
  function buildGalaxy(pulse, width, height, theme) {
1274
1369
  const canvas = createBraille(width, height);
1275
1370
  const accent = themeAccent(theme);
1276
- const palette = ['#bcd7ff', '#9ec2ff', '#ffffff', '#ffd9a8', '#ffb38a'];
1371
+ const armPalette = theme === 'mono'
1372
+ ? ['#666666', '#888888', '#aaaaaa', '#cccccc', '#ffffff']
1373
+ : ['#6a8cff', '#9ec2ff', '#c8dcff', '#ffffff', '#ffd9a8', '#ffb38a', '#e8a0ff', '#80e0ff'];
1374
+ const dustColor = theme === 'mono' ? '#333333' : '#1a1030';
1277
1375
  const dotsWide = width * 2;
1278
1376
  const dotsHigh = height * 4;
1279
1377
  const cx = (dotsWide - 1) / 2;
1280
1378
  const cy = (dotsHigh - 1) / 2;
1281
- const arms = 2;
1282
- const rotation = pulse * 0.03;
1283
- const span = Math.min(dotsWide, dotsHigh) * 0.62;
1284
- const count = Math.min(1100, Math.floor((dotsWide * dotsHigh) / 3));
1379
+ const arms = 4;
1380
+ const rotation = pulse * 0.022;
1381
+ const span = Math.min(dotsWide, dotsHigh) * 0.72;
1382
+ // Soft halo / bulge dust (background layer)
1383
+ const haloCount = Math.min(900, Math.floor((dotsWide * dotsHigh) / 5));
1384
+ for (let i = 0; i < haloCount; i += 1) {
1385
+ const a = hashNoise(i, 20, 0) * Math.PI * 2;
1386
+ const r = Math.pow(hashNoise(i, 21, 0), 0.55) * span * 0.55;
1387
+ if (hashNoise(i, 22, 0) > 0.45) {
1388
+ brailleSet(canvas, cx + Math.cos(a) * r * 1.15, cy + Math.sin(a) * r * 0.72, dustColor);
1389
+ }
1390
+ }
1391
+ // Spiral arms — dense multi-pass with dust lanes between
1392
+ const count = Math.min(3200, Math.floor((dotsWide * dotsHigh) / 1.6));
1285
1393
  for (let i = 0; i < count; i += 1) {
1286
1394
  const fraction = i / count;
1287
- const radius = fraction * span;
1395
+ const radius = Math.pow(fraction, 0.85) * span;
1288
1396
  const arm = i % arms;
1289
- const jitter = (hashNoise(i, 1, 0) - 0.5) * 0.55;
1290
- const angle = arm * ((Math.PI * 2) / arms) + radius * 0.12 + rotation + jitter;
1291
- const brightness = fraction < 0.12 ? 1 : 1 - fraction;
1292
- if (hashNoise(i, 7, 0) < 0.6 + brightness * 0.4) {
1293
- const color = fraction < 0.1
1294
- ? '#ffffff'
1295
- : theme === 'mono'
1296
- ? motionColorAt(1 - fraction, theme)
1297
- : palette[Math.floor(hashNoise(i, 3, 0) * palette.length) % palette.length] ?? accent;
1298
- brailleSet(canvas, cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius, color);
1397
+ // Logarithmic spiral + thickness jitter
1398
+ const armAngle = arm * ((Math.PI * 2) / arms);
1399
+ const spiral = radius * 0.15;
1400
+ const lane = (hashNoise(i, 1, 0) - 0.5) * (0.35 + fraction * 0.9);
1401
+ // Dust lane: thin empty strip along arm mid
1402
+ const laneAbs = Math.abs(lane);
1403
+ if (laneAbs < 0.06 && fraction > 0.15 && fraction < 0.85 && hashNoise(i, 8, 0) > 0.35) {
1404
+ continue;
1405
+ }
1406
+ const angle = armAngle + spiral + rotation + lane * 0.55;
1407
+ const x = cx + Math.cos(angle) * radius * (1 + lane * 0.08);
1408
+ const y = cy + Math.sin(angle) * radius * 0.62 * (1 + lane * 0.05);
1409
+ const brightness = fraction < 0.1 ? 1 : Math.pow(1 - fraction, 0.7);
1410
+ // HII regions / star clusters denser mid-arm
1411
+ const cluster = hashNoise(i, 5, 0) > 0.92 && fraction > 0.2 && fraction < 0.7;
1412
+ if (hashNoise(i, 7, 0) > 0.35 + brightness * 0.5 && !cluster) {
1413
+ continue;
1414
+ }
1415
+ let color;
1416
+ if (fraction < 0.08 || cluster) {
1417
+ color = '#ffffff';
1418
+ }
1419
+ else if (theme === 'mono') {
1420
+ color = motionColorAt(1 - fraction, theme);
1421
+ }
1422
+ else {
1423
+ const pi = Math.min(armPalette.length - 1, Math.floor((arm / arms + hashNoise(i, 3, 0) * 0.4 + fraction * 0.3) * armPalette.length) % armPalette.length);
1424
+ color = armPalette[pi] ?? accent;
1425
+ }
1426
+ brailleSet(canvas, x, y, color);
1427
+ // Cluster clumps
1428
+ if (cluster) {
1429
+ for (let k = 0; k < 5; k += 1) {
1430
+ brailleSet(canvas, x + (hashNoise(i, k, 1) - 0.5) * 2.5, y + (hashNoise(i, k, 2) - 0.5) * 2.5, k === 0 ? '#ffffff' : armPalette[2] ?? accent);
1431
+ }
1432
+ }
1433
+ }
1434
+ // Central bar
1435
+ const barLen = span * 0.18;
1436
+ for (let t = -barLen; t <= barLen; t += 0.55) {
1437
+ const bx = cx + Math.cos(rotation) * t;
1438
+ const by = cy + Math.sin(rotation) * t * 0.55;
1439
+ for (let w = -1.2; w <= 1.2; w += 0.5) {
1440
+ brailleSet(canvas, bx + Math.cos(rotation + Math.PI / 2) * w, by + Math.sin(rotation + Math.PI / 2) * w * 0.55, t * t < barLen * barLen * 0.15 ? '#ffffff' : armPalette[1] ?? accent);
1299
1441
  }
1300
1442
  }
1301
- const coreRadius = Math.min(dotsWide, dotsHigh) * 0.05;
1302
- for (let angle = 0; angle < Math.PI * 2; angle += 0.18) {
1303
- for (let r = 0; r < coreRadius; r += 0.7) {
1304
- brailleSet(canvas, cx + Math.cos(angle) * r, cy + Math.sin(angle) * r, '#ffffff');
1443
+ // Bright core + accretion glow
1444
+ const coreRadius = Math.min(dotsWide, dotsHigh) * 0.09;
1445
+ for (let angle = 0; angle < Math.PI * 2; angle += 0.1) {
1446
+ for (let r = 0; r < coreRadius; r += 0.45) {
1447
+ const fall = 1 - r / coreRadius;
1448
+ if (fall < 0.15 && hashNoise(Math.floor(angle * 10), Math.floor(r * 4), 0) < 0.4) {
1449
+ continue;
1450
+ }
1451
+ brailleSet(canvas, cx + Math.cos(angle) * r, cy + Math.sin(angle) * r * 0.7, fall > 0.55 ? '#ffffff' : armPalette[0] ?? accent);
1305
1452
  }
1306
1453
  }
1454
+ // Sparse foreground stars
1455
+ for (let s = 0; s < 40; s += 1) {
1456
+ const sx = hashNoise(s, 30, 0) * dotsWide;
1457
+ const sy = hashNoise(s, 31, 0) * dotsHigh;
1458
+ brailleSet(canvas, sx, sy, '#ffffff');
1459
+ }
1307
1460
  return brailleToLines(canvas, accent);
1308
1461
  }
1309
1462
  function buildCaustics(pulse, width, height, theme) {
@@ -1625,36 +1778,6 @@ function buildJulia(pulse, width, height, theme) {
1625
1778
  }
1626
1779
  return grid.map(row => lineFromCells(row, accent));
1627
1780
  }
1628
- function buildClifford(pulse, width, height, theme) {
1629
- const canvas = createBraille(width, height);
1630
- const ramp = themeContributionColors(theme);
1631
- const accent = themeAccent(theme);
1632
- const dotsWide = width * 2;
1633
- const dotsHigh = height * 4;
1634
- const cx = (dotsWide - 1) / 2;
1635
- const cy = (dotsHigh - 1) / 2;
1636
- const a = -1.4 + 0.28 * Math.sin(pulse * 0.011);
1637
- const b = 1.6 + 0.2 * Math.cos(pulse * 0.008);
1638
- const c = 1.0;
1639
- const d = 0.7;
1640
- const scaleX = dotsWide * 0.4 / 2.1;
1641
- const scaleY = dotsHigh * 0.4 / 1.9;
1642
- const count = Math.min(4200, Math.floor((dotsWide * dotsHigh) / 2));
1643
- let x = 0.12;
1644
- let y = 0.08;
1645
- for (let i = 0; i < count; i += 1) {
1646
- const nx = Math.sin(a * y) + c * Math.cos(a * x);
1647
- const ny = Math.sin(b * x) + d * Math.cos(b * y);
1648
- x = nx;
1649
- y = ny;
1650
- if (i < 20) {
1651
- continue;
1652
- }
1653
- const color = ramp[Math.min(ramp.length - 1, 1 + Math.round(((y + 1.9) / 3.8) * (ramp.length - 2)))] ?? accent;
1654
- brailleSet(canvas, cx + x * scaleX, cy + y * scaleY, color);
1655
- }
1656
- return brailleToLines(canvas, accent);
1657
- }
1658
1781
  function buildGoniometer(pulse, width, height, theme) {
1659
1782
  const canvas = createBraille(width, height);
1660
1783
  const ramp = themeContributionColors(theme);
@@ -1734,7 +1857,7 @@ function buildTwister(pulse, width, height, theme) {
1734
1857
  }
1735
1858
  return grid.map(row => lineFromCells(row, accent));
1736
1859
  }
1737
- function buildCoral(pulse, width, height, theme) {
1860
+ function buildCyclone(pulse, width, height, theme) {
1738
1861
  const canvas = createBraille(width, height);
1739
1862
  const ramp = themeContributionColors(theme);
1740
1863
  const accent = themeAccent(theme);
@@ -1742,161 +1865,2300 @@ function buildCoral(pulse, width, height, theme) {
1742
1865
  const dotsHigh = height * 4;
1743
1866
  const cx = (dotsWide - 1) / 2;
1744
1867
  const cy = (dotsHigh - 1) / 2;
1745
- const growFrac = clampNumber(((pulse * 1.3) % 200) / 150, 0, 1);
1746
- const effDepth = Math.min(8, 2 + Math.floor(growFrac * 6));
1747
- const seedLength = Math.min(dotsWide, dotsHigh) * 0.17;
1748
- const branch = (x, y, angle, length, depth) => {
1749
- if (depth > effDepth || length < 1.2) {
1750
- return;
1751
- }
1752
- const nx = x + Math.cos(angle) * length;
1753
- const ny = y + Math.sin(angle) * length;
1754
- const color = ramp[Math.min(ramp.length - 1, 1 + Math.round((depth / 7) * (ramp.length - 2)))] ?? accent;
1755
- drawBrailleLine(canvas, x, y, nx, ny, color);
1756
- const jitter = (hashNoise(Math.round(x), Math.round(y), depth) - 0.5) * 0.3;
1757
- branch(nx, ny, angle + Math.PI / 3 + jitter, length * 0.74, depth + 1);
1758
- branch(nx, ny, angle - Math.PI / 3 + jitter, length * 0.74, depth + 1);
1759
- branch(nx, ny, angle + jitter * 0.5, length * 0.6, depth + 2);
1868
+ const maxR = Math.min(dotsWide, dotsHigh) * 0.52;
1869
+ const count = Math.min(260, Math.floor((dotsWide * dotsHigh) / 9));
1870
+ for (let i = 0; i < count; i += 1) {
1871
+ const phase = hashNoise(i, 1, 0) * Math.PI * 2;
1872
+ // Slightly slower spiral inhale + spin than before
1873
+ const life = ((pulse * 0.68 + i * 7.3) % 130) / 130;
1874
+ const r = maxR * (1 - life);
1875
+ const angle = phase + pulse * 0.036 + (maxR / (r + 5)) * 0.9;
1876
+ for (let trail = 0; trail < 4; trail += 1) {
1877
+ const rr = r + trail * 1.4;
1878
+ const aa = angle - trail * 0.12;
1879
+ const x = cx + Math.cos(aa) * rr;
1880
+ const y = cy + Math.sin(aa) * rr;
1881
+ const color = trail === 0 ? (r < maxR * 0.18 ? '#ffffff' : ramp[ramp.length - 1] ?? accent) : ramp[Math.min(ramp.length - 1, 1 + Math.round((1 - rr / maxR) * (ramp.length - 2)))] ?? accent;
1882
+ brailleSet(canvas, x, y, color);
1883
+ }
1884
+ }
1885
+ brailleSet(canvas, cx, cy, '#ffffff');
1886
+ return brailleToLines(canvas, accent);
1887
+ }
1888
+ function buildLavaLamp(pulse, width, height, theme) {
1889
+ const grid = emptyMotionGrid(width, height, '#0a0610');
1890
+ const ramp = themeContributionColors(theme);
1891
+ const accent = themeAccent(theme);
1892
+ const blobs = Array.from({ length: 5 }, (_, b) => {
1893
+ const speed = 0.14 + ((b * 7) % 5) / 20;
1894
+ return {
1895
+ x: width * (0.5 + 0.32 * Math.sin(pulse * 0.015 * (1 + b * 0.2) + b)),
1896
+ y: height + (b * height) / 5 - ((pulse * speed) % (height + 6)),
1897
+ r: 2.2 + 1.3 * Math.sin(pulse * 0.02 + b * 1.7) + (b % 2 ? 0.8 : 0)
1898
+ };
1899
+ });
1900
+ for (let y = 0; y < height; y += 1) {
1901
+ for (let x = 0; x < width; x += 1) {
1902
+ let field = 0;
1903
+ for (const blob of blobs) {
1904
+ const dx = (x - blob.x) * 0.5;
1905
+ const dy = y - blob.y;
1906
+ field += (blob.r * blob.r) / (dx * dx + dy * dy + 0.6);
1907
+ }
1908
+ const glyph = field > 2 ? '█' : field > 1.4 ? '▓' : field > 1 ? '▒' : field > 0.7 ? '░' : ' ';
1909
+ if (glyph !== ' ') {
1910
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(clampNumber(field / 2.4, 0, 1) * (ramp.length - 1))));
1911
+ paintCell(grid, x, y, glyph, ramp[colorIndex] ?? accent);
1912
+ }
1913
+ }
1914
+ }
1915
+ return grid.map(row => lineFromCells(row, accent));
1916
+ }
1917
+ function buildNewton(pulse, width, height, theme) {
1918
+ const grid = emptyMotionGrid(width, height, '#05060c');
1919
+ const ramp = themeContributionColors(theme);
1920
+ const accent = themeAccent(theme);
1921
+ const shades = [' ', '·', '░', '▒', '▓', '█'];
1922
+ const roots = [
1923
+ { x: 1, y: 0, color: ramp[2] ?? accent },
1924
+ { x: -0.5, y: Math.sqrt(3) / 2, color: ramp[4] ?? accent },
1925
+ { x: -0.5, y: -Math.sqrt(3) / 2, color: accent }
1926
+ ];
1927
+ const zoom = 1.7 + 0.4 * Math.sin(pulse * 0.02);
1928
+ const rot = pulse * 0.012;
1929
+ const cosR = Math.cos(rot);
1930
+ const sinR = Math.sin(rot);
1931
+ const maxIter = 22;
1932
+ for (let y = 0; y < height; y += 1) {
1933
+ for (let x = 0; x < width; x += 1) {
1934
+ const px = (x / Math.max(1, width - 1) - 0.5) * 2 * zoom;
1935
+ const py = (y / Math.max(1, height - 1) - 0.5) * 2 * zoom;
1936
+ let zx = px * cosR - py * sinR;
1937
+ let zy = px * sinR + py * cosR;
1938
+ let iter = 0;
1939
+ for (; iter < maxIter; iter += 1) {
1940
+ const r2 = zx * zx + zy * zy;
1941
+ if (r2 < 1e-6) {
1942
+ break;
1943
+ }
1944
+ const denom = 3 * ((zx * zx - zy * zy) * (zx * zx - zy * zy) + (2 * zx * zy) * (2 * zx * zy));
1945
+ const ax = zx * zx - zy * zy;
1946
+ const ay = 2 * zx * zy;
1947
+ const numX = (ax * zx - ay * zy) - 1;
1948
+ const numY = ax * zy + ay * zx;
1949
+ zx = zx - (numX * ax + numY * ay) / (denom || 1e-6);
1950
+ zy = zy - (numY * ax - numX * ay) / (denom || 1e-6);
1951
+ let done = false;
1952
+ for (const root of roots) {
1953
+ if ((zx - root.x) ** 2 + (zy - root.y) ** 2 < 0.01) {
1954
+ done = true;
1955
+ break;
1956
+ }
1957
+ }
1958
+ if (done) {
1959
+ break;
1960
+ }
1961
+ }
1962
+ let chosen = roots[0];
1963
+ let best = Infinity;
1964
+ for (const root of roots) {
1965
+ const dist = (zx - root.x) ** 2 + (zy - root.y) ** 2;
1966
+ if (dist < best) {
1967
+ best = dist;
1968
+ chosen = root;
1969
+ }
1970
+ }
1971
+ const t = 1 - iter / maxIter;
1972
+ const shadeIndex = Math.min(shades.length - 1, 1 + Math.floor(t * (shades.length - 1)));
1973
+ paintCell(grid, x, y, shades[shadeIndex] ?? '·', chosen.color);
1974
+ }
1975
+ }
1976
+ return grid.map(row => lineFromCells(row, accent));
1977
+ }
1978
+ function buildAurora(pulse, width, height, theme) {
1979
+ const accent = themeAccent(theme);
1980
+ // Northern-lights palette: deep night → teal → green → violet → white fringe
1981
+ const night = theme === 'mono' ? '#0a0a0a' : '#040814';
1982
+ const ramp = theme === 'mono'
1983
+ ? ['#0e0e12', '#1a1a24', '#2e2e3a', '#4a4a5a', '#787888', '#a8a8b8', '#d0d0dc', '#ffffff']
1984
+ : ['#061018', '#0a2a28', '#0d4a3c', '#128a5a', '#2ecf7a', '#7dffb0', '#a8ffe0', '#c8b0ff', '#e8f0ff'];
1985
+ const glyphs = [' ', '·', '┊', '│', '║', '░', '▒', '▓', '█'];
1986
+ const curtains = 6;
1987
+ return Array.from({ length: height }, (_, y) => {
1988
+ const ny = y / Math.max(1, height - 1);
1989
+ const cells = Array.from({ length: width }, (_, x) => {
1990
+ const nx = x / Math.max(1, width - 1);
1991
+ let field = 0;
1992
+ let filament = 0;
1993
+ for (let c = 0; c < curtains; c += 1) {
1994
+ // Curtain folds drift slowly; each sheet has different phase & width
1995
+ const fold = 0.08 +
1996
+ c * 0.15 +
1997
+ 0.06 * Math.sin(pulse * 0.035 + c * 1.9) +
1998
+ 0.03 * Math.sin(y * 0.28 + pulse * 0.05 + c) +
1999
+ 0.02 * Math.sin(y * 0.55 - pulse * 0.04 + c * 0.7);
2000
+ const halfWidth = 0.045 + 0.02 * Math.sin(pulse * 0.03 + c) + 0.015 * Math.sin(y * 0.4 + c);
2001
+ const dist = Math.abs(nx - fold);
2002
+ // Soft body of the curtain (filled, not holey)
2003
+ const body = Math.exp(-Math.pow(dist / halfWidth, 2));
2004
+ // Vertical brightness: bright mid-band, dimmer top & bottom edge
2005
+ const vertical = 0.35 +
2006
+ 0.65 * Math.sin(Math.PI * clampNumber(ny * 1.15 - 0.05, 0, 1)) *
2007
+ (0.7 + 0.3 * Math.sin(pulse * 0.06 + c * 1.1 + y * 0.15));
2008
+ field += body * vertical * (0.85 + 0.15 * Math.sin(pulse * 0.08 + c));
2009
+ // Fine vertical filaments inside the curtain
2010
+ if (body > 0.15) {
2011
+ const micro = Math.sin(x * (2.8 + c * 0.4) + pulse * 0.25 + y * 0.1);
2012
+ filament += body * Math.max(0, micro) * 0.35;
2013
+ }
2014
+ }
2015
+ // Background night sky stars
2016
+ const star = hashNoise(x, y, 3) > 0.992 ? 0.55 : 0;
2017
+ // Horizontal shimmer bands
2018
+ const shimmer = 0.08 * Math.sin(x * 0.55 + pulse * 0.18 + y * 0.08);
2019
+ const intensity = clampNumber(field * 0.7 + filament + shimmer + star * 0.3, 0, 1);
2020
+ // Always paint night background so there are no "holes"
2021
+ if (intensity < 0.06) {
2022
+ const starGlyph = star > 0 ? '·' : ' ';
2023
+ return {
2024
+ text: starGlyph,
2025
+ color: star > 0 ? '#8899aa' : accent,
2026
+ backgroundColor: night
2027
+ };
2028
+ }
2029
+ const level = Math.min(ramp.length - 1, Math.floor(intensity * (ramp.length - 0.01)));
2030
+ const gi = Math.min(glyphs.length - 1, Math.floor(intensity * glyphs.length));
2031
+ // Prefer vertical glyphs for curtain fibers, blocks for dense cores
2032
+ let glyph = glyphs[gi] ?? '█';
2033
+ if (intensity > 0.25 && intensity < 0.7 && filament > 0.12) {
2034
+ glyph = intensity > 0.5 ? '║' : '│';
2035
+ }
2036
+ if (star > 0 && intensity < 0.35) {
2037
+ glyph = '✦';
2038
+ }
2039
+ return {
2040
+ text: glyph,
2041
+ color: intensity > 0.82 ? '#ffffff' : ramp[Math.min(ramp.length - 1, level + 1)] ?? accent,
2042
+ backgroundColor: ramp[level] ?? night,
2043
+ bold: intensity > 0.75
2044
+ };
2045
+ });
2046
+ return lineFromCells(cells, accent);
2047
+ });
2048
+ }
2049
+ function buildXorTexture(pulse, width, height, theme) {
2050
+ const grid = emptyMotionGrid(width, height, '#05060c');
2051
+ const ramp = themeContributionColors(theme);
2052
+ const accent = themeAccent(theme);
2053
+ const angle = pulse * 0.04;
2054
+ const zoom = 0.55 + 0.2 * Math.sin(pulse * 0.02);
2055
+ const cos = Math.cos(angle);
2056
+ const sin = Math.sin(angle);
2057
+ const cx = (width - 1) / 2;
2058
+ const cy = (height - 1) / 2;
2059
+ const shades = [' ', '·', '░', '▒', '▓', '█'];
2060
+ for (let y = 0; y < height; y += 1) {
2061
+ for (let x = 0; x < width; x += 1) {
2062
+ const dx = (x - cx) * zoom;
2063
+ const dy = (y - cy) * 2 * zoom;
2064
+ const u = Math.floor(dx * cos - dy * sin + pulse * 0.4);
2065
+ const v = Math.floor(dx * sin + dy * cos - pulse * 0.25);
2066
+ const xor = (u ^ v) & 255;
2067
+ const normalized = xor / 255;
2068
+ const shadeIndex = Math.min(shades.length - 1, Math.floor(normalized * shades.length));
2069
+ if (shadeIndex === 0) {
2070
+ continue;
2071
+ }
2072
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round(normalized * (ramp.length - 2)));
2073
+ paintCell(grid, x, y, shades[shadeIndex] ?? '·', ramp[colorIndex] ?? accent);
2074
+ }
2075
+ }
2076
+ return grid.map(row => lineFromCells(row, accent));
2077
+ }
2078
+ function buildHexPulse(pulse, width, height, theme) {
2079
+ const grid = emptyMotionGrid(width, height, '#05060c');
2080
+ const ramp = themeContributionColors(theme);
2081
+ const accent = themeAccent(theme);
2082
+ const cx = (width - 1) / 2;
2083
+ const cy = (height - 1) / 2;
2084
+ const size = Math.max(1.4, Math.min(2.4, Math.min(width * 0.55, height) / 3));
2085
+ const shades = ['·', '░', '▒', '▓', '█'];
2086
+ for (let y = 0; y < height; y += 1) {
2087
+ for (let x = 0; x < width; x += 1) {
2088
+ const px = (x - cx) * 0.55;
2089
+ const py = y - cy;
2090
+ const q = ((Math.sqrt(3) / 3) * px - (1 / 3) * py) / size;
2091
+ const r = ((2 / 3) * py) / size;
2092
+ const s = -q - r;
2093
+ const iq = Math.round(q);
2094
+ const ir = Math.round(r);
2095
+ const is = Math.round(s);
2096
+ const qDiff = Math.abs(iq - q);
2097
+ const rDiff = Math.abs(ir - r);
2098
+ const sDiff = Math.abs(is - s);
2099
+ let hq = iq;
2100
+ let hr = ir;
2101
+ if (qDiff > rDiff && qDiff > sDiff) {
2102
+ hq = -ir - is;
2103
+ }
2104
+ else if (rDiff > sDiff) {
2105
+ hr = -iq - is;
2106
+ }
2107
+ const dist = Math.sqrt(hq * hq + hr * hr + hq * hr);
2108
+ const edgeQ = Math.abs(q - hq);
2109
+ const edgeR = Math.abs(r - hr);
2110
+ const edge = Math.max(edgeQ, edgeR, Math.abs((-q - r) - (-hq - hr)));
2111
+ const wave = (Math.sin(dist * 1.1 - pulse * 0.12) + 1) / 2;
2112
+ const border = edge > 0.3 ? 1 : 0.28;
2113
+ const intensity = (0.28 + wave * 0.72) * border * (0.48 + 0.52 * (1 - Math.min(1, dist / 8)));
2114
+ if (intensity < 0.1) {
2115
+ continue;
2116
+ }
2117
+ const shadeIndex = Math.min(shades.length - 1, Math.floor(intensity * shades.length));
2118
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round(wave * (ramp.length - 2)));
2119
+ paintCell(grid, x, y, shades[shadeIndex] ?? '·', ramp[colorIndex] ?? accent);
2120
+ }
2121
+ }
2122
+ return grid.map(row => lineFromCells(row, accent));
2123
+ }
2124
+ function buildSpirograph(pulse, width, height, theme) {
2125
+ const canvas = createBraille(width, height);
2126
+ const ramp = themeContributionColors(theme);
2127
+ const accent = themeAccent(theme);
2128
+ const dotsWide = width * 2;
2129
+ const dotsHigh = height * 4;
2130
+ const cx = (dotsWide - 1) / 2;
2131
+ const cy = (dotsHigh - 1) / 2;
2132
+ const scale = Math.min(dotsWide, dotsHigh) * 0.36;
2133
+ const k = 0.42 + 0.08 * Math.sin(pulse * 0.01);
2134
+ const l = 0.68 + 0.12 * Math.cos(pulse * 0.015);
2135
+ const rot = pulse * 0.02;
2136
+ const steps = Math.min(1400, Math.floor(dotsWide * dotsHigh * 0.35));
2137
+ const drawTo = Math.floor(steps * (0.55 + 0.45 * ((Math.sin(pulse * 0.03) + 1) / 2)));
2138
+ let prevX = cx;
2139
+ let prevY = cy;
2140
+ for (let i = 0; i <= drawTo; i += 1) {
2141
+ const t = (i / steps) * Math.PI * 2 * 11 + rot;
2142
+ const x = cx + scale * ((1 - k) * Math.cos(t) + l * k * Math.cos(((1 - k) / k) * t));
2143
+ const y = cy + scale * ((1 - k) * Math.sin(t) - l * k * Math.sin(((1 - k) / k) * t));
2144
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round((i / Math.max(1, drawTo)) * (ramp.length - 2)));
2145
+ const color = ramp[colorIndex] ?? accent;
2146
+ if (i > 0) {
2147
+ drawBrailleLine(canvas, prevX, prevY, x, y, color);
2148
+ }
2149
+ prevX = x;
2150
+ prevY = y;
2151
+ }
2152
+ return brailleToLines(canvas, accent);
2153
+ }
2154
+ function blitAsciiSprite(grid, sprite, originX, originY, color) {
2155
+ for (let row = 0; row < sprite.length; row += 1) {
2156
+ const line = sprite[row] ?? '';
2157
+ for (let col = 0; col < line.length; col += 1) {
2158
+ const ch = line[col];
2159
+ if (ch === ' ') {
2160
+ continue;
2161
+ }
2162
+ paintCell(grid, originX + col, originY + row, ch, color);
2163
+ }
2164
+ }
2165
+ }
2166
+ /**
2167
+ * One-shot launch with camera follow: rocket stays in frame; world scrolls past.
2168
+ * Pulse resets on Now Playing re-entry and resumes from the frozen pause frame.
2169
+ */
2170
+ function buildLiftoff(pulse, width, height, theme) {
2171
+ const grid = emptyMotionGrid(width, height, '#03040c');
2172
+ const ramp = themeContributionColors(theme);
2173
+ const accent = themeAccent(theme);
2174
+ const rocket = [
2175
+ ' /\\',
2176
+ ' /██\\',
2177
+ ' |██|',
2178
+ ' |▓▓|',
2179
+ ' |██|',
2180
+ ' /| |\\',
2181
+ ' /_|██|_\\',
2182
+ '/__|__|__\\'
2183
+ ];
2184
+ const padHold = 18;
2185
+ const ignition = Math.max(0, pulse - padHold);
2186
+ // 0 = still on pad, 1 = camera locked on rocket in cruise — slower climb
2187
+ const liftT = clampNumber(ignition / 48, 0, 1);
2188
+ const ease = liftT * liftT * (3 - 2 * liftT); // smoothstep
2189
+ // Rocket stays in frame: rises from pad to a fixed cruise position, then holds
2190
+ const padBottom = height - 1;
2191
+ const cruiseBottom = Math.min(height - 1, Math.max(rocket.length - 1, Math.floor(height * 0.4)));
2192
+ const rocketBottom = Math.floor(padBottom + (cruiseBottom - padBottom) * ease);
2193
+ const bodyX = Math.floor(width / 2) - 4;
2194
+ const engineX = bodyX + 4;
2195
+ const offPad = liftT > 0.08;
2196
+ const highAltitude = liftT > 0.55;
2197
+ const deepSpace = liftT > 0.85;
2198
+ // Stars / atmosphere scroll downward as we climb (camera rides with rocket)
2199
+ // Keep parallax gentle so scenery doesn't whip past
2200
+ const scrollSpeed = 0.08 + ease * 1.35 + Math.max(0, ignition - 48) * 0.018;
2201
+ for (let s = 0; s < Math.floor((width * height) / 7); s += 1) {
2202
+ const sx = Math.floor(hashNoise(s, 1, 0) * width);
2203
+ const baseY = hashNoise(s, 2, 0) * height * 4;
2204
+ const sy = Math.floor((baseY + ignition * scrollSpeed * (0.5 + hashNoise(s, 3, 0))) % height);
2205
+ const bright = hashNoise(s, 4, 0);
2206
+ if (offPad && bright > 0.5) {
2207
+ const len = 1 + Math.min(7, Math.floor(scrollSpeed * bright * 1.2));
2208
+ for (let k = 0; k < len; k += 1) {
2209
+ paintCell(grid, sx, (sy + k) % height, k === 0 ? (bright > 0.9 ? '┃' : '|') : '┊', bright > 0.9 ? '#ffffff' : ramp[1] ?? accent);
2210
+ }
2211
+ }
2212
+ else {
2213
+ paintCell(grid, sx, sy, bright > 0.85 ? '+' : '·', bright > 0.85 ? '#ffffff' : ramp[1] ?? accent);
2214
+ }
2215
+ }
2216
+ // Distant planets drift past once we're high
2217
+ if (deepSpace) {
2218
+ for (let p = 0; p < 3; p += 1) {
2219
+ const planetX = Math.floor(width * (0.15 + p * 0.32) + Math.sin(pulse * 0.015 + p) * 2);
2220
+ const planetY = Math.floor(((hashNoise(p, 8, 0) * height * 2 + ignition * scrollSpeed * 0.15) % (height + 8)) - 4);
2221
+ const pr = 1 + (p % 2);
2222
+ for (let dy = -pr; dy <= pr; dy += 1) {
2223
+ for (let dx = -pr * 2; dx <= pr * 2; dx += 1) {
2224
+ if ((dx / 2) * (dx / 2) + dy * dy <= pr * pr + 0.2) {
2225
+ const col = theme === 'mono'
2226
+ ? '#888888'
2227
+ : ['#6a8ab0', '#c08060', '#80b080'][p % 3];
2228
+ paintCell(grid, planetX + dx, planetY + dy, '█', col);
2229
+ }
2230
+ }
2231
+ }
2232
+ }
2233
+ }
2234
+ // Ground / pad scroll down and off-screen as camera climbs with the rocket
2235
+ const groundY = Math.floor(height - 1 + ease * (height + 6));
2236
+ if (groundY < height && groundY >= 0) {
2237
+ for (let x = 0; x < width; x += 1) {
2238
+ const pad = Math.abs(x - engineX) < width * 0.22;
2239
+ paintCell(grid, x, groundY, pad ? '▀' : '▁', pad ? '#888899' : '#333344');
2240
+ }
2241
+ if (!offPad || liftT < 0.35) {
2242
+ const towerX = bodyX - 3;
2243
+ for (let ty = Math.max(0, rocketBottom - 6); ty < groundY; ty += 1) {
2244
+ paintCell(grid, towerX, ty, '║', '#667788');
2245
+ if (ty % 2 === 0) {
2246
+ paintCell(grid, towerX + 1, ty, '═', '#556677');
2247
+ }
2248
+ }
2249
+ }
2250
+ }
2251
+ // Ground blast during ignition
2252
+ if (liftT < 0.4 && groundY < height && groundY >= height - 10) {
2253
+ const blast = 1 - liftT / 0.4;
2254
+ const smokeColors = theme === 'mono' ? ['#444444', '#777777', '#aaaaaa'] : ['#4a4038', '#8a7a68', '#c8b8a0', '#eee8dc'];
2255
+ for (let sy = groundY; sy >= Math.max(0, groundY - Math.floor(7 * blast)); sy -= 1) {
2256
+ const rise = groundY - sy;
2257
+ const spread = Math.floor(3 + rise * 2.4 * blast + Math.sin(pulse * 0.5 + rise) * 2);
2258
+ for (let dx = -spread; dx <= spread; dx += 1) {
2259
+ if (hashNoise(dx + 50, sy, Math.floor(pulse * 2)) < 0.32) {
2260
+ continue;
2261
+ }
2262
+ const t = 1 - Math.abs(dx) / Math.max(1, spread);
2263
+ const gi = Math.min(3, Math.floor(t * blast * 4));
2264
+ paintCell(grid, engineX + dx, sy, ['░', '▒', '▓', '█'][gi] ?? '░', smokeColors[Math.min(smokeColors.length - 1, gi)] ?? accent);
2265
+ }
2266
+ }
2267
+ }
2268
+ // Exhaust plume always under the rocket (in frame)
2269
+ const flameColors = theme === 'mono'
2270
+ ? ['#555555', '#888888', '#bbbbbb', '#ffffff']
2271
+ : ['#5a1a08', '#ff3a10', '#ff8a14', '#ffd040', '#ffffff'];
2272
+ const plumeLen = Math.min(height - rocketBottom, 5 + Math.floor(3 + Math.sin(pulse * 0.9) * 2) + (highAltitude ? 1 : 3));
2273
+ for (let f = 0; f < plumeLen; f += 1) {
2274
+ const along = f / Math.max(1, plumeLen - 1);
2275
+ const billow = along < 0.35 ? along * 2.5 : 0.9 + Math.sin(along * 4 + pulse) * 0.15;
2276
+ const spread = Math.max(1, Math.floor((1.2 + billow * 3.2 + Math.sin(pulse * 1.1 + f) * 1.1) * (highAltitude ? 0.7 : 1)));
2277
+ for (let dx = -spread; dx <= spread; dx += 1) {
2278
+ const edge = Math.abs(dx) / Math.max(1, spread);
2279
+ const flicker = hashNoise(dx + 30, f, Math.floor(pulse * 3));
2280
+ if (flicker < edge * 0.35) {
2281
+ continue;
2282
+ }
2283
+ const heat = clampNumber((1 - edge) * (1 - along * 0.55) * (0.7 + flicker * 0.3), 0, 1);
2284
+ const glyph = heat > 0.75 ? '█' : heat > 0.5 ? '▓' : heat > 0.3 ? '▒' : '░';
2285
+ const ci = Math.min(flameColors.length - 1, Math.floor(heat * flameColors.length));
2286
+ const color = along > 0.55 && edge > 0.45
2287
+ ? theme === 'mono'
2288
+ ? '#666666'
2289
+ : '#8a7060'
2290
+ : flameColors[ci] ?? accent;
2291
+ const px = engineX + dx + Math.floor(Math.sin(pulse * 0.7 + f * 0.4) * (along * 1.5));
2292
+ const py = rocketBottom + f;
2293
+ if (py >= 0 && py < height) {
2294
+ paintCell(grid, px, py, glyph, color);
2295
+ }
2296
+ }
2297
+ }
2298
+ if (offPad && !highAltitude) {
2299
+ for (let d = 1; d <= 4; d += 1) {
2300
+ const dy = rocketBottom + d * 2;
2301
+ if (dy >= 0 && dy < height) {
2302
+ paintCell(grid, engineX, dy, '◆', '#ffffff');
2303
+ paintCell(grid, engineX - 1, dy, '·', flameColors[3] ?? accent);
2304
+ paintCell(grid, engineX + 1, dy, '·', flameColors[3] ?? accent);
2305
+ }
2306
+ }
2307
+ }
2308
+ // Rocket always drawn in frame
2309
+ const rocketTop = rocketBottom - rocket.length + 1;
2310
+ blitAsciiSprite(grid, rocket, bodyX, rocketTop, ramp[ramp.length - 1] ?? accent);
2311
+ if (rocketTop >= 0 && rocketTop < height) {
2312
+ paintCell(grid, engineX, rocketTop, '▲', '#ffffff');
2313
+ }
2314
+ return grid.map(row => lineFromCells(row, accent));
2315
+ }
2316
+ function buildNeuralNet(pulse, width, height, theme) {
2317
+ const canvas = createBraille(width, height);
2318
+ const ramp = themeContributionColors(theme);
2319
+ const accent = themeAccent(theme);
2320
+ const dotsWide = width * 2;
2321
+ const dotsHigh = height * 4;
2322
+ const layers = [4, 7, 7, 5, 3];
2323
+ const nodes = [];
2324
+ const marginX = dotsWide * 0.08;
2325
+ const usableX = dotsWide - marginX * 2;
2326
+ layers.forEach((count, layer) => {
2327
+ const x = marginX + (layer / Math.max(1, layers.length - 1)) * usableX;
2328
+ for (let i = 0; i < count; i += 1) {
2329
+ const y = dotsHigh * (0.12 + (i + 0.5) * (0.76 / count));
2330
+ nodes.push({ x, y, layer, index: i });
2331
+ }
2332
+ });
2333
+ const byLayer = layers.map((_, layer) => nodes.filter(n => n.layer === layer));
2334
+ for (let layer = 0; layer < byLayer.length - 1; layer += 1) {
2335
+ const a = byLayer[layer] ?? [];
2336
+ const b = byLayer[layer + 1] ?? [];
2337
+ for (const from of a) {
2338
+ for (const to of b) {
2339
+ const activity = (Math.sin(pulse * 0.07 + from.index * 0.7 + to.index * 0.5 + layer) + 1) / 2;
2340
+ if (activity < 0.35) {
2341
+ continue;
2342
+ }
2343
+ const color = ramp[Math.min(ramp.length - 1, 1 + Math.round(activity * (ramp.length - 2)))] ?? accent;
2344
+ drawBrailleLine(canvas, from.x, from.y, to.x, to.y, color);
2345
+ const t = ((pulse * 0.05 + from.index * 0.13 + to.index * 0.09 + layer * 0.2) % 1 + 1) % 1;
2346
+ const px = from.x + (to.x - from.x) * t;
2347
+ const py = from.y + (to.y - from.y) * t;
2348
+ brailleSet(canvas, px, py, '#ffffff');
2349
+ brailleSet(canvas, px + 1, py, '#ffffff');
2350
+ }
2351
+ }
2352
+ }
2353
+ for (const node of nodes) {
2354
+ const pulseNode = (Math.sin(pulse * 0.09 + node.layer + node.index) + 1) / 2;
2355
+ const color = pulseNode > 0.7 ? '#ffffff' : ramp[Math.min(ramp.length - 1, 2 + node.layer)] ?? accent;
2356
+ for (let dy = -1; dy <= 1; dy += 1) {
2357
+ for (let dx = -1; dx <= 1; dx += 1) {
2358
+ if (dx * dx + dy * dy <= 2) {
2359
+ brailleSet(canvas, node.x + dx, node.y + dy, color);
2360
+ }
2361
+ }
2362
+ }
2363
+ }
2364
+ return brailleToLines(canvas, accent);
2365
+ }
2366
+ const BAYER_4 = [
2367
+ [0, 8, 2, 10],
2368
+ [12, 4, 14, 6],
2369
+ [3, 11, 1, 9],
2370
+ [15, 7, 13, 5]
2371
+ ];
2372
+ function buildFlyover(pulse, width, height, theme) {
2373
+ const grid = emptyMotionGrid(width, height, '#04070c');
2374
+ const ramp = themeContributionColors(theme);
2375
+ const accent = themeAccent(theme);
2376
+ const scroll = pulse * 0.09;
2377
+ const asciiRamp = ' ·:;+=xX$&#@';
2378
+ for (let y = 0; y < height; y += 1) {
2379
+ const depth = (y + 1) / height;
2380
+ const perspective = 0.35 + depth * 1.8;
2381
+ for (let x = 0; x < width; x += 1) {
2382
+ const u = (x / Math.max(1, width - 1) - 0.5) * perspective * 6 + scroll * 0.4;
2383
+ const v = scroll + depth * 4;
2384
+ const h = 0.5 +
2385
+ 0.28 * Math.sin(u * 1.3 + v * 0.7) +
2386
+ 0.18 * Math.sin(u * 0.45 - v * 1.1) +
2387
+ 0.12 * Math.sin(u * 2.4 + v * 1.7 + pulse * 0.02);
2388
+ const ridge = Math.pow(clampNumber(h, 0, 1), 1.35);
2389
+ const fog = 1 - depth * 0.55;
2390
+ const value = clampNumber(ridge * fog, 0, 1);
2391
+ const threshold = ((BAYER_4[y & 3][x & 3] ?? 0) + 0.5) / 16;
2392
+ const dithered = value > threshold ? value : value * 0.35;
2393
+ if (dithered < 0.08) {
2394
+ continue;
2395
+ }
2396
+ const glyphIndex = Math.min(asciiRamp.length - 1, Math.floor(dithered * asciiRamp.length));
2397
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round(ridge * (ramp.length - 2)));
2398
+ paintCell(grid, x, y, asciiRamp[glyphIndex] ?? '·', ramp[colorIndex] ?? accent);
2399
+ }
2400
+ }
2401
+ return grid.map(row => lineFromCells(row, accent));
2402
+ }
2403
+ /** Downtown night drive — parallax towers, window grids, streetlamps and passing traffic. */
2404
+ function buildSkyline(pulse, width, height, theme) {
2405
+ const accent = themeAccent(theme);
2406
+ const P = (color, mono) => artColor(theme, color, mono);
2407
+ const skyTop = P('#040610', '#0a0a0e');
2408
+ const skyMid = P('#0b1128', '#14141c');
2409
+ const skyGlow = P('#3c2a1e', '#2a2a30');
2410
+ const farBody = P('#131c33', '#202028');
2411
+ const farWindow = P('#4a5a7e', '#5c5c5c');
2412
+ const bodyA = P('#0d1428', '#17171e');
2413
+ const bodyB = P('#111a32', '#1d1d26');
2414
+ const bodyEdge = P('#25324e', '#3c3c46');
2415
+ const winWarm = P('#ffd166', '#e2e2e2');
2416
+ const winCool = P('#a9d4ff', '#c8c8c8');
2417
+ const winDim = P('#5a6f9e', '#8a8a8a');
2418
+ const shopGlow = P('#ffb45c', '#d4d4d4');
2419
+ const lampWarm = P('#ffe9b0', '#f0f0f0');
2420
+ const moonTint = P('#e8ecf8', '#e8e8e8');
2421
+ const starTint = P('#9fb2e8', '#8a8a8a');
2422
+ const curbBg = P('#1a2030', '#26262e');
2423
+ const roadBg = P('#090b12', '#101014');
2424
+ const dashPaint = P('#c8b06a', '#9a9a9a');
2425
+ const headlight = P('#fff6dc', '#f8f8f8');
2426
+ const taillight = P('#ff4455', '#b8b8b8');
2427
+ const carBody = P('#232b3e', '#33333c');
2428
+ const beacon = P('#ff5f87', '#dcdcdc');
2429
+ const tankWood = P('#6b5340', '#585858');
2430
+ const grid = emptyMotionGrid(width, height, skyTop);
2431
+ const sidewalkY = height - 2;
2432
+ const ground = sidewalkY;
2433
+ // Advance the rigid city and road exactly one cell on every Skyline frame.
2434
+ // A style-specific timer controls the actual speed; keeping positions
2435
+ // integral here eliminates duplicate frames and fractional-rounding jumps.
2436
+ const cityScroll = pulse;
2437
+ const starScroll = Math.floor(pulse / 4);
2438
+ const roadScroll = cityScroll;
2439
+ const trafficScroll = pulse;
2440
+ const worldPeriod = Math.max(80, width * 3);
2441
+ // Night-sky gradient sinking into a sodium glow on the roofline.
2442
+ for (let y = 0; y < sidewalkY; y += 1) {
2443
+ const t = y / Math.max(1, sidewalkY - 1);
2444
+ const bg = t < 0.6 ? interpolateHex(skyTop, skyMid, t / 0.6) : interpolateHex(skyMid, skyGlow, (t - 0.6) / 0.4);
2445
+ for (let x = 0; x < width; x += 1) {
2446
+ sceneCell(grid, x, y, ' ', bg, bg);
2447
+ }
2448
+ }
2449
+ // Parallax stars and a crescent moon drift slower than the towers.
2450
+ for (let s = 0; s < Math.floor(width / 2); s += 1) {
2451
+ const sx = (Math.floor(hashNoise(s, 4, 0) * width * 2) - starScroll + width * 4) % (width + 8) - 4;
2452
+ const sy = Math.floor(hashNoise(s, 5, 0) * Math.max(1, sidewalkY * 0.45));
2453
+ if (sx >= 0 && sx < width && hashNoise(s, 6, 0) > 0.27) {
2454
+ sceneOverlay(grid, sx, sy, s % 5 === 0 ? '+' : '·', starTint);
2455
+ }
2456
+ }
2457
+ const moonX = Math.round(width * 0.84);
2458
+ const moonY = Math.max(1, Math.round(sidewalkY * 0.16));
2459
+ for (let dy = -1; dy <= 1; dy += 1) {
2460
+ for (let dx = -2; dx <= 1; dx += 1) {
2461
+ if (Math.abs(dx) + Math.abs(dy) * 2 > 2) {
2462
+ continue;
2463
+ }
2464
+ sceneOverlay(grid, moonX + dx, moonY + dy, dx === 1 ? '·' : '•', dx === 1 ? interpolateHex(moonTint, skyMid, 0.5) : moonTint);
2465
+ }
2466
+ }
2467
+ const farTowers = [];
2468
+ let farCursor = 0;
2469
+ let farId = 0;
2470
+ while (farCursor < worldPeriod + width + 20) {
2471
+ const w = 5 + Math.floor(hashNoise(farId, 41, 0) * 8);
2472
+ const h = 2 + Math.floor(hashNoise(farId, 42, 0) * Math.max(2, Math.round(sidewalkY * 0.5)));
2473
+ farTowers.push({ id: farId, x: farCursor, w, h });
2474
+ farCursor += w + 1 + Math.floor(hashNoise(farId, 43, 0) * 4);
2475
+ farId += 1;
2476
+ }
2477
+ for (const b of farTowers) {
2478
+ const farScroll = Math.floor(pulse / 2);
2479
+ let screenX = b.x - (farScroll % worldPeriod);
2480
+ if (screenX + b.w < -2) {
2481
+ screenX += worldPeriod;
2482
+ }
2483
+ if (screenX > width + 2) {
2484
+ continue;
2485
+ }
2486
+ const top = sidewalkY - 1 - b.h;
2487
+ for (let y = Math.max(0, top); y < sidewalkY; y += 1) {
2488
+ for (let dx = 0; dx < b.w; dx += 1) {
2489
+ const sx = Math.round(screenX + dx);
2490
+ if (sx < 0 || sx >= width) {
2491
+ continue;
2492
+ }
2493
+ sceneCell(grid, sx, y, ' ', farBody, farBody);
2494
+ }
2495
+ }
2496
+ for (let y = Math.max(0, top) + 1; y < sidewalkY - 1; y += 2) {
2497
+ for (let dx = 1; dx < b.w - 1; dx += 3) {
2498
+ const sx = Math.round(screenX + dx);
2499
+ if (sx >= 0 && sx < width && hashNoise(b.id * 3, y, dx) > 0.68) {
2500
+ sceneOverlay(grid, sx, y, '·', farWindow);
2501
+ }
2502
+ }
2503
+ }
2504
+ }
2505
+ // Near towers: setbacks, parapets, water tanks and blinking masts.
2506
+ const towers = [];
2507
+ let cursor = 0;
2508
+ let id = 0;
2509
+ while (cursor < worldPeriod + width + 20) {
2510
+ const w = 4 + Math.floor(hashNoise(id, 1, 0) * 7);
2511
+ const h = 3 + Math.floor(hashNoise(id, 2, 0) * Math.max(3, sidewalkY - 3));
2512
+ towers.push({ id, x: cursor, w, h });
2513
+ cursor += w + 1 + Math.floor(hashNoise(id, 3, 0) * 3);
2514
+ id += 1;
2515
+ }
2516
+ for (const b of towers) {
2517
+ let screenX = b.x - (cityScroll % worldPeriod);
2518
+ if (screenX + b.w < -2) {
2519
+ screenX += worldPeriod;
2520
+ }
2521
+ if (screenX > width + 2) {
2522
+ continue;
2523
+ }
2524
+ const top = ground - b.h;
2525
+ const body = b.id % 2 === 0 ? bodyA : bodyB;
2526
+ const inset = b.w >= 7 && hashNoise(b.id, 6, 0) > 0.5 ? 1 : 0;
2527
+ const shoulderY = top + Math.max(1, Math.round(b.h * 0.28));
2528
+ for (let y = Math.max(0, top); y < ground; y += 1) {
2529
+ for (let dx = 0; dx < b.w; dx += 1) {
2530
+ if (inset > 0 && y < shoulderY && (dx < inset || dx >= b.w - inset)) {
2531
+ continue;
2532
+ }
2533
+ const sx = Math.round(screenX + dx);
2534
+ if (sx < 0 || sx >= width) {
2535
+ continue;
2536
+ }
2537
+ const edge = dx === 0 || dx === b.w - 1 || y === Math.max(0, top);
2538
+ const fleck = hashNoise(b.id * 7 + dx, y * 3, 0);
2539
+ sceneCell(grid, sx, y, edge ? '█' : fleck > 0.88 ? '▒' : '▓', edge ? bodyEdge : body, body);
2540
+ }
2541
+ }
2542
+ // Office windows in a mullioned grid; a few flicker as tenants move.
2543
+ for (let y = Math.max(0, top) + 1; y < ground - 1; y += 1) {
2544
+ for (let dx = 1; dx < b.w - 1; dx += 2) {
2545
+ if (inset > 0 && y < shoulderY && (dx < inset || dx >= b.w - inset)) {
2546
+ continue;
2547
+ }
2548
+ const sx = Math.round(screenX + dx);
2549
+ if (sx < 0 || sx >= width) {
2550
+ continue;
2551
+ }
2552
+ const roll = hashNoise(b.id * 13 + dx, y * 5, 0);
2553
+ if (roll < 0.4) {
2554
+ continue;
2555
+ }
2556
+ const color = roll > 0.86 ? winCool : roll > 0.62 ? winWarm : winDim;
2557
+ sceneOverlay(grid, sx, y, roll > 0.7 ? '▀' : '·', color);
2558
+ }
2559
+ }
2560
+ // Storefront glow spilling onto the sidewalk.
2561
+ for (let dx = 0; dx < b.w; dx += 1) {
2562
+ const sx = Math.round(screenX + dx);
2563
+ if (sx < 0 || sx >= width) {
2564
+ continue;
2565
+ }
2566
+ const lit = hashNoise(b.id, dx, 17) > 0.3 && dx % 2 === 0;
2567
+ sceneCell(grid, sx, ground - 1, lit ? '▀' : '▄', lit ? shopGlow : bodyEdge, curbBg);
2568
+ }
2569
+ // Rooftop furniture: parapet cap, water tank or blinking mast.
2570
+ if (top >= 1) {
2571
+ for (let dx = 0; dx < b.w; dx += 1) {
2572
+ const sx = Math.round(screenX + dx);
2573
+ if (sx >= 0 && sx < width && !(inset > 0 && (dx < inset || dx >= b.w - inset))) {
2574
+ sceneOverlay(grid, sx, top, '▀', bodyEdge);
2575
+ }
2576
+ }
2577
+ const tankRoll = hashNoise(b.id, 9, 0);
2578
+ const cx = Math.round(screenX + b.w / 2);
2579
+ if (tankRoll > 0.68 && b.w >= 5 && top >= 3) {
2580
+ sceneOverlay(grid, cx - 1, top - 1, '│', bodyEdge);
2581
+ sceneOverlay(grid, cx, top - 1, '│', bodyEdge);
2582
+ sceneOverlay(grid, cx - 1, top - 2, '▄', tankWood);
2583
+ sceneOverlay(grid, cx, top - 2, '▄', tankWood);
2584
+ sceneOverlay(grid, cx - 1, top - 3, '▀', tankWood);
2585
+ sceneOverlay(grid, cx, top - 3, '▀', tankWood);
2586
+ }
2587
+ else if (top >= 2) {
2588
+ sceneOverlay(grid, cx, top - 1, '│', bodyEdge);
2589
+ if ((Math.floor(pulse / 8) + b.id) % 2 === 0) {
2590
+ sceneOverlay(grid, cx, top - 2, '•', beacon);
2591
+ }
2592
+ }
2593
+ }
2594
+ }
2595
+ // Sidewalk and asphalt: the roadside blurs past quickest of all.
2596
+ for (let x = 0; x < width; x += 1) {
2597
+ sceneCell(grid, x, sidewalkY, ' ', curbBg, curbBg);
2598
+ const grit = hashNoise(x, 71, 0) > 0.72 ? '·' : ' ';
2599
+ sceneCell(grid, x, height - 1, grit, interpolateHex(roadBg, '#ffffff', 0.14), roadBg);
2600
+ if ((x + roadScroll) % 9 < 4) {
2601
+ sceneOverlay(grid, x, height - 1, '─', dashPaint);
2602
+ }
2603
+ }
2604
+ // Streetlamps pool their light between the towers and the road.
2605
+ for (let lx = -(roadScroll % 13); lx < width; lx += 13) {
2606
+ sceneOverlay(grid, lx, sidewalkY, '•', lampWarm, true);
2607
+ sceneOverlay(grid, lx, sidewalkY - 1, '·', interpolateHex(lampWarm, curbBg, 0.35));
2608
+ if (hashNoise(lx + roadScroll, 0, 0) > 0.5) {
2609
+ sceneOverlay(grid, lx, height - 1, '˙', interpolateHex(lampWarm, roadBg, 0.55));
2610
+ }
2611
+ }
2612
+ // Oncoming headlights sweep by; the car ahead keeps its red distance.
2613
+ const oncoming = width + 4 - (trafficScroll % (width + 10));
2614
+ sceneOverlay(grid, oncoming, sidewalkY, '•', headlight, true);
2615
+ sceneOverlay(grid, oncoming + 1, sidewalkY, '•', headlight, true);
2616
+ sceneOverlay(grid, oncoming + 2, sidewalkY, '█', carBody);
2617
+ sceneOverlay(grid, oncoming + 3, sidewalkY, '█', carBody);
2618
+ const oncomingTwo = width + 4 - ((trafficScroll + Math.round(width * 0.55)) % (width + 10));
2619
+ sceneOverlay(grid, oncomingTwo, sidewalkY, '•', headlight, true);
2620
+ sceneOverlay(grid, oncomingTwo + 1, sidewalkY, '█', carBody);
2621
+ const ahead = Math.round(width * 0.62);
2622
+ sceneOverlay(grid, ahead, sidewalkY, '•', taillight, true);
2623
+ sceneOverlay(grid, ahead + 1, sidewalkY, '•', taillight, true);
2624
+ sceneOverlay(grid, ahead + 2, sidewalkY, '█', carBody);
2625
+ return grid.map(row => lineFromCells(row, accent));
2626
+ }
2627
+ /** Scenic painters keep their own art palettes; the mono theme falls back to greys. */
2628
+ function artColor(theme, color, mono) {
2629
+ return theme === 'mono' ? mono : color;
2630
+ }
2631
+ /** Bounds-checked painter that writes a full cell (glyph + ink + paper). */
2632
+ function sceneCell(grid, x, y, text, color, backgroundColor, bold = false) {
2633
+ const row = grid[Math.round(y)];
2634
+ const cell = row?.[Math.round(x)];
2635
+ if (!row || !cell) {
2636
+ return;
2637
+ }
2638
+ row[Math.round(x)] = bold ? { text, color, backgroundColor, bold } : { text, color, backgroundColor };
2639
+ }
2640
+ /** Bounds-checked painter that keeps the paper already underneath the mark. */
2641
+ function sceneOverlay(grid, x, y, text, color, bold = false) {
2642
+ const row = grid[Math.round(y)];
2643
+ const cell = row?.[Math.round(x)];
2644
+ if (!row || !cell) {
2645
+ return;
2646
+ }
2647
+ row[Math.round(x)] = bold
2648
+ ? { text, color, backgroundColor: cell.backgroundColor, bold }
2649
+ : { text, color, backgroundColor: cell.backgroundColor };
2650
+ }
2651
+ /** Golden Gate at midday — stippled cumulus, international-orange span, shimmering bay. */
2652
+ function buildGoldenGate(pulse, width, height, theme) {
2653
+ const accent = themeAccent(theme);
2654
+ const P = (color, mono) => artColor(theme, color, mono);
2655
+ const skyTop = P('#0f3c8e', '#12121a');
2656
+ const skyMid = P('#4a90d8', '#34343e');
2657
+ const skyLow = P('#aad4f4', '#5c5c68');
2658
+ const cloudCore = P('#fff8e6', '#f2f2f2');
2659
+ const cloudMid = P('#f6e4bc', '#d2d2d2');
2660
+ const cloudShade = P('#d9c298', '#9e9e9e');
2661
+ const headland = P('#4c5c40', '#24242c');
2662
+ const bluff = P('#2e3f26', '#17171d');
2663
+ const bluffLight = P('#4c6234', '#2e2e36');
2664
+ const waterNear = P('#1a5a9e', '#26262e');
2665
+ const waterFar = P('#0d3160', '#121218');
2666
+ const glint = P('#d9ecfc', '#c4c4c4');
2667
+ const orange = P('#e0521f', '#7c7c7c');
2668
+ const orangeLight = P('#ff8148', '#b4b4b4');
2669
+ const orangeDark = P('#a83a12', '#4e4e4e');
2670
+ const sunCore = P('#fffdf4', '#ffffff');
2671
+ const sunHalo = P('#fff2c8', '#e8e8e8');
2672
+ const fogTint = P('#e8ecf2', '#c8c8c8');
2673
+ const carWhite = P('#e8e8e8', '#d0d0d0');
2674
+ const carRed = P('#c8352a', '#6a6a6a');
2675
+ const carBlue = P('#2a4a7c', '#4a4a4a');
2676
+ const grid = emptyMotionGrid(width, height, skyTop);
2677
+ const horizon = Math.max(2, Math.round(height * 0.58));
2678
+ const deckY = horizon;
2679
+ const towerTopY = Math.max(1, Math.round(height * 0.15));
2680
+ const towerAX = Math.round(width * 0.26);
2681
+ const towerBX = Math.round(width * 0.64);
2682
+ const waterStart = Math.min(height - 1, deckY + 1);
2683
+ // Sky wash with a fine offset dot grain.
2684
+ const skyBg = [];
2685
+ for (let y = 0; y < horizon; y += 1) {
2686
+ const t = y / Math.max(1, horizon - 1);
2687
+ const bg = t < 0.55 ? interpolateHex(skyTop, skyMid, t / 0.55) : interpolateHex(skyMid, skyLow, (t - 0.55) / 0.45);
2688
+ skyBg.push(bg);
2689
+ for (let x = 0; x < width; x += 1) {
2690
+ const dot = (x + y) % 2 === 0 && hashNoise(x * 0.9, y * 1.7, 0) > 0.66;
2691
+ sceneCell(grid, x, y, dot ? '˙' : ' ', dot ? interpolateHex(bg, '#ffffff', 0.3) : bg, bg);
2692
+ }
2693
+ }
2694
+ // High afternoon sun with a soft halo over the ocean side.
2695
+ const sunX = Math.round(width * 0.8);
2696
+ const sunY = Math.max(1, Math.round(horizon * 0.22));
2697
+ const sunR = Math.max(1.1, height * 0.08);
2698
+ for (let y = Math.floor(sunY - sunR * 2.2); y <= Math.ceil(sunY + sunR * 2.2); y += 1) {
2699
+ if (y < 0 || y >= horizon) {
2700
+ continue;
2701
+ }
2702
+ for (let x = Math.floor(sunX - sunR * 2); x <= Math.ceil(sunX + sunR * 2); x += 1) {
2703
+ if (x < 0 || x >= width) {
2704
+ continue;
2705
+ }
2706
+ const dx = (x - sunX) / 1.9;
2707
+ const dy = y - sunY;
2708
+ const d = Math.sqrt(dx * dx + dy * dy);
2709
+ if (d <= sunR) {
2710
+ sceneCell(grid, x, y, d < sunR * 0.62 ? '●' : '•', d < sunR * 0.55 ? sunCore : sunHalo, skyBg[y], d < sunR * 0.5);
2711
+ }
2712
+ else if (d <= sunR * 1.9 && hashNoise(x, y, 0) > 0.84) {
2713
+ sceneOverlay(grid, x, y, '˙', sunHalo);
2714
+ }
2715
+ }
2716
+ }
2717
+ // Slow-drifting stippled cumulus with shaded bellies.
2718
+ const clouds = [
2719
+ { cx: 0.14, cy: 0.18, rx: 0.11, ry: 0.2, seed: 5 },
2720
+ { cx: 0.42, cy: 0.1, rx: 0.15, ry: 0.16, seed: 9 },
2721
+ { cx: 0.72, cy: 0.26, rx: 0.1, ry: 0.18, seed: 13 },
2722
+ { cx: 0.93, cy: 0.08, rx: 0.09, ry: 0.13, seed: 19 }
2723
+ ];
2724
+ for (const cloud of clouds) {
2725
+ const drift = ((cloud.cx + pulse * 0.0016) % 1.3) - 0.15;
2726
+ const cx = drift * width;
2727
+ const cy = cloud.cy * horizon;
2728
+ const rx = Math.max(3, cloud.rx * width);
2729
+ const ry = Math.max(1.2, cloud.ry * horizon);
2730
+ for (let y = Math.floor(cy - ry); y <= Math.ceil(cy + ry); y += 1) {
2731
+ if (y < 0 || y >= horizon) {
2732
+ continue;
2733
+ }
2734
+ for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x += 1) {
2735
+ if (x < 0 || x >= width) {
2736
+ continue;
2737
+ }
2738
+ const dx = (x - cx) / rx;
2739
+ const dy = (y - cy) / ry;
2740
+ const d = dx * dx + dy * dy * 1.3;
2741
+ if (d > 1) {
2742
+ continue;
2743
+ }
2744
+ const fluff = (1 - d) * (0.5 + 0.5 * hashNoise(x * 1.9 + cloud.seed, y * 2.7, cloud.seed));
2745
+ if (fluff < 0.3) {
2746
+ continue;
2747
+ }
2748
+ const shade = dy > 0.32;
2749
+ sceneCell(grid, x, y, fluff > 0.64 ? '●' : fluff > 0.44 ? '•' : '·', shade ? cloudShade : fluff > 0.6 ? cloudCore : cloudMid, skyBg[y], fluff > 0.8);
2750
+ }
2751
+ }
2752
+ }
2753
+ // Tiny gulls high above the strait, bobbing on the wind.
2754
+ for (let b = 0; b < 4; b += 1) {
2755
+ const bx = Math.floor((hashNoise(b, 21, 0) * width + pulse * 0.06) % width);
2756
+ const by = Math.floor(hashNoise(b, 22, 0) * Math.max(1, horizon * 0.45)) + (Math.sin(pulse * 0.18 + b * 2.2) > 0.55 ? 1 : 0);
2757
+ sceneOverlay(grid, bx, by, '~', P('#2c3c58', '#686868'));
2758
+ }
2759
+ // Distant Marin ridgeline just above the horizon.
2760
+ for (let x = 0; x < width; x += 1) {
2761
+ const lift = 0.5 + 0.5 * Math.sin((x / Math.max(1, width)) * 7.3 + 1.2);
2762
+ const ridge = horizon - 1 - Math.round(lift * height * 0.04);
2763
+ sceneCell(grid, x, ridge, '░', headland, skyBg[ridge]);
2764
+ if (ridge + 1 < horizon) {
2765
+ sceneCell(grid, x, ridge + 1, '▒', headland, headland);
2766
+ }
2767
+ }
2768
+ // Bay water: gradient body, tidal streaks, lapping glints, whitecap flecks.
2769
+ for (let y = waterStart; y < height; y += 1) {
2770
+ const depth = (y - waterStart) / Math.max(1, height - 1 - waterStart);
2771
+ const bg = interpolateHex(waterNear, waterFar, depth);
2772
+ for (let x = 0; x < width; x += 1) {
2773
+ const lap = hashNoise(x * 0.6, y * 3.1, Math.floor(pulse / 3));
2774
+ const still = hashNoise(x * 1.7, y * 2.9, 0);
2775
+ const streak = depth < 0.4 && hashNoise(x * 0.35, y * 6.3, Math.floor(pulse / 4)) > 0.94;
2776
+ const text = streak ? '~' : lap > 0.9 ? '·' : still > 0.86 ? '˙' : ' ';
2777
+ const color = lap > 0.965 ? glint : streak ? interpolateHex(glint, bg, 0.35) : interpolateHex(glint, bg, 0.55);
2778
+ sceneCell(grid, x, y, text, color, bg);
2779
+ }
2780
+ }
2781
+ // Sunlight glitter lane wobbling across the channel.
2782
+ for (let y = waterStart; y < height; y += 1) {
2783
+ const depth = (y - waterStart) / Math.max(1, height - 1 - waterStart);
2784
+ const laneX = Math.round(sunX + Math.sin(y * 0.9 + pulse * 0.22) * (1 + depth * 2));
2785
+ for (let dx = -1; dx <= 1; dx += 1) {
2786
+ if (hashNoise(laneX + dx, y * 3.7, Math.floor(pulse / 2)) > 0.52) {
2787
+ sceneOverlay(grid, laneX + dx, y, hashNoise(laneX + dx, y, 3) > 0.5 ? '·' : '˙', interpolateHex(glint, waterFar, depth * 0.35));
2788
+ }
2789
+ }
2790
+ }
2791
+ // Foreground bluff on the left anchorage side.
2792
+ const bluffW = Math.max(3, Math.round(width * 0.09));
2793
+ for (let x = 0; x < bluffW; x += 1) {
2794
+ const falloff = x / bluffW;
2795
+ const ridge = Math.round(height * (0.68 + falloff * 0.22) + hashNoise(x, 3, 0) * 1.5);
2796
+ for (let y = Math.max(waterStart, ridge); y < height; y += 1) {
2797
+ const fleck = hashNoise(x * 1.9, y * 2.3, 0);
2798
+ sceneCell(grid, x, y, fleck > 0.62 ? '▒' : fleck > 0.3 ? '░' : ' ', fleck > 0.62 ? bluffLight : bluff, bluff);
2799
+ }
2800
+ }
2801
+ // Fort Point crouched at the waterline under the south anchorage.
2802
+ const fortBrick = P('#7a5a44', '#4a4a4a');
2803
+ const fortRoof = P('#3a2c22', '#2c2c2c');
2804
+ const fortX = Math.min(bluffW + 1, Math.max(1, width - 8));
2805
+ sceneCell(grid, fortX, waterStart, '█', fortBrick, fortRoof);
2806
+ sceneCell(grid, fortX + 1, waterStart, '░', P('#d9c8a8', '#9a9a9a'), fortBrick);
2807
+ sceneCell(grid, fortX + 2, waterStart, '█', fortBrick, fortRoof);
2808
+ // Broken tower reflections trembling in the water.
2809
+ for (const tx of [towerAX, towerBX]) {
2810
+ for (let y = waterStart + 1; y < height; y += 1) {
2811
+ const depth = (y - waterStart) / Math.max(1, height - 1 - waterStart);
2812
+ for (let dx = -2; dx <= 3; dx += 1) {
2813
+ const shimmerX = tx + dx + (hashNoise(y, dx * 7, 15) > 0.5 ? 1 : 0);
2814
+ if (hashNoise(shimmerX * 2.9, y * 1.3, Math.floor(pulse / 4)) > 0.5 + depth * 0.25) {
2815
+ sceneOverlay(grid, shimmerX, y, depth > 0.45 ? '·' : '•', depth > 0.45 ? orangeDark : orange);
2816
+ }
2817
+ }
2818
+ }
2819
+ }
2820
+ // Main cables: straight backstays, parabolic main span, dotted suspenders.
2821
+ const sagBottom = deckY - 2;
2822
+ const mainSpan = Math.max(1, towerBX - towerAX);
2823
+ const cableY = (x) => {
2824
+ if (x < towerAX) {
2825
+ return deckY - 1 + (towerTopY - (deckY - 1)) * clampNumber(x / Math.max(1, towerAX), 0, 1);
2826
+ }
2827
+ if (x > towerBX) {
2828
+ return towerTopY + (deckY - 1 - towerTopY) * clampNumber((x - towerBX) / Math.max(1, width - 1 - towerBX), 0, 1);
2829
+ }
2830
+ const t = (x - towerAX) / mainSpan;
2831
+ return towerTopY + (sagBottom - towerTopY) * 4 * t * (1 - t);
2832
+ };
2833
+ for (let x = 0; x < width; x += 1) {
2834
+ sceneOverlay(grid, x, cableY(x), '•', orangeDark);
2835
+ }
2836
+ for (let x = 2; x < width - 1; x += 2) {
2837
+ const top = Math.round(cableY(x));
2838
+ for (let y = top + 1; y < deckY; y += 1) {
2839
+ sceneOverlay(grid, x, y, '│', orangeDark);
2840
+ }
2841
+ }
2842
+ // Deck with a sunlit top chord.
2843
+ for (let x = 0; x < width; x += 1) {
2844
+ sceneCell(grid, x, deckY, '▀', orangeLight, orange);
2845
+ }
2846
+ // Commuter traffic humming across the deck in both directions.
2847
+ for (let i = 0; i < Math.max(2, Math.floor(width / 26)); i += 1) {
2848
+ const east = Math.round((pulse * 0.7 + i * 29) % (width + 4)) - 2;
2849
+ const west = Math.round(width + 1 - ((pulse * 0.55 + i * 23) % (width + 4)));
2850
+ const eastPaint = [carWhite, carRed, carBlue][i % 3] ?? carWhite;
2851
+ sceneCell(grid, east, deckY, '▀', eastPaint, orangeDark);
2852
+ sceneCell(grid, west, deckY, '▀', i % 2 === 0 ? carRed : carWhite, orangeDark);
2853
+ }
2854
+ // Art-deco towers: tapered saddles, portal struts, woven brace under the deck.
2855
+ for (const tx of [towerAX, towerBX]) {
2856
+ const legL = tx - 3;
2857
+ const legR = tx + 1;
2858
+ const base = Math.min(height - 1, waterStart + 2);
2859
+ const taperRows = Math.max(1, Math.round((deckY - towerTopY) * 0.18));
2860
+ for (let y = towerTopY; y <= base; y += 1) {
2861
+ const legs = y < towerTopY + taperRows ? [legL, legR + 1] : [legL, legL + 1, legR, legR + 1];
2862
+ for (const lx of legs) {
2863
+ const edge = lx === legL || lx === legR + 1;
2864
+ sceneCell(grid, lx, y, '█', edge ? orangeLight : orange, orangeDark);
2865
+ }
2866
+ if (y > deckY) {
2867
+ const weave = (y - deckY) % 3;
2868
+ if (weave !== 2) {
2869
+ sceneCell(grid, legL + 2 + weave, y, weave === 0 ? '╲' : '╱', orange, orangeDark);
2870
+ sceneCell(grid, legR - 1 - weave, y, weave === 0 ? '╱' : '╲', orange, orangeDark);
2871
+ }
2872
+ }
2873
+ }
2874
+ for (let s = 0; s < 4; s += 1) {
2875
+ const sy = towerTopY + Math.round(((deckY - towerTopY) * s) / 3);
2876
+ for (let x = legL; x <= legR + 1; x += 1) {
2877
+ sceneCell(grid, x, sy, '█', orangeLight, orangeDark);
2878
+ }
2879
+ }
2880
+ sceneOverlay(grid, legL, towerTopY - 1, '▀', orangeLight);
2881
+ sceneOverlay(grid, legR, towerTopY - 1, '▀', orangeLight);
2882
+ if (towerTopY >= 2 && Math.sin(pulse * 0.3 + tx) > 0) {
2883
+ sceneOverlay(grid, legL, towerTopY - 2, '•', P('#ff5566', '#ffffff'));
2884
+ }
2885
+ }
2886
+ // A lone sloop heeling across the strait, wake trailing astern.
2887
+ const sail = P('#f4f2e8', '#e2e2e2');
2888
+ const hullWood = P('#3a3a34', '#4c4c4c');
2889
+ const boatX = 2 + Math.floor((width * 0.4 + pulse * 0.11) % Math.max(1, width - 6));
2890
+ const boatY = Math.min(height - 2, waterStart + 1);
2891
+ sceneOverlay(grid, boatX, boatY - 1, '│', hullWood);
2892
+ sceneOverlay(grid, boatX - 1, boatY, '▲', sail);
2893
+ sceneOverlay(grid, boatX + 1, boatY, '▲', sail);
2894
+ sceneOverlay(grid, boatX, boatY, '│', hullWood);
2895
+ sceneOverlay(grid, boatX - 1, boatY + 1, '▄', hullWood);
2896
+ sceneOverlay(grid, boatX, boatY + 1, '▄', hullWood);
2897
+ sceneOverlay(grid, boatX + 1, boatY + 1, '▄', hullWood);
2898
+ sceneOverlay(grid, boatX - 2, boatY + 1, '·', glint);
2899
+ sceneOverlay(grid, boatX - 3, boatY + 1, '˙', interpolateHex(glint, waterFar, 0.4));
2900
+ // Karl the Fog spilling through the Gate while the towers poke clear.
2901
+ const fogDrift = Math.floor(pulse * 0.15);
2902
+ const fogBase = towerTopY + Math.max(2, Math.round((deckY - towerTopY) * 0.55));
2903
+ for (let y = fogBase; y <= Math.min(height - 1, waterStart + 1); y += 1) {
2904
+ for (let x = 0; x < width; x += 1) {
2905
+ const bank = hashNoise((x - fogDrift) * 0.16, y * 2.3, 5);
2906
+ const shred = hashNoise((x - fogDrift) * 0.55, y * 4.1, 0);
2907
+ if (x < width * (0.3 + bank * 0.45) && shred > 0.58 && bank > 0.42) {
2908
+ sceneOverlay(grid, x, y, shred > 0.8 ? '░' : '·', fogTint);
2909
+ }
2910
+ }
2911
+ }
2912
+ return grid.map(row => lineFromCells(row, accent));
2913
+ }
2914
+ /** Manhattan at night — landmark silhouettes, lit windows, Hudson reflections. */
2915
+ function buildManhattan(pulse, width, height, theme) {
2916
+ const accent = themeAccent(theme);
2917
+ const P = (color, mono) => artColor(theme, color, mono);
2918
+ const skyTop = P('#030510', '#08080c');
2919
+ const skyMid = P('#0a1534', '#15151e');
2920
+ const skyGlow = P('#2c2450', '#262630');
2921
+ const starDim = P('#7a8ab8', '#5c5c5c');
2922
+ const starBright = P('#dce6ff', '#eaeaea');
2923
+ const body = P('#0b1128', '#111116');
2924
+ const bodyAlt = P('#0e1732', '#191920');
2925
+ const bodyFleck = P('#1a2444', '#2b2b34');
2926
+ const farBody = P('#111a35', '#202028');
2927
+ const facadeLine = P('#26375c', '#4a4a52');
2928
+ const crownLight = P('#edf3ff', '#ffffff');
2929
+ const winWarm = P('#ffd166', '#e2e2e2');
2930
+ const winCool = P('#a9d4ff', '#bebebe');
2931
+ const winDim = P('#5a6f9e', '#787878');
2932
+ const waterTop = P('#060b1c', '#0d0d12');
2933
+ const waterDeep = P('#030612', '#07070a');
2934
+ const beacon = P('#ff4455', '#ffffff');
2935
+ const silver = P('#c7d2e8', '#d8d8d8');
2936
+ const tankWood = P('#4a4038', '#3c3c3c');
2937
+ const shopGlow = P('#ffb45c', '#d0d0d0');
2938
+ const headlight = P('#fff6dc', '#f4f4f4');
2939
+ const taillight = P('#ff4455', '#a8a8a8');
2940
+ const ferryHull = P('#1c2a24', '#242428');
2941
+ const grid = emptyMotionGrid(width, height, skyTop);
2942
+ const ground = Math.max(3, Math.round(height * 0.78));
2943
+ // Night sky gradient with a warm city glow at the roofline.
2944
+ const skyBg = [];
2945
+ for (let y = 0; y < ground; y += 1) {
2946
+ const t = y / Math.max(1, ground - 1);
2947
+ const bg = t < 0.62 ? interpolateHex(skyTop, skyMid, t / 0.62) : interpolateHex(skyMid, skyGlow, (t - 0.62) / 0.38);
2948
+ skyBg.push(bg);
2949
+ for (let x = 0; x < width; x += 1) {
2950
+ sceneCell(grid, x, y, ' ', bg, bg);
2951
+ }
2952
+ }
2953
+ const skyAt = (y) => skyBg[clampNumber(Math.round(y), 0, skyBg.length - 1)] ?? skyMid;
2954
+ // Sparse stars with a slow twinkle.
2955
+ const starCount = Math.floor(width * ground * 0.026);
2956
+ for (let s = 0; s < starCount; s += 1) {
2957
+ const sx = Math.floor(hashNoise(s, 1, 0) * width);
2958
+ const sy = Math.floor(hashNoise(s, 2, 0) * ground * 0.7);
2959
+ const twinkle = 0.5 + 0.5 * Math.sin(pulse * (0.05 + hashNoise(s, 3, 0) * 0.12) + s * 1.7);
2960
+ if (twinkle < 0.42) {
2961
+ continue;
2962
+ }
2963
+ const bright = hashNoise(s, 4, 0) * twinkle;
2964
+ sceneOverlay(grid, sx, sy, bright > 0.72 ? '•' : bright > 0.5 ? '·' : '˙', bright > 0.6 ? starBright : starDim);
2965
+ }
2966
+ // Crescent moon with a faint dotted halo.
2967
+ const moonX = Math.round(width * 0.84);
2968
+ const moonY = Math.round(ground * 0.2);
2969
+ const moonR = Math.max(1.2, height * 0.07);
2970
+ for (let y = Math.floor(moonY - moonR * 2.4); y <= Math.ceil(moonY + moonR * 1.4); y += 1) {
2971
+ for (let x = Math.floor(moonX - moonR * 2.2); x <= Math.ceil(moonX + moonR * 2.2); x += 1) {
2972
+ const dx = (x - moonX) / 1.9;
2973
+ const dy = y - moonY;
2974
+ const d = Math.sqrt(dx * dx + dy * dy);
2975
+ const shadow = Math.sqrt((dx - moonR * 0.5) ** 2 + dy * dy);
2976
+ if (d <= moonR && shadow > moonR * 0.9) {
2977
+ sceneCell(grid, x, y, d < moonR * 0.62 ? '●' : '•', P('#e8ecf8', '#f0f0f0'), skyAt(y), d < moonR * 0.5);
2978
+ }
2979
+ else if (d > moonR && d <= moonR * 2.1 && hashNoise(x, y, 0) > 0.78) {
2980
+ sceneOverlay(grid, x, y, '˙', P('#8a94b8', '#6a6a6a'));
2981
+ }
2982
+ }
2983
+ }
2984
+ // A distant borough layer gives the island depth behind the hero towers.
2985
+ let farX = 0;
2986
+ let farId = 0;
2987
+ while (farX < width) {
2988
+ const farW = 2 + Math.floor(hashNoise(farId, 121, 0) * 5);
2989
+ const farH = 2 + Math.floor(hashNoise(farId, 122, 0) * Math.max(2, ground * 0.34));
2990
+ const farTop = ground - farH;
2991
+ for (let y = Math.max(1, farTop); y < ground; y += 1) {
2992
+ for (let x = farX; x < Math.min(width, farX + farW); x += 1) {
2993
+ const edge = x === farX || x === farX + farW - 1;
2994
+ sceneCell(grid, x, y, edge ? '░' : ' ', farBody, farBody);
2995
+ }
2996
+ }
2997
+ for (let y = farTop + 1; y < ground - 1; y += 2) {
2998
+ for (let x = farX + 1; x < Math.min(width, farX + farW - 1); x += 2) {
2999
+ if (hashNoise(farId, x, y) > 0.52) {
3000
+ sceneOverlay(grid, x, y, '·', winDim);
3001
+ }
3002
+ }
3003
+ }
3004
+ farX += farW + 1;
3005
+ farId += 1;
3006
+ }
3007
+ const landmarks = [
3008
+ { cx: Math.round(width * 0.28), w: Math.max(9, Math.round(width * 0.1)), top: Math.max(3, Math.round(height * 0.16)), kind: 'empire' },
3009
+ { cx: Math.round(width * 0.5), w: Math.max(7, Math.round(width * 0.07)), top: Math.max(3, Math.round(height * 0.21)), kind: 'chrysler' },
3010
+ { cx: Math.round(width * 0.72), w: Math.max(8, Math.round(width * 0.085)), top: Math.max(2, Math.round(height * 0.12)), kind: 'wtc' }
3011
+ ];
3012
+ const blocks = landmarks.map(l => ({ x: Math.max(0, l.cx - Math.floor(l.w / 2)), w: l.w, top: l.top, seed: l.cx, kind: l.kind }));
3013
+ const reserved = (x, w) => blocks.some(b => x < b.x + b.w + 1 && x + w > b.x - 1);
3014
+ let cursor = 0;
3015
+ let slabId = 0;
3016
+ while (cursor < width) {
3017
+ const w = 3 + Math.floor(hashNoise(slabId, 7, 0) * 6);
3018
+ if (!reserved(cursor, w)) {
3019
+ const tallest = Math.max(2, ground - 4);
3020
+ const h = 2 + Math.floor(hashNoise(slabId, 11, 0) * tallest * 0.7);
3021
+ blocks.push({ x: cursor, w: Math.min(w, width - cursor), top: ground - 1 - h, seed: slabId + 100, kind: 'slab' });
3022
+ }
3023
+ cursor += w + (hashNoise(slabId, 13, 0) > 0.68 ? 2 : 1);
3024
+ slabId += 1;
3025
+ }
3026
+ const reflections = [];
3027
+ const paintWindows = (b, litRatio, coolBias) => {
3028
+ for (let y = b.top + 1; y < ground; y += 1) {
3029
+ for (let x = b.x + 1; x < Math.min(width - 1, b.x + b.w - 1); x += 2) {
3030
+ const roll = hashNoise(x * 3 + b.seed * 17, y * 7, 0);
3031
+ if (roll >= litRatio) {
3032
+ continue;
3033
+ }
3034
+ const tone = hashNoise(x * 5, y * 3, b.seed);
3035
+ const color = tone < coolBias ? winCool : tone < 0.86 ? winWarm : winDim;
3036
+ sceneOverlay(grid, x, y, tone > 0.86 ? '▪' : tone < 0.3 ? '•' : '·', color);
3037
+ if (hashNoise(x, y, 45) > 0.5) {
3038
+ reflections.push({ x, color });
3039
+ }
3040
+ }
3041
+ }
3042
+ };
3043
+ const carveSky = (x, y) => {
3044
+ sceneCell(grid, x, y, ' ', skyAt(y), skyAt(y));
3045
+ };
3046
+ for (const b of blocks) {
3047
+ const bodyBg = hashNoise(b.seed, 5, 0) > 0.5 ? body : bodyAlt;
3048
+ for (let y = b.top; y < ground; y += 1) {
3049
+ for (let x = b.x; x < Math.min(width, b.x + b.w); x += 1) {
3050
+ const fleck = hashNoise(x * 2.3, y * 3.1, b.seed);
3051
+ const edge = x === b.x || x === b.x + b.w - 1;
3052
+ const roof = y === b.top;
3053
+ const mullion = !edge && (x - b.x) % 4 === 0;
3054
+ const glyph = roof ? '▀' : edge ? '█' : mullion ? '│' : fleck > 0.84 ? '░' : ' ';
3055
+ const color = roof || edge || mullion ? facadeLine : fleck > 0.84 ? bodyFleck : bodyBg;
3056
+ sceneCell(grid, x, y, glyph, color, bodyBg);
3057
+ }
3058
+ }
3059
+ // Narrow masonry cornices break the facades into believable floor groups.
3060
+ for (let y = b.top + 3; y < ground - 1; y += 3) {
3061
+ for (let x = b.x + 1; x < Math.min(width, b.x + b.w - 1); x += 1) {
3062
+ if ((x - b.x) % 3 !== 0) {
3063
+ sceneOverlay(grid, x, y, '▄', interpolateHex(facadeLine, bodyBg, 0.45));
3064
+ }
3065
+ }
3066
+ }
3067
+ // Shoulder setbacks give the wider slabs a stepped profile.
3068
+ if (b.kind === 'slab' && b.w >= 8) {
3069
+ for (let y = b.top; y < Math.min(ground, b.top + 2); y += 1) {
3070
+ carveSky(b.x, y);
3071
+ carveSky(b.x + b.w - 1, y);
3072
+ }
3073
+ }
3074
+ paintWindows(b, b.kind === 'slab' ? 0.34 : 0.42, b.kind === 'wtc' ? 0.62 : 0.2);
3075
+ // Street-level lobby glow washing the sidewalk.
3076
+ for (let x = b.x; x < Math.min(width, b.x + b.w); x += 1) {
3077
+ const glow = hashNoise(b.seed, x, 31) > 0.42;
3078
+ sceneCell(grid, x, ground - 1, glow && x % 2 === 0 ? '░' : ' ', glow ? shopGlow : bodyBg, bodyBg);
3079
+ }
3080
+ // Cedar water tanks and blinking masts on the roofline.
3081
+ if (b.kind === 'slab' && b.w >= 6 && hashNoise(b.seed, 21, 0) > 0.55 && b.top >= 3) {
3082
+ const tx = b.x + 1 + Math.floor(hashNoise(b.seed, 22, 0) * Math.max(1, b.w - 4));
3083
+ sceneOverlay(grid, tx, b.top - 1, '│', bodyFleck);
3084
+ sceneOverlay(grid, tx + 1, b.top - 1, '│', bodyFleck);
3085
+ sceneOverlay(grid, tx, b.top - 2, '▄', tankWood);
3086
+ sceneOverlay(grid, tx + 1, b.top - 2, '▄', tankWood);
3087
+ sceneOverlay(grid, tx, b.top - 3, '▀', interpolateHex(tankWood, '#ffffff', 0.15));
3088
+ sceneOverlay(grid, tx + 1, b.top - 3, '▀', interpolateHex(tankWood, '#ffffff', 0.15));
3089
+ }
3090
+ if (b.kind === 'slab' && b.w >= 4 && hashNoise(b.seed, 8, 0) > 0.55 && b.top > 1) {
3091
+ const ax = b.x + Math.floor(b.w / 2);
3092
+ sceneOverlay(grid, ax, b.top - 1, '│', bodyFleck);
3093
+ if (Math.sin(pulse * 0.15 + b.seed) > 0.4) {
3094
+ sceneOverlay(grid, ax, b.top - 1, '•', beacon);
3095
+ }
3096
+ }
3097
+ }
3098
+ // Landmark profiles, drawn over their slabs.
3099
+ for (const b of blocks) {
3100
+ const cxB = b.x + Math.floor(b.w / 2);
3101
+ if (b.kind === 'empire') {
3102
+ const h = ground - b.top;
3103
+ // Three setbacks stepping in toward the crown.
3104
+ const setbacks = [
3105
+ { until: 0.3, inset: 3 },
3106
+ { until: 0.55, inset: 2 },
3107
+ { until: 1, inset: 0 }
3108
+ ];
3109
+ let previousUntil = 0;
3110
+ for (const section of setbacks) {
3111
+ for (let y = b.top + Math.round(h * previousUntil); y < b.top + Math.round(h * section.until); y += 1) {
3112
+ for (let i = 0; i < section.inset; i += 1) {
3113
+ carveSky(b.x + i, y);
3114
+ carveSky(b.x + b.w - 1 - i, y);
3115
+ }
3116
+ }
3117
+ previousUntil = section.until;
3118
+ }
3119
+ // Floodlit crown: denser white windows over a pale wash.
3120
+ const crownBg = P('#8f9cc0', '#565660');
3121
+ for (let y = b.top; y < b.top + Math.round(h * 0.3); y += 1) {
3122
+ for (let x = b.x + 3; x <= b.x + b.w - 4; x += 1) {
3123
+ const lit = hashNoise(x * 7, y * 3, 0) > 0.42;
3124
+ sceneCell(grid, x, y, lit ? '•' : ' ', lit ? crownLight : crownBg, crownBg);
3125
+ }
3126
+ }
3127
+ const observationY = Math.min(ground - 2, b.top + Math.max(2, Math.round(h * 0.3)));
3128
+ for (let x = b.x + 1; x < b.x + b.w - 1; x += 1) {
3129
+ sceneOverlay(grid, x, observationY, '▀', x % 2 === 0 ? crownLight : silver);
3130
+ }
3131
+ sceneOverlay(grid, cxB, b.top - 1, '│', silver);
3132
+ sceneOverlay(grid, cxB, b.top - 2, '│', silver);
3133
+ sceneOverlay(grid, cxB, b.top - 3, '•', Math.sin(pulse * 0.3) > -0.2 ? beacon : P('#5a1a22', '#4a4a4a'));
3134
+ }
3135
+ if (b.kind === 'chrysler') {
3136
+ // Tiered steel crown arcs, then the needle.
3137
+ for (let arc = 0; arc < 4; arc += 1) {
3138
+ const y = b.top + arc;
3139
+ const inset = (3 - arc) <= 1 ? (3 - arc) : 2;
3140
+ for (let x = b.x; x < b.x + b.w; x += 1) {
3141
+ const edge = x - b.x;
3142
+ if (edge < inset || edge > b.w - 1 - inset) {
3143
+ carveSky(x, y);
3144
+ continue;
3145
+ }
3146
+ const centerDistance = Math.abs(x - cxB);
3147
+ const crownGlyph = centerDistance === 0 ? '◆' : x < cxB ? '╱' : '╲';
3148
+ sceneCell(grid, x, y, crownGlyph, arc < 2 ? crownLight : silver, body);
3149
+ }
3150
+ }
3151
+ for (let y = b.top + 4; y < Math.min(ground, b.top + 7); y += 1) {
3152
+ sceneOverlay(grid, cxB, y, '│', silver);
3153
+ }
3154
+ sceneOverlay(grid, cxB, b.top - 1, '│', silver);
3155
+ sceneOverlay(grid, cxB, b.top - 2, '│', silver);
3156
+ sceneOverlay(grid, cxB, b.top - 3, '•', P('#f2f6ff', '#ffffff'));
3157
+ }
3158
+ if (b.kind === 'wtc') {
3159
+ const h = ground - b.top;
3160
+ // Chamfered taper plus glass pinstripes and a lit parapet.
3161
+ for (let y = b.top; y < ground; y += 1) {
3162
+ const t = (y - b.top) / Math.max(1, h);
3163
+ const inset = Math.round((1 - t) * 2.2);
3164
+ for (let i = 0; i < inset; i += 1) {
3165
+ carveSky(b.x + i, y);
3166
+ carveSky(b.x + b.w - 1 - i, y);
3167
+ }
3168
+ }
3169
+ for (let y = b.top + 1; y < ground; y += 1) {
3170
+ for (const sx of [b.x + 2, b.x + b.w - 3]) {
3171
+ const cell = grid[y]?.[sx];
3172
+ if (cell && cell.text === ' ') {
3173
+ sceneOverlay(grid, sx, y, '│', P('#2a4a86', '#484858'));
3174
+ }
3175
+ }
3176
+ }
3177
+ const tInset = 2;
3178
+ for (let x = b.x + tInset; x <= b.x + b.w - 1 - tInset; x += 1) {
3179
+ sceneCell(grid, x, b.top, '▀', P('#3a5a9e', '#585860'), body);
3180
+ }
3181
+ sceneOverlay(grid, cxB, b.top - 1, '│', silver);
3182
+ sceneOverlay(grid, cxB, b.top - 2, '│', silver);
3183
+ sceneOverlay(grid, cxB, b.top - 3, '│', silver);
3184
+ sceneOverlay(grid, cxB, b.top - 4, '•', Math.sin(pulse * 0.3 + 2.1) > -0.2 ? beacon : P('#5a1a22', '#4a4a4a'));
3185
+ }
3186
+ }
3187
+ // The Hudson: dark water carrying trembling columns of window light.
3188
+ for (let y = ground; y < height; y += 1) {
3189
+ const depth = (y - ground) / Math.max(1, height - 1 - ground);
3190
+ const bg = interpolateHex(waterTop, waterDeep, depth);
3191
+ for (let x = 0; x < width; x += 1) {
3192
+ const ripple = hashNoise(x * 0.55, y * 4.2, Math.floor(pulse / 4));
3193
+ const glyph = ripple > 0.94 ? '─' : ripple > 0.84 ? '˙' : ' ';
3194
+ sceneCell(grid, x, y, glyph, interpolateHex(silver, bg, 0.55 + depth * 0.2), bg);
3195
+ }
3196
+ }
3197
+ for (const source of reflections) {
3198
+ const reach = Math.min(height - ground, 4);
3199
+ for (let d = 0; d < reach; d += 1) {
3200
+ const y = ground + d;
3201
+ const strength = (0.72 - d * 0.16) * (0.55 + 0.45 * Math.sin(pulse * 0.18 + source.x * 1.3 + d));
3202
+ if (hashNoise(source.x * 3, y * 7, 12) < strength) {
3203
+ const jx = source.x + (hashNoise(y, source.x, 33) > 0.6 ? 1 : 0);
3204
+ sceneOverlay(grid, jx, y, d === 0 ? '•' : '·', d === 0 ? source.color : interpolateHex(source.color, waterDeep, 0.45));
3205
+ }
3206
+ }
3207
+ }
3208
+ // A worn silver path on the water beneath the moon.
3209
+ for (let y = ground; y < height; y += 1) {
3210
+ const depth = (y - ground) / Math.max(1, height - 1 - ground);
3211
+ const laneX = Math.round(moonX + Math.sin(y * 1.15 + pulse * 0.18) * 1.5);
3212
+ if (hashNoise(y * 7, Math.floor(pulse / 2), 5) > 0.42) {
3213
+ sceneOverlay(grid, laneX, y, depth > 0.5 ? '·' : '•', interpolateHex(silver, waterDeep, 0.3 + depth * 0.45));
3214
+ }
3215
+ }
3216
+ // Headlights and taillights streaming the West Side Highway.
3217
+ for (const flow of [{ speed: 0.9, color: headlight, seed: 3 }, { speed: -0.7, color: taillight, seed: 11 }]) {
3218
+ for (let c = 0; c < Math.max(2, Math.floor(width / 22)); c += 1) {
3219
+ const raw = pulse * flow.speed + c * Math.floor(width / 2.4) + flow.seed * 7;
3220
+ const cx = ((Math.round(raw) % width) + width) % width;
3221
+ sceneOverlay(grid, cx, ground, '•', flow.color);
3222
+ if (ground + 1 < height) {
3223
+ sceneOverlay(grid, cx, ground + 1, '·', interpolateHex(flow.color, waterDeep, 0.5));
3224
+ }
3225
+ }
3226
+ }
3227
+ // The Staten Island ferry churning across with lit decks.
3228
+ const ferryX = Math.round(width + 5 - ((pulse * 0.24) % (width + 12)));
3229
+ const ferryY = Math.min(height - 1, ground + 1);
3230
+ sceneOverlay(grid, ferryX, ferryY, '▄', ferryHull);
3231
+ sceneOverlay(grid, ferryX + 1, ferryY, '▄', ferryHull);
3232
+ sceneOverlay(grid, ferryX + 2, ferryY, '▄', ferryHull);
3233
+ sceneOverlay(grid, ferryX, ferryY - 1, '•', winWarm);
3234
+ sceneOverlay(grid, ferryX + 1, ferryY - 1, '•', winWarm);
3235
+ sceneOverlay(grid, ferryX + 2, ferryY - 1, '·', winDim);
3236
+ sceneOverlay(grid, ferryX + 3, ferryY, '·', interpolateHex(silver, waterDeep, 0.45));
3237
+ sceneOverlay(grid, ferryX + 4, ferryY, '˙', interpolateHex(silver, waterDeep, 0.7));
3238
+ // Quay lamps strung along the waterline.
3239
+ for (let x = 4; x < width; x += 9) {
3240
+ sceneOverlay(grid, x, ground, '•', winWarm);
3241
+ sceneOverlay(grid, x, ground + 1, '·', interpolateHex(winWarm, waterDeep, 0.4));
3242
+ }
3243
+ // A red-eye blinking its way across the Hudson sky.
3244
+ const planeX = Math.round((pulse * 0.5) % (width + 18)) - 9;
3245
+ const planeY = Math.max(1, Math.round(ground * 0.14));
3246
+ sceneOverlay(grid, planeX, planeY, '─', silver);
3247
+ sceneOverlay(grid, planeX + 1, planeY, '•', Math.sin(pulse * 0.6) > 0 ? beacon : silver);
3248
+ sceneOverlay(grid, planeX - 2, planeY, '·', interpolateHex(silver, skyAt(planeY), 0.55));
3249
+ sceneOverlay(grid, planeX - 4, planeY, '˙', interpolateHex(silver, skyAt(planeY), 0.75));
3250
+ return grid.map(row => lineFromCells(row, accent));
3251
+ }
3252
+ /** Alpine at night — moonlit peaks, festive chalet, snowy pines and soft drifts. */
3253
+ function buildAlpine(pulse, width, height, theme) {
3254
+ const accent = themeAccent(theme);
3255
+ const P = (color, mono) => artColor(theme, color, mono);
3256
+ const skyTop = P('#050818', '#08080c');
3257
+ const skyMid = P('#0d1630', '#15151e');
3258
+ const skyLow = P('#1c2a4a', '#262630');
3259
+ const starDim = P('#8a9ac0', '#5c5c5c');
3260
+ const starBright = P('#e4ecff', '#eaeaea');
3261
+ const moonCore = P('#f2f4fa', '#f2f2f2');
3262
+ const moonShade = P('#c6cede', '#c4c4c4');
3263
+ const rockFace = P('#1a2236', '#1a1a20');
3264
+ const rockShade = P('#10141f', '#101014');
3265
+ const rockHi = P('#2a3450', '#3c3c46');
3266
+ const snowBright = P('#eef2fc', '#e8e8e8');
3267
+ const snowShade = P('#b9c4dc', '#a0a0a0');
3268
+ const chaletWall = P('#4a3226', '#3c3c3c');
3269
+ const chaletDark = P('#241a12', '#202020');
3270
+ const chaletGlow = P('#ffd9a0', '#e8e8e8');
3271
+ const chaletRoof = P('#4b1717', '#303030');
3272
+ const evergreen = P('#17382c', '#282828');
3273
+ const evergreenDeep = P('#0b211a', '#171717');
3274
+ const hollyRed = P('#e94747', '#d8d8d8');
3275
+ const hollyGreen = P('#4f9a62', '#a0a0a0');
3276
+ const garlandGold = P('#ffd166', '#eeeeee');
3277
+ const snowShadow = P('#8ea5c8', '#989898');
3278
+ const smoke = P('#8a92a8', '#8a8a8a');
3279
+ const grid = emptyMotionGrid(width, height, skyTop);
3280
+ const ground = Math.max(3, Math.round(height * 0.8));
3281
+ const chaletX = Math.round(width * 0.56);
3282
+ // Cold night gradient.
3283
+ const skyBg = [];
3284
+ for (let y = 0; y < ground; y += 1) {
3285
+ const t = y / Math.max(1, ground - 1);
3286
+ const bg = t < 0.55 ? interpolateHex(skyTop, skyMid, t / 0.55) : interpolateHex(skyMid, skyLow, (t - 0.55) / 0.45);
3287
+ skyBg.push(bg);
3288
+ for (let x = 0; x < width; x += 1) {
3289
+ sceneCell(grid, x, y, ' ', bg, bg);
3290
+ }
3291
+ }
3292
+ const skyAt = (y) => skyBg[clampNumber(Math.round(y), 0, skyBg.length - 1)] ?? skyMid;
3293
+ // Star field with a slow twinkle.
3294
+ const starCount = Math.floor(width * ground * 0.03);
3295
+ for (let s = 0; s < starCount; s += 1) {
3296
+ const sx = Math.floor(hashNoise(s, 31, 0) * width);
3297
+ const sy = Math.floor(hashNoise(s, 32, 0) * ground * 0.62);
3298
+ const twinkle = 0.5 + 0.5 * Math.sin(pulse * (0.06 + hashNoise(s, 33, 0) * 0.12) + s * 2.1);
3299
+ if (twinkle < 0.4) {
3300
+ continue;
3301
+ }
3302
+ const bright = hashNoise(s, 34, 0) * twinkle;
3303
+ sceneOverlay(grid, sx, sy, bright > 0.74 ? '•' : bright > 0.5 ? '·' : '˙', bright > 0.6 ? starBright : starDim);
3304
+ }
3305
+ // A pale band of Milky Way dust arching over the peaks.
3306
+ for (let s = 0; s < Math.floor(width * 1.4); s += 1) {
3307
+ const t = hashNoise(s, 61, 0);
3308
+ const sx = Math.floor(t * width);
3309
+ const sy = Math.floor(ground * (0.08 + t * 0.3) + (hashNoise(s, 62, 0) - 0.5) * ground * 0.1);
3310
+ if (sy >= 0 && sy < ground * 0.6 && hashNoise(s, 63, 0) > 0.38) {
3311
+ sceneOverlay(grid, sx, sy, '˙', interpolateHex(starDim, skyAt(sy), 0.45));
3312
+ }
3313
+ }
3314
+ // A shooting star stitches the sky every few seconds.
3315
+ const shootCycle = 90;
3316
+ const shootPhase = pulse % shootCycle;
3317
+ if (shootPhase < 5) {
3318
+ const shootSeed = Math.floor(pulse / shootCycle);
3319
+ const headX = Math.floor(width * (0.2 + hashNoise(shootSeed, 71, 0) * 0.5)) + shootPhase * 2;
3320
+ const headY = Math.max(1, Math.floor(ground * 0.08)) + shootPhase;
3321
+ for (let k = 0; k < 3; k += 1) {
3322
+ sceneOverlay(grid, headX - k * 2, headY - k, k === 0 ? '•' : k === 1 ? '·' : '˙', interpolateHex(starBright, skyMid, 0.2 + k * 0.3));
3323
+ }
3324
+ }
3325
+ // Full moon with crater shading and a faint halo.
3326
+ const moonX = Math.round(width * 0.2);
3327
+ const moonY = Math.round(ground * 0.2);
3328
+ const moonR = Math.max(1.4, height * 0.09);
3329
+ for (let y = Math.floor(moonY - moonR * 2.4); y <= Math.ceil(moonY + moonR * 2.4); y += 1) {
3330
+ if (y < 0 || y >= ground) {
3331
+ continue;
3332
+ }
3333
+ for (let x = Math.floor(moonX - moonR * 2.2); x <= Math.ceil(moonX + moonR * 2.2); x += 1) {
3334
+ if (x < 0 || x >= width) {
3335
+ continue;
3336
+ }
3337
+ const dx = (x - moonX) / 1.9;
3338
+ const dy = y - moonY;
3339
+ const d = Math.sqrt(dx * dx + dy * dy);
3340
+ if (d <= moonR) {
3341
+ const crater = hashNoise(Math.round(dx * 3) + 9, Math.round(dy * 3) + 4, 0) > 0.68;
3342
+ sceneCell(grid, x, y, d < moonR * 0.6 ? '●' : '•', crater ? moonShade : moonCore, skyAt(y), d < moonR * 0.45);
3343
+ }
3344
+ else if (d <= moonR * 2.1 && hashNoise(x, y, 0) > 0.82) {
3345
+ sceneOverlay(grid, x, y, '˙', starDim);
3346
+ }
3347
+ }
3348
+ }
3349
+ // Two restrained ridgelines keep the Alpine setting without crowding the
3350
+ // chalet or swallowing the open snowy-night sky.
3351
+ const heroX = Math.round(width * 0.58);
3352
+ const ranges = [
3353
+ { base: Math.round(ground * 0.68), amp: 0.15, seed: 2, rock: rockShade, cliff: rockFace, snow: snowShade, snowLine: 0.38, hero: true },
3354
+ { base: Math.round(ground * 0.82), amp: 0.1, seed: 9, rock: rockFace, cliff: rockShade, snow: snowBright, snowLine: 0.5, hero: false }
3355
+ ];
3356
+ for (const range of ranges) {
3357
+ const crests = [];
3358
+ for (let x = 0; x < width; x += 1) {
3359
+ const ridge = Math.abs(Math.sin(x * 0.09 + range.seed)) * 0.6 +
3360
+ Math.abs(Math.sin(x * 0.23 + range.seed * 1.7)) * 0.3 +
3361
+ hashNoise(x, range.seed, 0) * 0.1;
3362
+ const hero = range.hero ? Math.exp(-Math.pow((x - heroX) / Math.max(2, width * 0.07), 2)) * height * 0.08 : 0;
3363
+ crests.push(Math.round(range.base - ridge * height * range.amp - hero));
3364
+ }
3365
+ for (let x = 0; x < width; x += 1) {
3366
+ const crest = crests[x] ?? range.base;
3367
+ const slope = Math.abs((crests[x + 1] ?? crest) - (crests[x - 1] ?? crest));
3368
+ for (let y = crest; y < ground; y += 1) {
3369
+ const depth = (y - crest) / Math.max(1, ground - crest);
3370
+ const snowy = depth < range.snowLine * (0.5 + hashNoise(x, range.seed + 40, 0) * 0.7);
3371
+ const fleck = hashNoise(x * 1.9, y * 2.7, range.seed);
3372
+ if (snowy) {
3373
+ const glyph = depth < 0.12 ? '░' : fleck > 0.55 ? '·' : ' ';
3374
+ sceneCell(grid, x, y, glyph, range.snow, interpolateHex(range.snow, range.rock, 0.35));
3375
+ }
3376
+ else if (slope >= 2 && depth < 0.75) {
3377
+ const glyph = fleck > 0.62 ? '▒' : fleck > 0.4 ? '░' : ' ';
3378
+ sceneCell(grid, x, y, glyph, range.cliff, range.rock);
3379
+ }
3380
+ else {
3381
+ const glyph = fleck > 0.9 ? '░' : ' ';
3382
+ const color = depth > 0.5 ? range.rock : interpolateHex(range.rock, skyAt(y), 0.2);
3383
+ sceneCell(grid, x, y, glyph, fleck > 0.9 ? rockHi : color, range.rock);
3384
+ }
3385
+ }
3386
+ }
3387
+ // Bright crest highlight along each ridgeline.
3388
+ for (let x = 0; x < width; x += 1) {
3389
+ const crest = crests[x] ?? range.base;
3390
+ sceneOverlay(grid, x, crest, '·', range.snow);
3391
+ }
3392
+ }
3393
+ // Recast the shoreline as a soft snowfield, then stage a generous exterior
3394
+ // view of the chalet. The warm facade and flanking trees are the foreground
3395
+ // focus; the moonlit ranges stay atmospheric rather than competing with it.
3396
+ for (let y = ground; y < height; y += 1) {
3397
+ const depth = (y - ground) / Math.max(1, height - 1 - ground);
3398
+ const bg = interpolateHex(snowShade, snowBright, 0.38 + depth * 0.34);
3399
+ for (let x = 0; x < width; x += 1) {
3400
+ const drift = Math.sin(x * 0.11 + y * 0.8) + Math.sin(x * 0.035 - y * 1.3);
3401
+ const glyph = hashNoise(x * 0.7, y * 3.1, 0) > 0.91 ? '·' : drift > 1.55 ? '˙' : ' ';
3402
+ sceneCell(grid, x, y, glyph, glyph === ' ' ? bg : snowBright, bg);
3403
+ }
3404
+ }
3405
+ const drawSnowyPine = (treeX, treeHeight, foreground = false) => {
3406
+ const top = Math.max(1, ground - treeHeight);
3407
+ const rows = Math.max(2, ground - top);
3408
+ for (let row = 0; row < rows; row += 1) {
3409
+ const y = top + row;
3410
+ const half = Math.max(0, Math.round((row / Math.max(1, rows - 1)) * Math.min(4, treeHeight * 0.42)));
3411
+ for (let dx = -half; dx <= half; dx += 1) {
3412
+ const edge = Math.abs(dx) === half;
3413
+ const snowed = row % 2 === 0 && edge;
3414
+ const color = snowed ? snowBright : foreground ? evergreen : evergreenDeep;
3415
+ sceneOverlay(grid, treeX + dx, y, snowed ? '▲' : row === 0 ? '▲' : '▓', color);
3416
+ }
3417
+ }
3418
+ sceneOverlay(grid, treeX, ground, '│', chaletDark);
3419
+ sceneOverlay(grid, treeX, ground - 1, '▲', foreground ? evergreen : evergreenDeep);
1760
3420
  };
1761
- for (let seed = 0; seed < 6; seed += 1) {
1762
- branch(cx, cy, (seed / 6) * Math.PI * 2 + pulse * 0.01, seedLength, 0);
3421
+ const sideTrees = [
3422
+ { x: Math.round(width * 0.1), h: 6, foreground: true },
3423
+ { x: Math.round(width * 0.23), h: 4, foreground: false },
3424
+ { x: Math.round(width * 0.84), h: 5, foreground: false },
3425
+ { x: Math.round(width * 0.94), h: 7, foreground: true }
3426
+ ];
3427
+ for (const tree of sideTrees) {
3428
+ if (Math.abs(tree.x - chaletX) > 7) {
3429
+ drawSnowyPine(tree.x, Math.min(tree.h, Math.max(3, ground - 1)), tree.foreground);
3430
+ }
1763
3431
  }
1764
- return brailleToLines(canvas, accent);
3432
+ const houseHalf = Math.min(12, Math.max(6, Math.round(width * 0.12)));
3433
+ const ridgeY = Math.max(1, ground - 7);
3434
+ const eaveY = Math.max(ridgeY + 2, ground - 4);
3435
+ const wallLeft = chaletX - houseHalf + 2;
3436
+ const wallRight = chaletX + houseHalf - 2;
3437
+ // Timber facade and golden windows, seen clearly from outside.
3438
+ for (let y = eaveY + 1; y < ground; y += 1) {
3439
+ for (let x = wallLeft; x <= wallRight; x += 1) {
3440
+ const beam = y === eaveY + 1 || x === wallLeft || x === wallRight || (x - wallLeft) % 5 === 0;
3441
+ sceneCell(grid, x, y, beam ? '▓' : '░', beam ? chaletDark : chaletWall, chaletWall);
3442
+ }
3443
+ }
3444
+ const roofRise = Math.max(1, eaveY - ridgeY);
3445
+ for (let y = ridgeY; y <= eaveY; y += 1) {
3446
+ const span = Math.max(1, Math.round(((y - ridgeY + 1) / (roofRise + 1)) * houseHalf));
3447
+ for (let dx = -span; dx <= span; dx += 1) {
3448
+ const snowyEdge = y === ridgeY || Math.abs(dx) === span || (y === eaveY && Math.abs(dx) % 3 !== 1);
3449
+ sceneCell(grid, chaletX + dx, y, snowyEdge ? '▀' : '▓', snowyEdge ? snowBright : chaletRoof, snowyEdge ? snowShadow : chaletRoof);
3450
+ }
3451
+ }
3452
+ const windowY = Math.min(ground - 1, eaveY + 2);
3453
+ for (const windowX of [chaletX - Math.max(3, Math.round(houseHalf * 0.48)), chaletX + Math.max(3, Math.round(houseHalf * 0.48))]) {
3454
+ sceneCell(grid, windowX - 1, windowY, '▌', chaletGlow, chaletDark, true);
3455
+ sceneCell(grid, windowX, windowY, '╋', chaletDark, chaletGlow, true);
3456
+ sceneCell(grid, windowX + 1, windowY, '▐', chaletGlow, chaletDark, true);
3457
+ }
3458
+ const doorX = chaletX + 1;
3459
+ for (let y = Math.max(eaveY + 1, ground - 2); y < ground; y += 1) {
3460
+ sceneCell(grid, doorX, y, '█', chaletDark, chaletDark);
3461
+ }
3462
+ sceneOverlay(grid, doorX, ground - 2, '●', hollyGreen, true);
3463
+ sceneOverlay(grid, doorX, ground - 1, '•', hollyRed, true);
3464
+ // A strand of colored bulbs turns the chalet unmistakably festive.
3465
+ const lightColors = [hollyRed, garlandGold, hollyGreen, garlandGold];
3466
+ for (let x = wallLeft + 1; x < wallRight; x += 2) {
3467
+ const color = lightColors[Math.abs(x - wallLeft) % lightColors.length] ?? garlandGold;
3468
+ sceneOverlay(grid, x, eaveY, '•', color, true);
3469
+ }
3470
+ // Chimney smoke rises slowly while the snow itself keeps its established
3471
+ // fall speed and night-time drift below.
3472
+ const chimneyX = chaletX + Math.max(3, Math.round(houseHalf * 0.48));
3473
+ sceneCell(grid, chimneyX, Math.max(ridgeY, eaveY - 2), '█', chaletDark, chaletDark);
3474
+ for (let puff = 0; puff < 4; puff += 1) {
3475
+ const rise = (pulse * 0.16 + puff * 1.4) % 5;
3476
+ const puffY = eaveY - 3 - Math.floor(rise);
3477
+ const puffX = chimneyX + Math.round(Math.sin(pulse * 0.07 + puff * 1.8) * (1 + rise * 0.28));
3478
+ if (puffY >= 0) {
3479
+ sceneOverlay(grid, puffX, puffY, rise < 1.4 ? '░' : rise < 3 ? '·' : '˙', interpolateHex(smoke, skyAt(puffY), rise * 0.13));
3480
+ }
3481
+ }
3482
+ // A gently lit path gives the scene a welcoming point of entry.
3483
+ for (let y = ground; y < height; y += 1) {
3484
+ const spread = 1 + (y - ground);
3485
+ for (let dx = -spread; dx <= spread; dx += 1) {
3486
+ if ((dx + y) % 2 === 0) {
3487
+ sceneOverlay(grid, doorX + dx, y, '·', interpolateHex(chaletGlow, snowShade, 0.62));
3488
+ }
3489
+ }
3490
+ }
3491
+ // Gentle falling snow, nearer flakes drifting larger.
3492
+ const flakes = Math.floor(width * 0.5);
3493
+ for (let f = 0; f < flakes; f += 1) {
3494
+ const speed = winterPrecipitationSpeed(f);
3495
+ const fx = Math.floor(hashNoise(f, 52, 0) * width + Math.sin(pulse * 0.1 + f) * 2);
3496
+ const fy = Math.floor((hashNoise(f, 53, 0) * ground + pulse * speed) % (ground + 2));
3497
+ if (fx >= 0 && fx < width && fy >= 0 && fy < ground) {
3498
+ const cell = grid[fy]?.[fx];
3499
+ if (cell && cell.text === ' ') {
3500
+ const size = hashNoise(f, 54, 0);
3501
+ sceneOverlay(grid, fx, fy, size > 0.78 ? '•' : size > 0.4 ? '·' : '˙', snowBright);
3502
+ }
3503
+ }
3504
+ }
3505
+ return grid.map(row => lineFromCells(row, accent));
1765
3506
  }
1766
- function buildCyclone(pulse, width, height, theme) {
1767
- const canvas = createBraille(width, height);
3507
+ const ASCII_DITHER_RAMP = ' .\'`^",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@';
3508
+ function buildOrderedDither(pulse, width, height, theme) {
3509
+ const accent = themeAccent(theme);
1768
3510
  const ramp = themeContributionColors(theme);
3511
+ const t = pulse * 0.06;
3512
+ return Array.from({ length: height }, (_, y) => {
3513
+ const cells = Array.from({ length: width }, (_, x) => {
3514
+ const nx = x / Math.max(1, width - 1);
3515
+ const ny = y / Math.max(1, height - 1);
3516
+ const field = 0.5 +
3517
+ 0.25 * Math.sin((nx * 8 + t) * Math.PI) +
3518
+ 0.2 * Math.cos((ny * 6 - t * 1.3) * Math.PI) +
3519
+ 0.15 * Math.sin((nx + ny) * 10 * Math.PI + t * 2);
3520
+ const bayer = ((BAYER_4[y & 3][x & 3] ?? 0) + 0.5) / 16;
3521
+ const value = clampNumber(field * 0.85 + bayer * 0.15, 0, 1);
3522
+ const glyphIndex = Math.min(ASCII_DITHER_RAMP.length - 1, Math.floor(value * ASCII_DITHER_RAMP.length));
3523
+ const colorIndex = Math.min(ramp.length - 1, Math.round(value * (ramp.length - 1)));
3524
+ return {
3525
+ text: ASCII_DITHER_RAMP[glyphIndex] ?? ' ',
3526
+ color: ramp[Math.max(1, colorIndex)] ?? accent
3527
+ };
3528
+ });
3529
+ return lineFromCells(cells, accent);
3530
+ });
3531
+ }
3532
+ function buildPixelCrush(pulse, width, height, theme) {
1769
3533
  const accent = themeAccent(theme);
1770
- const dotsWide = width * 2;
1771
- const dotsHigh = height * 4;
1772
- const cx = (dotsWide - 1) / 2;
1773
- const cy = (dotsHigh - 1) / 2;
1774
- const maxR = Math.min(dotsWide, dotsHigh) * 0.52;
1775
- const count = Math.min(260, Math.floor((dotsWide * dotsHigh) / 9));
1776
- for (let i = 0; i < count; i += 1) {
1777
- const phase = hashNoise(i, 1, 0) * Math.PI * 2;
1778
- const life = ((pulse * 0.9 + i * 7.3) % 130) / 130;
1779
- const r = maxR * (1 - life);
1780
- const angle = phase + pulse * 0.05 + (maxR / (r + 5)) * 0.9;
1781
- for (let trail = 0; trail < 4; trail += 1) {
1782
- const rr = r + trail * 1.4;
1783
- const aa = angle - trail * 0.12;
1784
- const x = cx + Math.cos(aa) * rr;
1785
- const y = cy + Math.sin(aa) * rr;
1786
- const color = trail === 0 ? (r < maxR * 0.18 ? '#ffffff' : ramp[ramp.length - 1] ?? accent) : ramp[Math.min(ramp.length - 1, 1 + Math.round((1 - rr / maxR) * (ramp.length - 2)))] ?? accent;
1787
- brailleSet(canvas, x, y, color);
3534
+ const ramp = themeContributionColors(theme);
3535
+ const blockW = 4;
3536
+ const blockH = 2;
3537
+ const cols = Math.ceil(width / blockW);
3538
+ const rows = Math.ceil(height / blockH);
3539
+ const cellsGrid = emptyMotionGrid(width, height, '#04050a');
3540
+ for (let by = 0; by < rows; by += 1) {
3541
+ for (let bx = 0; bx < cols; bx += 1) {
3542
+ const nx = bx / Math.max(1, cols - 1);
3543
+ const ny = by / Math.max(1, rows - 1);
3544
+ const field = 0.5 +
3545
+ 0.3 * Math.sin(nx * 5 + pulse * 0.09) +
3546
+ 0.25 * Math.cos(ny * 4 - pulse * 0.07) +
3547
+ 0.2 * Math.sin((nx + ny) * 7 + pulse * 0.05);
3548
+ const n = hashNoise(bx, by, Math.floor(pulse / 3));
3549
+ const value = clampNumber(field * 0.75 + n * 0.25, 0, 1);
3550
+ const colorIndex = Math.min(ramp.length - 1, Math.floor(value * (ramp.length - 1)));
3551
+ const glyph = value > 0.72 ? '█' : value > 0.5 ? '▓' : value > 0.3 ? '▒' : value > 0.15 ? '░' : ' ';
3552
+ const color = ramp[Math.max(1, colorIndex)] ?? accent;
3553
+ for (let dy = 0; dy < blockH; dy += 1) {
3554
+ for (let dx = 0; dx < blockW; dx += 1) {
3555
+ const x = bx * blockW + dx;
3556
+ const y = by * blockH + dy;
3557
+ if (x < width && y < height && glyph !== ' ') {
3558
+ paintCell(cellsGrid, x, y, glyph, color);
3559
+ }
3560
+ }
3561
+ }
1788
3562
  }
1789
3563
  }
1790
- brailleSet(canvas, cx, cy, '#ffffff');
1791
- return brailleToLines(canvas, accent);
3564
+ return cellsGrid.map(row => lineFromCells(row, accent));
1792
3565
  }
1793
- function buildLavaLamp(pulse, width, height, theme) {
1794
- const grid = emptyMotionGrid(width, height, '#0a0610');
1795
- const ramp = themeContributionColors(theme);
3566
+ function buildStormfront(pulse, width, height, theme) {
1796
3567
  const accent = themeAccent(theme);
1797
- const blobs = Array.from({ length: 5 }, (_, b) => {
1798
- const speed = 0.14 + ((b * 7) % 5) / 20;
1799
- return {
1800
- x: width * (0.5 + 0.32 * Math.sin(pulse * 0.015 * (1 + b * 0.2) + b)),
1801
- y: height + (b * height) / 5 - ((pulse * speed) % (height + 6)),
1802
- r: 2.2 + 1.3 * Math.sin(pulse * 0.02 + b * 1.7) + (b % 2 ? 0.8 : 0)
1803
- };
1804
- });
3568
+ const palette = theme === 'mono'
3569
+ ? ['#080808', '#171717', '#292929', '#454545', '#696969', '#9a9a9a', '#e0e0e0']
3570
+ : ['#040912', '#091421', '#102338', '#1a344d', '#294c69', '#527998', '#a6bdd0'];
3571
+ const rainColor = theme === 'mono' ? '#d0d0d0' : '#8ec9ed';
3572
+ const grid = emptyMotionGrid(width, height, palette[0] ?? '#050a12');
3573
+ const flashSeed = Math.floor(pulse / 3);
3574
+ const flash = hashNoise(flashSeed, 9, 2) > 0.9 && pulse % 3 < 1.25;
3575
+ // Cloud layers taper into torn scud instead of ending on a shared horizon.
3576
+ // The per-column underside and full-height fade keep the shelf organically
3577
+ // connected to the rain beneath it.
1805
3578
  for (let y = 0; y < height; y += 1) {
1806
3579
  for (let x = 0; x < width; x += 1) {
1807
- let field = 0;
1808
- for (const blob of blobs) {
1809
- const dx = (x - blob.x) * 0.5;
1810
- const dy = y - blob.y;
1811
- field += (blob.r * blob.r) / (dx * dx + dy * dy + 0.6);
3580
+ const nx = x / Math.max(1, width - 1);
3581
+ const ny = y / Math.max(1, height - 1);
3582
+ const billow = 0.64 +
3583
+ Math.sin(nx * 6.8 + pulse * 0.017 + ny * 2.1) * 0.19 +
3584
+ Math.cos(nx * 14.7 - pulse * 0.012 - ny * 4.3) * 0.14 +
3585
+ Math.sin((nx + ny) * 8.4 + pulse * 0.009) * 0.08;
3586
+ const raggedUnderside = 0.5 +
3587
+ Math.sin(nx * 11.3 - pulse * 0.01) * 0.1 +
3588
+ Math.cos(nx * 4.1 + pulse * 0.006) * 0.08;
3589
+ const verticalFade = clampNumber(1 - ny / Math.max(0.2, raggedUnderside + 0.3), 0, 1);
3590
+ const tornScud = ny > 0.42
3591
+ ? Math.max(0, Math.sin(nx * 19.2 + ny * 13.4 + pulse * 0.013)) * Math.max(0, 1 - ny) * 0.22
3592
+ : 0;
3593
+ const ceiling = billow * verticalFade + tornScud;
3594
+ if (ceiling <= 0.1) {
3595
+ continue;
1812
3596
  }
1813
- const glyph = field > 2 ? '█' : field > 1.4 ? '▓' : field > 1 ? '▒' : field > 0.7 ? '░' : ' ';
1814
- if (glyph !== ' ') {
1815
- const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(clampNumber(field / 2.4, 0, 1) * (ramp.length - 1))));
1816
- paintCell(grid, x, y, glyph, ramp[colorIndex] ?? accent);
3597
+ const value = clampNumber(ceiling + (flash ? 0.3 : 0), 0, 1);
3598
+ const level = Math.min(palette.length - 1, 1 + Math.floor(value * (palette.length - 1)));
3599
+ grid[y][x] = {
3600
+ text: value > 0.72 ? '▓' : value > 0.38 ? '▒' : value > 0.18 ? '░' : '·',
3601
+ color: flash && value > 0.45 ? '#dceeff' : palette[level] ?? accent
3602
+ };
3603
+ }
3604
+ }
3605
+ if (flash) {
3606
+ let boltX = Math.round(width * (0.27 + hashNoise(flashSeed, 2, 0) * 0.46));
3607
+ const boltEnd = Math.max(2, Math.round(height * 0.72));
3608
+ for (let y = 1; y < boltEnd; y += 1) {
3609
+ if (y % 2 === 0) {
3610
+ boltX += hashNoise(y, flashSeed, 3) > 0.5 ? 1 : -1;
3611
+ }
3612
+ paintCell(grid, boltX, y, y % 3 === 0 ? '╲' : '│', '#ffffff');
3613
+ }
3614
+ }
3615
+ // Heavy seeded block drops advance toward increasing rows, so the rain reads
3616
+ // downward without relying on thin line glyphs that vary between terminals.
3617
+ const dropCount = Math.max(12, Math.floor(width * 0.7));
3618
+ for (let index = 0; index < dropCount; index += 1) {
3619
+ const speed = winterPrecipitationSpeed(index);
3620
+ const cycle = height + 8;
3621
+ const headY = Math.floor((hashNoise(index, 32, 0) * cycle + pulse * speed) % cycle) - 3;
3622
+ const baseX = Math.floor(hashNoise(index, 33, 0) * Math.max(1, width));
3623
+ const driftX = Math.floor(pulse * speed * 0.14);
3624
+ const x = (baseX + driftX) % Math.max(1, width);
3625
+ const length = 2 + Math.floor(hashNoise(index, 34, 0) * 3);
3626
+ for (let trail = 0; trail < length; trail += 1) {
3627
+ const y = headY - trail;
3628
+ if (y >= 0 && y < height) {
3629
+ const dropX = x - Math.floor(trail / 2);
3630
+ paintCell(grid, dropX, y, trail === 0 ? '█' : trail === length - 1 ? '░' : '▓', trail === 0 ? rainColor : palette[5] ?? rainColor);
1817
3631
  }
1818
3632
  }
3633
+ if (headY >= height - 1 && headY <= height + 1) {
3634
+ paintCell(grid, x, height - 1, '⌁', palette[6] ?? rainColor);
3635
+ }
3636
+ }
3637
+ // Sparse low mist suggests distance without covering the lower third.
3638
+ const mistY = Math.max(0, height - 3);
3639
+ for (let x = 0; x < width; x += 1) {
3640
+ if ((x + Math.floor(pulse * 0.03)) % 9 < 3) {
3641
+ paintCell(grid, x, mistY, '·', palette[3] ?? accent);
3642
+ }
3643
+ }
3644
+ // Keep one continuous storm sky behind both the cloud shelf and the open
3645
+ // rain. Applying it last preserves every glyph while avoiding the abrupt
3646
+ // empty lower half that made the scene look cut in two.
3647
+ for (let y = 0; y < height; y += 1) {
3648
+ const depth = y / Math.max(1, height - 1);
3649
+ const lift = flash ? 18 : 0;
3650
+ const backgroundColor = theme === 'mono'
3651
+ ? rgbColor(8 + Math.round(depth * 12) + lift, 8 + Math.round(depth * 12) + lift, 8 + Math.round(depth * 12) + lift)
3652
+ : rgbColor(4 + Math.round(depth * 5) + lift, 9 + Math.round(depth * 12) + lift, 18 + Math.round(depth * 18) + lift);
3653
+ for (let x = 0; x < width; x += 1) {
3654
+ const cell = grid[y][x];
3655
+ grid[y][x] = { ...cell, backgroundColor };
3656
+ }
1819
3657
  }
1820
3658
  return grid.map(row => lineFromCells(row, accent));
1821
3659
  }
1822
- function buildNewton(pulse, width, height, theme) {
1823
- const grid = emptyMotionGrid(width, height, '#05060c');
3660
+ function buildCascade(pulse, width, height, theme) {
1824
3661
  const ramp = themeContributionColors(theme);
1825
3662
  const accent = themeAccent(theme);
1826
- const shades = [' ', '·', '░', '', '', ''];
1827
- const roots = [
1828
- { x: 1, y: 0, color: ramp[2] ?? accent },
1829
- { x: -0.5, y: Math.sqrt(3) / 2, color: ramp[4] ?? accent },
1830
- { x: -0.5, y: -Math.sqrt(3) / 2, color: accent }
1831
- ];
1832
- const zoom = 1.7 + 0.4 * Math.sin(pulse * 0.02);
1833
- const rot = pulse * 0.012;
1834
- const cosR = Math.cos(rot);
1835
- const sinR = Math.sin(rot);
1836
- const maxIter = 22;
3663
+ const glyphs = ['█', '▓', '', '░', '', '', ''];
3664
+ return Array.from({ length: height }, (_, y) => {
3665
+ const cells = Array.from({ length: width }, (_, x) => {
3666
+ const colSpeed = 0.7 + hashNoise(x, 1, 0) * 1.6;
3667
+ const head = ((pulse * colSpeed + hashNoise(x, 2, 0) * height * 3) % (height + 6)) - 2;
3668
+ const dist = head - y;
3669
+ if (dist < 0 || dist > height * 0.9) {
3670
+ // residual mist so columns feel thick
3671
+ const mist = hashNoise(x, y, Math.floor(pulse / 4));
3672
+ if (mist > 0.88) {
3673
+ return { text: '·', color: ramp[1] ?? accent };
3674
+ }
3675
+ return { text: ' ', color: accent };
3676
+ }
3677
+ const trail = 1 - dist / Math.max(4, height * 0.55);
3678
+ const bright = dist < 1.2;
3679
+ const gi = bright ? 0 : Math.min(glyphs.length - 1, Math.floor((1 - trail) * glyphs.length));
3680
+ const colorIndex = Math.min(ramp.length - 1, Math.floor(trail * (ramp.length - 1)));
3681
+ return {
3682
+ text: glyphs[gi] ?? '█',
3683
+ color: bright ? '#ffffff' : ramp[Math.max(1, colorIndex)] ?? accent,
3684
+ backgroundColor: trail > 0.55 ? ramp[Math.max(1, colorIndex - 1)] : undefined,
3685
+ bold: bright
3686
+ };
3687
+ });
3688
+ return lineFromCells(cells, accent);
3689
+ });
3690
+ }
3691
+ /** Closed-eye phosphenes — dense geometric pressure-patterns blooming from center. */
3692
+ function buildPhosphene(pulse, width, height, theme) {
3693
+ const accent = themeAccent(theme);
3694
+ const palette = theme === 'mono'
3695
+ ? ['#0a0a0a', '#222222', '#444444', '#777777', '#aaaaaa', '#dddddd', '#ffffff']
3696
+ : ['#0a0618', '#1a0a40', '#3a1080', '#8020a0', '#c04080', '#e08040', '#f0d060', '#ffffff'];
3697
+ const cx = (width - 1) / 2;
3698
+ const cy = (height - 1) / 2;
3699
+ const glyphs = ['·', '░', '▒', '▓', '█'];
3700
+ return Array.from({ length: height }, (_, y) => {
3701
+ const cells = Array.from({ length: width }, (_, x) => {
3702
+ const dx = (x - cx) / Math.max(1, width * 0.5);
3703
+ const dy = (y - cy) / Math.max(1, height * 0.5);
3704
+ const r = Math.sqrt(dx * dx + dy * dy);
3705
+ const ang = Math.atan2(dy, dx);
3706
+ const rings = Math.sin(r * 14 - pulse * 0.22);
3707
+ const wedges = Math.sin(ang * 6 + pulse * 0.08);
3708
+ const spiral = Math.sin(r * 8 - ang * 3 + pulse * 0.12);
3709
+ const bloom = Math.exp(-r * r * 1.4) * (0.5 + 0.5 * Math.sin(pulse * 0.06));
3710
+ const field = clampNumber(0.35 + rings * 0.28 + wedges * 0.2 + spiral * 0.18 + bloom * 0.35, 0, 1);
3711
+ const band = Math.abs(rings) > 0.7 ? 0.2 : 0;
3712
+ const value = clampNumber(field + band, 0, 1);
3713
+ const level = Math.min(palette.length - 1, Math.floor(value * (palette.length - 0.01)));
3714
+ const gi = Math.min(glyphs.length - 1, Math.floor(value * glyphs.length));
3715
+ return {
3716
+ text: glyphs[gi] ?? '█',
3717
+ color: value > 0.85 ? '#ffffff' : palette[level] ?? accent,
3718
+ backgroundColor: palette[Math.max(0, level - 1)] ?? palette[0],
3719
+ bold: value > 0.8
3720
+ };
3721
+ });
3722
+ return lineFromCells(cells, accent);
3723
+ });
3724
+ }
3725
+ /** Slow solar system: dense star field, sun, banded planets with rings and moons. */
3726
+ function buildPlanetarium(pulse, width, height, theme) {
3727
+ const accent = themeAccent(theme);
3728
+ const voidColor = theme === 'mono' ? '#050508' : '#040610';
3729
+ const grid = emptyMotionGrid(width, height, voidColor);
3730
+ const cx = (width - 1) / 2;
3731
+ const cy = (height - 1) / 2;
3732
+ // Milky-way dust band (behind everything)
1837
3733
  for (let y = 0; y < height; y += 1) {
1838
3734
  for (let x = 0; x < width; x += 1) {
1839
- const px = (x / Math.max(1, width - 1) - 0.5) * 2 * zoom;
1840
- const py = (y / Math.max(1, height - 1) - 0.5) * 2 * zoom;
1841
- let zx = px * cosR - py * sinR;
1842
- let zy = px * sinR + py * cosR;
1843
- let iter = 0;
1844
- for (; iter < maxIter; iter += 1) {
1845
- const r2 = zx * zx + zy * zy;
1846
- if (r2 < 1e-6) {
1847
- break;
1848
- }
1849
- const denom = 3 * ((zx * zx - zy * zy) * (zx * zx - zy * zy) + (2 * zx * zy) * (2 * zx * zy));
1850
- const ax = zx * zx - zy * zy;
1851
- const ay = 2 * zx * zy;
1852
- const numX = (ax * zx - ay * zy) - 1;
1853
- const numY = ax * zy + ay * zx;
1854
- zx = zx - (numX * ax + numY * ay) / (denom || 1e-6);
1855
- zy = zy - (numY * ax - numX * ay) / (denom || 1e-6);
1856
- let done = false;
1857
- for (const root of roots) {
1858
- if ((zx - root.x) ** 2 + (zy - root.y) ** 2 < 0.01) {
1859
- done = true;
1860
- break;
3735
+ const nx = (x - cx) / Math.max(1, width * 0.5);
3736
+ const ny = (y - cy) / Math.max(1, height * 0.5);
3737
+ // Diagonal galactic plane
3738
+ const plane = Math.abs(nx * 0.35 + ny * 0.9);
3739
+ const dust = Math.exp(-plane * plane * 4.5) *
3740
+ (0.35 +
3741
+ 0.25 * Math.sin(nx * 8 + pulse * 0.015) +
3742
+ 0.2 * Math.sin(ny * 11 - pulse * 0.012) +
3743
+ hashNoise(x, y, 0) * 0.25);
3744
+ if (dust > 0.22) {
3745
+ const c = theme === 'mono'
3746
+ ? dust > 0.5
3747
+ ? '#2a2a38'
3748
+ : '#161620'
3749
+ : dust > 0.55
3750
+ ? '#2a1848'
3751
+ : dust > 0.35
3752
+ ? '#1a1030'
3753
+ : '#0e0c1c';
3754
+ paintCell(grid, x, y, dust > 0.5 ? '░' : '·', c);
3755
+ }
3756
+ }
3757
+ }
3758
+ // Dense multi-layer star field with color variety + twinkle
3759
+ const starCount = Math.floor((width * height) / 3.2);
3760
+ for (let s = 0; s < starCount; s += 1) {
3761
+ const sx = Math.floor(hashNoise(s, 1, 0) * width);
3762
+ const sy = Math.floor(hashNoise(s, 2, 0) * height);
3763
+ const twinkle = 0.45 + 0.55 * Math.sin(pulse * (0.08 + hashNoise(s, 3, 0) * 0.18) + s);
3764
+ const bright = hashNoise(s, 4, 0) * twinkle;
3765
+ if (bright < 0.18) {
3766
+ continue;
3767
+ }
3768
+ const huePick = hashNoise(s, 5, 0);
3769
+ let color;
3770
+ if (theme === 'mono') {
3771
+ color = bright > 0.8 ? '#ffffff' : bright > 0.5 ? '#bbbbbb' : '#777777';
3772
+ }
3773
+ else if (bright > 0.88) {
3774
+ color = '#ffffff';
3775
+ }
3776
+ else if (huePick > 0.82) {
3777
+ color = '#ffd0a0'; // warm
3778
+ }
3779
+ else if (huePick > 0.62) {
3780
+ color = '#a0c8ff'; // cool blue
3781
+ }
3782
+ else if (huePick > 0.45) {
3783
+ color = accent;
3784
+ }
3785
+ else {
3786
+ color = '#5a5a80';
3787
+ }
3788
+ const glyph = bright > 0.9 ? '✦' : bright > 0.75 ? '*' : bright > 0.55 ? '+' : bright > 0.35 ? '·' : '˙';
3789
+ paintCell(grid, sx, sy, glyph, color);
3790
+ // Tiny diffraction spikes on the brightest
3791
+ if (bright > 0.92) {
3792
+ paintCell(grid, sx - 1, sy, '·', color);
3793
+ paintCell(grid, sx + 1, sy, '·', color);
3794
+ paintCell(grid, sx, sy - 1, '·', color);
3795
+ paintCell(grid, sx, sy + 1, '·', color);
3796
+ }
3797
+ }
3798
+ // Soft colored nebula wash
3799
+ for (let y = 0; y < height; y += 1) {
3800
+ for (let x = 0; x < width; x += 1) {
3801
+ const nx = x / Math.max(1, width - 1);
3802
+ const ny = y / Math.max(1, height - 1);
3803
+ const neb = 0.32 * Math.sin(nx * 3.2 + pulse * 0.018) * Math.cos(ny * 2.6 - pulse * 0.014) +
3804
+ 0.18 * Math.sin((nx + ny) * 5.5 + pulse * 0.01) +
3805
+ 0.12 * Math.cos(nx * 9 - ny * 4 + pulse * 0.008);
3806
+ const cell = grid[y]?.[x]?.text;
3807
+ if (neb > 0.2 && (cell === ' ' || cell === '˙' || cell === '·')) {
3808
+ const cool = neb > 0.32;
3809
+ paintCell(grid, x, y, cool ? '░' : '·', theme === 'mono' ? '#1a1a28' : cool ? '#1a1840' : '#141028');
3810
+ }
3811
+ }
3812
+ }
3813
+ // Central sun with granulation, corona, and slow flares
3814
+ const sunR = Math.max(2.2, Math.min(width, height * 2) * 0.075);
3815
+ const sunColors = theme === 'mono'
3816
+ ? ['#3a3a3a', '#666666', '#999999', '#cccccc', '#ffffff']
3817
+ : ['#8a2000', '#c04000', '#ff7010', '#ffb028', '#ffe060', '#fff8c0', '#ffffff'];
3818
+ for (let y = 0; y < height; y += 1) {
3819
+ for (let x = 0; x < width; x += 1) {
3820
+ const dx = (x - cx) / 1.95;
3821
+ const dy = y - cy;
3822
+ const d = Math.sqrt(dx * dx + dy * dy);
3823
+ const ang = Math.atan2(dy, dx);
3824
+ if (d > sunR * 2.4) {
3825
+ continue;
3826
+ }
3827
+ const grain = 0.08 * Math.sin(dx * 6 + pulse * 0.05) * Math.cos(dy * 5 - pulse * 0.04);
3828
+ const core = 1 - d / (sunR * 1.55);
3829
+ // Spiky corona / prominence
3830
+ const flare = d > sunR
3831
+ ? Math.pow(Math.max(0, 1 - (d - sunR) / (sunR * 0.9)), 1.8) *
3832
+ (0.35 + 0.65 * Math.pow(Math.max(0, Math.sin(ang * 5 + pulse * 0.12)), 3))
3833
+ : 0;
3834
+ const heat = clampNumber(core + grain + flare * 0.85, 0, 1);
3835
+ if (heat < 0.12) {
3836
+ continue;
3837
+ }
3838
+ const ci = Math.min(sunColors.length - 1, Math.floor(heat * (sunColors.length - 0.01)));
3839
+ const glyph = heat > 0.78 ? '█' : heat > 0.5 ? '▓' : heat > 0.28 ? '▒' : '░';
3840
+ paintCell(grid, x, y, glyph, sunColors[ci] ?? accent);
3841
+ }
3842
+ }
3843
+ const planets = theme === 'mono'
3844
+ ? [
3845
+ { orbit: 0.17, size: 0.65, speed: 0.12, phase: 0.15, colors: ['#777777', '#aaaaaa', '#dddddd'] },
3846
+ { orbit: 0.27, size: 0.9, speed: 0.09, phase: 2.4, colors: ['#666666', '#999999', '#cccccc'], atmosphere: '#888888' },
3847
+ { orbit: 0.37, size: 1.05, speed: 0.07, phase: 4.55, colors: ['#555555', '#888888', '#bbbbbb', '#eeeeee'], bands: true, moons: 1, atmosphere: '#aaaaaa' },
3848
+ { orbit: 0.47, size: 0.8, speed: 0.055, phase: 1.35, colors: ['#666666', '#999999', '#cccccc'] },
3849
+ { orbit: 0.58, size: 1.8, speed: 0.035, phase: 3.7, colors: ['#444444', '#777777', '#aaaaaa', '#dddddd'], bands: true, moons: 4 },
3850
+ { orbit: 0.69, size: 1.7, speed: 0.025, phase: 5.65, colors: ['#555555', '#888888', '#bbbbbb'], rings: true, moons: 3 },
3851
+ { orbit: 0.79, size: 1.25, speed: 0.018, phase: 0.65, colors: ['#4a4a4a', '#7a7a7a', '#aaaaaa'], rings: true, moons: 2 },
3852
+ { orbit: 0.89, size: 1.15, speed: 0.014, phase: 2.85, colors: ['#404050', '#707080', '#a0a0b0'], moons: 1 },
3853
+ { orbit: 0.97, size: 0.65, speed: 0.01, phase: 5.0, colors: ['#555555', '#888888', '#bbbbbb'] }
3854
+ ]
3855
+ : [
3856
+ { orbit: 0.17, size: 0.65, speed: 0.12, phase: 0.15, colors: ['#6a6a6a', '#9a9a9a', '#c8c8c8', '#e8e8e8'] },
3857
+ { orbit: 0.27, size: 0.9, speed: 0.09, phase: 2.4, colors: ['#a06030', '#d09050', '#f0c070', '#fff0b0'], atmosphere: '#ffc080' },
3858
+ { orbit: 0.37, size: 1.05, speed: 0.07, phase: 4.55, colors: ['#184070', '#2a70b0', '#50b0e8', '#a0e0ff', '#d8f4ff'], bands: true, moons: 1, atmosphere: '#60c0ff' },
3859
+ { orbit: 0.47, size: 0.8, speed: 0.055, phase: 1.35, colors: ['#802010', '#c04020', '#e07040', '#f0a070'] },
3860
+ { orbit: 0.58, size: 1.85, speed: 0.035, phase: 3.7, colors: ['#a07040', '#d0a060', '#f0c888', '#fff0d0', '#ffffff'], bands: true, moons: 4 },
3861
+ { orbit: 0.69, size: 1.7, speed: 0.025, phase: 5.65, colors: ['#a08840', '#d0b868', '#f0e0a0', '#fff8d0'], rings: true, moons: 3 },
3862
+ { orbit: 0.79, size: 1.25, speed: 0.018, phase: 0.65, colors: ['#40a0a8', '#70d0d8', '#a8f0f4', '#d8ffff'], rings: true, moons: 2, atmosphere: '#80e0e8' },
3863
+ { orbit: 0.89, size: 1.15, speed: 0.014, phase: 2.85, colors: ['#2028a0', '#4048d0', '#7080ff', '#a0b0ff'], moons: 1, atmosphere: '#5060e0' },
3864
+ { orbit: 0.97, size: 0.65, speed: 0.01, phase: 5.0, colors: ['#807060', '#a09080', '#c0b0a0', '#e0d0c0'] }
3865
+ ];
3866
+ // Faint orbital paths (denser sampling)
3867
+ for (const p of planets) {
3868
+ const orbitRx = p.orbit * width * 0.48;
3869
+ const orbitRy = p.orbit * height * 0.42;
3870
+ for (let a = 0; a < Math.PI * 2; a += 0.045) {
3871
+ const ox = Math.round(cx + Math.cos(a) * orbitRx);
3872
+ const oy = Math.round(cy + Math.sin(a) * orbitRy);
3873
+ const cell = grid[oy]?.[ox]?.text;
3874
+ if (cell === ' ' || cell === '·' || cell === '˙' || cell === '░') {
3875
+ paintCell(grid, ox, oy, '·', theme === 'mono' ? '#2a2a38' : '#1a2848');
3876
+ }
3877
+ }
3878
+ }
3879
+ for (const p of planets) {
3880
+ const angle = pulse * p.speed + p.phase;
3881
+ const orbitRx = p.orbit * width * 0.48;
3882
+ const orbitRy = p.orbit * height * 0.42;
3883
+ const px = cx + Math.cos(angle) * orbitRx;
3884
+ const py = cy + Math.sin(angle) * orbitRy;
3885
+ const behindSun = Math.sin(angle) < 0;
3886
+ const paintPlanetCell = (x, y, text, color) => {
3887
+ const sunDx = (x - cx) / 1.95;
3888
+ const sunDy = y - cy;
3889
+ const hiddenBySun = behindSun && Math.sqrt(sunDx * sunDx + sunDy * sunDy) <= sunR * 1.55;
3890
+ if (!hiddenBySun) {
3891
+ paintCell(grid, x, y, text, color);
3892
+ }
3893
+ };
3894
+ // Light direction: toward the sun (center)
3895
+ const toSunX = cx - px;
3896
+ const toSunY = cy - py;
3897
+ const toSunLen = Math.sqrt(toSunX * toSunX + toSunY * toSunY) + 0.001;
3898
+ const lx = toSunX / toSunLen;
3899
+ const ly = toSunY / toSunLen;
3900
+ // Rings first (behind body)
3901
+ if (p.rings) {
3902
+ const ringColors = theme === 'mono' ? ['#666666', '#999999', '#cccccc'] : ['#a09060', '#d0b880', '#f0e0b0', '#807050'];
3903
+ for (let a = 0; a < Math.PI * 2; a += 0.035) {
3904
+ for (const scale of [1.55, 1.85, 2.15, 2.45]) {
3905
+ const rx = Math.round(px + Math.cos(a) * p.size * scale * 2.15);
3906
+ const ry = Math.round(py + Math.sin(a) * p.size * scale * 0.42);
3907
+ const ddx = (rx - px) / 2;
3908
+ const ddy = ry - py;
3909
+ if (ddx * ddx + ddy * ddy < p.size * p.size * 0.9) {
3910
+ continue;
3911
+ }
3912
+ // Cassini-ish gap
3913
+ if (scale > 1.9 && scale < 2.1 && hashNoise(rx, ry, 0) > 0.55) {
3914
+ continue;
1861
3915
  }
3916
+ const gi = Math.min(ringColors.length - 1, Math.floor((scale - 1.5) * 2));
3917
+ paintPlanetCell(rx, ry, scale > 2.2 ? '·' : '═', ringColors[gi] ?? accent);
1862
3918
  }
1863
- if (done) {
1864
- break;
3919
+ }
3920
+ }
3921
+ // Thin atmosphere halo
3922
+ if (p.atmosphere) {
3923
+ const ar = p.size * 1.25;
3924
+ for (let dy = -Math.ceil(ar); dy <= Math.ceil(ar); dy += 1) {
3925
+ for (let dx = -Math.ceil(ar * 2); dx <= Math.ceil(ar * 2); dx += 1) {
3926
+ const nx = dx / 2 / ar;
3927
+ const ny = dy / ar;
3928
+ const d2 = nx * nx + ny * ny;
3929
+ if (d2 > 1 || d2 < 0.72) {
3930
+ continue;
3931
+ }
3932
+ paintPlanetCell(Math.round(px + dx), Math.round(py + dy), '·', p.atmosphere);
1865
3933
  }
1866
3934
  }
1867
- let chosen = roots[0];
1868
- let best = Infinity;
1869
- for (const root of roots) {
1870
- const dist = (zx - root.x) ** 2 + (zy - root.y) ** 2;
1871
- if (dist < best) {
1872
- best = dist;
1873
- chosen = root;
3935
+ }
3936
+ // Planet body with sun-facing lighting
3937
+ const pr = p.size;
3938
+ for (let dy = -Math.ceil(pr); dy <= Math.ceil(pr); dy += 1) {
3939
+ for (let dx = -Math.ceil(pr * 2); dx <= Math.ceil(pr * 2); dx += 1) {
3940
+ const nx = dx / 2 / pr;
3941
+ const ny = dy / pr;
3942
+ const d2 = nx * nx + ny * ny;
3943
+ if (d2 > 1) {
3944
+ continue;
3945
+ }
3946
+ // N·L lighting toward sun
3947
+ const ndx = nx;
3948
+ const ndy = ny;
3949
+ const ndz = Math.sqrt(Math.max(0, 1 - d2));
3950
+ const lambert = clampNumber(ndx * lx + ndy * ly + ndz * 0.55, 0.08, 1);
3951
+ let shade = lambert * (0.55 + 0.45 * (1 - d2));
3952
+ if (p.bands) {
3953
+ const band = 0.7 + 0.3 * Math.sin(ny * 9 + pulse * 0.04 + nx * 1.5);
3954
+ // Great-spot-ish oval on gas giants
3955
+ const spot = Math.exp(-Math.pow((nx - 0.25) / 0.35, 2) - Math.pow((ny - 0.15) / 0.2, 2));
3956
+ shade *= band * (1 - spot * 0.35);
3957
+ if (spot > 0.45) {
3958
+ shade *= 0.75;
3959
+ }
3960
+ }
3961
+ // Surface roughness on rocky worlds
3962
+ if (!p.bands && !p.rings) {
3963
+ shade *= 0.85 + 0.15 * hashNoise(Math.floor(px + dx), Math.floor(py + dy), 7);
1874
3964
  }
3965
+ const ci = Math.min(p.colors.length - 1, Math.floor(clampNumber(shade, 0, 0.99) * p.colors.length));
3966
+ const glyph = d2 > 0.78 ? '▒' : d2 > 0.45 ? '▓' : '█';
3967
+ paintPlanetCell(Math.round(px + dx), Math.round(py + dy), glyph, p.colors[ci] ?? accent);
1875
3968
  }
1876
- const t = 1 - iter / maxIter;
1877
- const shadeIndex = Math.min(shades.length - 1, 1 + Math.floor(t * (shades.length - 1)));
1878
- paintCell(grid, x, y, shades[shadeIndex] ?? '·', chosen.color);
3969
+ }
3970
+ // Specular glint on lit limb
3971
+ const hx = Math.round(px + lx * pr * 0.55);
3972
+ const hy = Math.round(py + ly * pr * 0.45);
3973
+ paintPlanetCell(hx, hy, '·', '#ffffff');
3974
+ // Moons with tiny discs when large enough
3975
+ const moonCount = p.moons ?? 0;
3976
+ for (let m = 0; m < moonCount; m += 1) {
3977
+ const moonAngle = pulse * (p.speed * (2.8 + m * 0.9)) + p.phase * 2 + m * 1.7;
3978
+ const dist = p.size * (2.6 + m * 1.15) + 1.6;
3979
+ const mx = px + Math.cos(moonAngle) * dist * 1.65;
3980
+ const my = py + Math.sin(moonAngle) * dist * 0.72;
3981
+ const moonColor = theme === 'mono' ? '#bbbbbb' : m === 0 ? '#d0d0e0' : '#a0a0b8';
3982
+ paintPlanetCell(Math.round(mx), Math.round(my), m === 0 ? '●' : '·', moonColor);
3983
+ if (m === 0 && p.size > 1.5) {
3984
+ paintPlanetCell(Math.round(mx) + 1, Math.round(my), '·', moonColor);
3985
+ }
3986
+ }
3987
+ }
3988
+ // Dense asteroid belt (Mars–Jupiter) + Kuiper dust
3989
+ for (let a = 0; a < 90; a += 1) {
3990
+ const ang = pulse * 0.01 + a * 0.21 + hashNoise(a, 9, 0) * 0.4;
3991
+ const r = 0.34 + hashNoise(a, 10, 0) * 0.07;
3992
+ const ax = Math.round(cx + Math.cos(ang) * r * width * 0.48);
3993
+ const ay = Math.round(cy + Math.sin(ang) * r * height * 0.42);
3994
+ if (hashNoise(a, 11, Math.floor(pulse / 10)) > 0.28) {
3995
+ paintCell(grid, ax, ay, a % 5 === 0 ? '+' : '·', theme === 'mono' ? '#666666' : '#7a6a50');
3996
+ }
3997
+ }
3998
+ for (let a = 0; a < 40; a += 1) {
3999
+ const ang = pulse * 0.006 + a * 0.4 + hashNoise(a, 12, 0);
4000
+ const r = 0.88 + hashNoise(a, 13, 0) * 0.08;
4001
+ const ax = Math.round(cx + Math.cos(ang) * r * width * 0.48);
4002
+ const ay = Math.round(cy + Math.sin(ang) * r * height * 0.42);
4003
+ if (hashNoise(a, 14, Math.floor(pulse / 12)) > 0.4) {
4004
+ paintCell(grid, ax, ay, '·', theme === 'mono' ? '#444444' : '#4a4058');
4005
+ }
4006
+ }
4007
+ // Occasional comet with tail pointing away from sun
4008
+ const cometPhase = (pulse * 0.012) % 1;
4009
+ if (cometPhase < 0.85) {
4010
+ const cAng = pulse * 0.011 + 1.2;
4011
+ const cR = 0.55 + cometPhase * 0.35;
4012
+ const comX = cx + Math.cos(cAng) * cR * width * 0.48;
4013
+ const comY = cy + Math.sin(cAng) * cR * height * 0.42;
4014
+ paintCell(grid, Math.round(comX), Math.round(comY), '●', '#ffffff');
4015
+ // Tail away from sun
4016
+ for (let t = 1; t <= 8; t += 1) {
4017
+ const tx = Math.round(comX + (comX - cx) * 0.04 * t);
4018
+ const ty = Math.round(comY + (comY - cy) * 0.04 * t + Math.sin(t + pulse * 0.1) * 0.3);
4019
+ paintCell(grid, tx, ty, t < 3 ? '═' : t < 6 ? '·' : '˙', theme === 'mono' ? '#aaaaaa' : '#c0d8ff');
4020
+ }
4021
+ }
4022
+ return grid.map(row => lineFromCells(row, accent));
4023
+ }
4024
+ function receiverArtPalette(theme, colors) {
4025
+ if (theme !== 'mono') {
4026
+ return colors;
4027
+ }
4028
+ return ['#181818', '#303030', '#505050', '#777777', '#9a9a9a', '#bcbcbc', '#dddddd', '#ffffff'];
4029
+ }
4030
+ function receiverBandEnergy(pulse, startBand, samples = 6) {
4031
+ let total = 0;
4032
+ for (let index = 0; index < samples; index += 1) {
4033
+ total += spectrumSample(startBand + index * 1.7, pulse + index * 0.11);
4034
+ }
4035
+ return total / Math.max(1, samples);
4036
+ }
4037
+ /** A deep-perspective indigo ocean rendered with restrained sumi marks. */
4038
+ function buildSumiOcean(pulse, width, height, theme) {
4039
+ const accent = themeAccent(theme);
4040
+ const palette = receiverArtPalette(theme, ['#020817', '#06172c', '#092743', '#0d3c60', '#15567d', '#2777a1', '#66a9cc', '#d7edf5']);
4041
+ const grid = emptyMotionGrid(width, height, palette[0] ?? '#020817');
4042
+ const moonX = width * (0.73 + Math.sin(pulse * 0.006) * 0.015);
4043
+ const moonY = height * 0.22;
4044
+ const moonRadius = Math.max(1.1, height * 0.11);
4045
+ for (let y = 0; y < height; y += 1) {
4046
+ for (let x = 0; x < width; x += 1) {
4047
+ const dx = (x - moonX) / 1.9;
4048
+ const dy = y - moonY;
4049
+ const distance = Math.sqrt(dx * dx + dy * dy);
4050
+ if (distance <= moonRadius) {
4051
+ grid[y][x] = { text: distance < moonRadius * 0.72 ? '█' : '▓', color: palette[7] ?? accent, bold: distance < moonRadius * 0.55 };
4052
+ }
4053
+ else if (hashNoise(x, y, 0) > 0.992) {
4054
+ grid[y][x] = { text: '·', color: palette[5] ?? accent };
4055
+ }
4056
+ }
4057
+ }
4058
+ const horizon = 0.4;
4059
+ const layers = [
4060
+ { base: 0.42, amp: 0.035, cycles: 15, speed: 0.009, color: 2, glyph: '·' },
4061
+ { base: 0.47, amp: 0.038, cycles: 12, speed: 0.012, color: 2, glyph: '░' },
4062
+ { base: 0.53, amp: 0.042, cycles: 10, speed: 0.016, color: 3, glyph: '░' },
4063
+ { base: 0.61, amp: 0.05, cycles: 8, speed: 0.021, color: 3, glyph: '▒' },
4064
+ { base: 0.71, amp: 0.06, cycles: 6.5, speed: 0.027, color: 4, glyph: '▒' },
4065
+ { base: 0.83, amp: 0.075, cycles: 5.2, speed: 0.034, color: 5, glyph: '▓' },
4066
+ { base: 0.96, amp: 0.1, cycles: 4.2, speed: 0.042, color: 5, glyph: '█' }
4067
+ ];
4068
+ const bass = receiverBandEnergy(pulse, 2);
4069
+ // The water plane grows darker and more textured toward the viewer. Far
4070
+ // water is quiet and compressed at the horizon; nearby water occupies more
4071
+ // screen space, producing a clear vanishing-depth read.
4072
+ const horizonRow = Math.max(0, Math.round(height * horizon));
4073
+ for (let y = horizonRow; y < height; y += 1) {
4074
+ const depth = (y - horizonRow) / Math.max(1, height - 1 - horizonRow);
4075
+ const colorIndex = Math.min(5, 2 + Math.floor(depth * 4));
4076
+ for (let x = 0; x < width; x += 1) {
4077
+ const texture = hashNoise(x, y, 41);
4078
+ const glyph = texture > 0.975
4079
+ ? ' '
4080
+ : depth > 0.68
4081
+ ? texture > 0.48 ? '▓' : '▒'
4082
+ : depth > 0.32
4083
+ ? texture > 0.62 ? '▒' : '░'
4084
+ : texture > 0.76 ? '░' : '·';
4085
+ grid[y][x] = { text: glyph, color: palette[colorIndex] ?? accent };
4086
+ }
4087
+ }
4088
+ layers.forEach((layer, layerIndex) => {
4089
+ for (let x = 0; x < width; x += 1) {
4090
+ const nx = x / Math.max(1, width - 1);
4091
+ const phase = nx * layer.cycles * Math.PI * 2 + pulse * layer.speed + layerIndex * 1.9;
4092
+ const ridgeNoise = clampNumber(0.5 +
4093
+ Math.sin(phase) * 0.27 +
4094
+ Math.sin(phase * 1.83 + 0.9) * 0.16 +
4095
+ Math.cos(phase * 3.2 - 0.4) * 0.07, 0, 1);
4096
+ const near = layerIndex / Math.max(1, layers.length - 1);
4097
+ const ridgeY = Math.round(height * (layer.base + layer.amp * (1 - ridgeNoise * 2) - (near > 0.7 ? bass * 0.018 : 0)));
4098
+ const crest = layerIndex < 3 ? '─' : '━';
4099
+ paintCell(grid, x, ridgeY, crest, palette[Math.min(6, layer.color + 1)] ?? accent);
4100
+ if (ridgeY + 1 < height && layerIndex > 1) {
4101
+ paintCell(grid, x, ridgeY + 1, layer.glyph, palette[layer.color] ?? accent);
4102
+ }
4103
+ if (layerIndex > 3 && ridgeY > 0 && (x + layerIndex * 3) % 13 < 3 + Math.floor(near * 3)) {
4104
+ paintCell(grid, x, ridgeY - 1, layerIndex > 5 ? '▀' : '·', palette[6] ?? accent);
4105
+ }
4106
+ }
4107
+ });
4108
+ // A narrow moon path widens toward the foreground, reinforcing the same
4109
+ // perspective even in short terminal viewports.
4110
+ for (let y = horizonRow; y < height; y += 1) {
4111
+ const depth = (y - horizonRow) / Math.max(1, height - 1 - horizonRow);
4112
+ const reflectionHalfWidth = 1 + Math.floor(depth * Math.max(2, width * 0.045));
4113
+ const center = Math.round(moonX + Math.sin(y * 2.1 + pulse * 0.025) * reflectionHalfWidth * 0.45);
4114
+ for (let offset = -reflectionHalfWidth; offset <= reflectionHalfWidth; offset += 1) {
4115
+ if (hashNoise(offset + 30, y, Math.floor(pulse / 12)) > 0.48 + depth * 0.14) {
4116
+ paintCell(grid, center + offset, y, depth > 0.65 ? '━' : '─', palette[6] ?? accent);
4117
+ }
4118
+ }
4119
+ }
4120
+ // Two distant seabirds keep the horizon alive without making it busy.
4121
+ const birdX = Math.round(width * (0.24 + 0.04 * Math.sin(pulse * 0.01)));
4122
+ const birdY = Math.max(1, Math.round(height * 0.2));
4123
+ paintCell(grid, birdX - 1, birdY, '⌁', palette[6] ?? accent);
4124
+ paintCell(grid, birdX + 2, birdY + 1, '⌁', palette[5] ?? accent);
4125
+ // A blue water plane beneath the block texture removes isolated black gaps
4126
+ // while retaining the foreground-to-horizon depth bands.
4127
+ for (let y = horizonRow; y < height; y += 1) {
4128
+ const depth = (y - horizonRow) / Math.max(1, height - 1 - horizonRow);
4129
+ const backgroundIndex = depth > 0.68 ? 3 : depth > 0.28 ? 2 : 1;
4130
+ const backgroundColor = palette[backgroundIndex] ?? palette[1] ?? accent;
4131
+ for (let x = 0; x < width; x += 1) {
4132
+ const cell = grid[y][x];
4133
+ grid[y][x] = { ...cell, backgroundColor };
1879
4134
  }
1880
4135
  }
1881
4136
  return grid.map(row => lineFromCells(row, accent));
1882
4137
  }
4138
+ function rgbColor(red, green, blue) {
4139
+ const channel = (value) => Math.round(clampNumber(value, 0, 255)).toString(16).padStart(2, '0');
4140
+ return `#${channel(red)}${channel(green)}${channel(blue)}`;
4141
+ }
1883
4142
  function lineFromCells(cells, fallbackColor) {
1884
4143
  const text = cells.map(cell => cell.text).join('');
1885
4144
  const segments = [];
1886
4145
  for (const cell of cells) {
4146
+ const invisibleForeground = cell.text.trim() === '' && cell.backgroundColor === undefined;
4147
+ const color = invisibleForeground ? fallbackColor : cell.color;
4148
+ const bold = invisibleForeground ? undefined : cell.bold;
1887
4149
  const previous = segments[segments.length - 1];
1888
4150
  if (previous &&
1889
- previous.color === cell.color &&
4151
+ previous.color === color &&
1890
4152
  previous.backgroundColor === cell.backgroundColor &&
1891
- previous.bold === cell.bold) {
4153
+ previous.bold === bold) {
1892
4154
  previous.text += cell.text;
1893
4155
  }
1894
4156
  else {
1895
4157
  segments.push({
1896
4158
  text: cell.text,
1897
- color: cell.color,
4159
+ color,
1898
4160
  backgroundColor: cell.backgroundColor,
1899
- bold: cell.bold
4161
+ bold
1900
4162
  });
1901
4163
  }
1902
4164
  }
@@ -1905,36 +4167,6 @@ function lineFromCells(cells, fallbackColor) {
1905
4167
  function emptyMotionGrid(width, height, color) {
1906
4168
  return Array.from({ length: height }, () => Array.from({ length: width }, () => ({ text: ' ', color })));
1907
4169
  }
1908
- function setMotionCell(grid, x, y, text, color) {
1909
- const row = grid[y];
1910
- const cell = row?.[x];
1911
- if (!row || !cell) {
1912
- return;
1913
- }
1914
- row[x] = cell.text === ' ' ? { text, color } : { text: '█', color: '#ffe45c' };
1915
- }
1916
- function motionEnvelope(position, pulse, variant) {
1917
- const t = pulse * 0.13 + variant;
1918
- const centers = [
1919
- 0.12 + 0.035 * Math.sin(t * 0.9),
1920
- 0.34 + 0.045 * Math.cos(t * 0.55 + variant),
1921
- 0.58 + 0.05 * Math.sin(t * 0.72 + 1.4),
1922
- 0.80 + 0.032 * Math.cos(t * 1.1)
1923
- ];
1924
- const widths = [0.065, 0.095, 0.08, 0.055];
1925
- const weights = [0.95, 0.7, 1.0, 0.42];
1926
- let value = 0.06;
1927
- for (let index = 0; index < centers.length; index += 1) {
1928
- const center = centers[index] ?? 0.5;
1929
- const width = widths[index] ?? 0.08;
1930
- const weight = weights[index] ?? 0.5;
1931
- value += weight * Math.exp(-Math.pow((position - center) / width, 2));
1932
- }
1933
- const ripple = 0.1 * Math.sin(position * 30 + t * 2.4) +
1934
- 0.06 * Math.cos(position * 61 - t * 1.6) +
1935
- 0.04 * Math.sin(position * 97 + variant * 3.1);
1936
- return clampNumber(value + ripple, 0.03, 1);
1937
- }
1938
4170
  function motionColorAt(position, theme) {
1939
4171
  if (theme === 'mono') {
1940
4172
  const mono = ['#767676', '#9a9a9a', '#b0b0b0', '#d0d0d0'];
@@ -1945,138 +4177,14 @@ function motionColorAt(position, theme) {
1945
4177
  const index = Math.min(palette.length - 1, Math.max(0, Math.floor(position * palette.length)));
1946
4178
  return palette[index] ?? '#6ee7f2';
1947
4179
  }
1948
- function dimMotionColorAt(position, theme) {
1949
- if (theme === 'mono') {
1950
- return '#767676';
1951
- }
1952
- const palette = ['#245760', '#315f49', '#626629', '#6b5523', '#6e3728', '#5b2444', '#3f2c5c', '#2f4368'];
1953
- const index = Math.min(palette.length - 1, Math.max(0, Math.floor(position * palette.length)));
1954
- return palette[index] ?? '#245760';
1955
- }
1956
4180
  export function visualizerHeight(style, availableRows, width = 80) {
1957
- const maxRowsByStyle = {
1958
- ultracode: 14,
1959
- 'motion-blob': 12,
1960
- 'motion-area': 11,
1961
- 'motion-contour': 14,
1962
- leds: 10,
1963
- matrix: 14,
1964
- hologram: 12,
1965
- cube: 14,
1966
- fire: 14,
1967
- fireworks: 14,
1968
- plasma: 14,
1969
- 'spinning-donut': 14,
1970
- starfield: 14,
1971
- mesh: 14,
1972
- ribbon: 12,
1973
- orbits: 12,
1974
- mirror: 12,
1975
- tunnel: 14,
1976
- kaleidoscope: 14,
1977
- constellation: 12,
1978
- 'pulse-grid': 12,
1979
- lissajous: 14,
1980
- 'braille-wave': 12,
1981
- 'radial-eq': 14,
1982
- spectrogram: 12,
1983
- nebula: 14,
1984
- silk: 14,
1985
- 'ripple-tank': 14,
1986
- phyllotaxis: 14,
1987
- harmonograph: 14,
1988
- 'bloom-bars': 12,
1989
- moire: 14,
1990
- galaxy: 14,
1991
- caustics: 14,
1992
- lorenz: 14,
1993
- fern: 14,
1994
- chladni: 14,
1995
- tesseract: 14,
1996
- 'torus-knot': 14,
1997
- rotozoomer: 14,
1998
- 'fractal-tree': 14,
1999
- julia: 14,
2000
- clifford: 14,
2001
- goniometer: 14,
2002
- 'copper-bars': 14,
2003
- twister: 14,
2004
- coral: 14,
2005
- cyclone: 14,
2006
- 'lava-lamp': 14,
2007
- newton: 14
2008
- };
2009
- const spaciousStyles = new Set([
2010
- 'ultracode',
2011
- 'motion-blob',
2012
- 'motion-area',
2013
- 'motion-contour',
2014
- 'leds',
2015
- 'matrix',
2016
- 'hologram',
2017
- 'cube',
2018
- 'fire',
2019
- 'fireworks',
2020
- 'plasma',
2021
- 'spinning-donut',
2022
- 'starfield',
2023
- 'mesh',
2024
- 'ribbon',
2025
- 'orbits',
2026
- 'mirror',
2027
- 'tunnel',
2028
- 'kaleidoscope',
2029
- 'constellation',
2030
- 'pulse-grid',
2031
- 'lissajous',
2032
- 'braille-wave',
2033
- 'radial-eq',
2034
- 'spectrogram',
2035
- 'nebula',
2036
- 'silk',
2037
- 'ripple-tank',
2038
- 'phyllotaxis',
2039
- 'harmonograph',
2040
- 'bloom-bars',
2041
- 'moire',
2042
- 'galaxy',
2043
- 'caustics',
2044
- 'lorenz',
2045
- 'fern',
2046
- 'chladni',
2047
- 'tesseract',
2048
- 'torus-knot',
2049
- 'rotozoomer',
2050
- 'fractal-tree',
2051
- 'julia',
2052
- 'clifford',
2053
- 'goniometer',
2054
- 'copper-bars',
2055
- 'twister',
2056
- 'coral',
2057
- 'cyclone',
2058
- 'lava-lamp',
2059
- 'newton'
2060
- ]);
2061
- const minRows = style === 'ultracode'
2062
- ? 7
2063
- : style === 'cube' || style === 'motion-contour' || style === 'spinning-donut'
2064
- ? 8
2065
- : spaciousStyles.has(style)
2066
- ? 7
2067
- : style.startsWith('motion-')
2068
- ? 6
2069
- : 3;
2070
- const baseMaxRows = maxRowsByStyle[style] ?? 8;
2071
- const maxRows = responsiveVisualizerMaxRows(style, availableRows, width, baseMaxRows, spaciousStyles);
2072
- return Math.max(minRows, Math.min(maxRows, availableRows));
2073
- }
2074
- function responsiveVisualizerMaxRows(style, availableRows, width, baseMaxRows, spaciousStyles) {
2075
- if (!spaciousStyles.has(style)) {
2076
- return baseMaxRows;
2077
- }
2078
- const landscapeSafeRows = Math.max(baseMaxRows, Math.floor(Math.max(1, width) / 3));
2079
- return Math.min(availableRows, landscapeSafeRows);
4181
+ const metadata = receiverStyleMetadata[style];
4182
+ const safeAvailableRows = Math.max(1, availableRows);
4183
+ const landscapeSafeRows = Math.max(metadata.maxRows, Math.floor(Math.max(1, width) / 3));
4184
+ const maxRows = Math.min(safeAvailableRows, landscapeSafeRows);
4185
+ // Never claim more rows than the parent can provide. Individual builders
4186
+ // already degrade gracefully below their preferred minimums.
4187
+ return Math.min(safeAvailableRows, Math.max(metadata.minRows, maxRows));
2080
4188
  }
2081
4189
  function cubeVertices() {
2082
4190
  return [
@@ -2202,6 +4310,9 @@ function hashNoise(x, y, pulse) {
2202
4310
  const value = Math.sin(x * 12.9898 + y * 78.233 + pulse * 0.037) * 43758.5453;
2203
4311
  return value - Math.floor(value);
2204
4312
  }
4313
+ function winterPrecipitationSpeed(index) {
4314
+ return 0.5 + hashNoise(index, 51, 0) * 0.8;
4315
+ }
2205
4316
  function clampNumber(value, min, max) {
2206
4317
  return Math.min(max, Math.max(min, value));
2207
4318
  }