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/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
|
+
[](https://www.npmjs.com/package/claude-tetris)
|
|
14
|
+
[](https://www.npmjs.com/package/claude-tetris)
|
|
15
|
+
[](./LICENSE)
|
|
16
|
+
[](#)
|
|
17
|
+
[](https://nodejs.org)
|
|
18
|
+
[](#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
|
+
}
|