achievements and map

This commit is contained in:
tcsenpai 2025-04-01 11:54:10 +02:00
parent 05bad8b6cf
commit 1ad0f8d871
10 changed files with 750 additions and 20 deletions

59
achievements.json Normal file
View File

@ -0,0 +1,59 @@
[
{
"id": "first_steps",
"name": "First Steps",
"description": "Complete your first level",
"icon": "🏆",
"unlocked": false
},
{
"id": "speed_demon",
"name": "Speed Demon",
"description": "Complete a level in under 60 seconds",
"icon": "⚡",
"unlocked": false
},
{
"id": "no_hints",
"name": "Solo Hacker",
"description": "Complete a level without using hints",
"icon": "🧠",
"unlocked": false
},
{
"id": "command_master",
"name": "Command Master",
"description": "Use at least 10 different commands in one level",
"icon": "💻",
"unlocked": false
},
{
"id": "persistence",
"name": "Persistence",
"description": "Try at least 20 commands in a single level",
"icon": "🔨",
"unlocked": false
},
{
"id": "explorer",
"name": "Explorer",
"description": "Visit all directories in a file system level",
"icon": "🧭",
"unlocked": false
},
{
"id": "easter_egg_hunter",
"name": "Easter Egg Hunter",
"description": "Find a hidden secret",
"icon": "🥚",
"secret": true,
"unlocked": false
},
{
"id": "master_hacker",
"name": "Master Hacker",
"description": "Complete the game",
"icon": "👑",
"unlocked": false
}
]

View File

@ -15,6 +15,7 @@
"bun-types": "latest"
},
"dependencies": {
"beep": "^0.0.0",
"figlet": "^1.6.0",
"figlet-cli": "^0.2.0",
"gradient-string": "^2.0.2",

366
src/core/achievements.ts Normal file
View File

@ -0,0 +1,366 @@
import fs from 'fs/promises';
import path from 'path';
import { getCurrentGameState } from './gameState';
import { getTheme, successAnimation } from '../ui/visualEffects';
import { playSound } from '../ui/soundEffects';
// Define achievement types
export interface Achievement {
id: string;
name: string;
description: string;
icon: string;
secret?: boolean;
unlocked: boolean;
unlockedAt?: number;
}
// Define all achievements
export const achievements: Achievement[] = [
{
id: 'first_steps',
name: 'First Steps',
description: 'Complete your first level',
icon: '🏆',
unlocked: false
},
{
id: 'speed_demon',
name: 'Speed Demon',
description: 'Complete a level in under 60 seconds',
icon: '⚡',
unlocked: false
},
{
id: 'no_hints',
name: 'Solo Hacker',
description: 'Complete a level without using hints',
icon: '🧠',
unlocked: false
},
{
id: 'command_master',
name: 'Command Master',
description: 'Use at least 10 different commands in one level',
icon: '💻',
unlocked: false
},
{
id: 'persistence',
name: 'Persistence',
description: 'Try at least 20 commands in a single level',
icon: '🔨',
unlocked: false
},
{
id: 'explorer',
name: 'Explorer',
description: 'Visit all directories in a file system level',
icon: '🧭',
unlocked: false
},
{
id: 'easter_egg_hunter',
name: 'Easter Egg Hunter',
description: 'Find a hidden secret',
icon: '🥚',
secret: true,
unlocked: false
},
{
id: 'master_hacker',
name: 'Master Hacker',
description: 'Complete the game',
icon: '👑',
unlocked: false
}
];
// Path to achievements file
const achievementsPath = path.join(process.cwd(), 'achievements.json');
// Load achievements from file
export async function loadAchievements(): Promise<Achievement[]> {
try {
const data = await fs.readFile(achievementsPath, 'utf-8');
return JSON.parse(data);
} catch (error) {
// If file doesn't exist, create it with default achievements
await saveAchievements(achievements);
return achievements;
}
}
// Save achievements to file
export async function saveAchievements(achievements: Achievement[]): Promise<void> {
try {
await fs.writeFile(achievementsPath, JSON.stringify(achievements, null, 2));
} catch (error) {
console.error('Error saving achievements:', error);
}
}
// Get player achievements
export async function getPlayerAchievements(playerName: string): Promise<Achievement[]> {
const allAchievements = await loadAchievements();
// Filter achievements for this player (in a real game, you'd store player-specific achievements)
return allAchievements;
}
// Unlock an achievement
export async function unlockAchievement(achievementId: string): Promise<boolean> {
const gameState = getCurrentGameState();
if (!gameState) return false;
// Load achievements
const allAchievements = await loadAchievements();
const achievement = allAchievements.find(a => a.id === achievementId);
if (!achievement || achievement.unlocked) {
return false; // Achievement doesn't exist or is already unlocked
}
// Unlock the achievement
achievement.unlocked = true;
achievement.unlockedAt = Date.now();
// Save achievements
await saveAchievements(allAchievements);
// Show achievement notification
const theme = getTheme();
console.log('\n');
console.log('╔' + '═'.repeat(50) + '╗');
console.log('║ ' + theme.accent('Achievement Unlocked!').padEnd(48) + ' ║');
console.log('║ ' + `${achievement.icon} ${achievement.name}`.padEnd(48) + ' ║');
console.log('║ ' + achievement.description.padEnd(48) + ' ║');
console.log('╚' + '═'.repeat(50) + '╝');
// Play sound
playSound('success');
return true;
}
// Check and potentially unlock achievements based on game events
export async function checkAchievements(event: string, data?: any): Promise<void> {
const gameState = getCurrentGameState();
if (!gameState) return;
switch (event) {
case 'level_completed':
// First Steps achievement
await unlockAchievement('first_steps');
// Speed Demon achievement
const levelState = gameState.levelStates[gameState.currentLevel];
if (levelState && levelState.startTime) {
const completionTime = Date.now() - levelState.startTime;
if (completionTime < 60000) { // Less than 60 seconds
await unlockAchievement('speed_demon');
}
}
// No Hints achievement
if (levelState && !levelState.usedHint) {
await unlockAchievement('no_hints');
}
// Command Master achievement
if (levelState && levelState.uniqueCommands && levelState.uniqueCommands.size >= 10) {
await unlockAchievement('command_master');
}
// Persistence achievement
if (levelState && levelState.commandCount && levelState.commandCount >= 20) {
await unlockAchievement('persistence');
}
// Master Hacker achievement (game completed)
const allLevels = data?.allLevels || [];
if (gameState.currentLevel >= allLevels.length) {
await unlockAchievement('master_hacker');
}
break;
case 'hint_used':
// Mark that hints were used for this level
const currentLevel = gameState.currentLevel;
if (!gameState.levelStates[currentLevel]) {
gameState.levelStates[currentLevel] = {};
}
gameState.levelStates[currentLevel].usedHint = true;
break;
case 'command_used':
// Track unique commands used
const level = gameState.currentLevel;
if (!gameState.levelStates[level]) {
gameState.levelStates[level] = {};
}
if (!gameState.levelStates[level].uniqueCommands) {
gameState.levelStates[level].uniqueCommands = new Set();
}
if (!gameState.levelStates[level].commandCount) {
gameState.levelStates[level].commandCount = 0;
}
gameState.levelStates[level].uniqueCommands.add(data.command);
gameState.levelStates[level].commandCount++;
break;
case 'easter_egg_found':
await unlockAchievement('easter_egg_hunter');
break;
case 'all_directories_visited':
await unlockAchievement('explorer');
break;
}
}
// Display achievements screen
export async function showAchievements(): Promise<void> {
const gameState = getCurrentGameState();
if (!gameState) return;
const theme = getTheme();
const allAchievements = await loadAchievements();
console.clear();
console.log(theme.accent('=== Achievements ==='));
console.log('');
// Group achievements by unlocked status
const unlockedAchievements = allAchievements.filter(a => a.unlocked);
const lockedAchievements = allAchievements.filter(a => !a.unlocked && !a.secret);
const secretAchievements = allAchievements.filter(a => !a.unlocked && a.secret);
// Display unlocked achievements
console.log(theme.success('Unlocked Achievements:'));
if (unlockedAchievements.length === 0) {
console.log(' None yet. Keep playing!');
} else {
unlockedAchievements.forEach(a => {
console.log(` ${a.icon} ${theme.accent(a.name)} - ${a.description}`);
});
}
console.log('');
// Display locked achievements
console.log(theme.secondary('Locked Achievements:'));
if (lockedAchievements.length === 0) {
console.log(' You\'ve unlocked all regular achievements!');
} else {
lockedAchievements.forEach(a => {
console.log(` ${a.icon} ${theme.accent(a.name)} - ${a.description}`);
});
}
console.log('');
// Display secret achievements (just show that they exist)
console.log(theme.warning('Secret Achievements:'));
secretAchievements.forEach(a => {
console.log(` ${a.icon} ${theme.accent('???')} - Find this secret achievement!`);
});
console.log('');
console.log(`Total Progress: ${unlockedAchievements.length}/${allAchievements.length} achievements unlocked`);
}
// Add this function to the achievements.ts file
export async function triggerAchievement(
eventType: 'level_completed' | 'hint_used' | 'command_used' | 'easter_egg_found' | 'all_directories_visited',
data: any = {}
): Promise<void> {
const gameState = getCurrentGameState();
if (!gameState) return;
// Process the event and check for achievements
switch (eventType) {
case 'level_completed':
// First level completion
if (data.levelId === 1) {
await unlockAchievement('first_steps');
}
// Complete a level quickly
if (data.timeSpent && data.timeSpent < 60) {
await unlockAchievement('speed_demon');
}
// Complete a level without hints
if (!data.usedHint) {
await unlockAchievement('no_hints');
}
// Complete all levels
if (data.levelId === data.allLevels) {
await unlockAchievement('master_hacker');
}
break;
case 'hint_used':
// Track hint usage
const currentLevel = gameState.currentLevel;
if (!gameState.levelStates[currentLevel]) {
gameState.levelStates[currentLevel] = {};
}
gameState.levelStates[currentLevel].usedHint = true;
break;
case 'command_used':
// Track unique commands used
const level = gameState.currentLevel;
if (!gameState.levelStates[level]) {
gameState.levelStates[level] = {};
}
if (!gameState.levelStates[level].uniqueCommands) {
gameState.levelStates[level].uniqueCommands = new Set();
}
if (!gameState.levelStates[level].commandCount) {
gameState.levelStates[level].commandCount = 0;
}
gameState.levelStates[level].uniqueCommands.add(data.command);
gameState.levelStates[level].commandCount++;
// Check for command master achievement
if (gameState.levelStates[level].uniqueCommands.size >= 10) {
await unlockAchievement('command_master');
}
// Check for persistence achievement
if (gameState.levelStates[level].commandCount >= 20) {
await unlockAchievement('persistence');
}
break;
case 'easter_egg_found':
await unlockAchievement('easter_egg_hunter');
break;
case 'all_directories_visited':
await unlockAchievement('explorer');
break;
}
}
// Add this function to initialize achievements
export async function initializeAchievements(): Promise<void> {
try {
// Check if achievements file exists, if not create it
if (!fs.existsSync(achievementsPath)) {
await fs.writeFile(achievementsPath, JSON.stringify(achievements, null, 2));
}
} catch (error) {
console.error('Error initializing achievements:', error);
}
}

View File

@ -3,15 +3,19 @@
import { renderMainMenu } from './ui/mainMenu';
import { initializeGame } from './core/gameInit';
import { registerAllLevels } from './levels';
import { initializeAchievements } from './core/achievements';
async function main() {
// Initialize game systems
await initializeGame();
// Initialize achievements
await initializeAchievements();
// Register all game levels
registerAllLevels();
// Render the main menu to start (which now includes the boot sequence)
// Render the main menu to start
await renderMainMenu();
}

87
src/ui/commandHistory.ts Normal file
View File

@ -0,0 +1,87 @@
// Maximum number of commands to store in history
const MAX_HISTORY_SIZE = 50;
// Command history for each player
const commandHistories: Record<string, string[]> = {};
let currentHistoryIndex = -1;
let currentInput = '';
// Initialize command history for a player
export function initCommandHistory(playerName: string): void {
if (!commandHistories[playerName]) {
commandHistories[playerName] = [];
}
currentHistoryIndex = -1;
currentInput = '';
}
// Add a command to history
export function addToHistory(playerName: string, command: string): void {
if (!commandHistories[playerName]) {
initCommandHistory(playerName);
}
// Don't add empty commands or duplicates of the last command
if (command.trim() === '' ||
(commandHistories[playerName].length > 0 &&
commandHistories[playerName][0] === command)) {
return;
}
// Add to the beginning of the array
commandHistories[playerName].unshift(command);
// Trim history if it gets too long
if (commandHistories[playerName].length > MAX_HISTORY_SIZE) {
commandHistories[playerName].pop();
}
// Reset index
currentHistoryIndex = -1;
currentInput = '';
}
// Get previous command from history
export function getPreviousCommand(playerName: string, currentCommand: string): string {
if (!commandHistories[playerName] || commandHistories[playerName].length === 0) {
return currentCommand;
}
// Save current input if we're just starting to navigate history
if (currentHistoryIndex === -1) {
currentInput = currentCommand;
}
// Move back in history
currentHistoryIndex = Math.min(currentHistoryIndex + 1, commandHistories[playerName].length - 1);
return commandHistories[playerName][currentHistoryIndex];
}
// Get next command from history
export function getNextCommand(playerName: string): string {
if (!commandHistories[playerName] || currentHistoryIndex === -1) {
return currentInput;
}
// Move forward in history
currentHistoryIndex = Math.max(currentHistoryIndex - 1, -1);
// Return original input if we've reached the end of history
if (currentHistoryIndex === -1) {
return currentInput;
}
return commandHistories[playerName][currentHistoryIndex];
}
// Get all commands in history
export function getCommandHistory(playerName: string): string[] {
return commandHistories[playerName] || [];
}
// Clear command history
export function clearCommandHistory(playerName: string): void {
commandHistories[playerName] = [];
currentHistoryIndex = -1;
currentInput = '';
}

View File

@ -10,6 +10,8 @@ import {
} from './visualEffects';
import { playSound } from './soundEffects';
import { levelUI } from './levelRenderer';
import { addToHistory } from './commandHistory';
import { triggerAchievement } from '../core/achievements';
export async function renderGameUI(): Promise<void> {
const gameState = getCurrentGameState();
@ -54,6 +56,10 @@ export async function renderGameUI(): Promise<void> {
levelUI.inputBox();
const input = await promptInput('');
if (input.trim()) {
addToHistory(gameState.playerName, input);
}
// Handle special commands
if (input.startsWith('/')) {
const command = input.slice(1).toLowerCase();
@ -76,6 +82,7 @@ export async function renderGameUI(): Promise<void> {
}
if (command === 'hint') {
await triggerAchievement('hint_used');
await showHint(currentLevel.hints);
continue;
}
@ -93,6 +100,15 @@ export async function renderGameUI(): Promise<void> {
if (result.completed) {
playSound('levelComplete');
await completeCurrentLevel();
// Trigger level completion achievement
await triggerAchievement('level_completed', {
levelId: gameState.currentLevel,
usedHint: gameState.levelStates[gameState.currentLevel]?.usedHint || false,
timeSpent: gameState.levelStates[gameState.currentLevel]?.timeSpent || 0,
allLevels: getAllLevels().length
});
await successAnimation('Level completed!');
if (result.nextAction === 'main_menu') {
@ -116,6 +132,9 @@ export async function renderGameUI(): Promise<void> {
}
}
}
// When using a command, track it for achievements
await triggerAchievement('command_used', { command: input });
}
}

View File

@ -14,10 +14,24 @@ import {
successAnimation,
loadingAnimation
} from './visualEffects';
import { showAchievements } from '../core/achievements';
import { renderProgressMap } from './progressMap';
import {
toggleSound,
toggleAmbientSound,
toggleSoundEffects,
setSoundVolume,
soundConfig,
initSoundSystem
} from './soundEffects';
import { addToHistory } from './commandHistory';
// Track if we've shown the boot sequence
let bootSequenceShown = false;
// Initialize sound system in the main menu
initSoundSystem();
export async function renderMainMenu(): Promise<void> {
// Show boot sequence only once
if (!bootSequenceShown) {
@ -41,8 +55,10 @@ export async function renderMainMenu(): Promise<void> {
'1. ' + theme.accent('New Game'),
'2. ' + theme.accent('Load Game'),
'3. ' + theme.accent('Leaderboard'),
'4. ' + theme.accent('Settings'),
'5. ' + theme.accent('Exit')
'4. ' + theme.accent('Achievements'),
'5. ' + theme.accent('Progress Map'),
'6. ' + theme.accent('Settings'),
'7. ' + theme.accent('Exit')
];
console.log(drawBox('MAIN MENU', menuOptions.join('\n')));
@ -61,9 +77,18 @@ export async function renderMainMenu(): Promise<void> {
await showLeaderboard();
break;
case '4':
await showSettings();
await showAchievements();
await promptInput('Press Enter to continue...');
break;
case '5':
clearScreen();
renderProgressMap();
await promptInput('Press Enter to continue...');
break;
case '6':
await showSettings();
break;
case '7':
await animateText('Thanks for playing Terminal Escape!', 30);
process.exit(0);
default:
@ -204,4 +229,69 @@ async function showLeaderboard(): Promise<void> {
console.log('');
await promptInput('Press Enter to return to main menu...');
}
// Add this function to the mainMenu.ts file
async function soundSettings(): Promise<void> {
const theme = getTheme();
while (true) {
clearScreen();
console.log(theme.accent('=== SOUND SETTINGS ==='));
console.log('');
console.log(`1. Sound: ${soundConfig.enabled ? theme.success('ON') : theme.error('OFF')}`);
console.log(`2. Ambient Sound: ${soundConfig.ambientEnabled ? theme.success('ON') : theme.error('OFF')}`);
console.log(`3. Sound Effects: ${soundConfig.effectsEnabled ? theme.success('ON') : theme.error('OFF')}`);
console.log(`4. Volume: ${Math.round(soundConfig.volume * 100)}%`);
console.log('5. Back to Settings');
console.log('');
const choice = await promptInput('Select an option: ');
switch (choice) {
case '1':
toggleSound();
break;
case '2':
toggleAmbientSound();
break;
case '3':
toggleSoundEffects();
break;
case '4':
await changeVolume();
break;
case '5':
return;
default:
console.log(theme.error('Invalid option. Press Enter to continue...'));
await promptInput('');
}
}
}
// Add this function to change volume
async function changeVolume(): Promise<void> {
const theme = getTheme();
clearScreen();
console.log(theme.accent('=== VOLUME SETTINGS ==='));
console.log('');
console.log('Current volume: ' + Math.round(soundConfig.volume * 100) + '%');
console.log('Enter a value between 0 and 100:');
const input = await promptInput('');
const volume = parseInt(input);
if (isNaN(volume) || volume < 0 || volume > 100) {
console.log(theme.error('Invalid volume. Please enter a number between 0 and 100.'));
await promptInput('Press Enter to continue...');
return;
}
setSoundVolume(volume / 100);
console.log(theme.success(`Volume set to ${volume}%`));
await promptInput('Press Enter to continue...');
}

73
src/ui/progressMap.ts Normal file
View File

@ -0,0 +1,73 @@
import { getAllLevels, getLevelById } from '../core/levelSystem';
import { getCurrentGameState } from '../core/gameState';
import { getTheme } from './visualEffects';
export function renderProgressMap(): void {
const gameState = getCurrentGameState();
if (!gameState) return;
const theme = getTheme();
const allLevels = getAllLevels();
const currentLevelId = gameState.currentLevel;
console.log(theme.accent('=== Progress Map ==='));
console.log('');
// Calculate the maximum level name length for formatting
const maxNameLength = Math.max(...allLevels.map(level => level.name.length));
// Create a visual map of levels
console.log('┌' + '─'.repeat(maxNameLength + 22) + '┐');
allLevels.forEach((level, index) => {
const levelNumber = level.id;
const isCurrentLevel = levelNumber === currentLevelId;
const isCompleted = levelNumber < currentLevelId;
const isLocked = levelNumber > currentLevelId;
let statusIcon;
let levelName;
if (isCompleted) {
statusIcon = theme.success('✓');
levelName = theme.success(level.name.padEnd(maxNameLength));
} else if (isCurrentLevel) {
statusIcon = theme.accent('▶');
levelName = theme.accent(level.name.padEnd(maxNameLength));
} else if (isLocked) {
statusIcon = theme.secondary('🔒');
levelName = theme.secondary(level.name.padEnd(maxNameLength));
}
console.log(`${statusIcon} Level ${levelNumber.toString().padStart(2)}${levelName}`);
// Add connector line between levels
if (index < allLevels.length - 1) {
console.log('│ ' + ' '.repeat(maxNameLength + 20) + '│');
console.log('│ ' + theme.secondary('│').padStart(7) + ' '.repeat(maxNameLength + 14) + '│');
console.log('│ ' + theme.secondary('▼').padStart(7) + ' '.repeat(maxNameLength + 14) + '│');
console.log('│ ' + ' '.repeat(maxNameLength + 20) + '│');
}
});
console.log('└' + '─'.repeat(maxNameLength + 22) + '┘');
// Show completion percentage
const completedLevels = Math.max(0, currentLevelId - 1);
const completionPercentage = Math.round((completedLevels / allLevels.length) * 100);
console.log('');
console.log(`Overall Progress: ${completedLevels}/${allLevels.length} levels completed (${completionPercentage}%)`);
// Visual progress bar
const progressBarWidth = 40;
const filledWidth = Math.round((completionPercentage / 100) * progressBarWidth);
const emptyWidth = progressBarWidth - filledWidth;
const progressBar = '[' +
theme.success('='.repeat(filledWidth)) +
theme.secondary('-'.repeat(emptyWidth)) +
'] ' + completionPercentage + '%';
console.log(progressBar);
}

View File

@ -1,20 +1,49 @@
import player from 'play-sound';
// Simplified sound effects module that doesn't actually play sounds
// but maintains the interface for the rest of the application
const audioPlayer = player({});
export const soundConfig = {
enabled: false,
volume: 0.5,
ambientEnabled: false,
effectsEnabled: false
};
// Play a sound effect (does nothing)
export function playSound(sound: 'success' | 'error' | 'typing' | 'levelComplete'): void {
try {
const soundMap = {
success: 'sounds/success.wav',
error: 'sounds/error.wav',
typing: 'sounds/typing.wav',
levelComplete: 'sounds/level-complete.wav'
};
audioPlayer.play(soundMap[sound], (err) => {
if (err) console.error('Error playing sound:', err);
});
} catch (error) {
// Silently fail if sound can't be played
}
// No-op function to maintain API compatibility
}
// Play ambient sound (does nothing)
export function playAmbientSound(): void {
// No-op function to maintain API compatibility
}
// Stop the ambient sound (does nothing)
export function stopAmbientSound(): void {
// No-op function to maintain API compatibility
}
// Toggle sound on/off
export function toggleSound(): boolean {
return soundConfig.enabled;
}
// Toggle ambient sound on/off
export function toggleAmbientSound(): boolean {
return soundConfig.ambientEnabled;
}
// Toggle sound effects on/off
export function toggleSoundEffects(): boolean {
return soundConfig.effectsEnabled;
}
// Set sound volume
export function setSoundVolume(volume: number): void {
soundConfig.volume = Math.max(0, Math.min(1, volume));
}
// Initialize sound system (does nothing)
export function initSoundSystem(): void {
// No-op function to maintain API compatibility
}

View File

@ -1,5 +1,7 @@
import readline from 'readline';
import kleur from 'kleur';
import { getCurrentGameState } from '../core/gameState';
import { getPreviousCommand, getNextCommand } from './commandHistory';
// Enable colors
kleur.enabled = true;