@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.
- package/bin/claude-spinner.js +11 -15
- package/package.json +4 -4
- package/src/daemon.js +15 -100
- package/src/game.js +249 -0
- package/src/animations.js +0 -135
package/bin/claude-spinner.js
CHANGED
|
@@ -42,32 +42,28 @@ switch (command) {
|
|
|
42
42
|
default: {
|
|
43
43
|
console.log(`
|
|
44
44
|
┌──────────────────────────────────────────────────┐
|
|
45
|
-
│
|
|
46
|
-
│
|
|
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
|
|
53
|
-
stop Stop the
|
|
54
|
-
status Show whether the
|
|
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
|
-
#
|
|
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
|
-
•
|
|
63
|
-
your terminal
|
|
64
|
-
•
|
|
65
|
-
|
|
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": "
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
20
|
-
"
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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 };
|