jumpurr
Version:
jumpurr is a terminal-based game where you control a cat running through a retro-style environment, avoiding obstacles and collecting items.
522 lines (440 loc) โข 14.3 kB
JavaScript
#!/usr/bin/env node
const blessed = require('blessed');
const colors = require('ansi-colors');
// Create screen
const screen = blessed.screen({
smartCSR: true,
title: '๐พ Jumpurr DX'
});
// Game constants
const gameWidth = 60;
const gameHeight = 20;
const groundY = gameHeight - 1;
const catX = 5;
const catHeight = 3;
const catWidth = 7;
// Game state
let score = 0;
let speed = 1;
let gameState = 'title'; // 'title', 'running', 'paused', 'gameover'
let isJumping = false;
let jumpFrame = 0;
const maxJumpFrame = 16;
const jumpStrength = 8;
let obstacles = [];
let frameCount = 0;
let highScore = 0;
let obstacleTimer = 0;
// ASCII art for title and game over screens
const titleArt = [
" __ _____ ",
" | | | __ \\ ",
" | |_ _ _ __ ___ | |_ ) |_ _ _ __ _ __ ",
" _ | | | | | '_ ` _ \\| ___/| | | | '__| '__|",
" | |__| | |_| | | | | | | | | |_| | | || | ",
" \\____/ \\__,_|_| |_| |_|_| \\__,_|_| |_| "
]
const gameOverArt = [
" _____ ____ ",
" / ____| / __ \\ ",
"| | __ __ _ _ __ ___ ___ | | | |_ _____ _ __ ",
"| | |_ |/ _` | '_ ` _ \\ / _ \\| | | \\ \\ / / _ \\ '__|",
"| |__| | (_| | | | | | | __/| |__| |\\ V / __/ | ",
" \\_____|\\__,_|_| |_| |_|\\___| \\____/ \\_/ \\___|_| "
];
// Cat sprites
const catSprites = {
run1: [' /\\_/\\ ', '( o.o )', ' > ^ < '],
run2: [' /\\_/\\ ', '( -.- )', ' > ^ < '],
jump: [' /\\_/\\ ', '( ^.^ )', ' \\_/ ']
};
// Obstacle types
const obstacleTypes = [
{ char: 'โ', height: 2, width: 1 },
{ char: 'โ', height: 3, width: 1 },
{ char: 'โ', height: 1, width: 1 }
];
// Create UI container with retro border
const container = blessed.box({
top: 'center',
left: 'center',
width: gameWidth + 6,
height: gameHeight + 8,
border: {
type: 'line',
fg: 'cyan'
},
label: ' Jumpurr DX ',
style: {
border: { fg: 'cyan' },
label: { fg: 'yellow', bold: true },
bg: 'black'
},
parent: screen
});
// Game display area
const gameBox = blessed.box({
top: 1,
left: 1,
width: gameWidth + 2,
height: gameHeight + 2,
tags: true,
style: {
bg: 'black'
},
parent: container
});
// Stats display
const statsBox = blessed.box({
bottom: 2,
left: 'center',
width: gameWidth,
height: 1,
tags: true,
content: '',
style: { fg: 'green' },
parent: container
});
// Controls hint
const controlsBox = blessed.box({
bottom: 0,
left: 'center',
width: gameWidth,
height: 1,
tags: true,
content: '{bold}[SPACE]{/bold} Jump | {bold}[P]{/bold} Pause | {bold}[Q]{/bold} Quit',
style: { fg: 'gray' },
parent: container
});
// Register key events
screen.key(['space'], () => {
if (gameState === 'title') {
startGame();
} else if (!isJumping && gameState === 'running') {
isJumping = true;
jumpFrame = 0;
} else if (gameState === 'gameover') {
startGame();
}
});
screen.key(['p'], () => {
if (gameState === 'running') {
gameState = 'paused';
renderPauseScreen();
} else if (gameState === 'paused') {
gameState = 'running';
}
});
screen.key(['q', 'C-c'], () => process.exit(0));
// Helper functions
function getCatY() {
if (!isJumping) return groundY - catHeight + 1;
const phase = Math.sin((Math.PI * jumpFrame) / maxJumpFrame);
const jumpOffset = Math.round(jumpStrength * phase);
return groundY - catHeight + 1 - jumpOffset;
}
function generateObstacle() {
const type = obstacleTypes[Math.floor(Math.random() * obstacleTypes.length)];
return {
x: gameWidth,
width: type.width || 1,
height: type.height,
char: type.char,
passed: false
};
}
// Function to generate obstacle patterns
function generateObstaclePattern() {
const level = Math.floor(score / 300) + 1;
const patternType = Math.random();
// Single obstacle
if (patternType < Math.max(0.2, 0.7 - level * 0.1)) {
obstacles.push(generateObstacle());
return;
}
// Double obstacles
if (patternType < 0.7) {
const obs1 = generateObstacle();
obstacles.push(obs1);
// Second obstacle close to the first
const obs2 = generateObstacle();
obs2.x = gameWidth + Math.floor(Math.random() * 5) + 3;
obstacles.push(obs2);
return;
}
// Triple obstacles
const obs1 = generateObstacle();
const obs2 = generateObstacle();
const obs3 = generateObstacle();
obs1.x = gameWidth;
obs2.x = gameWidth + Math.floor(Math.random() * 4) + 4;
obs3.x = gameWidth + Math.floor(Math.random() * 4) + 10;
obstacles.push(obs1);
obstacles.push(obs2);
obstacles.push(obs3);
// Occasionally add a fourth obstacle at higher levels
if (level > 3 && Math.random() < 0.3) {
const obs4 = generateObstacle();
obs4.x = gameWidth + Math.floor(Math.random() * 5) + 16;
obstacles.push(obs4);
}
}
function colorText(text, colorFn) {
return text.split('').map(char => colorFn(char)).join('');
}
function renderTitleScreen() {
let frame = Array.from({ length: gameHeight }, () => Array(gameWidth).fill(' '));
const offsetY = 3;
// Draw title art
for (let i = 0; i < titleArt.length; i++) {
for (let j = 0; j < titleArt[i].length && j < gameWidth; j++) {
if (offsetY + i < gameHeight) {
const char = titleArt[i][j];
if (char !== ' ') {
// Alternate colors for title
const colorFn = (i % 2 === 0) ? colors.cyan : colors.blue;
frame[offsetY + i][Math.floor((gameWidth - titleArt[i].length) / 2) + j] = colorFn(char);
} else {
frame[offsetY + i][Math.floor((gameWidth - titleArt[i].length) / 2) + j] = ' ';
}
}
}
}
// Draw blinking press start
const startText = "PRESS SPACE TO START";
const startY = offsetY + titleArt.length + 3;
const blinkOn = Math.floor(Date.now() / 500) % 2 === 0;
if (blinkOn && startY < gameHeight) {
for (let i = 0; i < startText.length; i++) {
const x = Math.floor((gameWidth - startText.length) / 2) + i;
frame[startY][x] = colors.yellow(startText[i]);
}
}
// Draw cat
const sprite = Math.floor(Date.now() / 200) % 2 === 0 ? catSprites.run1 : catSprites.run2;
const catPosX = Math.floor((gameWidth - catWidth) / 2);
const catPosY = startY + 3;
for (let i = 0; i < sprite.length && catPosY + i < gameHeight; i++) {
for (let j = 0; j < sprite[i].length; j++) {
if (catPosY + i >= 0 && catPosX + j < gameWidth) {
frame[catPosY + i][catPosX + j] = colors.cyan(sprite[i][j]);
}
}
}
// Draw ground
for (let x = 0; x < gameWidth; x++) {
const groundPattern = (x % 4 === 0) ? 'โ' : (x % 4 === 2) ? 'โ' : 'โ';
frame[groundY][x] = colors.green(groundPattern);
}
// Render frame
gameBox.setContent(frame.map(row => row.join('')).join('\n'));
statsBox.setContent(`{bold}HIGH SCORE: ${highScore}{/bold}`);
screen.render();
}
function renderPauseScreen() {
const pauseText = "GAME PAUSED";
const continueText = "Press P to continue";
gameBox.setContent(
`\n\n\n\n\n{center}${colors.yellow(pauseText)}\n\n${continueText}{/center}`
);
screen.render();
}
function renderGameOverScreen() {
let frame = Array.from({ length: gameHeight }, () => Array(gameWidth).fill(' '));
const offsetY = 3;
// Draw game over art
for (let i = 0; i < gameOverArt.length; i++) {
for (let j = 0; j < gameOverArt[i].length && j < gameWidth; j++) {
if (offsetY + i < gameHeight) {
const char = gameOverArt[i][j];
if (char !== ' ') {
frame[offsetY + i][Math.floor((gameWidth - gameOverArt[i].length) / 2) + j] = colors.red(char);
} else {
frame[offsetY + i][Math.floor((gameWidth - gameOverArt[i].length) / 2) + j] = ' ';
}
}
}
}
// Show score
const scoreText = `SCORE: ${score}`;
const highScoreText = `HIGH SCORE: ${highScore}`;
const restartText = "PRESS SPACE TO RESTART";
const scoreY = offsetY + gameOverArt.length + 2;
const highScoreY = scoreY + 1;
const restartY = highScoreY + 2;
if (scoreY < gameHeight) {
for (let i = 0; i < scoreText.length; i++) {
const x = Math.floor((gameWidth - scoreText.length) / 2) + i;
frame[scoreY][x] = colors.yellow(scoreText[i]);
}
}
if (highScoreY < gameHeight) {
for (let i = 0; i < highScoreText.length; i++) {
const x = Math.floor((gameWidth - highScoreText.length) / 2) + i;
frame[highScoreY][x] = colors.green(highScoreText[i]);
}
}
const blinkOn = Math.floor(Date.now() / 500) % 2 === 0;
if (blinkOn && restartY < gameHeight) {
for (let i = 0; i < restartText.length; i++) {
const x = Math.floor((gameWidth - restartText.length) / 2) + i;
frame[restartY][x] = colors.cyan(restartText[i]);
}
}
// Render frame
gameBox.setContent(frame.map(row => row.join('')).join('\n'));
screen.render();
}
const starCount = 30;
const stars = Array.from({ length: starCount }, () => ({
x: Math.floor(Math.random() * gameWidth),
y: Math.floor(Math.random() * (groundY - 1)),
char: Math.random() > 0.8 ? 'โ
' : 'ยท'
}));
function renderGame() {
frameCount++;
let frame = Array.from({ length: gameHeight }, () => Array(gameWidth).fill(' '));
for (const star of stars) {
if (star.y >= 0 && star.y < groundY && star.x >= 0 && star.x < gameWidth) {
frame[star.y][star.x] = colors.gray(star.char);
}
}
// Draw ground with retro pattern
for (let x = 0; x < gameWidth; x++) {
const groundPattern = ((x + frameCount) % 4 === 0) ? 'โ' : ((x + frameCount) % 4 === 2) ? 'โ' : 'โ';
frame[groundY][x] = colors.green(groundPattern);
}
// Draw obstacles with retro colors
for (const obs of obstacles) {
for (let w = 0; w < obs.width; w++) {
for (let h = 0; h < obs.height; h++) {
const y = groundY - h;
const x = Math.floor(obs.x) + w;
if (y >= 0 && y < gameHeight && x >= 0 && x < gameWidth) {
const colorFn = (h % 2 === 0) ? colors.red : colors.yellow;
frame[y][x] = colorFn(obs.char);
}
}
}
}
// Draw cat with animation
const sprite = isJumping
? catSprites.jump
: (Math.floor(frameCount / 3) % 2 === 0 ? catSprites.run1 : catSprites.run2);
const catY = getCatY();
for (let i = 0; i < sprite.length; i++) {
for (let j = 0; j < sprite[i].length; j++) {
const y = catY + i;
const x = catX + j;
if (y >= 0 && y < gameHeight && x >= 0 && x < gameWidth) {
// Add color effects to cat while jumping
let colorFn = colors.cyan;
if (isJumping) {
// Simple color cycling for jumping cat
const jumpColors = [colors.cyan, colors.magenta, colors.blue, colors.yellow];
colorFn = jumpColors[Math.floor(frameCount / 3) % jumpColors.length];
}
frame[y][x] = colorFn(sprite[i][j]);
}
}
}
gameBox.setContent(frame.map(row => row.join('')).join('\n'));
// Update stats with retro styling
const levelText = Math.floor(score / 300) + 1;
const paddedScore = score.toString().padStart(6, '0');
statsBox.setContent(
`{bold}SCORE: ${colors.yellow(paddedScore)} | ` +
`LEVEL: ${colors.magenta(levelText)} | ` +
`SPEED: ${colors.green((speed).toFixed(1))} | ` +
`OBSTACLES: ${colors.red(obstacles.length)}{/bold}`
);
screen.render();
}
function detectCollision() {
const catY = getCatY();
const catX2 = catX + catWidth - 2; // Adjust collision box to be more forgiving
for (const obs of obstacles) {
const obsX1 = Math.floor(obs.x);
const obsX2 = obsX1 + obs.width;
// Check for score counting
if (!obs.passed && obsX2 < catX) {
obs.passed = true;
// Add point bonus for each obstacle passed
score += 5;
}
// Collision detection with hitbox adjustment
if (obsX2 > catX + 1 && obsX1 < catX2) {
const catY2 = catY + catHeight - 1;
const obsY1 = groundY - obs.height + 1;
if (catY2 > obsY1) {
return true;
}
}
}
return false;
}
function startGame() {
gameState = 'running';
score = 0;
speed = 1;
obstacles = [];
isJumping = false;
frameCount = 0;
obstacleTimer = 0;
}
function gameLoop() {
if (gameState === 'title') {
renderTitleScreen();
return;
}
if (gameState === 'paused') return;
if (gameState !== 'running') return;
// Move obstacles
for (const obs of obstacles) {
obs.x -= speed;
}
obstacles = obstacles.filter(o => o.x + o.width > 0);
obstacleTimer++;
// Base interval that decreases with level and speed
const level = Math.floor(score / 200) + 1;
let spawnInterval = Math.max(20, 40 - level * 3 - speed * 5);
// Make spawning somewhat unpredictable
spawnInterval += Math.floor(Math.random() * 10) - 5;
if (obstacleTimer >= spawnInterval) {
generateObstaclePattern();
obstacleTimer = 0;
}
// Jump logic
if (isJumping) {
jumpFrame++;
if (jumpFrame >= maxJumpFrame) isJumping = false;
}
// Collision detection
if (detectCollision()) {
gameState = 'gameover';
if (score > highScore) highScore = score;
renderGameOverScreen();
return;
}
// Update score and speed
score++;
if (score % 100 === 0) {
speed = Math.min(speed + 0.1, 3.0);
}
// Render frame
renderGame();
}
// Start loop
setInterval(gameLoop, 100);
// Show initial title screen
renderTitleScreen();
function startGame() {
score = 0;
speed = 1;
obstacles = [];
isJumping = false;
jumpFrame = 0;
frameCount = 0;
obstacleTimer = 0;
gameState = 'running';
}