mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 19:00:44 +00:00
Snake: add full border frame and pause/resume with session persistence
- Full box border around title, game area, and instructions - ESC pauses and saves state to session via pi.appendEntry() - Resume shows 'PAUSED - press any key to continue' - Q quits and clears saved state - High score persists across games
This commit is contained in:
parent
5ae33defd3
commit
b47151a04f
1 changed files with 100 additions and 18 deletions
|
|
@ -55,18 +55,37 @@ class SnakeComponent {
|
||||||
private state: GameState;
|
private state: GameState;
|
||||||
private interval: ReturnType<typeof setInterval> | null = null;
|
private interval: ReturnType<typeof setInterval> | null = null;
|
||||||
private onClose: () => void;
|
private onClose: () => void;
|
||||||
|
private onSave: (state: GameState | null) => void;
|
||||||
private requestRender: () => void;
|
private requestRender: () => void;
|
||||||
private cachedLines: string[] = [];
|
private cachedLines: string[] = [];
|
||||||
private cachedWidth = 0;
|
private cachedWidth = 0;
|
||||||
private version = 0;
|
private version = 0;
|
||||||
private cachedVersion = -1;
|
private cachedVersion = -1;
|
||||||
|
private paused: boolean;
|
||||||
|
|
||||||
constructor(onClose: () => void, requestRender: () => void) {
|
constructor(
|
||||||
|
onClose: () => void,
|
||||||
|
onSave: (state: GameState | null) => void,
|
||||||
|
requestRender: () => void,
|
||||||
|
savedState?: GameState,
|
||||||
|
) {
|
||||||
|
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();
|
this.state = createInitialState();
|
||||||
this.onClose = onClose;
|
if (savedState) {
|
||||||
this.requestRender = requestRender;
|
this.state.highScore = savedState.highScore;
|
||||||
|
}
|
||||||
|
this.paused = false;
|
||||||
this.startGame();
|
this.startGame();
|
||||||
}
|
}
|
||||||
|
this.onClose = onClose;
|
||||||
|
this.onSave = onSave;
|
||||||
|
this.requestRender = requestRender;
|
||||||
|
}
|
||||||
|
|
||||||
private startGame(): void {
|
private startGame(): void {
|
||||||
this.interval = setInterval(() => {
|
this.interval = setInterval(() => {
|
||||||
|
|
@ -129,12 +148,35 @@ class SnakeComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(data: string): void {
|
handleInput(data: string): void {
|
||||||
// ESC or q to quit
|
// If paused (resuming), wait for any key
|
||||||
|
if (this.paused) {
|
||||||
if (isEscape(data) || data === "q" || data === "Q") {
|
if (isEscape(data) || data === "q" || data === "Q") {
|
||||||
|
// Quit without clearing save
|
||||||
this.dispose();
|
this.dispose();
|
||||||
this.onClose();
|
this.onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Any other key resumes
|
||||||
|
this.paused = false;
|
||||||
|
this.startGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC to pause and save
|
||||||
|
if (isEscape(data)) {
|
||||||
|
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
|
// Arrow keys or WASD
|
||||||
if (isArrowUp(data) || data === "w" || data === "W") {
|
if (isArrowUp(data) || data === "w" || data === "W") {
|
||||||
|
|
@ -152,6 +194,7 @@ class SnakeComponent {
|
||||||
const highScore = this.state.highScore;
|
const highScore = this.state.highScore;
|
||||||
this.state = createInitialState();
|
this.state = createInitialState();
|
||||||
this.state.highScore = highScore;
|
this.state.highScore = highScore;
|
||||||
|
this.onSave(null); // Clear saved state on restart
|
||||||
this.version++;
|
this.version++;
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
}
|
}
|
||||||
|
|
@ -180,18 +223,31 @@ class SnakeComponent {
|
||||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
||||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||||
|
|
||||||
|
const boxWidth = effectiveWidth * cellWidth;
|
||||||
|
|
||||||
|
// Helper to pad content inside box
|
||||||
|
const boxLine = (content: string, contentLen: number) => {
|
||||||
|
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
|
// Header with score
|
||||||
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
||||||
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
|
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
|
||||||
const title = `${bold(green("🐍 SNAKE"))} │ ${scoreText} │ ${highText}`;
|
const title = `${bold(green("🐍 SNAKE"))} │ ${scoreText} │ ${highText}`;
|
||||||
lines.push(this.padLine(` ${title}`, width));
|
// Approximate visible length (emojis and formatting make this tricky)
|
||||||
|
const titleLen = 8 + 3 + 7 + String(this.state.score).length + 3 + 6 + String(this.state.highScore).length;
|
||||||
|
lines.push(this.padLine(boxLine(title, titleLen), width));
|
||||||
|
|
||||||
// Top border with rounded corners
|
// Separator
|
||||||
lines.push(this.padLine(dim(` ╭${"─".repeat(effectiveWidth * cellWidth)}╮`), width));
|
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||||
|
|
||||||
// Game grid
|
// Game grid
|
||||||
for (let y = 0; y < effectiveHeight; y++) {
|
for (let y = 0; y < effectiveHeight; y++) {
|
||||||
let row = dim(" │");
|
let row = "";
|
||||||
for (let x = 0; x < effectiveWidth; x++) {
|
for (let x = 0; x < effectiveWidth; x++) {
|
||||||
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
|
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 isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
|
||||||
|
|
@ -207,21 +263,29 @@ class SnakeComponent {
|
||||||
row += " "; // Empty cell (2 spaces)
|
row += " "; // Empty cell (2 spaces)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row += dim("│");
|
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
|
||||||
lines.push(this.padLine(row, width));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom border with rounded corners
|
// Separator
|
||||||
lines.push(this.padLine(dim(` ╰${"─".repeat(effectiveWidth * cellWidth)}╯`), width));
|
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
if (this.state.gameOver) {
|
let footer: string;
|
||||||
lines.push(
|
let footerLen: number;
|
||||||
this.padLine(` ${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("ESC")} to quit`, width),
|
if (this.paused) {
|
||||||
);
|
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
|
||||||
|
footerLen = 42;
|
||||||
|
} else if (this.state.gameOver) {
|
||||||
|
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
|
||||||
|
footerLen = 40;
|
||||||
} else {
|
} else {
|
||||||
lines.push(this.padLine(dim(` ↑↓←→ or WASD to move, ESC to quit`), width));
|
footer = dim(`↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`);
|
||||||
|
footerLen = 38;
|
||||||
}
|
}
|
||||||
|
lines.push(this.padLine(boxLine(footer, footerLen), width));
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
|
||||||
|
|
||||||
this.cachedLines = lines;
|
this.cachedLines = lines;
|
||||||
this.cachedWidth = width;
|
this.cachedWidth = width;
|
||||||
|
|
@ -245,6 +309,8 @@ class SnakeComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SNAKE_SAVE_TYPE = "snake-save";
|
||||||
|
|
||||||
export default function (pi: HookAPI) {
|
export default function (pi: HookAPI) {
|
||||||
pi.registerCommand("snake", {
|
pi.registerCommand("snake", {
|
||||||
description: "Play Snake!",
|
description: "Play Snake!",
|
||||||
|
|
@ -255,11 +321,27 @@ export default function (pi: HookAPI) {
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let ui: { close: () => void; requestRender: () => void } | null = null;
|
let ui: { close: () => void; requestRender: () => void } | null = null;
|
||||||
|
|
||||||
const component = new SnakeComponent(
|
const component = new SnakeComponent(
|
||||||
() => ui?.close(),
|
() => ui?.close(),
|
||||||
|
(state) => {
|
||||||
|
// Save or clear state
|
||||||
|
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
||||||
|
},
|
||||||
() => ui?.requestRender(),
|
() => ui?.requestRender(),
|
||||||
|
savedState,
|
||||||
);
|
);
|
||||||
|
|
||||||
ui = ctx.ui.custom(component);
|
ui = ctx.ui.custom(component);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue