use std::cmp::max; use std::collections::HashMap; use std::time::{Duration, Instant}; use crossterm::event; use crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; use tui::backend::Backend; use tui::layout::{Constraint, Direction, Layout}; use tui::style::Color; use tui::widgets::Paragraph; use tui::{Frame, Terminal}; use sea_battle_backend::data::{Coordinates, CurrentGameMapStatus, CurrentGameStatus}; use sea_battle_backend::human_player_ws::{ClientMessage, ServerMessage}; use sea_battle_backend::utils::time_utils::time; use sea_battle_backend::utils::Res; use crate::client::Client; use crate::constants::*; use crate::ui_screens::confirm_dialog_screen::confirm; use crate::ui_screens::popup_screen::PopupScreen; use crate::ui_screens::set_boats_layout_screen::SetBoatsLayoutScreen; use crate::ui_screens::utils::{centered_rect_size, centered_text}; use crate::ui_screens::ScreenResult; use crate::ui_widgets::button_widget::ButtonWidget; use crate::ui_widgets::game_map_widget::{ColoredCells, GameMapWidget}; type CoordinatesMapper = HashMap; #[derive(Eq, PartialEq, Ord, PartialOrd)] enum GameStatus { Connecting, WaitingForAnotherPlayer, OpponentConnected, WaitingForOpponentBoatsConfig, OpponentReady, Starting, MustFire, OpponentMustFire, WonGame, LostGame, RematchRequestedByOpponent, RematchRequestedByPlayer, RematchAccepted, RematchRejected, OpponentLeftGame, } impl GameStatus { pub fn can_show_game_maps(&self) -> bool { self > &GameStatus::Starting } pub fn status_text(&self) -> &str { match self { GameStatus::Connecting => "Connecting...", GameStatus::WaitingForAnotherPlayer => "Waiting for another player...", GameStatus::OpponentConnected => "Opponent connected!", GameStatus::WaitingForOpponentBoatsConfig => "Waiting for ### boats configuration", GameStatus::OpponentReady => "### is ready!", GameStatus::Starting => "Game is starting...", GameStatus::MustFire => "You must fire!", GameStatus::OpponentMustFire => "### must fire!", GameStatus::WonGame => "You win the game!", GameStatus::LostGame => "### wins the game. You loose.", GameStatus::RematchRequestedByOpponent => "Rematch requested by ###", GameStatus::RematchRequestedByPlayer => "Rematch requested by you", GameStatus::RematchAccepted => "Rematch accepted!", GameStatus::RematchRejected => "Rematch rejected!", GameStatus::OpponentLeftGame => "Opponent left game!", } } } #[derive(Debug, Eq, PartialEq, Copy, Clone)] enum Buttons { RequestRematch, AcceptRematch, RejectRematch, QuitGame, } impl Buttons { pub fn text(&self) -> &str { match self { Buttons::RequestRematch => "Request rematch", Buttons::AcceptRematch => "Accept rematch", Buttons::RejectRematch => "Reject rematch", Buttons::QuitGame => "Quit game", } } } pub struct GameScreen { client: Client, invite_code: Option, status: GameStatus, opponent_name: Option, game_last_update: u64, game: CurrentGameStatus, curr_shoot_position: Coordinates, last_opponent_fire_position: Coordinates, curr_button: usize, } impl GameScreen { pub fn new(client: Client) -> Self { Self { client, invite_code: None, status: GameStatus::Connecting, opponent_name: None, game_last_update: 0, game: Default::default(), curr_shoot_position: Coordinates::new(0, 0), last_opponent_fire_position: Coordinates::invalid(), curr_button: 0, } } pub async fn show(mut self, terminal: &mut Terminal) -> Res { let mut last_tick = Instant::now(); let mut coordinates_mapper = CoordinatesMapper::new(); loop { if !self.visible_buttons().is_empty() { self.curr_button %= self.visible_buttons().len(); } // Update UI terminal.draw(|f| coordinates_mapper = self.ui(f))?; let timeout = TICK_RATE .checked_sub(last_tick.elapsed()) .unwrap_or_else(|| Duration::from_secs(0)); // Handle terminal events if crossterm::event::poll(timeout)? { let event = event::read()?; // Keyboard event if let Event::Key(key) = &event { let mut new_shoot_pos = self.curr_shoot_position; match key.code { // Leave game KeyCode::Char('q') if confirm(terminal, "Do you really want to leave game?") => { self.client.close_connection().await; return Ok(ScreenResult::Canceled); } // Move shoot cursor KeyCode::Left if self.can_fire() => new_shoot_pos = new_shoot_pos.add_x(-1), KeyCode::Right if self.can_fire() => new_shoot_pos = new_shoot_pos.add_x(1), KeyCode::Up if self.can_fire() => new_shoot_pos = new_shoot_pos.add_y(-1), KeyCode::Down if self.can_fire() => new_shoot_pos = new_shoot_pos.add_y(1), // Shoot KeyCode::Enter if self.can_fire() => { if self.game.can_fire_at_location(self.curr_shoot_position) { self.client .send_message(&ClientMessage::Fire { location: self.curr_shoot_position, }) .await?; } } // Change buttons KeyCode::Left if self.game_over() => { self.curr_button += self.visible_buttons().len() - 1 } KeyCode::Right if self.game_over() => self.curr_button += 1, KeyCode::Tab if self.game_over() => self.curr_button += 1, // Submit button KeyCode::Enter if self.game_over() => match self.curr_button() { Buttons::RequestRematch => { self.client .send_message(&ClientMessage::RequestRematch) .await?; self.status = GameStatus::RematchRequestedByPlayer; } Buttons::AcceptRematch => { self.client .send_message(&ClientMessage::AcceptRematch) .await?; self.status = GameStatus::RematchAccepted; } Buttons::RejectRematch => { self.client .send_message(&ClientMessage::RejectRematch) .await?; self.status = GameStatus::RematchRejected; } Buttons::QuitGame => { self.client.close_connection().await; return Ok(ScreenResult::Ok(())); } }, _ => {} } if new_shoot_pos.is_valid(&self.game.rules) { self.curr_shoot_position = new_shoot_pos; } } // Mouse event if let Event::Mouse(mouse) = event { if mouse.kind == MouseEventKind::Up(MouseButton::Left) { if let Some(c) = coordinates_mapper.get(&Coordinates::new(mouse.column, mouse.row)) { self.curr_shoot_position = *c; if self.can_fire() && self.game.can_fire_at_location(self.curr_shoot_position) { self.client .send_message(&ClientMessage::Fire { location: self.curr_shoot_position, }) .await?; } } } } } // Handle incoming messages while let Some(msg) = self.client.try_recv_next_message().await? { match msg { ServerMessage::SetInviteCode { code } => { self.status = GameStatus::WaitingForAnotherPlayer; self.invite_code = Some(code); } ServerMessage::InvalidInviteCode => { PopupScreen::new("Invalid invite code!").show(terminal)?; return Ok(ScreenResult::Ok(())); } ServerMessage::WaitingForAnotherPlayer => { self.status = GameStatus::WaitingForAnotherPlayer; } ServerMessage::OpponentConnected => { self.status = GameStatus::OpponentConnected; } ServerMessage::SetOpponentName { name } => self.opponent_name = Some(name), ServerMessage::QueryBoatsLayout { rules } => { match SetBoatsLayoutScreen::new(&rules) .set_confirm_on_cancel(true) .show(terminal)? { ScreenResult::Ok(layout) => { self.client .send_message(&ClientMessage::BoatsLayout { layout }) .await? } ScreenResult::Canceled => { self.client.close_connection().await; return Ok(ScreenResult::Canceled); } }; } ServerMessage::RejectedBoatsLayout { .. } => { PopupScreen::new("Server rejected boats layout!! (is your version of SeaBattle up to date?)") .show(terminal)?; } ServerMessage::WaitingForOtherPlayerConfiguration => { self.status = GameStatus::WaitingForOpponentBoatsConfig; } ServerMessage::OpponentReady => { self.status = GameStatus::OpponentReady; } ServerMessage::GameStarting => { self.status = GameStatus::Starting; } ServerMessage::OpponentMustFire { status } => { self.status = GameStatus::OpponentMustFire; self.game_last_update = time(); self.game = status; } ServerMessage::RequestFire { status } => { self.status = GameStatus::MustFire; self.game_last_update = time(); self.game = status; } ServerMessage::FireResult { .. } => { /* not used */ } ServerMessage::OpponentFireResult { pos, .. } => { self.last_opponent_fire_position = pos; } ServerMessage::LostGame { status } => { self.game_last_update = time(); self.game = status; self.status = GameStatus::LostGame; } ServerMessage::WonGame { status } => { self.game_last_update = time(); self.game = status; self.status = GameStatus::WonGame; } ServerMessage::OpponentRequestedRematch => { self.status = GameStatus::RematchRequestedByOpponent; } ServerMessage::OpponentAcceptedRematch => { self.status = GameStatus::RematchAccepted; } ServerMessage::OpponentRejectedRematch => { self.status = GameStatus::RematchRejected; } ServerMessage::OpponentLeftGame => { self.status = GameStatus::OpponentLeftGame; } ServerMessage::OpponentReplacedByBot => { PopupScreen::new("Opponent was replaced by a bot.").show(terminal)?; } } } if last_tick.elapsed() >= TICK_RATE { last_tick = Instant::now(); } } } fn can_fire(&self) -> bool { matches!(self.status, GameStatus::MustFire) } fn game_over(&self) -> bool { self.game.is_game_over() } fn visible_buttons(&self) -> Vec { let mut buttons = vec![]; if self.game_over() && self.status != GameStatus::RematchAccepted { // Respond to rematch request / quit if self.status == GameStatus::RematchRequestedByOpponent { buttons.push(Buttons::AcceptRematch); buttons.push(Buttons::RejectRematch); } else if self.status != GameStatus::OpponentLeftGame && self.status != GameStatus::RematchRejected { buttons.push(Buttons::RequestRematch); } buttons.push(Buttons::QuitGame); } buttons } fn opponent_name(&self) -> &str { self.opponent_name.as_deref().unwrap_or("opponent") } fn curr_button(&self) -> Buttons { self.visible_buttons()[self.curr_button] } fn player_map(&self, map: &CurrentGameMapStatus, opponent_map: bool) -> GameMapWidget { let mut map_widget = GameMapWidget::new(&self.game.rules).set_default_empty_char(' '); // Current shoot position if opponent_map { map_widget = map_widget.add_colored_cells(ColoredCells { color: match ( self.game.can_fire_at_location(self.curr_shoot_position), self.game .opponent_map .successful_strikes .contains(&self.curr_shoot_position), ) { (true, _) => Color::Green, (false, false) => Color::LightYellow, (false, true) => Color::LightRed, }, cells: vec![self.curr_shoot_position], }); } else { map_widget = map_widget.add_colored_cells(ColoredCells { color: Color::Green, cells: vec![self.last_opponent_fire_position], }); } // Sunk boats for b in &map.sunk_boats { for c in b.all_coordinates() { map_widget = map_widget.set_char(c, b.len.to_string().chars().next().unwrap_or('9')); } } let sunk_boats = ColoredCells { color: Color::LightRed, cells: map .sunk_boats .iter() .flat_map(|b| b.all_coordinates()) .collect::>(), }; // Touched boats for b in &map.successful_strikes { map_widget = map_widget.set_char_no_overwrite(*b, 'T'); } let touched_areas = ColoredCells { color: Color::Red, cells: map.successful_strikes.clone(), }; // Failed strikes for b in &map.failed_strikes { map_widget = map_widget.set_char_no_overwrite(*b, '.'); } let failed_strikes = ColoredCells { color: Color::Black, cells: map.failed_strikes.clone(), }; // Boats for b in &map.boats.0 { for c in b.all_coordinates() { map_widget = map_widget.set_char_no_overwrite(c, 'B'); } } let boats = ColoredCells { color: Color::Blue, cells: map .boats .0 .iter() .flat_map(|b| b.all_coordinates()) .collect::>(), }; map_widget .add_colored_cells(sunk_boats) .add_colored_cells(touched_areas) .add_colored_cells(failed_strikes) .add_colored_cells(boats) } fn ui(&mut self, f: &mut Frame) -> CoordinatesMapper { let mut status_text = self .status .status_text() .replace("###", self.opponent_name()); // If the game is in a state where game maps can not be shown if !self.status.can_show_game_maps() { if self.status == GameStatus::WaitingForAnotherPlayer { if let Some(code) = &self.invite_code { status_text.push_str(&format!("\n Invite code: {}", code)); } } PopupScreen::new(&status_text).show_in_frame(f); return HashMap::default(); } // Add timeout (if required) let mut timeout_str = String::new(); if self.status == GameStatus::MustFire || self.status == GameStatus::OpponentMustFire { if let Some(remaining) = self.game.remaining_time_for_strike { let timeout = self.game_last_update + remaining; if time() < timeout { timeout_str = format!(" {} seconds left", timeout - time()); } } } // Draw main ui (default play UI) let player_map = self .player_map(&self.game.your_map, false) .set_title("YOUR map"); let mut coordinates_mapper = HashMap::new(); let mut opponent_map = self .player_map(&self.game.opponent_map, true) .set_title(self.opponent_name()) .set_yield_func(|c, r| { for i in 0..r.width { for j in 0..r.height { coordinates_mapper.insert(Coordinates::new(r.x + i, r.y + j), c); } } }); if self.can_fire() { opponent_map = opponent_map .set_legend("Use arrows + Enter\nor click on the place\nwhere you want\nto shoot"); } // Prepare buttons let buttons = self .visible_buttons() .iter() .map(|b| ButtonWidget::new(b.text(), self.curr_button() == *b)) .collect::>(); // Show both maps if there is enough room on the screen let player_map_size = player_map.estimated_size(); let opponent_map_size = opponent_map.estimated_size(); let both_maps_width = player_map_size.0 + opponent_map_size.0 + 3; let show_both_maps = both_maps_width <= f.size().width; let maps_height = max(player_map_size.1, opponent_map_size.1); let maps_width = match show_both_maps { true => both_maps_width, false => max(player_map_size.0, opponent_map_size.0), }; let buttons_width = buttons.iter().fold(0, |a, b| a + b.estimated_size().0 + 4); let max_width = max(maps_width, status_text.len() as u16) .max(buttons_width) .max(timeout_str.len() as u16); let total_height = 3 + 1 + maps_height + 3; // Check if frame is too small if max_width > f.size().width || total_height > f.size().height { PopupScreen::new("Screen too small!").show_in_frame(f); return HashMap::default(); } let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(2), Constraint::Length(2), Constraint::Length(maps_height), Constraint::Length(3), ]) .split(centered_rect_size(max_width, total_height, &f.size())); // Render status let paragraph = Paragraph::new(status_text.as_str()); f.render_widget(paragraph, centered_text(&status_text, &chunks[0])); // Render timeout let paragraph = Paragraph::new(timeout_str.as_str()); f.render_widget(paragraph, centered_text(&timeout_str, &chunks[1])); // Render maps if show_both_maps { let maps_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(player_map_size.0), Constraint::Length(3), Constraint::Length(opponent_map_size.0), ]) .split(chunks[2]); f.render_widget(player_map, maps_chunks[0]); f.render_widget(opponent_map, maps_chunks[2]); } else { // Render a single map if self.can_fire() { f.render_widget(opponent_map, chunks[2]); } else { f.render_widget(player_map, chunks[2]); drop(opponent_map); } } // Render buttons if !buttons.is_empty() { let buttons_area = Layout::default() .direction(Direction::Horizontal) .constraints( (0..buttons.len()) .map(|_| Constraint::Percentage(100 / buttons.len() as u16)) .collect::>(), ) .split(chunks[3]); for (idx, b) in buttons.into_iter().enumerate() { let target = centered_rect_size( b.estimated_size().0, b.estimated_size().1, &buttons_area[idx], ); f.render_widget(b, target); } } coordinates_mapper } }