diff --git a/Cargo.toml b/Cargo.toml index e3b19a5..5094f1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,9 @@ categories = ["games", "mathematics", "science", "algorithms"] [dependencies] crossterm = "0.27.0" rand = "0.8.5" + +[profile.release] +codegen-units = 1 +lto = true +panic = "abort" +strip = true diff --git a/README.md b/README.md index 30ffe8d..1d6ed16 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,25 @@ A basic tui sudoku game for your shell. --- +### Preview + +``` +┌────────┬────────┬────────┐ ┌───────┐ +│ │ │ 8 9 │ │ Hard │ <- Difficulty +│ 4 9 7 │ │ 6 │ │ 36/81 │ <- Completion in number of cells +│ 2 │ 3 1 │ 7 │ ├───────┤ +├────────┼────────┼────────┤ │ 01:22 │ <- Elapsed Time +│ 6 │ 9 7 │ 3 │ ├───────┤ +│ 3 │ 5 2 │ │ │> Edit │ <- Active Mode +│ 7 2 │ 1 3 │ 5 4 │ │ Mark │ +├────────┼────────┼────────┤ │ Go │ +│ 2 1 │ 3 7 │ 9 5 │ ├───────┤ +│ 5 │ 9 │ 3 4 │ │ [9] │ <- Preselected Number +│ │ 4 │ 6 1 │ │ 4 / 9 │ <- Completion of preselected Number +└────────┴────────┴────────┘ └───────┘ +``` + + ### Controls The current control scheme adheres to vim-like keybindings and is modal. @@ -30,35 +49,12 @@ The current control scheme adheres to vim-like keybindings and is modal. - `q` to quit -### Preview - -This is what the sudoku will be displayed like. - -``` -┌────────┬────────┬────────┐ -│ 5 3 │ 7 │ │ -│ 6 │ 1 9 5 │ │ -│ 9 8 │ │ 6 │ -├────────┼────────┼────────┤ -│ 8 │ 6 │ 3 │ -│ 4 │ 8 3 │ 1 │ -│ 7 │ 2 │ 6 │ -├────────┼────────┼────────┤ -│ 6 │ │ 2 8 │ -│ │ 4 1 9 │ 5 │ -│ │ 8 │ 7 9 │ -└────────┴────────┴────────┘ -``` - - ### Todo - [ ] Game logic - [x] Validate Sudokus - [x] Generate Sudokus - [x] Difficulties to choose - - [ ] Timer - - [ ] Scoreboard per difficulty - [ ] Undo functionality @@ -76,31 +72,16 @@ This is what the sudoku will be displayed like. - [x] Markup Mode to mark where numbers could go - [x] Go Mode to move to blocks 1-9 - [x] Toggle Number/Mark with Space + - [ ] Undo/Redo stack - [ ] Colored UI - - [ ] Hightlight selected numbers - - [ ] Hightlight selected markups + - [ ] Hightlight preselected numbers + - [ ] Hightlight preselected markups + - [x] Scoreboard + - [x] Live timer + - [x] Mode indicator + - [x] Difficulty indicator + - [x] Completion information + - [ ] Menu + - [ ] Difficulty selection + - [ ] Highscore section - [ ] Color chooser - - [ ] Live timer - - [ ] Scoreboard access - - [ ] Difficulty selection - - -The Final UI design should include a sidebar, -that will look something like the following: - - -``` -┌────────┬────────┬────────┐ ┌───────┐ -│ │ │ 8 9 │ │ Hard │ <- Difficulty -│ 4 9 7 │ │ 6 │ │ 36/81 │ <- Completion in number of cells -│ 2 │ 3 1 │ 7 │ ├───────┤ -├────────┼────────┼────────┤ │ 01:22 │ <- Elapsed Time -│ 6 │ 9 7 │ 3 │ ├───────┤ -│ 3 │ 5 2 │ │ │> Edit │ <- Active Mode -│ 7 2 │ 1 3 │ 5 4 │ │ Mark │ -├────────┼────────┼────────┤ │ Go │ -│ 2 1 │ 3 7 │ 9 5 │ ├───────┤ -│ 5 │ 9 │ 3 4 │ │ [9] │ <- Preselected Number -│ │ 4 │ 6 1 │ │ 4 / 9 │ <- Completion of preselected Number -└────────┴────────┴────────┘ └───────┘ -``` diff --git a/src/main.rs b/src/main.rs index 6b1760e..26d49d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,12 +14,12 @@ use ui::*; use std::{io, time::Duration}; -fn main() -> io::Result<()> { +fn main() { let mut screen = Screen::init(io::stdout()); let mut state = State::init(Difficulty::Mid); loop { - if poll(Duration::from_millis(250))? { + if poll(Duration::from_millis(250)).unwrap_or(false) { if let Ok(Key(k)) = read() { match k.code { Char('h') => state.move_cursor(Dir::Left), @@ -49,8 +49,12 @@ fn main() -> io::Result<()> { state.toggle_current_cell(); state.enter_next_mode(); if is_solution(&state.board) { - screen.deinit()?; - println!("you win"); + screen.deinit(); + println!("+------------+"); + println!("| You Win :) |"); + println!("+------------+"); + println!("Difficulty: {}", state.difficulty); + println!("Final Time: {}", state.get_timer_string()); break; } } @@ -77,7 +81,7 @@ fn main() -> io::Result<()> { }, Char('q') | Char('Q') => { - screen.deinit()?; + screen.deinit(); break; } @@ -87,12 +91,14 @@ fn main() -> io::Result<()> { } } - screen.update_dimensions()?; - screen.render(&state); - screen.draw(state.cur_row, state.cur_col)?; - } + screen.update_dimensions() + .unwrap_or_else(|_| std::process::exit(1)); - Ok(()) + screen.render(&state); + + screen.draw(state.cur_row, state.cur_col) + .unwrap_or_else(|_| std::process::exit(1)); + } } mod tests; diff --git a/src/state.rs b/src/state.rs index 542db90..5b70895 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,8 @@ use crate::sudoku::*; use crate::Dir::*; +use std::time; + pub enum Dir { Up, Down, @@ -22,11 +24,13 @@ pub enum Mode { pub struct State { pub board: Board, + pub difficulty: Difficulty, pub modifiable: [[bool; 9]; 9], pub markups: [[[bool; 9]; 9]; 9], + pub start_time: time::Instant, pub mode: Mode, pub next_mode: Mode, - pub cur_num: u8, + pub preselection: u8, pub cur_row: usize, pub cur_col: usize, } @@ -38,16 +42,22 @@ impl State { Self { board, + difficulty, modifiable, markups: [[[false; 9]; 9]; 9], + start_time: time::Instant::now(), mode: Mode::default(), next_mode: Mode::default(), - cur_num: 1, + preselection: 1, cur_row: 4, cur_col: 4, } } + fn get_time(&self) -> time::Duration { + time::Instant::now() - self.start_time + } + fn get_modifiables(board: Board) -> [[bool; 9]; 9] { let mut modifiable = [[false; 9]; 9]; for (board_row, modifiable_row) in board.iter().zip(modifiable.iter_mut()) { @@ -87,15 +97,15 @@ impl State { } pub fn preselect_num(&mut self, num: u8) { - self.cur_num = num; + self.preselection = num; } pub fn toggle_current_cell(&mut self) { if self.current_cell_is_modifiable() { - *self.current_cell() = if *self.current_cell() == self.cur_num { + *self.current_cell() = if *self.current_cell() == self.preselection { 0 } else { - self.cur_num + self.preselection } } } @@ -139,10 +149,67 @@ impl State { } pub fn toggle_current_mark(&mut self) { - self.markups[self.cur_row][self.cur_col][self.cur_num as usize] ^= true; + self.markups[self.cur_row][self.cur_col][self.preselection as usize] ^= true; } pub fn delete_current_mark(&mut self) { - self.markups[self.cur_row][self.cur_col][self.cur_num as usize] = false; + self.markups[self.cur_row][self.cur_col][self.preselection as usize] = false; + } + + pub fn get_completion_chars(&self) -> [char; 2] { + let mut count = 0; + for row in self.board { + for cell in row { + if cell != 0 { + count += 1; + } + } + } + let to_char = |x| (x + b'0') as char; + [to_char(count / 10), to_char(count % 10)] + } + + pub fn get_preselection_completion_char(&self) -> char { + let count = self + .board + .into_iter() + .flatten() + .filter(|&cell| cell == self.preselection) + .count(); + (count.min(9) as u8 + b'0') as char + } + + pub fn get_difficulty_chars(&self) -> [char; 5] { + match self.difficulty { + Difficulty::Easy => [' ', 'E', 'a', 's', 'y'], + Difficulty::Mid => [' ', 'M', 'i', 'd', ' '], + Difficulty::Hard => [' ', 'H', 'a', 'r', 'd'], + Difficulty::Expert => ['E', 'x', 'p', 'r', 't'], + Difficulty::Custom(x) => [ + 'C', + '(', + (x as u8 / 10 + b'0') as char, + (x as u8 % 10 + b'0') as char, + ')', + ], + } + } + + pub fn get_timer_chars(&self) -> [char; 5] { + let mut secs = self.get_time().as_secs(); + let mut chars = [':'; 5]; + let to_char = |x: u64| (x as u8 + b'0') as char; + chars[0] = to_char(secs / 600); + secs %= 600; + chars[1] = to_char(secs / 60); + secs %= 60; + chars[3] = to_char(secs / 10); + secs %= 10; + chars[4] = to_char(secs); + chars + } + + pub fn get_timer_string(&self) -> String { + self.get_timer_chars().iter().collect() } } diff --git a/src/sudoku/generator.rs b/src/sudoku/generator.rs index 1db669e..4f15576 100644 --- a/src/sudoku/generator.rs +++ b/src/sudoku/generator.rs @@ -10,7 +10,9 @@ use std::fmt; /// categories of difficulty, indicating how many /// empty spaces will be on a sudoku board. #[allow(unused)] +#[derive(Default, Copy, Clone, PartialEq)] pub enum Difficulty { + #[default] Easy, Mid, Hard, @@ -36,10 +38,10 @@ impl fmt::Display for Difficulty { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { match self { Easy => write!(f, "Easy"), - Mid => write!(f, " Mid"), + Mid => write!(f, "Mid"), Hard => write!(f, "Hard"), - Expert => write!(f, "Exprt"), - Custom(x) => write!(f, "C({})", x), + Expert => write!(f, "Expert"), + Custom(x) => write!(f, "Custom ({:02})", x), } } } diff --git a/src/ui.rs b/src/ui.rs index 73293cd..2e79567 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,7 +2,7 @@ use crate::state::*; use std::io; use crossterm::{ - cursor::{MoveTo, SetCursorStyle}, + cursor::{MoveTo, MoveToColumn, SetCursorStyle}, execute, queue, terminal::{ disable_raw_mode, enable_raw_mode, size, Clear, ClearType::All, EnterAlternateScreen, @@ -14,7 +14,7 @@ pub struct Screen where T: io::Write, { - pub data: Vec, + pub data: Vec>, pub ostream: T, pub width: usize, pub height: usize, @@ -26,12 +26,15 @@ where { pub fn init(ostream: T) -> Self { let (width, height) = size().unwrap(); + let (width, height) = (width as usize, height as usize); + + let data = RENDER_TEMPLATE.iter().map(|s| s.to_vec()).collect(); let mut screen = Screen { - data: vec![String::with_capacity(width as usize); height as usize], + data, ostream, - width: width as usize, - height: height as usize, + width, + height, }; enable_raw_mode().expect("[-]: Error: ui::init: Failed to enable raw mode."); @@ -40,86 +43,126 @@ where screen } - pub fn deinit(&mut self) -> io::Result<()> { - disable_raw_mode()?; - execute!(self.ostream, LeaveAlternateScreen)?; - Ok(()) + pub fn deinit(&mut self) { + disable_raw_mode().unwrap_or(()); + execute!(self.ostream, LeaveAlternateScreen).unwrap_or(()); } pub fn update_dimensions(&mut self) -> io::Result<()> { let (width, height) = size()?; self.width = width as usize; self.height = height as usize; + + if height < 14 || width < 54 { + self.clear()?; + self.deinit(); + eprintln!("[-]: Error: ui::update_dimensions: Terminal size too small to display UI."); + return Err(io::Error::from(io::ErrorKind::Other)); + } + Ok(()) } pub fn clear(&mut self) -> io::Result<()> { - queue!(self.ostream, Clear(All)) + queue!(self.ostream, Clear(All))?; + queue!(self.ostream, MoveToColumn(0)) } pub fn draw(&mut self, row: usize, col: usize) -> io::Result<()> { self.draw_screen()?; - self.place_cursor(row, col)?; + self.draw_cursor(row, col)?; self.ostream.flush()?; Ok(()) } pub fn draw_screen(&mut self) -> io::Result<()> { self.clear()?; - let s: String = self.data.join("\r\n"); - write!(self.ostream, "{}", s)?; + let lft_pad = self.width / 2 - 14; + let bot_pad = self.height / 2 - 7; + + let mut display = String::with_capacity((43 + lft_pad) * 13 + bot_pad * 2); + + display.extend(self.data.iter().flat_map(|line| { + std::iter::repeat(' ') + .take(lft_pad) + .chain(line.to_owned()) + .chain(['\r', '\n']) + })); + + display.push_str(&"\r\n".repeat(bot_pad)); + + write!(self.ostream, "{}", display)?; Ok(()) } pub fn render(&mut self, state: &State) { - let mut lines = Vec::new(); - lines.push("┌────────┬────────┬────────┐".to_string()); - - for (row, row_slice) in state.board.iter().enumerate() { - match row { - 3 | 6 => lines.push("├────────┼────────┼────────┤".to_string()), - _ => {} - } - let mut line = String::new(); - for (col, cur_cell) in row_slice.iter().enumerate() { - match col { - 0 => line.push_str("│ "), - 3 | 6 => line.push_str(" │ "), - _ => {} - } - line.push(match cur_cell { - 0 => ' ', - n => (b'0' + n) as char, - }); - line.push(' '); - } - line.push_str(" │"); - lines.push(line); - } - lines.push("└────────┴────────┴────────┘".to_string()); - - let pad_hori = self.width / 2 + lines[0].chars().count() / 2; - let pad_vert = self.height - lines.len(); - let pad_top = pad_vert / 2; - let pad_bot = pad_vert - pad_top; - - let mut new_data = Vec::new(); - - for _ in 0..pad_top { - new_data.push(String::new()); - } - for line in lines { - let padded = format!("{: >width$}", line, width = pad_hori); - new_data.push(padded); - } - for _ in 0..pad_bot { - new_data.push(String::new()); - } - - self.data = new_data; + self.render_board(); + self.render_board_cells(state); + self.render_scoreboard_elements(state); } - pub fn place_cursor(&mut self, row: usize, col: usize) -> io::Result<()> { + fn render_board(&mut self) { + self.data = RENDER_TEMPLATE.iter().map(|s| s.to_vec()).collect(); + } + + fn render_board_cells(&mut self, state: &State) { + for row in 0..9 { + for col in 0..9 { + let i = row + 1 + row / 3; + let j = col + 2 + col + col / 3 * 3; + + self.data[i][j] = match state.board[row][col] { + 0 => ' ', + x => (x + b'0') as char, + } + } + } + } + + fn render_scoreboard_elements(&mut self, state: &State) { + // char indeces of scoreboard elements + // ----- + // + // difficulty [1][34] - [1][38] + // completion [2][34] - [1][35] + // + // timer [4][34] - [3][38] + // + // > edit: [6][33] + // > markup: [7][33] + // > go: [8][33] + // + // preselection: [10][36] + // preselection completion: [11][34] + + for (i, c) in (34..39).zip(state.get_difficulty_chars()) { + self.data[1][i] = c; + } + + for (i, c) in (34..36).zip(state.get_completion_chars()) { + self.data[2][i] = c; + } + + for (i, c) in (34..39).zip(state.get_timer_chars()) { + self.data[4][i] = c; + } + + for i in 6..9 { + self.data[i][33] = ' '; + } + + self.data[match state.mode { + Mode::Edit => 6, + Mode::Markup => 7, + Mode::Go => 8, + }][33] = '>'; + + self.data[10][36] = (state.preselection + b'0') as char; + + self.data[11][34] = state.get_preselection_completion_char(); + } + + pub fn draw_cursor(&mut self, row: usize, col: usize) -> io::Result<()> { let (x, y) = ( (self.width / 2 - 14) as u16, (self.height / 2 - 7 + (self.height & 1)) as u16, @@ -139,3 +182,71 @@ where queue!(self.ostream, MoveTo(x, y), SetCursorStyle::SteadyBlock) } } + +const RENDER_TEMPLATE: [[char; 41]; 13] = [ + [ + '┌', '─', '─', '─', '─', '─', '─', '─', '─', '┬', '─', '─', '─', '─', '─', '─', '─', '─', + '┬', '─', '─', '─', '─', '─', '─', '─', '─', '┐', ' ', ' ', ' ', ' ', '┌', '─', '─', '─', + '─', '─', '─', '─', '┐', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', + ' ', ' ', ' ', ' ', '│', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', + '/', '8', '1', ' ', '│', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '├', '─', '─', '─', + '─', '─', '─', '─', '┤', + ], + [ + '├', '─', '─', '─', '─', '─', '─', '─', '─', '┼', '─', '─', '─', '─', '─', '─', '─', '─', + '┼', '─', '─', '─', '─', '─', '─', '─', '─', '┤', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', + ' ', ' ', ' ', ' ', '│', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '├', '─', '─', '─', + '─', '─', '─', '─', '┤', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '│', ' ', ' ', 'E', + 'd', 'i', 't', ' ', '│', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '│', ' ', ' ', 'M', + 'a', 'r', 'k', ' ', '│', + ], + [ + '├', '─', '─', '─', '─', '─', '─', '─', '─', '┼', '─', '─', '─', '─', '─', '─', '─', '─', + '┼', '─', '─', '─', '─', '─', '─', '─', '─', '┤', ' ', ' ', ' ', ' ', '│', ' ', ' ', 'G', + 'o', ' ', ' ', ' ', '│', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '├', '─', '─', '─', + '─', '─', '─', '─', '┤', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '│', ' ', ' ', '[', + ' ', ']', ' ', ' ', '│', + ], + [ + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + '│', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', ' ', '│', ' ', ' ', ' ', + '/', ' ', '9', ' ', '│', + ], + [ + '└', '─', '─', '─', '─', '─', '─', '─', '─', '┴', '─', '─', '─', '─', '─', '─', '─', '─', + '┴', '─', '─', '─', '─', '─', '─', '─', '─', '┘', ' ', ' ', ' ', ' ', '└', '─', '─', '─', + '─', '─', '─', '─', '┘', + ], +];