Add terminal Snake game in Node.js
Zero-dependency, fully playable Snake with ANSI colors, box-drawn border, score/high-score tracking, and title screen. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1be043e7c0
commit
6e2fc5a05a
197
snake.js
Normal file
197
snake.js
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
const W = 40, H = 20;
|
||||||
|
const TICK = 100; // ms per frame
|
||||||
|
|
||||||
|
// --- ANSI helpers ---
|
||||||
|
const ESC = '\x1b[';
|
||||||
|
const cls = () => process.stdout.write('\x1bc');
|
||||||
|
const hide = () => process.stdout.write(ESC + '?25l');
|
||||||
|
const show = () => process.stdout.write(ESC + '?25h');
|
||||||
|
const move = (x, y) => process.stdout.write(ESC + `${y};${x}H`);
|
||||||
|
const color = (code, s) => `\x1b[${code}m${s}\x1b[0m`;
|
||||||
|
const green = s => color('32;1', s);
|
||||||
|
const yellow = s => color('33;1', s);
|
||||||
|
const red = s => color('31;1', s);
|
||||||
|
const cyan = s => color('36;1', s);
|
||||||
|
const white = s => color('37;1', s);
|
||||||
|
const dim = s => color('2', s);
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let snake, dir, nextDir, food, score, highScore = 0, running, timer;
|
||||||
|
|
||||||
|
function randPos() {
|
||||||
|
return { x: 1 + Math.floor(Math.random() * (W - 2)), y: 1 + Math.floor(Math.random() * (H - 2)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnFood() {
|
||||||
|
let pos;
|
||||||
|
do { pos = randPos(); }
|
||||||
|
while (snake.some(s => s.x === pos.x && s.y === pos.y));
|
||||||
|
food = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const mid = { x: Math.floor(W / 2), y: Math.floor(H / 2) };
|
||||||
|
snake = [mid, { x: mid.x - 1, y: mid.y }, { x: mid.x - 2, y: mid.y }];
|
||||||
|
dir = { x: 1, y: 0 };
|
||||||
|
nextDir = { x: 1, y: 0 };
|
||||||
|
score = 0;
|
||||||
|
spawnFood();
|
||||||
|
running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBorder() {
|
||||||
|
const top = cyan('╔' + '═'.repeat(W - 2) + '╗');
|
||||||
|
const bottom = cyan('╚' + '═'.repeat(W - 2) + '╝');
|
||||||
|
move(1, 1); process.stdout.write(top);
|
||||||
|
move(1, H); process.stdout.write(bottom);
|
||||||
|
for (let row = 2; row < H; row++) {
|
||||||
|
move(1, row); process.stdout.write(cyan('║'));
|
||||||
|
move(W, row); process.stdout.write(cyan('║'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawStatus() {
|
||||||
|
move(1, H + 1);
|
||||||
|
process.stdout.write(
|
||||||
|
white(' Score: ') + yellow(String(score).padEnd(6)) +
|
||||||
|
white(' Best: ') + yellow(String(highScore).padEnd(6)) +
|
||||||
|
dim(' [←↑→↓] move [q] quit')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGame() {
|
||||||
|
// Clear interior
|
||||||
|
for (let row = 2; row < H; row++) {
|
||||||
|
move(2, row);
|
||||||
|
process.stdout.write(' '.repeat(W - 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Food
|
||||||
|
move(food.x, food.y);
|
||||||
|
process.stdout.write(red('◆'));
|
||||||
|
|
||||||
|
// Snake
|
||||||
|
snake.forEach((seg, i) => {
|
||||||
|
move(seg.x, seg.y);
|
||||||
|
process.stdout.write(i === 0 ? green('█') : green('▓'));
|
||||||
|
});
|
||||||
|
|
||||||
|
drawStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
dir = nextDir;
|
||||||
|
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
|
||||||
|
|
||||||
|
// Wall collision
|
||||||
|
if (head.x <= 0 || head.x >= W || head.y <= 0 || head.y >= H) {
|
||||||
|
return gameOver();
|
||||||
|
}
|
||||||
|
// Self collision
|
||||||
|
if (snake.some(s => s.x === head.x && s.y === head.y)) {
|
||||||
|
return gameOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
snake.unshift(head);
|
||||||
|
|
||||||
|
if (head.x === food.x && head.y === food.y) {
|
||||||
|
score++;
|
||||||
|
if (score > highScore) highScore = score;
|
||||||
|
spawnFood();
|
||||||
|
} else {
|
||||||
|
snake.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameOver() {
|
||||||
|
running = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
|
||||||
|
const msg = red(' GAME OVER ');
|
||||||
|
const sub = ` Score: ${yellow(String(score))} `;
|
||||||
|
const reLine = ` Press ${green('r')} to restart or ${red('q')} to quit `;
|
||||||
|
|
||||||
|
const cx = Math.floor(W / 2) - 12;
|
||||||
|
const cy = Math.floor(H / 2) - 1;
|
||||||
|
move(cx, cy); process.stdout.write('┌' + '─'.repeat(28) + '┐');
|
||||||
|
move(cx, cy + 1); process.stdout.write('│' + msg.padEnd(28 + msg.length - 13) + '│');
|
||||||
|
move(cx, cy + 2); process.stdout.write('│' + sub.padEnd(28 + sub.length - 14) + '│');
|
||||||
|
move(cx, cy + 3); process.stdout.write('│' + reLine.padEnd(28 + reLine.length - 27) + '│');
|
||||||
|
move(cx, cy + 4); process.stdout.write('└' + '─'.repeat(28) + '┘');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
cls();
|
||||||
|
hide();
|
||||||
|
init();
|
||||||
|
drawBorder();
|
||||||
|
drawGame();
|
||||||
|
timer = setInterval(tick, TICK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input ---
|
||||||
|
readline.emitKeypressEvents(process.stdin);
|
||||||
|
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
||||||
|
|
||||||
|
process.stdin.on('keypress', (_, key) => {
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
||||||
|
clearInterval(timer);
|
||||||
|
show();
|
||||||
|
cls();
|
||||||
|
move(1, 1);
|
||||||
|
console.log(white('Thanks for playing! Final score: ') + yellow(String(score)));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!running) {
|
||||||
|
if (key.name === 'r') { startGame(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moves = {
|
||||||
|
up: { x: 0, y: -1 },
|
||||||
|
down: { x: 0, y: 1 },
|
||||||
|
left: { x: -1, y: 0 },
|
||||||
|
right: { x: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = moves[key.name];
|
||||||
|
if (!next) return;
|
||||||
|
|
||||||
|
// Prevent 180° reversal
|
||||||
|
if (next.x === -dir.x && next.y === -dir.y) return;
|
||||||
|
nextDir = next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Title screen ---
|
||||||
|
cls();
|
||||||
|
move(1, Math.floor(H / 2) - 3);
|
||||||
|
const logo = [
|
||||||
|
' ███████╗███╗ ██╗ █████╗ ██╗ ██╗███████╗',
|
||||||
|
' ██╔════╝████╗ ██║██╔══██╗██║ ██╔╝██╔════╝',
|
||||||
|
' ███████╗██╔██╗██║███████║█████╔╝ █████╗ ',
|
||||||
|
' ╚════██║██║╚████║██╔══██║██╔═██╗ ██╔══╝ ',
|
||||||
|
' ███████║██║ ╚███║██║ ██║██║ ██╗███████╗',
|
||||||
|
' ╚══════╝╚═╝ ╚══╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝',
|
||||||
|
];
|
||||||
|
logo.forEach((line, i) => { move(1, Math.floor(H / 2) - 3 + i); process.stdout.write(green(line)); });
|
||||||
|
move(1, Math.floor(H / 2) + 4);
|
||||||
|
process.stdout.write(white(' Use ') + cyan('arrow keys') + white(' to move · ') + yellow('eat ◆ to grow') + white(' · ') + red('q') + white(' to quit'));
|
||||||
|
move(1, Math.floor(H / 2) + 6);
|
||||||
|
process.stdout.write(white(' Press ') + green('any arrow key') + white(' to start...'));
|
||||||
|
|
||||||
|
// Start on first keypress
|
||||||
|
process.stdin.once('keypress', (_, key) => {
|
||||||
|
if (key && (key.ctrl && key.name === 'c')) process.exit(0);
|
||||||
|
startGame();
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user