From 5c9be8fc797a25054d8cd0685cb9cfd3dfec2bcc Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 1 Apr 2025 12:03:30 +0200 Subject: [PATCH] profiles --- src/core/achievements.ts | 70 +++++++++-------- src/core/gameInit.ts | 32 ++++++-- src/core/gameState.ts | 55 +++++++++++++- src/core/playerProfile.ts | 88 ++++++++++++++++++++++ src/index.ts | 6 +- src/ui/entryMenu.ts | 131 ++++++++++++++++++++++++++++++++ src/ui/mainMenu.ts | 154 ++++++++++++-------------------------- src/ui/progressMap.ts | 22 +++--- 8 files changed, 402 insertions(+), 156 deletions(-) create mode 100644 src/core/playerProfile.ts create mode 100644 src/ui/entryMenu.ts diff --git a/src/core/achievements.ts b/src/core/achievements.ts index e15fca9..11a3d9b 100644 --- a/src/core/achievements.ts +++ b/src/core/achievements.ts @@ -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 { - 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 { - const gameState = getCurrentGameState(); - if (!gameState) return false; +export async function unlockAchievement(id: string): Promise { + 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; } diff --git a/src/core/gameInit.ts b/src/core/gameInit.ts index fa6467a..91e9a6d 100644 --- a/src/core/gameInit.ts +++ b/src/core/gameInit.ts @@ -33,15 +33,37 @@ export async function initializeGame() { } // Helper functions for save management -export async function listSaves() { +export async function listSaves(): Promise { 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; \ No newline at end of file +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 { + try { + await fs.mkdir(SAVE_DIR, { recursive: true }); + } catch (error) { + console.error('Error creating saves directory:', error); + } +} \ No newline at end of file diff --git a/src/core/gameState.ts b/src/core/gameState.ts index df828fc..e3af692 100644 --- a/src/core/gameState.ts +++ b/src/core/gameState.ts @@ -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 { @@ -61,6 +74,10 @@ export async function loadGame(saveName: string): Promise { 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 { export async function autoSave(): Promise { if (!currentGameState) return false; return saveGame(`${currentGameState.playerName}_autosave`); +} + +async function createOrLoadProfile(playerName: string): Promise { + const profile = await loadProfile(playerName); + if (!profile) { + await createProfile(playerName); + } +} + +export async function completeCurrentLevel(): Promise { + 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(); } \ No newline at end of file diff --git a/src/core/playerProfile.ts b/src/core/playerProfile.ts new file mode 100644 index 0000000..8660edc --- /dev/null +++ b/src/core/playerProfile.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const gameState = getCurrentGameState(); + if (!gameState) return null; + + const profile = await loadProfile(gameState.playerName); + return profile; +} + +// List all profiles +export async function listProfiles(): Promise { + 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 []; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 823af16..f7ebfc4 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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 => { diff --git a/src/ui/entryMenu.ts b/src/ui/entryMenu.ts new file mode 100644 index 0000000..7e4e304 --- /dev/null +++ b/src/ui/entryMenu.ts @@ -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 { + // 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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/src/ui/mainMenu.ts b/src/ui/mainMenu.ts index b262dd3..a8d7689 100644 --- a/src/ui/mainMenu.ts +++ b/src/ui/mainMenu.ts @@ -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 { - // 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 { 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 { } } -// Update the existing functions to use the current theme -async function newGameMenu(): Promise { +async function showLeaderboard(): Promise { 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 { - 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 { - 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)}`); }); } diff --git a/src/ui/progressMap.ts b/src/ui/progressMap.ts index 065fab1..bd85126 100644 --- a/src/ui/progressMap.ts +++ b/src/ui/progressMap.ts @@ -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 { + 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;