ui rework, added scoreboard + timer

This commit is contained in:
markichnich 2023-08-24 01:37:49 +02:00
parent 97839e250c
commit 7594ac19d6
6 changed files with 301 additions and 128 deletions

View File

@ -11,3 +11,9 @@ categories = ["games", "mathematics", "science", "algorithms"]
[dependencies] [dependencies]
crossterm = "0.27.0" crossterm = "0.27.0"
rand = "0.8.5" rand = "0.8.5"
[profile.release]
codegen-units = 1
lto = true
panic = "abort"
strip = true

View File

@ -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 ### Controls
The current control scheme adheres to vim-like keybindings and is modal. 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 - `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 ### Todo
- [ ] Game logic - [ ] Game logic
- [x] Validate Sudokus - [x] Validate Sudokus
- [x] Generate Sudokus - [x] Generate Sudokus
- [x] Difficulties to choose - [x] Difficulties to choose
- [ ] Timer
- [ ] Scoreboard per difficulty
- [ ] Undo functionality - [ ] 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] Markup Mode to mark where numbers could go
- [x] Go Mode to move to blocks 1-9 - [x] Go Mode to move to blocks 1-9
- [x] Toggle Number/Mark with Space - [x] Toggle Number/Mark with Space
- [ ] Undo/Redo stack
- [ ] Colored UI - [ ] Colored UI
- [ ] Hightlight selected numbers - [ ] Hightlight preselected numbers
- [ ] Hightlight selected markups - [ ] Hightlight preselected markups
- [ ] Color chooser - [x] Scoreboard
- [ ] Live timer - [x] Live timer
- [ ] Scoreboard access - [x] Mode indicator
- [x] Difficulty indicator
- [x] Completion information
- [ ] Menu
- [ ] Difficulty selection - [ ] Difficulty selection
- [ ] Highscore section
- [ ] Color chooser
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
└────────┴────────┴────────┘ └───────┘
```

View File

@ -14,12 +14,12 @@ use ui::*;
use std::{io, time::Duration}; use std::{io, time::Duration};
fn main() -> io::Result<()> { fn main() {
let mut screen = Screen::init(io::stdout()); let mut screen = Screen::init(io::stdout());
let mut state = State::init(Difficulty::Mid); let mut state = State::init(Difficulty::Mid);
loop { loop {
if poll(Duration::from_millis(250))? { if poll(Duration::from_millis(250)).unwrap_or(false) {
if let Ok(Key(k)) = read() { if let Ok(Key(k)) = read() {
match k.code { match k.code {
Char('h') => state.move_cursor(Dir::Left), Char('h') => state.move_cursor(Dir::Left),
@ -49,8 +49,12 @@ fn main() -> io::Result<()> {
state.toggle_current_cell(); state.toggle_current_cell();
state.enter_next_mode(); state.enter_next_mode();
if is_solution(&state.board) { if is_solution(&state.board) {
screen.deinit()?; screen.deinit();
println!("you win"); println!("+------------+");
println!("| You Win :) |");
println!("+------------+");
println!("Difficulty: {}", state.difficulty);
println!("Final Time: {}", state.get_timer_string());
break; break;
} }
} }
@ -77,7 +81,7 @@ fn main() -> io::Result<()> {
}, },
Char('q') | Char('Q') => { Char('q') | Char('Q') => {
screen.deinit()?; screen.deinit();
break; break;
} }
@ -87,12 +91,14 @@ fn main() -> io::Result<()> {
} }
} }
screen.update_dimensions()?; screen.update_dimensions()
screen.render(&state); .unwrap_or_else(|_| std::process::exit(1));
screen.draw(state.cur_row, state.cur_col)?;
}
Ok(()) screen.render(&state);
screen.draw(state.cur_row, state.cur_col)
.unwrap_or_else(|_| std::process::exit(1));
}
} }
mod tests; mod tests;

View File

@ -1,6 +1,8 @@
use crate::sudoku::*; use crate::sudoku::*;
use crate::Dir::*; use crate::Dir::*;
use std::time;
pub enum Dir { pub enum Dir {
Up, Up,
Down, Down,
@ -22,11 +24,13 @@ pub enum Mode {
pub struct State { pub struct State {
pub board: Board, pub board: Board,
pub difficulty: Difficulty,
pub modifiable: [[bool; 9]; 9], pub modifiable: [[bool; 9]; 9],
pub markups: [[[bool; 9]; 9]; 9], pub markups: [[[bool; 9]; 9]; 9],
pub start_time: time::Instant,
pub mode: Mode, pub mode: Mode,
pub next_mode: Mode, pub next_mode: Mode,
pub cur_num: u8, pub preselection: u8,
pub cur_row: usize, pub cur_row: usize,
pub cur_col: usize, pub cur_col: usize,
} }
@ -38,16 +42,22 @@ impl State {
Self { Self {
board, board,
difficulty,
modifiable, modifiable,
markups: [[[false; 9]; 9]; 9], markups: [[[false; 9]; 9]; 9],
start_time: time::Instant::now(),
mode: Mode::default(), mode: Mode::default(),
next_mode: Mode::default(), next_mode: Mode::default(),
cur_num: 1, preselection: 1,
cur_row: 4, cur_row: 4,
cur_col: 4, cur_col: 4,
} }
} }
fn get_time(&self) -> time::Duration {
time::Instant::now() - self.start_time
}
fn get_modifiables(board: Board) -> [[bool; 9]; 9] { fn get_modifiables(board: Board) -> [[bool; 9]; 9] {
let mut modifiable = [[false; 9]; 9]; let mut modifiable = [[false; 9]; 9];
for (board_row, modifiable_row) in board.iter().zip(modifiable.iter_mut()) { 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) { pub fn preselect_num(&mut self, num: u8) {
self.cur_num = num; self.preselection = num;
} }
pub fn toggle_current_cell(&mut self) { pub fn toggle_current_cell(&mut self) {
if self.current_cell_is_modifiable() { 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 0
} else { } else {
self.cur_num self.preselection
} }
} }
} }
@ -139,10 +149,67 @@ impl State {
} }
pub fn toggle_current_mark(&mut self) { 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) { 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()
} }
} }

View File

@ -10,7 +10,9 @@ use std::fmt;
/// categories of difficulty, indicating how many /// categories of difficulty, indicating how many
/// empty spaces will be on a sudoku board. /// empty spaces will be on a sudoku board.
#[allow(unused)] #[allow(unused)]
#[derive(Default, Copy, Clone, PartialEq)]
pub enum Difficulty { pub enum Difficulty {
#[default]
Easy, Easy,
Mid, Mid,
Hard, Hard,
@ -38,8 +40,8 @@ impl fmt::Display for Difficulty {
Easy => write!(f, "Easy"), Easy => write!(f, "Easy"),
Mid => write!(f, "Mid"), Mid => write!(f, "Mid"),
Hard => write!(f, "Hard"), Hard => write!(f, "Hard"),
Expert => write!(f, "Exprt"), Expert => write!(f, "Expert"),
Custom(x) => write!(f, "C({})", x), Custom(x) => write!(f, "Custom ({:02})", x),
} }
} }
} }

213
src/ui.rs
View File

@ -2,7 +2,7 @@ use crate::state::*;
use std::io; use std::io;
use crossterm::{ use crossterm::{
cursor::{MoveTo, SetCursorStyle}, cursor::{MoveTo, MoveToColumn, SetCursorStyle},
execute, queue, execute, queue,
terminal::{ terminal::{
disable_raw_mode, enable_raw_mode, size, Clear, ClearType::All, EnterAlternateScreen, disable_raw_mode, enable_raw_mode, size, Clear, ClearType::All, EnterAlternateScreen,
@ -14,7 +14,7 @@ pub struct Screen<T>
where where
T: io::Write, T: io::Write,
{ {
pub data: Vec<String>, pub data: Vec<Vec<char>>,
pub ostream: T, pub ostream: T,
pub width: usize, pub width: usize,
pub height: usize, pub height: usize,
@ -26,12 +26,15 @@ where
{ {
pub fn init(ostream: T) -> Self { pub fn init(ostream: T) -> Self {
let (width, height) = size().unwrap(); 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 { let mut screen = Screen {
data: vec![String::with_capacity(width as usize); height as usize], data,
ostream, ostream,
width: width as usize, width,
height: height as usize, height,
}; };
enable_raw_mode().expect("[-]: Error: ui::init: Failed to enable raw mode."); enable_raw_mode().expect("[-]: Error: ui::init: Failed to enable raw mode.");
@ -40,86 +43,126 @@ where
screen screen
} }
pub fn deinit(&mut self) -> io::Result<()> { pub fn deinit(&mut self) {
disable_raw_mode()?; disable_raw_mode().unwrap_or(());
execute!(self.ostream, LeaveAlternateScreen)?; execute!(self.ostream, LeaveAlternateScreen).unwrap_or(());
Ok(())
} }
pub fn update_dimensions(&mut self) -> io::Result<()> { pub fn update_dimensions(&mut self) -> io::Result<()> {
let (width, height) = size()?; let (width, height) = size()?;
self.width = width as usize; self.width = width as usize;
self.height = height 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(()) Ok(())
} }
pub fn clear(&mut self) -> io::Result<()> { 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<()> { pub fn draw(&mut self, row: usize, col: usize) -> io::Result<()> {
self.draw_screen()?; self.draw_screen()?;
self.place_cursor(row, col)?; self.draw_cursor(row, col)?;
self.ostream.flush()?; self.ostream.flush()?;
Ok(()) Ok(())
} }
pub fn draw_screen(&mut self) -> io::Result<()> { pub fn draw_screen(&mut self) -> io::Result<()> {
self.clear()?; self.clear()?;
let s: String = self.data.join("\r\n"); let lft_pad = self.width / 2 - 14;
write!(self.ostream, "{}", s)?; 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(()) Ok(())
} }
pub fn render(&mut self, state: &State) { pub fn render(&mut self, state: &State) {
let mut lines = Vec::new(); self.render_board();
lines.push("┌────────┬────────┬────────┐".to_string()); self.render_board_cells(state);
self.render_scoreboard_elements(state);
}
for (row, row_slice) in state.board.iter().enumerate() { fn render_board(&mut self) {
match row { self.data = RENDER_TEMPLATE.iter().map(|s| s.to_vec()).collect();
3 | 6 => lines.push("├────────┼────────┼────────┤".to_string()),
_ => {}
} }
let mut line = String::new();
for (col, cur_cell) in row_slice.iter().enumerate() { fn render_board_cells(&mut self, state: &State) {
match col { for row in 0..9 {
0 => line.push_str(""), for col in 0..9 {
3 | 6 => line.push_str(""), let i = row + 1 + row / 3;
_ => {} let j = col + 2 + col + col / 3 * 3;
}
line.push(match cur_cell { self.data[i][j] = match state.board[row][col] {
0 => ' ', 0 => ' ',
n => (b'0' + n) as char, x => (x + b'0') 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; 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;
} }
pub fn place_cursor(&mut self, row: usize, col: usize) -> io::Result<()> { 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) = ( let (x, y) = (
(self.width / 2 - 14) as u16, (self.width / 2 - 14) as u16,
(self.height / 2 - 7 + (self.height & 1)) as u16, (self.height / 2 - 7 + (self.height & 1)) as u16,
@ -139,3 +182,71 @@ where
queue!(self.ostream, MoveTo(x, y), SetCursorStyle::SteadyBlock) 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', ' ', '│',
],
[
'└', '─', '─', '─', '─', '─', '─', '─', '─', '┴', '─', '─', '─', '─', '─', '─', '─', '─',
'┴', '─', '─', '─', '─', '─', '─', '─', '─', '┘', ' ', ' ', ' ', ' ', '└', '─', '─', '─',
'─', '─', '─', '─', '┘',
],
];