Files
ssh-portfolio/src/commands/flappy.js
T

71 lines
3.9 KiB
JavaScript

import { safeName } from '../content-policy.js';
export const name = 'flappy';
export const description = 'Play ASCII Flappy and save scores';
function grid(width, height) { return Array.from({ length: height }, () => Array(width).fill(' ')); }
function frame(cells) { return `+${'-'.repeat(cells[0].length)}+\n${cells.map(row => `|${row.join('')}|`).join('\n')}\n+${'-'.repeat(cells[0].length)}+`; }
export class FlappyGame {
constructor(random = Math.random) { this.random = random; this.width = 30; this.height = 12; this.reset(); }
reset() { this.y = 5; this.velocity = 0; this.score = 0; this.over = false; this.recorded = false; this.pipes = [{ x: 22, gap: this.newGap() }]; }
newGap() { return 2 + Math.floor(this.random() * (this.height - 6)); }
tick(flap = false) {
if (this.over) return;
this.velocity = flap ? -0.65 : Math.min(0.7, this.velocity + 0.16); this.y += this.velocity;
for (const pipe of this.pipes) pipe.x--;
if (this.pipes[0].x < -1) this.pipes.shift();
const last = this.pipes.at(-1);
if (last.x < 17) this.pipes.push({ x: this.width - 1, gap: this.newGap() });
for (const pipe of this.pipes) {
if (pipe.x === 4) this.score++;
if ((pipe.x === 4 || pipe.x === 5) && (this.y < pipe.gap || this.y > pipe.gap + 3)) this.over = true;
}
if (this.y < 0 || this.y >= this.height) this.over = true;
}
render() {
const cells = grid(this.width, this.height);
for (const pipe of this.pipes) if (pipe.x >= 0 && pipe.x < this.width) for (let y = 0; y < this.height; y++) if (y < pipe.gap || y > pipe.gap + 3) cells[y][pipe.x] = '#';
const birdY = Math.round(this.y);
if (birdY >= 0 && birdY < this.height) cells[birdY][4] = '>';
return `${frame(cells)}\nScore: ${this.score}${this.over ? ' GAME OVER' : ''}`;
}
}
export class FlappyService {
constructor(db) { this.db = db; this.live = new WeakMap(); }
draw(ctx, text, help) { if (!ctx.channel?.destroyed) ctx.channel.write(`\x1b[?25l\x1b[2J\x1b[H${text}\r\n\r\n${help}`.replace(/\n/g, '\r\n')); }
display(ctx, game) {
const player = safeName(ctx.username); const scores = this.db.getLeaderboard('flappy', 3);
const board = scores.length ? scores.map((row, index) => `${index + 1}. ${row.player.slice(0, 16).padEnd(16)} ${row.score}`).join('\n') : 'No scores yet';
return `${game.render()}\n\nYour high score: ${Math.max(game.score, this.db.getPlayerBest('flappy', player))}\nTop Flappy\n${board}`;
}
stop(ctx, message = '') {
const state = this.live.get(ctx); if (state?.timer) clearInterval(state.timer);
this.live.delete(ctx); ctx.tui?.setInputHandler(null);
if (message) ctx.tui?.finishLive(`\x1b[?25h${message}`);
}
start(ctx) {
if (!ctx.tui) return 'Flappy is interactive; connect with an SSH shell (without a remote command).';
this.stop(ctx); const game = new FlappyGame(); const state = { game, timer: null, pendingFlap: false }; this.live.set(ctx, state);
const finish = () => {
if (!game.recorded) { this.db.addScore('flappy', safeName(ctx.username), game.score); game.recorded = true; }
if (state.timer) clearInterval(state.timer); state.timer = null;
this.draw(ctx, this.display(ctx, game), 'R: restart Q: quit');
};
const update = flap => { game.tick(flap); if (game.over) finish(); else this.draw(ctx, this.display(ctx, game), 'SPACE: flap Q: quit'); };
ctx.tui.setInputHandler(input => {
if (input.includes('\x03') || /q/i.test(input) || input.includes('\x1b')) this.stop(ctx, 'Flappy exited.');
else if (game.over && /r/i.test(input)) this.start(ctx);
else if (input.includes(' ')) state.pendingFlap = true;
return true;
});
state.timer = setInterval(() => { update(state.pendingFlap); state.pendingFlap = false; }, 180);
this.draw(ctx, this.display(ctx, game), 'SPACE: flap Q: quit');
return '__LIVE__';
}
disconnect(ctx) { this.stop(ctx); }
}
export function run({ ctx, api }) { return api.flappy.start(ctx); }