// UI utilities and game interface import { colors } from "@cliffy/ansi/colors"; // UI Constants const WAIT_TIMES = { DEFAULT: 2000, // 2 seconds for default modes WORDS: 6000, // 6 seconds for words mode PHRASES: 8000, // 8 seconds for phrases mode } as const; import { Confirm } from "@cliffy/prompt/confirm"; import { Input } from "@cliffy/prompt/input"; import { Select } from "@cliffy/prompt/select"; import { Table } from "@cliffy/table"; import type { GameMode, GameSession, RoundResult } from "./game.ts"; import { getChallenge, getGameSummary, processRound } from "./game.ts"; import { morseToText, textToMorse } from "./morse.ts"; import type { GameStats } from "./stats.ts"; import { getAccuracy } from "./stats.ts"; /** * Clear the terminal screen */ export function clearScreen(): void { console.clear(); } /** * Print a banner/header */ export function printBanner(): void { console.log( colors.bold.cyan(` ╔═══════════════════════════════════════╗ ║ MORSE CODE PRACTICE GAME ║ ║ .... . .-.. .-.. --- ║ ╚═══════════════════════════════════════╝ `) ); } /** * Show main menu and get user selection */ export async function showMainMenu(): Promise { const action = await Select.prompt({ message: "What would you like to do?", options: [ { name: "Start New Game", value: "play" }, { name: "View Statistics", value: "stats" }, { name: "Morse Code Reference", value: "reference" }, { name: "Translator", value: "translator" }, { name: "Reset Statistics", value: "reset" }, { name: "Exit", value: "exit" }, ], }); return action; } /** * Show translator utility */ export async function showTranslator(): Promise { clearScreen(); console.log(colors.bold.cyan("\n=== Morse Code Translator ===\n")); const direction = await Select.prompt({ message: "Select translation direction:", options: [ { name: "Text → Morse Code", value: "toMorse" }, { name: "Morse Code → Text", value: "toText" }, ], }); if (direction === "toMorse") { const text = await Input.prompt({ message: "Enter text to translate:", validate: (value) => { if (!value.trim()) { return "Please enter some text"; } return true; }, }); const morse = textToMorse(text.toUpperCase()); console.log(colors.bold.green("\nTranslation:")); console.log(colors.yellow(morse)); } else { const morse = await Input.prompt({ message: "Enter Morse code to translate (use dots and dashes):", validate: (value) => { if (!value.trim()) { return "Please enter some Morse code"; } return true; }, }); const text = morseToText(morse); console.log(colors.bold.green("\nTranslation:")); console.log(colors.yellow(text)); } console.log(colors.gray("\nPress Enter to continue...")); await new Promise((resolve) => { const buf = new Uint8Array(1); Deno.stdin.setRaw(true); Deno.stdin.read(buf) .then(() => resolve(undefined)) .catch(() => resolve(undefined)) .finally(() => Deno.stdin.setRaw(false)); }); } /** * Select game mode */ export async function selectGameMode(): Promise { const mode = await Select.prompt({ message: "Select difficulty mode:", options: [ { name: `${colors.green("Easy")} - Letters (A-Z)`, value: "letters", }, { name: `${colors.yellow("Medium")} - Letters + Numbers (A-Z, 0-9)`, value: "alphanumeric", }, { name: `${colors.magenta( "Hard" )} - Letters + Numbers + Punctuation (🔥) `, value: "full", }, { name: `${colors.cyan("Challenge")} - Words (3-8 letters 🥵)`, value: "words", }, { name: `${colors.red("Expert")} - Phrases (2-4 words 🤯)`, value: "phrases", }, ], }); return mode as GameMode; } /** * Configure game settings */ export async function configureGame( mode: GameMode ): Promise<{ rounds: number; timePerRound: number; dynamicTime: boolean }> { // Set default time based on mode let defaultTime: string; switch (mode) { case "letters": defaultTime = "3"; break; case "alphanumeric": defaultTime = "5"; break; case "full": defaultTime = "7"; break; case "words": case "phrases": defaultTime = "5"; break; } const roundsInput = await Input.prompt({ message: "How many rounds? (5-50)", default: "10", validate: (value) => { const num = parseInt(value); if (isNaN(num) || num < 5 || num > 50) { return "Please enter a number between 5 and 50"; } return true; }, }); // Ask about dynamic time for words/phrases let dynamicTime = false; if (mode === "words" || mode === "phrases") { dynamicTime = await Confirm.prompt({ message: "Use dynamic time? (time multiplied by character count)", default: true, }); } const timeInput = await Input.prompt({ message: dynamicTime ? "Base seconds per character? (3-60)" : "Seconds per round? (3-60)", default: defaultTime, validate: (value) => { const num = parseInt(value); if (isNaN(num) || num < 3 || num > 60) { return "Please enter a number between 3 and 60"; } return true; }, }); return { rounds: parseInt(roundsInput), timePerRound: parseInt(timeInput), dynamicTime, }; } /** * Play a single round of the game */ export async function playRound( session: GameSession, roundNumber: number ): Promise { const challenge = getChallenge(session.config.mode); // Calculate dynamic time limit for words and phrases if enabled let timeLimit = session.config.timePerRound; if ( session.config.dynamicTime && (session.config.mode === "words" || session.config.mode === "phrases") ) { // Use configured time as multiplier per character timeLimit = challenge.length * session.config.timePerRound; } const startTime = Date.now(); clearScreen(); printBanner(); console.log( colors.bold(`\nRound ${roundNumber} of ${session.config.rounds}`) ); console.log( colors.gray( `Time limit: ${timeLimit}s | Current streak: ${session.currentStreak}\n` ) ); console.log( colors.bold.white(`Translate to Morse code: ${colors.yellow(challenge)}\n`) ); console.log( colors.dim("Tip: Use dots (.) and dashes (-), separate letters with spaces") ); console.log(colors.dim(" Use / for word spaces\n")); // Get user input with timeout let userInput = ""; let timeoutId: number | undefined; try { // Create a promise that rejects after the time limit const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error("Time's up!")); }, timeLimit * 1000); }); const inputPromise = Input.prompt({ message: "Your answer:", minLength: 0, }); userInput = await Promise.race([inputPromise, timeoutPromise]); // Clear timeout if input was received first if (timeoutId !== undefined) { clearTimeout(timeoutId); } } catch (error) { // Clear timeout on error if (timeoutId !== undefined) { clearTimeout(timeoutId); } if (error instanceof Error && error.message === "Time's up!") { console.log(colors.red("\n⏰ Time's up!")); userInput = ""; // Empty input for timeout } else { throw error; } } const timeSpent = Date.now() - startTime; const result = processRound(session, challenge, userInput, timeSpent); // Show result console.log(); if (result.correct) { console.log(colors.bold.green("✓ Correct!")); } else { console.log(colors.bold.red("✗ Incorrect")); console.log(colors.gray(`Expected: ${result.expectedMorse}`)); console.log(colors.gray(`You entered: ${result.userInput || "(nothing)"}`)); // Show what the user's morse code translates to if (result.userInput) { try { const decoded = morseToText(result.userInput); if (decoded && decoded !== result.userInput) { console.log(colors.yellow(`Your morse translates to: ${decoded}`)); } } catch { // If decoding fails, just skip showing it } } } // Longer wait time for words and phrases let waitTime: number = WAIT_TIMES.DEFAULT; if (session.config.mode === "words") { waitTime = WAIT_TIMES.WORDS; } else if (session.config.mode === "phrases") { waitTime = WAIT_TIMES.PHRASES; } // Wait for user to continue await new Promise((resolve) => setTimeout(resolve, waitTime)); return result; } /** * Show game results */ export function showGameResults(session: GameSession): void { clearScreen(); printBanner(); const summary = getGameSummary(session); console.log(colors.bold.cyan("\n🎮 GAME COMPLETE!\n")); const tableBody: string[][] = [ ["Total Rounds", summary.totalRounds.toString()], ["Correct", colors.green(summary.correct.toString())], ["Incorrect", colors.red(summary.incorrect.toString())], ["Accuracy", `${summary.accuracy.toFixed(1)}%`], ["Average Time", `${(summary.averageTime / 1000).toFixed(1)}s`], ]; // Add average time per character for words/phrases modes if (session.config.mode === "words" || session.config.mode === "phrases") { const totalChars = session.results.reduce( (sum, r) => sum + r.challenge.length, 0 ); const totalTime = session.results.reduce((sum, r) => sum + r.timeSpent, 0); const avgTimePerChar = totalChars > 0 ? totalTime / totalChars / 1000 : 0; tableBody.push(["Avg Time/Char", `${avgTimePerChar.toFixed(2)}s`]); } tableBody.push(["Best Streak", colors.yellow(summary.streak.toString())]); const table = new Table() .header([colors.bold("Metric"), colors.bold("Value")]) .body(tableBody) .border(true) .padding(1); table.render(); // Show detailed results console.log(colors.bold("\n📊 Round Details:\n")); const detailsTable = new Table().header([ colors.bold("#"), colors.bold("Challenge"), colors.bold("Expected"), colors.bold("Your Answer"), colors.bold("Translates To"), colors.bold("Result"), colors.bold("Time"), ]); session.results.forEach((result, index) => { // Try to decode the user's morse input let translation = "-"; if (result.userInput && result.userInput.trim()) { try { const decoded = morseToText(result.userInput); if (decoded && decoded !== result.userInput) { translation = decoded; } } catch { translation = "?"; } } detailsTable.push([ (index + 1).toString(), result.challenge, result.expectedMorse, result.userInput || "-", translation, result.correct ? colors.green("✓") : colors.red("✗"), `${(result.timeSpent / 1000).toFixed(1)}s`, ]); }); detailsTable.border(true).render(); } /** * Show statistics */ export function showStats(stats: GameStats): void { clearScreen(); printBanner(); console.log(colors.bold.cyan("\n📈 YOUR STATISTICS\n")); if (stats.totalGames === 0) { console.log( colors.yellow("No games played yet! Start playing to see your stats.\n") ); return; } // Overall stats const overallTable = new Table() .header([colors.bold("Overall Stats"), colors.bold("Value")]) .body([ ["Total Games", stats.totalGames.toString()], ["Total Rounds", stats.totalRounds.toString()], ["Correct Answers", colors.green(stats.correctAnswers.toString())], ["Incorrect Answers", colors.red(stats.incorrectAnswers.toString())], ["Overall Accuracy", `${getAccuracy(stats).toFixed(1)}%`], ["Average Time/Round", `${stats.averageTimePerRound.toFixed(1)}s`], ["Best Streak", colors.yellow(stats.bestStreak.toString())], [ "Last Played", stats.lastPlayed ? new Date(stats.lastPlayed).toLocaleDateString() : "Never", ], ]) .border(true) .padding(1); overallTable.render(); // Mode-specific stats console.log(colors.bold("\n📊 Stats by Mode:\n")); const modeTable = new Table().header([ colors.bold("Mode"), colors.bold("Games"), colors.bold("Correct"), colors.bold("Incorrect"), colors.bold("Accuracy"), ]); Object.entries(stats.modeStats).forEach(([mode, modeStats]) => { if (modeStats.gamesPlayed > 0) { modeTable.push([ mode.charAt(0).toUpperCase() + mode.slice(1), modeStats.gamesPlayed.toString(), colors.green(modeStats.correctAnswers.toString()), colors.red(modeStats.incorrectAnswers.toString()), `${modeStats.averageAccuracy.toFixed(1)}%`, ]); } }); modeTable.border(true).render(); console.log(); } /** * Show morse code reference */ export function showReference(): void { clearScreen(); printBanner(); console.log(colors.bold.cyan("\n📖 MORSE CODE REFERENCE\n")); console.log(colors.bold("Letters:")); const letterTable = new Table(); const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); // Display in 3 columns for (let i = 0; i < letters.length; i += 3) { const row = []; for (let j = 0; j < 3 && i + j < letters.length; j++) { const letter = letters[i + j]; row.push(`${colors.yellow(letter)} = ${textToMorse(letter)}`); } letterTable.push(row); } letterTable.render(); console.log(colors.bold("\nNumbers:")); const numberTable = new Table(); const numbers = "0123456789".split(""); for (let i = 0; i < numbers.length; i += 5) { const row = []; for (let j = 0; j < 5 && i + j < numbers.length; j++) { const num = numbers[i + j]; row.push(`${colors.yellow(num)} = ${textToMorse(num)}`); } numberTable.push(row); } numberTable.render(); console.log(colors.bold("\nSpecial:")); console.log(`${colors.yellow("Space")} = /`); console.log(`${colors.yellow(".")} = .-.-.-`); console.log(`${colors.yellow(",")} = --..--`); console.log(`${colors.yellow("?")} = ..--..`); console.log(); } /** * Confirm action */ export async function confirmAction(message: string): Promise { return await Confirm.prompt({ message, default: false }); }