mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 13:03:42 +00:00
- Add setEditorComponent() to ctx.ui for custom editor components - Add CustomEditor base class for extensions (handles app keybindings) - Add keybindings parameter to ctx.ui.custom() factory (breaking change) - Add modal-editor.ts example (vim-like modes) - Add rainbow-editor.ts example (animated text highlighting) - Update docs: extensions.md, tui.md Pattern 7 - Clean up terminal on TUI render errors
343 lines
9.2 KiB
TypeScript
343 lines
9.2 KiB
TypeScript
/**
|
|
* Snake game extension - play snake with /snake command
|
|
*/
|
|
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
|
|
|
const GAME_WIDTH = 40;
|
|
const GAME_HEIGHT = 15;
|
|
const TICK_MS = 100;
|
|
|
|
type Direction = "up" | "down" | "left" | "right";
|
|
type Point = { x: number; y: number };
|
|
|
|
interface GameState {
|
|
snake: Point[];
|
|
food: Point;
|
|
direction: Direction;
|
|
nextDirection: Direction;
|
|
score: number;
|
|
gameOver: boolean;
|
|
highScore: number;
|
|
}
|
|
|
|
function createInitialState(): GameState {
|
|
const startX = Math.floor(GAME_WIDTH / 2);
|
|
const startY = Math.floor(GAME_HEIGHT / 2);
|
|
return {
|
|
snake: [
|
|
{ x: startX, y: startY },
|
|
{ x: startX - 1, y: startY },
|
|
{ x: startX - 2, y: startY },
|
|
],
|
|
food: spawnFood([{ x: startX, y: startY }]),
|
|
direction: "right",
|
|
nextDirection: "right",
|
|
score: 0,
|
|
gameOver: false,
|
|
highScore: 0,
|
|
};
|
|
}
|
|
|
|
function spawnFood(snake: Point[]): Point {
|
|
let food: Point;
|
|
do {
|
|
food = {
|
|
x: Math.floor(Math.random() * GAME_WIDTH),
|
|
y: Math.floor(Math.random() * GAME_HEIGHT),
|
|
};
|
|
} while (snake.some((s) => s.x === food.x && s.y === food.y));
|
|
return food;
|
|
}
|
|
|
|
class SnakeComponent {
|
|
private state: GameState;
|
|
private interval: ReturnType<typeof setInterval> | null = null;
|
|
private onClose: () => void;
|
|
private onSave: (state: GameState | null) => void;
|
|
private tui: { requestRender: () => void };
|
|
private cachedLines: string[] = [];
|
|
private cachedWidth = 0;
|
|
private version = 0;
|
|
private cachedVersion = -1;
|
|
private paused: boolean;
|
|
|
|
constructor(
|
|
tui: { requestRender: () => void },
|
|
onClose: () => void,
|
|
onSave: (state: GameState | null) => void,
|
|
savedState?: GameState,
|
|
) {
|
|
this.tui = tui;
|
|
if (savedState && !savedState.gameOver) {
|
|
// Resume from saved state, start paused
|
|
this.state = savedState;
|
|
this.paused = true;
|
|
} else {
|
|
// New game or saved game was over
|
|
this.state = createInitialState();
|
|
if (savedState) {
|
|
this.state.highScore = savedState.highScore;
|
|
}
|
|
this.paused = false;
|
|
this.startGame();
|
|
}
|
|
this.onClose = onClose;
|
|
this.onSave = onSave;
|
|
}
|
|
|
|
private startGame(): void {
|
|
this.interval = setInterval(() => {
|
|
if (!this.state.gameOver) {
|
|
this.tick();
|
|
this.version++;
|
|
this.tui.requestRender();
|
|
}
|
|
}, TICK_MS);
|
|
}
|
|
|
|
private tick(): void {
|
|
// Apply queued direction change
|
|
this.state.direction = this.state.nextDirection;
|
|
|
|
// Calculate new head position
|
|
const head = this.state.snake[0];
|
|
let newHead: Point;
|
|
|
|
switch (this.state.direction) {
|
|
case "up":
|
|
newHead = { x: head.x, y: head.y - 1 };
|
|
break;
|
|
case "down":
|
|
newHead = { x: head.x, y: head.y + 1 };
|
|
break;
|
|
case "left":
|
|
newHead = { x: head.x - 1, y: head.y };
|
|
break;
|
|
case "right":
|
|
newHead = { x: head.x + 1, y: head.y };
|
|
break;
|
|
}
|
|
|
|
// Check wall collision
|
|
if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
|
|
this.state.gameOver = true;
|
|
return;
|
|
}
|
|
|
|
// Check self collision
|
|
if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
|
|
this.state.gameOver = true;
|
|
return;
|
|
}
|
|
|
|
// Move snake
|
|
this.state.snake.unshift(newHead);
|
|
|
|
// Check food collision
|
|
if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
|
|
this.state.score += 10;
|
|
if (this.state.score > this.state.highScore) {
|
|
this.state.highScore = this.state.score;
|
|
}
|
|
this.state.food = spawnFood(this.state.snake);
|
|
} else {
|
|
this.state.snake.pop();
|
|
}
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
// If paused (resuming), wait for any key
|
|
if (this.paused) {
|
|
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
// Quit without clearing save
|
|
this.dispose();
|
|
this.onClose();
|
|
return;
|
|
}
|
|
// Any other key resumes
|
|
this.paused = false;
|
|
this.startGame();
|
|
return;
|
|
}
|
|
|
|
// ESC to pause and save
|
|
if (matchesKey(data, "escape")) {
|
|
this.dispose();
|
|
this.onSave(this.state);
|
|
this.onClose();
|
|
return;
|
|
}
|
|
|
|
// Q to quit without saving (clears saved state)
|
|
if (data === "q" || data === "Q") {
|
|
this.dispose();
|
|
this.onSave(null); // Clear saved state
|
|
this.onClose();
|
|
return;
|
|
}
|
|
|
|
// Arrow keys or WASD
|
|
if (matchesKey(data, "up") || data === "w" || data === "W") {
|
|
if (this.state.direction !== "down") this.state.nextDirection = "up";
|
|
} else if (matchesKey(data, "down") || data === "s" || data === "S") {
|
|
if (this.state.direction !== "up") this.state.nextDirection = "down";
|
|
} else if (matchesKey(data, "right") || data === "d" || data === "D") {
|
|
if (this.state.direction !== "left") this.state.nextDirection = "right";
|
|
} else if (matchesKey(data, "left") || data === "a" || data === "A") {
|
|
if (this.state.direction !== "right") this.state.nextDirection = "left";
|
|
}
|
|
|
|
// Restart on game over
|
|
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
|
|
const highScore = this.state.highScore;
|
|
this.state = createInitialState();
|
|
this.state.highScore = highScore;
|
|
this.onSave(null); // Clear saved state on restart
|
|
this.version++;
|
|
this.tui.requestRender();
|
|
}
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.cachedWidth = 0;
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
|
return this.cachedLines;
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
|
|
// Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect)
|
|
const cellWidth = 2;
|
|
const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
|
|
const effectiveHeight = GAME_HEIGHT;
|
|
|
|
// Colors
|
|
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
|
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
|
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
|
|
const boxWidth = effectiveWidth * cellWidth;
|
|
|
|
// Helper to pad content inside box
|
|
const boxLine = (content: string) => {
|
|
const contentLen = visibleWidth(content);
|
|
const padding = Math.max(0, boxWidth - contentLen);
|
|
return dim(" │") + content + " ".repeat(padding) + dim("│");
|
|
};
|
|
|
|
// Top border
|
|
lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width));
|
|
|
|
// Header with score
|
|
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
|
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
|
|
const title = `${bold(green("SNAKE"))} │ ${scoreText} │ ${highText}`;
|
|
lines.push(this.padLine(boxLine(title), width));
|
|
|
|
// Separator
|
|
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
|
|
|
// Game grid
|
|
for (let y = 0; y < effectiveHeight; y++) {
|
|
let row = "";
|
|
for (let x = 0; x < effectiveWidth; x++) {
|
|
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
|
|
const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
|
|
const isFood = this.state.food.x === x && this.state.food.y === y;
|
|
|
|
if (isHead) {
|
|
row += green("██"); // Snake head (2 chars)
|
|
} else if (isBody) {
|
|
row += green("▓▓"); // Snake body (2 chars)
|
|
} else if (isFood) {
|
|
row += red("◆ "); // Food (2 chars)
|
|
} else {
|
|
row += " "; // Empty cell (2 spaces)
|
|
}
|
|
}
|
|
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
|
|
}
|
|
|
|
// Separator
|
|
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
|
|
|
// Footer
|
|
let footer: string;
|
|
if (this.paused) {
|
|
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
|
|
} else if (this.state.gameOver) {
|
|
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
|
|
} else {
|
|
footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`;
|
|
}
|
|
lines.push(this.padLine(boxLine(footer), width));
|
|
|
|
// Bottom border
|
|
lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
|
|
|
|
this.cachedLines = lines;
|
|
this.cachedWidth = width;
|
|
this.cachedVersion = this.version;
|
|
|
|
return lines;
|
|
}
|
|
|
|
private padLine(line: string, width: number): string {
|
|
// Calculate visible length (strip ANSI codes)
|
|
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
const padding = Math.max(0, width - visibleLen);
|
|
return line + " ".repeat(padding);
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
this.interval = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
const SNAKE_SAVE_TYPE = "snake-save";
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.registerCommand("snake", {
|
|
description: "Play Snake!",
|
|
|
|
handler: async (_args, ctx) => {
|
|
if (!ctx.hasUI) {
|
|
ctx.ui.notify("Snake requires interactive mode", "error");
|
|
return;
|
|
}
|
|
|
|
// Load saved state from session
|
|
const entries = ctx.sessionManager.getEntries();
|
|
let savedState: GameState | undefined;
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const entry = entries[i];
|
|
if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
|
|
savedState = entry.data as GameState;
|
|
break;
|
|
}
|
|
}
|
|
|
|
await ctx.ui.custom((tui, _theme, _kb, done) => {
|
|
return new SnakeComponent(
|
|
tui,
|
|
() => done(undefined),
|
|
(state) => {
|
|
// Save or clear state
|
|
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
|
},
|
|
savedState,
|
|
);
|
|
});
|
|
},
|
|
});
|
|
}
|