@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.
- package/CHANGELOG.md +44 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +36 -2
- package/dist/player/airplay-discovery.js +16 -4
- package/dist/player/airplay-worker-protocol.js +1 -0
- package/dist/player/airplay-worker.js +4 -1
- package/dist/player/command.js +37 -14
- package/dist/player/mpv-ipc-client.js +194 -0
- package/dist/player/player-controller.js +169 -103
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +26 -6
- package/dist/providers/radio-browser.js +139 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +253 -152
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +251 -47
- package/dist/ui/AppContent.js +9 -10
- package/dist/ui/app-state.js +26 -16
- package/dist/ui/ascii.js +42 -1
- package/dist/ui/components/Logo.js +6 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +8 -6
- package/dist/ui/components/TopTabs.js +8 -7
- package/dist/ui/cosmo-land-data.js +1 -1
- package/dist/ui/exit-confirmation.js +7 -0
- package/dist/ui/explore-map-layout.js +3 -1
- package/dist/ui/format.js +56 -2
- package/dist/ui/help-content.js +9 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +12 -1
- package/dist/ui/screen-items.js +60 -22
- package/dist/ui/screen-meta.js +35 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +1 -1
- package/dist/ui/screens/HelpScreen.js +17 -3
- package/dist/ui/screens/HomeScreen.js +2 -3
- package/dist/ui/screens/MapScreen.js +2 -2
- package/dist/ui/screens/NowPlayingScreen.js +31 -51
- package/dist/ui/screens/SearchScreen.js +1 -1
- package/dist/ui/screens/SettingsScreen.js +93 -21
- package/dist/ui/screens/StationScreen.js +14 -1
- package/dist/ui/screens/StatsScreen.js +35 -44
- package/dist/ui/system-actions.js +10 -3
- package/dist/ui/terminal-mouse.js +29 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +24 -7
- package/dist/ui/use-command-executor.js +28 -0
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -235
|
@@ -1,162 +1,160 @@
|
|
|
1
1
|
import { themeAccent, themeContributionColors } from '../theme.js';
|
|
2
|
-
|
|
3
|
-
|
|
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 (
|
|
7
|
-
return
|
|
70
|
+
if (viewport === 'micro') {
|
|
71
|
+
return buildMicroVisualizer(style, pulse, width, height, theme);
|
|
8
72
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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 (
|
|
148
|
-
|
|
121
|
+
else if (line) {
|
|
122
|
+
for (const text of Array.from(line.text)) {
|
|
123
|
+
cells.push({ text, color: line.color });
|
|
124
|
+
}
|
|
149
125
|
}
|
|
150
|
-
|
|
151
|
-
|
|
126
|
+
while (cells.length < width) {
|
|
127
|
+
cells.push({ text: ' ', color: fallbackColor });
|
|
152
128
|
}
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
151
|
+
return selected;
|
|
157
152
|
}
|
|
158
|
-
function
|
|
159
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
533
|
-
const
|
|
534
|
-
|
|
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
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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
|
|
557
|
-
const
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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 *
|
|
627
|
-
const screenY = Math.round(cy + scale * inverseZ * y * 0.
|
|
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
|
-
|
|
641
|
-
|
|
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:
|
|
721
|
+
color: accent
|
|
644
722
|
}));
|
|
645
723
|
}
|
|
646
724
|
function buildAsciiStarfield(pulse, width, height, theme) {
|
|
647
|
-
const
|
|
648
|
-
const
|
|
649
|
-
const cx = width / 2;
|
|
650
|
-
const cy = height / 2;
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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, '·',
|
|
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
|
|
998
|
+
const canvas = createBraille(width, height);
|
|
874
999
|
const accent = themeAccent(theme);
|
|
875
1000
|
const colors = themeContributionColors(theme);
|
|
876
|
-
const
|
|
877
|
-
const
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
|
885
|
-
const
|
|
886
|
-
|
|
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
|
|
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
|
-
|
|
1256
|
-
const
|
|
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) /
|
|
1261
|
-
const db = Math.sqrt(((x - centerB.x) /
|
|
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
|
|
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 =
|
|
1282
|
-
const rotation = pulse * 0.
|
|
1283
|
-
const span = Math.min(dotsWide, dotsHigh) * 0.
|
|
1284
|
-
|
|
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
|
-
|
|
1290
|
-
const
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
|
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
|
|
1746
|
-
const
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
const
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
1762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
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
|
|
1771
|
-
const
|
|
1772
|
-
const
|
|
1773
|
-
const
|
|
1774
|
-
const
|
|
1775
|
-
const
|
|
1776
|
-
for (let
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
const
|
|
1785
|
-
const
|
|
1786
|
-
const
|
|
1787
|
-
|
|
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
|
-
|
|
1791
|
-
return brailleToLines(canvas, accent);
|
|
3564
|
+
return cellsGrid.map(row => lineFromCells(row, accent));
|
|
1792
3565
|
}
|
|
1793
|
-
function
|
|
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
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
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
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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
|
|
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
|
|
1827
|
-
|
|
1828
|
-
{
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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
|
|
1840
|
-
const
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
const
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
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
|
-
|
|
1864
|
-
|
|
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
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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 ===
|
|
4151
|
+
previous.color === color &&
|
|
1890
4152
|
previous.backgroundColor === cell.backgroundColor &&
|
|
1891
|
-
previous.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
|
|
4159
|
+
color,
|
|
1898
4160
|
backgroundColor: cell.backgroundColor,
|
|
1899
|
-
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
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
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
|
}
|