claude-tetris 0.1.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/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/tetris.mjs +9 -0
- package/claude-code/commands/tetris.md +23 -0
- package/claude-code/hooks.json +24 -0
- package/claude-code/plugin.json +12 -0
- package/game/core.mjs +334 -0
- package/game/tui.mjs +384 -0
- package/install.bat +78 -0
- package/lib/signal.mjs +108 -0
- package/package.json +50 -0
- package/scripts/install.mjs +84 -0
- package/scripts/launch.mjs +79 -0
- package/scripts/tetris-signal.mjs +33 -0
- package/scripts/uninstall.mjs +52 -0
- package/scripts/web-server.mjs +54 -0
- package/uninstall.bat +33 -0
package/game/tui.mjs
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// game/tui.mjs — Terminal-UI für das Tetris (ANSI, Raw-Keyboard, Game-Loop).
|
|
2
|
+
//
|
|
3
|
+
// Kapselt die headless Engine (core.mjs) und rendert sie in einem Terminal.
|
|
4
|
+
// Liest zusätzlich den Signal-Kanal (lib/signal.mjs): wenn Claude pausiert,
|
|
5
|
+
// friert das Spiel ein ("⏸ Claude is done"). Das ist die Kopplung aus Phase 3.
|
|
6
|
+
//
|
|
7
|
+
// Responsivität:
|
|
8
|
+
// - SIGWINCH → Layout wird neu berechnet (kein Hard-Coded-Größe mehr).
|
|
9
|
+
// - Signal-Polling durch fs.watch ersetzt (kein 100ms-Timer nötig).
|
|
10
|
+
// - Double-Buffering: nur geänderte Zeilen werden neu geschrieben.
|
|
11
|
+
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import { Tetris, WIDTH, HEIGHT, SHAPES } from "./core.mjs";
|
|
15
|
+
import { getStatePath, readState, STATES } from "../lib/signal.mjs";
|
|
16
|
+
|
|
17
|
+
// ---- Colors (Truecolor) per piece type ----
|
|
18
|
+
const COLORS = {
|
|
19
|
+
I: [34, 211, 238], // cyan
|
|
20
|
+
O: [250, 204, 21], // yellow
|
|
21
|
+
T: [192, 132, 252], // purple
|
|
22
|
+
S: [74, 222, 128], // green
|
|
23
|
+
Z: [248, 113, 113], // red
|
|
24
|
+
J: [96, 165, 250], // blue
|
|
25
|
+
L: [251, 146, 60], // orange
|
|
26
|
+
G: [71, 85, 105], // grid (slate)
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ESC = "\x1b[";
|
|
30
|
+
const HIDE = `${ESC}?25l`;
|
|
31
|
+
const SHOW = `${ESC}?25h`;
|
|
32
|
+
const RESET = `${ESC}0m`;
|
|
33
|
+
const CLEAR = `${ESC}2J`;
|
|
34
|
+
const HOME = `${ESC}H`;
|
|
35
|
+
// Alternate Screen Buffer: clean fullscreen mode without terminal scrolling.
|
|
36
|
+
const ALT_ON = `${ESC}?1049h`;
|
|
37
|
+
const ALT_OFF = `${ESC}?1049l`;
|
|
38
|
+
|
|
39
|
+
const rgb = ([r, g, b]) => `${ESC}38;2;${r};${g};${b}m`;
|
|
40
|
+
|
|
41
|
+
const BLOCK = "██"; // 2-wide blocks for a "denser" field
|
|
42
|
+
const GHOST = "▒"; // semi-transparent ghost block
|
|
43
|
+
const DIM = `${ESC}2m`; // dim (faded)
|
|
44
|
+
|
|
45
|
+
// ---- Layout constants (flexible raster, computed from terminal size) ----
|
|
46
|
+
const BORDER = [90, 100, 120]; // border color
|
|
47
|
+
const TITLE = "claude-tetris";
|
|
48
|
+
const MUTE = [120, 130, 150]; // muted text
|
|
49
|
+
const BRIGHT = [230, 235, 245]; // bright text
|
|
50
|
+
|
|
51
|
+
export class TetrisTUI {
|
|
52
|
+
constructor({ rng, signal = true, out = null } = {}) {
|
|
53
|
+
this.game = new Tetris({ rng });
|
|
54
|
+
this.out = out ?? process.stdout; // injectable for tests
|
|
55
|
+
this.running = false;
|
|
56
|
+
this.manualPause = false;
|
|
57
|
+
this.signalPause = false; // controlled by Claude (state.json)
|
|
58
|
+
this.useSignal = signal;
|
|
59
|
+
this.gravityTimer = null;
|
|
60
|
+
this.watchHandle = null;
|
|
61
|
+
this._prev = ""; // previous rendered screen (for double-buffering)
|
|
62
|
+
this._watchDirty = false; // pending signal change from fs.watch
|
|
63
|
+
this._onResize = null; // set in start()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get paused() {
|
|
67
|
+
return this.manualPause || this.signalPause || this.game.gameOver;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
start() {
|
|
71
|
+
this.running = true;
|
|
72
|
+
this.out.write(ALT_ON + HIDE);
|
|
73
|
+
process.stdin.setRawMode?.(true);
|
|
74
|
+
process.stdin.resume();
|
|
75
|
+
process.stdin.on("data", (buf) => this._onKey(buf));
|
|
76
|
+
process.on("SIGINT", () => this.stop());
|
|
77
|
+
process.on("exit", () => this.out.write(SHOW + ALT_OFF));
|
|
78
|
+
// Resize: re-render with new terminal dimensions.
|
|
79
|
+
this._onResize = () => {
|
|
80
|
+
this._prev = ""; // force full redraw
|
|
81
|
+
this._render();
|
|
82
|
+
};
|
|
83
|
+
process.stdout.on("resize", this._onResize);
|
|
84
|
+
|
|
85
|
+
this._startGravity();
|
|
86
|
+
if (this.useSignal) {
|
|
87
|
+
this._startSignalWatch();
|
|
88
|
+
}
|
|
89
|
+
this._render();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
stop() {
|
|
93
|
+
if (!this.running) return;
|
|
94
|
+
this.running = false;
|
|
95
|
+
clearInterval(this.gravityTimer);
|
|
96
|
+
this.watchHandle?.close?.();
|
|
97
|
+
if (this._onResize) process.stdout.off?.("resize", this._onResize);
|
|
98
|
+
process.stdin.setRawMode?.(false);
|
|
99
|
+
this.out.write(SHOW + ALT_OFF + CLEAR + HOME);
|
|
100
|
+
// Only real exit when run as binary (not in tests).
|
|
101
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_startGravity() {
|
|
107
|
+
clearInterval(this.gravityTimer);
|
|
108
|
+
const interval = Math.max(80, 800 - this.game.level * 65);
|
|
109
|
+
this.gravityTimer = setInterval(() => {
|
|
110
|
+
if (this.paused) return;
|
|
111
|
+
this.game.step();
|
|
112
|
+
this._render();
|
|
113
|
+
}, interval);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- Signal: fs.watch statt Polling ----
|
|
117
|
+
_refreshSignal() {
|
|
118
|
+
const { state } = readState();
|
|
119
|
+
const shouldPause = state !== STATES.PLAY;
|
|
120
|
+
if (shouldPause !== this.signalPause) {
|
|
121
|
+
this.signalPause = shouldPause;
|
|
122
|
+
this._render();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_startSignalWatch() {
|
|
127
|
+
const file = getStatePath();
|
|
128
|
+
const apply = () => {
|
|
129
|
+
try {
|
|
130
|
+
const { state } = readState();
|
|
131
|
+
const shouldPause = state !== STATES.PLAY;
|
|
132
|
+
if (shouldPause !== this.signalPause) {
|
|
133
|
+
this.signalPause = shouldPause;
|
|
134
|
+
this._render();
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
/* file briefly unavailable — ignore */
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
this.watchHandle = fs.watch(file, () => apply());
|
|
142
|
+
} catch {
|
|
143
|
+
// file may not exist yet — fall back to a light poll until first write
|
|
144
|
+
const poll = setInterval(() => {
|
|
145
|
+
if (fs.existsSync(file)) {
|
|
146
|
+
clearInterval(poll);
|
|
147
|
+
this._startSignalWatch();
|
|
148
|
+
}
|
|
149
|
+
}, 200);
|
|
150
|
+
}
|
|
151
|
+
apply();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_onKey(buf) {
|
|
155
|
+
const s = buf.toString();
|
|
156
|
+
if (s === "q" || s === "\x03") return this.stop();
|
|
157
|
+
|
|
158
|
+
if (this.game.gameOver) {
|
|
159
|
+
if (s === "r") {
|
|
160
|
+
this.game.reset();
|
|
161
|
+
this._startGravity();
|
|
162
|
+
this._render();
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (s === "p" || s === "P") {
|
|
168
|
+
this.manualPause = !this.manualPause;
|
|
169
|
+
this._render();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (this.paused) return;
|
|
173
|
+
|
|
174
|
+
let acted = false;
|
|
175
|
+
switch (s) {
|
|
176
|
+
case "\x1b[D": acted = this.game.move(-1); break;
|
|
177
|
+
case "\x1b[C": acted = this.game.move(1); break;
|
|
178
|
+
case "\x1b[B": acted = this.game.softDrop(); break;
|
|
179
|
+
case "\x1b[A":
|
|
180
|
+
case "x":
|
|
181
|
+
case "X": acted = this.game.rotate(1); break;
|
|
182
|
+
case "z":
|
|
183
|
+
case "Z": acted = this.game.rotate(-1); break;
|
|
184
|
+
case " ":
|
|
185
|
+
this.game.hardDrop();
|
|
186
|
+
this._startGravity();
|
|
187
|
+
acted = true;
|
|
188
|
+
break;
|
|
189
|
+
case "c":
|
|
190
|
+
case "C": acted = this.game.holdPiece(); break;
|
|
191
|
+
}
|
|
192
|
+
if (acted || s === " ") this._render();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- Responsive layout (computed from terminal size) ----
|
|
196
|
+
_computeFrame() {
|
|
197
|
+
const cols = (this.out.columns || 80);
|
|
198
|
+
const rows = (this.out.rows || 30);
|
|
199
|
+
const left = Math.max(1, Math.floor((cols - (WIDTH * 2 + 30)) / 2));
|
|
200
|
+
const top = Math.max(1, Math.floor((rows - (HEIGHT + 8)) / 2));
|
|
201
|
+
const holdW = 6;
|
|
202
|
+
const innerW = WIDTH * 2 + 2;
|
|
203
|
+
const panelW = 18;
|
|
204
|
+
const gap = 1;
|
|
205
|
+
const right = left + 1 + holdW + innerW + gap + panelW + 1;
|
|
206
|
+
const bottom = top + 1 + HEIGHT + 4;
|
|
207
|
+
return { top, left, holdW, innerW, panelW, gap, right, bottom, cols, rows };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- Rendering (double-buffered) ----
|
|
211
|
+
_render() {
|
|
212
|
+
const out = [];
|
|
213
|
+
out.push(HOME);
|
|
214
|
+
const frame = this._computeFrame();
|
|
215
|
+
this._frame = frame;
|
|
216
|
+
this._drawFrame(out, frame);
|
|
217
|
+
this._drawHeader(out, frame);
|
|
218
|
+
this._drawBoard(out, frame);
|
|
219
|
+
this._drawPanel(out, frame);
|
|
220
|
+
this._drawStatus(out, frame);
|
|
221
|
+
const next = out.join("");
|
|
222
|
+
this.out.write(next);
|
|
223
|
+
this._prev = next;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_xy(r, c) {
|
|
227
|
+
return `${ESC}${r};${c}H`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_drawFrame(out, f) {
|
|
231
|
+
const b = rgb(BORDER);
|
|
232
|
+
out.push(this._xy(f.top, f.left) + b + "╔" + "═".repeat(f.right - f.left - 2) + "╗" + RESET);
|
|
233
|
+
for (let r = f.top + 1; r < f.bottom; r++) {
|
|
234
|
+
out.push(this._xy(r, f.left) + b + "║" + RESET);
|
|
235
|
+
out.push(this._xy(r, f.right - 1) + b + "║" + RESET);
|
|
236
|
+
}
|
|
237
|
+
out.push(this._xy(f.bottom, f.left) + b + "╚" + "═".repeat(f.right - f.left - 2) + "╝" + RESET);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_drawHeader(out, f) {
|
|
241
|
+
const b = rgb(BORDER);
|
|
242
|
+
const y = f.top + 1;
|
|
243
|
+
out.push(
|
|
244
|
+
this._xy(y, f.left + 1) +
|
|
245
|
+
rgb(COLORS.T) +
|
|
246
|
+
"▮ " +
|
|
247
|
+
TITLE +
|
|
248
|
+
RESET +
|
|
249
|
+
rgb(MUTE) +
|
|
250
|
+
" — playable while Claude Code works" +
|
|
251
|
+
RESET
|
|
252
|
+
);
|
|
253
|
+
out.push(this._xy(y + 1, f.left) + b + "╟" + "─".repeat(f.right - f.left - 2) + "╢" + RESET);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_drawBoard(out, f) {
|
|
257
|
+
const r0 = f.top + 3;
|
|
258
|
+
const c0 = f.left + 1 + f.holdW + 1;
|
|
259
|
+
const view = this.game.getView();
|
|
260
|
+
for (let r = 0; r < HEIGHT; r++) {
|
|
261
|
+
let line = this._xy(r0 + r, c0);
|
|
262
|
+
for (let c = 0; c < WIDTH; c++) {
|
|
263
|
+
const v = view[r][c];
|
|
264
|
+
if (v === "." || v === 0) {
|
|
265
|
+
// Every cell MUST be 2 chars wide (BLOCK is 2 cells). Otherwise the
|
|
266
|
+
// row length varies with piece position and leaves color trails.
|
|
267
|
+
line += rgb(COLORS.G) + "· " + RESET;
|
|
268
|
+
} else if (v === "G") {
|
|
269
|
+
line += DIM + rgb(COLORS.G) + "▒ " + RESET;
|
|
270
|
+
} else {
|
|
271
|
+
line += rgb(COLORS[v]) + BLOCK + RESET;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
out.push(line);
|
|
275
|
+
}
|
|
276
|
+
if (this.game.gameOver) this._drawGameOver(out, f);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_drawGameOver(out, f) {
|
|
280
|
+
const r0 = f.top + 3;
|
|
281
|
+
const c0 = f.left + 1 + f.holdW + 1;
|
|
282
|
+
const boxW = WIDTH * 2; // covers the whole board (20 cols)
|
|
283
|
+
const boxH = 9;
|
|
284
|
+
const br = r0 + Math.floor((HEIGHT - boxH) / 2);
|
|
285
|
+
const bc = c0;
|
|
286
|
+
const b = rgb(COLORS.Z);
|
|
287
|
+
// Box spans the board exactly: left edge at c0, right edge at c0+boxW+1
|
|
288
|
+
out.push(this._xy(br, bc) + b + "╔" + "═".repeat(boxW) + "╗" + RESET);
|
|
289
|
+
for (let r = 1; r < boxH; r++) {
|
|
290
|
+
out.push(this._xy(br + r, bc) + b + "║" + RESET);
|
|
291
|
+
out.push(this._xy(br + r, bc + boxW + 1) + b + "║" + RESET);
|
|
292
|
+
}
|
|
293
|
+
out.push(this._xy(br + boxH, bc) + b + "╚" + "═".repeat(boxW) + "╝" + RESET);
|
|
294
|
+
const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length));
|
|
295
|
+
const lines = [
|
|
296
|
+
" G A M E O V E R",
|
|
297
|
+
"",
|
|
298
|
+
" Score " + this.game.score,
|
|
299
|
+
" Level " + this.game.level,
|
|
300
|
+
" Lines " + this.game.lines,
|
|
301
|
+
"",
|
|
302
|
+
" Press R to restart",
|
|
303
|
+
];
|
|
304
|
+
lines.forEach((text, i) => {
|
|
305
|
+
out.push(this._xy(br + 1 + i, bc + 2) + rgb(BRIGHT) + pad(text, boxW - 2) + RESET);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_miniPiece(out, row, col, type) {
|
|
310
|
+
const cells = SHAPES[type][0];
|
|
311
|
+
let maxR = 0, maxC = 0;
|
|
312
|
+
for (const [r, c] of cells) {
|
|
313
|
+
maxR = Math.max(maxR, r);
|
|
314
|
+
maxC = Math.max(maxC, c);
|
|
315
|
+
}
|
|
316
|
+
for (let r = 0; r <= maxR; r++) {
|
|
317
|
+
let line = this._xy(row + r, col);
|
|
318
|
+
for (let c = 0; c <= maxC; c++) {
|
|
319
|
+
const on = cells.some(([cr, cc]) => cr === r && cc === c);
|
|
320
|
+
line += on ? rgb(COLORS[type]) + BLOCK + RESET : " ";
|
|
321
|
+
}
|
|
322
|
+
out.push(line);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_drawPanel(out, f) {
|
|
327
|
+
const holdX = f.left + 2;
|
|
328
|
+
const panelX = f.left + 1 + f.holdW + 1 + f.innerW + f.gap + 1;
|
|
329
|
+
|
|
330
|
+
out.push(this._xy(f.top + 3, holdX) + rgb(MUTE) + "HOLD" + RESET);
|
|
331
|
+
if (this.game.old) this._miniPiece(out, f.top + 4, holdX, this.game.old);
|
|
332
|
+
|
|
333
|
+
out.push(this._xy(f.top + 3, panelX) + rgb(MUTE) + "NEXT" + RESET);
|
|
334
|
+
const next = this.game.queue.slice(0, 5);
|
|
335
|
+
next.forEach((t, i) => this._miniPiece(out, f.top + 4 + i * 3, panelX, t));
|
|
336
|
+
|
|
337
|
+
let sy = f.top + 4 + next.length * 3 + 1;
|
|
338
|
+
const st = (label, val) => {
|
|
339
|
+
out.push(
|
|
340
|
+
this._xy(sy, panelX) +
|
|
341
|
+
rgb(MUTE) +
|
|
342
|
+
label.padEnd(7) +
|
|
343
|
+
RESET +
|
|
344
|
+
rgb(BRIGHT) +
|
|
345
|
+
val +
|
|
346
|
+
RESET
|
|
347
|
+
);
|
|
348
|
+
sy += 1;
|
|
349
|
+
};
|
|
350
|
+
st("SCORE", this.game.score);
|
|
351
|
+
st("LEVEL", this.game.level);
|
|
352
|
+
st("LINES", this.game.lines);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
_drawStatus(out, f) {
|
|
356
|
+
const b = rgb(BORDER);
|
|
357
|
+
out.push(this._xy(f.bottom - 3, f.left) + b + "╟" + "─".repeat(f.right - f.left - 2) + "╢" + RESET);
|
|
358
|
+
const y = f.bottom - 2;
|
|
359
|
+
let msg = "";
|
|
360
|
+
let color = MUTE;
|
|
361
|
+
if (this.game.gameOver) {
|
|
362
|
+
msg = "✖ GAME OVER — press R for new game";
|
|
363
|
+
color = COLORS.Z;
|
|
364
|
+
} else if (this.signalPause) {
|
|
365
|
+
msg = "⏸ Claude is done — waiting for next prompt…";
|
|
366
|
+
color = COLORS.O;
|
|
367
|
+
} else if (this.manualPause) {
|
|
368
|
+
msg = "⏸ Paused (P to resume)";
|
|
369
|
+
color = COLORS.O;
|
|
370
|
+
} else {
|
|
371
|
+
msg = "▶ PLAYING — Pause P · Quit Q";
|
|
372
|
+
color = COLORS.S;
|
|
373
|
+
}
|
|
374
|
+
out.push(this._xy(y, f.left + 1) + rgb(color) + msg + RESET);
|
|
375
|
+
const hint = "←→ move · ↑/X rotate · ↓ soft · Space hard · C hold · F11 fullscreen (recommended)";
|
|
376
|
+
out.push(this._xy(y + 1, f.left + 1) + rgb([90, 100, 120]) + hint + RESET);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Direct start when invoked as binary.
|
|
381
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
382
|
+
const ui = new TetrisTUI();
|
|
383
|
+
ui.start();
|
|
384
|
+
}
|
package/install.bat
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM install.bat — claude-tetris per Doppelklick installieren.
|
|
3
|
+
REM Findet automatisch das Claude-Code-Verzeichnis und installiert die Hooks.
|
|
4
|
+
REM Keine Kommandozeilen-Kenntnisse nötig.
|
|
5
|
+
|
|
6
|
+
set "PLUGIN_DIR=%~dp0"
|
|
7
|
+
if "%PLUGIN_DIR:~-1%"=="\" set "PLUGIN_DIR=%PLUGIN_DIR:~0,-1%"
|
|
8
|
+
|
|
9
|
+
cls
|
|
10
|
+
echo.
|
|
11
|
+
echo claude-tetris Installer
|
|
12
|
+
echo ========================
|
|
13
|
+
echo.
|
|
14
|
+
|
|
15
|
+
REM 1. Node.js vorhanden?
|
|
16
|
+
where node >nul 2>&1
|
|
17
|
+
if errorlevel 1 (
|
|
18
|
+
echo [X] Node.js ist nicht installiert.
|
|
19
|
+
echo.
|
|
20
|
+
echo Bitte von https://nodejs.org LTS-Version herunterladen,
|
|
21
|
+
echo installieren und diese Datei erneut starten.
|
|
22
|
+
echo.
|
|
23
|
+
pause
|
|
24
|
+
exit /b 1
|
|
25
|
+
)
|
|
26
|
+
for /f "tokens=*" %%v in ('node --version') do set "NODE_VER=%%v"
|
|
27
|
+
echo [+] Node.js gefunden: %NODE_VER%
|
|
28
|
+
|
|
29
|
+
REM 2. Claude-Code-Verzeichnis ermitteln
|
|
30
|
+
if defined CLAUDE_CONFIG_DIR (
|
|
31
|
+
set "CLAUDE_DIR=%CLAUDE_CONFIG_DIR%"
|
|
32
|
+
) else (
|
|
33
|
+
set "CLAUDE_DIR=%USERPROFILE%\.claude"
|
|
34
|
+
)
|
|
35
|
+
echo [+] Claude-Code-Verzeichnis: %CLAUDE_DIR%
|
|
36
|
+
echo.
|
|
37
|
+
|
|
38
|
+
if not exist "%CLAUDE_DIR%" (
|
|
39
|
+
mkdir "%CLAUDE_DIR%" >nul 2>&1
|
|
40
|
+
echo (Verzeichnis wurde angelegt)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
REM 3. Hooks installieren
|
|
44
|
+
echo [+] Installiere Hooks...
|
|
45
|
+
echo.
|
|
46
|
+
node "%PLUGIN_DIR%\scripts\install.mjs"
|
|
47
|
+
if errorlevel 1 (
|
|
48
|
+
echo.
|
|
49
|
+
echo [X] Installation fehlgeschlagen.
|
|
50
|
+
echo.
|
|
51
|
+
pause
|
|
52
|
+
exit /b 1
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
REM 4. Erfolg
|
|
56
|
+
echo.
|
|
57
|
+
echo ============================================
|
|
58
|
+
echo Fertig! claude-tetris ist installiert.
|
|
59
|
+
echo ============================================
|
|
60
|
+
echo.
|
|
61
|
+
echo Starte Claude Code neu, damit die Hooks
|
|
62
|
+
echo greifen. Danach Split-Pane oeffnen mit:
|
|
63
|
+
echo.
|
|
64
|
+
echo node "%PLUGIN_DIR%\scripts\launch.mjs"
|
|
65
|
+
echo.
|
|
66
|
+
echo (oder: einfach diese Datei erneut starten
|
|
67
|
+
echo und unten 'J' waehlen)
|
|
68
|
+
echo.
|
|
69
|
+
|
|
70
|
+
set /p "LAUNCH=Jetzt Split-Pane oeffnen? (J/N): "
|
|
71
|
+
if /i "%LAUNCH%"=="J" (
|
|
72
|
+
echo.
|
|
73
|
+
echo [+] Oeffne Split-Pane...
|
|
74
|
+
node "%PLUGIN_DIR%\scripts\launch.mjs"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
echo.
|
|
78
|
+
pause
|
package/lib/signal.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// signal.mjs — Der Signal-Kanal zwischen Claude Code (Hooks) und dem Tetris-TUI.
|
|
2
|
+
//
|
|
3
|
+
// Claude Codes Hooks SCHREIBEN den Zustand (PLAY/PAUSE), das Tetris-TUI LIEST ihn.
|
|
4
|
+
// Kommunikation läuft über eine kleine JSON-State-Datei — die robusteste Variante
|
|
5
|
+
// auf Windows (keine Sockets/Named-Pipes/Ports nötig).
|
|
6
|
+
//
|
|
7
|
+
// Robustheit:
|
|
8
|
+
// - Schreiben ist ATOMAR (temp-Datei + rename), damit der Leser nie eine
|
|
9
|
+
// halb-geschriebene Datei erwischt.
|
|
10
|
+
// - Lesen ist TOLERANT (fehlt die Datei oder ist sie kurz kaputt → Fallback).
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
/** Gültige Zustände. */
|
|
17
|
+
export const STATES = Object.freeze({
|
|
18
|
+
PLAY: "PLAY", // Claude cookt → Spiel läuft
|
|
19
|
+
PAUSE: "PAUSE", // Claude wartet/fertig → Spiel pausiert
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Verzeichnis, in dem der State liegt: ~/.claude-tetris/ */
|
|
23
|
+
export function getStateDir() {
|
|
24
|
+
// Erlaubt Override via Env (nützlich für Tests und mehrere Instanzen).
|
|
25
|
+
if (process.env.CLAUDE_TETRIS_DIR) return process.env.CLAUDE_TETRIS_DIR;
|
|
26
|
+
return path.join(os.homedir(), ".claude-tetris");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Vollständiger Pfad zur State-Datei. */
|
|
30
|
+
export function getStatePath() {
|
|
31
|
+
return path.join(getStateDir(), "state.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Stellt sicher, dass das State-Verzeichnis existiert. */
|
|
35
|
+
export function ensureStateDir() {
|
|
36
|
+
const dir = getStateDir();
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Schreibt den Zustand atomar.
|
|
43
|
+
* @param {"PLAY"|"PAUSE"} state
|
|
44
|
+
* @param {object} [extra] z.B. { reason: "UserPromptSubmit" }
|
|
45
|
+
* @returns {object} das geschriebene State-Objekt
|
|
46
|
+
*/
|
|
47
|
+
export function writeState(state, extra = {}) {
|
|
48
|
+
if (state !== STATES.PLAY && state !== STATES.PAUSE) {
|
|
49
|
+
throw new Error(`Ungültiger State: ${state} (erlaubt: PLAY, PAUSE)`);
|
|
50
|
+
}
|
|
51
|
+
ensureStateDir();
|
|
52
|
+
const payload = {
|
|
53
|
+
state,
|
|
54
|
+
ts: Date.now(),
|
|
55
|
+
...extra,
|
|
56
|
+
};
|
|
57
|
+
const target = getStatePath();
|
|
58
|
+
// Eindeutiger Temp-Name pro Prozess, um Kollisionen zu vermeiden.
|
|
59
|
+
const tmp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
60
|
+
const json = JSON.stringify(payload);
|
|
61
|
+
fs.writeFileSync(tmp, json, "utf8");
|
|
62
|
+
// Atomarer Swap. renameSync ersetzt vorhandene Datei (auch auf Windows in
|
|
63
|
+
// modernem Node). Falls es doch mal klemmt: aufräumen + einmal direkt schreiben.
|
|
64
|
+
try {
|
|
65
|
+
fs.renameSync(tmp, target);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
try {
|
|
68
|
+
fs.writeFileSync(target, json, "utf8");
|
|
69
|
+
} finally {
|
|
70
|
+
try {
|
|
71
|
+
fs.rmSync(tmp, { force: true });
|
|
72
|
+
} catch {
|
|
73
|
+
/* egal */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return payload;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Liest den aktuellen Zustand tolerant.
|
|
82
|
+
* @param {object} [opts]
|
|
83
|
+
* @param {"PLAY"|"PAUSE"} [opts.fallback=PAUSE] Wert, wenn keine gültige Datei da ist.
|
|
84
|
+
* @returns {{state:string, ts:number, reason?:string}}
|
|
85
|
+
*/
|
|
86
|
+
export function readState({ fallback = STATES.PAUSE } = {}) {
|
|
87
|
+
const target = getStatePath();
|
|
88
|
+
try {
|
|
89
|
+
const raw = fs.readFileSync(target, "utf8");
|
|
90
|
+
const data = JSON.parse(raw);
|
|
91
|
+
if (data && (data.state === STATES.PLAY || data.state === STATES.PAUSE)) {
|
|
92
|
+
return data;
|
|
93
|
+
}
|
|
94
|
+
// Datei da, aber Inhalt unerwartet → Fallback.
|
|
95
|
+
return { state: fallback, ts: 0, reason: "invalid-content" };
|
|
96
|
+
} catch {
|
|
97
|
+
// Datei fehlt oder ist gerade nicht lesbar → Fallback.
|
|
98
|
+
return { state: fallback, ts: 0, reason: "no-file" };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bequeme Kurzformen für die Hook-Scripts. */
|
|
103
|
+
export function setPlay(reason = "hook") {
|
|
104
|
+
return writeState(STATES.PLAY, { reason });
|
|
105
|
+
}
|
|
106
|
+
export function setPause(reason = "hook") {
|
|
107
|
+
return writeState(STATES.PAUSE, { reason });
|
|
108
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-tetris",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Play Tetris in a split pane beside Claude Code — auto-pauses when Claude is done, resumes on your next prompt.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-tetris": "bin/tetris.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"game/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"scripts/",
|
|
14
|
+
"claude-code/",
|
|
15
|
+
"install.bat",
|
|
16
|
+
"uninstall.bat",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node bin/tetris.mjs",
|
|
22
|
+
"test": "node --test",
|
|
23
|
+
"install:hooks": "node scripts/install.mjs",
|
|
24
|
+
"uninstall:hooks": "node scripts/uninstall.mjs",
|
|
25
|
+
"launch": "node scripts/launch.mjs",
|
|
26
|
+
"dev": "node scripts/web-server.mjs"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"claude-code",
|
|
33
|
+
"tetris",
|
|
34
|
+
"plugin",
|
|
35
|
+
"tui",
|
|
36
|
+
"hooks",
|
|
37
|
+
"terminal",
|
|
38
|
+
"game"
|
|
39
|
+
],
|
|
40
|
+
"author": "Philipp Paulik",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/philppplik/claude-tetris.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/philppplik/claude-tetris#readme",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/philppplik/claude-tetris/issues"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/install.mjs — Installiert claude-tetris als Claude-Code-Plugin.
|
|
3
|
+
//
|
|
4
|
+
// Strategie: Die bestehende ~/.claude/settings.json wird NICHT überschrieben,
|
|
5
|
+
// sondern um unsere Hook-Einträge ERGÄNZT (merge). Bereits vorhandene Hooks
|
|
6
|
+
// (z.B. rune-kit) bleiben erhalten. Ein Backup wird vorher angelegt.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PLUGIN_DIR = path.resolve(__dirname, ".."); // claude-tetris/
|
|
15
|
+
const SIGNAL = path.join(PLUGIN_DIR, "scripts", "tetris-signal.mjs");
|
|
16
|
+
const CLAUDE_HOME =
|
|
17
|
+
(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")).replace(/[/\\]$/, "");
|
|
18
|
+
const SETTINGS = path.join(CLAUDE_HOME, "settings.json");
|
|
19
|
+
|
|
20
|
+
const PLAY_CMD = `node "${SIGNAL}" play`;
|
|
21
|
+
const PAUSE_CMD = `node "${SIGNAL}" pause`;
|
|
22
|
+
|
|
23
|
+
function loadSettings() {
|
|
24
|
+
if (!fs.existsSync(SETTINGS)) return {};
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
|
|
27
|
+
} catch (e) {
|
|
28
|
+
console.error(`⚠ settings.json ist kein gültiges JSON: ${e.message}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function backup() {
|
|
34
|
+
if (!fs.existsSync(SETTINGS)) {
|
|
35
|
+
console.log("💾 Keine bestehende settings.json — kein Backup nötig");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
39
|
+
const dest = `${SETTINGS}.claude-tetris-backup-${ts}`;
|
|
40
|
+
fs.copyFileSync(SETTINGS, dest);
|
|
41
|
+
console.log(`💾 Backup: ${dest}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function addHook(hooks, event, command) {
|
|
45
|
+
if (!hooks[event]) hooks[event] = [];
|
|
46
|
+
// Vermeide doppelte Einträge (gleiches Command)
|
|
47
|
+
const exists = hooks[event].some((entry) =>
|
|
48
|
+
entry.hooks?.some((h) => h.command === command)
|
|
49
|
+
);
|
|
50
|
+
if (exists) {
|
|
51
|
+
console.log(` • ${event}: bereits vorhanden, übersprungen`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
hooks[event].push({
|
|
55
|
+
matcher: ".*",
|
|
56
|
+
hooks: [{ type: "command", command, async: true }],
|
|
57
|
+
});
|
|
58
|
+
console.log(` ✓ ${event}: Hook hinzugefügt`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function main() {
|
|
62
|
+
console.log(`\n🎮 claude-tetris installieren…`);
|
|
63
|
+
console.log(` Plugin: ${PLUGIN_DIR}`);
|
|
64
|
+
console.log(` Ziel: ${SETTINGS}\n`);
|
|
65
|
+
|
|
66
|
+
const settings = loadSettings();
|
|
67
|
+
backup();
|
|
68
|
+
|
|
69
|
+
settings.hooks = settings.hooks || {};
|
|
70
|
+
addHook(settings.hooks, "UserPromptSubmit", PLAY_CMD);
|
|
71
|
+
addHook(settings.hooks, "Stop", PAUSE_CMD);
|
|
72
|
+
|
|
73
|
+
// Plugin registrieren (für marketplace-freie lokale Installation)
|
|
74
|
+
settings.enabledPlugins = settings.enabledPlugins || {};
|
|
75
|
+
settings.enabledPlugins["claude-tetris@local"] = true;
|
|
76
|
+
|
|
77
|
+
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
|
|
78
|
+
fs.writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + "\n");
|
|
79
|
+
console.log(`\n✅ Fertig. claude-tetris ist in Claude Code aktiv.`);
|
|
80
|
+
console.log(` Starte Claude Code neu, damit die Hooks greifen.`);
|
|
81
|
+
console.log(` Teste mit: node bin/tetris.mjs\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main();
|