Download packages/coding-agent/examples/extensions/snake.ts from SaylorTwift/pi: direct link, hf CLI and curl.
- Browser
- Download file 9.42 kB
-
https://huggingface.co/SaylorTwift/pi/resolve/main/packages/coding-agent/examples/extensions/snake.ts
- Command line
-
hf download hf://SaylorTwift/pi/packages/coding-agent/examples/extensions/snake.ts
-
curl -L -o snake.ts https://huggingface.co/SaylorTwift/pi/resolve/main/packages/coding-agent/examples/extensions/snake.ts
9.42 kB
| /** | |
| * 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.mode !== "tui") { | |
| 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, | |
| ); | |
| }); | |
| }, | |
| }); | |
| } | |