diff --git a/Cargo.lock b/Cargo.lock index eb0f17a..20b7fa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,9 +70,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "cassowary" @@ -172,7 +172,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.6.0", "crossterm_winapi", "mio", "parking_lot", @@ -242,9 +242,9 @@ checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" [[package]] name = "getrandom" -version = "0.2.11" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", @@ -317,7 +317,7 @@ version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.6.0", "libc", "redox_syscall", ] @@ -403,18 +403,18 @@ checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -425,7 +425,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.6.0", "cassowary", "compact_str", "crossterm", @@ -466,7 +466,7 @@ version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", @@ -583,9 +583,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.39" +version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ "proc-macro2", "quote", @@ -623,9 +623,9 @@ dependencies = [ [[package]] name = "uci" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6b9b0b262ac8343865c678e09dc793f4065e1359507608a1fe1d2c34426c40" +checksum = "4c1b9eaa4d073139668463f22d8f5151f53600a10909b9902ffe7547b176044a" dependencies = [ "log", ] @@ -708,7 +708,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.6", ] [[package]] @@ -728,17 +728,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -749,9 +750,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -761,9 +762,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -773,9 +774,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" @@ -785,9 +792,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -797,9 +804,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -809,9 +816,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -821,6 +828,6 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml index c2333cb..2db239b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/thomas-mauran/chess-tui" clap = { version = "4.4.11", features = ["derive"] } dirs = "5.0.1" ratatui = "0.28.1" -uci = "0.2.0" +uci = "0.2.1" toml = "0.5.8" [features] diff --git a/README.md b/README.md index 74aa659..59e544a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

chess-tui

-A chess TUI implementation in rust πŸ¦€ +A rusty chess game in your terminal πŸ¦€ ![board](./examples/play_against_white_bot.gif) @@ -10,6 +10,10 @@ A chess TUI implementation in rust πŸ¦€
+### Description + +Chess-tui is a simple chess game you can play from your terminal. It supports local 2 players mode, online multiplayer and playing against any UCI compatible chess engine. + ### Quick install ```bash @@ -30,7 +34,11 @@ If you want to install the game with your favorite package manager, you can find
Local 2 player mode - Helper menu + Local 2 players +
+
+ Online multiplayer + Online multiplayer
Draws diff --git a/examples/demo-two-player.gif b/examples/demo-two-player.gif index e3c4036..6b3f556 100644 Binary files a/examples/demo-two-player.gif and b/examples/demo-two-player.gif differ diff --git a/examples/demo-two-player.tape b/examples/demo-two-player.tape index 2a7f999..cc9dc83 100644 --- a/examples/demo-two-player.tape +++ b/examples/demo-two-player.tape @@ -15,61 +15,62 @@ Set WindowBarSize 40 Type "cargo run" Sleep 500ms Enter Sleep 0.5s -Down @0.8s -Down @0.8s -Down @0.8s -Down @0.8s -Down @0.8s -Space @0.8s +Down @0.3s +Down @0.3s +Down @0.3s +Down @0.3s +Down @0.3s +Down @0.3s +Space @0.3s Sleep 1.5s -Down @0.8s -Down @0.8s -Space @0.8s +Down @0.3s +Down @0.3s +Space @0.3s Sleep 0.8s -Space @0.8s +Space @0.3s Sleep 0.8s -Left @0.8s -Space @0.8s +Left @0.3s +Space @0.3s Sleep 0.8s -Space @0.8s +Space @0.3s -Down @0.8s -Space @0.8s +Down @0.3s +Space @0.3s Sleep 0.8s -Space @0.8s +Space @0.3s Sleep 0.8s -Right @0.8s -Right @0.8s -Right @0.8s -Space @0.8s +Right @0.3s +Right @0.3s +Right @0.3s +Space @0.3s Sleep 0.8s -Space @0.8s +Space @0.3s Sleep 0.8s -Left @0.8s -Space @0.8s +Left @0.3s +Space @0.3s Sleep 0.8s -Right @0.8s -Right @0.8s -Space @0.8s +Right @0.3s +Right @0.3s +Space @0.3s Sleep 0.8s -Up @0.8s -Right @0.8s -Right @0.8s -Space @0.8s +Up @0.3s +Right @0.3s +Right @0.3s +Space @0.3s Sleep 0.8s -Space @0.8s +Space @0.3s Sleep 0.8s -Up @0.8s -Up @0.8s -Up @0.8s -Space @0.8s +Up @0.3s +Up @0.3s +Up @0.3s +Space @0.3s Sleep 0.8s -Space @0.8s +Space @0.3s Sleep 0.8s diff --git a/examples/helper.gif b/examples/helper.gif index e02ffe2..895c2e4 100644 Binary files a/examples/helper.gif and b/examples/helper.gif differ diff --git a/examples/helper.tape b/examples/helper.tape index 23351d7..5936e7d 100644 --- a/examples/helper.tape +++ b/examples/helper.tape @@ -18,6 +18,7 @@ Sleep 2s Down @0.3s Down @0.3s Down @0.3s +Down @0.3s Space @0.3s Sleep 1s diff --git a/examples/play_against_black_bot.gif b/examples/play_against_black_bot.gif index 2b67df2..32d30a9 100644 Binary files a/examples/play_against_black_bot.gif and b/examples/play_against_black_bot.gif differ diff --git a/examples/play_against_black_bot.tape b/examples/play_against_black_bot.tape index 0658fa3..1050c80 100644 --- a/examples/play_against_black_bot.tape +++ b/examples/play_against_black_bot.tape @@ -15,37 +15,38 @@ Set WindowBarSize 40 Type "cargo run" Sleep 500ms Enter Sleep 2s -Down @0.8s -Space @0.8s +Down @0.3s +Down @0.3s +Space @0.3s Sleep 1s -Right @0.8s -Space @0.8s +Right @0.3s +Space @0.3s Sleep 0.3s -Down @0.8s -Down @0.8s -Space @0.8s +Down @0.3s +Down @0.3s +Space @0.3s Sleep 0.3s -Space @0.8s +Space @0.3s -Right @0.8s -Space @0.8s +Right @0.3s +Space @0.3s Sleep 0.3s -Space @0.8s +Space @0.3s -Right @0.8s -Space @0.8s +Right @0.3s +Space @0.3s Sleep 0.3s -Space @0.8s - -Left @0.8s -Left @0.8s -Left @0.8s -Left @0.8s -Left @0.8s -Space @0.8s +Space @0.3s + +Left @0.3s +Left @0.3s +Left @0.3s +Left @0.3s +Left @0.3s +Space @0.3s Sleep 0.8s -Space @0.8s +Space @0.3s Sleep 0.8s diff --git a/examples/play_against_white_bot.gif b/examples/play_against_white_bot.gif index 73626a6..0fb7307 100644 Binary files a/examples/play_against_white_bot.gif and b/examples/play_against_white_bot.gif differ diff --git a/examples/play_against_white_bot.tape b/examples/play_against_white_bot.tape index ee9f940..f04b160 100644 --- a/examples/play_against_white_bot.tape +++ b/examples/play_against_white_bot.tape @@ -15,40 +15,41 @@ Set WindowBarSize 40 Type "cargo run" Sleep 500ms Enter Sleep 2s -Down @0.8s -Space @0.8s +Down @0.3s +Down @0.3s +Space @0.3s Sleep 0.5s -Space @0.8s +Space @0.3s Sleep 0.5s -Down @0.8s -Down @0.8s -Space @0.8s -Space @0.8s - -Down @0.8s -Space @0.8s -Space @0.8s - -Up @0.8s -Space @0.8s -Space @0.8s - -Up @0.8s -Left @0.8s -Space @0.8s -Space @0.8s - -Up @0.8s -Left @0.8s -Space @0.8s -Right @0.8s -Space @0.8s - -Left @0.8s -Down @0.8s -Space @0.8s +Down @0.3s +Down @0.3s +Space @0.3s +Space @0.3s + +Down @0.3s +Space @0.3s +Space @0.3s + +Up @0.3s +Space @0.3s +Space @0.3s + +Up @0.3s +Left @0.3s +Space @0.3s +Space @0.3s + +Up @0.3s +Left @0.3s +Space @0.3s +Right @0.3s +Space @0.3s + +Left @0.3s +Down @0.3s +Space @0.3s Sleep 0.5s -Space @0.8s +Space @0.3s Sleep 5s \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 63d5302..d1ae04a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,14 +2,18 @@ use dirs::home_dir; use toml::Value; use crate::{ - constants::{DisplayMode, Pages}, - game_logic::{bot::Bot, game::Game}, + constants::{DisplayMode, Pages, Popups}, + game_logic::{bot::Bot, game::Game, opponent::Opponent}, pieces::PieceColor, + server::game_server::GameServer, }; use std::{ error, fs::{self, File}, io::Write, + net::{IpAddr, UdpSocket}, + thread::sleep, + time::Duration, }; /// Application result type. @@ -23,12 +27,14 @@ pub struct App { pub game: Game, /// Current page to render pub current_page: Pages, - /// Used to show the help popup during the game or in the home menu - pub show_help_popup: bool, - /// Used to show the side selection popup when playing against the bot - pub show_color_popup: bool, + /// Current popup to render + pub current_popup: Option, // Selected color when playing against the bot pub selected_color: Option, + /// Hosting + pub hosting: Option, + /// Host Ip + pub host_ip: Option, /// menu current cursor pub menu_cursor: u8, /// path of the chess engine @@ -41,9 +47,10 @@ impl Default for App { running: true, game: Game::default(), current_page: Pages::Home, - show_help_popup: false, - show_color_popup: false, + current_popup: None, selected_color: None, + hosting: None, + host_ip: None, menu_cursor: 0, chess_engine_path: None, } @@ -52,7 +59,11 @@ impl Default for App { impl App { pub fn toggle_help_popup(&mut self) { - self.show_help_popup = !self.show_help_popup; + if self.current_popup == Some(Popups::Help) { + self.current_popup = None; + } else { + self.current_popup = Some(Popups::Help); + } } pub fn toggle_credit_popup(&mut self) { if self.current_page == Pages::Home { @@ -62,11 +73,64 @@ impl App { } } + pub fn setup_game_server(&mut self, host_color: PieceColor) { + let is_host_white = host_color == PieceColor::White; + + std::thread::spawn(move || { + let game_server = GameServer::new(is_host_white); + game_server.run(); + }); + + sleep(Duration::from_millis(100)); + } + + pub fn create_opponent(&mut self) { + let other_player_color = if self.selected_color.is_some() { + Some(self.selected_color.unwrap().opposite()) + } else { + None + }; + if self.hosting.unwrap() { + self.current_popup = Some(Popups::WaitingForOpponentToJoin); + self.host_ip = Some(format!("{}:2308", self.get_host_ip())); + } + + let addr = self.host_ip.as_ref().unwrap().to_string(); + let addr_with_port = addr.to_string(); + + // ping the server to see if it's up + + let s = UdpSocket::bind(addr_with_port.clone()); + if s.is_err() { + eprintln!("\nServer is unreachable. Make sure you entered the correct IP and port."); + self.host_ip = None; + return; + } + + self.game.opponent = Some(Opponent::new(addr_with_port, other_player_color)); + + if !self.hosting.unwrap() { + // If we are not hosting (joining) we set the selected color as the opposite of the opposite player color + self.selected_color = Some(self.game.opponent.as_mut().unwrap().color.opposite()); + self.game.opponent.as_mut().unwrap().game_started = true; + } + if self.selected_color.unwrap() == PieceColor::Black { + self.game.game_board.flip_the_board(); + } + } + pub fn go_to_home(&mut self) { self.current_page = Pages::Home; self.restart(); } + pub fn get_host_ip(&self) -> IpAddr { + let socket = UdpSocket::bind("0.0.0.0:0").unwrap(); + socket.connect("8.8.8.8:80").unwrap(); // Use an external IP to identify the default route + + socket.local_addr().unwrap().ip() + } + /// Handles the tick event of the terminal. pub fn tick(&self) {} @@ -105,15 +169,16 @@ impl App { } pub fn color_selection(&mut self) { - self.show_color_popup = false; - + self.current_popup = None; let color = match self.menu_cursor { 0 => PieceColor::White, 1 => PieceColor::Black, _ => unreachable!("Invalid color selection"), }; self.selected_color = Some(color); + } + pub fn bot_setup(&mut self) { let empty = "".to_string(); let path = match self.chess_engine_path.as_ref() { Some(engine_path) => engine_path, @@ -131,10 +196,21 @@ impl App { } } + pub fn hosting_selection(&mut self) { + let choice = self.menu_cursor == 0; + self.hosting = Some(choice); + self.current_popup = None; + } + pub fn restart(&mut self) { let bot = self.game.bot.clone(); + let opponent = self.game.opponent.clone(); self.game = Game::default(); + self.game.bot = bot; + self.game.opponent = opponent; + self.current_popup = None; + if self.game.bot.as_ref().is_some() && self .game @@ -152,17 +228,21 @@ impl App { 0 => self.current_page = Pages::Solo, 1 => { self.menu_cursor = 0; - self.current_page = Pages::Bot + self.current_page = Pages::Multiplayer } 2 => { + self.menu_cursor = 0; + self.current_page = Pages::Bot + } + 3 => { self.game.ui.display_mode = match self.game.ui.display_mode { DisplayMode::ASCII => DisplayMode::DEFAULT, DisplayMode::DEFAULT => DisplayMode::ASCII, }; self.update_config(); } - 3 => self.show_help_popup = true, - 4 => self.current_page = Pages::Credit, + 4 => self.toggle_help_popup(), + 5 => self.current_page = Pages::Credit, _ => {} } } @@ -187,4 +267,14 @@ impl App { let mut file = File::create(config_path.clone()).unwrap(); file.write_all(config.to_string().as_bytes()).unwrap(); } + + pub fn reset(&mut self) { + self.game = Game::default(); + self.current_popup = None; + self.selected_color = None; + self.hosting = None; + self.host_ip = None; + self.menu_cursor = 0; + self.chess_engine_path = None; + } } diff --git a/src/constants.rs b/src/constants.rs index 30938c8..2cda12d 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -38,15 +38,26 @@ pub fn home_dir() -> Result { } } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub enum Pages { Home, Solo, + Multiplayer, Bot, Credit, } impl Pages { pub fn variant_count() -> usize { - 5 + 6 } } + +#[derive(Debug, PartialEq, Clone)] +pub enum Popups { + ColorSelection, + MultiplayerSelection, + EnterHostIP, + WaitingForOpponentToJoin, + EnginePathError, + Help, +} diff --git a/src/game_logic/game.rs b/src/game_logic/game.rs index 35c307e..f5ca10b 100644 --- a/src/game_logic/game.rs +++ b/src/game_logic/game.rs @@ -1,4 +1,4 @@ -use super::{bot::Bot, coord::Coord, game_board::GameBoard, ui::UI}; +use super::{bot::Bot, coord::Coord, game_board::GameBoard, opponent::Opponent, ui::UI}; use crate::{ pieces::{PieceColor, PieceMove, PieceType}, utils::get_int_from_char, @@ -12,7 +12,6 @@ pub enum GameState { Promotion, } -#[derive(Clone)] pub struct Game { /// The GameBoard storing data about the board related stuff pub game_board: GameBoard, @@ -20,18 +19,41 @@ pub struct Game { pub ui: UI, /// The struct to handle Bot related stuff pub bot: Option, + /// The other player when playing in multiplayer + pub opponent: Option, /// Which player is it to play pub player_turn: PieceColor, /// The current state of the game (Playing, Draw, Checkmate. Promotion) pub game_state: GameState, } +impl Clone for Game { + fn clone(&self) -> Self { + let opponent_clone = self.opponent.as_ref().map(|p| Opponent { + stream: p.stream.as_ref().and_then(|s| s.try_clone().ok()), + opponent_will_move: p.opponent_will_move, + color: p.color, + game_started: p.game_started, + }); + + Game { + game_board: self.game_board.clone(), + ui: self.ui.clone(), + bot: self.bot.clone(), + opponent: opponent_clone, + player_turn: self.player_turn, + game_state: self.game_state, + } + } +} + impl Default for Game { fn default() -> Self { Self { game_board: GameBoard::default(), ui: UI::default(), bot: None, + opponent: None, player_turn: PieceColor::White, game_state: GameState::Playing, } @@ -45,6 +67,7 @@ impl Game { game_board, ui: UI::default(), bot: None, + opponent: None, player_turn, game_state: GameState::Playing, } @@ -69,88 +92,135 @@ impl Game { } // Methods to select a cell on the board - // TODO: Split this in multiple methods - pub fn select_cell(&mut self) { + pub fn handle_cell_click(&mut self) { // If we are doing a promotion the cursor is used for the popup if self.game_state == GameState::Promotion { - self.promote_piece(); + self.handle_promotion(); } else if !(self.game_state == GameState::Checkmate) && !(self.game_state == GameState::Draw) { if self.ui.is_cell_selected() { - // We already selected a piece so we apply the move - if self.ui.cursor_coordinates.is_valid() { - let selected_coords_usize = &self.ui.selected_coordinates.clone(); - let cursor_coords_usize = &self.ui.cursor_coordinates.clone(); - self.execute_move(selected_coords_usize, cursor_coords_usize); - self.ui.unselect_cell(); - self.switch_player_turn(); + self.already_selected_cell_action(); + } else { + self.select_cell() + } + } + self.update_game_state(); + } - if self.game_board.is_draw(self.player_turn) { - self.game_state = GameState::Draw; + fn update_game_state(&mut self) { + if self.game_board.is_checkmate(self.player_turn) { + self.game_state = GameState::Checkmate; + } else if self.game_board.is_draw(self.player_turn) { + self.game_state = GameState::Draw; + } else if self.game_board.is_latest_move_promotion() { + self.game_state = GameState::Promotion; + } + } + + pub fn handle_promotion(&mut self) { + self.promote_piece(); + + if self.opponent.is_some() { + self.handle_multiplayer_promotion(); + } + + if self.bot.is_some() { + self.execute_bot_move(); + } + } + pub fn already_selected_cell_action(&mut self) { + // We already selected a piece so we apply the move + if self.ui.cursor_coordinates.is_valid() { + let selected_coords_usize = &self.ui.selected_coordinates.clone(); + let cursor_coords_usize = &self.ui.cursor_coordinates.clone(); + self.execute_move(selected_coords_usize, cursor_coords_usize); + self.ui.unselect_cell(); + self.switch_player_turn(); + + if self.game_board.is_draw(self.player_turn) { + self.game_state = GameState::Draw; + } + + if (self.bot.is_none() || (self.bot.as_ref().map_or(false, |bot| bot.is_bot_starting))) + && (self.opponent.is_none()) + && (!self.game_board.is_latest_move_promotion() + || self.game_board.is_draw(self.player_turn) + || self.game_board.is_checkmate(self.player_turn)) + { + self.game_board.flip_the_board(); + } + + // If we play against a bot we will play his move and switch the player turn again + if self.bot.is_some() { + // do this in background + if self.game_board.is_latest_move_promotion() { + self.game_state = GameState::Promotion; + } + + if !(self.game_state == GameState::Promotion) { + if self.game_board.is_checkmate(self.player_turn) { + self.game_state = GameState::Checkmate; } - if (self.bot.is_none() - || (self.bot.as_ref().map_or(false, |bot| bot.is_bot_starting))) - && (!self.game_board.is_latest_move_promotion() - || self.game_board.is_draw(self.player_turn) - || self.game_board.is_checkmate(self.player_turn)) - { - self.game_board.flip_the_board(); + if self.game_board.is_draw(self.player_turn) { + self.game_state = GameState::Draw; } - // If we play against a bot we will play his move and switch the player turn again - if self.bot.is_some() { - // do this in background - if self.game_board.is_latest_move_promotion() { - self.game_state = GameState::Promotion; - } - if !(self.game_state == GameState::Promotion) { - if self.game_board.is_checkmate(self.player_turn) { - self.game_state = GameState::Checkmate; - } - if self.game_board.is_latest_move_promotion() { - self.game_state = GameState::Promotion; - } - if !(self.game_state == GameState::Checkmate) { - if let Some(bot) = self.bot.as_mut() { - bot.bot_will_move = true; - } - } + if !(self.game_state == GameState::Checkmate) { + if let Some(bot) = self.bot.as_mut() { + bot.bot_will_move = true; } } } - } else { - // Check if the piece on the cell can move before selecting it - let authorized_positions = self - .game_board - .get_authorized_positions(self.player_turn, self.ui.cursor_coordinates); + } + // If we play against a player we will wait for his move + if self.opponent.is_some() { + if self.game_board.is_latest_move_promotion() { + self.game_state = GameState::Promotion; + } else { + if self.game_board.is_checkmate(self.player_turn) { + self.game_state = GameState::Checkmate; + } - if authorized_positions.is_empty() { - return; - } - if let Some(piece_color) = - self.game_board.get_piece_color(&self.ui.cursor_coordinates) - { - let authorized_positions = self - .game_board - .get_authorized_positions(self.player_turn, self.ui.cursor_coordinates); - - if piece_color == self.player_turn { - self.ui.selected_coordinates = self.ui.cursor_coordinates; - self.ui.old_cursor_position = self.ui.cursor_coordinates; - self.ui - .move_selected_piece_cursor(true, 1, authorized_positions); + if self.game_board.is_draw(self.player_turn) { + self.game_state = GameState::Draw; } + + if !(self.game_state == GameState::Checkmate) { + if let Some(opponent) = self.opponent.as_mut() { + opponent.opponent_will_move = true; + } + } + self.opponent + .as_mut() + .unwrap() + .send_move_to_server(self.game_board.move_history.last().unwrap(), None); } } } - if self.game_board.is_checkmate(self.player_turn) { - self.game_state = GameState::Checkmate; - } else if self.game_board.is_draw(self.player_turn) { - self.game_state = GameState::Draw; - } else if self.game_board.is_latest_move_promotion() { - self.game_state = GameState::Promotion; + } + + pub fn select_cell(&mut self) { + // Check if the piece on the cell can move before selecting it + let authorized_positions = self + .game_board + .get_authorized_positions(self.player_turn, self.ui.cursor_coordinates); + + if authorized_positions.is_empty() { + return; + } + if let Some(piece_color) = self.game_board.get_piece_color(&self.ui.cursor_coordinates) { + let authorized_positions = self + .game_board + .get_authorized_positions(self.player_turn, self.ui.cursor_coordinates); + + if piece_color == self.player_turn { + self.ui.selected_coordinates = self.ui.cursor_coordinates; + self.ui.old_cursor_position = self.ui.cursor_coordinates; + self.ui + .move_selected_piece_cursor(true, 1, authorized_positions); + } } } @@ -222,11 +292,19 @@ impl Game { self.game_board.board[last_move.to.row as usize][last_move.to.col as usize] = Some((new_piece, piece_color)); } + + // We replace the piece type in the move history + let latest_move = self.game_board.move_history.last_mut().unwrap(); + latest_move.piece_type = new_piece; + self.game_board.board_history.pop(); + self.game_board.board_history.push(self.game_board.board); } self.game_state = GameState::Playing; self.ui.promotion_cursor = 0; if !self.game_board.is_draw(self.player_turn) && !self.game_board.is_checkmate(self.player_turn) + && self.opponent.is_none() + && self.bot.is_none() { self.game_board.flip_the_board(); } @@ -314,4 +392,53 @@ impl Game { // We store the current position of the board self.game_board.board_history.push(self.game_board.board); } + + pub fn execute_opponent_move(&mut self) { + let opponent_move = self.opponent.as_mut().unwrap().read_stream(); + self.game_board.flip_the_board(); + self.opponent.as_mut().unwrap().opponent_will_move = false; + + if opponent_move.is_empty() { + return; + } + + let from_y = get_int_from_char(opponent_move.chars().next()); + let from_x = get_int_from_char(opponent_move.chars().nth(1)); + let to_y = get_int_from_char(opponent_move.chars().nth(2)); + let to_x = get_int_from_char(opponent_move.chars().nth(3)); + + let mut promotion_piece: Option = None; + if opponent_move.chars().count() == 5 { + promotion_piece = match opponent_move.chars().nth(4) { + Some('q') => Some(PieceType::Queen), + Some('r') => Some(PieceType::Rook), + Some('b') => Some(PieceType::Bishop), + Some('n') => Some(PieceType::Knight), + _ => None, + }; + } + + let from = &Coord::new(from_y, from_x); + let to = &Coord::new(to_y, to_x); + + self.execute_move(from, to); + + if promotion_piece.is_some() { + self.game_board.board[to_y as usize][to_x as usize] = + Some((promotion_piece.unwrap(), self.player_turn)); + } + self.game_board.flip_the_board(); + } + + pub fn handle_multiplayer_promotion(&mut self) { + let opponent = self.opponent.as_mut().unwrap(); + + let last_move_promotion_type = self.game_board.get_last_move_piece_type_as_string(); + + opponent.send_move_to_server( + self.game_board.move_history.last().unwrap(), + Some(last_move_promotion_type), + ); + opponent.opponent_will_move = true; + } } diff --git a/src/game_logic/game_board.rs b/src/game_logic/game_board.rs index c42e2c8..574da7f 100644 --- a/src/game_logic/game_board.rs +++ b/src/game_logic/game_board.rs @@ -78,6 +78,20 @@ impl GameBoard { } } + pub fn get_last_move_piece_type_as_string(&self) -> String { + if let Some(last_move) = self.move_history.last() { + match last_move.piece_type { + PieceType::Pawn => return String::from("p"), + PieceType::Rook => return String::from("r"), + PieceType::Knight => return String::from("n"), + PieceType::Bishop => return String::from("b"), + PieceType::Queen => return String::from("q"), + PieceType::King => return String::from("k"), + } + } + String::from("") + } + pub fn increment_consecutive_non_pawn_or_capture( &mut self, piece_type_from: PieceType, @@ -143,6 +157,11 @@ impl GameBoard { self.get_piece_type(&coordinates), self.get_piece_color(&coordinates), ) { + // If the piece color is not the same as the player turn we return an empty vector it's not his turn + if player_turn != piece_color { + return vec![]; + } + piece_type.authorized_positions( &coordinates, piece_color, diff --git a/src/game_logic/mod.rs b/src/game_logic/mod.rs index 67aa5d8..750d2cb 100644 --- a/src/game_logic/mod.rs +++ b/src/game_logic/mod.rs @@ -3,4 +3,5 @@ pub mod bot; pub mod coord; pub mod game; pub mod game_board; +pub mod opponent; pub mod ui; diff --git a/src/game_logic/opponent.rs b/src/game_logic/opponent.rs new file mode 100644 index 0000000..8ab4272 --- /dev/null +++ b/src/game_logic/opponent.rs @@ -0,0 +1,183 @@ +use crate::pieces::{PieceColor, PieceMove}; +use std::{ + io::{Read, Write}, + net::TcpStream, + panic, +}; + +pub struct Opponent { + // The stream to communicate with the engine + pub stream: Option, + /// Used to indicate if a Opponent move is following + pub opponent_will_move: bool, + // The color of the Opponent + pub color: PieceColor, + /// Is Game started + pub game_started: bool, +} + +// Custom Default implementation +impl Default for Opponent { + fn default() -> Self { + Opponent { + stream: None, + opponent_will_move: false, + color: PieceColor::Black, + game_started: false, + } + } +} + +impl Clone for Opponent { + fn clone(&self) -> Self { + Opponent { + stream: self.stream.as_ref().and_then(|s| s.try_clone().ok()), // Custom handling for TcpStream + opponent_will_move: self.opponent_will_move, + color: self.color, + game_started: self.game_started, + } + } +} + +impl Opponent { + pub fn copy(&self) -> Self { + Opponent { + stream: None, + opponent_will_move: self.opponent_will_move, + color: self.color, + game_started: self.game_started, + } + } + + pub fn new(addr: String, color: Option) -> Opponent { + // Attempt to connect 5 times to the provided address + let mut stream: Option = None; // Initialize `stream` as None + for _ in 0..5 { + match TcpStream::connect(addr.clone()) { + Ok(s) => { + stream = Some(s); + break; + } + Err(_) => { + println!( + "Failed to connect to the server addr: {}. Retrying...", + addr + ); + } + } + } + + if let Some(stream) = stream { + // Determine the Opponent's color + let color = match color { + Some(color) => color, // Use the provided color if available + None => get_color_from_stream(&stream), + }; + + let opponent_will_move = match color { + PieceColor::White => true, + PieceColor::Black => false, + }; + + Opponent { + stream: Some(stream), + opponent_will_move, + color, + game_started: false, + } + } else { + panic!( + "Failed to connect to the server after 5 attempts to the following address {}", + addr + ); + } + } + + pub fn start_stream(&mut self, addr: &str) { + match TcpStream::connect(addr) { + Ok(stream) => { + self.stream = Some(stream); + } + Err(e) => { + panic!("Failed to connect: {}", e); + } + } + } + + pub fn send_end_game_to_server(&mut self) { + if let Some(game_stream) = self.stream.as_mut() { + if let Err(e) = game_stream.write_all("ended".as_bytes()) { + eprintln!("Failed to send end game: {}", e); + } + } + } + + pub fn send_move_to_server( + &mut self, + move_to_send: &PieceMove, + promotion_type: Option, + ) { + if let Some(game_stream) = self.stream.as_mut() { + let move_str = format!( + "{}{}{}{}{}", + move_to_send.from.row, + move_to_send.from.col, + move_to_send.to.row, + move_to_send.to.col, + match promotion_type { + Some(promotion) => promotion, + None => "".to_string(), + } + ); + if let Err(e) = game_stream.write_all(move_str.as_bytes()) { + eprintln!("Failed to send move: {}", e); + } + } + } + + pub fn read_stream(&mut self) -> String { + if let Some(game_stream) = self.stream.as_mut() { + let mut buffer = vec![0; 5]; + let buf = game_stream.read(&mut buffer); + match buf { + Ok(bytes_read) => { + let response = String::from_utf8_lossy(&buffer[..bytes_read]); + + if response.trim() == "ended" || response.trim() == "" { + panic!("Game ended by the other Opponent"); + } + response.to_string() + } + Err(e) => { + eprintln!("Failed to read from stream: {}", e); + "".to_string() + } + } + } else { + "".to_string() + } + } +} + +pub fn get_color_from_stream(mut stream: &TcpStream) -> PieceColor { + let mut buffer = [0; 5]; + let bytes_read = stream.read(&mut buffer).unwrap(); // Number of bytes read + let color = String::from_utf8_lossy(&buffer[..bytes_read]).to_string(); + + match color.as_str() { + "w" => PieceColor::White, + "b" => PieceColor::Black, + _ => panic!("Failed to get color from stream"), + } +} + +pub fn wait_for_game_start(mut stream: &TcpStream) { + let mut buffer = [0; 5]; + let bytes_read = stream.read(&mut buffer).unwrap(); // Number of bytes read + let response = String::from_utf8_lossy(&buffer[..bytes_read]).to_string(); + + match response.as_str() { + "s" => (), + _ => panic!("Failed to get color from stream"), + } +} diff --git a/src/game_logic/ui.rs b/src/game_logic/ui.rs index ebb388f..08d4ce6 100644 --- a/src/game_logic/ui.rs +++ b/src/game_logic/ui.rs @@ -2,7 +2,7 @@ use super::{coord::Coord, game::Game}; use crate::{ constants::{DisplayMode, BLACK, UNDEFINED_POSITION, WHITE}, pieces::{PieceColor, PieceMove, PieceType}, - ui::main_ui::render_cell, + ui::{main_ui::render_cell, prompt::Prompt}, utils::{convert_position_into_notation, get_cell_paragraph, invert_position}, }; use ratatui::{ @@ -35,6 +35,8 @@ pub struct UI { pub mouse_used: bool, /// The skin of the game pub display_mode: DisplayMode, + // The prompt for the player + pub prompt: Prompt, } impl Default for UI { @@ -51,6 +53,7 @@ impl Default for UI { height: 0, mouse_used: false, display_mode: DisplayMode::DEFAULT, + prompt: Prompt::new(), } } } @@ -320,7 +323,7 @@ impl UI { } /// Method to render the board - pub fn board_render(&mut self, area: Rect, frame: &mut Frame, game: &Game) { + pub fn board_render(&mut self, area: Rect, frame: &mut Frame<'_>, game: &Game) { let width = area.width / 8; let height = area.height / 8; let border_height = area.height / 2 - (4 * height); @@ -390,6 +393,14 @@ impl UI { last_move_from = invert_position(&last_move.map(|m| m.from).unwrap()); last_move_to = invert_position(&last_move.map(|m| m.to).unwrap()); } + + // If the opponent is the same as the last move player, we don't want to show his last move + if game.opponent.is_some() + && game.opponent.as_ref().unwrap().color == game.player_turn + { + last_move_from = Coord::undefined(); + last_move_to = Coord::undefined(); + } } let mut positions: Vec = vec![]; diff --git a/src/handler.rs b/src/handler.rs index bfa5888..b61db8a 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,3 +1,4 @@ +use crate::constants::Popups; use crate::game_logic::coord::Coord; use crate::game_logic::game::GameState; use crate::{ @@ -25,130 +26,230 @@ pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { } } - match key_event.code { - // Exit application on `q` - KeyCode::Char('q') => { - app.quit(); + if app.current_popup == Some(Popups::EnterHostIP) { + if key_event.kind == KeyEventKind::Press { + match key_event.code { + KeyCode::Enter => { + app.game.ui.prompt.submit_message(); + if app.current_page == Pages::Multiplayer { + app.host_ip = Some(app.game.ui.prompt.message.clone()); + } + app.current_popup = None; + } + KeyCode::Char(to_insert) => app.game.ui.prompt.enter_char(to_insert), + KeyCode::Backspace => app.game.ui.prompt.delete_char(), + KeyCode::Left => app.game.ui.prompt.move_cursor_left(), + KeyCode::Right => app.game.ui.prompt.move_cursor_right(), + KeyCode::Esc => { + app.current_popup = None; + if app.current_page == Pages::Multiplayer { + app.hosting = None; + app.selected_color = None; + app.menu_cursor = 0; + } + app.current_page = Pages::Home; + } + _ => {} + } } - // Exit application on `Ctrl-C` - KeyCode::Char('c' | 'C') => { - if key_event.modifiers == KeyModifiers::CONTROL { + } else { + match key_event.code { + // Exit application on `q` + KeyCode::Char('q') => { app.quit(); } - } - // Counter handlers - KeyCode::Right | KeyCode::Char('l') => { - // When we are in the color selection menu - if app.current_page == Pages::Bot && app.selected_color.is_none() { - app.menu_cursor_right(2); - } else if app.game.game_state == GameState::Promotion { - app.game.ui.cursor_right_promotion(); - } else if !(app.game.game_state == GameState::Checkmate) - && !(app.game.game_state == GameState::Draw) - { - let authorized_positions = app.game.game_board.get_authorized_positions( - app.game.player_turn, - app.game.ui.selected_coordinates, - ); - app.game.ui.cursor_right(authorized_positions); + // Exit application on `Ctrl-C` + KeyCode::Char('c' | 'C') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.quit(); + } + } + // Counter handlers + // Counter handlers + KeyCode::Right | KeyCode::Char('l') => { + if (app.current_page == Pages::Multiplayer + && (app.hosting.is_none() || app.selected_color.is_none())) + || (app.current_page == Pages::Bot && app.selected_color.is_none()) + { + app.menu_cursor_right(2); + } else if app.game.game_state == GameState::Promotion { + app.game.ui.cursor_right_promotion(); + } else if !(app.game.game_state == GameState::Checkmate) + && !(app.game.game_state == GameState::Draw) + { + let authorized_positions = app.game.game_board.get_authorized_positions( + app.game.player_turn, + app.game.ui.selected_coordinates, + ); + app.game.ui.cursor_right(authorized_positions); + } } - } - KeyCode::Left | KeyCode::Char('h') => { - // When we are in the color selection menu - if app.current_page == Pages::Bot && app.selected_color.is_none() { - app.menu_cursor_left(2); - } else if app.game.game_state == GameState::Promotion { - app.game.ui.cursor_left_promotion(); - } else if !(app.game.game_state == GameState::Checkmate) - && !(app.game.game_state == GameState::Draw) - { - let authorized_positions = app.game.game_board.get_authorized_positions( - app.game.player_turn, - app.game.ui.selected_coordinates, - ); - app.game.ui.cursor_left(authorized_positions); + KeyCode::Left | KeyCode::Char('h') => { + if (app.current_page == Pages::Multiplayer + && (app.hosting.is_none() || app.selected_color.is_none())) + || (app.current_page == Pages::Bot && app.selected_color.is_none()) + { + app.menu_cursor_left(2); + } else if app.game.game_state == GameState::Promotion { + app.game.ui.cursor_left_promotion(); + } else if !(app.game.game_state == GameState::Checkmate) + && !(app.game.game_state == GameState::Draw) + { + let authorized_positions = app.game.game_board.get_authorized_positions( + app.game.player_turn, + app.game.ui.selected_coordinates, + ); + + app.game.ui.cursor_left(authorized_positions); + } } - } - KeyCode::Up | KeyCode::Char('k') => { - if app.current_page == Pages::Home { - app.menu_cursor_up(Pages::variant_count() as u8); - } else if !(app.game.game_state == GameState::Checkmate) - && !(app.game.game_state == GameState::Draw) - && !(app.game.game_state == GameState::Promotion) - { - let authorized_positions = app.game.game_board.get_authorized_positions( - app.game.player_turn, - app.game.ui.selected_coordinates, - ); - app.game.ui.cursor_up(authorized_positions); + KeyCode::Up | KeyCode::Char('k') => { + if app.current_page == Pages::Home { + app.menu_cursor_up(Pages::variant_count() as u8); + } else if !(app.game.game_state == GameState::Checkmate) + && !(app.game.game_state == GameState::Draw) + && !(app.game.game_state == GameState::Promotion) + { + let authorized_positions = app.game.game_board.get_authorized_positions( + app.game.player_turn, + app.game.ui.selected_coordinates, + ); + app.game.ui.cursor_up(authorized_positions); + } } - } - KeyCode::Down | KeyCode::Char('j') => { - if app.current_page == Pages::Home { - app.menu_cursor_down(Pages::variant_count() as u8); - } else if !(app.game.game_state == GameState::Checkmate) - && !(app.game.game_state == GameState::Draw) - && !(app.game.game_state == GameState::Promotion) - { - let authorized_positions = app.game.game_board.get_authorized_positions( - app.game.player_turn, - app.game.ui.selected_coordinates, - ); + KeyCode::Down | KeyCode::Char('j') => { + if app.current_page == Pages::Home { + app.menu_cursor_down(Pages::variant_count() as u8); + } else if !(app.game.game_state == GameState::Checkmate) + && !(app.game.game_state == GameState::Draw) + && !(app.game.game_state == GameState::Promotion) + { + let authorized_positions = app.game.game_board.get_authorized_positions( + app.game.player_turn, + app.game.ui.selected_coordinates, + ); - app.game.ui.cursor_down(authorized_positions); + app.game.ui.cursor_down(authorized_positions); + } } - } - KeyCode::Char(' ') | KeyCode::Enter => { - if app.current_page == Pages::Bot && app.selected_color.is_none() { - app.color_selection(); - } else if app.current_page == Pages::Home { - app.menu_select(); - } else { - app.game.select_cell(); + KeyCode::Char(' ') | KeyCode::Enter => match app.current_page { + Pages::Home => { + app.menu_select(); + } + Pages::Bot => { + if app.selected_color.is_none() { + app.color_selection(); + app.bot_setup(); + } else { + app.game.handle_cell_click(); + } + } + Pages::Multiplayer => { + if app.hosting.is_none() { + app.hosting_selection(); + } else if app.selected_color.is_none() { + if app.hosting.is_some() && app.hosting.unwrap() { + app.color_selection(); + } + } else { + app.game.handle_cell_click(); + } + } + Pages::Credit => { + app.current_page = Pages::Home; + } + _ => { + app.game.handle_cell_click(); + } + }, + KeyCode::Char('?') => { + if app.current_page != Pages::Credit { + app.toggle_help_popup(); + } } - } - KeyCode::Char('?') => { - if app.current_page != Pages::Credit { - app.toggle_help_popup(); + KeyCode::Char('r') => { + // We can't restart the game if it's a multiplayer one + if app.game.opponent.is_none() { + app.restart(); + } } - } - KeyCode::Char('r') => app.restart(), - KeyCode::Esc => { - if app.show_help_popup { - app.show_help_popup = false; - } else if app.show_color_popup { - app.show_color_popup = false; - app.current_page = Pages::Home; - } else if app.current_page == Pages::Credit { - app.current_page = Pages::Home; - } else if app.current_page == Pages::Bot && app.selected_color.is_none() { - app.current_page = Pages::Home; - app.show_color_popup = false; - app.menu_cursor = 0; + KeyCode::Esc => { + match app.current_popup { + Some(Popups::ColorSelection) => { + app.current_popup = None; + app.selected_color = None; + app.hosting = None; + app.current_page = Pages::Home; + app.menu_cursor = 0; + } + Some(Popups::MultiplayerSelection) => { + app.current_popup = None; + app.selected_color = None; + app.hosting = None; + app.current_page = Pages::Home; + app.menu_cursor = 0; + } + Some(Popups::WaitingForOpponentToJoin) => { + app.current_popup = None; + app.selected_color = None; + app.hosting = None; + app.current_page = Pages::Home; + app.menu_cursor = 0; + } + Some(Popups::Help) => { + app.current_popup = None; + } + _ => {} + } + + match app.current_page { + Pages::Bot => { + app.current_page = Pages::Home; + app.menu_cursor = 0; + app.selected_color = None; + } + Pages::Credit => { + app.current_page = Pages::Home; + } + _ => {} + } + + app.game.ui.unselect_cell(); } - app.game.ui.unselect_cell(); - } - KeyCode::Char('b') => { - let display_mode = app.game.ui.display_mode; - app.selected_color = None; - if app.game.bot.is_some() { - app.game.bot = None; + KeyCode::Char('b') => { + let display_mode = app.game.ui.display_mode; + app.selected_color = None; + if app.game.bot.is_some() { + app.game.bot = None; + } + if app.game.opponent.is_some() { + app.game + .opponent + .as_mut() + .unwrap() + .send_end_game_to_server(); + app.game.opponent = None; + app.hosting = None; + app.host_ip = None; + } + + app.go_to_home(); + app.game.game_board.reset(); + app.game.ui.reset(); + app.game.ui.display_mode = display_mode; } - app.go_to_home(); - app.game.game_board.reset(); - app.game.ui.reset(); - app.game.ui.display_mode = display_mode; + // Other handlers you could add here. + _ => {} } - // Other handlers you could add here. - _ => {} } + Ok(()) } pub fn handle_mouse_events(mouse_event: MouseEvent, app: &mut App) -> AppResult<()> { // Mouse control only implemented for actual game - if app.current_page == Pages::Home { + if app.current_page == Pages::Home || app.current_page == Pages::Credit { return Ok(()); } if mouse_event.kind == MouseEventKind::Down(MouseButton::Left) { @@ -156,7 +257,7 @@ pub fn handle_mouse_events(mouse_event: MouseEvent, app: &mut App) -> AppResult< return Ok(()); } - if app.show_color_popup || app.show_help_popup { + if app.current_popup.is_some() { return Ok(()); } @@ -170,6 +271,9 @@ pub fn handle_mouse_events(mouse_event: MouseEvent, app: &mut App) -> AppResult< } app.game.ui.promotion_cursor = x as i8; app.game.promote_piece(); + if app.game.opponent.is_some() { + app.game.handle_multiplayer_promotion(); + } } if mouse_event.column < app.game.ui.top_x || mouse_event.row < app.game.ui.top_y { return Ok(()); @@ -199,7 +303,7 @@ pub fn handle_mouse_events(mouse_event: MouseEvent, app: &mut App) -> AppResult< } { app.game.ui.cursor_coordinates = coords; - app.game.select_cell(); + app.game.handle_cell_click(); } else { app.game.ui.selected_coordinates = coords; } diff --git a/src/lib.rs b/src/lib.rs index e8751aa..a6ab415 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ pub mod event; /// Widget renderer. pub mod ui; +pub mod server; + /// Event handler. pub mod handler; diff --git a/src/main.rs b/src/main.rs index 6198095..2d19686 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,11 +5,13 @@ use chess_tui::app::{App, AppResult}; use chess_tui::constants::{home_dir, DisplayMode}; use chess_tui::event::{Event, EventHandler}; use chess_tui::game_logic::game::GameState; +use chess_tui::game_logic::opponent::wait_for_game_start; use chess_tui::handler::{handle_key_events, handle_mouse_events}; use chess_tui::ui::tui::Tui; use clap::Parser; use std::fs::{self, File}; use std::io::Write; +use std::panic; use std::path::Path; use toml::Value; @@ -39,7 +41,7 @@ fn main() -> AppResult<()> { config_create(&args, &folder_path, &config_path)?; // Create an application. - let mut app: App = App::default(); + let mut app = App::default(); // We store the chess engine path if there is one if let Ok(content) = fs::read_to_string(config_path) { @@ -67,6 +69,17 @@ fn main() -> AppResult<()> { let events = EventHandler::new(250); let mut tui = Tui::new(terminal, events); + let default_panic = std::panic::take_hook(); + panic::set_hook(Box::new(move |info| { + ratatui::restore(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::DisableMouseCapture + ) + .unwrap(); + default_panic(info); + })); + // Start the main loop. while app.running { // Render the user interface. @@ -92,6 +105,45 @@ fn main() -> AppResult<()> { } tui.draw(&mut app)?; } + + if app.game.opponent.is_some() + && app + .game + .opponent + .as_ref() + .map_or(false, |opponent| !opponent.game_started) + { + let opponent = app.game.opponent.as_mut().unwrap(); + wait_for_game_start(opponent.stream.as_ref().unwrap()); + opponent.game_started = true; + app.current_popup = None; + } + + // If it's the opponent turn, wait for the opponent to move + if app.game.opponent.is_some() + && app + .game + .opponent + .as_ref() + .map_or(false, |opponent| opponent.opponent_will_move) + { + tui.draw(&mut app)?; + + if !app.game.game_board.is_checkmate(app.game.player_turn) + && !app.game.game_board.is_draw(app.game.player_turn) + { + app.game.execute_opponent_move(); + app.game.switch_player_turn(); + } + + // need to be centralised + if app.game.game_board.is_checkmate(app.game.player_turn) { + app.game.game_state = GameState::Checkmate; + } else if app.game.game_board.is_draw(app.game.player_turn) { + app.game.game_state = GameState::Draw; + } + tui.draw(&mut app)?; + } } // Exit the user interface. diff --git a/src/server/game_server.rs b/src/server/game_server.rs new file mode 100644 index 0000000..01cc0d0 --- /dev/null +++ b/src/server/game_server.rs @@ -0,0 +1,148 @@ +use std::{ + io::{Read, Write}, + net::{TcpListener, TcpStream}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Mutex, + }, + thread, +}; + +#[derive(Debug)] +pub struct Client { + addr: String, + stream: TcpStream, +} + +#[derive(Clone)] +pub struct GameServer { + pub clients: Arc>>, + pub client_id: usize, + pub is_host_white: bool, + pub stop_signal: Arc, +} + +impl GameServer { + pub fn new(is_host_white: bool) -> Self { + Self { + clients: Arc::new(Mutex::new(vec![])), + client_id: 0, + is_host_white, + stop_signal: Arc::new(AtomicBool::new(false)), + } + } + + pub fn run(&self) { + let listener = TcpListener::bind("0.0.0.0:2308").expect("Failed to create listener"); + listener + .set_nonblocking(true) + .expect("Failed to set listener to non-blocking"); + + let state = self.clients.clone(); + let stop_signal = self.stop_signal.clone(); + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + + // Spawn a thread to watch for the stop signal + let stop_signal_clone = stop_signal.clone(); + thread::spawn(move || { + while !stop_signal_clone.load(Ordering::SeqCst) { + thread::sleep(std::time::Duration::from_millis(100)); + } + let _ = shutdown_tx.send(()); + }); + + loop { + // Check for shutdown signal + if shutdown_rx.try_recv().is_ok() { + break; + } + + // Handle incoming connections + match listener.accept() { + Ok((mut stream, _addr)) => { + let state = Arc::clone(&state); + let stop_signal = Arc::clone(&stop_signal); + let color = if self.is_host_white { "w" } else { "b" }; + + thread::spawn(move || { + { + let mut state_lock = state.lock().unwrap(); + // There is already one player (host who choose the color) we will need to send the color to the joining player and inform the host of the game start + if state_lock.len() == 1 { + stream.write_all(color.as_bytes()).unwrap(); + let other_player = state_lock.last().unwrap(); + let mut other_player_stream = + other_player.stream.try_clone().unwrap(); + other_player_stream.write_all("s".as_bytes()).unwrap(); + } else if state_lock.len() >= 2 { + stream.write_all("Game is already full".as_bytes()).unwrap(); + return; + } + + state_lock.push(Client { + addr: stream.peer_addr().unwrap().to_string(), + stream: stream.try_clone().unwrap(), + }); + } + handle_client(state, stop_signal, stream); + }); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // No connection ready, sleep briefly + thread::sleep(std::time::Duration::from_millis(100)); + } + Err(e) => { + eprintln!("Failed to accept connection: {}", e); + } + } + } + } +} + +fn handle_client( + state: Arc>>, + stop_signal: Arc, + mut stream: TcpStream, +) { + loop { + let mut buffer = [0; 5]; + let addr = stream.peer_addr().unwrap().to_string(); + let bytes_read = stream.read(&mut buffer).unwrap_or(0); + + if bytes_read == 0 { + broadcast_message(state.clone(), "ended".to_string(), &addr); + remove_client(&state, &addr); + // we stop the server if one of the clients disconnects + stop_signal.store(true, Ordering::SeqCst); + break; + } + + let request = String::from_utf8_lossy(&buffer[..]); + broadcast_message(state.clone(), format!("{}", request), &addr); + + if request.trim() == "ended" { + remove_client(&state, &addr); + // We stop the server if one of the clients disconnects + stop_signal.store(true, Ordering::SeqCst); + break; + } + } +} + +fn broadcast_message(state: Arc>>, message: String, sender_addr: &String) { + let state = state.lock().unwrap(); + for client in state.iter() { + if &client.addr == sender_addr { + continue; + } + let mut client_stream = client.stream.try_clone().unwrap(); + client_stream.write_all(message.as_bytes()).unwrap(); + } +} + +fn remove_client(state: &Arc>>, addr: &str) { + let mut state_lock = state.lock().unwrap(); + if let Some(index) = state_lock.iter().position(|client| client.addr == addr) { + state_lock.remove(index); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..90c0122 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1 @@ +pub mod game_server; diff --git a/src/ui/main_ui.rs b/src/ui/main_ui.rs index 1da5e35..374f698 100644 --- a/src/ui/main_ui.rs +++ b/src/ui/main_ui.rs @@ -8,6 +8,7 @@ use ratatui::{ }; use crate::{ + constants::Popups, game_logic::{bot::Bot, game::GameState}, ui::popups::{ render_color_selection_popup, render_credit_popup, render_end_popup, @@ -15,6 +16,9 @@ use crate::{ }, }; +use super::popups::{ + render_enter_multiplayer_ip, render_multiplayer_selection_popup, render_wait_for_other_player, +}; use crate::{ app::App, constants::{DisplayMode, Pages, TITLE}, @@ -22,16 +26,40 @@ use crate::{ }; /// Renders the user interface widgets. -pub fn render(app: &mut App, frame: &mut Frame) { +pub fn render(app: &mut App, frame: &mut Frame<'_>) { let main_area = frame.area(); + // Solo game if app.current_page == Pages::Solo { render_game_ui(frame, app, main_area); - } else if app.current_page == Pages::Bot { + } + // Multiplayer game + else if app.current_page == Pages::Multiplayer { + if app.hosting.is_none() { + app.current_popup = Some(Popups::MultiplayerSelection); + } else if app.selected_color.is_none() && app.hosting.unwrap() { + app.current_popup = Some(Popups::ColorSelection); + } else if app.game.opponent.is_none() { + if app.host_ip.is_none() { + if app.hosting.is_some() && app.hosting.unwrap() { + app.setup_game_server(app.selected_color.unwrap()); + app.host_ip = Some("127.0.0.1".to_string()); + } else { + app.current_popup = Some(Popups::EnterHostIP); + } + } else { + app.create_opponent(); + } + } else if app.game.opponent.as_mut().unwrap().game_started { + render_game_ui(frame, app, main_area); + } + } + // Play against bot + else if app.current_page == Pages::Bot { if app.chess_engine_path.is_none() || app.chess_engine_path.as_ref().unwrap().is_empty() { render_engine_path_error_popup(frame); } else if app.selected_color.is_none() { - app.show_color_popup = true; + app.current_popup = Some(Popups::ColorSelection); } else if app.game.bot.is_none() { let engine_path = app.chess_engine_path.clone().unwrap(); let is_bot_starting = app.selected_color.unwrap() == PieceColor::Black; @@ -39,20 +67,35 @@ pub fn render(app: &mut App, frame: &mut Frame) { } else { render_game_ui(frame, app, main_area); } - } else { - render_menu_ui(frame, app, main_area); } - if app.show_color_popup { - render_color_selection_popup(frame, app); - } - - if app.show_help_popup { - render_help_popup(frame); + // Render menu + else { + render_menu_ui(frame, app, main_area); } if app.current_page == Pages::Credit { render_credit_popup(frame); } + + // Render popups + match app.current_popup { + Some(Popups::ColorSelection) => { + render_color_selection_popup(frame, app); + } + Some(Popups::MultiplayerSelection) => { + render_multiplayer_selection_popup(frame, app); + } + Some(Popups::EnterHostIP) => { + render_enter_multiplayer_ip(frame, &app.game.ui.prompt); + } + Some(Popups::WaitingForOpponentToJoin) => { + render_wait_for_other_player(frame, app.get_host_ip()); + } + Some(Popups::Help) => { + render_help_popup(frame); + } + _ => {} + } } /// Helper function to create a centered rect using up certain percentage of the available rect `r` @@ -123,6 +166,7 @@ pub fn render_menu_ui(frame: &mut Frame, app: &App, main_area: Rect) { // Board block representing the full board div let menu_items = [ "Normal game", + "Multiplayer", "Play against a bot", &display_mode_menu, "Help", @@ -149,7 +193,7 @@ pub fn render_menu_ui(frame: &mut Frame, app: &App, main_area: Rect) { } // Method to render the game board and handle game popups -pub fn render_game_ui(frame: &mut Frame, app: &mut App, main_area: Rect) { +pub fn render_game_ui(frame: &mut Frame<'_>, app: &mut App, main_area: Rect) { let main_layout_horizontal = Layout::default() .direction(Direction::Vertical) .constraints( @@ -232,10 +276,14 @@ pub fn render_game_ui(frame: &mut Frame, app: &mut App, main_area: Rect) { PieceColor::Black => "Black", }; - render_end_popup(frame, &format!("{string_color} Won !!!")); + render_end_popup( + frame, + &format!("{string_color} Won !!!"), + app.game.opponent.is_some(), + ); } if app.game.game_state == GameState::Draw { - render_end_popup(frame, "That's a draw"); + render_end_popup(frame, "That's a draw", app.game.opponent.is_some()); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8d74b47..d46c76f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ pub mod main_ui; pub mod popups; +pub mod prompt; pub mod tui; diff --git a/src/ui/popups.rs b/src/ui/popups.rs index 4317c43..6af956c 100644 --- a/src/ui/popups.rs +++ b/src/ui/popups.rs @@ -1,3 +1,5 @@ +use std::net::IpAddr; + use crate::{ app::App, constants::WHITE, @@ -5,13 +7,15 @@ use crate::{ ui::main_ui::centered_rect, }; use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout}, - style::{Color, Style, Stylize}, - text::Line, + layout::{Alignment, Constraint, Direction, Layout, Position}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span, Text}, widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap}, Frame, }; +use super::prompt::Prompt; + // This renders a popup when the selected game mode is bot and there is no chess engine path pub fn render_engine_path_error_popup(frame: &mut Frame) { let block = Block::default() @@ -42,7 +46,7 @@ pub fn render_engine_path_error_popup(frame: &mut Frame) { } // This renders a popup for a promotion -pub fn render_end_popup(frame: &mut Frame, sentence: &str) { +pub fn render_end_popup(frame: &mut Frame, sentence: &str, is_multiplayer: bool) { let block = Block::default() .title("Game ended") .borders(Borders::ALL) @@ -55,7 +59,12 @@ pub fn render_end_popup(frame: &mut Frame, sentence: &str) { Line::from(sentence).alignment(Alignment::Center), Line::from(""), Line::from(""), - Line::from("Press `R` to start a new game").alignment(Alignment::Center), + Line::from(if is_multiplayer { + "Press `B` to go back to the menu" + } else { + "Press `R` to restart a new game" + }) + .alignment(Alignment::Center), ]; let paragraph = Paragraph::new(text) @@ -68,6 +77,7 @@ pub fn render_end_popup(frame: &mut Frame, sentence: &str) { frame.render_widget(paragraph, area); } +// This renders a popup for a promotion pub fn render_promotion_popup(frame: &mut Frame, app: &mut App) { let block = Block::default() .title("Pawn promotion") @@ -214,7 +224,7 @@ pub fn render_help_popup(frame: &mut Frame) { Line::from("Game controls:".underlined().bold()), Line::from(""), Line::from(vec![ - "←/h ↑/k ↓/j β†’/l: Use these keys to move the ".into(), + "←/h ↑/k ↓/j β†’/l: Use these keys or the mouse to move the ".into(), "blue".blue(), " cursor".into(), ]), @@ -336,3 +346,149 @@ pub fn render_color_selection_popup(frame: &mut Frame, app: &App) { ); frame.render_widget(black_pawn, inner_popup_layout_horizontal[2]); } + +// This renders a popup for the multiplayer hosting / joining popup +pub fn render_multiplayer_selection_popup(frame: &mut Frame, app: &App) { + let block: Block<'_> = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .padding(Padding::horizontal(1)) + .border_style(Style::default().fg(WHITE)); + let area = centered_rect(40, 40, frame.area()); + + let text = vec![ + Line::from(""), + Line::from("-- Are you hosting or joining a game ? --").alignment(Alignment::Center), + Line::from(""), + ]; + + let paragraph = Paragraph::new(text) + .block(Block::default()) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + frame.render_widget(paragraph, area); + + let inner_popup_layout_vertical = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ] + .as_ref(), + ) + .split(area); + + let inner_popup_layout_horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ] + .as_ref(), + ) + .split(inner_popup_layout_vertical[1]); + + let hosting = Paragraph::new(Text::from(vec![Line::from(vec![Span::styled( + "HOSTING", + Style::default().add_modifier(if app.menu_cursor == 0 { + Modifier::UNDERLINED + } else { + Modifier::empty() + }), + )])])) + .block(Block::default()) + .alignment(Alignment::Center); + + frame.render_widget(hosting, inner_popup_layout_horizontal[0]); + + let joining = Paragraph::new(Text::from(vec![Line::from(vec![Span::styled( + "JOINING", + Style::default().add_modifier(if app.menu_cursor == 1 { + Modifier::UNDERLINED + } else { + Modifier::empty() + }), + )])])) + .block(Block::default()) + .alignment(Alignment::Center); + frame.render_widget(joining, inner_popup_layout_horizontal[2]); +} + +// MULTIPLAYER POPUPS +// This renders a popup indicating we are waiting for the other player +pub fn render_wait_for_other_player(frame: &mut Frame, ip: IpAddr) { + let block = Block::default() + .title("Waiting ...") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .padding(Padding::horizontal(1)) + .border_style(Style::default().fg(WHITE)); + let area = centered_rect(40, 40, frame.area()); + + let text = vec![ + Line::from(""), + Line::from(""), + Line::from("Waiting for other player").alignment(Alignment::Center), + Line::from(format!("Host IP address and port: {}:2308", ip)).alignment(Alignment::Center), + ]; + + let paragraph = Paragraph::new(text) + .block(block.clone()) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); + + frame.render_widget(Clear, area); //this clears out the background + frame.render_widget(block, area); + frame.render_widget(paragraph, area); +} + +// This renders a popup allowing us to get a user input +pub fn render_enter_multiplayer_ip(frame: &mut Frame, prompt: &Prompt) { + let block = Block::default() + .title("Join a game") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .padding(Padding::horizontal(1)) + .border_style(Style::default().fg(WHITE)); + let area = centered_rect(40, 40, frame.area()); + + let current_input = prompt.input.as_str(); + + let text = vec![ + Line::from("Enter the ip address and port of the host:").alignment(Alignment::Center), + Line::from(""), + Line::from(current_input), + Line::from(""), + Line::from(""), + Line::from(""), + Line::from(""), + Line::from("Example: 10.111.6.50:2308;"), + Line::from("Documentation: https://thomas-mauran.github.io/chess-tui/docs/Multiplayer/Online%20multiplayer/"), + Line::from(""), + Line::from(""), + Line::from("Press `Esc` to close the popup.").alignment(Alignment::Center), + ]; + + let paragraph = Paragraph::new(text) + .block(block.clone()) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); + + frame.set_cursor_position(Position::new( + // Draw the cursor at the current position in the input field. + // This position is can be controlled via the left and right arrow key + area.x + prompt.character_index as u16 + 2, + // Move one line down, from the border to the input line + area.y + 3, + )); + + frame.render_widget(Clear, area); //this clears out the background + frame.render_widget(block, area); + frame.render_widget(paragraph, area); +} diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs new file mode 100644 index 0000000..7eef693 --- /dev/null +++ b/src/ui/prompt.rs @@ -0,0 +1,87 @@ +/// App holds the state of the application + +#[derive(Clone, Default)] +pub struct Prompt { + /// Current value of the input box + pub input: String, + /// Position of cursor in the editor area. + pub character_index: usize, + /// The prompt entry message + pub message: String, +} + +impl Prompt { + pub fn new() -> Self { + Self { + input: "".to_string(), + character_index: 0, + message: String::new(), + } + } + + pub fn move_cursor_left(&mut self) { + let cursor_moved_left = self.character_index.saturating_sub(1); + self.character_index = self.clamp_cursor(cursor_moved_left); + } + + pub fn move_cursor_right(&mut self) { + let cursor_moved_right = self.character_index.saturating_add(1); + self.character_index = self.clamp_cursor(cursor_moved_right); + } + + pub fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { + new_cursor_pos.clamp(0, self.input.chars().count()) + } + + pub fn reset_cursor(&mut self) { + self.character_index = 0; + } + + pub fn submit_message(&mut self) { + self.message = self.input.clone(); + self.input.clear(); + self.reset_cursor(); + } + + pub fn enter_char(&mut self, new_char: char) { + let index = self.byte_index(); + if index < 40 { + self.input.insert(index, new_char); + self.move_cursor_right(); + } + } + + /// Returns the byte index based on the character position. + /// + /// Since each character in a string can be contain multiple bytes, it's necessary to calculate + /// the byte index based on the index of the character. + pub fn byte_index(&self) -> usize { + self.input + .char_indices() + .map(|(i, _)| i) + .nth(self.character_index) + .unwrap_or(self.input.len()) + } + + pub fn delete_char(&mut self) { + let is_not_cursor_leftmost = self.character_index != 0; + if is_not_cursor_leftmost { + // Method "remove" is not used on the saved text for deleting the selected char. + // Reason: Using remove on String works on bytes instead of the chars. + // Using remove would require special care because of char boundaries. + + let current_index = self.character_index; + let from_left_to_current_index = current_index - 1; + + // Getting all characters before the selected character. + let before_char_to_delete = self.input.chars().take(from_left_to_current_index); + // Getting all characters after selected character. + let after_char_to_delete = self.input.chars().skip(current_index); + + // Put all characters together except the selected one. + // By leaving the selected one out, it is forgotten and therefore deleted. + self.input = before_char_to_delete.chain(after_char_to_delete).collect(); + self.move_cursor_left(); + } + } +} diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 548c887..9c7c5c8 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -26,7 +26,9 @@ impl Tui { /// /// [`Draw`]: ratatui::Terminal::draw /// [`rendering`]: crate::ui:render + // CrΓ©er une fonction async pour le rendu pub fn draw(&mut self, app: &mut App) -> AppResult<()> { + // Passe une closure synchrone qui appelle la fonction async self.terminal.draw(|frame| main_ui::render(app, frame))?; Ok(()) } diff --git a/website/docs/Code Architecture/Game.md b/website/docs/Code Architecture/Game.md index e6cb0c5..76102f1 100644 --- a/website/docs/Code Architecture/Game.md +++ b/website/docs/Code Architecture/Game.md @@ -3,36 +3,44 @@ ```mermaid classDiagram - class App { +class App { +bool running +Game game +Pages current_page - +bool show_help_popup - +bool show_color_popup - +Option~PieceColor~ selected_color + +Option current_popup + +Option selected_color + +Option hosting + +Option host_ip +u8 menu_cursor - +Option~String~ chess_engine_path + +Option chess_engine_path + +toggle_help_popup() +toggle_credit_popup() +go_to_home() +tick() +quit() - +menu_cursor_up(u8) - +menu_cursor_right(u8) - +menu_cursor_left(u8) - +menu_cursor_down(u8) + +menu_cursor_up(l: u8) + +menu_cursor_right(l: u8) + +menu_cursor_left(l: u8) + +menu_cursor_down(l: u8) +color_selection() +restart() +menu_select() +update_config() + +setup_game_server(host_color: PieceColor) + +create_opponent() + +hosting_selection() + +bot_setup() + +get_host_ip() IpAddr } - class Game { +GameBoard game_board +UI ui +Option bot + +Option opponent +PieceColor player_turn +GameState game_state + +new(game_board: GameBoard, player_turn: PieceColor) +set_board(game_board: GameBoard) +set_player_turn(player_turn: PieceColor) @@ -41,6 +49,11 @@ classDiagram +execute_bot_move() +promote_piece() +execute_move(from: Coord, to: Coord) + +handle_cell_click() + +already_selected_cell_action() + +handle_promotion() + +execute_opponent_move() + +handle_multiplayer_promotion() } class GameBoard { @@ -96,7 +109,20 @@ classDiagram +set_engine(engine_path: &str) +create_engine(engine_path: &str): Engine +get_bot_move(fen_position: String): String + } + class Opponent { + +Option stream + +bool opponent_will_move + +PieceColor color + +bool game_started + + +copy() : Opponent + +new(addr: String, color: Option) : Opponent + +start_stream(addr: &str) : void + +send_end_game_to_server() : void + +send_move_to_server(move_to_send: &PieceMove, promotion_type: Option) : void + +read_stream() : String } class Coord { @@ -149,6 +175,8 @@ classDiagram PieceMove "1" --> "1" Coord : from_to Coord "1" --> "1" PieceColor : color Coord "1" --> "1" PieceType : type + Game "1" --> "0..1" Opponent : "has" + Opponent "1" --> "1" PieceColor : color ``` diff --git a/website/docs/Multiplayer/Local multiplayer.md b/website/docs/Multiplayer/Local multiplayer.md new file mode 100644 index 0000000..6cb2abb --- /dev/null +++ b/website/docs/Multiplayer/Local multiplayer.md @@ -0,0 +1,8 @@ +# Local Multiplayer + +The local multiplayer feature is available in the `Normal game` menu option. You can play chess with your friends on the same computer using this feature. + + +Each turn the board will turn allowing your opponent to play. The game will continue until one of the players wins or the game ends in a draw. + +![Demo](../../static/gif/demo-two-player.gif) \ No newline at end of file diff --git a/website/docs/Multiplayer/Online multiplayer.md b/website/docs/Multiplayer/Online multiplayer.md new file mode 100644 index 0000000..6acd2a3 --- /dev/null +++ b/website/docs/Multiplayer/Online multiplayer.md @@ -0,0 +1,74 @@ +# Online Multiplayer + +You can now play chess with your friends online. The online multiplayer feature is available in the `Multiplayer` menu option. + +![multiplayer gif demo](../../static/gif/multiplayer.gif) + + +## LAN + +If you are on the same network as your friend you don't have anything to worry about. One of the player need to choose `Host` and the other player need to choose `Join`. The player who is hosting the game will get it's ip displayed on the screen. The other player need to enter the `ip`:2308 and click on `Join`. + +By default the game will be hosted on port 2308, make sure you had :2308 at the end of the ip address. + +## WLAN + +If you are not on the same network as your friend you need to do some port forwarding, but don't worry tools allows you to do that in one command ! + +For this we will use [Bore](https://github.com/ekzhang/bore) which is an open source rust written tool that allows you to expose your local server to the internet. + +First you need to install bore, you can do that by running the following command: + +```bash +cargo install bore +``` + +Then you need to create a tcp tunnel to your local server, you can do that by running the following command: + +```bash +bore local 2308 --to bore.pub +``` + +this will create a tunnel to your local server on port 2308 to bore.pub, once done you will see the following message: +![Bore port](bore-port.png) + +This means that you can access the game on bore.pub:12455 (the port will obviously be different). + +The other player then only need to enter bore.pub:port_given to join the game. + +Here for example it would be `bore.pub:12455` + +### How does it work ? + +When you host a game a new thread will be created running a game_server instance that will listen on the port 2308. This Game Server will handle 2 clients at max and will simply forward the messages between the 2 clients. In the meantime the main thread creates a new Player instance which represent a connection to the game server. + +If you are joining a game you are not creating a game server but simply creating a Player instance that will connect to the game server address. + +```mermaid +graph TD + A[Start] -->|Host Game| B[Main Thread Creates Game Server] + B --> C[Game Server Listens on Port 2308] + B --> F[Main Thread Creates Player Instance] + F --> G[Player Instance Connects to Game Server] + A -->|Join Game| H[Create Player Instance] + H --> I[Player Connects to Game Server Address] + G --> C + I --> C +``` + +### Message exchange + +The message exchange between the clients and the server is done using a simple protocol with the following terms: + +- `b` : Player will play with black pieces +- `w` : Player will play with white pieces +- `s` : The game has started +- `ended` : The game has ended +- `e4e5` : A move from e4 to e5 +- `e6e7q` : A move from e6 to e7 with a promotion to queen + +When we are hosting we choose a color and then wait for the `s` message to be sent to start the game. When we are joining we wait for the color `b` or `w` message then for the `s` message to start the game. + +When the game is started the server will send the `s` message to both clients and the game will start. The clients will then send the moves to the server and the server will forward the moves to the other client. + +When the game ends the server will send the `ended` message to both clients and the game will be over. diff --git a/website/docs/Multiplayer/bore-port.png b/website/docs/Multiplayer/bore-port.png new file mode 100644 index 0000000..a0ce917 Binary files /dev/null and b/website/docs/Multiplayer/bore-port.png differ diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 3152298..1277ff4 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -1,5 +1,5 @@ -import {themes as prismThemes} from 'prism-react-renderer'; -import type {Config} from '@docusaurus/types'; +import { themes as prismThemes } from 'prism-react-renderer'; +import type { Config } from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) @@ -11,23 +11,16 @@ const config: Config = { // Set the production url of your site here url: 'https://thomas-mauran.github.io', - // Set the // pathname under which your site is served - // For GitHub pages deployment, it is often '//' baseUrl: '/chess-tui/', - // GitHub pages deployment config. - // If you aren't using GitHub pages, you don't need these. - organizationName: 'thomas-mauran', // Usually your GitHub org/user name. - projectName: 'chess-tui', // Usually your repo name. + organizationName: 'thomas-mauran', + projectName: 'chess-tui', deploymentBranch: 'gh-pages', trailingSlash: false, onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', - // Even if you don't use internationalization, you can use this field to set - // useful metadata like html lang. For example, if your site is Chinese, you - // may want to replace "en" with "zh-Hans". i18n: { defaultLocale: 'en', locales: ['en'], @@ -39,8 +32,6 @@ const config: Config = { { docs: { sidebarPath: './sidebars.ts', - // Please change this to your repo. - // Remove this to remove the "edit this page" links. editUrl: 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/', }, @@ -50,11 +41,8 @@ const config: Config = { type: ['rss', 'atom'], xslt: true, }, - // Please change this to your repo. - // Remove this to remove the "edit this page" links. editUrl: 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/', - // Useful options to enforce blogging best practices onInlineTags: 'warn', onInlineAuthors: 'warn', onUntruncatedBlogPosts: 'warn', @@ -67,7 +55,6 @@ const config: Config = { ], themeConfig: { - // Replace with your project's social card image: 'img/social-card.png', navbar: { title: 'Chess-tui', @@ -82,11 +69,13 @@ const config: Config = { position: 'left', label: 'Documentation', }, - {to: '/blog', label: 'Blog', position: 'left'}, + { to: '/blog', label: 'Blog', position: 'left' }, { href: 'https://github.com/thomas-mauran/chess-tui', - label: 'GitHub', position: 'right', + className: 'header-github-link', + 'aria-label': 'GitHub repository', + html: `GitHub Stars`, }, ], }, @@ -127,9 +116,6 @@ const config: Config = { ], copyright: `Copyright Β© ${new Date().getFullYear()} Thomas Mauran, Inc. Built with Docusaurus.`, }, - - - } satisfies Preset.ThemeConfig, markdown: { diff --git a/website/sidebars.ts b/website/sidebars.ts index cf1a13b..72bb661 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -8,7 +8,11 @@ const sidebars: SidebarsConfig = { label: 'Installation', items: ['Installation/Packaging status', 'Installation/Cargo', 'Installation/Build from source', 'Installation/NetBSD', 'Installation/Arch Linux', 'Installation/NixOS', 'Installation/Docker'], }, - + { + type: 'category', + label: 'Multiplayer', + items: ['Multiplayer/Local multiplayer', 'Multiplayer/Online multiplayer'], + }, { type: 'category', label: 'Code Architecture', diff --git a/website/src/css/custom.css b/website/src/css/custom.css index a7d2f8a..dbe423c 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -53,3 +53,24 @@ footer.footer a { footer.footer a:hover { color: var(--ifm-color-primary-lighter); /* Slightly warmer color for hover state */ } + +/* Ensure the theme toggle button aligns properly */ +.navbar__toggle { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +/* Adjust spacing or alignment if necessary */ +.navbar__toggle svg { + vertical-align: middle; + height: 1.2em; /* Adjust size to match GitHub icons */ + margin-left: 8px; /* Fine-tune the spacing */ + margin-right: 8px; /* Fine-tune the spacing */ +} + +/* Adjust GitHub badge alignment */ +.header-github-link img { + vertical-align: middle; +} diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index e8e8db5..677f8f5 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -26,7 +26,7 @@ function HomepageHeader() { - Let's start β™ŸοΈ + Play now β™ŸοΈ diff --git a/website/src/pages/markdown-page.md b/website/src/pages/markdown-page.md deleted file mode 100644 index 9756c5b..0000000 --- a/website/src/pages/markdown-page.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Markdown page example ---- - -# Markdown page example - -You don't need React to write simple standalone pages. diff --git a/website/static/gif/demo-two-player.gif b/website/static/gif/demo-two-player.gif index e3c4036..6b3f556 100644 Binary files a/website/static/gif/demo-two-player.gif and b/website/static/gif/demo-two-player.gif differ diff --git a/website/static/gif/helper.gif b/website/static/gif/helper.gif index e02ffe2..895c2e4 100644 Binary files a/website/static/gif/helper.gif and b/website/static/gif/helper.gif differ diff --git a/website/static/gif/multiplayer.gif b/website/static/gif/multiplayer.gif new file mode 100644 index 0000000..f1eda6d Binary files /dev/null and b/website/static/gif/multiplayer.gif differ diff --git a/website/static/gif/play_against_black_bot.gif b/website/static/gif/play_against_black_bot.gif index 2b67df2..32d30a9 100644 Binary files a/website/static/gif/play_against_black_bot.gif and b/website/static/gif/play_against_black_bot.gif differ diff --git a/website/static/gif/play_against_white_bot.gif b/website/static/gif/play_against_white_bot.gif index 1929664..0fb7307 100644 Binary files a/website/static/gif/play_against_white_bot.gif and b/website/static/gif/play_against_white_bot.gif differ