mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 22:03:45 +00:00
feat(coding-agent): add Space Invaders example extension
- Classic gameplay with 5x11 alien formation - 3 alien types, destructible shields, level progression - Uses Kitty keyboard protocol for smooth movement - Saves game state on pause, tracks high score
This commit is contained in:
parent
ccf6db6376
commit
289d88c284
1 changed files with 560 additions and 0 deletions
560
packages/coding-agent/examples/extensions/space-invaders.ts
Normal file
560
packages/coding-agent/examples/extensions/space-invaders.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
/**
|
||||
* Space Invaders game extension - play with /invaders command
|
||||
* Uses Kitty keyboard protocol for smooth movement (press/release detection)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { isKeyRelease, Key, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
const GAME_WIDTH = 60;
|
||||
const GAME_HEIGHT = 24;
|
||||
const TICK_MS = 50;
|
||||
const PLAYER_Y = GAME_HEIGHT - 2;
|
||||
const ALIEN_ROWS = 5;
|
||||
const ALIEN_COLS = 11;
|
||||
const ALIEN_START_Y = 2;
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
interface Bullet extends Point {
|
||||
direction: -1 | 1; // -1 = up (player), 1 = down (alien)
|
||||
}
|
||||
|
||||
interface Alien extends Point {
|
||||
type: number; // 0, 1, 2 for different alien types
|
||||
alive: boolean;
|
||||
}
|
||||
|
||||
interface Shield {
|
||||
x: number;
|
||||
segments: boolean[][]; // 4x3 grid of destructible segments
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
player: { x: number; lives: number };
|
||||
aliens: Alien[];
|
||||
alienDirection: 1 | -1;
|
||||
alienMoveCounter: number;
|
||||
alienMoveDelay: number;
|
||||
alienDropping: boolean;
|
||||
bullets: Bullet[];
|
||||
shields: Shield[];
|
||||
score: number;
|
||||
highScore: number;
|
||||
level: number;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
alienShootCounter: number;
|
||||
}
|
||||
|
||||
interface KeyState {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
fire: boolean;
|
||||
}
|
||||
|
||||
function createShields(): Shield[] {
|
||||
const shields: Shield[] = [];
|
||||
const shieldPositions = [8, 22, 36, 50];
|
||||
for (const x of shieldPositions) {
|
||||
shields.push({
|
||||
x,
|
||||
segments: [
|
||||
[true, true, true, true],
|
||||
[true, true, true, true],
|
||||
[true, false, false, true],
|
||||
],
|
||||
});
|
||||
}
|
||||
return shields;
|
||||
}
|
||||
|
||||
function createAliens(): Alien[] {
|
||||
const aliens: Alien[] = [];
|
||||
for (let row = 0; row < ALIEN_ROWS; row++) {
|
||||
const type = row === 0 ? 2 : row < 3 ? 1 : 0;
|
||||
for (let col = 0; col < ALIEN_COLS; col++) {
|
||||
aliens.push({
|
||||
x: 4 + col * 5,
|
||||
y: ALIEN_START_Y + row * 2,
|
||||
type,
|
||||
alive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return aliens;
|
||||
}
|
||||
|
||||
function createInitialState(highScore = 0, level = 1): GameState {
|
||||
return {
|
||||
player: { x: Math.floor(GAME_WIDTH / 2), lives: 3 },
|
||||
aliens: createAliens(),
|
||||
alienDirection: 1,
|
||||
alienMoveCounter: 0,
|
||||
alienMoveDelay: Math.max(5, 20 - level * 2),
|
||||
alienDropping: false,
|
||||
bullets: [],
|
||||
shields: createShields(),
|
||||
score: 0,
|
||||
highScore,
|
||||
level,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
alienShootCounter: 0,
|
||||
};
|
||||
}
|
||||
|
||||
class SpaceInvadersComponent {
|
||||
private state: GameState;
|
||||
private keys: KeyState = { left: false, right: false, fire: false };
|
||||
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;
|
||||
private fireCooldown = 0;
|
||||
private playerMoveCounter = 0;
|
||||
|
||||
// Opt-in to key release events for smooth movement
|
||||
wantsKeyRelease = true;
|
||||
|
||||
constructor(
|
||||
tui: { requestRender: () => void },
|
||||
onClose: () => void,
|
||||
onSave: (state: GameState | null) => void,
|
||||
savedState?: GameState,
|
||||
) {
|
||||
this.tui = tui;
|
||||
if (savedState && !savedState.gameOver && !savedState.victory) {
|
||||
this.state = savedState;
|
||||
this.paused = true;
|
||||
} else {
|
||||
this.state = createInitialState(savedState?.highScore);
|
||||
this.paused = false;
|
||||
this.startGame();
|
||||
}
|
||||
this.onClose = onClose;
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
private startGame(): void {
|
||||
this.interval = setInterval(() => {
|
||||
if (!this.state.gameOver && !this.state.victory) {
|
||||
this.tick();
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
// Player movement (smooth, every other tick)
|
||||
this.playerMoveCounter++;
|
||||
if (this.playerMoveCounter >= 2) {
|
||||
this.playerMoveCounter = 0;
|
||||
if (this.keys.left && this.state.player.x > 2) {
|
||||
this.state.player.x--;
|
||||
}
|
||||
if (this.keys.right && this.state.player.x < GAME_WIDTH - 3) {
|
||||
this.state.player.x++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire cooldown
|
||||
if (this.fireCooldown > 0) this.fireCooldown--;
|
||||
|
||||
// Player shooting
|
||||
if (this.keys.fire && this.fireCooldown === 0) {
|
||||
const playerBullets = this.state.bullets.filter((b) => b.direction === -1);
|
||||
if (playerBullets.length < 2) {
|
||||
this.state.bullets.push({ x: this.state.player.x, y: PLAYER_Y - 1, direction: -1 });
|
||||
this.fireCooldown = 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Move bullets
|
||||
this.state.bullets = this.state.bullets.filter((bullet) => {
|
||||
bullet.y += bullet.direction;
|
||||
return bullet.y >= 0 && bullet.y < GAME_HEIGHT;
|
||||
});
|
||||
|
||||
// Alien movement
|
||||
this.state.alienMoveCounter++;
|
||||
if (this.state.alienMoveCounter >= this.state.alienMoveDelay) {
|
||||
this.state.alienMoveCounter = 0;
|
||||
this.moveAliens();
|
||||
}
|
||||
|
||||
// Alien shooting
|
||||
this.state.alienShootCounter++;
|
||||
if (this.state.alienShootCounter >= 30) {
|
||||
this.state.alienShootCounter = 0;
|
||||
this.alienShoot();
|
||||
}
|
||||
|
||||
// Collision detection
|
||||
this.checkCollisions();
|
||||
|
||||
// Check victory
|
||||
if (this.state.aliens.every((a) => !a.alive)) {
|
||||
this.state.victory = true;
|
||||
}
|
||||
}
|
||||
|
||||
private moveAliens(): void {
|
||||
const aliveAliens = this.state.aliens.filter((a) => a.alive);
|
||||
if (aliveAliens.length === 0) return;
|
||||
|
||||
if (this.state.alienDropping) {
|
||||
// Drop down
|
||||
for (const alien of aliveAliens) {
|
||||
alien.y++;
|
||||
if (alien.y >= PLAYER_Y - 1) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.state.alienDropping = false;
|
||||
} else {
|
||||
// Check if we need to change direction
|
||||
const minX = Math.min(...aliveAliens.map((a) => a.x));
|
||||
const maxX = Math.max(...aliveAliens.map((a) => a.x));
|
||||
|
||||
if (
|
||||
(this.state.alienDirection === 1 && maxX >= GAME_WIDTH - 3) ||
|
||||
(this.state.alienDirection === -1 && minX <= 2)
|
||||
) {
|
||||
this.state.alienDirection *= -1;
|
||||
this.state.alienDropping = true;
|
||||
} else {
|
||||
// Move horizontally
|
||||
for (const alien of aliveAliens) {
|
||||
alien.x += this.state.alienDirection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Speed up as fewer aliens remain
|
||||
const aliveCount = aliveAliens.length;
|
||||
if (aliveCount <= 5) {
|
||||
this.state.alienMoveDelay = 1;
|
||||
} else if (aliveCount <= 10) {
|
||||
this.state.alienMoveDelay = 2;
|
||||
} else if (aliveCount <= 20) {
|
||||
this.state.alienMoveDelay = 3;
|
||||
}
|
||||
}
|
||||
|
||||
private alienShoot(): void {
|
||||
const aliveAliens = this.state.aliens.filter((a) => a.alive);
|
||||
if (aliveAliens.length === 0) return;
|
||||
|
||||
// Find bottom-most alien in each column
|
||||
const columns = new Map<number, Alien>();
|
||||
for (const alien of aliveAliens) {
|
||||
const existing = columns.get(alien.x);
|
||||
if (!existing || alien.y > existing.y) {
|
||||
columns.set(alien.x, alien);
|
||||
}
|
||||
}
|
||||
|
||||
// Random column shoots
|
||||
const shooters = Array.from(columns.values());
|
||||
if (shooters.length > 0 && this.state.bullets.filter((b) => b.direction === 1).length < 3) {
|
||||
const shooter = shooters[Math.floor(Math.random() * shooters.length)];
|
||||
this.state.bullets.push({ x: shooter.x, y: shooter.y + 1, direction: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
private checkCollisions(): void {
|
||||
const bulletsToRemove = new Set<Bullet>();
|
||||
|
||||
for (const bullet of this.state.bullets) {
|
||||
// Player bullets hitting aliens
|
||||
if (bullet.direction === -1) {
|
||||
for (const alien of this.state.aliens) {
|
||||
if (alien.alive && Math.abs(bullet.x - alien.x) <= 1 && bullet.y === alien.y) {
|
||||
alien.alive = false;
|
||||
bulletsToRemove.add(bullet);
|
||||
const points = [10, 20, 30][alien.type];
|
||||
this.state.score += points;
|
||||
if (this.state.score > this.state.highScore) {
|
||||
this.state.highScore = this.state.score;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alien bullets hitting player
|
||||
if (bullet.direction === 1) {
|
||||
if (Math.abs(bullet.x - this.state.player.x) <= 1 && bullet.y === PLAYER_Y) {
|
||||
bulletsToRemove.add(bullet);
|
||||
this.state.player.lives--;
|
||||
if (this.state.player.lives <= 0) {
|
||||
this.state.gameOver = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bullets hitting shields
|
||||
for (const shield of this.state.shields) {
|
||||
const relX = bullet.x - shield.x;
|
||||
const relY = bullet.y - (PLAYER_Y - 5);
|
||||
if (relX >= 0 && relX < 4 && relY >= 0 && relY < 3) {
|
||||
if (shield.segments[relY][relX]) {
|
||||
shield.segments[relY][relX] = false;
|
||||
bulletsToRemove.add(bullet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.state.bullets = this.state.bullets.filter((b) => !bulletsToRemove.has(b));
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const released = isKeyRelease(data);
|
||||
|
||||
// Pause handling
|
||||
if (this.paused && !released) {
|
||||
if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
|
||||
this.dispose();
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
this.paused = false;
|
||||
this.startGame();
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC to pause and save
|
||||
if (!released && matchesKey(data, Key.escape)) {
|
||||
this.dispose();
|
||||
this.onSave(this.state);
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Q to quit without saving
|
||||
if (!released && (data === "q" || data === "Q")) {
|
||||
this.dispose();
|
||||
this.onSave(null);
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Movement keys (track press/release state)
|
||||
if (matchesKey(data, Key.left) || data === "a" || data === "A" || matchesKey(data, "a")) {
|
||||
this.keys.left = !released;
|
||||
}
|
||||
if (matchesKey(data, Key.right) || data === "d" || data === "D" || matchesKey(data, "d")) {
|
||||
this.keys.right = !released;
|
||||
}
|
||||
|
||||
// Fire key
|
||||
if (matchesKey(data, Key.space) || data === " " || data === "f" || data === "F" || matchesKey(data, "f")) {
|
||||
this.keys.fire = !released;
|
||||
}
|
||||
|
||||
// Restart on game over or victory
|
||||
if (!released && (this.state.gameOver || this.state.victory)) {
|
||||
if (data === "r" || data === "R" || data === " ") {
|
||||
const highScore = this.state.highScore;
|
||||
const nextLevel = this.state.victory ? this.state.level + 1 : 1;
|
||||
this.state = createInitialState(highScore, nextLevel);
|
||||
this.keys = { left: false, right: false, fire: false };
|
||||
this.onSave(null);
|
||||
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[] = [];
|
||||
|
||||
// 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 cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
|
||||
const magenta = (s: string) => `\x1b[35m${s}\x1b[0m`;
|
||||
const white = (s: string) => `\x1b[97m${s}\x1b[0m`;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
|
||||
const boxWidth = GAME_WIDTH;
|
||||
|
||||
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
|
||||
const title = `${bold(green("SPACE INVADERS"))}`;
|
||||
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
||||
const highText = `Hi: ${bold(yellow(String(this.state.highScore)))}`;
|
||||
const levelText = `Lv: ${bold(cyan(String(this.state.level)))}`;
|
||||
const livesText = `${red("♥".repeat(this.state.player.lives))}`;
|
||||
const header = `${title} │ ${scoreText} │ ${highText} │ ${levelText} │ ${livesText}`;
|
||||
lines.push(this.padLine(boxLine(header), width));
|
||||
|
||||
// Separator
|
||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||
|
||||
// Game grid
|
||||
for (let y = 0; y < GAME_HEIGHT; y++) {
|
||||
let row = "";
|
||||
for (let x = 0; x < GAME_WIDTH; x++) {
|
||||
let char = " ";
|
||||
let colored = false;
|
||||
|
||||
// Check aliens
|
||||
for (const alien of this.state.aliens) {
|
||||
if (alien.alive && alien.y === y && Math.abs(alien.x - x) <= 1) {
|
||||
const sprites = [
|
||||
x === alien.x ? "▼" : "╲╱"[x < alien.x ? 0 : 1],
|
||||
x === alien.x ? "◆" : "╱╲"[x < alien.x ? 0 : 1],
|
||||
x === alien.x ? "☆" : "◄►"[x < alien.x ? 0 : 1],
|
||||
];
|
||||
const colors = [green, cyan, magenta];
|
||||
char = colors[alien.type](sprites[alien.type]);
|
||||
colored = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check shields
|
||||
if (!colored) {
|
||||
for (const shield of this.state.shields) {
|
||||
const relX = x - shield.x;
|
||||
const relY = y - (PLAYER_Y - 5);
|
||||
if (relX >= 0 && relX < 4 && relY >= 0 && relY < 3) {
|
||||
if (shield.segments[relY][relX]) {
|
||||
char = dim("█");
|
||||
colored = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check player
|
||||
if (!colored && y === PLAYER_Y && Math.abs(x - this.state.player.x) <= 1) {
|
||||
if (x === this.state.player.x) {
|
||||
char = white("▲");
|
||||
} else {
|
||||
char = white("═");
|
||||
}
|
||||
colored = true;
|
||||
}
|
||||
|
||||
// Check bullets
|
||||
if (!colored) {
|
||||
for (const bullet of this.state.bullets) {
|
||||
if (bullet.x === x && bullet.y === y) {
|
||||
char = bullet.direction === -1 ? yellow("│") : red("│");
|
||||
colored = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row += colored ? char : " ";
|
||||
}
|
||||
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 if (this.state.victory) {
|
||||
footer = `${green(bold("VICTORY!"))} Press ${bold("R")} for level ${this.state.level + 1}, ${bold("Q")} to quit`;
|
||||
} else {
|
||||
footer = `←→ or AD to move, ${bold("SPACE")}/F to fire, ${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 {
|
||||
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 INVADERS_SAVE_TYPE = "space-invaders-save";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("invaders", {
|
||||
description: "Play Space Invaders!",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Space Invaders 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 === INVADERS_SAVE_TYPE) {
|
||||
savedState = entry.data as GameState;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.ui.custom((tui, _theme, _kb, done) => {
|
||||
return new SpaceInvadersComponent(
|
||||
tui,
|
||||
() => done(undefined),
|
||||
(state) => {
|
||||
pi.appendEntry(INVADERS_SAVE_TYPE, state);
|
||||
},
|
||||
savedState,
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue