first commit
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Deno
|
||||
.deno/
|
||||
deno.lock
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# User data
|
||||
.morse-game/
|
||||
169
README.md
Normal file
169
README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Morse Code Practice Game 🎮
|
||||
|
||||
A terminal-based morse code practice game built with Deno. Improve your morse code translation skills through interactive gameplay with multiple difficulty levels and comprehensive statistics tracking.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Difficulty Modes**
|
||||
- 🟢 Easy: Single letters (A-Z)
|
||||
- 🟡 Medium: Single numbers (0-9)
|
||||
- 🟣 Hard: Common words
|
||||
- 🔴 Expert: Short phrases
|
||||
|
||||
- **Interactive TUI Interface**
|
||||
- Beautiful terminal UI with colors and tables
|
||||
- Real-time timer for each round
|
||||
- Immediate feedback on answers
|
||||
- Progress tracking with streak counter
|
||||
|
||||
- **Statistics Tracking**
|
||||
- Overall accuracy and performance metrics
|
||||
- Mode-specific statistics
|
||||
- Best streak tracking
|
||||
- Historical game data
|
||||
- Average time per round
|
||||
|
||||
- **Flexible CLI**
|
||||
- Interactive menu mode (default)
|
||||
- Quick play mode with command-line arguments
|
||||
- Stats viewer
|
||||
- Built-in morse code reference
|
||||
|
||||
## Installation
|
||||
|
||||
Make sure you have [Deno](https://deno.land/) installed.
|
||||
|
||||
```bash
|
||||
# Clone or download this repository
|
||||
cd morse-game
|
||||
|
||||
# Make the script executable (optional)
|
||||
chmod +x main.ts
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Mode (Default)
|
||||
|
||||
Run the game and navigate through the menu:
|
||||
|
||||
```bash
|
||||
deno task start
|
||||
# or
|
||||
deno run --allow-read --allow-write --allow-env main.ts
|
||||
```
|
||||
|
||||
### Quick Play Mode
|
||||
|
||||
Start a game directly with custom settings:
|
||||
|
||||
```bash
|
||||
# Play 5 rounds of letters mode with 20 seconds per round
|
||||
deno task start play --mode letters --rounds 5 --time 20
|
||||
|
||||
# Play expert mode
|
||||
deno task start play --mode phrases --rounds 10 --time 45
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-m, --mode <mode>` - Game mode: `letters`, `numbers`, `words`, or `phrases` (default: letters)
|
||||
- `-r, --rounds <number>` - Number of rounds (default: 10)
|
||||
- `-t, --time <seconds>` - Seconds per round (default: 30)
|
||||
|
||||
### View Statistics
|
||||
|
||||
```bash
|
||||
deno task start stats
|
||||
```
|
||||
|
||||
### Show Reference
|
||||
|
||||
Display a morse code reference chart:
|
||||
|
||||
```bash
|
||||
deno task start reference
|
||||
```
|
||||
|
||||
### Reset Statistics
|
||||
|
||||
```bash
|
||||
deno task start reset
|
||||
```
|
||||
|
||||
## How to Play
|
||||
|
||||
1. **Select a difficulty mode** - Choose from letters, numbers, words, or phrases
|
||||
2. **Configure your game** - Set the number of rounds and time per round
|
||||
3. **Translate to morse code** - You'll be shown a challenge (letter, number, word, or phrase)
|
||||
4. **Enter your answer** - Type the morse code using:
|
||||
- `.` (dot) for short signals
|
||||
- `-` (dash) for long signals
|
||||
- Space to separate letters
|
||||
- `/` to separate words
|
||||
5. **Get instant feedback** - See if you got it right and track your streak
|
||||
6. **Review your performance** - After all rounds, view detailed statistics
|
||||
|
||||
## Morse Code Basics
|
||||
|
||||
- Use `.` for dots and `-` for dashes
|
||||
- Separate letters with spaces
|
||||
- Use `/` for word boundaries
|
||||
- Example: `HELLO` = `.... . .-.. .-.. ---`
|
||||
- Example: `SOS` = `... --- ...`
|
||||
|
||||
## Statistics
|
||||
|
||||
The game tracks:
|
||||
- Total games played
|
||||
- Total rounds completed
|
||||
- Correct and incorrect answers
|
||||
- Overall accuracy percentage
|
||||
- Average time per round
|
||||
- Best streak (consecutive correct answers)
|
||||
- Per-mode statistics
|
||||
|
||||
Stats are saved to `~/.morse-game/stats.json`
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run in watch mode for development
|
||||
deno task dev
|
||||
|
||||
# Run with specific permissions
|
||||
deno run --allow-read --allow-write --allow-env main.ts
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
morse-game/
|
||||
├── main.ts # CLI entry point and command parser
|
||||
├── game.ts # Game logic and session management
|
||||
├── morse.ts # Morse code translation utilities
|
||||
├── stats.ts # Statistics tracking and persistence
|
||||
├── ui.ts # TUI interface and menus
|
||||
├── deno.json # Deno configuration and dependencies
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [@cliffy/command](https://jsr.io/@cliffy/command) - CLI framework
|
||||
- [@cliffy/prompt](https://jsr.io/@cliffy/prompt) - Interactive prompts
|
||||
- [@cliffy/ansi](https://jsr.io/@cliffy/ansi) - ANSI colors and formatting
|
||||
- [@cliffy/table](https://jsr.io/@cliffy/table) - Table rendering
|
||||
- [@std/path](https://jsr.io/@std/path) - Path utilities
|
||||
- [@std/fs](https://jsr.io/@std/fs) - File system utilities
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to open issues or submit pull requests!
|
||||
|
||||
---
|
||||
|
||||
**73** (Best regards in morse code) 📡
|
||||
18
deno.json
Normal file
18
deno.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tasks": {
|
||||
"start": "deno run --allow-read --allow-write --allow-env main.ts",
|
||||
"dev": "deno run --watch --allow-read --allow-write --allow-env main.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@std/path": "jsr:@std/path@^1.0.0",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.0",
|
||||
"@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.7",
|
||||
"@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.7",
|
||||
"@cliffy/ansi": "jsr:@cliffy/ansi@^1.0.0-rc.7",
|
||||
"@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.7"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": ["deno.window"],
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
158
game.ts
Normal file
158
game.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// Game logic for morse code practice
|
||||
|
||||
import { textToMorse, compareMorse, normalizeMorse } from "./morse.ts";
|
||||
|
||||
export type GameMode = 'letters' | 'numbers' | 'words' | 'phrases';
|
||||
|
||||
export interface GameConfig {
|
||||
mode: GameMode;
|
||||
rounds: number;
|
||||
timePerRound: number; // seconds
|
||||
}
|
||||
|
||||
export interface RoundResult {
|
||||
challenge: string;
|
||||
expectedMorse: string;
|
||||
userInput: string;
|
||||
correct: boolean;
|
||||
timeSpent: number; // milliseconds
|
||||
}
|
||||
|
||||
export interface GameSession {
|
||||
config: GameConfig;
|
||||
currentRound: number;
|
||||
results: RoundResult[];
|
||||
currentStreak: number;
|
||||
bestStreak: number;
|
||||
}
|
||||
|
||||
// Word banks for different difficulty levels
|
||||
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const NUMBERS = '0123456789'.split('');
|
||||
const WORDS = [
|
||||
'HELLO', 'WORLD', 'CODE', 'TIME', 'GAME', 'TEST', 'PLAY',
|
||||
'FAST', 'SLOW', 'HELP', 'GOOD', 'BEST', 'NICE', 'COOL',
|
||||
'MORSE', 'SIGNAL', 'RADIO', 'SEND', 'MESSAGE', 'QUICK',
|
||||
'LEARN', 'PRACTICE', 'SKILL', 'MASTER', 'EXPERT',
|
||||
];
|
||||
const PHRASES = [
|
||||
'HELLO WORLD',
|
||||
'GOOD MORNING',
|
||||
'HOW ARE YOU',
|
||||
'THANK YOU',
|
||||
'SEE YOU SOON',
|
||||
'HAVE A NICE DAY',
|
||||
'MORSE CODE',
|
||||
'QUICK BROWN FOX',
|
||||
'THE END',
|
||||
'WELL DONE',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a random challenge based on game mode
|
||||
*/
|
||||
export function getChallenge(mode: GameMode): string {
|
||||
switch (mode) {
|
||||
case 'letters':
|
||||
return LETTERS[Math.floor(Math.random() * LETTERS.length)];
|
||||
case 'numbers':
|
||||
return NUMBERS[Math.floor(Math.random() * NUMBERS.length)];
|
||||
case 'words':
|
||||
return WORDS[Math.floor(Math.random() * WORDS.length)];
|
||||
case 'phrases':
|
||||
return PHRASES[Math.floor(Math.random() * PHRASES.length)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new game session
|
||||
*/
|
||||
export function createGameSession(config: GameConfig): GameSession {
|
||||
return {
|
||||
config,
|
||||
currentRound: 0,
|
||||
results: [],
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a round result
|
||||
*/
|
||||
export function processRound(
|
||||
session: GameSession,
|
||||
challenge: string,
|
||||
userInput: string,
|
||||
timeSpent: number
|
||||
): RoundResult {
|
||||
const expectedMorse = textToMorse(challenge);
|
||||
const normalizedInput = normalizeMorse(userInput);
|
||||
const correct = compareMorse(expectedMorse, normalizedInput);
|
||||
|
||||
const result: RoundResult = {
|
||||
challenge,
|
||||
expectedMorse,
|
||||
userInput: normalizedInput,
|
||||
correct,
|
||||
timeSpent,
|
||||
};
|
||||
|
||||
session.results.push(result);
|
||||
session.currentRound++;
|
||||
|
||||
// Update streak
|
||||
if (correct) {
|
||||
session.currentStreak++;
|
||||
session.bestStreak = Math.max(session.bestStreak, session.currentStreak);
|
||||
} else {
|
||||
session.currentStreak = 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game is complete
|
||||
*/
|
||||
export function isGameComplete(session: GameSession): boolean {
|
||||
return session.currentRound >= session.config.rounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get game summary statistics
|
||||
*/
|
||||
export function getGameSummary(session: GameSession) {
|
||||
const totalRounds = session.results.length;
|
||||
const correct = session.results.filter(r => r.correct).length;
|
||||
const incorrect = totalRounds - correct;
|
||||
const accuracy = totalRounds > 0 ? (correct / totalRounds) * 100 : 0;
|
||||
const averageTime = totalRounds > 0
|
||||
? session.results.reduce((sum, r) => sum + r.timeSpent, 0) / totalRounds
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRounds,
|
||||
correct,
|
||||
incorrect,
|
||||
accuracy,
|
||||
averageTime,
|
||||
streak: session.bestStreak,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get difficulty description for a mode
|
||||
*/
|
||||
export function getDifficultyDescription(mode: GameMode): string {
|
||||
switch (mode) {
|
||||
case 'letters':
|
||||
return 'Single letters A-Z';
|
||||
case 'numbers':
|
||||
return 'Single digits 0-9';
|
||||
case 'words':
|
||||
return 'Common 4-6 letter words';
|
||||
case 'phrases':
|
||||
return 'Short phrases';
|
||||
}
|
||||
}
|
||||
276
main.ts
Normal file
276
main.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env
|
||||
|
||||
import { Command } from "@cliffy/command";
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import {
|
||||
clearScreen,
|
||||
printBanner,
|
||||
showMainMenu,
|
||||
selectGameMode,
|
||||
configureGame,
|
||||
playRound,
|
||||
showGameResults,
|
||||
showStats,
|
||||
showReference,
|
||||
confirmAction,
|
||||
} from "./ui.ts";
|
||||
import { createGameSession, isGameComplete } from "./game.ts";
|
||||
import { loadStats, updateStats, resetStats, getAccuracy } from "./stats.ts";
|
||||
import type { GameResult } from "./stats.ts";
|
||||
|
||||
/**
|
||||
* Main application class
|
||||
*/
|
||||
class MorseGame {
|
||||
/**
|
||||
* Run the interactive menu
|
||||
*/
|
||||
async runInteractive(): Promise<void> {
|
||||
while (true) {
|
||||
clearScreen();
|
||||
printBanner();
|
||||
|
||||
const action = await showMainMenu();
|
||||
|
||||
switch (action) {
|
||||
case "play":
|
||||
await this.playGame();
|
||||
break;
|
||||
case "stats":
|
||||
await this.viewStats();
|
||||
break;
|
||||
case "reference":
|
||||
await this.showReference();
|
||||
break;
|
||||
case "reset":
|
||||
await this.resetStats();
|
||||
break;
|
||||
case "exit":
|
||||
console.log(colors.cyan("\nThanks for playing! 73 (Best regards in morse)\n"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a game
|
||||
*/
|
||||
async playGame(): Promise<void> {
|
||||
const mode = await selectGameMode();
|
||||
const config = await configureGame(mode);
|
||||
|
||||
const session = createGameSession({
|
||||
mode,
|
||||
rounds: config.rounds,
|
||||
timePerRound: config.timePerRound,
|
||||
});
|
||||
|
||||
// Play all rounds
|
||||
for (let i = 0; i < config.rounds; i++) {
|
||||
await playRound(session, i + 1);
|
||||
|
||||
if (isGameComplete(session)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Show results
|
||||
showGameResults(session);
|
||||
|
||||
// Update stats
|
||||
const summary = session.results.reduce(
|
||||
(acc, result) => ({
|
||||
correct: acc.correct + (result.correct ? 1 : 0),
|
||||
incorrect: acc.incorrect + (result.correct ? 0 : 1),
|
||||
totalTime: acc.totalTime + result.timeSpent,
|
||||
}),
|
||||
{ correct: 0, incorrect: 0, totalTime: 0 }
|
||||
);
|
||||
|
||||
const gameResult: GameResult = {
|
||||
mode,
|
||||
rounds: session.results.length,
|
||||
correct: summary.correct,
|
||||
incorrect: summary.incorrect,
|
||||
averageTime: summary.totalTime / session.results.length / 1000,
|
||||
streak: session.bestStreak,
|
||||
};
|
||||
|
||||
await updateStats(gameResult);
|
||||
|
||||
// Wait for user to continue
|
||||
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(() => {
|
||||
Deno.stdin.setRaw(false);
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* View statistics
|
||||
*/
|
||||
async viewStats(): Promise<void> {
|
||||
const stats = await loadStats();
|
||||
showStats(stats);
|
||||
|
||||
// Wait for user to continue
|
||||
console.log(colors.gray("Press Enter to continue..."));
|
||||
await new Promise((resolve) => {
|
||||
const buf = new Uint8Array(1);
|
||||
Deno.stdin.setRaw(true);
|
||||
Deno.stdin.read(buf).then(() => {
|
||||
Deno.stdin.setRaw(false);
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show reference
|
||||
*/
|
||||
async showReference(): Promise<void> {
|
||||
showReference();
|
||||
|
||||
// Wait for user to continue
|
||||
console.log(colors.gray("Press Enter to continue..."));
|
||||
await new Promise((resolve) => {
|
||||
const buf = new Uint8Array(1);
|
||||
Deno.stdin.setRaw(true);
|
||||
Deno.stdin.read(buf).then(() => {
|
||||
Deno.stdin.setRaw(false);
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset statistics
|
||||
*/
|
||||
async resetStats(): Promise<void> {
|
||||
const confirmed = await confirmAction(
|
||||
"Are you sure you want to reset all statistics? This cannot be undone."
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
await resetStats();
|
||||
console.log(colors.green("\n✓ Statistics have been reset.\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick play with CLI arguments
|
||||
*/
|
||||
async quickPlay(mode: string, rounds: number, time: number): Promise<void> {
|
||||
const validModes = ["letters", "numbers", "words", "phrases"];
|
||||
if (!validModes.includes(mode)) {
|
||||
console.error(colors.red(`Invalid mode: ${mode}`));
|
||||
console.log(`Valid modes: ${validModes.join(", ")}`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
clearScreen();
|
||||
printBanner();
|
||||
|
||||
const session = createGameSession({
|
||||
mode: mode as any,
|
||||
rounds,
|
||||
timePerRound: time,
|
||||
});
|
||||
|
||||
// Play all rounds
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await playRound(session, i + 1);
|
||||
}
|
||||
|
||||
// Show results
|
||||
showGameResults(session);
|
||||
|
||||
// Update stats
|
||||
const summary = session.results.reduce(
|
||||
(acc, result) => ({
|
||||
correct: acc.correct + (result.correct ? 1 : 0),
|
||||
incorrect: acc.incorrect + (result.correct ? 0 : 1),
|
||||
totalTime: acc.totalTime + result.timeSpent,
|
||||
}),
|
||||
{ correct: 0, incorrect: 0, totalTime: 0 }
|
||||
);
|
||||
|
||||
const gameResult: GameResult = {
|
||||
mode: mode as any,
|
||||
rounds: session.results.length,
|
||||
correct: summary.correct,
|
||||
incorrect: summary.incorrect,
|
||||
averageTime: summary.totalTime / session.results.length / 1000,
|
||||
streak: session.bestStreak,
|
||||
};
|
||||
|
||||
await updateStats(gameResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show quick stats
|
||||
*/
|
||||
async showQuickStats(): Promise<void> {
|
||||
const stats = await loadStats();
|
||||
|
||||
if (stats.totalGames === 0) {
|
||||
console.log(colors.yellow("No games played yet!"));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(colors.bold.cyan("\n📈 QUICK STATS\n"));
|
||||
console.log(`Games Played: ${colors.bold(stats.totalGames.toString())}`);
|
||||
console.log(`Total Rounds: ${colors.bold(stats.totalRounds.toString())}`);
|
||||
console.log(`Accuracy: ${colors.bold(getAccuracy(stats).toFixed(1) + "%")}`);
|
||||
console.log(`Best Streak: ${colors.bold(stats.bestStreak.toString())}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// CLI Setup
|
||||
const app = new MorseGame();
|
||||
|
||||
await new Command()
|
||||
.name("morse-game")
|
||||
.version("1.0.0")
|
||||
.description("A terminal-based morse code practice game")
|
||||
.action(async () => {
|
||||
// Default action: run interactive mode
|
||||
await app.runInteractive();
|
||||
})
|
||||
.command("play", "Start a quick game with specified settings")
|
||||
.option("-m, --mode <mode:string>", "Game mode (letters, numbers, words, phrases)", {
|
||||
default: "letters",
|
||||
})
|
||||
.option("-r, --rounds <rounds:number>", "Number of rounds", { default: 10 })
|
||||
.option("-t, --time <seconds:number>", "Seconds per round", { default: 30 })
|
||||
.action(async (options) => {
|
||||
await app.quickPlay(options.mode, options.rounds, options.time);
|
||||
})
|
||||
.command("stats", "Display your statistics")
|
||||
.action(async () => {
|
||||
await app.showQuickStats();
|
||||
})
|
||||
.command("reset", "Reset all statistics")
|
||||
.action(async () => {
|
||||
const confirmed = await confirmAction(
|
||||
"Are you sure you want to reset all statistics? This cannot be undone."
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
await resetStats();
|
||||
console.log(colors.green("\n✓ Statistics have been reset.\n"));
|
||||
} else {
|
||||
console.log(colors.yellow("\nCancelled.\n"));
|
||||
}
|
||||
})
|
||||
.command("reference", "Show morse code reference")
|
||||
.action(() => {
|
||||
showReference();
|
||||
})
|
||||
.parse(Deno.args);
|
||||
95
morse.ts
Normal file
95
morse.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Morse code translation utilities
|
||||
|
||||
export const MORSE_CODE: Record<string, string> = {
|
||||
'A': '.-',
|
||||
'B': '-...',
|
||||
'C': '-.-.',
|
||||
'D': '-..',
|
||||
'E': '.',
|
||||
'F': '..-.',
|
||||
'G': '--.',
|
||||
'H': '....',
|
||||
'I': '..',
|
||||
'J': '.---',
|
||||
'K': '-.-',
|
||||
'L': '.-..',
|
||||
'M': '--',
|
||||
'N': '-.',
|
||||
'O': '---',
|
||||
'P': '.--.',
|
||||
'Q': '--.-',
|
||||
'R': '.-.',
|
||||
'S': '...',
|
||||
'T': '-',
|
||||
'U': '..-',
|
||||
'V': '...-',
|
||||
'W': '.--',
|
||||
'X': '-..-',
|
||||
'Y': '-.--',
|
||||
'Z': '--..',
|
||||
'0': '-----',
|
||||
'1': '.----',
|
||||
'2': '..---',
|
||||
'3': '...--',
|
||||
'4': '....-',
|
||||
'5': '.....',
|
||||
'6': '-....',
|
||||
'7': '--...',
|
||||
'8': '---..',
|
||||
'9': '----.',
|
||||
' ': '/',
|
||||
'.': '.-.-.-',
|
||||
',': '--..--',
|
||||
'?': '..--..',
|
||||
'!': '-.-.--',
|
||||
'-': '-....-',
|
||||
'/': '-..-.',
|
||||
'@': '.--.-.',
|
||||
'(': '-.--.',
|
||||
')': '-.--.-'
|
||||
};
|
||||
|
||||
// Reverse mapping for morse to text
|
||||
export const MORSE_TO_TEXT: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(MORSE_CODE).map(([key, value]) => [value, key])
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert text to morse code
|
||||
*/
|
||||
export function textToMorse(text: string): string {
|
||||
return text
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => MORSE_CODE[char] || char)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert morse code to text
|
||||
*/
|
||||
export function morseToText(morse: string): string {
|
||||
return morse
|
||||
.split(' ')
|
||||
.map(code => MORSE_TO_TEXT[code] || code)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize morse code input (remove extra spaces, normalize separators)
|
||||
*/
|
||||
export function normalizeMorse(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\//g, ' / ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two morse code strings with tolerance for spacing differences
|
||||
*/
|
||||
export function compareMorse(expected: string, actual: string): boolean {
|
||||
const normalizedExpected = normalizeMorse(expected);
|
||||
const normalizedActual = normalizeMorse(actual);
|
||||
return normalizedExpected === normalizedActual;
|
||||
}
|
||||
126
stats.ts
Normal file
126
stats.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// User statistics tracking and persistence
|
||||
|
||||
import { join } from "@std/path";
|
||||
import { ensureDir } from "@std/fs";
|
||||
|
||||
export interface GameStats {
|
||||
totalGames: number;
|
||||
totalRounds: number;
|
||||
correctAnswers: number;
|
||||
incorrectAnswers: number;
|
||||
averageTimePerRound: number;
|
||||
bestStreak: number;
|
||||
modeStats: {
|
||||
letters: ModeStats;
|
||||
numbers: ModeStats;
|
||||
words: ModeStats;
|
||||
phrases: ModeStats;
|
||||
};
|
||||
lastPlayed?: string;
|
||||
}
|
||||
|
||||
export interface ModeStats {
|
||||
gamesPlayed: number;
|
||||
correctAnswers: number;
|
||||
incorrectAnswers: number;
|
||||
averageAccuracy: number;
|
||||
}
|
||||
|
||||
export interface GameResult {
|
||||
mode: 'letters' | 'numbers' | 'words' | 'phrases';
|
||||
rounds: number;
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
averageTime: number;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
const DEFAULT_STATS: GameStats = {
|
||||
totalGames: 0,
|
||||
totalRounds: 0,
|
||||
correctAnswers: 0,
|
||||
incorrectAnswers: 0,
|
||||
averageTimePerRound: 0,
|
||||
bestStreak: 0,
|
||||
modeStats: {
|
||||
letters: { gamesPlayed: 0, correctAnswers: 0, incorrectAnswers: 0, averageAccuracy: 0 },
|
||||
numbers: { gamesPlayed: 0, correctAnswers: 0, incorrectAnswers: 0, averageAccuracy: 0 },
|
||||
words: { gamesPlayed: 0, correctAnswers: 0, incorrectAnswers: 0, averageAccuracy: 0 },
|
||||
phrases: { gamesPlayed: 0, correctAnswers: 0, incorrectAnswers: 0, averageAccuracy: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
function getStatsPath(): string {
|
||||
const homeDir = Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || ".";
|
||||
return join(homeDir, ".morse-game", "stats.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stats from disk
|
||||
*/
|
||||
export async function loadStats(): Promise<GameStats> {
|
||||
try {
|
||||
const statsPath = getStatsPath();
|
||||
const content = await Deno.readTextFile(statsPath);
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return { ...DEFAULT_STATS };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save stats to disk
|
||||
*/
|
||||
export async function saveStats(stats: GameStats): Promise<void> {
|
||||
const statsPath = getStatsPath();
|
||||
const dir = join(Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || ".", ".morse-game");
|
||||
|
||||
await ensureDir(dir);
|
||||
await Deno.writeTextFile(statsPath, JSON.stringify(stats, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stats with a new game result
|
||||
*/
|
||||
export async function updateStats(result: GameResult): Promise<GameStats> {
|
||||
const stats = await loadStats();
|
||||
|
||||
// Update overall stats
|
||||
stats.totalGames++;
|
||||
stats.totalRounds += result.rounds;
|
||||
stats.correctAnswers += result.correct;
|
||||
stats.incorrectAnswers += result.incorrect;
|
||||
stats.averageTimePerRound =
|
||||
(stats.averageTimePerRound * (stats.totalRounds - result.rounds) +
|
||||
result.averageTime * result.rounds) / stats.totalRounds;
|
||||
stats.bestStreak = Math.max(stats.bestStreak, result.streak);
|
||||
stats.lastPlayed = new Date().toISOString();
|
||||
|
||||
// Update mode-specific stats
|
||||
const modeStats = stats.modeStats[result.mode];
|
||||
modeStats.gamesPlayed++;
|
||||
modeStats.correctAnswers += result.correct;
|
||||
modeStats.incorrectAnswers += result.incorrect;
|
||||
const totalAnswers = modeStats.correctAnswers + modeStats.incorrectAnswers;
|
||||
modeStats.averageAccuracy = totalAnswers > 0
|
||||
? (modeStats.correctAnswers / totalAnswers) * 100
|
||||
: 0;
|
||||
|
||||
await saveStats(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all stats
|
||||
*/
|
||||
export async function resetStats(): Promise<void> {
|
||||
await saveStats({ ...DEFAULT_STATS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted accuracy percentage
|
||||
*/
|
||||
export function getAccuracy(stats: GameStats): number {
|
||||
const total = stats.correctAnswers + stats.incorrectAnswers;
|
||||
return total > 0 ? (stats.correctAnswers / total) * 100 : 0;
|
||||
}
|
||||
348
ui.ts
Normal file
348
ui.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
// UI utilities and game interface
|
||||
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import { Input } from "@cliffy/prompt/input";
|
||||
import { Select } from "@cliffy/prompt/select";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
import { Table } from "@cliffy/table";
|
||||
import { textToMorse } from "./morse.ts";
|
||||
import type { GameStats } from "./stats.ts";
|
||||
import { getAccuracy } from "./stats.ts";
|
||||
import type { GameSession, RoundResult, GameMode } from "./game.ts";
|
||||
import {
|
||||
createGameSession,
|
||||
getChallenge,
|
||||
processRound,
|
||||
isGameComplete,
|
||||
getGameSummary,
|
||||
getDifficultyDescription,
|
||||
} from "./game.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: "Reset Statistics", value: "reset" },
|
||||
{ name: "Exit", value: "exit" },
|
||||
],
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select game mode
|
||||
*/
|
||||
export async function selectGameMode(): Promise<GameMode> {
|
||||
const mode = await Select.prompt<GameMode>({
|
||||
message: "Select difficulty mode:",
|
||||
options: [
|
||||
{
|
||||
name: `${colors.green("Easy")} - Letters (A-Z)`,
|
||||
value: "letters" as GameMode
|
||||
},
|
||||
{
|
||||
name: `${colors.yellow("Medium")} - Numbers (0-9)`,
|
||||
value: "numbers" as GameMode
|
||||
},
|
||||
{
|
||||
name: `${colors.magenta("Hard")} - Words`,
|
||||
value: "words" as GameMode
|
||||
},
|
||||
{
|
||||
name: `${colors.red("Expert")} - Phrases`,
|
||||
value: "phrases" as GameMode
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure game settings
|
||||
*/
|
||||
export async function configureGame(mode: GameMode): Promise<{ rounds: number; timePerRound: number }> {
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
const timeInput = await Input.prompt({
|
||||
message: "Seconds per round? (10-60)",
|
||||
default: "30",
|
||||
validate: (value) => {
|
||||
const num = parseInt(value);
|
||||
if (isNaN(num) || num < 10 || num > 60) {
|
||||
return "Please enter a number between 10 and 60";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rounds: parseInt(roundsInput),
|
||||
timePerRound: parseInt(timeInput),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a single round of the game
|
||||
*/
|
||||
export async function playRound(
|
||||
session: GameSession,
|
||||
roundNumber: number
|
||||
): Promise<RoundResult> {
|
||||
const challenge = getChallenge(session.config.mode);
|
||||
const startTime = Date.now();
|
||||
|
||||
clearScreen();
|
||||
printBanner();
|
||||
console.log(colors.bold(`\nRound ${roundNumber} of ${session.config.rounds}`));
|
||||
console.log(colors.gray(`Time limit: ${session.config.timePerRound}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"));
|
||||
|
||||
// Create a promise that rejects after the time limit
|
||||
const timeoutPromise = new Promise<string>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("Time's up!"));
|
||||
}, session.config.timePerRound * 1000);
|
||||
});
|
||||
|
||||
// Get user input with timeout
|
||||
let userInput = "";
|
||||
try {
|
||||
const inputPromise = Input.prompt({
|
||||
message: "Your answer:",
|
||||
minLength: 0,
|
||||
});
|
||||
|
||||
userInput = await Promise.race([inputPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
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)"}`));
|
||||
}
|
||||
|
||||
// Wait for user to continue
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
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 table = new Table()
|
||||
.header([colors.bold("Metric"), colors.bold("Value")])
|
||||
.body([
|
||||
["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`],
|
||||
["Best Streak", colors.yellow(summary.streak.toString())],
|
||||
])
|
||||
.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("Result"),
|
||||
colors.bold("Time"),
|
||||
]);
|
||||
|
||||
session.results.forEach((result, index) => {
|
||||
detailsTable.push([
|
||||
(index + 1).toString(),
|
||||
result.challenge,
|
||||
result.expectedMorse,
|
||||
result.userInput || "-",
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user