pi-extensions 0.1.9
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/.ralph/import-cc-codex.md +31 -0
- package/.ralph/import-cc-codex.state.json +14 -0
- package/.ralph/mario-not-impl.md +69 -0
- package/.ralph/mario-not-impl.state.json +14 -0
- package/.ralph/mario-not-spec.md +163 -0
- package/.ralph/mario-not-spec.state.json +14 -0
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/RELEASING.md +34 -0
- package/agent-guidance/CHANGELOG.md +4 -0
- package/agent-guidance/README.md +102 -0
- package/agent-guidance/agent-guidance.ts +147 -0
- package/agent-guidance/package.json +22 -0
- package/agent-guidance/setup.sh +75 -0
- package/agent-guidance/templates/CLAUDE.md +5 -0
- package/agent-guidance/templates/CODEX.md +92 -0
- package/agent-guidance/templates/GEMINI.md +5 -0
- package/arcade/CHANGELOG.md +4 -0
- package/arcade/README.md +85 -0
- package/arcade/assets/picman.png +0 -0
- package/arcade/assets/ping.png +0 -0
- package/arcade/assets/spice-invaders.png +0 -0
- package/arcade/assets/tetris.png +0 -0
- package/arcade/mario-not/README.md +30 -0
- package/arcade/mario-not/boss.js +103 -0
- package/arcade/mario-not/camera.js +59 -0
- package/arcade/mario-not/collision.js +91 -0
- package/arcade/mario-not/colors.js +36 -0
- package/arcade/mario-not/constants.js +97 -0
- package/arcade/mario-not/core.js +39 -0
- package/arcade/mario-not/death.js +77 -0
- package/arcade/mario-not/effects.js +84 -0
- package/arcade/mario-not/enemies.js +31 -0
- package/arcade/mario-not/engine.js +171 -0
- package/arcade/mario-not/fireballs.js +98 -0
- package/arcade/mario-not/items.js +24 -0
- package/arcade/mario-not/levels.js +403 -0
- package/arcade/mario-not/logic.js +104 -0
- package/arcade/mario-not/mario-not.ts +297 -0
- package/arcade/mario-not/player.js +244 -0
- package/arcade/mario-not/render.js +257 -0
- package/arcade/mario-not/spec.md +548 -0
- package/arcade/mario-not/state.js +246 -0
- package/arcade/mario-not/tests/e2e.test.js +855 -0
- package/arcade/mario-not/tests/engine.test.js +888 -0
- package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
- package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
- package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
- package/arcade/mario-not/tiles.js +79 -0
- package/arcade/mario-not/tsconfig.json +14 -0
- package/arcade/mario-not/types.js +225 -0
- package/arcade/package.json +26 -0
- package/arcade/picman.ts +328 -0
- package/arcade/ping.ts +594 -0
- package/arcade/spice-invaders.ts +1104 -0
- package/arcade/tetris.ts +662 -0
- package/code-actions/CHANGELOG.md +4 -0
- package/code-actions/README.md +65 -0
- package/code-actions/actions.ts +107 -0
- package/code-actions/index.ts +148 -0
- package/code-actions/package.json +22 -0
- package/code-actions/search.ts +79 -0
- package/code-actions/snippets.ts +179 -0
- package/code-actions/ui.ts +120 -0
- package/files-widget/CHANGELOG.md +90 -0
- package/files-widget/DESIGN.md +452 -0
- package/files-widget/README.md +122 -0
- package/files-widget/TODO.md +141 -0
- package/files-widget/browser.ts +922 -0
- package/files-widget/comment.ts +5 -0
- package/files-widget/constants.ts +18 -0
- package/files-widget/demo.svg +1 -0
- package/files-widget/file-tree.ts +224 -0
- package/files-widget/file-viewer.ts +93 -0
- package/files-widget/git.ts +107 -0
- package/files-widget/index.ts +140 -0
- package/files-widget/input-utils.ts +3 -0
- package/files-widget/package.json +22 -0
- package/files-widget/types.ts +28 -0
- package/files-widget/utils.ts +26 -0
- package/files-widget/viewer.ts +424 -0
- package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
- package/import-cc-codex/spec.md +79 -0
- package/package.json +29 -0
- package/ralph-wiggum/CHANGELOG.md +7 -0
- package/ralph-wiggum/README.md +96 -0
- package/ralph-wiggum/SKILL.md +73 -0
- package/ralph-wiggum/index.ts +792 -0
- package/ralph-wiggum/package.json +25 -0
- package/raw-paste/CHANGELOG.md +7 -0
- package/raw-paste/README.md +52 -0
- package/raw-paste/index.ts +112 -0
- package/raw-paste/package.json +22 -0
- package/tab-status/CHANGELOG.md +4 -0
- package/tab-status/README.md +61 -0
- package/tab-status/assets/tab-status.png +0 -0
- package/tab-status/package.json +22 -0
- package/tab-status/tab-status.ts +179 -0
- package/usage-extension/CHANGELOG.md +17 -0
- package/usage-extension/README.md +120 -0
- package/usage-extension/index.ts +628 -0
- package/usage-extension/package.json +22 -0
- package/usage-extension/screenshot.png +0 -0
package/arcade/tetris.ts
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tetris game extension - play with /tetris
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
|
|
8
|
+
const BOARD_WIDTH = 10;
|
|
9
|
+
const BOARD_HEIGHT = 20;
|
|
10
|
+
const TICK_MS = 50;
|
|
11
|
+
const CELL_WIDTH = 2;
|
|
12
|
+
const PREVIEW_COUNT = 3;
|
|
13
|
+
|
|
14
|
+
const TETRIS_SAVE_TYPE = "tetris-save";
|
|
15
|
+
|
|
16
|
+
// Tetromino definitions: each piece has rotations as [row][col] offsets from pivot
|
|
17
|
+
type Piece = { shape: number[][][]; color: string };
|
|
18
|
+
|
|
19
|
+
const PIECES: Record<string, Piece> = {
|
|
20
|
+
I: {
|
|
21
|
+
shape: [
|
|
22
|
+
[[0, -1], [0, 0], [0, 1], [0, 2]],
|
|
23
|
+
[[-1, 0], [0, 0], [1, 0], [2, 0]],
|
|
24
|
+
[[0, -1], [0, 0], [0, 1], [0, 2]],
|
|
25
|
+
[[-1, 0], [0, 0], [1, 0], [2, 0]],
|
|
26
|
+
],
|
|
27
|
+
color: "36", // cyan
|
|
28
|
+
},
|
|
29
|
+
O: {
|
|
30
|
+
shape: [
|
|
31
|
+
[[0, 0], [0, 1], [1, 0], [1, 1]],
|
|
32
|
+
[[0, 0], [0, 1], [1, 0], [1, 1]],
|
|
33
|
+
[[0, 0], [0, 1], [1, 0], [1, 1]],
|
|
34
|
+
[[0, 0], [0, 1], [1, 0], [1, 1]],
|
|
35
|
+
],
|
|
36
|
+
color: "33", // yellow
|
|
37
|
+
},
|
|
38
|
+
T: {
|
|
39
|
+
shape: [
|
|
40
|
+
[[0, -1], [0, 0], [0, 1], [-1, 0]],
|
|
41
|
+
[[-1, 0], [0, 0], [1, 0], [0, 1]],
|
|
42
|
+
[[0, -1], [0, 0], [0, 1], [1, 0]],
|
|
43
|
+
[[-1, 0], [0, 0], [1, 0], [0, -1]],
|
|
44
|
+
],
|
|
45
|
+
color: "35", // magenta
|
|
46
|
+
},
|
|
47
|
+
S: {
|
|
48
|
+
shape: [
|
|
49
|
+
[[0, 0], [0, 1], [-1, 1], [-1, 2]],
|
|
50
|
+
[[-1, 0], [0, 0], [0, 1], [1, 1]],
|
|
51
|
+
[[0, 0], [0, 1], [-1, 1], [-1, 2]],
|
|
52
|
+
[[-1, 0], [0, 0], [0, 1], [1, 1]],
|
|
53
|
+
],
|
|
54
|
+
color: "32", // green
|
|
55
|
+
},
|
|
56
|
+
Z: {
|
|
57
|
+
shape: [
|
|
58
|
+
[[-1, 0], [-1, 1], [0, 1], [0, 2]],
|
|
59
|
+
[[0, 0], [-1, 0], [-1, 1], [-2, 1]],
|
|
60
|
+
[[-1, 0], [-1, 1], [0, 1], [0, 2]],
|
|
61
|
+
[[0, 0], [-1, 0], [-1, 1], [-2, 1]],
|
|
62
|
+
],
|
|
63
|
+
color: "31", // red
|
|
64
|
+
},
|
|
65
|
+
J: {
|
|
66
|
+
shape: [
|
|
67
|
+
[[-1, -1], [0, -1], [0, 0], [0, 1]],
|
|
68
|
+
[[-1, 0], [-1, 1], [0, 0], [1, 0]],
|
|
69
|
+
[[0, -1], [0, 0], [0, 1], [1, 1]],
|
|
70
|
+
[[-1, 0], [0, 0], [1, 0], [1, -1]],
|
|
71
|
+
],
|
|
72
|
+
color: "34", // blue
|
|
73
|
+
},
|
|
74
|
+
L: {
|
|
75
|
+
shape: [
|
|
76
|
+
[[0, -1], [0, 0], [0, 1], [-1, 1]],
|
|
77
|
+
[[-1, 0], [0, 0], [1, 0], [1, 1]],
|
|
78
|
+
[[0, -1], [0, 0], [0, 1], [1, -1]],
|
|
79
|
+
[[-1, -1], [-1, 0], [0, 0], [1, 0]],
|
|
80
|
+
],
|
|
81
|
+
color: "38;5;208", // orange
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const PIECE_NAMES = Object.keys(PIECES);
|
|
86
|
+
|
|
87
|
+
interface FallingPiece {
|
|
88
|
+
type: string;
|
|
89
|
+
rotation: number;
|
|
90
|
+
row: number;
|
|
91
|
+
col: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface GameState {
|
|
95
|
+
board: (string | null)[][]; // color code or null for empty
|
|
96
|
+
current: FallingPiece;
|
|
97
|
+
queue: string[];
|
|
98
|
+
held: string | null;
|
|
99
|
+
canHold: boolean;
|
|
100
|
+
score: number;
|
|
101
|
+
lines: number;
|
|
102
|
+
level: number;
|
|
103
|
+
highScore: number;
|
|
104
|
+
gameOver: boolean;
|
|
105
|
+
tickCounter: number;
|
|
106
|
+
lockDelay: number;
|
|
107
|
+
clearingRows: number[];
|
|
108
|
+
clearAnimTicks: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const color = (code: string, text: string): string => `\x1b[${code}m${text}\x1b[0m`;
|
|
112
|
+
const dim = (text: string): string => color("2", text);
|
|
113
|
+
const accent = (text: string): string => color("33;1", text);
|
|
114
|
+
const bold = (text: string): string => color("1", text);
|
|
115
|
+
|
|
116
|
+
const randomPiece = (): string => PIECE_NAMES[Math.floor(Math.random() * PIECE_NAMES.length)];
|
|
117
|
+
|
|
118
|
+
const generateBag = (): string[] => {
|
|
119
|
+
const bag = [...PIECE_NAMES];
|
|
120
|
+
for (let i = bag.length - 1; i > 0; i--) {
|
|
121
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
122
|
+
[bag[i], bag[j]] = [bag[j], bag[i]];
|
|
123
|
+
}
|
|
124
|
+
return bag;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const createEmptyBoard = (): (string | null)[][] =>
|
|
128
|
+
Array.from({ length: BOARD_HEIGHT }, () => Array(BOARD_WIDTH).fill(null));
|
|
129
|
+
|
|
130
|
+
const createInitialState = (highScore = 0): GameState => {
|
|
131
|
+
const queue = [...generateBag(), ...generateBag()];
|
|
132
|
+
const current: FallingPiece = {
|
|
133
|
+
type: queue.shift()!,
|
|
134
|
+
rotation: 0,
|
|
135
|
+
row: 0,
|
|
136
|
+
col: Math.floor(BOARD_WIDTH / 2),
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
board: createEmptyBoard(),
|
|
140
|
+
current,
|
|
141
|
+
queue,
|
|
142
|
+
held: null,
|
|
143
|
+
canHold: true,
|
|
144
|
+
score: 0,
|
|
145
|
+
lines: 0,
|
|
146
|
+
level: 1,
|
|
147
|
+
highScore,
|
|
148
|
+
gameOver: false,
|
|
149
|
+
tickCounter: 0,
|
|
150
|
+
lockDelay: 0,
|
|
151
|
+
clearingRows: [],
|
|
152
|
+
clearAnimTicks: 0,
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const cloneState = (state: GameState): GameState => ({
|
|
157
|
+
...state,
|
|
158
|
+
board: state.board.map((row) => [...row]),
|
|
159
|
+
current: { ...state.current },
|
|
160
|
+
queue: [...state.queue],
|
|
161
|
+
clearingRows: [...state.clearingRows],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const getPieceCells = (piece: FallingPiece): [number, number][] => {
|
|
165
|
+
const shape = PIECES[piece.type].shape[piece.rotation];
|
|
166
|
+
return shape.map(([dr, dc]) => [piece.row + dr, piece.col + dc]);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const isValidPosition = (board: (string | null)[][], piece: FallingPiece): boolean => {
|
|
170
|
+
const cells = getPieceCells(piece);
|
|
171
|
+
for (const [r, c] of cells) {
|
|
172
|
+
// Allow cells above board (r < 0) - they're in the spawn zone
|
|
173
|
+
if (r >= BOARD_HEIGHT || c < 0 || c >= BOARD_WIDTH) return false;
|
|
174
|
+
// Only check board collision for cells that are on the board
|
|
175
|
+
if (r >= 0 && board[r][c] !== null) return false;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const getDropSpeed = (level: number): number => {
|
|
181
|
+
// Frames between drops, decreases with level
|
|
182
|
+
return Math.max(2, 20 - (level - 1) * 2);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
class TetrisComponent {
|
|
186
|
+
private state: GameState;
|
|
187
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
188
|
+
private onClose: () => void;
|
|
189
|
+
private onSave: (state: GameState | null) => void;
|
|
190
|
+
private tui: { requestRender: () => void };
|
|
191
|
+
private cachedLines: string[] = [];
|
|
192
|
+
private cachedWidth = 0;
|
|
193
|
+
private version = 0;
|
|
194
|
+
private cachedVersion = -1;
|
|
195
|
+
private paused: boolean;
|
|
196
|
+
|
|
197
|
+
constructor(
|
|
198
|
+
tui: { requestRender: () => void },
|
|
199
|
+
onClose: () => void,
|
|
200
|
+
onSave: (state: GameState | null) => void,
|
|
201
|
+
savedState?: GameState,
|
|
202
|
+
) {
|
|
203
|
+
this.tui = tui;
|
|
204
|
+
this.onClose = onClose;
|
|
205
|
+
this.onSave = onSave;
|
|
206
|
+
this.state = savedState ? cloneState(savedState) : createInitialState();
|
|
207
|
+
this.paused = false;
|
|
208
|
+
this.startLoop();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private startLoop(): void {
|
|
212
|
+
if (this.interval) return;
|
|
213
|
+
this.interval = setInterval(() => this.tick(), TICK_MS);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private stopLoop(): void {
|
|
217
|
+
if (this.interval) {
|
|
218
|
+
clearInterval(this.interval);
|
|
219
|
+
this.interval = null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private tick(): void {
|
|
224
|
+
if (this.paused || this.state.gameOver) return;
|
|
225
|
+
|
|
226
|
+
// Handle line clear animation
|
|
227
|
+
if (this.state.clearingRows.length > 0) {
|
|
228
|
+
this.state.clearAnimTicks++;
|
|
229
|
+
if (this.state.clearAnimTicks >= 6) {
|
|
230
|
+
this.finalizeClear();
|
|
231
|
+
}
|
|
232
|
+
this.version++;
|
|
233
|
+
this.tui.requestRender();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.state.tickCounter++;
|
|
238
|
+
const dropSpeed = getDropSpeed(this.state.level);
|
|
239
|
+
|
|
240
|
+
if (this.state.tickCounter >= dropSpeed) {
|
|
241
|
+
this.state.tickCounter = 0;
|
|
242
|
+
if (!this.tryMove(1, 0)) {
|
|
243
|
+
this.state.lockDelay++;
|
|
244
|
+
if (this.state.lockDelay >= 10) {
|
|
245
|
+
this.lockPiece();
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
this.state.lockDelay = 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.version++;
|
|
253
|
+
this.tui.requestRender();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private tryMove(dr: number, dc: number): boolean {
|
|
257
|
+
const newPiece = { ...this.state.current, row: this.state.current.row + dr, col: this.state.current.col + dc };
|
|
258
|
+
if (isValidPosition(this.state.board, newPiece)) {
|
|
259
|
+
this.state.current = newPiece;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private tryRotate(dir: 1 | -1): boolean {
|
|
266
|
+
const newRotation = (this.state.current.rotation + dir + 4) % 4;
|
|
267
|
+
const newPiece = { ...this.state.current, rotation: newRotation };
|
|
268
|
+
|
|
269
|
+
// Try basic rotation
|
|
270
|
+
if (isValidPosition(this.state.board, newPiece)) {
|
|
271
|
+
this.state.current = newPiece;
|
|
272
|
+
this.state.lockDelay = 0;
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Wall kicks
|
|
277
|
+
const kicks = [[0, -1], [0, 1], [0, -2], [0, 2], [-1, 0], [1, 0]];
|
|
278
|
+
for (const [dr, dc] of kicks) {
|
|
279
|
+
const kickedPiece = { ...newPiece, row: newPiece.row + dr, col: newPiece.col + dc };
|
|
280
|
+
if (isValidPosition(this.state.board, kickedPiece)) {
|
|
281
|
+
this.state.current = kickedPiece;
|
|
282
|
+
this.state.lockDelay = 0;
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private hardDrop(): void {
|
|
290
|
+
let dropDistance = 0;
|
|
291
|
+
while (this.tryMove(1, 0)) {
|
|
292
|
+
dropDistance++;
|
|
293
|
+
}
|
|
294
|
+
this.state.score += dropDistance * 2;
|
|
295
|
+
this.lockPiece();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private lockPiece(): void {
|
|
299
|
+
const cells = getPieceCells(this.state.current);
|
|
300
|
+
const pieceColor = PIECES[this.state.current.type].color;
|
|
301
|
+
|
|
302
|
+
for (const [r, c] of cells) {
|
|
303
|
+
if (r >= 0 && r < BOARD_HEIGHT && c >= 0 && c < BOARD_WIDTH) {
|
|
304
|
+
this.state.board[r][c] = pieceColor;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check for line clears
|
|
309
|
+
const fullRows: number[] = [];
|
|
310
|
+
for (let r = 0; r < BOARD_HEIGHT; r++) {
|
|
311
|
+
if (this.state.board[r].every((cell) => cell !== null)) {
|
|
312
|
+
fullRows.push(r);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (fullRows.length > 0) {
|
|
317
|
+
this.state.clearingRows = fullRows;
|
|
318
|
+
this.state.clearAnimTicks = 0;
|
|
319
|
+
} else {
|
|
320
|
+
this.spawnNext();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private finalizeClear(): void {
|
|
325
|
+
const clearedCount = this.state.clearingRows.length;
|
|
326
|
+
|
|
327
|
+
// Remove cleared rows
|
|
328
|
+
this.state.board = this.state.board.filter((_, i) => !this.state.clearingRows.includes(i));
|
|
329
|
+
|
|
330
|
+
// Add new empty rows at top
|
|
331
|
+
for (let i = 0; i < clearedCount; i++) {
|
|
332
|
+
this.state.board.unshift(Array(BOARD_WIDTH).fill(null));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Scoring: 100, 300, 500, 800 for 1-4 lines
|
|
336
|
+
const lineScores = [0, 100, 300, 500, 800];
|
|
337
|
+
this.state.score += (lineScores[clearedCount] || 800) * this.state.level;
|
|
338
|
+
this.state.lines += clearedCount;
|
|
339
|
+
|
|
340
|
+
// Level up every 10 lines
|
|
341
|
+
const newLevel = Math.floor(this.state.lines / 10) + 1;
|
|
342
|
+
if (newLevel > this.state.level) {
|
|
343
|
+
this.state.level = newLevel;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
this.state.clearingRows = [];
|
|
347
|
+
this.state.clearAnimTicks = 0;
|
|
348
|
+
this.spawnNext();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private spawnNext(): void {
|
|
352
|
+
// Ensure queue has enough pieces
|
|
353
|
+
while (this.state.queue.length < PREVIEW_COUNT + 1) {
|
|
354
|
+
this.state.queue.push(...generateBag());
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
this.state.current = {
|
|
358
|
+
type: this.state.queue.shift()!,
|
|
359
|
+
rotation: 0,
|
|
360
|
+
row: 0,
|
|
361
|
+
col: Math.floor(BOARD_WIDTH / 2),
|
|
362
|
+
};
|
|
363
|
+
this.state.lockDelay = 0;
|
|
364
|
+
this.state.canHold = true;
|
|
365
|
+
|
|
366
|
+
// Check game over
|
|
367
|
+
if (!isValidPosition(this.state.board, this.state.current)) {
|
|
368
|
+
this.state.gameOver = true;
|
|
369
|
+
if (this.state.score > this.state.highScore) {
|
|
370
|
+
this.state.highScore = this.state.score;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private holdPiece(): void {
|
|
376
|
+
if (!this.state.canHold) return;
|
|
377
|
+
|
|
378
|
+
const currentType = this.state.current.type;
|
|
379
|
+
if (this.state.held === null) {
|
|
380
|
+
this.state.held = currentType;
|
|
381
|
+
this.spawnNext();
|
|
382
|
+
} else {
|
|
383
|
+
const heldType = this.state.held;
|
|
384
|
+
this.state.held = currentType;
|
|
385
|
+
this.state.current = {
|
|
386
|
+
type: heldType,
|
|
387
|
+
rotation: 0,
|
|
388
|
+
row: 0,
|
|
389
|
+
col: Math.floor(BOARD_WIDTH / 2),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
this.state.canHold = false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
handleInput(data: string): void {
|
|
396
|
+
if (this.state.gameOver) {
|
|
397
|
+
if (matchesKey(data, "r") || data === "r" || data === "R") {
|
|
398
|
+
this.state = createInitialState(this.state.highScore);
|
|
399
|
+
this.version++;
|
|
400
|
+
this.tui.requestRender();
|
|
401
|
+
} else if (matchesKey(data, "q") || data === "q" || data === "Q" || matchesKey(data, "escape")) {
|
|
402
|
+
this.dispose();
|
|
403
|
+
this.onSave(null);
|
|
404
|
+
this.onClose();
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (matchesKey(data, "p") || data === "p" || data === "P") {
|
|
410
|
+
this.paused = !this.paused;
|
|
411
|
+
this.version++;
|
|
412
|
+
this.tui.requestRender();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (matchesKey(data, "escape")) {
|
|
417
|
+
this.dispose();
|
|
418
|
+
this.onSave(this.state);
|
|
419
|
+
this.onClose();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (matchesKey(data, "q") || data === "q" || data === "Q") {
|
|
424
|
+
this.dispose();
|
|
425
|
+
this.onSave(null);
|
|
426
|
+
this.onClose();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (this.paused) {
|
|
431
|
+
this.paused = false;
|
|
432
|
+
this.version++;
|
|
433
|
+
this.tui.requestRender();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Movement - left
|
|
438
|
+
if (matchesKey(data, "left") || data === "a" || data === "A" || data === "h" || data === "H") {
|
|
439
|
+
this.tryMove(0, -1);
|
|
440
|
+
}
|
|
441
|
+
// Movement - right
|
|
442
|
+
else if (matchesKey(data, "right") || data === "d" || data === "D" || data === "l" || data === "L") {
|
|
443
|
+
this.tryMove(0, 1);
|
|
444
|
+
}
|
|
445
|
+
// Soft drop - single step per keypress
|
|
446
|
+
else if (matchesKey(data, "down") || data === "s" || data === "S" || data === "j" || data === "J") {
|
|
447
|
+
if (this.tryMove(1, 0)) {
|
|
448
|
+
this.state.score += 1;
|
|
449
|
+
this.state.lockDelay = 0;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Rotate clockwise
|
|
453
|
+
else if (matchesKey(data, "up") || data === "w" || data === "W" || data === "k" || data === "K") {
|
|
454
|
+
this.tryRotate(1);
|
|
455
|
+
}
|
|
456
|
+
// Rotate counter-clockwise
|
|
457
|
+
else if (data === "z" || data === "Z" || data === "x" || data === "X") {
|
|
458
|
+
this.tryRotate(-1);
|
|
459
|
+
}
|
|
460
|
+
// Hard drop
|
|
461
|
+
else if (data === " ") {
|
|
462
|
+
this.hardDrop();
|
|
463
|
+
}
|
|
464
|
+
// Hold piece
|
|
465
|
+
else if (data === "c" || data === "C") {
|
|
466
|
+
this.holdPiece();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
this.version++;
|
|
470
|
+
this.tui.requestRender();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
render(width: number, _height: number): string[] {
|
|
474
|
+
if (this.cachedVersion === this.version && this.cachedWidth === width) {
|
|
475
|
+
return this.cachedLines;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const lines: string[] = [];
|
|
479
|
+
const boardWidth = BOARD_WIDTH * CELL_WIDTH;
|
|
480
|
+
const sideWidth = 12;
|
|
481
|
+
const totalWidth = boardWidth + 2 + sideWidth + 3;
|
|
482
|
+
|
|
483
|
+
// Title
|
|
484
|
+
lines.push(this.padLine(bold("╔═══ TETRIS ═══╗"), width));
|
|
485
|
+
lines.push("");
|
|
486
|
+
|
|
487
|
+
// Build board with current piece
|
|
488
|
+
const displayBoard: (string | null)[][] = this.state.board.map((row) => [...row]);
|
|
489
|
+
|
|
490
|
+
// Add ghost piece
|
|
491
|
+
let ghostRow = this.state.current.row;
|
|
492
|
+
const ghostPiece = { ...this.state.current };
|
|
493
|
+
while (isValidPosition(this.state.board, { ...ghostPiece, row: ghostRow + 1 })) {
|
|
494
|
+
ghostRow++;
|
|
495
|
+
}
|
|
496
|
+
ghostPiece.row = ghostRow;
|
|
497
|
+
if (ghostRow !== this.state.current.row) {
|
|
498
|
+
const ghostCells = getPieceCells(ghostPiece);
|
|
499
|
+
for (const [r, c] of ghostCells) {
|
|
500
|
+
if (r >= 0 && r < BOARD_HEIGHT && c >= 0 && c < BOARD_WIDTH && displayBoard[r][c] === null) {
|
|
501
|
+
displayBoard[r][c] = "ghost";
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Add current piece
|
|
507
|
+
const currentCells = getPieceCells(this.state.current);
|
|
508
|
+
const currentColor = PIECES[this.state.current.type].color;
|
|
509
|
+
for (const [r, c] of currentCells) {
|
|
510
|
+
if (r >= 0 && r < BOARD_HEIGHT && c >= 0 && c < BOARD_WIDTH) {
|
|
511
|
+
displayBoard[r][c] = currentColor;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Header
|
|
516
|
+
const scoreStr = `Score: ${this.state.score.toString().padStart(6)}`;
|
|
517
|
+
const levelStr = `Lv ${this.state.level}`;
|
|
518
|
+
const linesStr = `Lines: ${this.state.lines}`;
|
|
519
|
+
|
|
520
|
+
lines.push(this.padLine(`┌${"─".repeat(boardWidth)}┐ ${dim("HOLD")}`, width));
|
|
521
|
+
|
|
522
|
+
// Render held piece
|
|
523
|
+
const heldPreview = this.renderMiniPiece(this.state.held, !this.state.canHold);
|
|
524
|
+
|
|
525
|
+
// Render preview pieces
|
|
526
|
+
const previews = this.state.queue.slice(0, PREVIEW_COUNT).map((type) => this.renderMiniPiece(type, false));
|
|
527
|
+
|
|
528
|
+
// Main board rows
|
|
529
|
+
for (let r = 0; r < BOARD_HEIGHT; r++) {
|
|
530
|
+
let rowStr = "│";
|
|
531
|
+
for (let c = 0; c < BOARD_WIDTH; c++) {
|
|
532
|
+
const cell = displayBoard[r][c];
|
|
533
|
+
const clearing = this.state.clearingRows.includes(r);
|
|
534
|
+
|
|
535
|
+
if (clearing) {
|
|
536
|
+
// Flash animation
|
|
537
|
+
const flash = this.state.clearAnimTicks % 2 === 0;
|
|
538
|
+
rowStr += flash ? color("47", " ") : " ";
|
|
539
|
+
} else if (cell === null) {
|
|
540
|
+
rowStr += dim("· ");
|
|
541
|
+
} else if (cell === "ghost") {
|
|
542
|
+
rowStr += dim("░░");
|
|
543
|
+
} else {
|
|
544
|
+
rowStr += color(cell, "██");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
rowStr += "│";
|
|
548
|
+
|
|
549
|
+
// Side panel
|
|
550
|
+
let sideContent = "";
|
|
551
|
+
if (r === 0) {
|
|
552
|
+
sideContent = heldPreview[0] || "";
|
|
553
|
+
} else if (r === 1) {
|
|
554
|
+
sideContent = heldPreview[1] || "";
|
|
555
|
+
} else if (r === 3) {
|
|
556
|
+
sideContent = dim("NEXT");
|
|
557
|
+
} else if (r >= 4 && r < 4 + PREVIEW_COUNT * 3) {
|
|
558
|
+
const previewIdx = Math.floor((r - 4) / 3);
|
|
559
|
+
const previewRow = (r - 4) % 3;
|
|
560
|
+
if (previewIdx < previews.length && previewRow < 2) {
|
|
561
|
+
sideContent = previews[previewIdx][previewRow] || "";
|
|
562
|
+
}
|
|
563
|
+
} else if (r === 14) {
|
|
564
|
+
sideContent = scoreStr;
|
|
565
|
+
} else if (r === 15) {
|
|
566
|
+
sideContent = levelStr;
|
|
567
|
+
} else if (r === 16) {
|
|
568
|
+
sideContent = linesStr;
|
|
569
|
+
} else if (r === 18) {
|
|
570
|
+
sideContent = `Hi: ${this.state.highScore}`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
lines.push(this.padLine(`${rowStr} ${sideContent}`, width));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
lines.push(this.padLine(`└${"─".repeat(boardWidth)}┘`, width));
|
|
577
|
+
|
|
578
|
+
// Controls
|
|
579
|
+
lines.push("");
|
|
580
|
+
if (this.paused) {
|
|
581
|
+
lines.push(this.padLine(`${accent("PAUSED")} - Press any key to resume`, width));
|
|
582
|
+
} else if (this.state.gameOver) {
|
|
583
|
+
lines.push(this.padLine(color("31;1", "GAME OVER") + ` - ${accent("R")} restart, ${accent("Q")} quit`, width));
|
|
584
|
+
} else {
|
|
585
|
+
lines.push(this.padLine(`←→/AD move │ ↑/W rotate │ ↓/S soft drop │ ${accent("SPACE")} hard drop`, width));
|
|
586
|
+
lines.push(this.padLine(`${accent("C")} hold │ ${accent("P")} pause │ ${accent("ESC")} save & quit`, width));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
this.cachedLines = lines;
|
|
590
|
+
this.cachedWidth = width;
|
|
591
|
+
this.cachedVersion = this.version;
|
|
592
|
+
|
|
593
|
+
return lines;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private renderMiniPiece(type: string | null, faded: boolean): string[] {
|
|
597
|
+
if (type === null) return [" ", " "];
|
|
598
|
+
|
|
599
|
+
const piece = PIECES[type];
|
|
600
|
+
const shape = piece.shape[0];
|
|
601
|
+
const cells = new Set(shape.map(([r, c]) => `${r},${c}`));
|
|
602
|
+
|
|
603
|
+
const rows: string[] = [];
|
|
604
|
+
for (let r = -1; r <= 1; r++) {
|
|
605
|
+
let rowStr = "";
|
|
606
|
+
for (let c = -1; c <= 2; c++) {
|
|
607
|
+
if (cells.has(`${r},${c}`)) {
|
|
608
|
+
rowStr += faded ? dim("▓▓") : color(piece.color, "██");
|
|
609
|
+
} else {
|
|
610
|
+
rowStr += " ";
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
rows.push(rowStr);
|
|
614
|
+
}
|
|
615
|
+
return rows.slice(0, 2);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private padLine(line: string, width: number): string {
|
|
619
|
+
const truncated = truncateToWidth(line, width);
|
|
620
|
+
const padding = Math.max(0, width - visibleWidth(truncated));
|
|
621
|
+
return truncated + " ".repeat(padding);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
dispose(): void {
|
|
625
|
+
this.stopLoop();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export default function (pi: ExtensionAPI) {
|
|
630
|
+
const runGame = async (_args: string, ctx: ExtensionCommandContext) => {
|
|
631
|
+
if (!ctx.hasUI) {
|
|
632
|
+
ctx.ui.notify("Tetris requires interactive mode", "error");
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const entries = ctx.sessionManager.getEntries();
|
|
637
|
+
let savedState: GameState | undefined;
|
|
638
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
639
|
+
const entry = entries[i];
|
|
640
|
+
if (entry.type === "custom" && entry.customType === TETRIS_SAVE_TYPE) {
|
|
641
|
+
savedState = entry.data as GameState;
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
await ctx.ui.custom((tui, _theme, _kb, done) => {
|
|
647
|
+
return new TetrisComponent(
|
|
648
|
+
tui,
|
|
649
|
+
() => done(undefined),
|
|
650
|
+
(state) => {
|
|
651
|
+
pi.appendEntry(TETRIS_SAVE_TYPE, state);
|
|
652
|
+
},
|
|
653
|
+
savedState,
|
|
654
|
+
);
|
|
655
|
+
});
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
pi.registerCommand("tetris", {
|
|
659
|
+
description: "Play Tetris!",
|
|
660
|
+
handler: runGame,
|
|
661
|
+
});
|
|
662
|
+
}
|