sea_battle_cli_player/ui_screens/
game_screen.rs

1use std::cmp::max;
2use std::collections::HashMap;
3use std::time::{Duration, Instant};
4
5use crossterm::event;
6use crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
7use tui::backend::Backend;
8use tui::layout::{Constraint, Direction, Layout};
9use tui::style::Color;
10use tui::widgets::Paragraph;
11use tui::{Frame, Terminal};
12
13use sea_battle_backend::data::{Coordinates, CurrentGameMapStatus, CurrentGameStatus};
14use sea_battle_backend::human_player_ws::{ClientMessage, ServerMessage};
15use sea_battle_backend::utils::res_utils::Res;
16use sea_battle_backend::utils::time_utils::time;
17
18use crate::client::Client;
19use crate::consts::*;
20use crate::ui_screens::confirm_dialog_screen::confirm;
21use crate::ui_screens::popup_screen::{show_screen_too_small_popup, PopupScreen};
22use crate::ui_screens::set_boats_layout_screen::SetBoatsLayoutScreen;
23use crate::ui_screens::utils::{
24    centered_rect_size, centered_rect_size_horizontally, centered_text,
25};
26use crate::ui_screens::ScreenResult;
27use crate::ui_widgets::button_widget::ButtonWidget;
28use crate::ui_widgets::game_map_widget::{ColoredCells, GameMapWidget};
29
30type CoordinatesMapper = HashMap<Coordinates, Coordinates>;
31
32#[derive(Eq, PartialEq, Ord, PartialOrd)]
33enum GameStatus {
34    Connecting,
35    WaitingForAnotherPlayer,
36    OpponentConnected,
37    WaitingForOpponentBoatsConfig,
38    OpponentReady,
39    Starting,
40    MustFire,
41    OpponentMustFire,
42    WonGame,
43    LostGame,
44    RematchRequestedByOpponent,
45    RematchRequestedByPlayer,
46    RematchAccepted,
47    RematchRejected,
48    OpponentLeftGame,
49}
50
51impl GameStatus {
52    pub fn can_show_game_maps(&self) -> bool {
53        self > &GameStatus::Starting
54    }
55
56    pub fn status_text(&self) -> &str {
57        match self {
58            GameStatus::Connecting => "šŸ”Œ Connecting...",
59            GameStatus::WaitingForAnotherPlayer => "šŸ•‘ Waiting for another player...",
60            GameStatus::OpponentConnected => "āœ… Opponent connected!",
61            GameStatus::WaitingForOpponentBoatsConfig => "šŸ•‘ Waiting for ### boats configuration",
62            GameStatus::OpponentReady => "āœ… ### is ready!",
63            GameStatus::Starting => "šŸ•‘ Game is starting...",
64            GameStatus::MustFire => "🚨 You must fire!",
65            GameStatus::OpponentMustFire => "šŸ’£ ### must fire!",
66            GameStatus::WonGame => "šŸŽ‰ You win the game!",
67            GameStatus::LostGame => "😿 ### wins the game. You loose.",
68            GameStatus::RematchRequestedByOpponent => "ā“ Rematch requested by ###",
69            GameStatus::RematchRequestedByPlayer => "ā“ Rematch requested by you",
70            GameStatus::RematchAccepted => "āœ… Rematch accepted!",
71            GameStatus::RematchRejected => "āŒ Rematch rejected!",
72            GameStatus::OpponentLeftGame => "ā›” Opponent left game!",
73        }
74    }
75}
76
77#[derive(Debug, Eq, PartialEq, Copy, Clone)]
78enum Buttons {
79    RequestRematch,
80    AcceptRematch,
81    RejectRematch,
82    QuitGame,
83}
84
85impl Buttons {
86    pub fn text(&self) -> &str {
87        match self {
88            Buttons::RequestRematch => "ā“ Request rematch",
89            Buttons::AcceptRematch => "āœ… Accept rematch",
90            Buttons::RejectRematch => "āŒ Reject rematch",
91            Buttons::QuitGame => "āŒ Quit game",
92        }
93    }
94}
95
96pub struct GameScreen {
97    client: Client,
98    invite_code: Option<String>,
99    status: GameStatus,
100    opponent_name: Option<String>,
101    game_last_update: u64,
102    game: CurrentGameStatus,
103    curr_shoot_position: Coordinates,
104    last_opponent_fire_position: Coordinates,
105    curr_button: usize,
106}
107
108impl GameScreen {
109    pub fn new(client: Client) -> Self {
110        Self {
111            client,
112            invite_code: None,
113            status: GameStatus::Connecting,
114            opponent_name: None,
115            game_last_update: 0,
116            game: Default::default(),
117            curr_shoot_position: Coordinates::new(0, 0),
118            last_opponent_fire_position: Coordinates::invalid(),
119            curr_button: 0,
120        }
121    }
122
123    pub async fn show<B: Backend>(mut self, terminal: &mut Terminal<B>) -> Res<ScreenResult> {
124        let mut last_tick = Instant::now();
125
126        let mut coordinates_mapper = CoordinatesMapper::new();
127
128        loop {
129            if !self.visible_buttons().is_empty() {
130                self.curr_button %= self.visible_buttons().len();
131            }
132
133            // Update UI
134            terminal.draw(|f| coordinates_mapper = self.ui(f))?;
135
136            let timeout = TICK_RATE
137                .checked_sub(last_tick.elapsed())
138                .unwrap_or_else(|| Duration::from_secs(0));
139
140            // Handle terminal events
141            if event::poll(timeout)? {
142                let event = event::read()?;
143
144                // Keyboard event
145                if let Event::Key(key) = &event {
146                    let mut new_shoot_pos = self.curr_shoot_position;
147
148                    match key.code {
149                        // Leave game
150                        KeyCode::Char('q') | KeyCode::Esc
151                            if confirm(terminal, "Do you really want to leave game?") =>
152                        {
153                            self.client.close_connection().await;
154                            return Ok(ScreenResult::Canceled);
155                        }
156
157                        // Move  shoot cursor
158                        KeyCode::Left if self.can_fire() => new_shoot_pos = new_shoot_pos.add_x(-1),
159                        KeyCode::Right if self.can_fire() => new_shoot_pos = new_shoot_pos.add_x(1),
160                        KeyCode::Up if self.can_fire() => new_shoot_pos = new_shoot_pos.add_y(-1),
161                        KeyCode::Down if self.can_fire() => new_shoot_pos = new_shoot_pos.add_y(1),
162
163                        // Shoot
164                        KeyCode::Enter if self.can_fire() => {
165                            if self.game.can_fire_at_location(self.curr_shoot_position) {
166                                self.client
167                                    .send_message(&ClientMessage::Fire {
168                                        location: self.curr_shoot_position,
169                                    })
170                                    .await?;
171                            }
172                        }
173
174                        // Change buttons
175                        KeyCode::Left if self.game_over() => {
176                            self.curr_button += self.visible_buttons().len() - 1
177                        }
178                        KeyCode::Right if self.game_over() => self.curr_button += 1,
179                        KeyCode::Tab if self.game_over() => self.curr_button += 1,
180
181                        // Submit button
182                        KeyCode::Enter if self.game_over() => match self.curr_button() {
183                            Buttons::RequestRematch => {
184                                self.client
185                                    .send_message(&ClientMessage::RequestRematch)
186                                    .await?;
187                                self.status = GameStatus::RematchRequestedByPlayer;
188                            }
189                            Buttons::AcceptRematch => {
190                                self.client
191                                    .send_message(&ClientMessage::AcceptRematch)
192                                    .await?;
193                                self.status = GameStatus::RematchAccepted;
194                            }
195                            Buttons::RejectRematch => {
196                                self.client
197                                    .send_message(&ClientMessage::RejectRematch)
198                                    .await?;
199                                self.status = GameStatus::RematchRejected;
200                            }
201                            Buttons::QuitGame => {
202                                self.client.close_connection().await;
203                                return Ok(ScreenResult::Ok(()));
204                            }
205                        },
206
207                        _ => {}
208                    }
209
210                    if new_shoot_pos.is_valid(&self.game.rules) {
211                        self.curr_shoot_position = new_shoot_pos;
212                    }
213                }
214
215                // Mouse event
216                if let Event::Mouse(mouse) = event {
217                    if mouse.kind == MouseEventKind::Up(MouseButton::Left) {
218                        if let Some(c) =
219                            coordinates_mapper.get(&Coordinates::new(mouse.column, mouse.row))
220                        {
221                            self.curr_shoot_position = *c;
222
223                            if self.can_fire()
224                                && self.game.can_fire_at_location(self.curr_shoot_position)
225                            {
226                                self.client
227                                    .send_message(&ClientMessage::Fire {
228                                        location: self.curr_shoot_position,
229                                    })
230                                    .await?;
231                            }
232                        }
233                    }
234                }
235            }
236
237            // Handle incoming messages
238            while let Some(msg) = self.client.try_recv_next_message().await? {
239                match msg {
240                    ServerMessage::SetInviteCode { code } => {
241                        self.status = GameStatus::WaitingForAnotherPlayer;
242                        self.invite_code = Some(code);
243                    }
244
245                    ServerMessage::InvalidInviteCode => {
246                        PopupScreen::new("āŒ Invalid invite code!").show(terminal)?;
247                        return Ok(ScreenResult::Ok(()));
248                    }
249
250                    ServerMessage::WaitingForAnotherPlayer => {
251                        self.status = GameStatus::WaitingForAnotherPlayer;
252                    }
253
254                    ServerMessage::OpponentConnected => {
255                        self.status = GameStatus::OpponentConnected;
256                    }
257
258                    ServerMessage::SetOpponentName { name } => self.opponent_name = Some(name),
259
260                    ServerMessage::QueryBoatsLayout { rules } => {
261                        match SetBoatsLayoutScreen::new(&rules)
262                            .set_confirm_on_cancel(true)
263                            .show(terminal)?
264                        {
265                            ScreenResult::Ok(layout) => {
266                                self.client
267                                    .send_message(&ClientMessage::BoatsLayout { layout })
268                                    .await?
269                            }
270                            ScreenResult::Canceled => {
271                                self.client.close_connection().await;
272                                return Ok(ScreenResult::Canceled);
273                            }
274                        };
275                    }
276
277                    ServerMessage::RejectedBoatsLayout { .. } => {
278                        PopupScreen::new("Server rejected boats layout!! (is your version of SeaBattle up to date?)")
279                            .show(terminal)?;
280                    }
281
282                    ServerMessage::WaitingForOtherPlayerConfiguration => {
283                        self.status = GameStatus::WaitingForOpponentBoatsConfig;
284                    }
285
286                    ServerMessage::OpponentReady => {
287                        self.status = GameStatus::OpponentReady;
288                    }
289
290                    ServerMessage::GameStarting => {
291                        self.status = GameStatus::Starting;
292                    }
293
294                    ServerMessage::OpponentMustFire { status } => {
295                        self.status = GameStatus::OpponentMustFire;
296                        self.game_last_update = time();
297                        self.game = status;
298                    }
299
300                    ServerMessage::RequestFire { status } => {
301                        self.status = GameStatus::MustFire;
302                        self.game_last_update = time();
303                        self.game = status;
304                    }
305
306                    ServerMessage::FireResult { .. } => { /* not used */ }
307
308                    ServerMessage::OpponentFireResult { pos, .. } => {
309                        self.last_opponent_fire_position = pos;
310                    }
311
312                    ServerMessage::LostGame { status } => {
313                        self.game_last_update = time();
314                        self.game = status;
315                        self.status = GameStatus::LostGame;
316                    }
317
318                    ServerMessage::WonGame { status } => {
319                        self.game_last_update = time();
320                        self.game = status;
321                        self.status = GameStatus::WonGame;
322                    }
323
324                    ServerMessage::OpponentRequestedRematch => {
325                        self.status = GameStatus::RematchRequestedByOpponent;
326                    }
327
328                    ServerMessage::OpponentAcceptedRematch => {
329                        self.status = GameStatus::RematchAccepted;
330                    }
331
332                    ServerMessage::OpponentRejectedRematch => {
333                        self.status = GameStatus::RematchRejected;
334                    }
335
336                    ServerMessage::OpponentLeftGame => {
337                        self.status = GameStatus::OpponentLeftGame;
338                    }
339
340                    ServerMessage::OpponentReplacedByBot => {
341                        PopupScreen::new("Opponent was replaced by a bot.").show(terminal)?;
342                    }
343                }
344            }
345
346            if last_tick.elapsed() >= TICK_RATE {
347                last_tick = Instant::now();
348            }
349        }
350    }
351
352    fn can_fire(&self) -> bool {
353        matches!(self.status, GameStatus::MustFire)
354    }
355
356    fn game_over(&self) -> bool {
357        self.game.is_game_over()
358    }
359
360    fn visible_buttons(&self) -> Vec<Buttons> {
361        let mut buttons = vec![];
362        if self.game_over() && self.status != GameStatus::RematchAccepted {
363            // Respond to rematch request / quit
364            if self.status == GameStatus::RematchRequestedByOpponent {
365                buttons.push(Buttons::AcceptRematch);
366                buttons.push(Buttons::RejectRematch);
367            } else if self.status != GameStatus::OpponentLeftGame
368                && self.status != GameStatus::RematchRejected
369                && self.status != GameStatus::RematchRequestedByPlayer
370            {
371                buttons.push(Buttons::RequestRematch);
372            }
373
374            buttons.push(Buttons::QuitGame);
375        }
376
377        buttons
378    }
379
380    fn opponent_name(&self) -> &str {
381        self.opponent_name.as_deref().unwrap_or("opponent")
382    }
383
384    fn curr_button(&self) -> Buttons {
385        self.visible_buttons()[self.curr_button]
386    }
387
388    fn player_map(&self, map: &CurrentGameMapStatus, opponent_map: bool) -> GameMapWidget {
389        let mut map_widget = GameMapWidget::new(&self.game.rules).set_default_empty_char(' ');
390
391        // Current shoot position
392        if opponent_map {
393            map_widget = map_widget.add_colored_cells(ColoredCells {
394                color: match (
395                    self.game.can_fire_at_location(self.curr_shoot_position),
396                    self.game
397                        .opponent_map
398                        .successful_strikes
399                        .contains(&self.curr_shoot_position),
400                ) {
401                    (true, _) => Color::Green,
402                    (false, false) => Color::LightYellow,
403                    (false, true) => Color::LightRed,
404                },
405                cells: vec![self.curr_shoot_position],
406            });
407        } else {
408            map_widget = map_widget.add_colored_cells(ColoredCells {
409                color: Color::Green,
410                cells: vec![self.last_opponent_fire_position],
411            });
412        }
413
414        // Sunk boats
415        for b in &map.sunk_boats {
416            for c in b.all_coordinates() {
417                map_widget =
418                    map_widget.set_char(c, b.len.to_string().chars().next().unwrap_or('9'));
419            }
420        }
421        let sunk_boats = ColoredCells {
422            color: Color::LightRed,
423            cells: map
424                .sunk_boats
425                .iter()
426                .flat_map(|b| b.all_coordinates())
427                .collect::<Vec<_>>(),
428        };
429
430        // Touched boats
431        for b in &map.successful_strikes {
432            map_widget = map_widget.set_char_no_overwrite(*b, 'T');
433        }
434        let touched_areas = ColoredCells {
435            color: Color::Red,
436            cells: map.successful_strikes.clone(),
437        };
438
439        // Failed strikes
440        for b in &map.failed_strikes {
441            map_widget = map_widget.set_char_no_overwrite(*b, '.');
442        }
443        let failed_strikes = ColoredCells {
444            color: Color::Black,
445            cells: map.failed_strikes.clone(),
446        };
447
448        // Boats
449        for b in &map.boats.0 {
450            for c in b.all_coordinates() {
451                map_widget = map_widget.set_char_no_overwrite(c, 'B');
452            }
453        }
454        let boats = ColoredCells {
455            color: Color::Blue,
456            cells: map
457                .boats
458                .0
459                .iter()
460                .flat_map(|b| b.all_coordinates())
461                .collect::<Vec<_>>(),
462        };
463
464        map_widget
465            .add_colored_cells(sunk_boats)
466            .add_colored_cells(touched_areas)
467            .add_colored_cells(failed_strikes)
468            .add_colored_cells(boats)
469    }
470
471    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) -> CoordinatesMapper {
472        let mut status_text = self
473            .status
474            .status_text()
475            .replace("###", self.opponent_name());
476
477        // If the game is in a state where game maps can not be shown
478        if !self.status.can_show_game_maps() {
479            if self.status == GameStatus::WaitingForAnotherPlayer {
480                if let Some(code) = &self.invite_code {
481                    status_text.push_str(&format!("\n\nšŸŽ« Invite code: {}", code));
482                }
483            }
484
485            PopupScreen::new(&status_text).show_in_frame(f);
486            return HashMap::default();
487        }
488
489        // Add timeout (if required)
490        let mut timeout_str = String::new();
491        if self.status == GameStatus::MustFire || self.status == GameStatus::OpponentMustFire {
492            if let Some(remaining) = self.game.remaining_time_for_strike {
493                let timeout = self.game_last_update + remaining;
494                if time() < timeout {
495                    timeout_str = format!(" {} seconds left", timeout - time());
496                }
497            }
498        }
499
500        // Draw main ui (default play UI)
501        let player_map = self
502            .player_map(&self.game.your_map, false)
503            .set_title("YOUR map");
504
505        let mut coordinates_mapper = HashMap::new();
506        let mut opponent_map = self
507            .player_map(&self.game.opponent_map, true)
508            .set_title(self.opponent_name())
509            .set_yield_func(|c, r| {
510                for i in 0..r.width {
511                    for j in 0..r.height {
512                        coordinates_mapper.insert(Coordinates::new(r.x + i, r.y + j), c);
513                    }
514                }
515            });
516
517        if self.can_fire() {
518            opponent_map = opponent_map
519                .set_legend("Use arrows + Enter\nor click on the place\nwhere you want\nto shoot");
520        }
521
522        // Prepare buttons
523        let buttons = self
524            .visible_buttons()
525            .iter()
526            .map(|b| ButtonWidget::new(b.text(), self.curr_button() == *b))
527            .collect::<Vec<_>>();
528
529        // Show both maps if there is enough room on the screen
530        let player_map_size = player_map.estimated_size();
531        let opponent_map_size = opponent_map.estimated_size();
532        let both_maps_width = player_map_size.0 + opponent_map_size.0 + 3;
533        let show_both_maps = both_maps_width <= f.size().width;
534
535        let maps_height = max(player_map_size.1, opponent_map_size.1);
536        let maps_width = match show_both_maps {
537            true => both_maps_width,
538            false => max(player_map_size.0, opponent_map_size.0),
539        };
540
541        let buttons_width = buttons.iter().fold(0, |a, b| a + b.estimated_size().0 + 4);
542
543        let max_width = max(maps_width, status_text.len() as u16)
544            .max(buttons_width)
545            .max(timeout_str.len() as u16);
546        let total_height = 3 + 1 + maps_height + 3;
547
548        // Check if frame is too small
549        if max_width > f.size().width || total_height > f.size().height {
550            show_screen_too_small_popup(f);
551            return HashMap::default();
552        }
553
554        let chunks = Layout::default()
555            .direction(Direction::Vertical)
556            .constraints([
557                Constraint::Length(2),
558                Constraint::Length(2),
559                Constraint::Length(maps_height),
560                Constraint::Length(3),
561            ])
562            .split(centered_rect_size(max_width, total_height, &f.size()));
563
564        // Render status
565        let paragraph = Paragraph::new(status_text.as_str());
566        f.render_widget(paragraph, centered_text(&status_text, &chunks[0]));
567
568        // Render timeout
569        let paragraph = Paragraph::new(timeout_str.as_str());
570        f.render_widget(paragraph, centered_text(&timeout_str, &chunks[1]));
571
572        // Render maps
573        if show_both_maps {
574            let maps_chunks = Layout::default()
575                .direction(Direction::Horizontal)
576                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
577                .split(chunks[2]);
578
579            f.render_widget(
580                player_map,
581                centered_rect_size_horizontally(player_map_size.0, &maps_chunks[0]),
582            );
583            f.render_widget(
584                opponent_map,
585                centered_rect_size_horizontally(opponent_map_size.0, &maps_chunks[1]),
586            );
587        } else {
588            // Render a single map
589            if self.can_fire() {
590                f.render_widget(opponent_map, chunks[2]);
591            } else {
592                f.render_widget(player_map, chunks[2]);
593                drop(opponent_map);
594            }
595        }
596
597        // Render buttons
598        if !buttons.is_empty() {
599            let buttons_area = Layout::default()
600                .direction(Direction::Horizontal)
601                .constraints(
602                    (0..buttons.len())
603                        .map(|_| Constraint::Percentage(100 / buttons.len() as u16))
604                        .collect::<Vec<_>>(),
605                )
606                .split(chunks[3]);
607
608            for (idx, b) in buttons.into_iter().enumerate() {
609                let target = centered_rect_size(
610                    b.estimated_size().0,
611                    b.estimated_size().1,
612                    &buttons_area[idx],
613                );
614                f.render_widget(b, target);
615            }
616        }
617
618        coordinates_mapper
619    }
620}