616 lines
22 KiB
Rust
616 lines
22 KiB
Rust
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<Coordinates, Coordinates>;
|
|
|
|
#[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<String>,
|
|
status: GameStatus,
|
|
opponent_name: Option<String>,
|
|
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<B: Backend>(mut self, terminal: &mut Terminal<B>) -> Res<ScreenResult> {
|
|
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<Buttons> {
|
|
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::<Vec<_>>(),
|
|
};
|
|
|
|
// 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::<Vec<_>>(),
|
|
};
|
|
|
|
map_widget
|
|
.add_colored_cells(sunk_boats)
|
|
.add_colored_cells(touched_areas)
|
|
.add_colored_cells(failed_strikes)
|
|
.add_colored_cells(boats)
|
|
}
|
|
|
|
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) -> 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::<Vec<_>>();
|
|
|
|
// 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::<Vec<_>>(),
|
|
)
|
|
.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
|
|
}
|
|
}
|