|
15 | 15 | const FPS = 60; |
16 | 16 | const FRAME_MS = 1000 / FPS; |
17 | 17 |
|
18 | | - // Matrix rain tunables |
19 | | - const STREAM_SPEED_MIN = 0.25; // rows per frame |
20 | | - const STREAM_SPEED_MAX = 0.65; |
21 | | - const STREAM_LEN_MIN = 10; // trail length in cells |
22 | | - const STREAM_LEN_MAX = 24; |
23 | | - const COL_SPAWN_MIN = 20; // frames between spawns per column |
24 | | - const COL_SPAWN_MAX = 120; |
25 | | - const HEAD_ALPHA = 0.92; // alpha of the leading head cell |
| 18 | + // Radiation tunables |
| 19 | + const BASE_A = 0.04; // base alpha of idle characters |
| 20 | + |
| 21 | + // Cloud-chamber streak tunables |
| 22 | + const STREAK_SPEED_MIN = 2.0; // cells per frame |
| 23 | + const STREAK_SPEED_MAX = 5.5; |
| 24 | + const STREAK_HEAD_A = 0.80; // alpha stamped at the head cell each frame |
| 25 | + const STREAK_DECAY = 0.055; // alpha lost per frame (trail ~15 frames) |
| 26 | + const STREAK_SPAWN_PROB = 0.022; // chance per frame to spawn a new streak |
| 27 | + const STREAK_MAX = 7; // max simultaneous streaks |
26 | 28 |
|
27 | 29 | // GoL tunables |
28 | | - const GOL_ALIVE_A = 0.32; // base alpha for alive cells |
29 | | - const BASE_A = 0.04; // base alpha for dead/idle cells |
30 | | - const GOL_SEED = 0.30; // initial alive probability |
31 | | - const GOL_RESEED = 0.04; // reseed threshold (fraction of cells alive) |
32 | | - const GOL_TICK = 4; // advance GoL state every N rendered frames |
| 30 | + const GOL_ALIVE_A = 0.32; // base alpha for alive cells |
| 31 | + const GOL_SEED = 0.30; // initial alive probability |
| 32 | + const GOL_RESEED = 0.04; // reseed threshold (fraction of cells alive) |
| 33 | + const GOL_TICK = 4; // advance GoL state every N rendered frames |
33 | 34 |
|
34 | 35 | let canvas, ctx; |
35 | 36 | let cols, rows, cells; |
36 | 37 | let mouseX = -9999, mouseY = -9999; |
37 | 38 | let lastTs = 0; |
38 | | - let mode; // 'ascii' | 'gol' |
39 | | - |
40 | | - // Matrix rain state |
41 | | - let streams = []; |
42 | | - let colCooldown = []; |
| 39 | + let mode; // 'radiation' | 'gol' |
43 | 40 |
|
44 | 41 | // GoL double-buffer (Uint8Array for speed) |
45 | 42 | let golCur, golNxt; |
46 | 43 | let golFrame = 0; |
47 | 44 |
|
| 45 | + // Cloud-chamber streaks |
| 46 | + let streakA; // Float32Array — per-cell streak alpha contribution |
| 47 | + let streaks = []; |
| 48 | + |
48 | 49 | // ── Helpers ──────────────────────────────────────────────────────────────── |
49 | 50 |
|
50 | 51 | function rchar() { return CHARS[Math.random() * CHARS.length | 0]; } |
51 | 52 |
|
52 | | - // ── Matrix rain ───────────────────────────────────────────────────────────── |
| 53 | + // ── Cloud-chamber streaks ────────────────────────────────────────────────── |
53 | 54 |
|
54 | | - function initStreams() { |
55 | | - streams = []; |
56 | | - colCooldown = Array.from({ length: cols }, () => (Math.random() * COL_SPAWN_MAX) | 0); |
| 55 | + function initStreakBuffer() { |
| 56 | + streakA = new Float32Array(cols * rows); |
| 57 | + streaks = []; |
57 | 58 | } |
58 | 59 |
|
59 | | - function updateStreams() { |
60 | | - // Spawn new streams |
61 | | - for (let col = 0; col < cols; col++) { |
62 | | - if (colCooldown[col] > 0) { colCooldown[col]--; continue; } |
63 | | - // Don't spawn a new stream while one is still near the top of this column |
64 | | - const tooClose = streams.some(s => s.col === col && s.headY < STREAM_LEN_MAX + 2); |
65 | | - if (!tooClose) { |
66 | | - streams.push({ |
67 | | - col, |
68 | | - headY: 0, |
69 | | - speed: STREAM_SPEED_MIN + Math.random() * (STREAM_SPEED_MAX - STREAM_SPEED_MIN), |
70 | | - len: (STREAM_LEN_MIN + Math.random() * (STREAM_LEN_MAX - STREAM_LEN_MIN)) | 0, |
71 | | - rc: {}, // row -> char |
72 | | - }); |
73 | | - colCooldown[col] = (COL_SPAWN_MIN + Math.random() * (COL_SPAWN_MAX - COL_SPAWN_MIN)) | 0; |
| 60 | + function updateStreaks() { |
| 61 | + // Fade all trail cells |
| 62 | + for (let i = 0; i < streakA.length; i++) { |
| 63 | + if (streakA[i] > 0) { |
| 64 | + streakA[i] -= STREAK_DECAY; |
| 65 | + if (streakA[i] < 0) streakA[i] = 0; |
74 | 66 | } |
75 | 67 | } |
76 | 68 |
|
77 | | - // Advance all streams |
78 | | - for (let i = streams.length - 1; i >= 0; i--) { |
79 | | - const s = streams[i]; |
80 | | - s.headY += s.speed; |
81 | | - const hr = s.headY | 0; |
82 | | - |
83 | | - // Head char changes rapidly |
84 | | - s.rc[hr] = rchar(); |
85 | | - |
86 | | - // Assign and occasionally mutate trail chars |
87 | | - for (let d = 1; d < s.len; d++) { |
88 | | - const r = hr - d; |
89 | | - if (r < 0) continue; |
90 | | - if (!s.rc[r]) s.rc[r] = rchar(); |
91 | | - else if (Math.random() < 0.04) s.rc[r] = rchar(); |
| 69 | + // Spawn a new streak |
| 70 | + if (streaks.length < STREAK_MAX && Math.random() < STREAK_SPAWN_PROB) { |
| 71 | + // Start on a random edge |
| 72 | + const side = Math.random() * 4 | 0; |
| 73 | + let x, y; |
| 74 | + if (side === 0) { x = Math.random() * cols; y = 0; } |
| 75 | + else if (side === 1) { x = cols; y = Math.random() * rows; } |
| 76 | + else if (side === 2) { x = Math.random() * cols; y = rows; } |
| 77 | + else { x = 0; y = Math.random() * rows; } |
| 78 | + |
| 79 | + // Aim roughly toward a random interior point |
| 80 | + const tx = cols * (0.25 + Math.random() * 0.5); |
| 81 | + const ty = rows * (0.25 + Math.random() * 0.5); |
| 82 | + const dist = Math.hypot(tx - x, ty - y) || 1; |
| 83 | + const speed = STREAK_SPEED_MIN + Math.random() * (STREAK_SPEED_MAX - STREAK_SPEED_MIN); |
| 84 | + streaks.push({ x, y, dx: (tx - x) / dist, dy: (ty - y) / dist, speed }); |
| 85 | + } |
| 86 | + |
| 87 | + // Advance each streak |
| 88 | + for (let i = streaks.length - 1; i >= 0; i--) { |
| 89 | + const s = streaks[i]; |
| 90 | + s.x += s.dx * s.speed; |
| 91 | + s.y += s.dy * s.speed; |
| 92 | + const col = s.x | 0; |
| 93 | + const row = s.y | 0; |
| 94 | + if (col >= 0 && col < cols && row >= 0 && row < rows) { |
| 95 | + streakA[row * cols + col] = STREAK_HEAD_A; |
92 | 96 | } |
| 97 | + if (s.x < -1 || s.x > cols + 1 || s.y < -1 || s.y > rows + 1) { |
| 98 | + streaks.splice(i, 1); |
| 99 | + } |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + // ── Radiation ─────────────────────────────────────────────────────────────── |
93 | 104 |
|
94 | | - // Prune chars that have scrolled above the tail |
95 | | - for (const r in s.rc) { |
96 | | - if (+r < hr - s.len - 1) delete s.rc[r]; |
| 105 | + function updateRadiation() { |
| 106 | + updateStreaks(); |
| 107 | + |
| 108 | + for (let i = 0; i < cells.length; i++) { |
| 109 | + const c = cells[i]; |
| 110 | + |
| 111 | + // Character cycling |
| 112 | + if (--c.t <= 0) { |
| 113 | + c.ch = rchar(); |
| 114 | + c.t = (8 + Math.random() * 70) | 0; |
97 | 115 | } |
98 | 116 |
|
99 | | - // Remove streams whose tail is fully off the bottom |
100 | | - if (s.headY - s.len > rows + 1) streams.splice(i, 1); |
| 117 | + // Random flash trigger |
| 118 | + if (c.flash > 0) { |
| 119 | + c.flash -= FLASH_DECAY; |
| 120 | + if (c.flash < 0) c.flash = 0; |
| 121 | + } else if (Math.random() < FLASH_CHANCE) { |
| 122 | + c.flash = FLASH_PEAK; |
| 123 | + } |
101 | 124 | } |
102 | 125 | } |
103 | 126 |
|
104 | | - function drawMatrix() { |
| 127 | + function drawRadiation() { |
105 | 128 | ctx.clearRect(0, 0, canvas.width, canvas.height); |
106 | 129 | ctx.font = `${FONT_SZ}px "JetBrains Mono", monospace`; |
107 | 130 | ctx.textAlign = 'center'; |
108 | 131 | ctx.textBaseline = 'middle'; |
109 | 132 |
|
110 | | - for (const s of streams) { |
111 | | - const hr = s.headY | 0; |
112 | | - const cx = s.col * CELL + CELL * 0.5; |
113 | | - |
114 | | - for (let d = 0; d <= s.len; d++) { |
115 | | - const row = hr - d; |
116 | | - if (row < 0 || row >= rows) continue; |
117 | | - |
118 | | - const cy = row * CELL + CELL * 0.5; |
119 | | - const ch = s.rc[row] || ' '; |
120 | | - const idx = row * cols + s.col; |
121 | | - |
122 | | - if (d === 0) { |
123 | | - // Head: near-white, bright |
124 | | - ctx.fillStyle = `rgba(200,255,210,${HEAD_ALPHA})`; |
125 | | - ctx.fillText(ch, cx, cy); |
126 | | - continue; |
127 | | - } |
128 | | - |
129 | | - // Trail: fade with distance from head |
130 | | - let a = 0.7 * Math.pow(1 - d / s.len, 1.8); |
| 133 | + for (let row = 0; row < rows; row++) { |
| 134 | + const cy = row * CELL + CELL * 0.5; |
| 135 | + for (let col = 0; col < cols; col++) { |
| 136 | + const cx = col * CELL + CELL * 0.5; |
| 137 | + const c = cells[row * cols + col]; |
131 | 138 |
|
132 | | - // Mouse glow |
| 139 | + // Mouse proximity glow |
133 | 140 | const md = Math.hypot(cx - mouseX, cy - mouseY); |
134 | | - a += md < MOUSE_R ? MOUSE_PEAK * Math.pow(1 - md / MOUSE_R, 1.8) : 0; |
| 141 | + let a = BASE_A + (md < MOUSE_R |
| 142 | + ? MOUSE_PEAK * Math.pow(1 - md / MOUSE_R, 1.8) |
| 143 | + : 0); |
135 | 144 |
|
136 | | - // Flash |
137 | | - if (cells[idx]) a += cells[idx].flash; |
| 145 | + // Random flash + streak trail |
| 146 | + a += c.flash; |
| 147 | + a += streakA[row * cols + col]; |
138 | 148 |
|
139 | 149 | if (a < 0.008) continue; |
140 | 150 | if (a > 0.88) a = 0.88; |
141 | 151 |
|
142 | 152 | ctx.fillStyle = `rgba(0,200,71,${a.toFixed(3)})`; |
143 | | - ctx.fillText(ch, cx, cy); |
| 153 | + ctx.fillText(c.ch, cx, cy); |
144 | 154 | } |
145 | 155 | } |
146 | 156 | } |
|
220 | 230 | for (let i = 0; i < n; i++) { |
221 | 231 | cells[i] = { ch: rchar(), t: (Math.random() * 70) | 0, flash: 0 }; |
222 | 232 | } |
223 | | - if (mode === 'ascii') initStreams(); |
224 | | - if (mode === 'gol') initGol(); |
| 233 | + if (mode === 'radiation') initStreakBuffer(); |
| 234 | + if (mode === 'gol') initGol(); |
225 | 235 | } |
226 | 236 |
|
227 | 237 | function update() { |
228 | | - if (mode === 'ascii') { |
229 | | - updateStreams(); |
| 238 | + if (mode === 'radiation') { |
| 239 | + updateRadiation(); |
230 | 240 | } else { |
231 | | - if (++golFrame >= GOL_TICK) { golFrame = 0; stepGol(); } |
232 | | - } |
233 | | - |
234 | | - // Random flashes — GoL only flashes alive cells; matrix flashes are visible only on trail cells |
235 | | - for (let i = 0; i < cells.length; i++) { |
236 | | - const c = cells[i]; |
237 | | - if (c.flash > 0) { |
238 | | - c.flash -= FLASH_DECAY; |
239 | | - if (c.flash < 0) c.flash = 0; |
240 | | - } else if (mode === 'ascii' || golCur[i]) { |
241 | | - if (Math.random() < FLASH_CHANCE) c.flash = FLASH_PEAK; |
| 241 | + // Flash on alive GoL cells |
| 242 | + for (let i = 0; i < cells.length; i++) { |
| 243 | + const c = cells[i]; |
| 244 | + if (c.flash > 0) { |
| 245 | + c.flash -= FLASH_DECAY; |
| 246 | + if (c.flash < 0) c.flash = 0; |
| 247 | + } else if (golCur[i] && Math.random() < FLASH_CHANCE) { |
| 248 | + c.flash = FLASH_PEAK; |
| 249 | + } |
242 | 250 | } |
| 251 | + if (++golFrame >= GOL_TICK) { golFrame = 0; stepGol(); } |
243 | 252 | } |
244 | 253 | } |
245 | 254 |
|
246 | 255 | function draw() { |
247 | | - if (mode === 'ascii') drawMatrix(); |
248 | | - else drawGol(); |
| 256 | + if (mode === 'radiation') drawRadiation(); |
| 257 | + else drawGol(); |
249 | 258 | } |
250 | 259 |
|
251 | 260 | function loop(ts) { |
|
258 | 267 |
|
259 | 268 | // ── Toggle button ──────────────────────────────────────────────────────────── |
260 | 269 |
|
261 | | - const TOGGLE_LABELS = { ascii: '[ life ]', gol: '[ matrix ]' }; |
| 270 | + const TOGGLE_LABELS = { radiation: '[ life ]', gol: '[ death ]' }; |
262 | 271 | // ASCII-only pool for button glitch — avoids double-width CJK glyphs shifting layout |
263 | 272 | const BTN_CHARS = '0123456789ABCDEFabcdef+x=[]{}|/<>?!#$%'; |
264 | 273 | function rbchar() { return BTN_CHARS[Math.random() * BTN_CHARS.length | 0]; } |
|
289 | 298 | }, 80); |
290 | 299 |
|
291 | 300 | btn.addEventListener('click', () => { |
292 | | - mode = mode === 'ascii' ? 'gol' : 'ascii'; |
293 | | - if (mode === 'gol') { initGol(); golFrame = 0; } |
294 | | - if (mode === 'ascii') { initStreams(); } |
| 301 | + mode = mode === 'radiation' ? 'gol' : 'radiation'; |
| 302 | + if (mode === 'gol') { initGol(); golFrame = 0; } |
| 303 | + if (mode === 'radiation') { initStreakBuffer(); } |
295 | 304 | buildLabel(btn, TOGGLE_LABELS[mode]); |
296 | 305 | }); |
297 | 306 | } |
|
303 | 312 | if (!canvas) return; |
304 | 313 | ctx = canvas.getContext('2d'); |
305 | 314 |
|
306 | | - mode = Math.random() < 0.5 ? 'ascii' : 'gol'; |
| 315 | + mode = Math.random() < 0.5 ? 'radiation' : 'gol'; |
307 | 316 |
|
308 | 317 | resize(); |
309 | 318 | window.addEventListener('resize', resize); |
|
0 commit comments