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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philipp Paulik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ <div align="center">
2
+
3
+ <img src="claude-tetris.png" alt="claude-tetris — Tetris ▷_ Claude" width="100%" />
4
+
5
+ # 🧱 claude-tetris
6
+
7
+ ### Play Tetris in a split pane beside Claude Code.
8
+
9
+ A full Tetris game that runs **alongside** Claude Code. It **auto-pauses the moment
10
+ Claude is done** — and resumes the second you type your next prompt. A tiny reward
11
+ for long coding sessions.
12
+
13
+ [![npm version](https://img.shields.io/npm/v/claude-tetris.svg)](https://www.npmjs.com/package/claude-tetris)
14
+ [![npm downloads](https://img.shields.io/npm/dm/claude-tetris.svg)](https://www.npmjs.com/package/claude-tetris)
15
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
16
+ [![tests: 44 passing](https://img.shields.io/badge/tests-44%20passing-brightgreen.svg)](#)
17
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
18
+ [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing)
19
+
20
+ </div>
21
+
22
+ ---
23
+
24
+ ## ✨ Features
25
+
26
+ - 🎯 **SRS rotation** + wall kicks (exact Super Rotation System)
27
+ - 🎲 **7-bag randomizer** for fair piece distribution
28
+ - 👻 **Ghost piece**, **hold**, hard / soft drop
29
+ - ⏸ **Auto-pause coupling** via Claude Code hooks — no polling, just `fs.watch`
30
+ - 🖥 **Windows Terminal split-pane** (Claude left, Tetris right)
31
+ - 📐 **Responsive TUI** that recomputes on resize (SIGWINCH)
32
+ - 🌐 **Showcase website** in [`web/`](web/README.md) — Claude-style, interactive canvas Tetris
33
+ - ⌨️ **`/tetris` slash command** for Claude Code
34
+
35
+ ---
36
+
37
+ ## 📦 Install
38
+
39
+ ### Option A — npm (global)
40
+
41
+ ```bash
42
+ npm install -g claude-tetris
43
+ claude-tetris install # wire up the Claude Code hooks (backs up settings.json)
44
+ claude-tetris launch # open the split pane (Claude left, Tetris right)
45
+ ```
46
+
47
+ ### Option B — npx (no install)
48
+
49
+ ```bash
50
+ npx claude-tetris
51
+ ```
52
+
53
+ ### Option C — Windows double-click (easiest)
54
+
55
+ 1. Double-click **`install.bat`** — hooks install automatically (your `settings.json` is backed up).
56
+ 2. When prompted, open the split pane.
57
+ 3. To remove: double-click **`uninstall.bat`**.
58
+
59
+ ### Claude Code slash command
60
+
61
+ Once the hooks are installed, type **`/tetris`** inside Claude Code to launch the
62
+ game in a fresh split pane.
63
+
64
+ ---
65
+
66
+ ## 🪄 How the pause magic works
67
+
68
+ Claude Code hooks write a single signal file; the TUI watches it — no polling, no lag.
69
+
70
+ ```
71
+ Claude Code ──hook──▶ state.json ──fs.watch──▶ Tetris TUI
72
+ (UserPromptSubmit) {state:"PLAY"} (resume)
73
+ (Stop) {state:"PAUSE"} (freeze)
74
+ ```
75
+
76
+ | Hook event | Signal | Game |
77
+ | ------------------ | --------------------- | ---------- |
78
+ | `UserPromptSubmit` | `claude-tetris play` | ▶ resumes |
79
+ | `Stop` | `claude-tetris pause` | ⏸ freezes |
80
+
81
+ ---
82
+
83
+ ## 🎮 Controls
84
+
85
+ | Key | Action |
86
+ | ------------- | ------------------------- |
87
+ | `←` `→` | move |
88
+ | `↑` / `X` | rotate |
89
+ | `↓` | soft drop |
90
+ | `Space` | hard drop |
91
+ | `C` | hold |
92
+ | `P` | pause / resume |
93
+ | `Q` | quit |
94
+ | `R` | restart (after game over)|
95
+ | `F11` | fullscreen (recommended) |
96
+
97
+ ---
98
+
99
+ ## 🛠 CLI
100
+
101
+ ```bash
102
+ claude-tetris # play now (current terminal)
103
+ claude-tetris install # install Claude Code hooks
104
+ claude-tetris uninstall # remove hooks
105
+ claude-tetris launch # open Windows Terminal split pane
106
+ ```
107
+
108
+ Equivalent npm scripts:
109
+
110
+ ```bash
111
+ npm start # play now
112
+ npm run install:hooks # install hooks
113
+ npm run uninstall:hooks # remove hooks
114
+ npm run launch # open split pane
115
+ npm run dev # serve the showcase website (web/)
116
+ npm test # run the 44 unit tests
117
+ ```
118
+
119
+ ---
120
+
121
+ ## 🏗 Architecture
122
+
123
+ ```
124
+ claude-tetris/
125
+ ├── bin/tetris.mjs # CLI entry (npx claude-tetris)
126
+ ├── game/
127
+ │ ├── core.mjs # headless engine (testable, no I/O)
128
+ │ └── tui.mjs # terminal UI: ANSI render, raw keys, signal watch
129
+ ├── lib/signal.mjs # atomic state-file comms (hook ↔ TUI)
130
+ ├── scripts/
131
+ │ ├── install.mjs # merge hooks into ~/.claude/settings.json
132
+ │ ├── uninstall.mjs # remove claude-tetris hooks only
133
+ │ ├── launch.mjs # Windows Terminal split-pane launcher
134
+ │ └── tetris-signal.mjs # hook bridge: play / pause / status
135
+ ├── claude-code/ # plugin manifest + command + hooks template
136
+ ├── install.bat # double-click Windows installer
137
+ ├── uninstall.bat # double-click Windows uninstaller
138
+ ├── web/ # showcase website (static HTML/CSS/JS)
139
+ └── tests/ # 44 unit tests (node --test)
140
+ ```
141
+
142
+ **Key design decisions**
143
+
144
+ - **Headless engine** (`game/core.mjs`) — no terminal I/O, fully unit-tested.
145
+ - **Signal channel** (`lib/signal.mjs`) — atomic temp+rename writes, tolerant reads.
146
+ Avoids Windows socket/pipe pain.
147
+ - **Hook merge** — installs never overwrite existing hooks; backups auto-created.
148
+
149
+ ---
150
+
151
+ ## 🧪 Development
152
+
153
+ ```bash
154
+ git clone https://github.com/philppplik/claude-tetris.git
155
+ cd claude-tetris
156
+ npm test # 44 tests, ~1s
157
+ npm run dev # open the showcase site at http://localhost:8137
158
+ ```
159
+
160
+ ---
161
+
162
+ ## 🤝 Contributing
163
+
164
+ PRs welcome! The engine (`game/core.mjs`) is fully headless and tested — add a
165
+ feature, extend a test, open a PR.
166
+
167
+ > Built with [Hermes Agent](https://hermes-agent.nousresearch.com) 🤖 and
168
+ > [Claude](https://claude.ai) ✨ — Philipp Paulik's AI collaborators.
169
+
170
+ ---
171
+
172
+ ## 📜 License
173
+
174
+ MIT © Philipp Paulik
package/bin/tetris.mjs ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // bin/tetris.mjs — Einstiegspunkt für `npx claude-tetris` / direkten Aufruf.
3
+ // Startet die TUI. Die Pause-Kopplung an Claude Code erfolgt über die Hooks
4
+ // (scripts/tetris-signal.mjs), die state.json schreiben.
5
+
6
+ import { TetrisTUI } from "../game/tui.mjs";
7
+
8
+ const ui = new TetrisTUI();
9
+ ui.start();
@@ -0,0 +1,23 @@
1
+ ---
2
+ description: Launch claude-tetris in a new terminal pane (playable while Claude works)
3
+ ---
4
+
5
+ Launch the **claude-tetris** game in a new Windows Terminal pane so it runs alongside
6
+ Claude Code. The game auto-pauses when Claude finishes and resumes on your next prompt
7
+ (via the installed hooks).
8
+
9
+ Open a split pane and start the game. Prefer the global CLI if installed:
10
+
11
+ ```bash
12
+ claude-tetris launch
13
+ ```
14
+
15
+ If the global CLI is not on PATH (e.g. running from a local clone), fall back to:
16
+
17
+ ```bash
18
+ node "<plugin_dir>/scripts/launch.mjs"
19
+ ```
20
+
21
+ Tell the user the game is now running and remind them of the controls:
22
+ `←→` move · `↑`/`X` rotate · `↓` soft drop · `Space` hard drop · `C` hold · `P` pause · `Q` quit.
23
+ Press **F11** in the game pane for fullscreen (recommended).
@@ -0,0 +1,24 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"<plugin_dir>/scripts/tetris-signal.mjs\" play"
9
+ }
10
+ ]
11
+ }
12
+ ],
13
+ "Stop": [
14
+ {
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "node \"<plugin_dir>/scripts/tetris-signal.mjs\" pause"
19
+ }
20
+ ]
21
+ }
22
+ ]
23
+ }
24
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "claude-tetris",
3
+ "version": "0.2.0",
4
+ "description": "Play Tetris in a split pane while Claude Code works. The game auto-pauses when Claude is done and resumes on your next prompt.",
5
+ "author": {
6
+ "name": "Philipp Paulik"
7
+ },
8
+ "license": "MIT",
9
+ "hooks": "./hooks.json",
10
+ "commands": "./commands",
11
+ "keywords": ["tetris", "game", "hooks", "tui", "productivity"]
12
+ }
package/game/core.mjs ADDED
@@ -0,0 +1,334 @@
1
+ // game/core.mjs — Headless Tetris-Engine (keine Terminal-I/O).
2
+ //
3
+ // Vollständige Spiellogik: Board, 7 Tetrominoes, SRS-Rotation mit Wall-Kicks,
4
+ // 7-Bag-Randomizer, Line-Clears, Scoring, Hold, Game-Over.
5
+ //
6
+ // Diese Datei hat KEINEN Output und keine Eingabe — sie wird von der TUI (Phase 2)
7
+ // gekapselt und hier in Tests verifiziert.
8
+
9
+ export const WIDTH = 10;
10
+ export const HEIGHT = 20;
11
+ export const NEXT_QUEUE = 5; // wie viele "Next"-Steine vorgehalten werden
12
+
13
+ /** Stück-Typen. 0 = leer im Board. */
14
+ export const TYPES = ["I", "O", "T", "S", "Z", "J", "L"];
15
+
16
+ /**
17
+ * Relative Zellen [row, col] je Rotationszustand (0..3) innerhalb der
18
+ * Stück-Bounding-Box. Daten entsprechen dem offiziellen SRS (Super Rotation
19
+ * System) Guideline — das macht das Spiel sich „richtig" an.
20
+ */
21
+ export const SHAPES = {
22
+ O: [
23
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
24
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
25
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
26
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
27
+ ],
28
+ I: [
29
+ [[1, 0], [1, 1], [1, 2], [1, 3]],
30
+ [[0, 2], [1, 2], [2, 2], [3, 2]],
31
+ [[2, 0], [2, 1], [2, 2], [2, 3]],
32
+ [[0, 1], [1, 1], [2, 1], [3, 1]],
33
+ ],
34
+ T: [
35
+ [[0, 1], [1, 0], [1, 1], [1, 2]],
36
+ [[0, 1], [1, 1], [1, 2], [2, 1]],
37
+ [[1, 0], [1, 1], [1, 2], [2, 1]],
38
+ [[0, 1], [1, 0], [1, 1], [2, 1]],
39
+ ],
40
+ J: [
41
+ [[0, 0], [1, 0], [1, 1], [1, 2]],
42
+ [[0, 1], [0, 2], [1, 1], [2, 1]],
43
+ [[1, 0], [1, 1], [1, 2], [2, 2]],
44
+ [[0, 1], [1, 1], [2, 0], [2, 1]],
45
+ ],
46
+ L: [
47
+ [[0, 2], [1, 0], [1, 1], [1, 2]],
48
+ [[0, 1], [1, 1], [2, 1], [2, 2]],
49
+ [[1, 0], [1, 1], [1, 2], [2, 0]],
50
+ [[0, 0], [0, 1], [1, 1], [2, 1]],
51
+ ],
52
+ S: [
53
+ [[0, 1], [0, 2], [1, 0], [1, 1]],
54
+ [[0, 1], [1, 1], [1, 2], [2, 2]],
55
+ [[1, 1], [1, 2], [2, 0], [2, 1]],
56
+ [[0, 0], [1, 0], [1, 1], [2, 1]],
57
+ ],
58
+ Z: [
59
+ [[0, 0], [0, 1], [1, 1], [1, 2]],
60
+ [[0, 2], [1, 1], [1, 2], [2, 1]],
61
+ [[1, 0], [1, 1], [2, 1], [2, 2]],
62
+ [[0, 1], [1, 0], [1, 1], [2, 0]],
63
+ ],
64
+ };
65
+
66
+ /** Breite der Bounding-Box je Stück (für Spawn-Position). */
67
+ const BOX = { I: 4, O: 2, T: 3, J: 3, L: 3, S: 3, Z: 3 };
68
+
69
+ /**
70
+ * SRS Wall-Kick-Tabellen (x rechts+, y hoch+). Bei Anwendung wird y negiert,
71
+ * weil unser Board y nach unten zählt.
72
+ */
73
+ const KICKS_JLSTZ = {
74
+ "01": [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
75
+ "10": [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
76
+ "12": [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
77
+ "21": [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
78
+ "23": [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
79
+ "32": [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
80
+ "30": [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
81
+ "03": [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
82
+ };
83
+ const KICKS_I = {
84
+ "01": [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
85
+ "10": [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
86
+ "12": [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
87
+ "21": [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
88
+ "23": [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
89
+ "32": [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
90
+ "30": [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
91
+ "03": [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
92
+ };
93
+
94
+ /** Standard-Scoring (Guideline). */
95
+ function lineScore(lines, level) {
96
+ const base = { 1: 100, 2: 300, 3: 500, 4: 800 }[lines] ?? 0;
97
+ return base * (level + 1);
98
+ }
99
+
100
+ export class Tetris {
101
+ constructor(opts = {}) {
102
+ /** rng: Funktion -> [0,1). Für deterministische Tests übergebbar. */
103
+ this._rng = opts.rng ?? Math.random;
104
+ this.reset();
105
+ }
106
+
107
+ reset() {
108
+ this.board = Array.from({ length: HEIGHT }, () => new Array(WIDTH).fill(0));
109
+ this.score = 0;
110
+ this.lines = 0;
111
+ this.level = 0;
112
+ this.gameOver = false;
113
+ this._bag = [];
114
+ this.queue = [];
115
+ this.old = null;
116
+ this.canHold = true; // darf im Reset initial true sein
117
+ this.current = null;
118
+ this._refillQueue();
119
+ this.spawn();
120
+ }
121
+
122
+ // ---- Randomizer: 7-Bag ----
123
+ _refillQueue() {
124
+ while (this.queue.length < NEXT_QUEUE + 1) {
125
+ if (this._bag.length === 0) {
126
+ this._bag = [...TYPES];
127
+ // Fisher-Yates mit rng mischen
128
+ for (let i = this._bag.length - 1; i > 0; i--) {
129
+ const j = Math.floor(this._rng() * (i + 1));
130
+ [this._bag[i], this._bag[j]] = [this._bag[j], this._bag[i]];
131
+ }
132
+ }
133
+ this.queue.push(this._bag.pop());
134
+ }
135
+ }
136
+
137
+ /** Aktuelle Spawn-Position (oben, zentriert). */
138
+ _spawnPos(type) {
139
+ const box = BOX[type];
140
+ const col = Math.floor((WIDTH - box) / 2);
141
+ return { row: 0, col };
142
+ }
143
+
144
+ spawn(type = null) {
145
+ const t = type ?? this.queue.shift();
146
+ this._refillQueue();
147
+ const pos = this._spawnPos(t);
148
+ this.current = { type: t, rot: 0, ...pos };
149
+ // Achtung: canHold wird NICHT hier gesetzt — sonst wird der Hold-Lock
150
+ // (canHold=false) von holdPiece() beim erneuten Spawn überschrieben.
151
+ // canHold wird nur in _lock() (natürlicher Fall) auf true gesetzt.
152
+ // Überlappung bei Spawn = Game Over
153
+ if (this._collides(this.current, 0, 0, 0)) {
154
+ this.gameOver = true;
155
+ }
156
+ return !this.gameOver;
157
+ }
158
+
159
+ /** Liefert absolute Zellen [row,col] eines Stücks (oder eines Probe-Offsets). */
160
+ _cells(piece, dRow = 0, dCol = 0, dRot = 0) {
161
+ const shape = SHAPES[piece.type][(piece.rot + dRot + 4) % 4];
162
+ return shape.map(([r, c]) => [
163
+ piece.row + r + dRow,
164
+ piece.col + c + dCol,
165
+ ]);
166
+ }
167
+
168
+ _collides(piece, dRow, dCol, dRot) {
169
+ for (const [r, c] of this._cells(piece, dRow, dCol, dRot)) {
170
+ if (c < 0 || c >= WIDTH || r >= HEIGHT) return true; // außerhalb (unten/Seiten)
171
+ if (r >= 0 && this.board[r][c]) return true; // belegt (r<0 = temporär über dem Feld)
172
+ }
173
+ return false;
174
+ }
175
+
176
+ // ---- Bewegungen ----
177
+ move(dir) {
178
+ if (this.gameOver) return false;
179
+ if (!this._collides(this.current, 0, dir, 0)) {
180
+ this.current.col += dir;
181
+ return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ rotate(dir) {
187
+ if (this.gameOver) return false;
188
+ if (this.current.type === "O") return false; // O rotiert nicht
189
+ const from = this.current.rot;
190
+ const to = (from + dir + 4) % 4;
191
+ const table = this.current.type === "I" ? KICKS_I : KICKS_JLSTZ;
192
+ const kicks = table[`${from}${to}`] ?? [[0, 0]];
193
+ for (const [x, y] of kicks) {
194
+ const dy = -y; // y-up -> board-dy
195
+ if (!this._collides(this.current, dy, x, dir)) {
196
+ this.current.row += dy;
197
+ this.current.col += x;
198
+ this.current.rot = to;
199
+ return true;
200
+ }
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /** Soft-Drop: 1 nach unten. +1 Punkt. Gibt true zurück wenn bewegt. */
206
+ softDrop() {
207
+ if (this.gameOver) return false;
208
+ if (!this._collides(this.current, 1, 0, 0)) {
209
+ this.current.row += 1;
210
+ this.score += 1;
211
+ return true;
212
+ }
213
+ this._lock();
214
+ return false;
215
+ }
216
+
217
+ /** Hard-Drop: fällt ganz runter, +2 Punkte/Zeile, sofort Lock. */
218
+ hardDrop() {
219
+ if (this.gameOver) return 0;
220
+ let dist = 0;
221
+ while (!this._collides(this.current, dist + 1, 0, 0)) dist++;
222
+ this.current.row += dist;
223
+ this.score += dist * 2;
224
+ this._lock();
225
+ return dist;
226
+ }
227
+
228
+ /** Ein Gravitations-Schritt (von der TUI-Timer aufgerufen). */
229
+ step() {
230
+ if (this.gameOver) return;
231
+ if (!this._collides(this.current, 1, 0, 0)) {
232
+ this.current.row += 1;
233
+ } else {
234
+ this._lock();
235
+ }
236
+ }
237
+
238
+ /** Aktuelles Stück ins Board einbetten + Linien prüfen + nächstes Spawn. */
239
+ _lock() {
240
+ for (const [r, c] of this._cells(this.current)) {
241
+ if (r >= 0 && r < HEIGHT && c >= 0 && c < WIDTH) {
242
+ this.board[r][c] = this.current.type;
243
+ }
244
+ }
245
+ const cleared = this._clearLines();
246
+ if (cleared > 0) {
247
+ this.lines += cleared;
248
+ this.score += lineScore(cleared, this.level);
249
+ this.level = Math.floor(this.lines / 10);
250
+ }
251
+ this.spawn();
252
+ this.canHold = true; // nach natürlichem Lock wieder halten erlaubt
253
+ }
254
+
255
+ _clearLines() {
256
+ let cleared = 0;
257
+ for (let r = HEIGHT - 1; r >= 0; r--) {
258
+ if (this.board[r].every((v) => v !== 0)) {
259
+ this.board.splice(r, 1);
260
+ this.board.unshift(new Array(WIDTH).fill(0));
261
+ cleared++;
262
+ r++; // selbe Zeile erneut prüfen (durch Nachrücken)
263
+ }
264
+ }
265
+ return cleared;
266
+ }
267
+
268
+ /** Hold: aktuelles Stück tauschen (1× pro Fall). */
269
+ holdPiece() {
270
+ if (this.gameOver || !this.canHold) return false;
271
+ this.canHold = false;
272
+ const cur = this.current.type;
273
+ if (this.old == null) {
274
+ this.old = cur;
275
+ this.spawn();
276
+ } else {
277
+ const swap = this.hold;
278
+ this.hold = cur;
279
+ this.spawn(swap);
280
+ }
281
+ return true;
282
+ }
283
+
284
+ /** Board inkl. aktuellem Stück, für Rendering. 0 = leer, sonst Typ-Buchstabe. */
285
+ /**
286
+ * Landeposition („Ghost") des aktuellen Stücks: wie weit es bei Hard-Drop
287
+ * fallen würde. Gibt die Ziel-Row des Stücks (obenstehende Kante) zurück.
288
+ */
289
+ getGhostRow() {
290
+ if (!this.current || this.gameOver) return null;
291
+ let ghost = { ...this.current };
292
+ while (!this._collides(ghost, 1, 0, 0)) {
293
+ ghost.row++;
294
+ }
295
+ return ghost.row;
296
+ }
297
+
298
+ getView({ ghost = true } = {}) {
299
+ const view = this.board.map((row) => [...row]);
300
+ if (this.current && !this.gameOver) {
301
+ // Ghost zuerst (damit das echte Stück drüber zeichnet)
302
+ if (ghost) {
303
+ const gr = this.getGhostRow();
304
+ if (gr !== null && gr !== this.current.row) {
305
+ for (const [r, c] of this._cells({ ...this.current, row: gr })) {
306
+ if (r >= 0 && r < HEIGHT && c >= 0 && c < WIDTH) {
307
+ if (view[r][c] === "." || view[r][c] === 0) view[r][c] = "G"; // G = Ghost-Marker
308
+ }
309
+ }
310
+ }
311
+ }
312
+ for (const [r, c] of this._cells(this.current)) {
313
+ if (r >= 0 && r < HEIGHT && c >= 0 && c < WIDTH) {
314
+ view[r][c] = this.current.type;
315
+ }
316
+ }
317
+ }
318
+ return view;
319
+ }
320
+
321
+ /** Öffentliche Snapshot-Daten für TUI/Tests. */
322
+ getState() {
323
+ return {
324
+ board: this.getView(),
325
+ current: this.current,
326
+ next: [...this.queue.slice(0, NEXT_QUEUE)],
327
+ hold: this.hold,
328
+ score: this.score,
329
+ lines: this.lines,
330
+ level: this.level,
331
+ gameOver: this.gameOver,
332
+ };
333
+ }
334
+ }