Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
9.4 kB · 343 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344/** * Snake game extension - play snake with /snake command */
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { matchesKey, visibleWidth } from "@earendil-works/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, ); }); }, });}