@cobanalitalha/claude-spinner 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,32 +42,28 @@ switch (command) {
42
42
  default: {
43
43
  console.log(`
44
44
  ┌──────────────────────────────────────────────────┐
45
- claude-spinner v2.0.0
46
- Animated spinners for your Claude Code session
45
+ claude-spinner v3.0.0
46
+ Dino game for your Claude Code session!
47
47
  └──────────────────────────────────────────────────┘
48
48
 
49
49
  COMMANDS
50
50
  install Add hooks to ~/.claude/settings.json
51
51
  uninstall Remove hooks from ~/.claude/settings.json
52
- start Start the spinner animation daemon
53
- stop Stop the spinner animation daemon
54
- status Show whether the daemon is running
52
+ start Start the dino game
53
+ stop Stop the dino game
54
+ status Show whether the game is running
55
55
 
56
56
  QUICK START
57
57
  npm install -g claude-spinner
58
58
  claude-spinner install
59
- # Then open Claude Code a spinner appears automatically!
59
+ # Claude Code starts working dino game appears at the bottom!
60
+ # Press SPACE or ENTER to jump over cacti.
60
61
 
61
62
  HOW IT WORKS
62
- Draws an animated spinner in the top-right corner of
63
- your terminal using /dev/tty (never interferes with output).
64
- A random animation is chosen each session:
65
- coffee steam rising from a cup
66
- rocket – exhaust flame pulsing beneath a rocket
67
- clock – arrow hand spinning around a dial
68
- ball – ball bouncing inside a box
69
- • Starts automatically when Claude uses a tool and stops
70
- when Claude finishes responding.
63
+ When Claude uses a tool, a dino game launches at the bottom
64
+ of your terminal. Use SPACE to jump over cacti.
65
+ When Claude finishes responding, the game stops automatically.
66
+ • Score increases over time how high can you get?
71
67
  `);
72
68
  }
73
69
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cobanalitalha/claude-spinner",
3
- "version": "2.0.1",
4
- "description": "Animated spinners for your Claude Code session",
3
+ "version": "3.0.0",
4
+ "description": "Play a dino game while Claude Code is thinking",
5
5
  "bin": {
6
6
  "claude-spinner": "bin/claude-spinner.js"
7
7
  },
@@ -16,8 +16,8 @@
16
16
  "claude",
17
17
  "claude-code",
18
18
  "terminal",
19
- "animation",
20
- "spinner",
19
+ "game",
20
+ "dino",
21
21
  "hooks"
22
22
  ],
23
23
  "author": "Ali Talha ÇOBAN",
package/src/daemon.js CHANGED
@@ -1,21 +1,5 @@
1
1
  'use strict';
2
2
 
3
- /**
4
- * daemon.js
5
- *
6
- * Two responsibilities:
7
- * 1. Exported API: start() / stop() – called from the CLI to manage the daemon process.
8
- * 2. Daemon main loop – runs when this file is the entry point with the --daemon flag.
9
- *
10
- * KEY DESIGN NOTE
11
- * ───────────────
12
- * The daemon is spawned with `detached: true`, which means it loses its controlling
13
- * terminal and cannot open /dev/tty. We work around this by resolving the real TTY
14
- * device path (e.g. /dev/ttys003 on macOS, /dev/pts/2 on Linux) *before* detaching
15
- * and passing it via the SPINNER_TTY environment variable. A detached process can still
16
- * open a specific device node by path as long as it has permission.
17
- */
18
-
19
3
  const fs = require('fs');
20
4
  const path = require('path');
21
5
  const os = require('os');
@@ -24,9 +8,7 @@ const { spawn, execSync } = require('child_process');
24
8
  const PID_FILE = path.join(os.tmpdir(), 'claude-spinner.pid');
25
9
  const DAEMON_ARG = '--daemon';
26
10
 
27
- // ─────────────────────────────────────────────────────────────
28
- // Public API
29
- // ─────────────────────────────────────────────────────────────
11
+ // ── Helpers ───────────────────────────────────────────────────────────────────
30
12
 
31
13
  function isAlive(pid) {
32
14
  try { process.kill(pid, 0); return true; } catch { return false; }
@@ -36,7 +18,7 @@ function readPid() {
36
18
  try { return parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10); } catch { return null; }
37
19
  }
38
20
 
39
- /** Find the real TTY device path for the current process. Returns null if not a TTY. */
21
+ /** Resolve the real TTY device path (e.g. /dev/ttys003) for the current process tree. */
40
22
  function resolveTTY() {
41
23
  // 1. Direct check – works when hook stdin is still connected to the terminal
42
24
  try {
@@ -44,44 +26,43 @@ function resolveTTY() {
44
26
  if (out && out !== 'not a tty' && fs.existsSync(out)) return out;
45
27
  } catch {}
46
28
 
47
- // 2. Walk up the process tree looking for a parent that owns a TTY.
48
- // Claude Code itself always runs in a real terminal, so one of the
49
- // ancestor processes will have a TTY even if the hook's stdio is piped.
29
+ // 2. Walk up the process tree Claude Code always runs in a real terminal
50
30
  try {
51
31
  let pid = process.ppid;
52
32
  for (let depth = 0; depth < 5 && pid > 1; depth++) {
53
33
  const ttyName = execSync(`ps -o tty= -p ${pid} 2>/dev/null`, { encoding: 'utf8' }).trim();
54
34
  if (ttyName && ttyName !== '??' && ttyName !== '?') {
55
- // macOS: "s003" → "/dev/ttys003" | Linux: "pts/2" → "/dev/pts/2"
56
- const ttyPath = ttyName.startsWith('/') ? ttyName
35
+ const ttyPath = ttyName.startsWith('/') ? ttyName
57
36
  : ttyName.startsWith('pts/') ? `/dev/${ttyName}`
58
37
  : ttyName.startsWith('tty') ? `/dev/${ttyName}`
59
38
  : `/dev/tty${ttyName}`;
60
39
  if (fs.existsSync(ttyPath)) return ttyPath;
61
40
  }
62
- // Move up one level
63
41
  const ppid = execSync(`ps -o ppid= -p ${pid} 2>/dev/null`, { encoding: 'utf8' }).trim();
64
42
  pid = parseInt(ppid, 10);
65
43
  if (!pid || isNaN(pid)) break;
66
44
  }
67
45
  } catch {}
68
46
 
69
- return null; // not a supported environment – fail silently
47
+ return null;
70
48
  }
71
49
 
50
+ // ── Public API ────────────────────────────────────────────────────────────────
51
+
72
52
  function start() {
73
53
  const pid = readPid();
74
54
  if (pid && isAlive(pid)) return; // already running
75
55
 
76
56
  const ttyPath = resolveTTY();
77
- if (!ttyPath) return; // not a TTY environment – silently do nothing
57
+ if (!ttyPath) return; // not a TTY environment – fail silently
78
58
 
79
59
  const cols = (process.stdout.columns || 80).toString();
60
+ const rows = (process.stdout.rows || 24).toString();
80
61
 
81
62
  const child = spawn(process.execPath, [__filename, DAEMON_ARG], {
82
63
  detached: true,
83
64
  stdio: 'ignore',
84
- env: { ...process.env, SPINNER_TTY: ttyPath, SPINNER_COLS: cols },
65
+ env: { ...process.env, SPINNER_TTY: ttyPath, SPINNER_COLS: cols, SPINNER_ROWS: rows },
85
66
  });
86
67
  child.unref();
87
68
 
@@ -97,80 +78,14 @@ function stop() {
97
78
 
98
79
  module.exports = { start, stop };
99
80
 
100
- // ─────────────────────────────────────────────────────────────
101
- // Daemon entry point
102
- // ─────────────────────────────────────────────────────────────
81
+ // ── Daemon entry point ────────────────────────────────────────────────────────
103
82
 
104
83
  if (require.main === module && process.argv.includes(DAEMON_ARG)) {
105
- runDaemon().catch(() => process.exit(1));
106
- }
84
+ const ttyPath = process.env.SPINNER_TTY;
85
+ const termRows = parseInt(process.env.SPINNER_ROWS || '24', 10);
107
86
 
108
- // ─────────────────────────────────────────────────────────────
109
- // Daemon implementation
110
- // ─────────────────────────────────────────────────────────────
111
-
112
- async function runDaemon() {
113
- const animations = require('./animations');
114
- const keys = Object.keys(animations);
115
- const anim = animations[keys[Math.floor(Math.random() * keys.length)]];
116
-
117
- const ttyPath = process.env.SPINNER_TTY;
118
87
  if (!ttyPath) process.exit(1);
119
88
 
120
- let ttyFd;
121
- try {
122
- ttyFd = fs.openSync(ttyPath, 'r+');
123
- } catch {
124
- process.exit(1);
125
- }
126
-
127
- const cols = parseInt(process.env.SPINNER_COLS || '80', 10);
128
- const col = Math.max(1, cols - anim.width + 1);
129
- const HEIGHT = anim.frames[0].length;
130
-
131
- function writeTTY(str) {
132
- try { fs.writeSync(ttyFd, str); } catch {}
133
- }
134
-
135
- /** Draw a frame at the fixed top-right position without disturbing Claude's cursor. */
136
- function draw(frame) {
137
- let buf = '\x1b7'; // DECSC: save cursor
138
- for (let i = 0; i < frame.length; i++) {
139
- buf += `\x1b[${i + 1};${col}H${frame[i]}`;
140
- }
141
- buf += '\x1b8'; // DECRC: restore cursor
142
- writeTTY(buf);
143
- }
144
-
145
- /** Erase the spinner area on exit. */
146
- function clear() {
147
- const blank = ' '.repeat(anim.width + 2);
148
- let buf = '\x1b7';
149
- for (let i = 0; i < HEIGHT; i++) {
150
- buf += `\x1b[${i + 1};${col}H${blank}`;
151
- }
152
- buf += '\x1b8';
153
- writeTTY(buf);
154
- }
155
-
156
- function sleep(ms) {
157
- return new Promise(r => setTimeout(r, ms));
158
- }
159
-
160
- // Graceful shutdown: clear the spinner then exit.
161
- process.on('SIGTERM', () => {
162
- clear();
163
- try { fs.unlinkSync(PID_FILE); } catch {}
164
- process.exit(0);
165
- });
166
-
167
- // ── Animation loop ──────────────────────────────────────────
168
- let frameIdx = 0;
169
- draw(anim.frames[0]);
170
-
171
- while (true) {
172
- await sleep(anim.intervalMs);
173
- frameIdx = (frameIdx + 1) % anim.frames.length;
174
- draw(anim.frames[frameIdx]);
175
- }
89
+ const { runGame } = require('./game');
90
+ runGame(ttyPath, termRows);
176
91
  }
package/src/game.js ADDED
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { execSync } = require('child_process');
5
+
6
+ // ── ANSI helpers ──────────────────────────────────────────────────────────────
7
+ function pos(row, col) { return `\x1b[${row};${col}H`; }
8
+ const SAVE = '\x1b7';
9
+ const REST = '\x1b8';
10
+ const HIDE_C = '\x1b[?25l';
11
+ const SHOW_C = '\x1b[?25h';
12
+ const CYAN = (s) => `\x1b[36m${s}\x1b[0m`;
13
+ const GREEN = (s) => `\x1b[32m${s}\x1b[0m`;
14
+ const YELLOW = (s) => `\x1b[33m${s}\x1b[0m`;
15
+ const RED = (s) => `\x1b[31m${s}\x1b[0m`;
16
+ const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
17
+ const BOLD = (s) => `\x1b[1m${s}\x1b[0m`;
18
+
19
+ // ── Game constants ────────────────────────────────────────────────────────────
20
+ const GAME_W = 60; // game area width (columns)
21
+ const GAME_H = 10; // game area height (rows)
22
+ const GROUND = 7; // row (within game area, 0-indexed) where dino feet rest
23
+ const DINO_X = 6; // dino column (0-indexed within game area)
24
+ const GRAVITY = 0.8;
25
+ const JUMP_V = -3.2;
26
+ const TICK_MS = 100; // ms per game tick
27
+
28
+ // ── Sprites ───────────────────────────────────────────────────────────────────
29
+ // Dino: 3 cols wide [DINO_X-1 .. DINO_X+1], 3 rows tall
30
+ // head: round(dinoY)-2 | body: round(dinoY)-1 | feet: round(dinoY)
31
+ const DINO_RUN = [
32
+ [' O ', '/|\\', '/ \\'], // frame 0 – feet down
33
+ [' O ', '/|\\', '/\\ '], // frame 1 – feet lifted
34
+ ];
35
+ const DINO_DEAD = [' X ', '/|\\', ' '];
36
+
37
+ // Cactus: 1 col wide, 3 rows tall; bottom at GROUND, top at GROUND-2
38
+ const CACTUS = ['|', '|', '^'];
39
+
40
+ // ── Game state ────────────────────────────────────────────────────────────────
41
+ let dinoY = GROUND;
42
+ let dinoVy = 0;
43
+ let obstacles = []; // [{x: float}]
44
+ let score = 0;
45
+ let tick = 0;
46
+ let dead = false;
47
+ let legAnim = 0; // 0 or 1
48
+ let jumpQueued = false;
49
+ let nextSpawn = 30; // ticks until next obstacle
50
+
51
+ // ── TTY ───────────────────────────────────────────────────────────────────────
52
+ let ttyFd;
53
+ let ttyPath;
54
+ let startRow; // terminal row where game area begins (1-indexed)
55
+ const ibuf = Buffer.alloc(4);
56
+
57
+ function writeTTY(str) {
58
+ try { fs.writeSync(ttyFd, str); } catch {}
59
+ }
60
+
61
+ function pollInput() {
62
+ try {
63
+ const n = fs.readSync(ttyFd, ibuf, 0, 4, null);
64
+ if (n > 0 && (ibuf[0] === 0x20 || ibuf[0] === 0x0d)) {
65
+ jumpQueued = true; // space or enter
66
+ }
67
+ } catch {}
68
+ }
69
+
70
+ // ── Update ────────────────────────────────────────────────────────────────────
71
+ function update() {
72
+ if (dead) return;
73
+
74
+ tick++;
75
+ score = Math.floor(tick * 0.5);
76
+
77
+ // Jump
78
+ if (jumpQueued) {
79
+ if (dinoY >= GROUND - 0.5) dinoVy = JUMP_V;
80
+ jumpQueued = false;
81
+ }
82
+
83
+ // Physics
84
+ dinoVy += GRAVITY;
85
+ dinoY += dinoVy;
86
+ if (dinoY >= GROUND) { dinoY = GROUND; dinoVy = 0; }
87
+
88
+ // Leg animation (only while on ground)
89
+ if (dinoY >= GROUND - 0.5) legAnim = Math.floor(tick / 4) % 2;
90
+
91
+ // Obstacle speed increases with score
92
+ const speed = 1 + Math.floor(score / 30) * 0.2;
93
+
94
+ // Move obstacles
95
+ for (const o of obstacles) o.x -= speed;
96
+ obstacles = obstacles.filter(o => o.x > -3);
97
+
98
+ // Spawn new obstacle
99
+ nextSpawn--;
100
+ if (nextSpawn <= 0) {
101
+ obstacles.push({ x: GAME_W - 1 });
102
+ nextSpawn = Math.floor(25 + Math.random() * 20);
103
+ }
104
+
105
+ // Collision detection
106
+ // Cactus hitbox: col ox, rows [GROUND-2 .. GROUND]
107
+ // Dino hitbox : cols [DINO_X-1 .. DINO_X+1], rows [dy-2 .. dy]
108
+ // Dino clears cactus when dy < GROUND-2
109
+ const dy = Math.round(dinoY);
110
+ for (const o of obstacles) {
111
+ const ox = Math.round(o.x);
112
+ if (ox >= DINO_X - 1 && ox <= DINO_X + 1 && dy >= GROUND - 2) {
113
+ dead = true;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+
119
+ // ── Render ────────────────────────────────────────────────────────────────────
120
+ function render() {
121
+ // Build a plain character grid
122
+ const grid = Array.from({ length: GAME_H }, () => Array(GAME_W).fill(' '));
123
+ // Color tags per cell: 'G'=green(dino) 'Y'=yellow(cactus) ' '=plain 'D'=dim
124
+ const ctag = Array.from({ length: GAME_H }, () => Array(GAME_W).fill(' '));
125
+
126
+ // Row 0: score / hint
127
+ const scoreStr = `Score: ${String(score).padStart(4, '0')}`;
128
+ const hint = dead ? 'GAME OVER – Claude is still working...' : 'SPACE to jump';
129
+ const gap = GAME_W - scoreStr.length - hint.length;
130
+ const header = scoreStr + ' '.repeat(Math.max(1, gap)) + hint;
131
+ for (let i = 0; i < Math.min(header.length, GAME_W); i++) grid[0][i] = header[i];
132
+
133
+ // Ground line (row GROUND+1 = 8)
134
+ for (let x = 0; x < GAME_W; x++) { grid[GROUND + 1][x] = '\u2500'; ctag[GROUND + 1][x] = 'D'; }
135
+
136
+ // Obstacles (cactus)
137
+ for (const o of obstacles) {
138
+ const ox = Math.round(o.x);
139
+ if (ox < 0 || ox >= GAME_W) continue;
140
+ for (let h = 0; h < 3; h++) {
141
+ const r = GROUND - 2 + h;
142
+ if (r >= 0 && r < GAME_H) { grid[r][ox] = CACTUS[h]; ctag[r][ox] = 'Y'; }
143
+ }
144
+ }
145
+
146
+ // Dino
147
+ const dy = Math.round(dinoY);
148
+ const sprite = dead ? DINO_DEAD : DINO_RUN[legAnim];
149
+ for (let sr = 0; sr < 3; sr++) {
150
+ const r = dy - 2 + sr;
151
+ if (r < 0 || r >= GAME_H) continue;
152
+ for (let sc = 0; sc < 3; sc++) {
153
+ const c = DINO_X - 1 + sc;
154
+ if (c >= 0 && c < GAME_W) {
155
+ grid[r][c] = sprite[sr][sc];
156
+ ctag[r][c] = dead ? 'R' : 'G';
157
+ }
158
+ }
159
+ }
160
+
161
+ // Compose ANSI output (save cursor → draw → restore cursor)
162
+ let buf = SAVE + HIDE_C;
163
+ for (let r = 0; r < GAME_H; r++) {
164
+ buf += pos(startRow + r, 1);
165
+ if (r === 0) {
166
+ // Score row: cyan header
167
+ buf += CYAN(grid[0].join(''));
168
+ } else {
169
+ // Build row with per-cell coloring
170
+ let line = '';
171
+ for (let c = 0; c < GAME_W; c++) {
172
+ const ch = grid[r][c];
173
+ switch (ctag[r][c]) {
174
+ case 'G': line += GREEN(ch); break;
175
+ case 'Y': line += YELLOW(ch); break;
176
+ case 'R': line += RED(ch); break;
177
+ case 'D': line += DIM(ch); break;
178
+ default: line += ch;
179
+ }
180
+ }
181
+ buf += line;
182
+ }
183
+ }
184
+ buf += REST;
185
+ writeTTY(buf);
186
+ }
187
+
188
+ // ── Setup / teardown ──────────────────────────────────────────────────────────
189
+ function setup(ttyPathArg, termRows) {
190
+ ttyPath = ttyPathArg;
191
+ ttyFd = fs.openSync(ttyPath, 'r+');
192
+ startRow = Math.max(1, termRows - GAME_H - 1);
193
+
194
+ // Set TTY to raw mode: keypresses arrive immediately, VMIN=0 VTIME=0 = non-blocking reads
195
+ const flag = process.platform === 'darwin' ? '-f' : '-F';
196
+ try { execSync(`stty ${flag} ${ttyPath} raw -echo min 0 time 0`); } catch {}
197
+ }
198
+
199
+ function teardown() {
200
+ // Restore TTY to sane defaults
201
+ const flag = process.platform === 'darwin' ? '-f' : '-F';
202
+ try { execSync(`stty ${flag} ${ttyPath} sane`); } catch {}
203
+
204
+ // Erase game area and restore cursor visibility
205
+ let buf = SHOW_C;
206
+ for (let r = 0; r < GAME_H; r++) {
207
+ buf += pos(startRow + r, 1) + ' '.repeat(GAME_W);
208
+ }
209
+ writeTTY(buf);
210
+
211
+ try { fs.closeSync(ttyFd); } catch {}
212
+ }
213
+
214
+ // ── Entry point ───────────────────────────────────────────────────────────────
215
+ function runGame(ttyPathArg, termRows) {
216
+ setup(ttyPathArg, termRows);
217
+
218
+ // Input polling (every 30ms for snappy response)
219
+ const inputInterval = setInterval(pollInput, 30);
220
+
221
+ // Game loop
222
+ const gameInterval = setInterval(() => {
223
+ update();
224
+ render();
225
+ }, TICK_MS);
226
+
227
+ render(); // initial frame
228
+
229
+ function exit() {
230
+ clearInterval(inputInterval);
231
+ clearInterval(gameInterval);
232
+ teardown();
233
+ process.exit(0);
234
+ }
235
+
236
+ process.on('SIGTERM', exit);
237
+ process.on('SIGINT', exit);
238
+
239
+ // Auto-exit 2 seconds after game over (Claude is still working anyway)
240
+ const deathCheck = setInterval(() => {
241
+ if (dead) {
242
+ clearInterval(deathCheck);
243
+ render(); // show game over state
244
+ setTimeout(exit, 2000);
245
+ }
246
+ }, 200);
247
+ }
248
+
249
+ module.exports = { runGame };
package/src/animations.js DELETED
@@ -1,135 +0,0 @@
1
- 'use strict';
2
-
3
- // ANSI colour codes
4
- const Y = '\x1b[33m'; // yellow
5
- const C = '\x1b[36m'; // cyan
6
- const G = '\x1b[32m'; // green
7
- const R = '\x1b[31m'; // red
8
- const W = '\x1b[37m'; // white
9
- const DIM = '\x1b[2m';
10
- const RS = '\x1b[0m'; // reset
11
-
12
- // ─────────────────────────────────────────────────────────────────────────
13
- // 1. COFFEE CUP – steam drifts above the cup
14
- // Width: 13 Height: 7 Interval: 350 ms
15
- // ─────────────────────────────────────────────────────────────────────────
16
- const STEAM = [
17
- ' ~ ~ ~ ', // frame 0 (all 13 visible chars)
18
- ' ~ ~ ~ ', // frame 1
19
- '~ ~ ~ ', // frame 2
20
- ' ~ ~ ~ ', // frame 3
21
- ];
22
-
23
- const CUP_BODY = [
24
- `${Y} .-------. ${RS}`,
25
- `${Y} | | ${RS}`,
26
- `${Y} | |)${RS}`,
27
- `${Y} '-------' ${RS}`,
28
- ` `,
29
- ` `,
30
- ];
31
-
32
- const coffee = {
33
- name: 'coffee',
34
- width: 13,
35
- intervalMs: 350,
36
- frames: STEAM.map(s => [`${DIM}${s}${RS}`, ...CUP_BODY]),
37
- };
38
-
39
- // ─────────────────────────────────────────────────────────────────────────
40
- // 2. ROCKET – exhaust flame pulses beneath the rocket
41
- // Width: 10 Height: 7 Interval: 180 ms
42
- // ─────────────────────────────────────────────────────────────────────────
43
- const EXHAUST = [
44
- ` * * `, // frame 0 (10 visible chars)
45
- ` ** ** `, // frame 1
46
- ` **** *** `, // frame 2
47
- ` ** ** `, // frame 3
48
- ];
49
-
50
- const ROCKET_BODY = [
51
- `${W} /\\ ${RS}`,
52
- `${W} / \\ ${RS}`,
53
- `${W} | ${C}**${W} | ${RS}`,
54
- `${W} | | ${RS}`,
55
- `${W} '----' ${RS}`,
56
- ];
57
-
58
- const rocket = {
59
- name: 'rocket',
60
- width: 10,
61
- intervalMs: 180,
62
- frames: EXHAUST.map(e => [
63
- ...ROCKET_BODY,
64
- `${R}${e}${RS}`,
65
- ` `,
66
- ]),
67
- };
68
-
69
- // ─────────────────────────────────────────────────────────────────────────
70
- // 3. CLOCK – arrow hand rotates around the dial
71
- // Width: 11 Height: 7 Interval: 130 ms
72
- // ─────────────────────────────────────────────────────────────────────────
73
- const CLOCK_ARROWS = ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖'];
74
-
75
- const clock = {
76
- name: 'clock',
77
- width: 11,
78
- intervalMs: 130,
79
- frames: CLOCK_ARROWS.map(arrow => [
80
- `${C} .---. ${RS}`,
81
- `${C} / \\ ${RS}`,
82
- `${C} | ${Y}${arrow}${C} | ${RS}`,
83
- `${C} | | ${RS}`,
84
- `${C} \\ / ${RS}`,
85
- `${C} '---' ${RS}`,
86
- ` `,
87
- ]),
88
- };
89
-
90
- // ─────────────────────────────────────────────────────────────────────────
91
- // 4. BOUNCING BALL – ball ricochets inside a box
92
- // Width: 14 Height: 7 Interval: 110 ms
93
- // ─────────────────────────────────────────────────────────────────────────
94
- function makeBallFrames() {
95
- const X_MAX = 11; // x: 0 .. 11 (12 interior columns)
96
- const Y_MAX = 2; // y: 0 .. 2 (3 interior rows)
97
- let x = 0, y = 0, vx = 1, vy = 1;
98
- const frames = [];
99
-
100
- const top = `${DIM}.------------.${RS}`;
101
- const bot = `${DIM}'------------'${RS}`;
102
- const pad = ` `; // 14 spaces
103
-
104
- for (let f = 0; f < 28; f++) {
105
- const rows = [];
106
- for (let row = 0; row <= Y_MAX; row++) {
107
- const interior = row === y
108
- ? ' '.repeat(x) + `${G}●${RS}` + ' '.repeat(X_MAX - x)
109
- : ' '.repeat(X_MAX + 1);
110
- rows.push(`${DIM}|${RS}${interior}${DIM}|${RS}`);
111
- }
112
- frames.push([top, ...rows, bot, pad, pad]);
113
-
114
- // Move and bounce
115
- x += vx; y += vy;
116
- if (x <= 0) { x = 0; vx = 1; }
117
- if (x >= X_MAX) { x = X_MAX; vx = -1; }
118
- if (y <= 0) { y = 0; vy = 1; }
119
- if (y >= Y_MAX) { y = Y_MAX; vy = -1; }
120
- }
121
-
122
- return frames;
123
- }
124
-
125
- const ball = {
126
- name: 'ball',
127
- width: 14,
128
- intervalMs: 110,
129
- frames: makeBallFrames(),
130
- };
131
-
132
- // ─────────────────────────────────────────────────────────────────────────
133
- // Export – add new animations to this object; daemon picks one at random.
134
- // ─────────────────────────────────────────────────────────────────────────
135
- module.exports = { coffee, rocket, clock, ball };