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
-
+
+
+
+ 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: ` `,
},
],
},
@@ -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