first-test/snake.js
edo 6e2fc5a05a 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>
2026-04-30 22:24:13 +00:00

198 lines
5.7 KiB
JavaScript

#!/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();
});