533 lines
14 KiB
TypeScript
533 lines
14 KiB
TypeScript
// 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<string> {
|
|
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<void> {
|
|
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<GameMode> {
|
|
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<RoundResult> {
|
|
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<string>((_, 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<boolean> {
|
|
return await Confirm.prompt({ message, default: false });
|
|
}
|