diff options
| author | srdusr <trevorgray@srdusr.com> | 2025-09-26 13:39:28 +0200 |
|---|---|---|
| committer | srdusr <trevorgray@srdusr.com> | 2025-09-26 13:39:28 +0200 |
| commit | 8d60c7f93407988ee0232ea90980028f299cb0f3 (patch) | |
| tree | b343b691d1bce64fb3bc9b40324857486f2be244 | |
| parent | 76f0d0e902e6ed164704572bd81faa5e5e560cf3 (diff) | |
| download | typerpunk-8d60c7f93407988ee0232ea90980028f299cb0f3.tar.gz typerpunk-8d60c7f93407988ee0232ea90980028f299cb0f3.zip | |
Initial Commit
57 files changed, 6551 insertions, 548 deletions
@@ -1,21 +1,41 @@ -[package] -name = "typerpunk" +[workspace] +members = [ + "crates/core", + "crates/tui", + "crates/wasm" +] +resolver = "2" + +[workspace.package] version = "0.1.0" edition = "2021" +authors = ["Your Name <your.email@example.com>"] +license = "MIT" +description = "A modern typing test application with TUI and web interfaces" + +[workspace.dependencies] +# Core dependencies +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +thiserror = "1.0" +config = "0.13" +dirs = "5.0" +rlua = "0.19" +rand = "0.8" + +# Terminal UI +crossterm = "0.27" +ratatui = "0.24" -[dependencies] -crossterm = "0.27.0" -tui = { version = "0.19.0", features = ["crossterm"], default-features = false } -termion = "3.0.0" -rand = "0.8.5" -paragraph = "0.0.2" -console = "0.15.8" -ansi_term = "0.12.1" -serde_json = "1.0.114" -regex = "1.6.1" -ureq = "2.9.6" -tokio = { version = "1.36.0", features = ["full"] } +# Web/WASM +wasm-bindgen = "0.2" +js-sys = "0.3" +web-sys = "0.3" +wasm-bindgen-futures = "0.4" +wasm-timer = "0.2" -[[bin]] -name = "typerpunk" -path = "src/main.rs" +# Networking +tokio-tungstenite = { version = "0.20", features = ["native-tls"] } +futures-util = "0.3" @@ -1,76 +1,122 @@ -# _Typerpunk_ +# TyperPunk -**_Typerpunk_** is a simple typing game written in Rust, where players are challenged to type sentences as quickly as possible. The game calculates the player's typing speed in Words Per Minute (WPM) and time taken. +A modern typing test application available in both terminal (TUI) and web versions. -> NOTE: Game is still in early stages of development. Plenty of features will be implemented such as programming related minigames, difficulty/custom settings and multiplayer to name a few. There are also plans to make this into not just a "cmdline" game but also have a fully fledged website and desktop gui client as well. +## Project Structure -## Features (beta) +This is a monorepo containing two main parts: -- Randomly selects sentences from a provided list for the player to type. -- Calculates typing speed in Words Per Minute (WPM). -- Color-coded feedback on typed characters (green for correct, red for incorrect, gray for untyped). +1. **TUI Version** (`crates/tui`): A terminal-based typing test application +2. **Web Version** (`web/`): A web-based version for typerpunk.com -## Installation -To play **_Typerpunk_**, make sure you have Rust installed on your system. You can install Rust from [rustup.rs](https://rustup.rs/). +- `texts.json`: Shared dataset consumed by both CLI and Web (auto-generated). +- `data/packs/`: Offline pack files you can edit to add more texts. +- `scripts/merge_packs.js`: Merges all packs into `texts.json`. -- Can also use this to quickly download rust: +## Prerequisites -```bash -curl https://sh.rustup.rs -sSf | sh -s -``` +- Rust toolchain (`rustup`, `cargo`) +- Node.js + npm +- For web: `wasm-pack` (install via `cargo install wasm-pack` or see https://rustwasm.github.io/wasm-pack/installer/) -- Clone this repository: +## Running the TUI Version ```bash +# Clone and enter the repo git clone https://github.com/srdusr/typerpunk.git -``` +cd typerpunk -- Navigate to the project directory: +# Generate dataset from offline packs (recommended) +npm install +npm run merge-packs -```bash -cd typerpunk +# Run the TUI +cargo run --package typerpunk-tui ``` -- Build and run the game: +## Running the Website ```bash -cargo run --release -``` +# From repo root, ensure dataset exists +npm install +npm run merge-packs -## How to Play +# Launch the web dev server (builds WASM and starts Vite) +./web/launch.sh +``` +The website will be available at http://localhost:3000 -- Run the executable: +## Testing the Website ```bash -./target/release/typerpunk +cd web +npm test +# or +npm test -- --watch +# or +npm test -- --coverage ``` -- Or put the executable into your path. Example: +## Building for Production (Web) ```bash -sudo cp target/release/typerpunk /usr/local/bin +cd web +npm run build +npm run preview ``` +The production build will be in the `web/dist` directory. -### Gameplay: - -When the game starts, you will see a main menu. -Press `Enter` to begin the typing challenge. -Random text will be shown and game will only start as soon as you start typing. -Press `Enter` when you have finished typing the sentence. -The game will display your Words Per Minute (WPM) and time taken. -To play again, press `Enter` at the End screen. -To quit the game, press `Esc` at any time. - -### Controls: +## Common Development Tasks (Web) -`Enter`: Submit typed sentence or proceed in menus. -`Backspace`: Delete the last character. -`Esc`: Quit the game or go back to the main menu. - -## Contributing - -Contributions are welcome! If you have any ideas, bug fixes, or improvements, feel free to open an issue or submit a pull request. +```bash +cd web +npm run lint +## Text Dataset (Offline Packs + Online) + +TyperPunk uses a shared dataset `texts.json` for both CLI and Web. + +- Offline (recommended): + - Add files to `data/packs/*.json` with entries of the form: + ```json + { + "category": "programming", + "content": "A paragraph of 80–400 characters…", + "attribution": "Author or Source" + } + ``` + - Merge all packs into `texts.json` at repo root: + ```bash + npm install + npm run merge-packs + ``` +- Online (optional, web only): + - Host a `texts.json` and set a URL in the page (e.g., `web/index.html`): + ```html + <script> + window.TYPERPUNK_TEXTS_URL = "https://your.cdn/path/to/texts.json"; + </script> + ``` + - The app will fetch the online dataset on load; if unavailable, it falls back to the bundled local file. + +Notes: +- `web/launch.sh` copies the root `texts.json` to `web/src/data/texts.json` for local dev. +- A small fallback is checked into `web/src/data/texts.json` to ensure imports resolve. + +## Category Filters + +- Web UI (`web/src/components/MainMenu.tsx`): + - A Category dropdown is available on the main menu. + - Default is **Random**; selecting a category restricts the text pool to that category. + +- CLI/TUI (`crates/core/src/ui.rs`, `crates/core/src/app.rs`): + - On the main menu, use **Left/Right** arrows to cycle categories. + - Display shows: `Category: Random (←/→ to change)` or the selected category name. + - Press **Enter** to start; **Esc** to quit. + +- Attribution: + - Web shows attribution under the text. + - CLI shows attribution below the typing area. ## License diff --git a/config.json b/config.json new file mode 100644 index 0000000..e28a596 --- /dev/null +++ b/config.json @@ -0,0 +1,12 @@ +{ + "texts_path": "texts.json", + "default_text": "The quick brown fox jumps over the lazy dog.", + "theme": { + "name": "default", + "background": "Black", + "foreground": "White", + "accent": "Blue", + "error": "Red", + "success": "Green" + } +}
\ No newline at end of file diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..7da2b71 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "typerpunk-core" +version = "0.1.0" +edition = "2021" + +[lib] +name = "typerpunk_core" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[features] +default = ["full", "serde", "wasm"] +full = ["dirs", "tui"] +wasm = ["getrandom/js", "dep:wasm-bindgen", "dep:js-sys", "dep:web-sys", "dep:wasm-bindgen-futures", "serde"] +tui = ["dep:crossterm", "dep:ratatui"] +web = ["dep:wasm-bindgen", "dep:web-sys"] +multiplayer = ["dep:tokio-tungstenite", "dep:futures-util", "dep:tokio"] +serde = ["dep:serde", "dep:serde_json"] + +[dependencies] +# Core dependencies +tokio = { workspace = true, optional = true, features = ["full"] } +serde = { workspace = true, features = ["derive"], optional = true } +serde_json = { workspace = true, optional = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +config = { workspace = true, optional = true } +dirs = { workspace = true, optional = true } +rand = { workspace = true } +getrandom = { version = "0.2", optional = true } + +# Optional TUI dependencies +crossterm = { version = "0.27", optional = true } +ratatui = { version = "0.24", optional = true } + +# Optional Web dependencies +wasm-bindgen = { workspace = true, optional = true } +web-sys = { workspace = true, optional = true } +js-sys = { workspace = true, optional = true } + +# Optional Multiplayer dependencies +tokio-tungstenite = { version = "0.20", optional = true } +futures-util = { version = "0.3", optional = true } + +# WASM dependencies +wasm-bindgen-futures = { workspace = true, optional = true } +serde-wasm-bindgen = "0.6" + +[dev-dependencies] +criterion = "0.5" +mockall = "0.12" +proptest = "1.3"
\ No newline at end of file diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs new file mode 100644 index 0000000..b230bc3 --- /dev/null +++ b/crates/core/src/app.rs @@ -0,0 +1,275 @@ +use rand::Rng; +use crossterm::event::KeyEvent; +use crate::{ + config::Config, + stats::Stats, + text::Text, +}; +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq)] +pub enum State { + MainMenu, + TypingGame, + EndScreen, +} + +pub struct App { + pub config: Config, + pub texts: Vec<Text>, + pub categories: Vec<String>, + pub selected_category: Option<String>, // None = Random + pub stats: Stats, + pub input: String, + pub current_text_index: usize, + pub should_exit: bool, + pub state: State, +} + +impl App { + pub fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { + let config = Config::new(); + #[derive(Deserialize)] + struct RawText { category: String, content: String, attribution: String } + // texts.json is stored at repository root; this file is at crates/core/src/app.rs + const RAW_TEXTS: &str = include_str!("../../../texts.json"); + let parsed: Vec<RawText> = serde_json::from_str(RAW_TEXTS)?; + let texts: Vec<Text> = parsed + .into_iter() + .map(|t| Text { + content: t.content, + source: t.attribution, + language: "en".to_string(), + category: t.category, + }) + .collect(); + let stats = Stats::new(); + let input = String::new(); + let categories = { + let mut set = std::collections::BTreeSet::new(); + for t in &texts { if !t.category.is_empty() { set.insert(t.category.clone()); } } + set.into_iter().collect::<Vec<_>>() + }; + let current_text_index = if texts.is_empty() { 0 } else { rand::thread_rng().gen_range(0..texts.len()) }; + let should_exit = false; + let state = State::MainMenu; + + Ok(App { + config, + texts, + categories, + selected_category: None, + stats, + input, + current_text_index, + should_exit, + state, + }) + } + + pub fn new_with_config(config: Config) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { + #[derive(Deserialize)] + struct RawText { category: String, content: String, attribution: String } + const RAW_TEXTS: &str = include_str!("../../../texts.json"); + let parsed: Vec<RawText> = serde_json::from_str(RAW_TEXTS)?; + let texts: Vec<Text> = parsed + .into_iter() + .map(|t| Text { + content: t.content, + source: t.attribution, + language: "en".to_string(), + category: t.category, + }) + .collect(); + let categories = { + let mut set = std::collections::BTreeSet::new(); + for t in &texts { if !t.category.is_empty() { set.insert(t.category.clone()); } } + set.into_iter().collect::<Vec<_>>() + }; + let current_text_index = if texts.is_empty() { 0 } else { rand::thread_rng().gen_range(0..texts.len()) }; + Ok(Self { + state: State::MainMenu, + should_exit: false, + input: String::new(), + texts, + categories, + selected_category: None, + current_text_index, + stats: Stats::new(), + config, + }) + } + + pub fn reset(&mut self) { + self.input.clear(); + self.stats.reset(); + self.current_text_index = self.pick_random_index(); + } + + fn pick_random_index(&self) -> usize { + if self.texts.is_empty() { return 0; } + let pool: Vec<usize> = match &self.selected_category { + Some(cat) => self.texts.iter().enumerate().filter(|(_, t)| &t.category == cat).map(|(i, _)| i).collect(), + None => (0..self.texts.len()).collect(), + }; + if pool.is_empty() { return 0; } + let idx = rand::thread_rng().gen_range(0..pool.len()); + pool[idx] + } + + pub fn handle_input(&mut self, key: KeyEvent) { + match self.state { + State::MainMenu => { + match key.code { + crossterm::event::KeyCode::Enter => { + self.state = State::TypingGame; + self.reset(); + } + crossterm::event::KeyCode::Left => { + // cycle category backwards (None -> last) + if self.categories.is_empty() { + self.selected_category = None; + } else { + match &self.selected_category { + None => self.selected_category = Some(self.categories.last().unwrap().clone()), + Some(cur) => { + let pos = self.categories.iter().position(|c| c == cur).unwrap_or(0); + if pos == 0 { self.selected_category = None; } else { self.selected_category = Some(self.categories[pos-1].clone()); } + } + } + } + } + crossterm::event::KeyCode::Right => { + // cycle category forwards (None -> first) + if self.categories.is_empty() { + self.selected_category = None; + } else { + match &self.selected_category { + None => self.selected_category = Some(self.categories[0].clone()), + Some(cur) => { + let pos = self.categories.iter().position(|c| c == cur).unwrap_or(0); + if pos + 1 >= self.categories.len() { self.selected_category = None; } else { self.selected_category = Some(self.categories[pos+1].clone()); } + } + } + } + } + crossterm::event::KeyCode::Esc => { + self.should_exit = true; + } + _ => {} + } + } + State::TypingGame => { + match key.code { + crossterm::event::KeyCode::Char(c) => { + if !self.stats.is_running() { + self.stats.start(); + } + self.input.push(c); + self.update_stats(); + } + crossterm::event::KeyCode::Backspace => { + self.input.pop(); + self.update_stats(); + } + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + State::EndScreen => { + match key.code { + crossterm::event::KeyCode::Enter => { + self.state = State::TypingGame; + self.reset(); + } + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + } + + // Check if the current text is finished + if self.state == State::TypingGame && self.is_finished() { + self.state = State::EndScreen; + self.stats.stop(); + } + } + + pub fn update_stats(&mut self) { + if self.state == State::TypingGame { + let current_text = &self.texts[self.current_text_index].content; + self.stats.update(&self.input, current_text); + } + } + + pub fn is_finished(&self) -> bool { + self.input.trim() == self.texts[self.current_text_index].content.trim() + } + + pub fn current_text(&self) -> &Text { + &self.texts[self.current_text_index] + } + + pub fn get_input(&self) -> &str { + self.input.as_str() + } + + pub fn handle_backspace(&mut self) { + if self.state == State::TypingGame && !self.input.is_empty() { + self.input.pop(); + self.update_stats(); + } + } + + pub fn handle_enter(&mut self) { + match self.state { + State::MainMenu => { + self.state = State::TypingGame; + self.reset(); + } + State::EndScreen => { + self.state = State::TypingGame; + self.reset(); + } + _ => {} + } + } + + pub fn handle_escape(&mut self) { + match self.state { + State::TypingGame => { + self.state = State::MainMenu; + self.reset(); + } + State::EndScreen => { + self.state = State::MainMenu; + self.reset(); + } + State::MainMenu => { + self.should_exit = true; + } + } + } + + pub fn get_progress(&self) -> f64 { + if self.input.is_empty() { + 0.0 + } else { + let total_chars = self.current_text().content.chars().count(); + let current_chars = self.input.chars().count(); + (current_chars as f64 / total_chars as f64) * 100.0 + } + } + + pub fn update(&mut self) { + if self.state == State::TypingGame { + self.update_stats(); + } + } +}
\ No newline at end of file diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..3072438 --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub theme: String, + pub mode: String, + pub time: u64, + pub words: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + theme: "dark".to_string(), + mode: "time".to_string(), + time: 60, + words: 50, + } + } +} + +impl Config { + pub fn new() -> Self { + Self::default() + } + + pub fn get_theme(&self) -> String { + self.theme.clone() + } + + pub fn get_mode(&self) -> String { + self.mode.clone() + } + + pub fn get_time(&self) -> u64 { + self.time + } + + pub fn get_words(&self) -> usize { + self.words + } +} diff --git a/crates/core/src/game.rs b/crates/core/src/game.rs new file mode 100644 index 0000000..cb57e5c --- /dev/null +++ b/crates/core/src/game.rs @@ -0,0 +1,632 @@ +use std::time::Instant; +use serde::{Deserialize, Serialize}; +use crate::types::Theme; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Platform { + Desktop, + Web, + Mobile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum GameMode { + Normal, + Programming, + Security, + Multiplayer, + Zen, + Time(u64), + Words(usize), + Quote, +} + +impl Default for GameMode { + fn default() -> Self { + GameMode::Normal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Difficulty { + Basic, + Intermediate, + Advanced, + Easy, + Medium, + Hard, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Topic { + General, + Programming, + Security, + DataStructures, + Algorithms, + RedTeam, + BlueTeam, + Gaming, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub mode: GameMode, + pub difficulty: Difficulty, + pub topic: Topic, + pub time_limit: Option<u64>, + pub word_count: Option<usize>, + pub custom_text: Option<String>, + pub multiplayer: bool, + pub quote_length: usize, + pub theme: Theme, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + mode: GameMode::Normal, + difficulty: Difficulty::Basic, + topic: Topic::General, + time_limit: None, + word_count: None, + custom_text: None, + multiplayer: false, + quote_length: 50, + theme: Theme::default(), + } + } +} + +pub trait GameModeTrait { + fn get_mode(&self) -> GameMode; + fn get_difficulty(&self) -> Difficulty; + fn get_topic(&self) -> Topic; + fn get_time_limit(&self) -> Option<u64>; + fn get_word_count(&self) -> Option<usize>; + fn get_custom_text(&self) -> Option<&str>; + fn is_multiplayer(&self) -> bool; + fn get_quote_length(&self) -> usize; + fn get_theme(&self) -> &Theme; +} + +impl GameModeTrait for GameConfig { + fn get_mode(&self) -> GameMode { + self.mode + } + + fn get_difficulty(&self) -> Difficulty { + self.difficulty + } + + fn get_topic(&self) -> Topic { + self.topic + } + + fn get_time_limit(&self) -> Option<u64> { + self.time_limit + } + + fn get_word_count(&self) -> Option<usize> { + self.word_count + } + + fn get_custom_text(&self) -> Option<&str> { + self.custom_text.as_deref() + } + + fn is_multiplayer(&self) -> bool { + self.multiplayer + } + + fn get_quote_length(&self) -> usize { + self.quote_length + } + + fn get_theme(&self) -> &Theme { + &self.theme + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Game { + text: String, + input: String, + #[serde(skip)] + start_time: Option<Instant>, + is_started: bool, + is_finished: bool, + error_positions: Vec<usize>, + current_streak: u32, + best_streak: u32, + theme: Theme, + correct_positions: Vec<bool>, + pub total_mistakes: u32, + total_errors_made: u32, + last_input_length: usize, + total_characters_typed: u32, + total_correct_characters: u32, +} + +impl Game { + pub fn new() -> Self { + Self { + text: String::new(), + input: String::new(), + start_time: None, + is_started: false, + is_finished: false, + error_positions: Vec::new(), + current_streak: 0, + best_streak: 0, + theme: Theme::default(), + correct_positions: Vec::new(), + total_mistakes: 0, + total_errors_made: 0, + last_input_length: 0, + total_characters_typed: 0, + total_correct_characters: 0, + } + } + + pub fn set_text(&mut self, text: String) { + self.text = text; + self.reset(); + } + + pub fn get_text(&self) -> String { + self.text.clone() + } + + pub fn get_input(&self) -> String { + self.input.clone() + } + + pub fn start(&mut self) { + self.is_started = true; + self.start_time = Some(Instant::now()); + } + + pub fn handle_input(&mut self, input: &str) -> Result<(), String> { + println!("DEBUG: handle_input called with input='{}'", input); + if self.is_finished() { + return Ok(()); + } + + // Validate UTF-8 only + let input_str = match std::str::from_utf8(input.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err("Invalid UTF-8 input".to_string()), + }; + + // Update input + self.input = input_str; + + // Update game state + self.update_game_state(); + Ok(()) + } + + pub fn is_finished(&self) -> bool { + self.is_finished + } + + pub fn get_error_positions(&self) -> Vec<usize> { + self.error_positions.clone() + } + + pub fn get_current_streak(&self) -> u32 { + self.current_streak + } + + pub fn get_best_streak(&self) -> u32 { + self.best_streak + } + + pub fn get_theme(&self) -> Theme { + self.theme + } + + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + pub fn get_wpm(&self) -> f64 { + if let Some(start_time) = self.start_time { + let elapsed = start_time.elapsed().as_secs_f64(); + if elapsed > 0.0 { + let words = self.input.len() as f64 / 5.0; + return (words * 60.0) / elapsed; + } + } + 0.0 + } + + pub fn get_stats(&self) -> Result<(f64, u32), String> { + let accuracy = self.get_accuracy(); + let mistakes = self.get_total_mistakes(); + Ok((accuracy, mistakes)) + } + + pub fn get_stats_and_input(&self) -> Result<(String, f64, u32), String> { + let (accuracy, mistakes) = self.get_stats()?; + Ok((self.input.clone(), accuracy, mistakes)) + } + + pub fn get_accuracy(&self) -> f64 { + if self.total_characters_typed == 0 { + return 100.0; + } + + let accuracy = (self.total_correct_characters as f64 / self.total_characters_typed as f64) * 100.0; + accuracy.max(0.0).min(100.0) + } + + pub fn get_total_mistakes(&self) -> u32 { + // Return total errors made, not current mistakes + self.total_errors_made + } + + pub fn get_time_elapsed(&self) -> f64 { + if let Some(start_time) = self.start_time { + start_time.elapsed().as_secs_f64() + } else { + 0.0 + } + } + + fn reset(&mut self) { + self.input.clear(); + self.start_time = None; + self.is_started = false; + self.is_finished = false; + self.error_positions.clear(); + self.current_streak = 0; + self.best_streak = 0; + self.correct_positions = vec![false; self.text.len()]; + self.total_mistakes = 0; + self.total_errors_made = 0; + self.last_input_length = 0; + self.total_characters_typed = 0; + self.total_correct_characters = 0; + } + + pub fn can_backspace(&self) -> bool { + !self.is_finished && !self.input.is_empty() + } + + pub fn can_ctrl_backspace(&self) -> bool { + !self.is_finished && !self.input.is_empty() + } + + pub fn handle_backspace(&mut self, ctrl: bool) -> Result<bool, String> { + if !self.can_backspace() { + return Ok(false); + } + + let mut new_input = self.input.clone(); + + if ctrl { + // Find start of current word + let chars: Vec<char> = new_input.chars().collect(); + let mut word_start = 0; + let mut in_word = false; + for (i, c) in chars.iter().enumerate() { + if c.is_whitespace() { + if in_word { + word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + + // If at start of word, find previous error word start + if new_input.len() == word_start && !self.error_positions.is_empty() { + // Find the last error position before the current word + let prev_error = self.error_positions.iter().rev().find(|&&pos| pos < word_start); + if let Some(&err_pos) = prev_error { + // Find the start of the word containing this error + let mut prev_word_start = 0; + let mut in_word = false; + for (i, c) in chars.iter().enumerate() { + if i > err_pos { break; } + if c.is_whitespace() { + if in_word { + prev_word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + new_input = new_input[..prev_word_start].to_string(); + } + } else { + // Normal: delete to start of current word + new_input = new_input[..word_start].to_string(); + } + } else { + // Regular backspace: delete one character + if !self.can_backspace_to_position(new_input.len() - 1) { + return Ok(false); + } + new_input.pop(); + } + + self.input = new_input; + self.update_game_state(); + Ok(true) + } + + /// Check if there are any errors before a specific position + fn has_errors_before_position(&self, position: usize) -> bool { + let text_len = self.text.len().min(self.input.len()); + for i in 0..text_len.min(position) { + if i < self.input.len() { + let input_char = self.input.chars().nth(i); + let text_char = self.text.chars().nth(i); + if input_char != text_char { + return true; // Found an error + } + } + } + false + } + + /// Check if backspace is allowed to a specific position + /// Returns true if: + /// 1. We're in the current word (can always backspace within current word) + /// 2. There are errors in previous words (can backspace to fix them) + fn can_backspace_to_position(&self, target_pos: usize) -> bool { + if target_pos >= self.input.len() { + return false; + } + + // Always allow backspace within the current word + let current_word_start = self.get_current_word_start(); + if target_pos >= current_word_start { + return true; + } + + // Check if there are any errors in the text before the target position + let text_len = self.text.len().min(self.input.len()); + for i in 0..text_len.min(target_pos + 1) { + if i < self.input.len() { + let input_char = self.input.chars().nth(i); + let text_char = self.text.chars().nth(i); + if input_char != text_char { + return true; // Found an error, allow backspace + } + } + } + + false // No errors found, don't allow backspace to previous words + } + + /// Get the start position of the current word + fn get_current_word_start(&self) -> usize { + let mut word_start = 0; + let mut in_word = false; + + for (i, c) in self.input.chars().enumerate() { + if c.is_whitespace() { + if in_word { + word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + + word_start + } + + fn update_game_state(&mut self) { + println!("DEBUG: update_game_state called, input='{}', text='{}'", self.input, self.text); + self.error_positions.clear(); + let total_chars = self.input.len().min(self.text.len()); + + // Use string slices for comparison + let input_slice = &self.input[..total_chars]; + let text_slice = &self.text[..total_chars]; + + let mut _correct = 0; + let mut current_mistakes = 0; + let mut current_streak = 0; + let mut best_streak = 0; + + // Track new characters typed since last update + let new_chars_typed = if self.input.len() > self.last_input_length { + self.input.len() - self.last_input_length + } else { + 0 + }; + + // Add new characters to total typed + self.total_characters_typed += new_chars_typed as u32; + + // Compare characters using chars() iterator to handle UTF-8 correctly + for (i, (input_char, text_char)) in input_slice.chars().zip(text_slice.chars()).enumerate() { + if input_char == text_char { + _correct += 1; + current_streak += 1; + best_streak = best_streak.max(current_streak); + + // Count correct characters (only for new positions) + if i >= self.last_input_length { + self.total_correct_characters += 1; + } + } else { + current_mistakes += 1; + current_streak = 0; + self.error_positions.push(i); + + // Count new errors (only if this is a new character position) + if i >= self.last_input_length { + self.total_errors_made += 1; + } + } + } + + println!("DEBUG: before extra char logic, input.len()={}, text.len()={}", self.input.len(), self.text.len()); + // Add extra characters as current mistakes + if self.input.len() > self.text.len() { + current_mistakes += (self.input.len() - self.text.len()) as u32; + // Always count any new extra characters as errors + if self.input.len() > self.last_input_length { + let prev_extra = if self.last_input_length > self.text.len() { + self.last_input_length - self.text.len() + } else { + 0 + }; + let curr_extra = self.input.len() - self.text.len(); + let new_extra = curr_extra.saturating_sub(prev_extra); + println!("DEBUG: last_input_length={}, input.len()={}, prev_extra={}, curr_extra={}, new_extra={}", self.last_input_length, self.input.len(), prev_extra, curr_extra, new_extra); + if new_extra > 0 { + self.total_errors_made += new_extra as u32; + } + } + } + + self.total_mistakes = current_mistakes; + self.current_streak = current_streak as u32; + self.best_streak = best_streak as u32; + self.last_input_length = self.input.len(); + + // Check if game is finished - use both length and content comparison + let is_complete = !self.input.is_empty() && + self.input.len() >= self.text.len() && + self.input.trim() == self.text.trim(); + self.is_finished = is_complete; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backspace_within_current_word() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + game.handle_input("Hello").unwrap(); + + // Should be able to backspace within current word + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hell"); + } + + #[test] + fn test_backspace_to_previous_word_with_error() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + game.handle_input("Hallo world").unwrap(); // "Hallo" has an error + + // Should be able to backspace to fix the error in previous word + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hallo worl"); + + // Should be able to backspace more to fix the error + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hallo wor"); + } + + #[test] + fn test_backspace_to_previous_word_without_error() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + game.handle_input("Hello world").unwrap(); // No errors + + // Should not be able to backspace to previous word when no errors + let initial_input = game.get_input(); + assert!(!game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), initial_input); + } + + #[test] + fn test_ctrl_backspace() { + let mut game = Game::new(); + game.set_text("Hello world test".to_string()); + game.handle_input("Hallo world test").unwrap(); // "Hallo" has an error + + // Ctrl+backspace should delete the current word since there are errors before it + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "Hallo world "); + + // Test with no errors - should not allow ctrl+backspace + let mut game2 = Game::new(); + game2.set_text("Hello world test".to_string()); + game2.handle_input("Hello world test").unwrap(); // No errors + + assert!(!game2.handle_backspace(true).unwrap()); + + // Test going back to previous word with errors + let mut game3 = Game::new(); + game3.set_text("Hello world test".to_string()); + game3.handle_input("Hallo world test").unwrap(); // "Hallo" has an error + + // Should be able to ctrl+backspace to go back to the word with error + assert!(game3.handle_backspace(true).unwrap()); + assert_eq!(game3.get_input(), "Hallo world "); + + // Should be able to ctrl+backspace again to go back further + assert!(game3.handle_backspace(true).unwrap()); + assert_eq!(game3.get_input(), "Hallo world "); + } + + #[test] + fn test_game_completion_detection() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + + // Should not be finished initially + assert!(!game.is_finished()); + + // Should be finished when text is completed + game.handle_input("Hello world").unwrap(); + assert!(game.is_finished()); + + // Should be finished even with extra spaces (but we can't input more than text length) + let mut game2 = Game::new(); + game2.set_text("Hello world".to_string()); + game2.handle_input("Hello world").unwrap(); + assert!(game2.is_finished()); + } + + #[test] + fn test_error_counting() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + + // Type with errors + game.handle_input("Hallo world").unwrap(); // "Hallo" has an error + println!("After first error: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 1); // Should count the error + + // Correct the error + game.handle_input("Hello world").unwrap(); // Corrected + println!("After correction: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 1); // Should still show 1 error (total made) + + // Make another error by typing extra characters + game.handle_input("Hello worldx").unwrap(); // Extra character + println!("After second error: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 2); // Should now show 2 total errors + } + + #[test] + fn test_ctrl_backspace_one_error_word_at_a_time() { + let mut game = Game::new(); + game.set_text("foo bar baz qux".to_string()); + game.handle_input("fao bar bzz qux").unwrap(); // errors in 'fao' and 'bzz' + // Cursor at end, ctrl+backspace should delete to start of 'bzz' + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "fao bar "); + // Another ctrl+backspace should delete to start of 'fao' + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), ""); + } +}
\ No newline at end of file diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs new file mode 100644 index 0000000..803ddbd --- /dev/null +++ b/crates/core/src/input.rs @@ -0,0 +1,182 @@ +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use std::time::{Duration, Instant}; +use crate::app::App; + +#[derive(Debug, Clone)] +pub struct Input { + pub content: String, + pub cursor_position: usize, + pub history: Vec<String>, + pub history_index: usize, +} + +impl Input { + pub fn new() -> Self { + Self { + content: String::new(), + cursor_position: 0, + history: Vec::new(), + history_index: 0, + } + } + + pub fn handle_event(&mut self, event: Event) -> bool { + match event { + Event::Key(KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE, + .. + }) => { + if self.cursor_position == self.content.len() { + self.content.push(c); + } else { + self.content.insert(self.cursor_position, c); + } + self.cursor_position += 1; + true + } + Event::Key(KeyEvent { + code: KeyCode::Backspace, + .. + }) => { + if self.cursor_position > 0 { + self.cursor_position -= 1; + self.content.remove(self.cursor_position); + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Delete, + .. + }) => { + if self.cursor_position < self.content.len() { + self.content.remove(self.cursor_position); + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Left, + .. + }) => { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Right, + .. + }) => { + if self.cursor_position < self.content.len() { + self.cursor_position += 1; + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Home, + .. + }) => { + self.cursor_position = 0; + true + } + Event::Key(KeyEvent { + code: KeyCode::End, + .. + }) => { + self.cursor_position = self.content.len(); + true + } + _ => false, + } + } + + pub fn clear(&mut self) { + if !self.content.is_empty() { + self.history.push(self.content.clone()); + if self.history.len() > 100 { + self.history.remove(0); + } + } + self.content.clear(); + self.cursor_position = 0; + self.history_index = self.history.len(); + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn get_cursor_position(&self) -> usize { + self.cursor_position + } + + pub fn move_cursor_left(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + } + + pub fn move_cursor_right(&mut self) { + if self.cursor_position < self.content.len() { + self.cursor_position += 1; + } + } + + pub fn move_cursor_to_start(&mut self) { + self.cursor_position = 0; + } + + pub fn move_cursor_to_end(&mut self) { + self.cursor_position = self.content.len(); + } + + pub fn insert_char(&mut self, c: char) { + if self.cursor_position == self.content.len() { + self.content.push(c); + } else { + self.content.insert(self.cursor_position, c); + } + self.cursor_position += 1; + } + + pub fn delete_char(&mut self) -> bool { + if self.cursor_position < self.content.len() { + self.content.remove(self.cursor_position); + true + } else { + false + } + } + + pub fn backspace(&mut self) -> bool { + if self.cursor_position > 0 { + self.cursor_position -= 1; + self.content.remove(self.cursor_position); + true + } else { + false + } + } +} + +impl Default for Input { + fn default() -> Self { + Self::new() + } +} + +pub struct InputHandler { + pub app: App, + last_tick: Instant, + tick_rate: Duration, +} + +impl InputHandler { + pub fn new(app: App) -> Self { + Self { + app, + last_tick: Instant::now(), + tick_rate: Duration::from_millis(100), + } + } +}
\ No newline at end of file diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..6c365f1 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,23 @@ +pub mod config; +pub mod game; +pub mod stats; +pub mod text; +pub mod types; + +#[cfg(feature = "tui")] +pub mod app; +#[cfg(feature = "tui")] +pub mod input; +#[cfg(feature = "tui")] +pub mod ui; +#[cfg(feature = "tui")] +pub mod theme; + +#[cfg(feature = "multiplayer")] +pub mod multiplayer; + +#[cfg(feature = "wasm")] +pub mod wasm; + +#[cfg(target_arch = "wasm32")] +pub use wasm::TyperPunkGame;
\ No newline at end of file diff --git a/crates/core/src/multiplayer.rs b/crates/core/src/multiplayer.rs new file mode 100644 index 0000000..2154ac6 --- /dev/null +++ b/crates/core/src/multiplayer.rs @@ -0,0 +1,132 @@ +#[cfg(feature = "multiplayer")] +use tokio_tungstenite::tungstenite::Message; +#[cfg(feature = "multiplayer")] +use futures_util::{SinkExt, StreamExt}; +use serde::{Serialize, Deserialize}; +use std::net::SocketAddr; +use crate::{app::App, config::GameConfig, game::Platform}; +use std::error::Error; + +#[derive(Debug, Serialize, Deserialize)] +pub enum MultiplayerMessage { + Join, + Progress { progress: f32, wpm: f32 }, + Finish { wpm: f32, time: f32 }, +} + +#[derive(Debug)] +pub struct MultiplayerManager { + config: GameConfig, +} + +impl MultiplayerManager { + pub fn new(config: GameConfig) -> Self { + Self { config } + } + + #[cfg(feature = "multiplayer")] + pub async fn start_server(&self, addr: &str) -> Result<(), Box<dyn Error + Send + Sync>> { + tokio::spawn(async move { + let server = tokio::net::TcpListener::bind(addr).await?; + while let Ok((stream, _)) = server.accept().await { + let ws_stream = tokio_tungstenite::accept_async(stream).await?; + let (write, read) = ws_stream.split(); + read.forward(write).await?; + } + Ok::<(), Box<dyn Error + Send + Sync>>(()) + }); + Ok(()) + } + + #[cfg(feature = "multiplayer")] + pub async fn host_game(&mut self, addr: &str) -> Result<(), Box<dyn Error + Send + Sync>> { + tokio::spawn(async move { + let server = tokio::net::TcpListener::bind(addr).await?; + while let Ok((stream, _)) = server.accept().await { + let ws_stream = tokio_tungstenite::accept_async(stream).await?; + let (write, read) = ws_stream.split(); + read.forward(write).await?; + } + Ok::<(), Box<dyn Error + Send + Sync>>(()) + }); + Ok(()) + } + + #[cfg(feature = "multiplayer")] + pub async fn join_game(&mut self, url: &str) -> Result<(), Box<dyn Error + Send + Sync>> { + let (ws_stream, _) = tokio_tungstenite::connect_async(url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Send join message + let join_msg = serde_json::to_string(&MultiplayerMessage::Join)?; + write.send(Message::Text(join_msg)).await?; + + // Handle incoming messages + tokio::spawn(async move { + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + println!("Received: {}", text); + } + Err(e) => { + eprintln!("Error receiving message: {}", e); + break; + } + _ => {} + } + } + }); + + Ok(()) + } + + pub fn send_progress(&self, _progress: f32, _wpm: f32) { + #[cfg(feature = "multiplayer")] + let _msg = MultiplayerMessage::Progress { progress: _progress, wpm: _wpm }; + // Send message implementation + } + + pub fn send_finish(&self, _wpm: f32, _time: f32) { + #[cfg(feature = "multiplayer")] + let _msg = MultiplayerMessage::Finish { wpm: _wpm, time: _time }; + // Send message implementation + } +} + +#[cfg(target_arch = "wasm32")] +impl MultiplayerManager { + pub async fn connect_web(&mut self, _room_id: &str) -> Result<(), Box<dyn Error + Send + Sync>> { + // Connect to WebSocket server for web multiplayer + Ok(()) + } +} + +#[cfg(feature = "multiplayer")] +impl MultiplayerManager { + pub async fn connect_to_server(&self, url: &str) -> Result<(), Box<dyn Error + Send + Sync>> { + let (ws_stream, _) = tokio_tungstenite::connect_async(url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Send join message + let join_msg = serde_json::to_string(&MultiplayerMessage::Join)?; + write.send(Message::Text(join_msg)).await?; + + // Handle incoming messages + tokio::spawn(async move { + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + println!("Received: {}", text); + } + Err(e) => { + eprintln!("Error receiving message: {}", e); + break; + } + _ => {} + } + } + }); + + Ok(()) + } +}
\ No newline at end of file diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs new file mode 100644 index 0000000..59d56c9 --- /dev/null +++ b/crates/core/src/stats.rs @@ -0,0 +1,242 @@ +use std::time::{Duration, Instant}; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone)] +pub struct Stats { + start_time: Option<Instant>, + end_time: Option<Instant>, + error_positions: Vec<usize>, + current_streak: usize, + best_streak: usize, + total_chars: usize, + correct_chars: usize, + incorrect_chars: usize, + total_words: usize, + correct_words: usize, + errors: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedStats { + wpm: f64, + accuracy: f64, + total_chars: usize, + correct_chars: usize, + incorrect_chars: usize, + total_words: usize, + correct_words: usize, + errors: usize, + time_elapsed_secs: u64, + error_positions: Vec<usize>, + current_streak: usize, + best_streak: usize, +} + +impl From<&Stats> for SerializedStats { + fn from(stats: &Stats) -> Self { + let time_elapsed = stats.start_time + .and_then(|start| stats.end_time.map(|end| end.duration_since(start))) + .unwrap_or(Duration::from_secs(0)); + + let wpm = if time_elapsed.as_secs() > 0 { + (stats.correct_chars as f64 / 5.0) / (time_elapsed.as_secs_f64() / 60.0) + } else { + 0.0 + }; + + let accuracy = if stats.total_chars > 0 { + (stats.correct_chars as f64 / stats.total_chars as f64) * 100.0 + } else { + 0.0 + }; + + Self { + wpm, + accuracy, + total_chars: stats.total_chars, + correct_chars: stats.correct_chars, + incorrect_chars: stats.incorrect_chars, + total_words: stats.total_words, + correct_words: stats.correct_words, + errors: stats.errors, + time_elapsed_secs: time_elapsed.as_secs(), + error_positions: stats.error_positions.clone(), + current_streak: stats.current_streak, + best_streak: stats.best_streak, + } + } +} + +impl Stats { + pub fn new() -> Self { + Self { + start_time: None, + end_time: None, + error_positions: Vec::new(), + current_streak: 0, + best_streak: 0, + total_chars: 0, + correct_chars: 0, + incorrect_chars: 0, + total_words: 0, + correct_words: 0, + errors: 0, + } + } + + pub fn reset(&mut self) { + self.start_time = None; + self.end_time = None; + self.error_positions.clear(); + self.current_streak = 0; + self.best_streak = 0; + self.total_chars = 0; + self.correct_chars = 0; + self.incorrect_chars = 0; + self.total_words = 0; + self.correct_words = 0; + self.errors = 0; + } + + pub fn start(&mut self) { + self.start_time = Some(Instant::now()); + } + + pub fn update(&mut self, input: &str, target: &str) { + // Recompute everything from scratch for current input + self.error_positions.clear(); + let mut streak = 0; + let mut best_streak_local = 0; + let mut correct_chars = 0usize; + let mut incorrect_chars = 0usize; + let mut total_words = 0usize; + let mut correct_words = 0usize; + + // Tokenize by whitespace to count words + let input_words: Vec<&str> = input.split_whitespace().collect(); + let target_words: Vec<&str> = target.split_whitespace().collect(); + total_words = input_words.len(); + for (iw, tw) in input_words.iter().zip(target_words.iter()) { + if *iw == *tw { correct_words += 1; } + } + + for (i, (input_char, target_char)) in input.chars().zip(target.chars()).enumerate() { + if input_char == target_char { + streak += 1; + correct_chars += 1; + if streak > best_streak_local { best_streak_local = streak; } + } else { + self.error_positions.push(i); + streak = 0; + incorrect_chars += 1; + } + } + + // Extra characters beyond target count as incorrect + if input.len() > target.len() { + incorrect_chars += input.len() - target.len(); + } + + self.total_chars = input.len(); + self.correct_chars = correct_chars; + self.incorrect_chars = incorrect_chars; + self.total_words = total_words; + self.correct_words = correct_words; + self.errors = self.error_positions.len(); + self.current_streak = streak; + self.best_streak = self.best_streak.max(best_streak_local); + } + + pub fn finish(&mut self) { + self.end_time = Some(Instant::now()); + } + + pub fn get_error_positions(&self) -> Vec<usize> { + self.error_positions.clone() + } + + pub fn get_current_streak(&self) -> usize { + self.current_streak + } + + pub fn get_best_streak(&self) -> usize { + self.best_streak + } + + pub fn get_wpm(&self) -> f64 { + let time_elapsed = match (self.start_time, self.end_time) { + (Some(start), Some(end)) => end.duration_since(start), + (Some(start), None) => Instant::now().duration_since(start), + _ => Duration::from_secs(0), + }; + + if time_elapsed.as_secs_f64() > 0.0 { + (self.correct_chars as f64 / 5.0) / (time_elapsed.as_secs_f64() / 60.0) + } else { + 0.0 + } + } + + pub fn get_accuracy(&self) -> f64 { + if self.total_chars > 0 { + (self.correct_chars as f64 / self.total_chars as f64) * 100.0 + } else { + 0.0 + } + } + + pub fn get_time_elapsed(&self) -> Duration { + match (self.start_time, self.end_time) { + (Some(start), Some(end)) => end.duration_since(start), + (Some(start), None) => Instant::now().duration_since(start), + _ => Duration::from_secs(0), + } + } + + pub fn wpm(&self) -> f64 { self.get_wpm() } + pub fn accuracy(&self) -> f64 { self.get_accuracy() } + pub fn elapsed_time(&self) -> std::time::Duration { self.get_time_elapsed() } + pub fn is_running(&self) -> bool { self.start_time.is_some() && self.end_time.is_none() } + pub fn stop(&mut self) { self.end_time = Some(std::time::Instant::now()); } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + + #[test] + fn test_stats_initialization() { + let stats = Stats::new(); + assert_eq!(stats.start_time, None); + assert_eq!(stats.end_time, None); + assert_eq!(stats.error_positions, Vec::new()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); + } + + #[test] + fn test_stats_update() { + let mut stats = Stats::new(); + stats.start_time = Some(Instant::now()); + stats.update("hello", "hello"); + stats.end_time = Some(Instant::now()); + + assert_eq!(stats.start_time, Some(Instant::now())); + assert_eq!(stats.end_time, Some(Instant::now())); + assert_eq!(stats.error_positions, Vec::new()); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + } + + #[test] + fn test_stats_word_counting() { + let mut stats = Stats::new(); + stats.total_words = 2; + stats.correct_words = 2; + + assert_eq!(stats.total_words, 2); + assert_eq!(stats.correct_words, 2); + } +}
\ No newline at end of file diff --git a/crates/core/src/tests/game_tests.rs b/crates/core/src/tests/game_tests.rs new file mode 100644 index 0000000..6bc67ed --- /dev/null +++ b/crates/core/src/tests/game_tests.rs @@ -0,0 +1,143 @@ +use crate::game::{Game, GameMode}; +use crate::text::Text; + +#[test] +fn test_game_initialization() { + let game = Game::new(GameMode::Time(60)); + assert_eq!(game.mode(), GameMode::Time(60)); + assert_eq!(game.is_finished(), false); +} + +#[test] +fn test_game_time_mode() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + // Simulate some typing + game.update("Hello"); + assert_eq!(game.is_finished(), false); + + // Simulate time running out + game.update_time(61); + assert_eq!(game.is_finished(), true); +} + +#[test] +fn test_game_words_mode() { + let mut game = Game::new(GameMode::Words(10)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + // Simulate typing some words + game.update("Hello world!"); + assert_eq!(game.is_finished(), false); + + // Simulate completing all words + game.update("Hello world! This is a test."); + assert_eq!(game.is_finished(), true); +} + +#[test] +fn test_game_reset() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Hello"); + game.reset(); + + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), ""); +} + +#[test] +fn test_game_mode_transition() { + let mut game = Game::new(GameMode::Time(60)); + game.set_mode(GameMode::Words(10)); + assert_eq!(game.mode(), GameMode::Words(10)); +} + +#[test] +fn test_game_text_update() { + let mut game = Game::new(GameMode::Time(60)); + let text1 = Text::new("First text"); + let text2 = Text::new("Second text"); + + game.set_text(text1); + assert_eq!(game.current_text().content(), "First text"); + + game.set_text(text2); + assert_eq!(game.current_text().content(), "Second text"); +} + +#[test] +fn test_game_partial_completion() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), "Hello world!"); +} + +#[test] +fn test_game_error_handling() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Helo world!"); + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), "Helo world!"); +} + +#[test] +fn test_game_time_remaining() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update_time(30); + assert_eq!(game.time_remaining(), 30); +} + +#[test] +fn test_game_words_remaining() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.words_remaining(), 3); +} + +#[test] +fn test_game_progress() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.progress(), 0.4); // 2 out of 5 words completed +} + +#[test] +fn test_game_state_persistence() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Hello"); + let input = game.current_input(); + let is_finished = game.is_finished(); + + game.reset(); + assert_eq!(game.current_input(), ""); + assert_eq!(game.is_finished(), false); + + game.update(input); + assert_eq!(game.current_input(), input); + assert_eq!(game.is_finished(), is_finished); +}
\ No newline at end of file diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs new file mode 100644 index 0000000..f39330c --- /dev/null +++ b/crates/core/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod stats_tests; +mod text_tests; +mod game_tests;
\ No newline at end of file diff --git a/crates/core/src/tests/stats_tests.rs b/crates/core/src/tests/stats_tests.rs new file mode 100644 index 0000000..1b210c8 --- /dev/null +++ b/crates/core/src/tests/stats_tests.rs @@ -0,0 +1,125 @@ +use crate::stats::Stats; +use std::time::Duration; + +#[test] +fn test_stats_initialization() { + let stats = Stats::new(); + assert_eq!(stats.wpm, 0.0); + assert_eq!(stats.accuracy, 100.0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.correct_chars, 0); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_words, 0); + assert_eq!(stats.correct_words, 0); + assert_eq!(stats.errors, 0); + assert_eq!(stats.time_elapsed, Duration::from_secs(0)); + assert!(stats.error_positions.is_empty()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); +} + +#[test] +fn test_stats_start_stop() { + let mut stats = Stats::new(); + assert!(!stats.is_running()); + + stats.start(); + assert!(stats.is_running()); + + stats.stop(); + assert!(!stats.is_running()); +} + +#[test] +fn test_stats_update() { + let mut stats = Stats::new(); + stats.start(); + + // Test perfect typing + stats.update("hello", "hello"); + assert_eq!(stats.correct_chars, 5); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_chars, 5); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + + // Test with errors + stats.update("helo", "hello"); + assert_eq!(stats.correct_chars, 3); + assert_eq!(stats.incorrect_chars, 1); + assert_eq!(stats.total_chars, 4); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 5); +} + +#[test] +fn test_stats_reset() { + let mut stats = Stats::new(); + stats.start(); + stats.update("hello", "hello"); + stats.reset(); + + assert_eq!(stats.wpm, 0.0); + assert_eq!(stats.accuracy, 100.0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.correct_chars, 0); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_words, 0); + assert_eq!(stats.correct_words, 0); + assert_eq!(stats.errors, 0); + assert_eq!(stats.time_elapsed, Duration::from_secs(0)); + assert!(stats.error_positions.is_empty()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); +} + +#[test] +fn test_stats_wpm_calculation() { + let mut stats = Stats::new(); + stats.start(); + + // Type 60 characters (12 words) in 1 minute + stats.update("hello world hello world hello world", "hello world hello world hello world"); + assert_eq!(stats.wpm, 12.0); +} + +#[test] +fn test_stats_accuracy_calculation() { + let mut stats = Stats::new(); + stats.start(); + + // Type 10 characters with 2 errors + stats.update("hello wrld", "hello world"); + assert_eq!(stats.accuracy, 80.0); +} + +#[test] +fn test_stats_streak_tracking() { + let mut stats = Stats::new(); + stats.start(); + + // Test streak building and breaking + stats.update("hello", "hello"); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + + stats.update("helo", "hello"); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 5); + + stats.update("hello", "hello"); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); +} + +#[test] +fn test_stats_error_positions() { + let mut stats = Stats::new(); + stats.start(); + + stats.update("helo", "hello"); + assert_eq!(stats.error_positions, vec![3]); + + stats.update("hllo", "hello"); + assert_eq!(stats.error_positions, vec![1]); +}
\ No newline at end of file diff --git a/crates/core/src/tests/text_tests.rs b/crates/core/src/tests/text_tests.rs new file mode 100644 index 0000000..2964e2a --- /dev/null +++ b/crates/core/src/tests/text_tests.rs @@ -0,0 +1,83 @@ +use crate::text::Text; + +#[test] +fn test_text_creation() { + let content = "Hello, world!"; + let text = Text::new(content); + assert_eq!(text.content(), content); + assert_eq!(text.words().len(), 2); +} + +#[test] +fn test_text_word_count() { + let text = Text::new("Hello world! This is a test."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_empty() { + let text = Text::new(""); + assert_eq!(text.content(), ""); + assert_eq!(text.words().len(), 0); +} + +#[test] +fn test_text_with_special_chars() { + let text = Text::new("Hello, world! This is a test..."); + assert_eq!(text.words().len(), 7); + assert_eq!(text.content(), "Hello, world! This is a test..."); +} + +#[test] +fn test_text_word_boundaries() { + let text = Text::new("Hello-world! This_is_a_test."); + assert_eq!(text.words().len(), 4); +} + +#[test] +fn test_text_multiple_spaces() { + let text = Text::new("Hello world! This is a test."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_numbers() { + let text = Text::new("Hello 123 world! 456 test."); + assert_eq!(text.words().len(), 4); +} + +#[test] +fn test_text_with_punctuation() { + let text = Text::new("Hello, world! This is a test..."); + assert_eq!(text.words().len(), 7); +} + +#[test] +fn test_text_with_mixed_case() { + let text = Text::new("Hello WORLD! This IS a TEST."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_unicode() { + let text = Text::new("Hello 世界! This is a テスト."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_emojis() { + let text = Text::new("Hello 👋 world! This is a test 🎯."); + assert_eq!(text.words().len(), 7); +} + +#[test] +fn test_text_with_tabs() { + let text = Text::new("Hello\tworld!\tThis\tis\ta\ttest."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_newlines() { + let text = Text::new("Hello\nworld!\nThis\nis\na\ntest."); + assert_eq!(text.words().len(), 6); +}
\ No newline at end of file diff --git a/crates/core/src/text.rs b/crates/core/src/text.rs new file mode 100644 index 0000000..0e0e248 --- /dev/null +++ b/crates/core/src/text.rs @@ -0,0 +1,72 @@ +use std::fmt; + +#[derive(Debug, Clone)] +pub struct Text { + pub content: String, + pub source: String, + pub language: String, + pub category: String, +} + +impl Text { + pub fn new() -> Self { + Self { + content: String::new(), + source: String::new(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str(content: &str) -> Self { + Self { + content: content.to_string(), + source: String::new(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str_with_source(content: &str, source: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str_with_language(content: &str, language: &str) -> Self { + Self { + content: content.to_string(), + source: String::new(), + language: language.to_string(), + category: String::new(), + } + } + + pub fn from_str_with_source_and_language(content: &str, source: &str, language: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: language.to_string(), + category: String::new(), + } + } + + pub fn from_all(content: &str, source: &str, language: &str, category: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: language.to_string(), + category: category.to_string(), + } + } +} + +impl fmt::Display for Text { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.content) + } +} +
\ No newline at end of file diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs new file mode 100644 index 0000000..8ebad89 --- /dev/null +++ b/crates/core/src/theme.rs @@ -0,0 +1,24 @@ +#[cfg(feature = "tui")] +use ratatui::style::Color; + +#[cfg(feature = "tui")] +pub struct Theme { + pub background: Color, + pub foreground: Color, + pub accent: Color, + pub error: Color, + pub success: Color, +} + +#[cfg(feature = "tui")] +impl Default for Theme { + fn default() -> Self { + Self { + background: Color::Black, + foreground: Color::White, + accent: Color::Cyan, + error: Color::Red, + success: Color::Green, + } + } +}
\ No newline at end of file diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs new file mode 100644 index 0000000..dc57c9c --- /dev/null +++ b/crates/core/src/types.rs @@ -0,0 +1,148 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Platform { + Desktop, + Web, + Mobile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GameMode { + Normal, + Programming, + Security, + Multiplayer, + Zen, + Time(u64), + Words(usize), + Quote, +} + +impl Default for GameMode { + fn default() -> Self { + GameMode::Normal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Difficulty { + Basic, + Intermediate, + Advanced, + Easy, + Medium, + Hard, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Topic { + General, + Programming, + Security, + DataStructures, + Algorithms, + RedTeam, + BlueTeam, + Gaming, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub mode: GameMode, + pub difficulty: Difficulty, + pub topic: Topic, + pub time_limit: Option<u64>, + pub word_count: Option<usize>, + pub custom_text: Option<String>, + pub multiplayer: bool, + pub quote_length: usize, + pub theme: Theme, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + mode: GameMode::default(), + difficulty: Difficulty::Basic, + topic: Topic::General, + time_limit: None, + word_count: None, + custom_text: None, + multiplayer: false, + quote_length: 1, + theme: Theme::default(), + } + } +} + +pub trait GameModeTrait { + fn get_mode(&self) -> GameMode; + fn get_difficulty(&self) -> Difficulty; + fn get_topic(&self) -> Topic; + fn get_time_limit(&self) -> Option<u64>; + fn get_word_count(&self) -> Option<usize>; + fn get_custom_text(&self) -> Option<&str>; + fn is_multiplayer(&self) -> bool; + fn get_quote_length(&self) -> usize; + fn get_theme(&self) -> &Theme; +} + +impl GameModeTrait for GameConfig { + fn get_mode(&self) -> GameMode { + self.mode + } + + fn get_difficulty(&self) -> Difficulty { + self.difficulty + } + + fn get_topic(&self) -> Topic { + self.topic + } + + fn get_time_limit(&self) -> Option<u64> { + self.time_limit + } + + fn get_word_count(&self) -> Option<usize> { + self.word_count + } + + fn get_custom_text(&self) -> Option<&str> { + self.custom_text.as_deref() + } + + fn is_multiplayer(&self) -> bool { + self.multiplayer + } + + fn get_quote_length(&self) -> usize { + self.quote_length + } + + fn get_theme(&self) -> &Theme { + &self.theme + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Theme { + Light, + Dark, +} + +impl Default for Theme { + fn default() -> Self { + Theme::Dark + } +} + +impl std::fmt::Display for Theme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Theme::Light => write!(f, "light"), + Theme::Dark => write!(f, "dark"), + } + } +}
\ No newline at end of file diff --git a/crates/core/src/ui.rs b/crates/core/src/ui.rs new file mode 100644 index 0000000..f503474 --- /dev/null +++ b/crates/core/src/ui.rs @@ -0,0 +1,187 @@ +#[cfg(feature = "tui")] +use std::io; + +#[cfg(feature = "tui")] +use ratatui::{ + backend::Backend, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +#[cfg(feature = "tui")] +use crate::app::App; + +#[cfg(feature = "tui")] +use ratatui::prelude::{Line, Alignment}; +use crate::app::State; + +pub fn draw(f: &mut Frame, app: &App) { + match app.state { + State::MainMenu => draw_main_menu(f, app), + State::TypingGame => draw_typing_game(f, app), + State::EndScreen => draw_end_screen(f, app), + } +} + +pub fn draw_main_menu(f: &mut Frame, _app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let category_label = { + let cat = _app.selected_category.as_ref().map(String::as_str).unwrap_or("Random"); + format!("Category: {} (←/→ to change)", cat) + }; + let main_menu = vec![ + Line::from(Span::styled("Welcome to Typerpunk!", Style::default().add_modifier(Modifier::BOLD))), + Line::from(Span::styled(category_label, Style::default().fg(Color::Cyan))), + Line::from(Span::styled("Press Enter to Start", Style::default())), + Line::from(Span::styled("Press Esc to Quit", Style::default())), + ]; + + f.render_widget( + Paragraph::new(main_menu) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)), + chunks[0], + ); +} + +pub fn draw_typing_game(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Stats + Constraint::Min(0), // Text + ]) + .split(f.size()); + + // Draw stats + let stats = vec![ + Line::from(vec![ + Span::styled(format!("WPM: {:.1}", app.stats.wpm()), Style::default().fg(Color::Yellow)), + Span::styled(" | ", Style::default()), + Span::styled(format!("Time: {:.1}s", app.stats.elapsed_time().as_secs_f64()), Style::default().fg(Color::Cyan)), + Span::styled(" | ", Style::default()), + Span::styled(format!("Accuracy: {:.1}%", app.stats.accuracy()), Style::default().fg(Color::Green)), + ]), + ]; + + f.render_widget( + Paragraph::new(stats) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)), + chunks[0], + ); + + // Draw text + let text_chars: Vec<char> = app.current_text().content.chars().collect(); + let input_chars: Vec<char> = app.input.chars().collect(); + let mut colored_text: Vec<Span> = Vec::new(); + let cursor_pos = app.input.len(); + + for (i, &c) in text_chars.iter().enumerate() { + let style = if i < input_chars.len() { + if input_chars[i] == c { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + } + } else { + Style::default().fg(Color::Gray) + }; + + let span = if i == cursor_pos { + Span::styled(c.to_string(), style.add_modifier(Modifier::REVERSED)) + } else { + Span::styled(c.to_string(), style) + }; + colored_text.push(span); + } + + // Add any remaining incorrect characters + if input_chars.len() > text_chars.len() { + for &c in &input_chars[text_chars.len()..] { + colored_text.push(Span::styled( + c.to_string(), + Style::default().fg(Color::Red), + )); + } + } + + let text = vec![ + Line::from(Span::styled( + "Type the following text:", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(colored_text), + ]; + + f.render_widget( + Paragraph::new(text) + .alignment(Alignment::Left) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }), + chunks[1], + ); + + // Render attribution beneath the text box + let attribution = app.current_text().source.clone(); + if !attribution.is_empty() { + let area = ratatui::layout::Rect { + x: chunks[1].x, + y: chunks[1].y.saturating_add(chunks[1].height.saturating_sub(2)), + width: chunks[1].width, + height: 2, + }; + let attribution_line = Line::from(Span::styled( + format!("— {}", attribution), + Style::default().fg(Color::Gray), + )); + f.render_widget( + Paragraph::new(vec![attribution_line]) + .alignment(Alignment::Right) + .wrap(Wrap { trim: true }), + area, + ); + } +} + +pub fn draw_end_screen(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let end_screen = vec![ + Line::from(Span::styled("Game Over!", Style::default().add_modifier(Modifier::BOLD))), + Line::from(Span::styled( + format!("Words Per Minute: {:.1}", app.stats.wpm()), + Style::default(), + )), + Line::from(Span::styled( + format!("Time Taken: {:.1} seconds", app.stats.elapsed_time().as_secs_f64()), + Style::default(), + )), + Line::from(Span::styled( + format!("Accuracy: {:.1}%", app.stats.accuracy()), + Style::default(), + )), + Line::from(Span::styled("Press Enter to Play Again", Style::default())), + Line::from(Span::styled("Press Esc to Quit", Style::default())), + ]; + + f.render_widget( + Paragraph::new(end_screen) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)), + chunks[0], + ); +}
\ No newline at end of file diff --git a/crates/core/src/wasm.rs b/crates/core/src/wasm.rs new file mode 100644 index 0000000..c71072e --- /dev/null +++ b/crates/core/src/wasm.rs @@ -0,0 +1,98 @@ +use crate::game::Game; +use crate::types::Theme; + +pub struct TyperPunkGame { + pub game: Game, +} + +impl TyperPunkGame { + pub fn new() -> Self { + Self { + game: Game::new(), + } + } + + pub fn set_text(&mut self, text: String) { + self.game.set_text(text); + } + + pub fn get_text(&self) -> String { + self.game.get_text() + } + + pub fn get_input(&self) -> String { + self.game.get_input().to_string() + } + + pub fn start(&mut self) { + self.game.start(); + } + + pub fn handle_input(&mut self, input: &str) -> Result<(), String> { + self.game.handle_input(input) + } + + pub fn is_finished(&self) -> bool { + self.game.is_finished() + } + + pub fn get_error_positions(&self) -> Vec<usize> { + self.game.get_error_positions() + } + + pub fn get_current_streak(&self) -> u32 { + self.game.get_current_streak() + } + + pub fn get_best_streak(&self) -> u32 { + self.game.get_best_streak() + } + + pub fn get_theme(&self) -> String { + self.game.get_theme().to_string() + } + + pub fn set_theme(&mut self, theme: String) { + let theme = match theme.as_str() { + "light" => Theme::Light, + _ => Theme::Dark, + }; + self.game.set_theme(theme); + } + + pub fn get_wpm(&self) -> f64 { + self.game.get_wpm() + } + + pub fn get_accuracy(&self) -> f64 { + self.game.get_accuracy() + } + + pub fn get_time_elapsed(&self) -> f64 { + self.game.get_time_elapsed() + } + + pub fn can_backspace(&self) -> bool { + self.game.can_backspace() + } + + pub fn can_ctrl_backspace(&self) -> bool { + self.game.can_ctrl_backspace() + } + + pub fn handle_backspace(&mut self, ctrl: bool) -> Result<bool, String> { + self.game.handle_backspace(ctrl) + } + + pub fn get_total_mistakes(&self) -> u32 { + self.game.get_total_mistakes() + } + + pub fn get_stats(&self) -> Result<(f64, u32), String> { + self.game.get_stats() + } + + pub fn get_stats_and_input(&self) -> Result<(String, f64, u32), String> { + self.game.get_stats_and_input() + } +}
\ No newline at end of file diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000..8798269 --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "typerpunk-tui" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license.workspace = true + +[[bin]] +name = "typerpunk" +path = "src/main.rs" + +[features] +default = ["tui"] +tui = ["typerpunk-core/tui"] +multiplayer = ["typerpunk-core/multiplayer"] + +[dependencies] +typerpunk-core = { path = "../core", features = ["tui"] } +crossterm.workspace = true +ratatui.workspace = true +tokio = { workspace = true, features = ["full"] } +anyhow.workspace = true +config.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +dirs.workspace = true +rlua.workspace = true +rand.workspace = true +tokio-tungstenite = { workspace = true, optional = true } +futures-util = { workspace = true, optional = true }
\ No newline at end of file diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 0000000..1819103 --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,90 @@ +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + Terminal, +}; +use std::{io, error::Error as StdError}; +use typerpunk_core::{ + app::App, + input::InputHandler, + ui::draw, +}; + +fn main() -> Result<(), Box<dyn StdError>> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app and run it + let app = match App::new() { + Ok(app) => app, + Err(e) => { + cleanup_terminal(&mut terminal)?; + return Err(e); + } + }; + + let mut input_handler = InputHandler::new(app); + let res = run_app(&mut terminal, &mut input_handler); + + // Restore terminal + cleanup_terminal(&mut terminal)?; + + if let Err(err) = res { + println!("Error: {:?}", err); + } + + Ok(()) +} + +fn cleanup_terminal<B: ratatui::backend::Backend + std::io::Write>(terminal: &mut Terminal<B>) -> io::Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn run_app<B: ratatui::backend::Backend>( + terminal: &mut Terminal<B>, + input_handler: &mut InputHandler, +) -> io::Result<()> { + let mut last_render = std::time::Instant::now(); + let render_interval = std::time::Duration::from_millis(16); // ~60 FPS + + loop { + // Update app state to refresh timers and stats + input_handler.app.update(); + terminal.draw(|f| draw(f, &input_handler.app))?; + + if event::poll(std::time::Duration::from_millis(0))? { + if let Event::Key(key) = event::read()? { + input_handler.app.handle_input(key); + if input_handler.app.should_exit { + return Ok(()); + } + } + } + + // Limit render rate + let now = std::time::Instant::now(); + if now.duration_since(last_render) < render_interval { + std::thread::sleep(render_interval - now.duration_since(last_render)); + } + last_render = now; + + if input_handler.app.should_exit { + return Ok(()); + } + } +}
\ No newline at end of file diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml new file mode 100644 index 0000000..8092566 --- /dev/null +++ b/crates/wasm/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "typerpunk-wasm" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +typerpunk-core = { path = "../core", default-features = false, features = ["wasm", "serde"] } +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +web-sys = { version = "0.3", features = [ + "Document", + "Element", + "HtmlElement", + "Window", + "console", + "HtmlCanvasElement", + "CanvasRenderingContext2d", + "CssStyleDeclaration", + "MouseEvent", + "KeyboardEvent", +] } +serde = { workspace = true } +console_error_panic_hook = "0.1"
\ No newline at end of file diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs new file mode 100644 index 0000000..d17009d --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,213 @@ +use wasm_bindgen::prelude::*; +use typerpunk_core::game::Game; + +// Re-export TyperPunkGame as TyperPunk +pub use typerpunk_core::wasm::TyperPunkGame as TyperPunk; + +#[wasm_bindgen] +pub struct TyperPunkGame { + game: Option<Game>, +} + +#[wasm_bindgen] +impl TyperPunkGame { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + game: Some(Game::new()), + } + } + + #[wasm_bindgen] + pub fn set_text(&mut self, text: &str) -> Result<(), JsValue> { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Create a new owned string and validate UTF-8 + let text_str = match std::str::from_utf8(text.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err(JsValue::from_str("Invalid UTF-8 text")), + }; + + game.set_text(text_str); + Ok(()) + } + + #[wasm_bindgen] + pub fn get_text(&self) -> String { + self.game.as_ref() + .map(|game| game.get_text()) + .unwrap_or_default() + } + + #[wasm_bindgen] + pub fn get_input(&self) -> String { + self.game.as_ref() + .map(|game| game.get_input()) + .unwrap_or_default() + } + + #[wasm_bindgen] + pub fn handle_input(&mut self, input: &str) -> Result<(), JsValue> { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Create a new owned string and validate UTF-8 + let input_str = match std::str::from_utf8(input.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err(JsValue::from_str("Invalid UTF-8 input")), + }; + + // Process input + game.handle_input(&input_str) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + #[wasm_bindgen] + pub fn handle_backspace(&mut self, ctrl: bool) -> Result<bool, JsValue> { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + if !game.can_backspace() { + return Ok(false); + } + + // Check if ctrl+backspace is allowed + if ctrl && !game.can_ctrl_backspace() { + return Ok(false); + } + + // Perform backspace + game.handle_backspace(ctrl) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + #[wasm_bindgen] + pub fn get_stats(&self) -> Result<JsValue, JsValue> { + let game = self.game.as_ref() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + let (accuracy, mistakes) = game.get_stats() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let array = js_sys::Array::new(); + array.push(&JsValue::from_f64(accuracy)); + array.push(&JsValue::from_f64(mistakes as f64)); + Ok(array.into()) + } + + #[wasm_bindgen] + pub fn get_stats_and_input(&self) -> Result<JsValue, JsValue> { + let game = self.game.as_ref() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Get input first to avoid recursive use + let input = game.get_input(); + + // Then get stats + let (_, accuracy, mistakes) = game.get_stats_and_input() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let array = js_sys::Array::new(); + array.push(&JsValue::from_str(&input)); + array.push(&JsValue::from_f64(accuracy)); + array.push(&JsValue::from_f64(mistakes as f64)); + Ok(array.into()) + } + + #[wasm_bindgen] + pub fn is_finished(&self) -> bool { + self.game.as_ref() + .map(|game| { + let input = game.get_input(); + let text = game.get_text(); + // Use the same logic as the core game + !input.is_empty() && + input.len() >= text.len() && + input.trim() == text.trim() + }) + .unwrap_or(false) + } + + #[wasm_bindgen] + pub fn start(&mut self) { + if let Some(game) = &mut self.game { + game.start(); + } + } + + #[wasm_bindgen] + pub fn get_wpm(&self) -> f64 { + self.game.as_ref() + .map(|game| game.get_wpm()) + .unwrap_or(0.0) + } + + #[wasm_bindgen] + pub fn get_time_elapsed(&self) -> f64 { + self.game.as_ref() + .map(|game| game.get_time_elapsed()) + .unwrap_or(0.0) + } + + #[wasm_bindgen] + pub fn free(&mut self) { + self.game = None; + } + + #[wasm_bindgen] + pub fn can_backspace_to_position(&self, position: usize) -> bool { + self.game.as_ref() + .map(|game| { + // This is a simplified check - the actual logic is in the core game + // For now, we'll allow backspace if there are any errors before this position + let input = game.get_input(); + let text = game.get_text(); + + if position >= input.len() { + return false; + } + + // Check if there are any errors before this position + let check_len = position.min(text.len()).min(input.len()); + for i in 0..check_len { + let input_char = input.chars().nth(i); + let text_char = text.chars().nth(i); + if input_char != text_char { + return true; // Found an error, allow backspace + } + } + + false + }) + .unwrap_or(false) + } + + #[wasm_bindgen] + pub fn get_current_word_start(&self) -> usize { + self.game.as_ref() + .map(|game| { + let input = game.get_input(); + let mut word_start = 0; + let mut in_word = false; + + for (i, c) in input.chars().enumerate() { + if c.is_whitespace() { + if in_word { + word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + + word_start + }) + .unwrap_or(0) + } +} + +#[wasm_bindgen] +pub fn init() { + console_error_panic_hook::set_once(); +}
\ No newline at end of file diff --git a/data/packs/general.json b/data/packs/general.json new file mode 100644 index 0000000..4bbee55 --- /dev/null +++ b/data/packs/general.json @@ -0,0 +1,4 @@ +[ + {"category":"general","content":"The quick brown fox jumps over the lazy dog.","attribution":"Traditional pangram"}, + {"category":"general","content":"Simplicity is the soul of efficiency.","attribution":"Austin Freeman"} +] diff --git a/data/packs/literature.json b/data/packs/literature.json new file mode 100644 index 0000000..2c524f3 --- /dev/null +++ b/data/packs/literature.json @@ -0,0 +1,5 @@ +[ + {"category":"literature","content":"It was the best of times, it was the worst of times.","attribution":"Charles Dickens, A Tale of Two Cities"}, + {"category":"literature","content":"All we have to decide is what to do with the time that is given us.","attribution":"J.R.R. Tolkien, The Fellowship of the Ring"}, + {"category":"literature","content":"Not all those who wander are lost.","attribution":"J.R.R. Tolkien"} +] diff --git a/data/packs/programming.json b/data/packs/programming.json new file mode 100644 index 0000000..77c2e12 --- /dev/null +++ b/data/packs/programming.json @@ -0,0 +1,7 @@ +[ + {"category":"programming","content":"Programming is the art of telling another human what one wants the computer to do.","attribution":"Donald Knuth"}, + {"category":"programming","content":"Code is like humor. When you have to explain it, it's bad.","attribution":"Cory House"}, + {"category":"programming","content":"Any fool can write code that a computer can understand. Good programmers write code that humans can understand.","attribution":"Martin Fowler"}, + {"category":"programming","content":"Premature optimization is the root of all evil.","attribution":"Donald Knuth"}, + {"category":"programming","content":"Simple is better than complex. Complex is better than complicated.","attribution":"The Zen of Python"} +] diff --git a/data/packs/quotes.json b/data/packs/quotes.json new file mode 100644 index 0000000..89b45db --- /dev/null +++ b/data/packs/quotes.json @@ -0,0 +1,5 @@ +[ + {"category":"quotes","content":"The only limit to our realization of tomorrow is our doubts of today.","attribution":"Franklin D. Roosevelt"}, + {"category":"quotes","content":"It always seems impossible until it's done.","attribution":"Nelson Mandela"}, + {"category":"quotes","content":"Stay hungry, stay foolish.","attribution":"Steve Jobs"} +] diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..39e2bef --- /dev/null +++ b/install.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print status messages +print_status() { + echo -e "${GREEN}[*]${NC} $1" +} + +# Function to print error messages +print_error() { + echo -e "${RED}[!]${NC} $1" +} + +# Function to print warning messages +print_warning() { + echo -e "${YELLOW}[!]${NC} $1" +} + +# Check if Rust is installed +if ! command -v rustc &> /dev/null; then + print_status "Rust not found. Installing Rust..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" +else + print_status "Rust is already installed" +fi + +# Check if cargo is installed +if ! command -v cargo &> /dev/null; then + print_error "Cargo not found. Please install Rust properly." + exit 1 +fi + +# Try to generate texts.json from packs if npm is available +if command -v npm &> /dev/null; then + print_status "Installing Node.js dependencies (for dataset scripts)..." + npm install || print_warning "npm install failed; continuing without merging packs" + if npm run --silent merge-packs; then + print_status "Merged packs into texts.json" + else + print_warning "merge-packs script failed or missing; using existing texts.json" + fi +else + print_warning "npm not found; skipping dataset pack merge. Ensure texts.json exists at repo root." +fi + +# Build the TUI version +print_status "Building the TUI version..." +cargo build --release --package typerpunk-tui --features tui + +if [ $? -eq 0 ]; then + print_status "Build successful! The TUI executable is located in target/release/typerpunk" + print_status "You can run it with: ./target/release/typerpunk" +else + print_error "Build failed. Please check the error messages above." + exit 1 +fi + +print_status "Installation complete!"
\ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d139f6 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "scripts": { + "extract-texts": "node scripts/extract_texts.js", + "merge-packs": "node scripts/merge_packs.js" + }, + "devDependencies": { + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.3", + "@types/node": "^20.19.0" + }, + "dependencies": { + "chart.js": "^4.5.0", + "react-chartjs-2": "^5.3.0", + "cheerio": "^1.0.0-rc.12", + "glob": "^10.3.10" + } +} diff --git a/scripts/extract_texts.js b/scripts/extract_texts.js new file mode 100644 index 0000000..acd5660 --- /dev/null +++ b/scripts/extract_texts.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/* + Extract paragraphs from mirrored sites under similar/ to build a large texts.json. + - Scans HTML files in similar/play.typeracer.com, similar/monkeytype.com, etc. + - Extracts visible text nodes, splits into paragraphs, filters by length. + - Deduplicates and shuffles, attaches category from source directory, and attribution as the source path. + - Writes to repo-root texts.json for both CLI and Web to use. +*/ +const fs = require('fs'); +const path = require('path'); +const { glob } = require('glob'); +const cheerio = require('cheerio'); + +const ROOT = path.resolve(__dirname, '..'); +const SIMILAR_DIR = path.join(ROOT, 'similar'); +const OUTPUT = path.join(ROOT, 'texts.json'); + +function isLikelyVisibleText(text) { + const t = text.replace(/\s+/g, ' ').trim(); + if (!t) return false; + if (t.length < 60) return false; // avoid too-short snippets + // avoid nav/footer boilerplate + if (/©|copyright|cookie|privacy|terms|policy|subscribe|sign in|login|menu|footer|header/i.test(t)) return false; + return true; +} + +function splitIntoParagraphs(text) { + // Split by double newline or sentence groups + const blocks = text + .split(/\n\s*\n|\r\n\r\n/) + .map(s => s.replace(/\s+/g, ' ').trim()) + .filter(Boolean); + const paras = []; + for (const b of blocks) { + // Further chunk into 80-350 char ranges + if (b.length <= 400) { + paras.push(b); + } else { + let start = 0; + while (start < b.length) { + let end = Math.min(start + 350, b.length); + // try to cut at sentence boundary + const slice = b.slice(start, end); + const lastPeriod = slice.lastIndexOf('. '); + const lastComma = slice.lastIndexOf(', '); + const cut = lastPeriod > 150 ? lastPeriod + 1 : (lastComma > 150 ? lastComma + 1 : slice.length); + paras.push(slice.slice(0, cut).trim()); + start += cut; + } + } + } + return paras; +} + +(async () => { + try { + const htmlFiles = await glob('**/*.html', { cwd: SIMILAR_DIR, absolute: true, dot: false, nodir: true }); + const items = []; + const seen = new Set(); + + for (const file of htmlFiles) { + const rel = path.relative(SIMILAR_DIR, file); + const parts = rel.split(path.sep); + const category = parts[0]?.replace(/\W+/g, '').toLowerCase() || 'general'; + const attribution = `similar/${rel}`; + + const html = fs.readFileSync(file, 'utf8'); + const $ = cheerio.load(html); + + // Remove script/style/nav/footer elements + $('script, style, nav, footer, header, noscript').remove(); + // Collect text from paragraphs and common content containers + const textBits = []; + $('p, article, main, section, .content, .text, .article, .post').each((_, el) => { + const t = $(el).text(); + if (isLikelyVisibleText(t)) textBits.push(t); + }); + + const combined = textBits.join('\n\n'); + if (!combined.trim()) continue; + + const paras = splitIntoParagraphs(combined) + .map(s => s.replace(/\s+/g, ' ').trim()) + .filter(s => s.length >= 80 && s.length <= 400); + + for (const content of paras) { + const key = content.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + items.push({ category, content, attribution }); + } + } + + // Shuffle + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + + // If not enough, keep existing texts.json and merge + let existing = []; + if (fs.existsSync(OUTPUT)) { + try { existing = JSON.parse(fs.readFileSync(OUTPUT, 'utf8')); } catch {} + } + const merged = [...items, ...existing].slice(0, 5000); // cap to 5k entries + + fs.writeFileSync(OUTPUT, JSON.stringify(merged, null, 2)); + console.log(`Wrote ${merged.length} texts to ${OUTPUT}`); + } catch (err) { + console.error('extract_texts failed:', err); + process.exit(1); + } +})(); diff --git a/scripts/merge_packs.js b/scripts/merge_packs.js new file mode 100644 index 0000000..01bf412 --- /dev/null +++ b/scripts/merge_packs.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/* + Merge all JSON packs from data/packs/*.json into texts.json at repo root. + Each pack item: { category: string, content: string, attribution: string } +*/ +const fs = require('fs'); +const path = require('path'); +const { glob } = require('glob'); + +const ROOT = path.resolve(__dirname, '..'); +const PACKS_DIR = path.join(ROOT, 'data', 'packs'); +const OUTPUT = path.join(ROOT, 'texts.json'); + +(async () => { + try { + const files = await glob('*.json', { cwd: PACKS_DIR, absolute: true }); + const items = []; + const seen = new Set(); + + for (const file of files) { + const pack = JSON.parse(fs.readFileSync(file, 'utf8')); + for (const item of pack) { + if (!item || !item.content) continue; + const key = `${item.category || ''}\u0000${item.content.trim().toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + items.push({ + category: String(item.category || 'general'), + content: String(item.content), + attribution: String(item.attribution || path.relative(ROOT, file)), + }); + } + } + + // Shuffle + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + + fs.writeFileSync(OUTPUT, JSON.stringify(items, null, 2)); + console.log(`Merged ${items.length} items into ${OUTPUT}`); + } catch (err) { + console.error('merge_packs failed:', err); + process.exit(1); + } +})(); diff --git a/sentences.txt b/sentences.txt deleted file mode 100644 index 7288284..0000000 --- a/sentences.txt +++ /dev/null @@ -1,3 +0,0 @@ -The quick brown fox jumps over the lazy dog. -In the beginning God created the heavens and the earth. -To be, or not to be, that is the question:
\ No newline at end of file diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 9c529f0..0000000 --- a/src/main.rs +++ /dev/null @@ -1,320 +0,0 @@ -// Import necessary crates -use crossterm::{ - event::{self, KeyCode, KeyEvent}, - terminal::{disable_raw_mode, enable_raw_mode}, -}; -use rand::Rng; -use std::{ - fs, - io::{self}, - path::Path, - sync::{Arc, Mutex}, - time::Instant, -}; -use tui::{ - backend::{Backend, CrosstermBackend}, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Span, Spans}, - widgets::Paragraph, - Frame, Terminal, -}; - -// Define the possible states of the application -#[derive(Debug, Clone, Copy, PartialEq)] -enum State { - MainMenu, - TypingGame, - EndScreen, -} - -// Struct to hold the application state -struct App { - time_taken: u64, - input_string: String, - timer: Option<Instant>, - state: State, - should_exit: bool, - sentences: Vec<String>, - current_sentence_index: usize, -} - -impl App { - // Constructor to create a new instance of the application - fn new() -> Result<Self, io::Error> { - let sentences = read_sentences("sentences.txt")?; - let current_sentence_index = rand::thread_rng().gen_range(0..sentences.len()); - let app = App { - time_taken: 0, - input_string: String::new(), - timer: None, - state: State::MainMenu, - should_exit: false, - sentences, - current_sentence_index, - }; - Ok(app) - } - - // Reset the game to its initial state - fn reset(&mut self) { - let current_sentence_index = rand::thread_rng().gen_range(0..self.sentences.len()); - self.current_sentence_index = current_sentence_index; - self.time_taken = 0; - self.input_string.clear(); - self.timer = None; - self.state = State::TypingGame; - } - - // Get the current sentence the user needs to type - fn current_sentence(&self) -> &str { - if let Some(sentence) = self.sentences.get(self.current_sentence_index) { - sentence - } else { - "No sentence available" - } - } - - // Start the timer - fn start_timer(&mut self) { - if self.timer.is_none() { - self.timer = Some(Instant::now()); - } - } - - // Update the timer - fn update_timer(&mut self) { - if let Some(timer) = self.timer { - self.time_taken = timer.elapsed().as_secs(); - } - } - - // Calculate and return the current typing speed (Words Per Minute) - fn update_wpm(&self) -> f64 { - let time_elapsed = self.time_taken as f64; - if time_elapsed == 0.0 { - 0.0 - } else { - let wpm = (self.input_string.split_whitespace().count() as f64) / (time_elapsed / 60.0); - if wpm.is_nan() { - 0.0 - } else { - wpm - } - } - } -} - -// Function to read sentences from a file -fn read_sentences(filename: &str) -> Result<Vec<String>, io::Error> { - if !Path::new(filename).exists() { - return Err(io::Error::new(io::ErrorKind::NotFound, "File not found")); - } - - let contents = fs::read_to_string(filename)?; - let sentences: Vec<String> = contents.lines().map(|s| s.to_string()).collect(); - Ok(sentences) -} - -// Function to draw the typing game UI -fn draw_typing_game(f: &mut Frame<CrosstermBackend<std::io::Stdout>>, chunk: Rect, app: &mut App) { - let wpm = app.update_wpm(); - let time_used = app.time_taken as f64; - - let mut colored_text: Vec<Span> = Vec::new(); - let cursor_pos = app.input_string.len(); - - // Iterate over each character in the current sentence and color it based on user input - for (index, c) in app.current_sentence().chars().enumerate() { - let color = if let Some(input_char) = app.input_string.chars().nth(index) { - if c == input_char { - Color::Green - } else { - Color::Red - } - } else { - Color::Gray - }; - - // cursor - let span = if index == cursor_pos { - Span::styled( - c.to_string(), - Style::default().fg(color).add_modifier(Modifier::REVERSED), - ) - } else { - Span::styled(c.to_string(), Style::default().fg(color)) - }; - colored_text.push(span); - } - - let text = vec![ - Spans::from(Span::styled( - "Type the following sentence:", - Style::default().add_modifier(Modifier::BOLD), - )), - Spans::from(colored_text), - Spans::from(Span::styled(format!("WPM: {:.2}", wpm), Style::default())), - Spans::from(Span::styled( - format!("Time: {:.1} seconds", time_used), - Style::default(), - )), - ]; - - // Render the widget - f.render_widget(Paragraph::new(text).alignment(Alignment::Center), chunk); - - app.update_timer(); -} - -// Function to handle user input events -async fn input_handler(event: KeyEvent, app: &mut App, _event_tx: Arc<Mutex<()>>) { - match event.code { - KeyCode::Char(c) => { - if app.timer.is_none() { - app.timer = Some(Instant::now()); - } - app.input_string.push(c); - } - KeyCode::Backspace => { - app.input_string.pop(); - } - KeyCode::Esc => match app.state { - State::MainMenu => { - app.should_exit = true; - } - State::TypingGame | State::EndScreen => { - app.state = State::MainMenu; - app.input_string.clear(); - app.timer = None; - } - }, - KeyCode::Enter => match app.state { - State::MainMenu => { - app.state = State::TypingGame; - app.start_timer(); - app.input_string.clear(); - } - State::TypingGame => { - if app.input_string.trim() == app.current_sentence().trim() { - app.state = State::EndScreen; - app.update_timer(); - } - } - State::EndScreen => { - app.reset(); - } - }, - _ => {} - } -} - -// Include test module -#[cfg(test)] -mod test; - -// Main function -#[tokio::main] -async fn main() -> Result<(), io::Error> { - // Enable raw mode for terminal input - enable_raw_mode()?; - - // Create a new instance of the App - let mut app = App::new().unwrap(); - - // Initialize the terminal backend - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Clear the terminal and hide the cursor - terminal.clear()?; - terminal.hide_cursor()?; - - // Main event loop - loop { - // Get the terminal size - if let Ok(size) = terminal.backend().size() { - // Define layout for the UI - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Min(3), - Constraint::Percentage(70), - Constraint::Min(3), - ]) - .split(size); - - // Draw UI based on app state - terminal.draw(|f| match app.state { - State::MainMenu => { - let main_menu = vec![ - Spans::from(Span::styled("Welcome to typerpunk!", Style::default())), - Spans::from(Span::styled("Press Enter to Start", Style::default())), - Spans::from(Span::styled("Press Esc to Quit", Style::default())), - ]; - f.render_widget( - Paragraph::new(main_menu).alignment(Alignment::Center), - chunks[0], - ); - } - State::TypingGame => { - draw_typing_game(f, chunks[1], &mut app); - } - State::EndScreen => { - let wpm = app.update_wpm(); - let time_taken = app.time_taken as f64; - let end_screen = vec![ - Spans::from(Span::styled("Game Over!", Style::default())), - Spans::from(Span::styled( - format!("Words Per Minute: {:.2}", wpm), - Style::default(), - )), - Spans::from(Span::styled( - format!("Time Taken: {:.1} seconds", time_taken), - Style::default(), - )), - Spans::from(Span::styled("Press Enter to Play Again", Style::default())), - Spans::from(Span::styled("Press Esc to Quit", Style::default())), - ]; - f.render_widget( - Paragraph::new(end_screen).alignment(Alignment::Center), - chunks[1], - ); - } - })?; - - // Handle input events - if let event::Event::Key(event) = event::read()? { - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - } - - // Check if the app should exit - if app.should_exit { - break; - } - } - } - - // Cleanup: Show cursor, disable raw mode, and clear only the game UI - terminal.show_cursor()?; - disable_raw_mode()?; - let size = terminal.backend().size().unwrap(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Min(3), - Constraint::Percentage(70), - Constraint::Min(3), - ]) - .split(size); - terminal - .draw(|f| f.render_widget(Paragraph::new("").alignment(Alignment::Center), chunks[1]))?; - - // Manually clear the terminal before exiting - println!("\x1B[2J\x1B[1;1H"); - - Ok(()) -} diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index 7f2728e..0000000 --- a/src/test.rs +++ /dev/null @@ -1,164 +0,0 @@ -// Import necessary items for testing -use crate::{draw_typing_game, input_handler, read_sentences, App, State}; -use crossterm::event::{KeyCode, KeyEvent}; -use std::{ - io, - sync::{Arc, Mutex}, -}; -use tui::layout::Rect; -use tui::{backend::CrosstermBackend, Terminal}; - -#[cfg(test)] -#[allow(unused_imports)] // Ignore unused import warnings within the test module -mod tests { - use super::*; - - #[test] - fn test_new_app() { - // Test that a new App initializes correctly - let app_result = App::new(); - assert!(app_result.is_ok()); - let app = app_result.unwrap(); - assert_eq!(app.input_string, ""); - assert_eq!(app.time_taken, 0); - assert_eq!(app.timer, None); - assert_eq!(app.state, State::MainMenu); - assert_eq!(app.should_exit, false); - assert!(!app.sentences.is_empty()); - assert!(app.current_sentence_index < app.sentences.len()); - } - - #[test] - fn test_reset_app() { - // Test the reset functionality of App - let mut app = App::new().unwrap(); - app.current_sentence_index = 0; // Set initial index to 0 - let old_index = app.current_sentence_index; - println!( - "Before reset: old_index = {}, current_sentence_index = {}", - old_index, app.current_sentence_index - ); - - app.time_taken = 100; - app.input_string = "Some input".to_string(); - app.reset(); - - println!( - "After reset: old_index = {}, current_sentence_index = {}", - old_index, app.current_sentence_index - ); - - // Check that current_sentence_index changes after reset - assert_ne!(app.current_sentence_index, old_index); - assert_eq!(app.time_taken, 0); - assert_eq!(app.input_string, ""); - assert_eq!(app.timer, None); - assert_eq!(app.state, State::TypingGame); - } - - #[test] - fn test_current_sentence() { - // Test the retrieval of the current sentence - let app = App::new().unwrap(); - let current_sentence = app.current_sentence(); - assert!(!current_sentence.is_empty()); - } - - #[test] - fn test_start_timer() { - // Test starting the timer - let mut app = App::new().unwrap(); - app.start_timer(); - assert!(app.timer.is_some()); - } - - #[test] - fn test_update_timer() { - // Test updating the timer - let mut app = App::new().unwrap(); - app.start_timer(); - let initial_time = app.time_taken; - std::thread::sleep(std::time::Duration::from_secs(1)); - app.update_timer(); - assert!(app.time_taken > initial_time); - } - - #[test] - fn test_update_wpm() { - // Test updating words per minute calculation - let mut app = App::new().unwrap(); - app.time_taken = 60; // 1 minute - app.input_string = "This is a test sentence".to_string(); - let wpm = app.update_wpm(); - assert_eq!(wpm, 5.0); // 5 words per minute for this sentence - } - - #[test] - fn test_read_sentences() { - // Test reading sentences from a file - let sentences_result = read_sentences("sentences.txt"); - assert!(sentences_result.is_ok()); - let sentences = sentences_result.unwrap(); - assert!(!sentences.is_empty()); - } - - #[test] - fn test_draw_typing_game() { - // Test drawing the typing game UI - let mut app = App::new().unwrap(); - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend).unwrap(); - let size = Rect::default(); - draw_typing_game(&mut terminal.get_frame(), size, &mut app); - // No need to assert anything here since it's a rendering function - } - - #[tokio::test] - async fn test_input_handler() { - // Test the input handler function - let mut app = App::new().unwrap(); - - // Simulate typing 'a' - let event = KeyEvent::from(KeyCode::Char('a')); - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.input_string, "a"); - - // Simulate typing 'b' - let event = KeyEvent::from(KeyCode::Char('b')); - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.input_string, "ab"); - - // Simulate pressing Backspace - let event = KeyEvent::from(KeyCode::Backspace); - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.input_string, "a"); - - // Simulate pressing Enter in MainMenu state - let event = KeyEvent::from(KeyCode::Enter); - app.state = State::MainMenu; - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.state, State::TypingGame); - assert_eq!(app.input_string, ""); - - // Simulate typing 'T' in TypingGame state - let event = KeyEvent::from(KeyCode::Char('T')); - app.state = State::TypingGame; - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.input_string, "T"); - - // Simulate completing sentence and pressing Enter - let sentence = app.current_sentence().to_string(); - app.input_string = sentence.clone(); - let event = KeyEvent::from(KeyCode::Enter); - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.state, State::EndScreen); - assert_eq!(app.input_string.trim(), sentence.trim()); - - // Simulate pressing Esc in EndScreen state - let event = KeyEvent::from(KeyCode::Esc); - app.state = State::EndScreen; - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.should_exit, true); - } -} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e011e2d --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html lang="en" data-theme="light"> +<head> + <meta charset="UTF-8"> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>TyperPunk - Typing Test</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"> +</head> +<body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> +</body> +</html>
\ No newline at end of file diff --git a/web/launch.sh b/web/launch.sh new file mode 100755 index 0000000..8b2fd92 --- /dev/null +++ b/web/launch.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color +YELLOW='\033[0;33m' + +echo -e "${BLUE}Starting TyperPunk Web...${NC}" + +# Resolve directories +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="${SCRIPT_DIR}/.." + +# Ensure wasm-pack is available (auto-install if possible) +if ! command -v wasm-pack &> /dev/null; then + echo -e "${YELLOW}wasm-pack not found. Attempting to install via cargo...${NC}" + if command -v cargo &> /dev/null; then + cargo install wasm-pack || { + echo -e "${RED}Failed to install wasm-pack automatically. Please install manually: cargo install wasm-pack${NC}" + exit 1 + } + else + echo -e "${RED}Rust cargo is not installed. Please install Rust and then run: cargo install wasm-pack${NC}" + exit 1 + fi +fi + +# Ensure dataset exists by merging packs at repo root (best-effort) +if command -v npm &> /dev/null; then + echo "Ensuring dataset (texts.json) exists by merging packs..." + (cd "$ROOT_DIR" && npm install && npm run --silent merge-packs) \ + && echo "Merged packs into texts.json" \ + || echo -e "${YELLOW}Warning:${NC} Could not merge packs; continuing with existing texts.json" +else + echo -e "${YELLOW}Warning:${NC} npm not found; skipping dataset merge. Ensure texts.json exists at repo root." +fi + +# Build the WASM module +echo "Building WASM module..." +cd "$ROOT_DIR/crates/wasm" + +# Clean previous build +rm -rf pkg target + +# Build with wasm-pack +wasm-pack build --target web --release + +# Check if build was successful +if [ $? -ne 0 ]; then + echo -e "${RED}Failed to build WASM module${NC}" + exit 1 +fi + +cd "$SCRIPT_DIR" + +# Clean previous build +rm -rf dist node_modules/.vite + +# Copy shared texts.json into web/src/data if present +mkdir -p src/data +if [ -f "$ROOT_DIR/texts.json" ]; then + cp "$ROOT_DIR/texts.json" src/data/texts.json + echo "Copied shared texts.json into web/src/data/" +else + echo -e "${YELLOW}Warning:${NC} ../texts.json not found. Using fallback web/src/data/texts.json" +fi + +# Install dependencies +echo "Installing dependencies..." +npm install + +# Type check +echo "Type checking..." +npm run type-check + +# Start the development server +echo -e "${GREEN}Starting development server...${NC}" +echo -e "${GREEN}Website will be available at: http://localhost:3000${NC}" +npm run dev diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..76fff94 --- /dev/null +++ b/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "typerpunk-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "type-check": "tsc --noEmit", + "build:wasm": "cd ../crates/wasm && wasm-pack build --target web" + }, + "dependencies": { + "@typerpunk/wasm": "file:../crates/wasm/pkg", + "@types/react-router-dom": "^5.3.3", + "chart.js": "^4.5.0", + "react": "^18.2.0", + "react-chartjs-2": "^5.3.0", + "react-dom": "^18.2.0", + "react-router-dom": "^7.5.3" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "@types/react": "^18.2.56", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", + "@vitejs/plugin-react": "^4.5.1", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vite-plugin-top-level-await": "^1.4.1", + "vite-plugin-wasm": "^3.3.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..b34c4b4 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,436 @@ +import { useEffect, useState } from 'react'; +import { TypingGame } from './components/TypingGame'; +import MainMenu from './components/MainMenu'; +import { EndScreen } from './components/EndScreen'; +import { GameState, Stats } from './types'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { useCoreGame } from './hooks/useCoreGame'; +import './styles.css'; +import textsData from './data/texts.json'; + +type TextItem = { category: string; content: string; attribution: string }; +const LOCAL_TEXTS: TextItem[] = Array.isArray(textsData) ? (textsData as TextItem[]) : []; +function uniqueCategories(items: TextItem[]): string[] { + const set = new Set<string>(); + for (const t of items) if (t.category) set.add(t.category); + return Array.from(set).sort(); +} +function pickRandom<T>(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function getRandomTextItemFrom(items: TextItem[], category?: string): TextItem { + const pool = category && category !== 'random' + ? items.filter(t => t.category === category) + : items; + if (!pool.length) return { category: 'general', content: "The quick brown fox jumps over the lazy dog.", attribution: 'Traditional pangram' }; + return pickRandom(pool); +} + +function calculateStats(input: string, text: string, elapsedTime: number): Stats { + let correct = 0; + let incorrect = 0; + let currentStreak = 0; + let bestStreak = 0; + let totalErrors = 0; + let hasStartedTyping = input.length > 0; + + // Calculate raw WPM using MonkeyType's approach + const windowSize = 0.5; // 500ms window for more granular peak detection + let maxWpm = 0; + let windowChars = 0; + let windowStartTime = 0; + + // Track character timings and errors + const charTimings: { time: number; isCorrect: boolean }[] = []; + for (let i = 0; i < input.length; i++) { + const charTime = (i / input.length) * elapsedTime; + const isCorrect = input[i] === text[i]; + charTimings.push({ time: charTime, isCorrect }); + + // Update window + while (charTime - windowStartTime > windowSize && charTimings.length > 0) { + if (charTimings[0].isCorrect) { + windowChars--; + } + windowStartTime = charTimings[1]?.time ?? charTime; + charTimings.shift(); + } + + if (isCorrect) { + windowChars++; + const windowTime = charTime - windowStartTime; + if (windowTime > 0) { + const windowWpm = (windowChars / 5) / (windowTime / 60); + maxWpm = Math.max(maxWpm, windowWpm); + } + } + } + + const rawWpm = maxWpm; // Peak typing speed + + // Calculate other stats + let totalTyped = 0; + for (let i = 0; i < input.length; i++) { + totalTyped++; + if (i < text.length && input[i] === text[i]) { + correct++; + currentStreak++; + bestStreak = Math.max(bestStreak, currentStreak); + } else { + incorrect++; + totalErrors++; + currentStreak = 0; + } + } + + const totalChars = text.length; + // Only show 100% accuracy before typing starts + const accuracy = !hasStartedTyping ? 100 : Math.max(0, Math.min(100, (correct / totalTyped) * 100)); + const wpm = elapsedTime === 0 ? 0 : (correct / 5) / (elapsedTime / 60); + + return { + wpm, + rawWpm, + accuracy, + time: elapsedTime, + correctChars: correct, + incorrectChars: totalErrors, + totalChars, + currentStreak, + bestStreak, + }; +} + +export type Screen = 'main-menu' | 'typing-game' | 'end-screen'; + +function App() { + const { game, resetGame, cleanupGame } = useCoreGame(); + const [allTexts, setAllTexts] = useState<TextItem[]>(LOCAL_TEXTS); + const [categories, setCategories] = useState<string[]>(uniqueCategories(LOCAL_TEXTS)); + const [selectedCategory, setSelectedCategory] = useState<string>('random'); + const [gameState, setGameState] = useState<GameState>({ + screen: 'main-menu', + currentText: '', + input: '', + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + }); + const [gameKey, setGameKey] = useState<number>(0); // For remounting TypingGame + // Track last WPM history only for end-screen payloads (retain local var at finish) + const testStats: Stats = { + wpm: 85, rawWpm: 90, accuracy: 95, time: 60, + correctChars: 425, incorrectChars: 21, totalChars: 446, + currentStreak: 50, bestStreak: 100 + }; + const testWpmHistory = Array.from({ length: 60 }, (_, i) => ({ + time: i, wpm: 80 + Math.sin(i / 5) * 10, raw: 85 + Math.sin(i / 5) * 10, + isError: Math.random() > 0.95 + })); + const testText = "This is a test sentence for the end screen. It has some text to display and check for errors."; + const testUserInput = "This is a test sentance for the end screen. It has sone text to display and check for erors."; + const _testCharTimings = testText.split('').map((char, i) => ({ + time: (i / testText.length) * 60, + isCorrect: char === (testUserInput[i] || ''), + char: char, + index: i + })); + const [lastTest, setLastTest] = useState<{ stats: Stats; wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>; text: string; userInput: string; charTimings?: Array<{ time: number; isCorrect: boolean; char: string; index: number }>; keypressHistory?: Array<{ time: number; index: number; isCorrect: boolean }> } | null>(null); + + // Removed unused Enter key end-screen toggle handler + + // Optional online dataset fetch (fallback to local). Configure URL via window.TYPERPUNK_TEXTS_URL. + useEffect(() => { + const url = (window as any).TYPERPUNK_TEXTS_URL as string | undefined; + if (!url) return; // no online dataset configured + (async () => { + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data)) { + setAllTexts(data as TextItem[]); + setCategories(uniqueCategories(data as TextItem[])); + } + } catch {} + })(); + }, []); + + const handleStartGame = async () => { + try { + // Reset game state first + const item = getRandomTextItemFrom(allTexts, selectedCategory); + setGameState((prev: GameState) => ({ + ...prev, + screen: 'typing-game', + currentText: item.content, + currentAttribution: item.attribution, + input: '', + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + })); + + // Then reset WASM game instance + await resetGame(); + setGameKey((k: number) => k + 1); // Force remount + + // Ensure focus after a short delay + setTimeout(() => { + const inputElement = document.querySelector('.typing-input') as HTMLInputElement; + if (inputElement) { + inputElement.disabled = false; + inputElement.focus(); + } + }, 200); + } catch (err) { + console.error('Error starting game:', err); + // If start fails, stay in main menu + setGameState((prev: GameState) => ({ + ...prev, + screen: 'main-menu', + })); + } + }; + + const handleResetGame = async () => { + try { + // Reset game state first + const item = getRandomTextItemFrom(allTexts, selectedCategory); + setGameState((prev: GameState) => ({ + ...prev, + screen: 'typing-game', + currentText: item.content, + currentAttribution: item.attribution, + input: '', + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + })); + + // Then reset WASM game instance + await resetGame(); + setGameKey((k: number) => k + 1); // Force remount + + // Ensure focus after a short delay + setTimeout(() => { + const inputElement = document.querySelector('.typing-input') as HTMLInputElement; + if (inputElement) { + inputElement.disabled = false; + inputElement.focus(); + } + }, 200); + } catch (err) { + console.error('Error resetting game:', err); + // If reset fails, go back to main menu + handleMainMenu(); + } + }; + + const handleInput = (input: string, accuracy: number, mistakes: number) => { + setGameState((prev: GameState) => { + // Don't update if input hasn't changed + if (prev.input === input) { + return prev; + } + + const newState = { + ...prev, + input, + stats: { + ...prev.stats, + accuracy, + incorrectChars: mistakes + } + }; + + // Only update running state and start time once + if (!prev.isRunning) { + newState.isRunning = true; + newState.startTime = Date.now(); + } + + // Check if the game is finished using WASM game's is_finished method + if (game && game.is_finished() && prev.screen === 'typing-game') { + newState.isRunning = false; + newState.screen = 'end-screen'; + // Calculate final stats but preserve WASM accuracy and mistakes + const elapsedTime = (Date.now() - (newState.startTime || Date.now())) / 1000; + const stats = calculateStats(input, prev.currentText, elapsedTime); + // Preserve WASM accuracy and mistakes instead of recalculating + stats.accuracy = accuracy; + stats.incorrectChars = mistakes; + newState.stats = stats; + } + + return newState; + }); + }; + + const handleFinish = ( + finalStats: Stats, + wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>, + userInput: string, + charTimings: Array<{ time: number; isCorrect: boolean; char: string; index: number }>, + keypressHistory: Array<{ time: number; index: number; isCorrect: boolean }> + ) => { + setLastTest({ stats: finalStats, wpmHistory, text: gameState.currentText, userInput, charTimings, keypressHistory }); + setGameState(prev => ({ ...prev, isRunning: false, screen: 'end-screen' })); + }; + + const handleMainMenu = async () => { + try { + // Reset game state first + setGameState((prev: GameState) => ({ + ...prev, + screen: 'main-menu', + input: '', + currentText: '', + currentAttribution: undefined, + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + })); + + // Then cleanup WASM game instance + cleanupGame(); + setGameKey((k: number) => k + 1); // Force remount + } catch (err) { + console.error('Error going to main menu:', err); + // If cleanup fails, still try to go to main menu + setGameState((prev: GameState) => ({ + ...prev, + screen: 'main-menu', + input: '', + currentText: '', + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + })); + } + }; + + useEffect(() => { + let interval: ReturnType<typeof setInterval>; + if (gameState.isRunning && gameState.screen === 'typing-game' && gameState.startTime) { + interval = setInterval(() => { + setGameState((prev: GameState) => { + if (!prev.isRunning || !prev.startTime) return prev; + + const elapsedTime = (Date.now() - prev.startTime) / 1000; + const stats = calculateStats(prev.input, prev.currentText, elapsedTime); + + // Only update if stats have changed significantly + const hasSignificantChange = + Math.abs(prev.stats.wpm - stats.wpm) > 0.1 || + Math.abs(prev.stats.rawWpm - stats.rawWpm) > 0.1 || + Math.abs(prev.stats.time - stats.time) > 0.1; + + if (!hasSignificantChange) { + return prev; + } + + return { + ...prev, + stats, + }; + }); + }, 100); + } + return () => { + if (interval) clearInterval(interval); + }; + }, [gameState.isRunning, gameState.screen, gameState.startTime]); + + return ( + <ThemeProvider> + <div className="app"> + {gameState.screen === 'main-menu' ? ( + <MainMenu + onStartGame={handleStartGame} + categories={categories} + selectedCategory={selectedCategory} + onSelectCategory={setSelectedCategory} + /> + ) : gameState.screen === 'end-screen' ? ( + <EndScreen + stats={lastTest?.stats || testStats} + wpmHistory={lastTest?.wpmHistory || testWpmHistory} + text={lastTest?.text || testText} + userInput={lastTest?.userInput || testUserInput} + charTimings={lastTest?.charTimings} + keypressHistory={lastTest?.keypressHistory} + onPlayAgain={handleResetGame} + onMainMenu={handleMainMenu} + /> + ) : ( + <TypingGame + key={gameKey} + game={game as any} + text={gameState.currentText} + input={gameState.input} + stats={gameState.stats} + attribution={gameState.currentAttribution} + onInput={handleInput} + onFinish={handleFinish} + onReset={handleResetGame} + onMainMenu={handleMainMenu} + /> + )} + </div> + </ThemeProvider> + ); +} + +export default App;
\ No newline at end of file diff --git a/web/src/components/EndScreen.tsx b/web/src/components/EndScreen.tsx new file mode 100644 index 0000000..3c70e95 --- /dev/null +++ b/web/src/components/EndScreen.tsx @@ -0,0 +1,686 @@ +import { useEffect, useRef, useState, FC } from 'react'; +import { useTheme } from '../contexts/ThemeContext'; +import { Stats, Theme } from '../types'; +import { Line } from 'react-chartjs-2'; +import { + Chart as ChartJS, + LineElement, + PointElement, + LinearScale, + Title, + Tooltip, + Legend, + CategoryScale, +} from 'chart.js'; +import type { ChartData } from 'chart.js'; +ChartJS.register(LineElement, PointElement, LinearScale, Title, Tooltip, Legend, CategoryScale); + +interface Props { + stats: Stats; + wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>; + onPlayAgain: () => void; + onMainMenu: () => void; + text: string; + userInput: string; + charTimings?: Array<{ time: number; isCorrect: boolean; char: string; index: number }>; + keypressHistory?: Array<{ time: number; index: number; isCorrect: boolean }>; +} + +export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMenu, text, userInput, charTimings, keypressHistory }) => { + // Responsive flag must be declared first + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string } | null>(null); + const { theme, toggleTheme } = useTheme(); + const [isMobileScreen, setIsMobileScreen] = useState(window.innerWidth < 700); + + // Debug log + console.log('EndScreen wpmHistory:', wpmHistory); + console.log('EndScreen stats:', stats); + console.log('EndScreen charTimings:', charTimings); + console.log('EndScreen userInput:', userInput); + console.log('EndScreen text:', text); + + // --- Monkeytype-style rolling window graph data for WPM and RAW --- + const graphInterval = 1.0; // seconds, for 1s intervals + const wpmWindow = 2.0; // seconds (WPM window) + const rawWindow = 0.5; // seconds (RAW window) + let graphPoints: { time: number; wpm: number; raw: number }[] = []; + if (charTimings && charTimings.length > 0) { + const maxTime = Math.max(charTimings[charTimings.length - 1].time, stats.time); + for (let t = 1; t <= Math.ceil(maxTime); t += graphInterval) { // start at 1s, step by 1s + // WPM: correct chars in last 2.0s + const wpmChars = charTimings.filter(c => c.time > t - wpmWindow && c.time <= t); + const wpmCorrect = wpmChars.filter(c => c.isCorrect).length; + const wpm = wpmCorrect > 0 ? (wpmCorrect / 5) / (wpmWindow / 60) : 0; + // RAW: all chars in last 0.5s + const rawChars = charTimings.filter(c => c.time > t - rawWindow && c.time <= t); + const raw = rawChars.length > 0 ? (rawChars.length / 5) / (rawWindow / 60) : 0; + graphPoints.push({ time: t, wpm, raw }); + } + // Apply moving average smoothing to WPM and RAW + const smoothWPM: number[] = []; + const smoothRAW: number[] = []; + const smoothWindow = 10; // more smoothing + for (let i = 0; i < graphPoints.length; i++) { + let sumWPM = 0, sumRAW = 0, count = 0; + for (let j = Math.max(0, i - smoothWindow + 1); j <= i; j++) { + sumWPM += graphPoints[j].wpm; + sumRAW += graphPoints[j].raw; + count++; + } + smoothWPM.push(sumWPM / count); + smoothRAW.push(sumRAW / count); + } + graphPoints = graphPoints.map((p, i) => ({ ...p, wpm: smoothWPM[i], raw: smoothRAW[i] })); + } else if (stats.time > 0 && text.length > 0) { + // fallback: simulate timings + const charTimingsSim: { time: number; isCorrect: boolean }[] = []; + for (let i = 0; i < stats.correctChars + stats.incorrectChars; i++) { + const charTime = (i / (stats.correctChars + stats.incorrectChars)) * stats.time; + const isCorrect = i < stats.correctChars; + charTimingsSim.push({ time: charTime, isCorrect }); + } + for (let t = 1; t <= Math.ceil(stats.time); t += graphInterval) { + const wpmChars = charTimingsSim.filter(c => c.time > t - wpmWindow && c.time <= t); + const wpmCorrect = wpmChars.filter(c => c.isCorrect).length; + const wpm = wpmCorrect > 0 ? (wpmCorrect / 5) / (wpmWindow / 60) : 0; + const rawChars = charTimingsSim.filter(c => c.time > t - rawWindow && c.time <= t); + const raw = rawChars.length > 0 ? (rawChars.length / 5) / (rawWindow / 60) : 0; + graphPoints.push({ time: t, wpm, raw }); + } + // Apply moving average smoothing to WPM and RAW + const smoothWPM: number[] = []; + const smoothRAW: number[] = []; + const smoothWindow = 10; + for (let i = 0; i < graphPoints.length; i++) { + let sumWPM = 0, sumRAW = 0, count = 0; + for (let j = Math.max(0, i - smoothWindow + 1); j <= i; j++) { + sumWPM += graphPoints[j].wpm; + sumRAW += graphPoints[j].raw; + count++; + } + smoothWPM.push(sumWPM / count); + smoothRAW.push(sumRAW / count); + } + graphPoints = graphPoints.map((p, i) => ({ ...p, wpm: smoothWPM[i], raw: smoothRAW[i] })); + } + // --- Chart.js data and options --- + // Build labels for every second + const xMax = Math.ceil(stats.time); + const allLabels = Array.from({length: xMax}, (_, i) => (i+1).toString()); + // Map graphPoints by time for quick lookup + const graphPointsByTime = Object.fromEntries(graphPoints.map(p => [Math.round(p.time), p])); + // --- Error points for the graph (Monkeytype style, per-error, at closest WPM value, using keypressHistory) --- + let errorPoints: { x: number; y: number }[] = []; + if (keypressHistory && keypressHistory.length > 0) { + keypressHistory.forEach(({ time, isCorrect }) => { + if (!isCorrect) { + let p = graphPoints.reduce((prev, curr) => + Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]); + if (p) { + errorPoints.push({ x: p.time, y: p.wpm }); + } + } + }); + } else if (charTimings && charTimings.length > 0) { + charTimings.forEach(({ time, isCorrect }) => { + if (!isCorrect) { + let p = graphPoints.reduce((prev, curr) => + Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]); + if (p) { + errorPoints.push({ x: p.time, y: p.wpm }); + } + } + }); + } + console.log('EndScreen errorPoints:', errorPoints); + console.log('EndScreen graphPoints:', graphPoints); + const chartData = { + labels: allLabels, + datasets: [ + { + label: 'WPM', + data: graphPoints.map((p) => ({ x: p.time, y: p.wpm })), + borderColor: '#00ff9d', + backgroundColor: 'rgba(0,255,157,0.1)', + borderWidth: 2, + pointRadius: 0, + tension: 0.4, // smoother line + type: 'line', + order: 1, + yAxisID: 'y', + pointStyle: 'line', + }, + { + label: 'RAW', + data: graphPoints.map((p) => ({ x: p.time, y: p.raw })), + borderColor: '#00cc8f', + backgroundColor: 'rgba(0,204,143,0.1)', + borderWidth: 1, + borderDash: [10, 8], + pointRadius: 0, + tension: 0.25, + type: 'line', + order: 2, + yAxisID: 'y', + pointStyle: 'line', + }, + { + label: 'Errors', + data: errorPoints, + borderColor: '#ff3b3b', + borderWidth: 0, + backgroundColor: '#ff3b3b', + pointRadius: 3, + type: 'scatter', + showLine: false, + order: 3, + yAxisID: 'y', + pointStyle: 'circle', + }, + ], + } as ChartData<'line'>; + // --- Dynamic X-axis step size for time (responsive, long time) --- + let xStep = 1; + let autoSkip = false; + let maxTicksLimit = 100; + let xMin = 1; + if (window.innerWidth < 700) { + xStep = 2; + autoSkip = true; + maxTicksLimit = 10; + xMin = 2; // start at 2 for even spacing if skipping by 2 + } + // Calculate max number of x labels that fit in the viewport (assume 40px per label) + const maxLabels = Math.floor((isMobileScreen ? window.innerWidth : 600) / 40); // 600px for graph area on desktop + if (xMax > maxLabels) { + xStep = Math.ceil(xMax / maxLabels); + } else if (xMax > 60) xStep = 10; + else if (xMax > 30) xStep = 5; + const chartOptions: any = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + align: 'center', + labels: { + color: '#00ff9d', + font: { family: 'JetBrains Mono, monospace', size: 12 }, + boxWidth: 24, + boxHeight: 12, + usePointStyle: false, + symbol: (ctx: any) => { + const {dataset} = ctx; + return function customLegendSymbol(ctx2: any, x: any, y: any, width: any, height: any) { + ctx2.save(); + if (dataset.label === 'RAW') { + // Dashed line + ctx2.strokeStyle = dataset.borderColor || '#00cc8f'; + ctx2.lineWidth = 3; + ctx2.setLineDash([8, 5]); + ctx2.beginPath(); + ctx2.moveTo(x, y + height / 2); + ctx2.lineTo(x + width, y + height / 2); + ctx2.stroke(); + ctx2.setLineDash([]); + } else if (dataset.label === 'Errors') { + // Small dot + ctx2.fillStyle = dataset.borderColor || '#ff3b3b'; + ctx2.beginPath(); + ctx2.arc(x + width / 2, y + height / 2, 4, 0, 2 * Math.PI); + ctx2.fill(); + } else if (dataset.label === 'WPM') { + // Solid line + ctx2.strokeStyle = dataset.borderColor || '#00ff9d'; + ctx2.lineWidth = 3; + ctx2.beginPath(); + ctx2.moveTo(x, y + height / 2); + ctx2.lineTo(x + width, y + height / 2); + ctx2.stroke(); + } + ctx2.restore(); + }; + }, + }, + }, + tooltip: { + enabled: true, + mode: 'nearest', + intersect: false, + usePointStyle: true, + callbacks: { + labelPointStyle: function(context: any) { + if (context.dataset.label === 'WPM') { + return { pointStyle: 'line', rotation: 0, borderWidth: 2, borderDash: [] }; + } + if (context.dataset.label === 'RAW') { + // Chart.js does not support dashed line in tooltip, so use line + return { pointStyle: 'line', rotation: 0, borderWidth: 2, borderDash: [] }; + } + if (context.dataset.label === 'Errors') { + return { pointStyle: 'circle', rotation: 0, borderWidth: 0, radius: 4 }; + } + return { pointStyle: 'circle', rotation: 0 }; + }, + label: function(context: any) { + if (context.dataset.label === 'WPM') { + return `WPM: ${Math.round(context.parsed.y)}`; + } + if (context.dataset.label === 'RAW') { + return `Raw: ${Math.round(context.parsed.y)}`; + } + if (context.dataset.label === 'Errors') { + if (context.chart.tooltip?._errorShown) return ''; + context.chart.tooltip._errorShown = true; + let errorText = ''; + if (charTimings && charTimings.length > 0 && text) { + const errorPoint = context.raw; + const closest = charTimings.reduce((prev, curr) => + Math.abs(curr.time - errorPoint.x) < Math.abs(prev.time - errorPoint.x) ? curr : prev, charTimings[0]); + if (!closest.isCorrect) { + const idx = closest.index; + let start = idx, end = idx; + while (start > 0 && text[start-1] !== ' ') start--; + while (end < text.length && text[end] !== ' ') end++; + const word = text.slice(start, end).trim(); + if (word.length > 0) { + errorText = `Error: "${word}"`; + } else { + errorText = `Error: '${closest.char}'`; + } + } + } + return errorText || 'Error'; + } + return ''; + }, + title: function() { return ''; }, + }, + backgroundColor: 'rgba(30,30,30,0.97)', + titleColor: '#fff', + bodyColor: '#fff', + borderColor: '#00ff9d', + borderWidth: 1, + caretSize: 6, + padding: 10, + external: function(context: any) { + if (context && context.tooltip) { + context.tooltip._errorShown = false; + } + }, + }, + }, + scales: { + x: { + title: { + display: true, + text: 'Time (s)', + color: '#646669', + font: { family: 'JetBrains Mono, monospace', size: 13, weight: 'bold' }, + align: 'center', + }, + min: xMin, + max: xMax, + type: 'linear', + offset: false, // no extra space/lines before 1 or after xMax, even spacing + ticks: { + color: '#646669', + font: { family: 'JetBrains Mono, monospace', size: 12 }, + stepSize: xStep, + autoSkip: autoSkip, + maxTicksLimit: maxTicksLimit, + callback: function(val: string | number) { + const tickNum = Number(val); + return Number.isInteger(tickNum) ? tickNum : ''; + }, + maxRotation: 0, + minRotation: 0, + }, + grid: { color: 'rgba(100,102,105,0.15)' }, + beginAtZero: false, + }, + y: { + title: { + display: true, + text: 'WPM', + color: '#646669', + font: { family: 'JetBrains Mono, monospace', size: 13, weight: 'bold' }, + }, + beginAtZero: true, + ticks: { + color: '#646669', + font: { family: 'JetBrains Mono, monospace', size: 12 }, + }, + grid: { color: 'rgba(100,102,105,0.15)' }, + position: 'left', + }, + }, + }; + // --- Axis scaling and responsive graph width --- + const minGraphWidth = 320; + const maxGraphWidth = 1200; + const pxPerSecond = 60; + const graphHeight = isMobileScreen ? 160 : 220; + const graphWidth = Math.min(Math.max(minGraphWidth, Math.min((xMax) * pxPerSecond, maxGraphWidth)), window.innerWidth - 32); + const margin = 40; + const axisFont = '12px JetBrains Mono, monospace'; + const maxTime = stats.time || (graphPoints.length > 0 ? graphPoints[graphPoints.length - 1].time : 1); + const maxWPM = graphPoints.length > 0 ? Math.max(...graphPoints.map(p => p.wpm)) : 0; + + // --- Dynamic Y-axis step size for WPM --- + let yStep = xMax > 60 ? 10 : (maxWPM > 50 ? 10 : 5); + let yMax = Math.max(yStep, Math.ceil(maxWPM / yStep) * yStep); + if (window.innerWidth < 700 && yMax > 60) { + yStep = 20; + yMax = Math.max(yStep, Math.ceil(maxWPM / yStep) * yStep); + } + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container || graphPoints.length === 0) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + // Set canvas size + canvas.width = graphWidth; + canvas.height = graphHeight; + ctx.clearRect(0, 0, canvas.width, canvas.height); + // --- Draw axes --- + ctx.strokeStyle = '#646669'; + ctx.lineWidth = 1; + ctx.beginPath(); + // Y axis + ctx.moveTo(margin, margin); + ctx.lineTo(margin, canvas.height - margin); + // X axis + ctx.moveTo(margin, canvas.height - margin); + ctx.lineTo(canvas.width - margin, canvas.height - margin); + ctx.stroke(); + // --- Draw Y ticks and labels --- + ctx.font = axisFont; + ctx.fillStyle = '#646669'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let yValue = 0; yValue <= yMax; yValue += yStep) { + const y = canvas.height - margin - (yValue / yMax) * (canvas.height - 2 * margin); + ctx.beginPath(); + ctx.moveTo(margin - 6, y); + ctx.lineTo(margin, y); + ctx.stroke(); + ctx.fillText(Math.round(yValue).toString(), margin - 8, y); + } + // --- Draw X ticks and labels (every whole second) --- + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (let xValue = 0; xValue <= xMax; xValue += 1) { + const x = margin + xValue * ((canvas.width - 2 * margin) / (xMax || 1)); + ctx.beginPath(); + ctx.moveTo(x, canvas.height - margin); + ctx.lineTo(x, canvas.height - margin + 6); + ctx.stroke(); + if (xValue % 5 === 0 || xValue === 0 || xValue === xMax) { + ctx.fillText(xValue.toString(), x, canvas.height - margin + 8); + } + } + // --- Draw WPM line --- + ctx.strokeStyle = '#00ff9d'; + ctx.lineWidth = 2; + ctx.beginPath(); + graphPoints.forEach((point, i) => { + const x = margin + point.time * ((canvas.width - 2 * margin) / (xMax || 1)); + const y = canvas.height - margin - (point.wpm / yMax) * (canvas.height - 2 * margin); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + // --- Axis labels --- + ctx.save(); + ctx.font = 'bold 13px JetBrains Mono, monospace'; + ctx.fillStyle = '#646669'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText('Time (s)', canvas.width / 2, canvas.height - 2); + ctx.save(); + ctx.translate(10, canvas.height / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText('WPM', 0, 0); + ctx.restore(); + ctx.restore(); + }, [graphPoints]); + + useEffect(() => { + const handleResize = () => setIsMobileScreen(window.innerWidth < 700); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // --- Text highlighting (per-letter, using charTimings) --- + const renderText = () => { + if (!text) return null; + const inputChars = userInput ? userInput.split('') : []; + // Split text into words with trailing spaces (so spaces stay with words) + const wordRegex = /[^\s]+\s*/g; + const wordMatches = text.match(wordRegex) || []; + let charIndex = 0; + return wordMatches.map((word, wIdx) => { + const chars = []; + for (let i = 0; i < word.length; i++) { + const char = word[i]; + const inputChar = inputChars[charIndex]; + let className = 'neutral'; + let displayChar = char; + if (charIndex < inputChars.length) { + if (inputChar === char) { + className = 'correct'; + } else { + className = 'incorrect'; + displayChar = inputChar; // Show the mistyped character + } + } + chars.push( + <span key={`char-${charIndex}`} className={className}>{displayChar}</span> + ); + charIndex++; + } + return <span key={`word-${wIdx}`}>{chars}</span>; + }); + }; + // --- Layout --- + return ( + <div className="end-screen" style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem', minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}> + {/* Logo at top, same as TypingGame */} + <div className="logo" onClick={onMainMenu}>TyperPunk</div> + {/* Main content area, all in one flex column, no fixed elements */} + <div style={{ + width: '100%', + flex: '1 0 auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + overflow: 'hidden', + maxHeight: 'calc(100vh - 220px)', + boxSizing: 'border-box', + }}> + {/* Text */} + <div className="end-screen-text" style={{ margin: '0 auto 1.5rem auto', fontSize: '1.25rem', lineHeight: 1.7, maxWidth: 700, width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative', background: 'rgba(0,0,0,0.04)', borderRadius: 6, padding: '1rem 1.5rem', textAlign: 'left', wordBreak: 'break-word', height: 'auto' }}> + <div className="text-display" style={{ whiteSpace: 'pre-wrap', textAlign: 'left', width: '100%' }}>{renderText()}</div> + </div> + {/* Desktop: WPM | Graph | ACC */} + {!isMobileScreen && ( + <> + {/* WPM far left, fixed to viewport edge */} + <div style={{ position: 'fixed', left: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center' }}> + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}> + <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'left', width: '100%' }}>RAW</div> + <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'left', width: '100%' }}>{Math.round(stats.rawWpm)}</div> + </div> + </div> + {/* ACC far right, fixed to viewport edge */} + <div style={{ position: 'fixed', right: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center' }}> + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}> + <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(stats.accuracy)}%</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'right', width: '100%' }}>ERR</div> + <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'right', width: '100%' }}>{stats.incorrectChars}</div> + </div> + </div> + {/* Graph center, take all available space with margin for stats */} + <div style={{ margin: '0 auto 1.5rem auto', width: '100%', maxWidth: 900, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0 }}> + {graphPoints.length > 0 && ( + <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: graphHeight, minHeight: graphHeight, height: graphHeight, margin: '0 auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}> + <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> + <Line data={chartData} options={chartOptions} style={{ width: '100%', height: graphHeight }} /> + </div> + </div> + )} + {/* TIME stat below graph */} + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> + </div> + </> + )} + {isMobileScreen && ( + <> + {/* Graph at top, legend centered */} + {graphPoints.length > 0 && ( + <div className="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: graphHeight, minHeight: graphHeight, height: graphHeight, margin: '0 auto 0.5rem auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}> + {/* Center legend above chart by wrapping chart in a flex column */} + <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}> + <Line data={chartData} options={chartOptions} style={{ width: '100%', height: graphHeight }} /> + </div> + </div> + )} + {/* WPM, TIME, ACC in a row below graph */} + <div className="end-screen-stats" style={{ + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + alignItems: 'center', + width: '100%', + maxWidth: 700, + margin: '0.5rem auto 0.2rem auto', + gap: '0.3rem', + }}> + {/* WPM (left) */} + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center' }}> + <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'left', width: '100%' }}>RAW</div> + <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'left', width: '100%' }}>{Math.round(stats.rawWpm)}</div> + </div> + {/* TIME (center, big) */} + <div className="end-screen-stat time" style={{ textAlign: 'center', alignItems: 'center', justifyContent: 'center' }}> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%' }}>TIME</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> + </div> + {/* ACC (right) */} + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center' }}> + <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(stats.accuracy)}%</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'right', width: '100%' }}>ERR</div> + <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'right', width: '100%' }}>{stats.incorrectChars}</div> + </div> + </div> + {/* Buttons closer to stats */} + <div className="end-screen-buttons" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem', marginTop: '0.5rem', width: '100%' }}> + <button + className="end-screen-button" + style={{ + width: '100%', + maxWidth: 250, + fontSize: '1rem', + padding: '0.7rem 1.2rem', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onClick={() => { setTimeout(onPlayAgain, 0); }} + > + Play Again + </button> + <button + className="end-screen-button" + style={{ + width: '100%', + maxWidth: 250, + fontSize: '1rem', + padding: '0.7rem 1.2rem', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onClick={() => { setTimeout(onMainMenu, 0); }} + > + Main Menu + </button> + </div> + </> + )} + </div> + {/* Desktop: Move the button row outside the main content and make it fixed at the bottom */} + {!isMobileScreen && ( + <div className="end-screen-buttons" style={{ position: 'fixed', bottom: '5rem', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '1.5rem', zIndex: 100, marginBottom: '8rem' }}> + <button + className="end-screen-button" + style={{ + width: 180, + maxWidth: 250, + fontSize: '1.2rem', + padding: '1rem 2rem', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onClick={() => { setTimeout(onPlayAgain, 0); }} + > + Play Again + </button> + <button + className="end-screen-button" + style={{ + width: 180, + maxWidth: 250, + fontSize: '1.2rem', + padding: '1rem 2rem', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onClick={() => { setTimeout(onMainMenu, 0); }} + > + Main Menu + </button> + </div> + )} + {/* Theme button at the bottom, not fixed, match TypingGame */} + <div className="future-modes-placeholder" /> + <button onClick={toggleTheme} className="theme-toggle"> + {theme === Theme.Dark ? ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> + ) : ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> + )} + </button> + </div> + ); +}; + +export default EndScreen; diff --git a/web/src/components/MainMenu.tsx b/web/src/components/MainMenu.tsx new file mode 100644 index 0000000..b8daea0 --- /dev/null +++ b/web/src/components/MainMenu.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { useTheme } from '../contexts/ThemeContext'; +import { Theme } from '../types'; + +interface Props { + onStartGame: () => void; + categories?: string[]; + selectedCategory?: string; + onSelectCategory?: (cat: string) => void; +} + +const MainMenu: React.FC<Props> = ({ onStartGame, categories = [], selectedCategory = 'random', onSelectCategory }) => { + const { theme, toggleTheme } = useTheme(); + + return ( + <div className="main-menu"> + <h1>TyperPunk</h1> + <div className="menu-options"> + <div style={{ marginBottom: '0.75rem' }}> + <label htmlFor="category" style={{ marginRight: 8 }}>Category:</label> + <select + id="category" + value={selectedCategory} + onChange={(e) => onSelectCategory && onSelectCategory(e.target.value)} + style={{ padding: '0.4rem 0.6rem' }} + > + <option value="random">Random</option> + {categories.map((c) => ( + <option key={c} value={c}>{c}</option> + ))} + </select> + </div> + <button className="menu-button" onClick={onStartGame}> + Start Typing Test + </button> + <button className="menu-button" onClick={toggleTheme}> + Toggle {theme === Theme.Dark ? 'Light' : 'Dark'} Mode + </button> + </div> + <button onClick={toggleTheme} className="theme-toggle"> + {theme === Theme.Dark ? ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> + ) : ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> + )} + </button> + </div> + ); +}; + +export default MainMenu;
\ No newline at end of file diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..1d635e9 --- /dev/null +++ b/web/src/components/ThemeToggle.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import '../styles/ThemeToggle.css'; + +interface ThemeToggleProps { + isDark: boolean; + onToggle: () => void; +} + +export const ThemeToggle: React.FC<ThemeToggleProps> = ({ isDark, onToggle }) => { + return ( + <button + className={`theme-toggle ${isDark ? 'dark' : 'light'}`} + onClick={onToggle} + aria-label={`Switch to ${isDark ? 'light' : 'dark'} theme`} + > + <div className="toggle-track"> + <div className="toggle-thumb"> + {isDark ? ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5" /> + <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /> + </svg> + ) : ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> + </svg> + )} + </div> + </div> + </button> + ); +};
\ No newline at end of file diff --git a/web/src/components/TypingGame.tsx b/web/src/components/TypingGame.tsx new file mode 100644 index 0000000..d815420 --- /dev/null +++ b/web/src/components/TypingGame.tsx @@ -0,0 +1,591 @@ +import * as React from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { Stats, Theme, TyperPunkGame } from '../types'; +import { useTheme } from '../contexts/ThemeContext'; +import { EndScreen } from './EndScreen'; + +interface Props { + game: TyperPunkGame | null; + text: string; + input: string; + stats: Stats; + attribution?: string; + onInput: (input: string, accuracy: number, mistakes: number) => void; + onFinish: ( + finalStats: Stats, + wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>, + finalUserInput: string, + charTimings: Array<{ time: number; isCorrect: boolean; char: string; index: number }>, + keypressHistory: Array<{ time: number; index: number; isCorrect: boolean }> + ) => void; + onReset: () => void; + onMainMenu: () => void; +} + +// Error boundary for TypingGame +class TypingGameErrorBoundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { error: null }; + } + static getDerivedStateFromError(error: Error) { + return { error }; + } + componentDidCatch(error: Error, errorInfo: any) { + console.error('TypingGame error boundary caught:', error, errorInfo); + } + render() { + if (this.state.error) { + return <div style={{ color: 'red', padding: 32, background: '#111', minHeight: '100vh' }}>TypingGame Error: {this.state.error.message}</div>; + } + return this.props.children; + } +} + +export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Element => { + const { game, text, stats, attribution, onInput, onFinish, onReset, onMainMenu } = props; + const { theme, toggleTheme } = useTheme(); + const [isFinished, setIsFinished] = useState(false); + const [wpmHistory, setWpmHistory] = useState<Array<{ time: number; wpm: number; raw: number; isError: boolean }>>([]); + const [finalStats, setFinalStats] = useState<Stats | null>(null); + const [finalUserInput, setFinalUserInput] = useState<string>(''); + const [localInput, setLocalInput] = useState<string>(''); + const inputRef = useRef<HTMLInputElement>(null); + const [wasmAccuracy, setWasmAccuracy] = useState<number>(100); + const [wasmMistakes, setWasmMistakes] = useState<number>(0); + const gameRef = useRef(game); + const isInitialized = useRef(false); + const lastInputRef = useRef(''); + const inputQueueRef = useRef<string[]>([]); + const isProcessingQueueRef = useRef(false); + const [charTimings, setCharTimings] = useState<Array<{ time: number; isCorrect: boolean; char: string; index: number }>>([]); + const gameStartTimeRef = useRef<number | null>(null); + const [finalCharTimings, setFinalCharTimings] = useState<Array<{ time: number; isCorrect: boolean; char: string; index: number }>>([]); + // Persistent mistake tracking + const [allMistakes, setAllMistakes] = useState<Array<{ time: number; index: number }>>([]); + const [finalAllMistakes, setFinalAllMistakes] = useState<Array<{ time: number; index: number }>>([]); + // Persistent keypress history tracking + const [keypressHistory, setKeypressHistory] = useState<Array<{ time: number, index: number, isCorrect: boolean }>>([]); + const [finalKeypressHistory, setFinalKeypressHistory] = useState<Array<{ time: number, index: number, isCorrect: boolean }>>([]); + const [isMobileScreen, setIsMobileScreen] = useState(window.innerWidth < 700); + + // Initialize game only once + useEffect(() => { + if (game) { + gameRef.current = game; + isInitialized.current = true; + // Reset local state when game changes + setIsFinished(false); + setFinalStats(null); + setFinalUserInput(''); + setLocalInput(''); + lastInputRef.current = ''; + setWpmHistory([]); + inputQueueRef.current = []; + isProcessingQueueRef.current = false; + + // Ensure input is enabled and focused + setTimeout(() => { + if (inputRef.current) { + inputRef.current.disabled = false; + inputRef.current.focus(); + } + }, 100); + } + }, [game]); + + // Cleanup processing timeout + useEffect(() => { + return () => { + // No cleanup needed anymore + }; + }, []); + + // Focus input on mount and when component updates + useEffect(() => { + const focusInput = () => { + if (inputRef.current && !isFinished) { + inputRef.current.disabled = false; + inputRef.current.focus(); + } + }; + + const handleVisibilityChange = () => { + if (!document.hidden && !isFinished) { + // Small delay to ensure the page is fully visible + setTimeout(focusInput, 100); + } + }; + + const handleWindowFocus = () => { + if (!isFinished) { + focusInput(); + } + }; + + const handleClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target.closest('.typing-game') && !isFinished) { + focusInput(); + } + }; + + const handleKeyDown = () => { + // If user presses any key and input is not focused, focus it + if (!isFinished && document.activeElement !== inputRef.current) { + focusInput(); + } + }; + + // Initial focus with delay to ensure component is mounted + setTimeout(focusInput, 50); + + // Add event listeners + window.addEventListener('focus', handleWindowFocus); + window.addEventListener('visibilitychange', handleVisibilityChange); + document.addEventListener('click', handleClick); + document.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener('visibilitychange', handleVisibilityChange); + document.removeEventListener('click', handleClick); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isFinished]); + + // Update WPM history on every input/time change + useEffect(() => { + if (stats.time > 0 && !isFinished && gameRef.current) { + const game = gameRef.current; + const time = typeof game.get_time_elapsed === 'function' ? game.get_time_elapsed() : 0; + const wpm = typeof game.get_wpm === 'function' ? game.get_wpm() : 0; + const raw = typeof game.get_raw_wpm === 'function' ? game.get_raw_wpm() : 0; + // Track error positions for this input + let isError = false; + if (typeof game.get_stats_and_input === 'function') { + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + // If mistakes increased, mark as error + if (mistakes > 0) isError = true; + } + setWpmHistory(prev => [ + ...prev, + { + time, + wpm, + raw, + isError + } + ]); + } + }, [stats.time, isFinished]); + + // Update game text + useEffect(() => { + if (gameRef.current && text) { + try { + gameRef.current.set_text(text); + } catch (err) { + console.error('Error updating game text:', err); + } + } + }, [text]); + + // Process input queue + const processInputQueue = React.useCallback(() => { + if (isProcessingQueueRef.current || inputQueueRef.current.length === 0) return; + + isProcessingQueueRef.current = true; + + try { + const game = gameRef.current; + if (!game) return; + + const nextInput = inputQueueRef.current[0]; + + // Process input + try { + game.handle_input(nextInput); + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + setLocalInput(wasmInput); + lastInputRef.current = wasmInput; + setWasmAccuracy(accuracy); + setWasmMistakes(mistakes); + onInput(wasmInput, accuracy, mistakes); + + // Check if game is finished using WASM game's is_finished method + if (game.is_finished()) { + setIsFinished(true); + // Get latest stats from WASM + let accuracy = 100, mistakes = 0; + if (typeof game.get_stats === 'function') { + try { + [accuracy, mistakes] = game.get_stats(); + } catch (err) { + // fallback to last known + } + } + const finalStats = { + ...stats, + accuracy, + incorrectChars: mistakes + }; + setFinalStats(finalStats); + setFinalUserInput(wasmInput); + // Rebuild charTimings from final input and text + const rebuiltCharTimings = []; + for (let i = 0; i < wasmInput.length; i++) { + rebuiltCharTimings.push({ + time: (i / wasmInput.length) * stats.time, + isCorrect: wasmInput[i] === text[i], + char: wasmInput[i], + index: i, + }); + } + setFinalCharTimings(rebuiltCharTimings); + setFinalAllMistakes([...allMistakes]); // snapshot mistakes + if (inputRef.current) inputRef.current.disabled = true; + setFinalKeypressHistory(keypressHistory); + onFinish(finalStats, [...wpmHistory], wasmInput, rebuiltCharTimings, keypressHistory); + return; // Exit early to prevent further processing + } + } catch (err) { + console.error('WASM operation error:', err); + setLocalInput(nextInput); + lastInputRef.current = nextInput; + } + + // Remove processed input from queue + inputQueueRef.current.shift(); + } catch (err) { + console.error('WASM operation error:', err); + if (inputQueueRef.current.length > 0) { + const nextInput = inputQueueRef.current[0]; + setLocalInput(nextInput); + lastInputRef.current = nextInput; + inputQueueRef.current.shift(); + } + } + isProcessingQueueRef.current = false; + + // Process next input if any + if (inputQueueRef.current.length > 0) { + requestAnimationFrame(processInputQueue); + } + }, [onInput, text, stats, onFinish, wpmHistory, allMistakes, keypressHistory]); + + // Handle input changes + const handleInput = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + if (!gameRef.current || isFinished || isProcessingQueueRef.current) return; + + const newInput = e.target.value; + if (newInput.length > text.length) return; + + inputQueueRef.current.push(newInput); + if (!isProcessingQueueRef.current) { + processInputQueue(); + } + }, [isFinished, text.length, processInputQueue]); + + // Handle backspace + const handleKeyDown = React.useCallback(async (e: React.KeyboardEvent<HTMLInputElement>) => { + if (!gameRef.current || isFinished || isProcessingQueueRef.current) return; + + if (e.key === 'Backspace') { + e.preventDefault(); + try { + const ctrl = e.ctrlKey || e.metaKey; + const success = await gameRef.current.handle_backspace(ctrl); + if (success) { + const [wasmInput, accuracy, mistakes] = await gameRef.current.get_stats_and_input(); + setLocalInput(wasmInput); + lastInputRef.current = wasmInput; + setWasmAccuracy(accuracy); + setWasmMistakes(mistakes); + onInput(wasmInput, accuracy, mistakes); + } + } catch (err) { + console.error('WASM operation error:', err); + setLocalInput(lastInputRef.current); + } + } + }, [isFinished, onInput]); + + // Remove the text-based reset effect since we're handling it in the game effect + useEffect(() => { + if (!gameRef.current || isFinished) return; + + try { + const [accuracy, mistakes] = gameRef.current.get_stats(); + setWasmAccuracy(accuracy); + setWasmMistakes(mistakes); + } catch (err) { + console.error('WASM stats update error:', err); + } + }, [isFinished]); + + // Track per-character timing and correctness as user types + useEffect(() => { + if (!isFinished && localInput.length > 0) { + if (gameStartTimeRef.current === null) { + gameStartTimeRef.current = Date.now(); + } + const now = Date.now(); + const elapsed = (now - gameStartTimeRef.current) / 1000; + const idx = localInput.length - 1; + const char = localInput[idx]; + const isCorrect = text[idx] === char; + + // Log every keypress event (not just new chars) + setKeypressHistory(prev => [...prev, { time: elapsed, index: idx, isCorrect }]); + + setCharTimings(prev => { + // If user backspaced, trim timings + if (prev.length > localInput.length) { + return prev.slice(0, localInput.length); + } + // If user added a char, append + if (prev.length < localInput.length) { + return [ + ...prev, + { time: elapsed, isCorrect, char, index: idx } + ]; + } + // If user replaced a char, update + if (prev.length === localInput.length) { + const updated = [...prev]; + updated[idx] = { time: elapsed, isCorrect, char, index: idx }; + return updated; + } + return prev; + }); + } else if (!isFinished && localInput.length === 0) { + setCharTimings([]); + setKeypressHistory([]); + } + lastInputRef.current = localInput; + }, [localInput, isFinished, text]); + + // Reset charTimings and keypressHistory on new game + useEffect(() => { + setCharTimings([]); + setKeypressHistory([]); + gameStartTimeRef.current = null; + setAllMistakes([]); + }, [game]); + + // On finish, set finalCharTimings and finalKeypressHistory + useEffect(() => { + if (isFinished && charTimings.length > 0 && finalCharTimings.length === 0) { + setFinalCharTimings(charTimings); + } + if (isFinished && keypressHistory.length > 0 && finalKeypressHistory.length === 0) { + setFinalKeypressHistory(keypressHistory); + } + }, [isFinished, charTimings, finalCharTimings.length, keypressHistory, finalKeypressHistory.length]); + + const handleLogoClick = () => { + onMainMenu(); + }; + + const handlePlayAgain = () => { + setIsFinished(false); + setFinalStats(null); + setFinalUserInput(''); + setWpmHistory([]); + setCharTimings([]); + setLocalInput(''); + gameStartTimeRef.current = null; + onReset(); + }; + + const renderText = () => { + if (!text) return null; + const inputChars = localInput ? localInput.split('') : []; + // Split text into words with trailing spaces (so spaces stay with words) + const wordRegex = /[^\s]+\s*/g; + const wordMatches = text.match(wordRegex) || []; + let charIndex = 0; + return wordMatches.map((word, wIdx) => { + const chars = []; + for (let i = 0; i < word.length; i++) { + const char = word[i]; + const inputChar = inputChars[charIndex]; + let className = 'neutral'; + let displayChar = char; + if (charIndex < inputChars.length) { + if (inputChar === char) { + className = 'correct'; + } else { + className = 'incorrect'; + displayChar = inputChar; // Show the mistyped character + } + } + if (!isFinished && charIndex === localInput.length && localInput.length <= text.length) { + className += ' current'; + } + chars.push( + <span key={`char-${charIndex}`} className={className}>{displayChar}</span> + ); + charIndex++; + } + return <span key={`word-${wIdx}`}>{chars}</span>; + }); + }; + + useEffect(() => { + const handleResize = () => setIsMobileScreen(window.innerWidth < 700); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + if (isFinished && finalStats) { + return ( + <EndScreen + stats={finalStats} + wpmHistory={wpmHistory} + text={text} + charTimings={finalCharTimings} + userInput={finalUserInput} + onPlayAgain={handlePlayAgain} + onMainMenu={handleLogoClick} + keypressHistory={finalKeypressHistory} + /> + ); + } + + return ( + <TypingGameErrorBoundary> + <div className="typing-game" style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem', minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}> + {/* Logo at top */} + <div className="logo" onClick={handleLogoClick}>TyperPunk</div> + {/* Main content area */} + <div style={{ + width: '100%', + flex: '1 0 auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + overflow: 'hidden', + maxHeight: 'calc(100vh - 220px)', + boxSizing: 'border-box', + }}> + {/* Text */} + <div className="end-screen-text" style={{ margin: '0 auto 0.5rem auto', fontSize: '1.25rem', lineHeight: 1.7, maxWidth: 700, width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative' }}> + <div className="text-display" style={{ whiteSpace: 'pre-wrap', textAlign: 'left', width: '100%' }}>{renderText()}</div> + <input + ref={inputRef} + type="text" + value={localInput} + onChange={handleInput} + onKeyDown={handleKeyDown} + className="typing-input" + autoFocus + onBlur={(e) => { + if (!isFinished) { + setTimeout(() => e.target.focus(), 10); + } + }} + disabled={isFinished} + style={{ + opacity: 0, + caretColor: 'transparent', + width: '100%', + height: '2.5rem', + position: 'absolute', + left: 0, + top: 0, + zIndex: 9999, + backgroundColor: 'transparent', + border: 'none', + outline: 'none', + pointerEvents: isFinished ? 'none' : 'auto' + }} + /> + </div> + {attribution && ( + <div style={{ maxWidth: 700, width: '100%', margin: '0 auto 1.5rem auto', textAlign: 'right', color: 'var(--neutral-color)', fontSize: '0.9rem' }}> + — {attribution} + </div> + )} + {/* Desktop: WPM | Graph | ACC */} + {!isMobileScreen && ( + <> + {/* WPM far left, fixed to viewport edge */} + <div style={{ position: 'fixed', left: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center' }}> + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}> + <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> + </div> + </div> + {/* ACC far right, fixed to viewport edge */} + <div style={{ position: 'fixed', right: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center' }}> + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}> + <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(wasmAccuracy)}%</div> + </div> + </div> + {/* Graph center, take all available space with margin for stats */} + <div style={{ margin: '0 auto 1.5rem auto', width: '100%', maxWidth: 900, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0 }}> + <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} /> + {/* TIME stat below graph */} + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> + </div> + </> + )} + {/* Mobile: Graph at top, then WPM & ACC in a row, then TIME below */} + {isMobileScreen && ( + <> + <div className="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto 0.5rem auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} /> + <div className="end-screen-stats" style={{ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + alignItems: 'center', + width: '100%', + maxWidth: 700, + margin: '0.5rem auto 0.2rem auto', + gap: '0.3rem', + }}> + {/* WPM (left) */} + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center' }}> + <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> + </div> + {/* ACC (right) */} + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center' }}> + <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(wasmAccuracy)}%</div> + </div> + </div> + {/* TIME stat below WPM/ACC */} + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 8 }}>TIME</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> + </> + )} + </div> + {/* Empty space for future game modes, matches EndScreen */} + <div className="future-modes-placeholder" /> + <button onClick={toggleTheme} className="theme-toggle"> + {theme === Theme.Dark ? ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> + ) : ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> + )} + </button> + </div> + </TypingGameErrorBoundary> + ); +});
\ No newline at end of file diff --git a/web/src/contexts/ThemeContext.tsx b/web/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..f876269 --- /dev/null +++ b/web/src/contexts/ThemeContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { Theme, ThemeColors } from '../types'; + +interface ThemeContextType { + theme: Theme; + colors: ThemeColors; + toggleTheme: () => void; +} + +const lightColors: ThemeColors = { + primary: '#00ff9d', + secondary: '#00cc8f', + background: '#ffffff', + text: '#333333', + error: '#ca4754', + success: '#2ecc71' +}; + +const darkColors: ThemeColors = { + primary: '#00ff9d', + secondary: '#00cc8f', + background: '#000000', + text: '#646669', + error: '#ef5350', + success: '#66bb6a' +}; + +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [theme, setTheme] = useState<Theme>(() => { + const savedTheme = localStorage.getItem('theme'); + return (savedTheme as Theme) || Theme.Dark; + }); + + const colors = theme === Theme.Light ? lightColors : darkColors; + + useEffect(() => { + localStorage.setItem('theme', theme); + document.documentElement.setAttribute('data-theme', theme.toLowerCase()); + if (!window.location.pathname.includes('typing-game')) { + document.body.style.backgroundColor = theme === Theme.Light ? '#ffffff' : '#000000'; + } + }, [theme]); + + const toggleTheme = () => { + setTheme(prevTheme => prevTheme === Theme.Light ? Theme.Dark : Theme.Light); + }; + + return ( + <ThemeContext.Provider value={{ theme, colors, toggleTheme }}> + {children} + </ThemeContext.Provider> + ); +}; + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +};
\ No newline at end of file diff --git a/web/src/hooks/useCoreGame.ts b/web/src/hooks/useCoreGame.ts new file mode 100644 index 0000000..3aa69ec --- /dev/null +++ b/web/src/hooks/useCoreGame.ts @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react'; +import init, { TyperPunkGame as Game } from '@typerpunk/wasm'; + +export function useCoreGame() { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const gameRef = useRef<Game | null>(null); + + useEffect(() => { + let mounted = true; + + const initGame = async () => { + try { + await init(); + if (!mounted) return; + + const game = new Game(); + gameRef.current = game; + setIsLoading(false); + } catch (err) { + console.error('Failed to initialize game:', err); + if (mounted) { + setError('Failed to initialize game'); + setIsLoading(false); + } + } + }; + + initGame(); + + return () => { + mounted = false; + if (gameRef.current) { + try { + gameRef.current.free(); + } catch (err) { + console.error('Error cleaning up game:', err); + } + gameRef.current = null; + } + }; + }, []); + + const resetGame = async () => { + if (gameRef.current) { + try { + gameRef.current.free(); + } catch (err) { + console.error('Error freeing old game:', err); + } + } + + try { + const game = new Game(); + gameRef.current = game; + } catch (err) { + console.error('Error resetting game:', err); + setError('Failed to reset game'); + } + }; + + const cleanupGame = () => { + if (gameRef.current) { + try { + gameRef.current.free(); + } catch (err) { + console.error('Error cleaning up game:', err); + } + gameRef.current = null; + } + }; + + return { + game: gameRef.current, + isLoading, + error, + resetGame, + cleanupGame + }; +}
\ No newline at end of file diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts new file mode 100644 index 0000000..a5457b6 --- /dev/null +++ b/web/src/hooks/useTheme.ts @@ -0,0 +1,21 @@ +import { useState, useEffect } from 'react'; +import { Theme } from '../types'; + +export const useTheme = () => { + const [theme, setTheme] = useState<Theme>(() => { + const savedTheme = localStorage.getItem('theme'); + return (savedTheme as Theme) || Theme.Light; + }); + + useEffect(() => { + localStorage.setItem('theme', theme); + document.documentElement.classList.remove(Theme.Light, Theme.Dark); + document.documentElement.classList.add(theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prev => prev === Theme.Light ? Theme.Dark : Theme.Light); + }; + + return { theme, toggleTheme }; +};
\ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..f987afc --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import { ThemeProvider } from './contexts/ThemeContext'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + <React.StrictMode> + <ThemeProvider> + <App /> + </ThemeProvider> + </React.StrictMode> +);
\ No newline at end of file diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..d9130b4 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,910 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #000000; + --text-color: #646669; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #646669; +} + +[data-theme="light"] { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #ffffff; + --text-color: #333333; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #646669; +} + +[data-theme="dark"] { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #000000; + --text-color: #646669; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #646669; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: 'JetBrains Mono', monospace; + background-color: var(--background-color); + color: var(--text-color); + height: 100vh; + overflow: hidden; +} + +.app { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + padding: 1rem; + overflow: hidden; +} + +.logo { + position: fixed; + top: 1rem; + left: 1rem; + font-size: 1.2rem; + color: var(--primary-color); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 2px; + cursor: pointer; + transition: color 0.2s ease; +} + +.logo:hover { + color: var(--secondary-color); +} + +/* Main Menu */ +.main-menu { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + width: 100%; + padding: 2rem; +} + +.main-menu h1 { + font-size: 4rem; + color: var(--primary-color); + margin-bottom: 2rem; + text-transform: uppercase; + letter-spacing: 4px; +} + +.menu-options { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.menu-button { + background: none; + border: 2px solid var(--primary-color); + color: var(--text-color); + padding: 1rem 2rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + text-transform: uppercase; + letter-spacing: 2px; + min-width: 250px; +} + +.menu-button:hover { + background-color: var(--primary-color); + color: var(--background-color); +} + +/* Typing Game */ +.typing-game { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 1rem; + position: relative; + height: 100vh; + overflow: hidden; +} + +.text-container { + position: relative; + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 1rem 2rem 0 2rem; + margin-top: 2.5rem; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.text-display { + font-family: 'JetBrains Mono', monospace; + font-size: 1.5rem; + line-height: 1.5; + min-height: 6rem; + color: var(--text-color); + position: relative; + white-space: pre-wrap; + word-break: keep-all; + overflow-wrap: break-word; + width: fit-content; + max-width: 800px; + tab-size: 4; + hyphens: none; + text-align: left; + padding: 1rem; +} + +.text-display span { + white-space: pre; + position: relative; + display: inline-block; +} + +.text-display span.correct { + color: var(--correct-color); +} + +.text-display span.incorrect { + color: var(--error-color); +} + +.text-display span.neutral { + color: var(--neutral-color); +} + +.text-display span.current { + position: relative; +} + +.text-display span.current::after { + content: '▏'; + position: absolute; + left: 0; + top: 0; + bottom: 0; + color: var(--caret-color); + animation: blink 1s step-end infinite; +} + +.typing-input { + position: relative; + width: 100%; + opacity: 1 !important; + z-index: 1; + cursor: text; + caret-color: auto !important; + background: transparent !important; + color: transparent !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.typing-input:focus { + background: transparent !important; + color: transparent !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + background: rgba(255,255,255,0.08); + border: none !important; + outline: none !important; + box-shadow: 0 2px 12px 0 rgba(0,0,0,0.12), 0 1.5px 4px 0 rgba(0,0,0,0.10); + color: var(--primary-color); + padding: 0.7rem 1rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s cubic-bezier(.4,0,.2,1); + display: flex; + align-items: center; + gap: 0.5rem; + z-index: 100; + border-radius: 1.5rem; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.theme-toggle:focus, +.theme-toggle:active { + border: none !important; + outline: none !important; + box-shadow: 0 4px 16px 0 rgba(0,0,0,0.18); + background: rgba(255,255,255,0.16); +} + +.theme-toggle:hover { + color: var(--secondary-color); + background: rgba(255,255,255,0.18); + box-shadow: 0 6px 24px 0 rgba(0,0,0,0.18); + transform: scale(1.06); +} + +.theme-toggle svg { + width: 1.5rem; + height: 1.5rem; + transition: color 0.2s, filter 0.2s; + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.10)); +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .main-menu h1 { + font-size: 2.5rem; + margin-bottom: 1.5rem; + } + + .menu-button { + padding: 0.8rem 1.5rem; + font-size: 1rem; + min-width: 200px; + } + + .typing-game { + padding: 1rem; + } + + .text-container { + padding: 1rem; + margin-top: 5rem; + } + + .stats-container { + padding: 0 1rem; + } + + .wpm-stat { + left: 1rem; + top: 50%; + } + + .acc-stat { + right: 1rem; + top: 50%; + } + + .time-stat { + bottom: 0.5rem; + font-size: 1rem; + } + + .text-display { + font-size: 1.2rem; + } + + .end-screen-stats { + gap: 1rem; + padding: 0 1rem; + } + + .end-screen-stat { + padding: 0.5rem; + } + + .end-screen-stat-value { + font-size: 1.5rem; + } + + .graph-container { + height: 200px; + padding: 1rem; + } + + .graph-axis.x { + left: 1rem; + right: 1rem; + } + + .graph-axis.y { + bottom: 1rem; + } + + .end-screen-buttons { + bottom: 1rem; + } +} + +@media (max-height: 600px) { + .stats-container { + top: 0.5rem; + } + + .wpm-stat { + top: 50%; + } + + .acc-stat { + top: 50%; + } + + .time-stat { + bottom: 0.25rem; + font-size: 0.9rem; + } + + .text-container { + margin-top: 4.5rem; + padding: 1rem; + } + + .text-display { + font-size: 1.2rem; + min-height: 4.5rem; + } + + .end-screen-stats { + gap: 0.75rem; + margin: 1rem 0; + } + + .end-screen-stat { + padding: 0.25rem; + } + + .end-screen-stat-value { + font-size: 1.2rem; + } + + .graph-container { + height: 200px; + } +} + +@media (max-width: 600px) { + .typing-game { + padding: 0.5rem; + height: 100vh; + min-width: 0; + } + .text-container { + padding: 0.5rem 0.5rem 0 0.5rem; + margin-top: 1.5rem; + min-width: 0; + } + .text-display { + font-size: 1rem; + padding: 0.5rem; + min-height: 3rem; + max-width: 100vw; + word-break: break-word; + } +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + font-size: 1.5rem; + color: var(--primary-color); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 2px; +} + +#root { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Typing Game */ +.stats-bar { + display: none; +} + +.end-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 2rem; + padding-top: 6rem; + min-height: 100vh; + position: relative; +} + +.end-screen-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2rem; + width: 100%; + margin-bottom: 3rem; +} + +.end-screen-stat { + text-align: center; + padding: 1.5rem; + transition: transform 0.2s ease; +} + +.end-screen-stat.wpm { + grid-column: 1; + grid-row: 1; + text-align: left; +} + +.end-screen-stat.errors { + grid-column: 3; + grid-row: 1; + text-align: right; +} + +.end-screen-stat.time { + grid-column: 2; + grid-row: 1; + text-align: center; +} + +.end-screen-stat:hover { + transform: translateY(-2px); +} + +.end-screen-stat-label { + font-size: 0.8rem; + color: var(--text-color); + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 0.5rem; +} + +.end-screen-stat-value { + font-size: 2.5rem; + color: var(--primary-color); + font-weight: bold; +} + +.end-screen-buttons { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 1.5rem; +} + +.end-screen-button { + background: none; + border: 2px solid var(--primary-color); + color: var(--text-color); + padding: 1rem 2rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + text-transform: uppercase; + letter-spacing: 2px; + min-width: 250px; + border-radius: 8px; +} + +.end-screen-button:hover { + background-color: var(--primary-color); + color: var(--background-color); + transform: translateY(-2px); +} + +/* Character styling */ +.correct { + color: var(--correct-color); +} + +.incorrect { + color: var(--error-color); + text-decoration: underline; +} + +.current { + background-color: rgba(0, 0, 0, 0.1); + border-radius: 2px; +} + +.error-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; + text-align: center; +} + +.error-screen h2 { + margin-bottom: 1rem; +} + +.error-screen p { + margin-bottom: 2rem; +} + +.stats-container { + position: fixed; + top: 1rem; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + width: 100%; + padding: 0 2rem; + z-index: 10; +} + +.wpm-stat { + position: fixed; + left: 2rem; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + width: 6rem; +} + +.acc-stat { + position: fixed; + right: 2rem; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + width: 6rem; +} + +.time-stat { + position: fixed; + left: 50%; + transform: translateX(-50%); + bottom: 1rem; + z-index: 100; +} + +.stat-label { + font-size: 0.8rem; + color: var(--text-color); + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 0.5rem; +} + +.stat-value { + font-size: 2rem; + color: var(--primary-color); + font-weight: bold; +} + +.graph-container { + width: 100%; + height: 300px; + margin: 2rem 0; + position: relative; + padding: 2rem; + margin-top: 6rem; +} + +.graph-container canvas { + width: 100%; + height: 100%; +} + +.graph-axis { + position: absolute; + color: var(--text-color); + font-size: 0.8rem; + font-family: 'JetBrains Mono', monospace; +} + +.graph-axis.x { + bottom: 0; + left: 2rem; + right: 2rem; + display: flex; + justify-content: space-between; + padding: 0.5rem 0; +} + +.graph-axis.y { + top: 0; + bottom: 2rem; + left: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0 0.5rem; + width: 3rem; + text-align: right; +} + +body, .app, #root { + overflow-x: hidden; + scrollbar-width: none; /* Firefox */ +} +body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ +} + +.future-modes-placeholder { + width: 100%; + min-height: 3rem; + background: transparent; + margin-top: 1.5rem; +} + +.end-screen-text { + width: 100%; + max-width: 800px; + margin: 0 auto 2rem auto; + font-family: 'JetBrains Mono', monospace; + font-size: 1.2rem; + color: var(--text-color); + background: rgba(0,0,0,0.04); + border-radius: 6px; + padding: 1rem 1.5rem; + text-align: left; + word-break: break-word; +} + +.end-screen-graph-row { + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: space-between; + width: 100%; + max-width: 900px; + margin: 0 auto 1.5rem auto; +} + +.end-screen-stat.wpm { + flex: 0 0 120px; + text-align: left; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + margin-right: 1.5rem; +} + +.end-screen-stat.errors { + flex: 0 0 120px; + text-align: right; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-end; + margin-left: 1.5rem; +} + +.graph-container { + flex: 1 1 0; + min-width: 0; + margin: 0; + padding: 2rem 0; + height: 300px; + position: relative; + background: rgba(0,0,0,0.02); + border-radius: 8px; +} + +.end-screen-graph-row.end-screen-time-row { + display: flex; + justify-content: center; + align-items: flex-start; + margin: 0 auto 2rem auto; +} + +.end-screen-stat.time { + text-align: center; + margin: 0 auto; + font-size: 1.1rem; +} + +.end-screen-rawwpm { + width: 100%; + max-width: 900px; + margin: 0 auto 2rem auto; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 1.5rem; + font-size: 1.3rem; +} +.end-screen-rawwpm .stat-label { + font-size: 1rem; + color: var(--text-color); + text-transform: uppercase; + letter-spacing: 2px; + margin-right: 0.5rem; +} +.end-screen-rawwpm .stat-value { + font-size: 2rem; + color: var(--primary-color); + font-weight: bold; +} + +.end-screen-stat.acc { + flex: 0 0 120px; + text-align: right; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-end; + margin-left: 1.5rem; +} + +.end-screen-time { + width: 100%; + max-width: 900px; + margin: 0 auto; + text-align: center; + padding: 1rem 0; +} + +.end-screen-time .stat-label { + font-size: 0.9rem; + margin-bottom: 0.3rem; +} + +.end-screen-time .stat-value { + font-size: 1.5rem; +} + +@media (max-width: 900px) { + .end-screen-graph-row { + flex-direction: column !important; + align-items: stretch !important; + max-width: 100vw !important; + position: relative !important; + } + .endscreen-side-stat { + position: static !important; + left: unset !important; + right: unset !important; + top: unset !important; + transform: none !important; + width: 100% !important; + margin-bottom: 0.5rem !important; + z-index: 10; + display: flex !important; + flex-direction: row !important; + justify-content: space-between !important; + align-items: center !important; + } + .graph-container { + max-width: 100vw !important; + min-width: 0 !important; + width: 100% !important; + overflow-x: auto !important; + margin: 0 auto 1rem auto !important; + height: 160px !important; + } + .end-screen-buttons { + position: static !important; + left: unset !important; + bottom: unset !important; + transform: none !important; + width: 100% !important; + margin: 1.5rem 0 0 0 !important; + flex-direction: column !important; + gap: 1rem !important; + z-index: 10; + } + .end-screen-time { + position: static !important; + left: unset !important; + transform: none !important; + bottom: unset !important; + width: 100% !important; + margin-top: 1rem !important; + } +} + +@media (max-width: 600px) { + .end-screen-graph-row { + padding: 0 0.5rem !important; + } + .graph-container { + height: 180px !important; + padding: 0.5rem !important; + } + .end-screen-stat.wpm, .end-screen-stat.acc { + font-size: 1rem !important; + padding: 0.5rem !important; + } + .end-screen-time .stat-value { + font-size: 1.2rem !important; + } +} + +@media (max-width: 900px) { + .text-container { + padding: 0.5rem !important; + min-height: 120px !important; + } +} +@media (max-width: 600px) { + .text-container { + min-height: 80px !important; + font-size: 1rem !important; + } +} + +@media (max-width: 700px) { + body, #root, .app, .end-screen { + overflow-y: auto !important; + overflow-x: hidden !important; + } + .end-screen { + min-height: 0 !important; + height: auto !important; + padding-top: 1.5rem !important; + padding-bottom: 0.5rem !important; + display: flex !important; + flex-direction: column !important; + } + .end-screen-buttons { + margin-top: auto !important; + margin-bottom: 0 !important; + } + .end-screen-main-content { + flex: 1 0 auto !important; + width: 100% !important; + display: flex !important; + flex-direction: column !important; + } + .end-screen-buttons { + margin-top: auto !important; + margin-bottom: 0 !important; + width: 100% !important; + } +}
\ No newline at end of file diff --git a/web/src/styles/ThemeToggle.css b/web/src/styles/ThemeToggle.css new file mode 100644 index 0000000..818a1ba --- /dev/null +++ b/web/src/styles/ThemeToggle.css @@ -0,0 +1,70 @@ +.theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + background: none; + border: none; + padding: 0.5rem; + cursor: pointer; + z-index: 1000; + transition: transform 0.3s ease; +} + +.theme-toggle:hover { + transform: scale(1.1); +} + +.toggle-track { + width: 3rem; + height: 1.5rem; + background-color: var(--background-color); + border: 2px solid var(--primary-color); + border-radius: 1rem; + position: relative; + transition: background-color 0.3s ease; +} + +.toggle-thumb { + width: 1.2rem; + height: 1.2rem; + background-color: var(--primary-color); + border-radius: 50%; + position: absolute; + top: 0.1rem; + left: 0.1rem; + transition: transform 0.3s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.theme-toggle.dark .toggle-thumb { + transform: translateX(1.5rem); +} + +.theme-toggle svg { + width: 1rem; + height: 1rem; + color: var(--background-color); +} + +@media (max-width: 768px) { + .theme-toggle { + top: 0.5rem; + right: 0.5rem; + } + + .toggle-track { + width: 2.5rem; + height: 1.2rem; + } + + .toggle-thumb { + width: 1rem; + height: 1rem; + } + + .theme-toggle.dark .toggle-thumb { + transform: translateX(1.2rem); + } +}
\ No newline at end of file diff --git a/web/src/styles/TypingGame.css b/web/src/styles/TypingGame.css new file mode 100644 index 0000000..e5f5c8e --- /dev/null +++ b/web/src/styles/TypingGame.css @@ -0,0 +1,19 @@ +.space { + color: #666; + opacity: 0.7; +} + +.space.correct { + color: #4CAF50; + opacity: 1; +} + +.space.incorrect { + color: #f44336; + opacity: 1; +} + +.space.current { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 2px; +}
\ No newline at end of file diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..07871c2 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,54 @@ +export type Screen = 'main-menu' | 'typing-game' | 'end-screen'; + +export interface Stats { + wpm: number; + rawWpm: number; + accuracy: number; + time: number; + correctChars: number; + incorrectChars: number; + totalChars: number; + currentStreak: number; + bestStreak: number; +} + +export interface GameState { + screen: Screen; + currentText: string; + currentAttribution?: string; + input: string; + startTime: number | null; + isRunning: boolean; + stats: Stats; +} + +export enum Theme { + Light = 'light', + Dark = 'dark' +} + +export interface ThemeColors { + primary: string; + secondary: string; + background: string; + text: string; + error: string; + success: string; +} + +export interface TyperPunkGame { + handle_input(input: string): void; + handle_backspace(ctrl: boolean): boolean; + get_stats(): [number, number]; + get_stats_and_input(): [string, number, number]; + is_finished(): boolean; + get_text(): string; + get_input(): string; + set_text(text: string): void; + start(): void; + get_wpm(): number; + get_time_elapsed(): number; + get_raw_wpm(): number; +} + +export type TyperPunk = TyperPunkGame;
\ No newline at end of file diff --git a/web/src/types/typerpunk.d.ts b/web/src/types/typerpunk.d.ts new file mode 100644 index 0000000..a21ddd5 --- /dev/null +++ b/web/src/types/typerpunk.d.ts @@ -0,0 +1,27 @@ +declare module 'typerpunk' { + export class TyperPunkGame { + free(): void; + set_text(text: string): void; + get_text(): string; + get_input(): string; + start(): void; + handle_input(input: string): void; + handle_backspace(is_word_deletion: boolean): boolean; + is_finished(): boolean; + get_error_positions(): Uint32Array; + get_current_streak(): number; + get_best_streak(): number; + get_theme(): string; + set_theme(theme: string): void; + get_wpm(): number; + get_accuracy(): number; + get_time_elapsed(): number; + get_raw_wpm(): number; + can_backspace(): boolean; + can_ctrl_backspace(): boolean; + handle_backspace(ctrl: boolean): boolean; + get_total_mistakes(): number; + } + + export default function init(): Promise<void>; +}
\ No newline at end of file diff --git a/web/src/wasm.d.ts b/web/src/wasm.d.ts new file mode 100644 index 0000000..6692774 --- /dev/null +++ b/web/src/wasm.d.ts @@ -0,0 +1,25 @@ +export class TyperPunkGame { + constructor(text: string); + handle_input(input: string): Result<void, string>; + handle_backspace(ctrl: boolean): Result<boolean, string>; + get_stats(): Result<[number, number], string>; + get_stats_and_input(): Result<[string, number, number], string>; + is_finished(): boolean; + can_backspace_to_position(position: number): boolean; + get_current_word_start(): number; + set_text(text: string): Result<void, string>; + get_text(): string; + get_input(): string; + start(): void; + get_wpm(): number; + get_time_elapsed(): number; + get_raw_wpm(): number; + free(): void; +} + +export type Result<T, E> = { + isOk(): boolean; + isErr(): boolean; + unwrap(): T; + unwrapErr(): E; +};
\ No newline at end of file diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..8a2477d --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,19 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: 'var(--primary)', + background: 'var(--background)', + text: 'var(--text)', + error: 'var(--error)', + success: 'var(--success)', + }, + }, + }, + plugins: [], +}
\ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..ac0731c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowJs": true, + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@typerpunk/wasm": ["../crates/wasm/pkg"] + } + }, + "include": ["src", "../crates/wasm/pkg"], + "references": [{ "path": "./tsconfig.node.json" }] +}
\ No newline at end of file diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..862dfb2 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +}
\ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..b0c5a26 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import wasm from 'vite-plugin-wasm'; +import topLevelAwait from 'vite-plugin-top-level-await'; + +export default defineConfig({ + plugins: [ + react(), + wasm(), + topLevelAwait() + ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@typerpunk/wasm': path.resolve(__dirname, '../crates/wasm/pkg') + } + }, + server: { + port: 3000, + fs: { + allow: [ + path.resolve(__dirname, 'src'), + path.resolve(__dirname, 'node_modules'), + path.resolve(__dirname, '../crates/wasm/pkg'), + path.resolve(__dirname, '../crates/wasm/target'), + ] + } + }, + optimizeDeps: { + exclude: ['@typerpunk/wasm'] + }, + build: { + target: 'esnext', + rollupOptions: { + output: { + manualChunks: { + 'wasm': ['@typerpunk/wasm'] + } + } + } + } +});
\ No newline at end of file |
