Rename crate
This commit is contained in:
@ -0,0 +1,300 @@
|
||||
extern crate num as num_renamed;
|
||||
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use sea_battle_backend::consts::{
|
||||
MAX_BOATS_NUMBER, MAX_MAP_HEIGHT, MAX_MAP_WIDTH, MAX_STRIKE_TIMEOUT,
|
||||
};
|
||||
use tui::backend::Backend;
|
||||
use tui::layout::{Constraint, Direction, Layout, Margin};
|
||||
use tui::style::{Color, Style};
|
||||
use tui::widgets::*;
|
||||
use tui::{Frame, Terminal};
|
||||
|
||||
use sea_battle_backend::data::GameRules;
|
||||
|
||||
use crate::constants::{HIGHLIGHT_COLOR, TICK_RATE};
|
||||
use crate::ui_screens::popup_screen::show_screen_too_small_popup;
|
||||
use crate::ui_screens::select_bot_type_screen::SelectBotTypeScreen;
|
||||
use crate::ui_screens::utils::centered_rect_size;
|
||||
use crate::ui_screens::ScreenResult;
|
||||
use crate::ui_widgets::button_widget::ButtonWidget;
|
||||
use crate::ui_widgets::checkbox_widget::CheckboxWidget;
|
||||
use crate::ui_widgets::text_editor_widget::TextEditorWidget;
|
||||
|
||||
#[derive(num_derive::FromPrimitive, num_derive::ToPrimitive, Eq, PartialEq)]
|
||||
enum EditingField {
|
||||
MapWidth = 0,
|
||||
MapHeight,
|
||||
BoatsList,
|
||||
StrikeTimeout,
|
||||
BoatsCanTouch,
|
||||
PlayerContinueOnHit,
|
||||
BotType,
|
||||
Cancel,
|
||||
OK,
|
||||
}
|
||||
|
||||
pub struct GameRulesConfigurationScreen {
|
||||
rules: GameRules,
|
||||
curr_field: EditingField,
|
||||
}
|
||||
|
||||
impl GameRulesConfigurationScreen {
|
||||
pub fn new(rules: GameRules) -> Self {
|
||||
Self {
|
||||
rules,
|
||||
curr_field: EditingField::OK,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show<B: Backend>(
|
||||
mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
) -> io::Result<ScreenResult<GameRules>> {
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
terminal.draw(|f| self.ui(f))?;
|
||||
|
||||
let timeout = TICK_RATE
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if event::poll(timeout)? {
|
||||
let mut cursor_pos = self.curr_field as i32;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
// Quit app
|
||||
KeyCode::Char('q') => return Ok(ScreenResult::Canceled),
|
||||
|
||||
// Navigate between fields
|
||||
KeyCode::Up | KeyCode::Left => cursor_pos -= 1,
|
||||
KeyCode::Down | KeyCode::Right | KeyCode::Tab => cursor_pos += 1,
|
||||
|
||||
// Submit results
|
||||
KeyCode::Enter => {
|
||||
if self.curr_field == EditingField::BotType {
|
||||
if let ScreenResult::Ok(t) =
|
||||
SelectBotTypeScreen::default().show(terminal)?
|
||||
{
|
||||
self.rules.bot_type = t;
|
||||
}
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::Cancel {
|
||||
return Ok(ScreenResult::Canceled);
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::OK && self.rules.is_valid() {
|
||||
return Ok(ScreenResult::Ok(self.rules));
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Char(' ') => {
|
||||
if self.curr_field == EditingField::BoatsCanTouch {
|
||||
self.rules.boats_can_touch = !self.rules.boats_can_touch;
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::PlayerContinueOnHit {
|
||||
self.rules.player_continue_on_hit =
|
||||
!self.rules.player_continue_on_hit;
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Backspace => {
|
||||
if self.curr_field == EditingField::MapWidth {
|
||||
self.rules.map_width /= 10;
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::MapHeight {
|
||||
self.rules.map_height /= 10;
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::BoatsList
|
||||
&& !self.rules.boats_list().is_empty()
|
||||
{
|
||||
self.rules.remove_last_boat();
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::StrikeTimeout {
|
||||
match self.rules.strike_timeout.unwrap_or(0) / 10 {
|
||||
0 => self.rules.strike_timeout = None,
|
||||
v => self.rules.strike_timeout = Some(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Char(c) if ('0'..='9').contains(&c) => {
|
||||
let val = c.to_string().parse::<usize>().unwrap_or_default();
|
||||
|
||||
if self.curr_field == EditingField::MapWidth
|
||||
&& self.rules.map_width <= MAX_MAP_WIDTH
|
||||
{
|
||||
self.rules.map_width *= 10;
|
||||
self.rules.map_width += val;
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::MapHeight
|
||||
&& self.rules.map_height <= MAX_MAP_HEIGHT
|
||||
{
|
||||
self.rules.map_height *= 10;
|
||||
self.rules.map_height += val;
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::BoatsList
|
||||
&& self.rules.boats_list().len() < MAX_BOATS_NUMBER
|
||||
{
|
||||
self.rules.add_boat(val);
|
||||
}
|
||||
|
||||
if self.curr_field == EditingField::StrikeTimeout {
|
||||
let mut timeout = self.rules.strike_timeout.unwrap_or(0);
|
||||
if timeout <= MAX_STRIKE_TIMEOUT {
|
||||
timeout *= 10;
|
||||
timeout += val as u64;
|
||||
self.rules.strike_timeout = Some(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply new cursor position
|
||||
self.curr_field = if cursor_pos < 0 {
|
||||
EditingField::OK
|
||||
} else {
|
||||
num_renamed::FromPrimitive::from_u64(cursor_pos as u64)
|
||||
.unwrap_or(EditingField::MapWidth)
|
||||
}
|
||||
}
|
||||
if last_tick.elapsed() >= TICK_RATE {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
|
||||
let (w, h) = (50, 23);
|
||||
|
||||
if f.size().width < w || f.size().height < h {
|
||||
show_screen_too_small_popup(f);
|
||||
return;
|
||||
}
|
||||
|
||||
let area = centered_rect_size(w, h, &f.size());
|
||||
|
||||
let block = Block::default()
|
||||
.title("📓 Game rules")
|
||||
.borders(Borders::ALL);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Map width
|
||||
Constraint::Length(3), // Map height
|
||||
Constraint::Length(3), // Boats list
|
||||
Constraint::Length(3), // Strike timeout
|
||||
Constraint::Length(1), // Boats can touch
|
||||
Constraint::Length(1), // Player continue on hit
|
||||
Constraint::Length(3), // Bot type
|
||||
Constraint::Length(1), // Margin
|
||||
Constraint::Length(1), // Buttons
|
||||
Constraint::Length(1), // Error message (if any)
|
||||
])
|
||||
.split(area.inner(&Margin {
|
||||
horizontal: 2,
|
||||
vertical: 1,
|
||||
}));
|
||||
|
||||
let editor = TextEditorWidget::new(
|
||||
"Map width",
|
||||
&self.rules.map_width.to_string(),
|
||||
self.curr_field == EditingField::MapWidth,
|
||||
);
|
||||
f.render_widget(editor, chunks[EditingField::MapWidth as usize]);
|
||||
|
||||
let editor = TextEditorWidget::new(
|
||||
"Map height",
|
||||
&self.rules.map_height.to_string(),
|
||||
self.curr_field == EditingField::MapHeight,
|
||||
);
|
||||
f.render_widget(editor, chunks[EditingField::MapHeight as usize]);
|
||||
|
||||
let editor = TextEditorWidget::new(
|
||||
"Boats list",
|
||||
&self
|
||||
.rules
|
||||
.boats_list()
|
||||
.iter()
|
||||
.map(usize::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
self.curr_field == EditingField::BoatsList,
|
||||
);
|
||||
f.render_widget(editor, chunks[EditingField::BoatsList as usize]);
|
||||
|
||||
let editor = TextEditorWidget::new(
|
||||
"Strike timeout (0 to disable)",
|
||||
&self.rules.strike_timeout.unwrap_or(0).to_string(),
|
||||
self.curr_field == EditingField::StrikeTimeout,
|
||||
);
|
||||
f.render_widget(editor, chunks[EditingField::StrikeTimeout as usize]);
|
||||
|
||||
let editor = CheckboxWidget::new(
|
||||
"Boats can touch",
|
||||
self.rules.boats_can_touch,
|
||||
self.curr_field == EditingField::BoatsCanTouch,
|
||||
);
|
||||
f.render_widget(editor, chunks[EditingField::BoatsCanTouch as usize]);
|
||||
|
||||
let editor = CheckboxWidget::new(
|
||||
"Player continue on hit",
|
||||
self.rules.player_continue_on_hit,
|
||||
self.curr_field == EditingField::PlayerContinueOnHit,
|
||||
);
|
||||
f.render_widget(editor, chunks[EditingField::PlayerContinueOnHit as usize]);
|
||||
|
||||
// Select bot type
|
||||
let bot_type_text = format!("Bot type: {}", self.rules.bot_type.description().name);
|
||||
let text = Paragraph::new(bot_type_text.as_str()).style(
|
||||
match self.curr_field == EditingField::BotType {
|
||||
false => Style::default(),
|
||||
true => Style::default().fg(HIGHLIGHT_COLOR),
|
||||
},
|
||||
);
|
||||
f.render_widget(
|
||||
text,
|
||||
chunks[EditingField::BotType as usize].inner(&Margin {
|
||||
horizontal: 0,
|
||||
vertical: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
// Buttons
|
||||
let buttons_chunk = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[EditingField::OK as usize]);
|
||||
|
||||
let button = ButtonWidget::new("Cancel", self.curr_field == EditingField::Cancel);
|
||||
f.render_widget(button, buttons_chunk[0]);
|
||||
|
||||
let button = ButtonWidget::new("OK", self.curr_field == EditingField::OK)
|
||||
.set_disabled(!self.rules.is_valid());
|
||||
f.render_widget(button, buttons_chunk[1]);
|
||||
|
||||
// Error message (if any)
|
||||
if let Some(msg) = self.rules.get_errors().first() {
|
||||
let area = centered_rect_size(msg.len() as u16, 1, chunks.last().unwrap());
|
||||
let err = Paragraph::new(*msg).style(Style::default().fg(Color::Red));
|
||||
f.render_widget(err, area);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use tui::backend::Backend;
|
||||
use tui::layout::*;
|
||||
use tui::text::*;
|
||||
use tui::widgets::*;
|
||||
use tui::{Frame, Terminal};
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::ui_screens::utils::centered_rect_size;
|
||||
use crate::ui_screens::ScreenResult;
|
||||
use crate::ui_widgets::button_widget::ButtonWidget;
|
||||
|
||||
/// Convenience function to ask for user confirmation
|
||||
pub fn confirm<B: Backend>(terminal: &mut Terminal<B>, msg: &str) -> bool {
|
||||
matches!(
|
||||
ConfirmDialogScreen::new(msg)
|
||||
.show(terminal)
|
||||
.unwrap_or(ScreenResult::Canceled),
|
||||
ScreenResult::Ok(true)
|
||||
)
|
||||
}
|
||||
|
||||
pub struct ConfirmDialogScreen<'a> {
|
||||
title: &'a str,
|
||||
msg: &'a str,
|
||||
is_confirm: bool,
|
||||
can_cancel: bool,
|
||||
}
|
||||
|
||||
impl<'a> ConfirmDialogScreen<'a> {
|
||||
pub fn new(msg: &'a str) -> Self {
|
||||
Self {
|
||||
title: "Confirmation Request",
|
||||
msg,
|
||||
is_confirm: true,
|
||||
can_cancel: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show<B: Backend>(
|
||||
mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
) -> io::Result<ScreenResult<bool>> {
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
terminal.draw(|f| self.ui(f))?;
|
||||
|
||||
let timeout = TICK_RATE
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') if self.can_cancel => {
|
||||
return Ok(ScreenResult::Canceled)
|
||||
}
|
||||
|
||||
// Toggle selected choice
|
||||
KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
|
||||
self.is_confirm = !self.is_confirm
|
||||
}
|
||||
|
||||
// Submit choice
|
||||
KeyCode::Enter => {
|
||||
return Ok(ScreenResult::Ok(self.is_confirm));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_tick.elapsed() >= TICK_RATE {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
|
||||
// Preprocess message
|
||||
let lines = textwrap::wrap(self.msg, f.size().width as usize - 20);
|
||||
let line_max_len = lines.iter().map(|l| l.len()).max().unwrap();
|
||||
|
||||
let area = centered_rect_size(line_max_len as u16 + 4, 5 + lines.len() as u16, &f.size());
|
||||
|
||||
let block = Block::default().borders(Borders::ALL).title(self.title);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Create two chunks with equal horizontal screen space
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(lines.len() as u16),
|
||||
Constraint::Length(3),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(area.inner(&Margin {
|
||||
horizontal: 2,
|
||||
vertical: 1,
|
||||
}));
|
||||
|
||||
let text = lines
|
||||
.iter()
|
||||
.map(|s| Spans::from(s.as_ref()))
|
||||
.collect::<Vec<_>>();
|
||||
let paragraph = Paragraph::new(text);
|
||||
f.render_widget(paragraph, chunks[0]);
|
||||
|
||||
// Buttons
|
||||
let buttons_area = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||
.split(chunks[1]);
|
||||
|
||||
let cancel_button = ButtonWidget::new("Cancel", true).set_disabled(self.is_confirm);
|
||||
f.render_widget(cancel_button, buttons_area[0]);
|
||||
|
||||
let ok_button = ButtonWidget::new("Confirm", true).set_disabled(!self.is_confirm);
|
||||
f.render_widget(ok_button, buttons_area[1]);
|
||||
}
|
||||
}
|
620
rust/sea_battle_cli_player/src/ui_screens/game_screen.rs
Normal file
620
rust/sea_battle_cli_player/src/ui_screens/game_screen.rs
Normal file
@ -0,0 +1,620 @@
|
||||
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::res_utils::Res;
|
||||
use sea_battle_backend::utils::time_utils::time;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::constants::*;
|
||||
use crate::ui_screens::confirm_dialog_screen::confirm;
|
||||
use crate::ui_screens::popup_screen::{show_screen_too_small_popup, PopupScreen};
|
||||
use crate::ui_screens::set_boats_layout_screen::SetBoatsLayoutScreen;
|
||||
use crate::ui_screens::utils::{
|
||||
centered_rect_size, centered_rect_size_horizontally, 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 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
|
||||
&& self.status != GameStatus::RematchRequestedByPlayer
|
||||
{
|
||||
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\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 {
|
||||
show_screen_too_small_popup(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::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[2]);
|
||||
|
||||
f.render_widget(
|
||||
player_map,
|
||||
centered_rect_size_horizontally(player_map_size.0, &maps_chunks[0]),
|
||||
);
|
||||
f.render_widget(
|
||||
opponent_map,
|
||||
centered_rect_size_horizontally(opponent_map_size.0, &maps_chunks[1]),
|
||||
);
|
||||
} 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
|
||||
}
|
||||
}
|
187
rust/sea_battle_cli_player/src/ui_screens/input_screen.rs
Normal file
187
rust/sea_battle_cli_player/src/ui_screens/input_screen.rs
Normal file
@ -0,0 +1,187 @@
|
||||
use std::fmt::Display;
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
use tui::style::*;
|
||||
|
||||
use crossterm::event;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use tui::backend::Backend;
|
||||
use tui::layout::*;
|
||||
use tui::widgets::*;
|
||||
use tui::{Frame, Terminal};
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::ui_screens::utils::*;
|
||||
use crate::ui_screens::ScreenResult;
|
||||
use crate::ui_widgets::button_widget::ButtonWidget;
|
||||
use crate::ui_widgets::text_editor_widget::TextEditorWidget;
|
||||
|
||||
pub struct InputScreen<'a> {
|
||||
title: &'a str,
|
||||
msg: &'a str,
|
||||
input_label: &'a str,
|
||||
value: String,
|
||||
can_cancel: bool,
|
||||
is_cancel_hovered: bool,
|
||||
value_required: bool,
|
||||
min_len: usize,
|
||||
max_len: usize,
|
||||
has_already_been_edited: bool,
|
||||
}
|
||||
|
||||
impl<'a> InputScreen<'a> {
|
||||
pub fn new(msg: &'a str) -> Self {
|
||||
Self {
|
||||
title: "Input",
|
||||
msg,
|
||||
input_label: "",
|
||||
value: "".to_string(),
|
||||
can_cancel: true,
|
||||
is_cancel_hovered: false,
|
||||
value_required: true,
|
||||
min_len: 1,
|
||||
max_len: 10,
|
||||
has_already_been_edited: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_title(mut self, title: &'a str) -> Self {
|
||||
self.title = title;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_value<D: Display>(mut self, value: D) -> Self {
|
||||
self.value = value.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_min_length(mut self, v: usize) -> Self {
|
||||
self.min_len = v;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_max_length(mut self, v: usize) -> Self {
|
||||
self.max_len = v;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get error contained in input
|
||||
fn error(&self) -> Option<&'static str> {
|
||||
if self.value.len() > self.max_len {
|
||||
Some("Input is too large!")
|
||||
} else if self.value.is_empty() && !self.value_required {
|
||||
None
|
||||
} else if self.value.len() < self.min_len {
|
||||
Some("Input is too small!")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show<B: Backend>(
|
||||
mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
) -> io::Result<ScreenResult<String>> {
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
terminal.draw(|f| self.ui(f))?;
|
||||
|
||||
let timeout = TICK_RATE
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
KeyCode::Esc => return Ok(ScreenResult::Canceled),
|
||||
KeyCode::Tab if self.can_cancel => {
|
||||
self.is_cancel_hovered = !self.is_cancel_hovered;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.is_cancel_hovered {
|
||||
return Ok(ScreenResult::Canceled);
|
||||
} else if self.error().is_none() {
|
||||
return Ok(ScreenResult::Ok(self.value));
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.value.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if self.value.len() < self.max_len {
|
||||
self.has_already_been_edited = true;
|
||||
self.value.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_tick.elapsed() >= TICK_RATE {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
|
||||
let area = centered_rect_size(
|
||||
(self.msg.len() + 4).max(self.max_len + 4).max(25) as u16,
|
||||
7,
|
||||
&f.size(),
|
||||
);
|
||||
|
||||
let error = self.error();
|
||||
|
||||
let block = Block::default().borders(Borders::ALL).title(self.title);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Create two chunks with equal horizontal screen space
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(area.inner(&Margin {
|
||||
horizontal: 2,
|
||||
vertical: 1,
|
||||
}));
|
||||
|
||||
let paragraph = Paragraph::new(self.msg);
|
||||
f.render_widget(paragraph, chunks[0]);
|
||||
|
||||
let input_widget =
|
||||
TextEditorWidget::new(self.input_label, &self.value, !self.is_cancel_hovered);
|
||||
f.render_widget(input_widget, chunks[1]);
|
||||
|
||||
let buttons_area = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||
.split(*chunks.last().unwrap());
|
||||
|
||||
let cancel_button = ButtonWidget::new("Cancel", self.is_cancel_hovered)
|
||||
.set_disabled(!self.can_cancel)
|
||||
.set_min_width(8);
|
||||
f.render_widget(cancel_button, buttons_area[0]);
|
||||
|
||||
let ok_button = ButtonWidget::new("OK", !self.is_cancel_hovered)
|
||||
.set_min_width(8)
|
||||
.set_disabled(error.is_some());
|
||||
f.render_widget(ok_button, buttons_area[1]);
|
||||
|
||||
// Render error (if any)
|
||||
if let (Some(e), true) = (error, self.has_already_been_edited) {
|
||||
let target_area = centered_text(
|
||||
e,
|
||||
&Rect::new(f.size().x, area.bottom() + 2, f.size().width, 1),
|
||||
);
|
||||
|
||||
let paragraph = Paragraph::new(e).style(Style::default().fg(Color::Red));
|
||||
f.render_widget(paragraph, target_area)
|
||||
}
|
||||
}
|
||||
}
|
30
rust/sea_battle_cli_player/src/ui_screens/mod.rs
Normal file
30
rust/sea_battle_cli_player/src/ui_screens/mod.rs
Normal file
@ -0,0 +1,30 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
pub mod configure_game_rules;
|
||||
pub mod confirm_dialog_screen;
|
||||
pub mod game_screen;
|
||||
pub mod input_screen;
|
||||
pub mod popup_screen;
|
||||
pub mod select_bot_type_screen;
|
||||
pub mod select_play_mode_screen;
|
||||
pub mod set_boats_layout_screen;
|
||||
pub mod utils;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ScreenResult<E = ()> {
|
||||
Ok(E),
|
||||
Canceled,
|
||||
}
|
||||
|
||||
impl<E: Debug> ScreenResult<E> {
|
||||
pub fn value(self) -> Option<E> {
|
||||
match self {
|
||||
ScreenResult::Ok(v) => Some(v),
|
||||
ScreenResult::Canceled => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
format!("{:#?}", self)
|
||||
}
|
||||
}
|
127
rust/sea_battle_cli_player/src/ui_screens/popup_screen.rs
Normal file
127
rust/sea_battle_cli_player/src/ui_screens/popup_screen.rs
Normal file
@ -0,0 +1,127 @@
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use tui::backend::Backend;
|
||||
use tui::layout::*;
|
||||
use tui::text::*;
|
||||
use tui::widgets::*;
|
||||
use tui::{Frame, Terminal};
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::ui_screens::utils::centered_rect_size;
|
||||
use crate::ui_screens::ScreenResult;
|
||||
use crate::ui_widgets::button_widget::ButtonWidget;
|
||||
|
||||
/// Convenience function to inform user that his terminal window is too small to display the current
|
||||
/// screen
|
||||
pub fn show_screen_too_small_popup<B: Backend>(f: &mut Frame<B>) {
|
||||
PopupScreen::new("🖵 Screen too small!").show_in_frame(f)
|
||||
}
|
||||
|
||||
pub struct PopupScreen<'a> {
|
||||
title: &'a str,
|
||||
msg: &'a str,
|
||||
can_close: bool,
|
||||
}
|
||||
|
||||
impl<'a> PopupScreen<'a> {
|
||||
pub fn new(msg: &'a str) -> Self {
|
||||
Self {
|
||||
title: "Message",
|
||||
msg,
|
||||
can_close: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_can_close(mut self, can_close: bool) -> Self {
|
||||
self.can_close = can_close;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show<B: Backend>(mut self, terminal: &mut Terminal<B>) -> io::Result<ScreenResult<()>> {
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
terminal.draw(|f| self.ui(f))?;
|
||||
|
||||
let timeout = TICK_RATE
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return Ok(ScreenResult::Canceled),
|
||||
KeyCode::Enter => {
|
||||
return Ok(ScreenResult::Ok(()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_tick.elapsed() >= TICK_RATE {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show message once message, without polling messages
|
||||
pub fn show_once<B: Backend>(mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
|
||||
self.can_close = false;
|
||||
terminal.draw(|f| self.ui(f))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show message once message in a frame, without polling messages
|
||||
pub fn show_in_frame<B: Backend>(mut self, frame: &mut Frame<B>) {
|
||||
self.can_close = false;
|
||||
self.ui(frame)
|
||||
}
|
||||
|
||||
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
|
||||
// Preprocess message
|
||||
let lines = textwrap::wrap(self.msg, f.size().width as usize - 20);
|
||||
let line_max_len = lines.iter().map(|l| l.len()).max().unwrap();
|
||||
|
||||
let area = centered_rect_size(
|
||||
line_max_len as u16 + 4,
|
||||
match self.can_close {
|
||||
// reserve space for button
|
||||
true => 5,
|
||||
false => 2,
|
||||
} + lines.len() as u16,
|
||||
&f.size(),
|
||||
);
|
||||
|
||||
let block = Block::default().borders(Borders::ALL).title(self.title);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Create two chunks with equal horizontal screen space
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(lines.len() as u16),
|
||||
Constraint::Length(3),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(area.inner(&Margin {
|
||||
horizontal: 2,
|
||||
vertical: 1,
|
||||
}));
|
||||
|
||||
let text = lines
|
||||
.iter()
|
||||
.map(|s| Spans::from(s.as_ref()))
|
||||
.collect::<Vec<_>>();
|
||||
let paragraph = Paragraph::new(text);
|
||||
f.render_widget(paragraph, chunks[0]);
|
||||
|
||||
if self.can_close {
|
||||
let ok_button = ButtonWidget::new("OK", true);
|
||||
f.render_widget(ok_button, chunks[1]);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use tui::backend::Backend;
|
||||
use tui::style::*;
|
||||
use tui::text::*;
|
||||
use tui::widgets::*;
|
||||
use tui::{Frame, Terminal};
|
||||
|
||||
use sea_battle_backend::data::{BotDescription, BotType, PlayConfiguration};
|
||||
|
||||
use crate::constants::{HIGHLIGHT_COLOR, TICK_RATE};
|
||||
use crate::ui_screens::utils::centered_rect_size;
|
||||
use crate::ui_screens::ScreenResult;
|
||||
|
||||
pub struct SelectBotTypeScreen {
|
||||
state: ListState,
|
||||
curr_selection: usize,
|
||||
types: Vec<BotDescription>,
|
||||
}
|
||||
|
||||
impl Default for SelectBotTypeScreen {
|
||||
fn default() -> Self {
|
||||
let types = PlayConfiguration::default().bot_types;
|
||||
Self {
|
||||
state: Default::default(),
|
||||
curr_selection: types.len() - 1,
|
||||
types: types.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectBotTypeScreen {
|
||||
pub fn show<B: Backend>(
|
||||
mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
) -> io::Result<ScreenResult<BotType>> {
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
self.state.select(Some(self.curr_selection));
|
||||
terminal.draw(|f| self.ui(f))?;
|
||||
|
||||
let timeout = TICK_RATE
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return Ok(ScreenResult::Canceled),
|
||||
KeyCode::Enter => {
|
||||
return Ok(ScreenResult::Ok(self.types[self.curr_selection].r#type));
|
||||
}
|
||||
KeyCode::Down => self.curr_selection += 1,
|
||||
KeyCode::Up => self.curr_selection += self.types.len() - 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.curr_selection %= self.types.len();
|
||||
}
|
||||
}
|
||||
if last_tick.elapsed() >= TICK_RATE {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
|
||||
let area = centered_rect_size(60, self.types.len() as u16 * 2 + 2, &f.size());
|
||||
|
||||
// Create a List from all list items and highlight the currently selected one
|
||||
let items = self
|
||||
.types
|
||||
.iter()
|
||||
.map(|bot| {
|
||||
ListItem::new(vec![
|
||||
Spans::from(bot.name),
|
||||
Spans::from(Span::styled(
|
||||
bot.description,
|
||||
Style::default().add_modifier(Modifier::ITALIC),
|
||||
)),
|
||||
])
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let items = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.title("Select bot type")
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(HIGHLIGHT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(">> ");
|
||||
|
||||
f.render_stateful_widget(items, area, &mut self.state);
|
||||
}
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::constants::{HIGHLIGHT_COLOR, TICK_RATE};
|
||||
use crate::ui_screens::utils::centered_rect_size;
|
||||
use crate::ui_screens::ScreenResult;
|
||||
use crossterm::event;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use tui::backend::Backend;
|
||||
use tui::style::*;
|
||||
use tui::text::Text;
|
||||
use tui::widgets::*;
|
||||
use tui::{Frame, Terminal};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone, Default)]
|
||||
pub enum SelectPlayModeResult {
|
||||
#[default]
|
||||
PlayAgainstBot,
|
||||
PlayRandom,
|
||||
CreateInvite,
|
||||
AcceptInvite,
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl SelectPlayModeResult {
|
||||
/// Specify whether a selected play mode requires a user name or not
|
||||
pub fn need_player_name(&self) -> bool {
|
||||
self != &SelectPlayModeResult::PlayAgainstBot && self != &SelectPlayModeResult::Exit
|
||||
}
|
||||
|
||||
/// Specify whether a selected play mode requires a the user to specify its own game rules or
|
||||
/// not
|
||||
pub fn need_custom_rules(&self) -> bool {
|
||||
self == &SelectPlayModeResult::PlayAgainstBot || self == &SelectPlayModeResult::CreateInvite
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlayModeDescription {
|
||||
name: &'static str,
|
||||
value: SelectPlayModeResult,
|
||||
}
|
||||
|
||||
const AVAILABLE_PLAY_MODES: [PlayModeDescription; 5] = [
|
||||
PlayModeDescription {
|
||||
name: "🤖 Play against bot (offline)",
|
||||
value: SelectPlayModeResult::PlayAgainstBot,
|
||||
},
|
||||
PlayModeDescription {
|
||||
name: "🎲 Play against random player (online)",
|
||||
value: SelectPlayModeResult::PlayRandom,
|
||||
},
|
||||
PlayModeDescription {
|
||||
name: "➕ Create play invite (online)",
|
||||
value: SelectPlayModeResult::CreateInvite,
|
||||
},
|
||||
PlayModeDescription {
|
||||
name: "🎫 Accept play invite (online)",
|
||||
value: SelectPlayModeResult::AcceptInvite,
|
||||
},
|
||||
PlayModeDescription {
|
||||
name: "❌ Exit app",
|
||||
value: SelectPlayModeResult::Exit,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SelectPlayModeScreen {
|
||||
state: ListState,
|
||||
curr_selection: usize,
|
||||
}
|
||||
|
||||
impl SelectPlayModeScreen {
|
||||
pub fn show<B: Backend>(
|
||||
mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
) -> io::Result<ScreenResult<SelectPlayModeResult>> {
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
self.state.select(Some(self.curr_selection));
|
||||
terminal.draw(|f| self.ui(f))?;
|
||||
|
||||
let timeout = TICK_RATE
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if crossterm::event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return Ok(ScreenResult::Canceled),
|
||||
KeyCode::Enter => {
|
||||
return Ok(ScreenResult::Ok(
|
||||
AVAILABLE_PLAY_MODES[self.curr_selection].value,
|
||||
));
|
||||
}
|
||||
KeyCode::Down => self.curr_selection += 1,
|
||||
KeyCode::Up => self.curr_selection += AVAILABLE_PLAY_MODES.len() - 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.curr_selection %= AVAILABLE_PLAY_MODES.len();
|
||||
}
|
||||
}
|
||||
if last_tick.elapsed() >= TICK_RATE {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
|
||||
let area = centered_rect_size(50, 2 + AVAILABLE_PLAY_MODES.len() as u16, &f.size());
|
||||
|
||||
// Create a List from all list items and highlight the currently selected one
|
||||
let items = AVAILABLE_PLAY_MODES
|
||||
.iter()
|
||||
.map(|mode| ListItem::new(Text::raw(mode.name)))
|
||||
.collect::<Vec<_>>();
|
||||
let items = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.title("Select play mode")
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(HIGHLIGHT_COLOR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(">> ");
|
||||
|
||||
f.render_stateful_widget(items, area, &mut self.state);
|
||||
}
|
||||
}
|
@ -0,0 +1,258 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event;
|
||||
use crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
|
||||
use tui::backend::Backend;
|
||||
use tui::layout::Rect;
|
||||
use tui::style::*;
|
||||
use tui::widgets::*;
|
||||
use tui::{Frame, Terminal};
|
||||
|
||||
use sea_battle_backend::data::*;
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::ui_screens::confirm_dialog_screen::confirm;
|
||||
use crate::ui_screens::popup_screen::show_screen_too_small_popup;
|
||||
use crate::ui_screens::utils::{centered_rect_size, centered_text};
|
||||
use crate::ui_screens::ScreenResult;
|
||||
use crate::ui_widgets::game_map_widget::{ColoredCells, GameMapWidget};
|
||||
|
||||
type CoordinatesMapper = HashMap<Coordinates, Coordinates>;
|
||||
|
||||
pub struct SetBoatsLayoutScreen<'a> {
|
||||
curr_boat: usize,
|
||||
layout: BoatsLayout,
|
||||
rules: &'a GameRules,
|
||||
confirm_on_cancel: bool,
|
||||
}
|
||||
|
||||
impl<'a> SetBoatsLayoutScreen<'a> {
|
||||
pub fn new(rules: &'a GameRules) -> Self {
|
||||
Self {
|
||||
curr_boat: 0,
|
||||
layout: BoatsLayout::gen_random_for_rules(rules)
|
||||
.expect("Failed to generate initial boats layout"),
|
||||
rules,
|
||||
confirm_on_cancel: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify whether user should confirm his choice to cancel screen or not
|
||||
pub fn set_confirm_on_cancel(mut self, confirm: bool) -> Self {
|
||||
self.confirm_on_cancel = confirm;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show<B: Backend>(
|
||||
mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
) -> io::Result<ScreenResult<BoatsLayout>> {
|
||||
let mut coordinates_mapper = CoordinatesMapper::default();
|
||||
|
||||
let mut last_tick = Instant::now();
|
||||
let mut is_moving_boat = false;
|
||||
loop {
|
||||
terminal.draw(|f| coordinates_mapper = self.ui(f))?;
|
||||
|
||||
let timeout = TICK_RATE
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
|
||||
if event::poll(timeout)? {
|
||||
let mut move_boat = None;
|
||||
|
||||
let event = event::read()?;
|
||||
if let Event::Key(key) = &event {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
if !self.confirm_on_cancel
|
||||
|| confirm(terminal, "Do you really want to quit?")
|
||||
{
|
||||
return Ok(ScreenResult::Canceled);
|
||||
}
|
||||
}
|
||||
|
||||
// Select next boat
|
||||
KeyCode::Char('n') => self.curr_boat += self.layout.number_of_boats() - 1,
|
||||
|
||||
// Rotate boat
|
||||
KeyCode::Char('r') => {
|
||||
self.layout.0[self.curr_boat].direction =
|
||||
match self.layout.0[self.curr_boat].direction {
|
||||
BoatDirection::Right => BoatDirection::Down,
|
||||
_ => BoatDirection::Right,
|
||||
}
|
||||
}
|
||||
|
||||
// Move boat
|
||||
KeyCode::Left => move_boat = Some((-1, 0)),
|
||||
KeyCode::Right => move_boat = Some((1, 0)),
|
||||
KeyCode::Up => move_boat = Some((0, -1)),
|
||||
KeyCode::Down => move_boat = Some((0, 1)),
|
||||
|
||||
// Submit configuration
|
||||
KeyCode::Enter => {
|
||||
if self.layout.is_valid(self.rules) {
|
||||
return Ok(ScreenResult::Ok(self.layout));
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.curr_boat %= self.layout.number_of_boats();
|
||||
|
||||
// Apply boat move
|
||||
if let Some((x, y)) = move_boat {
|
||||
let new_pos = self.layout.0[self.curr_boat].start.add_x(x).add_y(y);
|
||||
if new_pos.is_valid(self.rules) {
|
||||
self.layout.0[self.curr_boat].start = new_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mouse event
|
||||
else if let Event::Mouse(mouse) = event {
|
||||
let src_pos = Coordinates::new(mouse.column, mouse.row);
|
||||
|
||||
// Start mouse action
|
||||
if MouseEventKind::Down(MouseButton::Left) == mouse.kind {
|
||||
is_moving_boat = if let Some(pos) = coordinates_mapper.get(&src_pos) {
|
||||
if let Some(b) = self.layout.find_boat_at_position(*pos) {
|
||||
self.curr_boat = self.layout.0.iter().position(|s| s == b).unwrap();
|
||||
}
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// Handle continue mouse action
|
||||
else if is_moving_boat {
|
||||
if let Some(pos) = coordinates_mapper.get(&src_pos) {
|
||||
self.layout.0[self.curr_boat].start = *pos;
|
||||
}
|
||||
|
||||
if let MouseEventKind::Up(_) = mouse.kind {
|
||||
is_moving_boat = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= TICK_RATE {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) -> CoordinatesMapper {
|
||||
let errors = self.layout.errors(self.rules);
|
||||
|
||||
// Color of current boat
|
||||
let current_boat = ColoredCells {
|
||||
color: Color::Green,
|
||||
cells: self.layout.0[self.curr_boat].all_coordinates(),
|
||||
};
|
||||
|
||||
// Color of invalid boats
|
||||
let mut invalid_coordinates = vec![];
|
||||
for (idx, pos) in self.layout.boats().iter().enumerate() {
|
||||
if idx == self.curr_boat {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self
|
||||
.layout
|
||||
.check_present_boat_position(idx, self.rules)
|
||||
.is_empty()
|
||||
{
|
||||
invalid_coordinates.append(&mut pos.all_coordinates());
|
||||
}
|
||||
}
|
||||
let invalid_boats = ColoredCells {
|
||||
color: Color::Red,
|
||||
cells: invalid_coordinates,
|
||||
};
|
||||
|
||||
// Color of other boats
|
||||
let mut other_boats_cells = vec![];
|
||||
for boat in &self.layout.0 {
|
||||
other_boats_cells.append(&mut boat.all_coordinates());
|
||||
}
|
||||
|
||||
let other_boats = ColoredCells {
|
||||
color: Color::Gray,
|
||||
cells: other_boats_cells,
|
||||
};
|
||||
|
||||
let mut coordinates_mapper = HashMap::new();
|
||||
|
||||
let mut legend = "n next boat \n\
|
||||
r rotate boat \n\n\
|
||||
← ↓↑ → move boat \n\n"
|
||||
.to_string();
|
||||
if errors.is_empty() {
|
||||
legend.push_str("Enter confirm layout");
|
||||
}
|
||||
|
||||
let mut game_map_widget = GameMapWidget::new(self.rules)
|
||||
.set_default_empty_char(' ')
|
||||
.add_colored_cells(current_boat)
|
||||
.add_colored_cells(invalid_boats)
|
||||
.add_colored_cells(other_boats)
|
||||
.set_title("Choose your boat layout")
|
||||
.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);
|
||||
}
|
||||
}
|
||||
})
|
||||
.set_legend(legend);
|
||||
|
||||
// Color of neighbors if boats can not touch
|
||||
if !self.rules.boats_can_touch {
|
||||
let mut boats_neighbors_cells = vec![];
|
||||
for boat in &self.layout.0 {
|
||||
for pos in boat.neighbor_coordinates(self.rules) {
|
||||
boats_neighbors_cells.push(pos);
|
||||
}
|
||||
}
|
||||
|
||||
game_map_widget = game_map_widget.add_colored_cells(ColoredCells {
|
||||
color: Color::Rgb(30, 30, 30),
|
||||
cells: boats_neighbors_cells,
|
||||
});
|
||||
}
|
||||
|
||||
let (w, h) = game_map_widget.estimated_size();
|
||||
|
||||
if f.size().width < w || f.size().height < h + 3 {
|
||||
// +3 = for errors
|
||||
show_screen_too_small_popup(f);
|
||||
drop(game_map_widget);
|
||||
return coordinates_mapper;
|
||||
}
|
||||
|
||||
let area = centered_rect_size(w, h, &f.size());
|
||||
f.render_widget(game_map_widget, area);
|
||||
|
||||
if !errors.is_empty() {
|
||||
let messages = ["INVALID_LAYOUT", errors[0]];
|
||||
for (i, msg) in messages.iter().enumerate() {
|
||||
let paragraph = Paragraph::new(*msg).style(Style::default().fg(Color::Red));
|
||||
f.render_widget(
|
||||
paragraph,
|
||||
centered_text(
|
||||
msg,
|
||||
&Rect::new(f.size().x, area.bottom() + i as u16, f.size().width, 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
coordinates_mapper
|
||||
}
|
||||
}
|
80
rust/sea_battle_cli_player/src/ui_screens/utils.rs
Normal file
80
rust/sea_battle_cli_player/src/ui_screens/utils.rs
Normal file
@ -0,0 +1,80 @@
|
||||
use tui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// helper function to create a centered rect using up certain percentage of the available rect `r`
|
||||
pub fn centered_rect_percentage(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(r);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(popup_layout[1])[1]
|
||||
}
|
||||
|
||||
/// helper function to create a centered rect using up certain container size
|
||||
pub fn centered_rect_size(width: u16, height: u16, parent: &Rect) -> Rect {
|
||||
if parent.width < width || parent.height < height {
|
||||
return Rect {
|
||||
x: parent.x,
|
||||
y: parent.y,
|
||||
width: parent.width,
|
||||
height: parent.height,
|
||||
};
|
||||
}
|
||||
|
||||
Rect {
|
||||
x: parent.x + (parent.width - width) / 2,
|
||||
y: parent.y + (parent.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// helper function to create a centered rect using up certain container size, only horizontally
|
||||
pub fn centered_rect_size_horizontally(width: u16, parent: &Rect) -> Rect {
|
||||
if parent.width < width {
|
||||
return Rect {
|
||||
x: parent.x,
|
||||
y: parent.y,
|
||||
width: parent.width,
|
||||
height: parent.height,
|
||||
};
|
||||
}
|
||||
|
||||
Rect {
|
||||
x: parent.x + (parent.width - width) / 2,
|
||||
y: parent.y,
|
||||
width,
|
||||
height: parent.height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get coordinates to render centered text
|
||||
pub fn centered_text(text: &str, container: &Rect) -> Rect {
|
||||
if text.len() > container.width as usize {
|
||||
return *container;
|
||||
}
|
||||
|
||||
Rect {
|
||||
x: container.x + (container.width - text.len() as u16) / 2,
|
||||
y: container.y,
|
||||
height: 1,
|
||||
width: text.len() as u16,
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user