This commit is contained in:
tcsenpai 2025-04-01 12:03:30 +02:00
parent 1ad0f8d871
commit 5c9be8fc79
8 changed files with 402 additions and 156 deletions

View File

@ -3,6 +3,8 @@ import path from 'path';
import { getCurrentGameState } from './gameState';
import { getTheme, successAnimation } from '../ui/visualEffects';
import { playSound } from '../ui/soundEffects';
import { getCurrentProfile } from './playerProfile';
import { saveProfile } from './playerProfile';
// Define achievement types
export interface Achievement {
@ -81,14 +83,14 @@ 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;
const profile = await getCurrentProfile();
if (profile && profile.achievements.length > 0) {
return profile.achievements;
}
// Return default achievements if no profile exists or no achievements in profile
return [...achievements];
}
// Save achievements to file
@ -109,36 +111,42 @@ export async function getPlayerAchievements(playerName: string): Promise<Achieve
}
// Unlock an achievement
export async function unlockAchievement(achievementId: string): Promise<boolean> {
const gameState = getCurrentGameState();
if (!gameState) return false;
export async function unlockAchievement(id: string): Promise<boolean> {
const profile = await getCurrentProfile();
if (!profile) return false;
// Load achievements
const allAchievements = await loadAchievements();
const achievement = allAchievements.find(a => a.id === achievementId);
// Find the achievement in the profile
let achievementIndex = profile.achievements.findIndex(a => a.id === id);
if (!achievement || achievement.unlocked) {
return false; // Achievement doesn't exist or is already unlocked
// If not found in profile, check the default achievements
if (achievementIndex === -1) {
const defaultAchievement = achievements.find(a => a.id === id);
if (!defaultAchievement) return false;
// Add the achievement to the profile
profile.achievements.push({
...defaultAchievement,
unlocked: true,
unlockedAt: Date.now()
});
} else {
// Achievement already exists in profile, just update it
if (profile.achievements[achievementIndex].unlocked) {
return false; // Already unlocked
}
profile.achievements[achievementIndex].unlocked = true;
profile.achievements[achievementIndex].unlockedAt = Date.now();
}
// Unlock the achievement
achievement.unlocked = true;
achievement.unlockedAt = Date.now();
// Save achievements
await saveAchievements(allAchievements);
// Save the updated profile
await saveProfile(profile);
// 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');
const achievement = profile.achievements.find(a => a.id === id);
if (achievement) {
await showAchievementNotification(achievement);
}
return true;
}

View File

@ -33,15 +33,37 @@ export async function initializeGame() {
}
// Helper functions for save management
export async function listSaves() {
export async function listSaves(): Promise<string[]> {
try {
await ensureSavesDir();
const files = await fs.readdir(SAVE_DIR);
return files.filter(file => file.endsWith('.json'));
// Filter out profile files and remove .json extension
return files
.filter(file => file.endsWith('.json') && !file.endsWith('_profile.json'))
.map(file => file.replace('.json', ''));
} catch (error) {
console.error('Failed to list saves:', error);
console.error('Error listing saves:', error);
return [];
}
}
export const getSavePath = (saveName: string) => path.join(SAVE_DIR, `${saveName}.json`);
export const getLeaderboardPath = () => LEADERBOARD_PATH;
export function getSavePath(saveName: string): string {
// Remove .json extension if it's already there
const baseName = saveName.endsWith('.json')
? saveName.slice(0, -5)
: saveName;
return path.join(SAVE_DIR, `${baseName}.json`);
}
export const getLeaderboardPath = () => LEADERBOARD_PATH;
// Ensure saves directory exists
export async function ensureSavesDir(): Promise<void> {
try {
await fs.mkdir(SAVE_DIR, { recursive: true });
} catch (error) {
console.error('Error creating saves directory:', error);
}
}

View File

@ -1,5 +1,7 @@
import fs from 'fs/promises';
import { getSavePath } from './gameInit';
import { createProfile, loadProfile, saveProfile } from './playerProfile';
import { getCurrentProfile } from './playerProfile';
export interface GameState {
playerName: string;
@ -17,8 +19,12 @@ export function getCurrentGameState(): GameState | null {
return currentGameState;
}
export function setCurrentGameState(gameState: GameState): void {
currentGameState = gameState;
}
export function createNewGame(playerName: string): GameState {
const newState: GameState = {
const gameState: GameState = {
playerName,
currentLevel: 1,
startTime: Date.now(),
@ -28,8 +34,15 @@ export function createNewGame(playerName: string): GameState {
levelStates: {}
};
currentGameState = newState;
return newState;
// Create a new profile for this player
createOrLoadProfile(playerName);
setCurrentGameState(gameState);
// Save the initial game state
saveGame(playerName);
return gameState;
}
export async function saveGame(saveName?: string): Promise<boolean> {
@ -61,6 +74,10 @@ export async function loadGame(saveName: string): Promise<boolean> {
try {
const saveData = await fs.readFile(getSavePath(saveName), 'utf-8');
currentGameState = JSON.parse(saveData) as GameState;
// Make sure the profile exists
await createOrLoadProfile(currentGameState.playerName);
console.log(`Game loaded: ${saveName}`);
return true;
} catch (error) {
@ -72,4 +89,36 @@ export async function loadGame(saveName: string): Promise<boolean> {
export async function autoSave(): Promise<boolean> {
if (!currentGameState) return false;
return saveGame(`${currentGameState.playerName}_autosave`);
}
async function createOrLoadProfile(playerName: string): Promise<void> {
const profile = await loadProfile(playerName);
if (!profile) {
await createProfile(playerName);
}
}
export async function completeCurrentLevel(): Promise<void> {
const gameState = getCurrentGameState();
if (!gameState) return;
// Add the level to completed levels in the game state
if (!gameState.completedLevels.includes(gameState.currentLevel)) {
gameState.completedLevels.push(gameState.currentLevel);
}
// Also update the profile
const profile = await getCurrentProfile();
if (profile) {
if (!profile.completedLevels.includes(gameState.currentLevel)) {
profile.completedLevels.push(gameState.currentLevel);
await saveProfile(profile);
}
}
// Move to the next level
gameState.currentLevel++;
// Save the game
await saveGame();
}

88
src/core/playerProfile.ts Normal file
View File

@ -0,0 +1,88 @@
import fs from 'fs/promises';
import path from 'path';
import { Achievement } from './achievements';
import { getCurrentGameState } from './gameState';
import { getSavePath } from './gameInit';
export interface PlayerProfile {
playerName: string;
achievements: Achievement[];
lastPlayed: number;
totalPlayTime: number;
completedLevels: number[];
}
// Use the same directory for profiles and saves
const profilesDir = path.join(process.cwd(), 'saves');
// Ensure profiles directory exists
export async function ensureProfilesDir(): Promise<void> {
try {
await fs.mkdir(profilesDir, { recursive: true });
} catch (error) {
console.error('Error creating profiles directory:', error);
}
}
// Get profile path for a player
function getProfilePath(playerName: string): string {
return path.join(profilesDir, `${playerName.toLowerCase()}_profile.json`);
}
// Create a new player profile
export async function createProfile(playerName: string): Promise<PlayerProfile> {
const profile: PlayerProfile = {
playerName,
achievements: [],
lastPlayed: Date.now(),
totalPlayTime: 0,
completedLevels: []
};
await saveProfile(profile);
return profile;
}
// Load a player profile
export async function loadProfile(playerName: string): Promise<PlayerProfile | null> {
try {
const profilePath = getProfilePath(playerName);
const data = await fs.readFile(profilePath, 'utf-8');
return JSON.parse(data) as PlayerProfile;
} catch (error) {
// Profile doesn't exist or can't be read
return null;
}
}
// Save a player profile
export async function saveProfile(profile: PlayerProfile): Promise<void> {
try {
const profilePath = getProfilePath(profile.playerName);
await fs.writeFile(profilePath, JSON.stringify(profile, null, 2));
} catch (error) {
console.error('Error saving profile:', error);
}
}
// Get current player profile
export async function getCurrentProfile(): Promise<PlayerProfile | null> {
const gameState = getCurrentGameState();
if (!gameState) return null;
const profile = await loadProfile(gameState.playerName);
return profile;
}
// List all profiles
export async function listProfiles(): Promise<string[]> {
try {
const files = await fs.readdir(profilesDir);
return files
.filter(file => file.endsWith('_profile.json'))
.map(file => file.replace('_profile.json', ''));
} catch (error) {
console.error('Error listing profiles:', error);
return [];
}
}

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { renderMainMenu } from './ui/mainMenu';
import { renderEntryMenu } from './ui/entryMenu';
import { initializeGame } from './core/gameInit';
import { registerAllLevels } from './levels';
import { initializeAchievements } from './core/achievements';
@ -15,8 +15,8 @@ async function main() {
// Register all game levels
registerAllLevels();
// Render the main menu to start
await renderMainMenu();
// Render the entry menu to start
await renderEntryMenu();
}
main().catch(error => {

131
src/ui/entryMenu.ts Normal file
View File

@ -0,0 +1,131 @@
import { clearScreen, promptInput, drawBox } from './uiHelpers';
import { generateLogo, getTheme, bootSequence } from './visualEffects';
import { loadProfile, createProfile, listProfiles } from '../core/playerProfile';
import { createNewGame, loadGame } from '../core/gameState';
import { renderMainMenu } from './mainMenu';
import { successAnimation, loadingAnimation } from './visualEffects';
import { listSaves } from '../core/gameInit';
// Track if we've shown the boot sequence
let bootSequenceShown = false;
export async function renderEntryMenu(): Promise<void> {
// Show boot sequence only once
if (!bootSequenceShown) {
await bootSequence();
bootSequenceShown = true;
} else {
clearScreen();
console.log(generateLogo());
console.log('');
}
const theme = getTheme();
while (true) {
clearScreen();
console.log(generateLogo());
console.log(theme.secondary('A Linux Terminal Escape Room Game'));
console.log('');
const menuOptions = [
'1. ' + theme.accent('New Game'),
'2. ' + theme.accent('Load Game'),
'3. ' + theme.accent('Exit')
];
console.log(drawBox('WELCOME', menuOptions.join('\n')));
console.log('');
const choice = await promptInput('Select an option: ');
if (choice === '1') {
const success = await newGameMenu();
if (success) {
await renderMainMenu();
return;
}
} else if (choice === '2') {
const success = await loadGameMenu();
if (success) {
await renderMainMenu();
return;
}
} else if (choice === '3') {
console.log('Thanks for playing Terminal Escape!');
process.exit(0);
} else {
console.log(theme.error('Invalid option. Press Enter to continue...'));
await promptInput('');
}
}
}
async function newGameMenu(): Promise<boolean> {
const theme = getTheme();
clearScreen();
console.log(theme.accent('=== NEW GAME ==='));
console.log('');
const playerName = await promptInput('Enter your name: ');
if (!playerName) {
console.log(theme.error('Name cannot be empty.'));
await promptInput('Press Enter to continue...');
return false;
}
await loadingAnimation('Creating new game...', 1000);
// Create a new game state
const gameState = createNewGame(playerName);
await successAnimation('Game created successfully!');
return true;
}
async function loadGameMenu(): Promise<boolean> {
const theme = getTheme();
clearScreen();
console.log(theme.accent('=== LOAD GAME ==='));
console.log('');
// Get list of save files
const saveFiles = await listSaves();
if (saveFiles.length === 0) {
console.log(theme.warning('No saved games found.'));
await promptInput('Press Enter to continue...');
return false;
}
console.log('Available saved games:');
saveFiles.forEach((save, index) => {
console.log(`${index + 1}. ${theme.accent(save)}`);
});
console.log('');
const choice = await promptInput('Select a saved game (or 0 to cancel): ');
const choiceNum = parseInt(choice);
if (choiceNum === 0 || isNaN(choiceNum) || choiceNum > saveFiles.length) {
return false;
}
const saveName = saveFiles[choiceNum - 1];
// Try to load the save
await loadingAnimation('Loading game...', 1000);
const success = await loadGame(saveName);
if (success) {
await successAnimation('Game loaded successfully!');
return true;
} else {
console.log(theme.error('Failed to load game.'));
await promptInput('Press Enter to continue...');
return false;
}
}

View File

@ -1,4 +1,4 @@
import { createNewGame, loadGame } from '../core/gameState';
import { createNewGame, loadGame, getCurrentGameState } from '../core/gameState';
import { listSaves } from '../core/gameInit';
import { getLeaderboard, formatTime } from '../core/leaderboard';
import { startLevel, getAllLevels } from '../core/levelSystem';
@ -25,6 +25,7 @@ import {
initSoundSystem
} from './soundEffects';
import { addToHistory } from './commandHistory';
import { renderEntryMenu } from './entryMenu';
// Track if we've shown the boot sequence
let bootSequenceShown = false;
@ -33,14 +34,11 @@ let bootSequenceShown = false;
initSoundSystem();
export async function renderMainMenu(): Promise<void> {
// Show boot sequence only once
if (!bootSequenceShown) {
await bootSequence();
bootSequenceShown = true;
} else {
clearScreen();
console.log(generateLogo());
console.log('');
const gameState = getCurrentGameState();
if (!gameState) {
// If no game state, go back to entry menu
await renderEntryMenu();
return;
}
const theme = getTheme();
@ -52,48 +50,52 @@ export async function renderMainMenu(): Promise<void> {
console.log('');
const menuOptions = [
'1. ' + theme.accent('New Game'),
'2. ' + theme.accent('Load Game'),
'3. ' + theme.accent('Leaderboard'),
'4. ' + theme.accent('Achievements'),
'5. ' + theme.accent('Progress Map'),
'6. ' + theme.accent('Settings'),
'1. ' + theme.accent('Continue Game'),
'2. ' + theme.accent('Achievements'),
'3. ' + theme.accent('Progress Map'),
'4. ' + theme.accent('Leaderboard'),
'5. ' + theme.accent('Settings'),
'6. ' + theme.accent('Back to Entry Menu'),
'7. ' + theme.accent('Exit')
];
console.log(drawBox('MAIN MENU', menuOptions.join('\n')));
console.log('');
console.log(theme.info(`Player: ${gameState.playerName}`));
console.log('');
const choice = await promptInput('Select an option: ');
switch (choice) {
case '1':
await newGameMenu();
break;
case '2':
await loadGameMenu();
break;
case '3':
await showLeaderboard();
break;
case '4':
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:
console.log(theme.error('Invalid option. Press Enter to continue...'));
await promptInput('');
if (choice === '1') {
// Continue game
startLevel(gameState.currentLevel);
await renderGameUI();
} else if (choice === '2') {
// Show achievements
await showAchievements();
await promptInput('Press Enter to continue...');
} else if (choice === '3') {
// Show progress map
clearScreen();
await renderProgressMap();
await promptInput('Press Enter to continue...');
} else if (choice === '4') {
// Show leaderboard
await showLeaderboard();
} else if (choice === '5') {
// Settings
await showSettings();
} else if (choice === '6') {
// Back to entry menu
await renderEntryMenu();
return;
} else if (choice === '7') {
// Exit
await animateText('Thanks for playing Terminal Escape!', 30);
process.exit(0);
} else {
console.log(theme.error('Invalid option. Press Enter to continue...'));
await promptInput('');
}
}
}
@ -150,80 +152,22 @@ async function changeTheme(): Promise<void> {
}
}
// Update the existing functions to use the current theme
async function newGameMenu(): Promise<void> {
async function showLeaderboard(): Promise<void> {
const theme = getTheme();
clearScreen();
console.log(theme.accent('=== NEW GAME ==='));
console.log('');
const playerName = await promptInput('Enter your name: ');
if (!playerName) {
console.log(theme.error('Name cannot be empty.'));
await promptInput('Press Enter to continue...');
return;
}
await loadingAnimation('Creating new game...', 1000);
const gameState = createNewGame(playerName);
startLevel(gameState.currentLevel);
await renderGameUI();
}
async function loadGameMenu(): Promise<void> {
clearScreen();
console.log('=== Load Game ===');
console.log('');
const saves = await listSaves();
if (saves.length === 0) {
console.log('No saved games found. Press Enter to return to main menu...');
await promptInput('');
return;
}
console.log('Available saves:');
saves.forEach((save, index) => {
console.log(`${index + 1}. ${save.replace('.json', '')}`);
});
console.log('');
const choice = await promptInput('Select a save to load (or 0 to cancel): ');
const choiceNum = parseInt(choice);
if (choiceNum === 0 || isNaN(choiceNum) || choiceNum > saves.length) {
return;
}
const saveName = saves[choiceNum - 1].replace('.json', '');
const success = await loadGame(saveName);
if (success) {
await renderGameUI();
} else {
console.log('Failed to load game. Press Enter to return to main menu...');
await promptInput('');
}
}
async function showLeaderboard(): Promise<void> {
clearScreen();
console.log('=== Leaderboard ===');
console.log(theme.accent('=== LEADERBOARD ==='));
console.log('');
const leaderboard = await getLeaderboard();
if (leaderboard.players.length === 0) {
console.log('No entries yet. Be the first to complete the game!');
console.log(theme.warning('No entries yet. Be the first to complete the game!'));
} else {
console.log('Top Players:');
console.log('-----------');
leaderboard.players.slice(0, 10).forEach((entry, index) => {
console.log(`${index + 1}. ${entry.playerName} - ${formatTime(entry.completionTime)}`);
console.log(`${index + 1}. ${theme.accent(entry.playerName)} - ${formatTime(entry.completionTime)}`);
});
}

View File

@ -1,14 +1,18 @@
import { getAllLevels, getLevelById } from '../core/levelSystem';
import { getCurrentGameState } from '../core/gameState';
import { getAllLevels } from '../core/levelSystem';
import { getCurrentProfile } from '../core/playerProfile';
import { getTheme } from './visualEffects';
export function renderProgressMap(): void {
const gameState = getCurrentGameState();
if (!gameState) return;
export async function renderProgressMap(): Promise<void> {
const profile = await getCurrentProfile();
if (!profile) {
console.log('No active player profile. Please start a game first.');
return;
}
const theme = getTheme();
const allLevels = getAllLevels();
const currentLevelId = gameState.currentLevel;
const completedLevels = profile.completedLevels;
const currentLevelId = Math.max(...completedLevels) + 1;
console.log(theme.accent('=== Progress Map ==='));
console.log('');
@ -53,11 +57,11 @@ export function renderProgressMap(): void {
console.log('└' + '─'.repeat(maxNameLength + 22) + '┘');
// Show completion percentage
const completedLevels = Math.max(0, currentLevelId - 1);
const completionPercentage = Math.round((completedLevels / allLevels.length) * 100);
const completedLevelsCount = completedLevels.length;
const completionPercentage = Math.round((completedLevelsCount / allLevels.length) * 100);
console.log('');
console.log(`Overall Progress: ${completedLevels}/${allLevels.length} levels completed (${completionPercentage}%)`);
console.log(`Overall Progress: ${completedLevelsCount}/${allLevels.length} levels completed (${completionPercentage}%)`);
// Visual progress bar
const progressBarWidth = 40;