diff --git a/examples/battleship/battlestate.rs b/examples/battleship/battlestate.rs new file mode 100644 index 00000000..1250f38d --- /dev/null +++ b/examples/battleship/battlestate.rs @@ -0,0 +1,222 @@ +use serde::{Deserialize, Serialize}; +use std::convert::TryInto; +use turtle::rand::{choose, random_range}; + +use crate::{ + grid::{Cell, Grid}, + ship::{Orientation, Ship, ShipKind}, +}; + +#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)] +pub enum AttackOutcome { + Miss, + Hit, + Destroyed(Ship), +} + +#[derive(Copy, Clone)] +pub enum Position { + ShipGrid((u8, u8)), + AttackGrid((u8, u8)), +} + +impl Position { + pub fn get(self) -> (u8, u8) { + match self { + Self::ShipGrid(p) => p, + Self::AttackGrid(p) => p, + } + } +} + +pub struct BattleState { + ship_grid: Grid, + attack_grid: Grid, + ships: [Ship; 5], + pub destroyed_rival_ships: u8, + pub ships_lost: u8, +} + +impl BattleState { + pub fn new() -> Self { + let (ships, ship_grid) = Self::random_ship_grid(); + Self { + ships, + ship_grid, + attack_grid: Grid::new(Cell::Unattacked), + destroyed_rival_ships: 0, + ships_lost: 0, + } + } + pub fn incoming_attack(&mut self, pos: &(u8, u8)) -> AttackOutcome { + let attacked_cell = self.ship_grid.get(pos); + match attacked_cell { + Cell::Empty => AttackOutcome::Miss, + Cell::Ship(_) => { + let standing_ship_parts = self.ship_grid.count(&attacked_cell); + match standing_ship_parts { + 1 => { + // If the attack is on the last standing ship part, + // change all the Cells of the Ship to Destroyed + let lost_ship = self + .ships + .iter() + .find(|ship| ship.kind.to_cell() == attacked_cell) + .copied() + .unwrap(); + lost_ship + .coordinates() + .into_iter() + .for_each(|loc| *self.ship_grid.get_mut(&loc) = Cell::Destroyed); + self.ships_lost += 1; + AttackOutcome::Destroyed(lost_ship) + } + _ => { + *self.ship_grid.get_mut(pos) = Cell::Bombed; + AttackOutcome::Hit + } + } + } + Cell::Bombed | Cell::Missed | Cell::Destroyed | Cell::Unattacked => unreachable!(), + } + } + pub fn can_bomb(&self, pos: &(u8, u8)) -> bool { + match self.attack_grid.get(pos) { + Cell::Bombed | Cell::Destroyed | Cell::Missed => false, + Cell::Unattacked => true, + Cell::Ship(_) | Cell::Empty => unreachable!(), + } + } + pub fn set_attack_outcome(&mut self, attacked_pos: &(u8, u8), outcome: AttackOutcome) { + match outcome { + AttackOutcome::Miss => *self.attack_grid.get_mut(attacked_pos) = Cell::Missed, + AttackOutcome::Hit => *self.attack_grid.get_mut(attacked_pos) = Cell::Bombed, + AttackOutcome::Destroyed(ship) => { + for pos in ship.coordinates() { + *self.attack_grid.get_mut(&pos) = Cell::Destroyed; + } + self.destroyed_rival_ships += 1; + } + } + } + fn random_ship_grid() -> ([Ship; 5], Grid) { + let ship_types = [ + ShipKind::Carrier, + ShipKind::Battleship, + ShipKind::Cruiser, + ShipKind::Submarine, + ShipKind::Destroyer, + ]; + let mut grid = Grid::new(Cell::Empty); + let mut ships = Vec::new(); + + // Randomly select a position and orientation for a ship type to create a Ship + // Check if the ship doesn't overlap with other ships already added to Grid + // Check if the ship is within the Grid bounds + // If the above two conditions are met, add the ship to the Grid + // And proceed with next ship type + for kind in ship_types { + loop { + let x: u8 = random_range(0, 9); + let y: u8 = random_range(0, 9); + let orient: Orientation = choose(&[Orientation::Horizontal, Orientation::Veritcal]).copied().unwrap(); + + let ship = Ship::new(kind, (x, y), orient); + + let no_overlap = ships + .iter() + .all(|other: &Ship| other.coordinates().iter().all(|pos| !ship.is_located_over(pos))); + + let within_board = ship + .coordinates() + .iter() + .all(|pos| matches!(pos.0, 0..=9) && matches!(pos.1, 0..=9)); + + if no_overlap && within_board { + ships.push(ship); + ship.coordinates().iter().for_each(|pos| { + *grid.get_mut(pos) = kind.to_cell(); + }); + break; + } + } + } + + (ships.try_into().unwrap(), grid) + } + pub fn ship_grid(&self) -> &'_ Grid { + &self.ship_grid + } + pub fn attack_grid(&self) -> &'_ Grid { + &self.attack_grid + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn custom_battlestate(ships: [Ship; 5]) -> BattleState { + let mut ship_grid = Grid::new(Cell::Empty); + ships.iter().for_each(|ship| { + ship.coordinates().iter().for_each(|pos| { + *ship_grid.get_mut(pos) = ship.kind.to_cell(); + }) + }); + BattleState { + ships, + ship_grid, + attack_grid: Grid::new(Cell::Unattacked), + destroyed_rival_ships: 0, + ships_lost: 0, + } + } + + #[test] + fn battle_actions() { + let ships = [ + Ship::new(ShipKind::Carrier, (2, 4), Orientation::Veritcal), + Ship::new(ShipKind::Battleship, (1, 0), Orientation::Horizontal), + Ship::new(ShipKind::Cruiser, (5, 2), Orientation::Horizontal), + Ship::new(ShipKind::Submarine, (8, 4), Orientation::Veritcal), + Ship::new(ShipKind::Destroyer, (6, 7), Orientation::Horizontal), + ]; + // Player's ship grid Opponent's ship grid + // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 + // 0 . B B B B . . . . . 0 . . . . . . . . . . + // 1 . . . . . . . . . . 1 . . . . . S S S . . + // 2 . . . . . R R R . . 2 . . . . . . D D . . + // 3 . . . . . . . . . . 3 . . . . . . . . . . + // 4 . . C . . . . . S . 4 . . . . B B B B . . + // 5 . . C . . . . . S . 5 . . . C C C C C . . + // 6 . . C . . . . . S . 6 . . . . . . . . . . + // 7 . . C . . . D D . . 7 . . . R R R . . . . + // 8 . . C . . . . . . . 8 . . . . . . . . . . + // 9 . . . . . . . . . . 9 . . . . . . . . . . + let mut state = custom_battlestate(ships); + // turn 1: player attacks (2, 2) - misses + state.set_attack_outcome(&(2, 2), AttackOutcome::Miss); + assert_eq!(state.attack_grid.get(&(2, 2)), Cell::Missed); + // turn 2: opponent attacks (6, 7) - hits + let outcome = state.incoming_attack(&(6, 7)); + assert_eq!(outcome, AttackOutcome::Hit); + assert_eq!(state.ship_grid.get(&(6, 7)), Cell::Bombed); + // turn 3: opponent attacks (again) (7, 7) - destroys D + let outcome = state.incoming_attack(&(7, 7)); + assert_eq!(outcome, AttackOutcome::Destroyed(ships[4])); + assert_eq!(state.ship_grid.get(&(7, 7)), Cell::Destroyed); + assert_eq!(state.ship_grid.get(&(6, 7)), Cell::Destroyed); + assert_eq!(state.ships_lost, 1); + // turn 4: player attacks (7, 2) - hits + state.set_attack_outcome(&(7, 2), AttackOutcome::Hit); + assert_eq!(state.attack_grid.get(&(7, 2)), Cell::Bombed); + // turn 5: player attacks (6, 2) - destroys D + state.set_attack_outcome( + &(6, 2), + AttackOutcome::Destroyed(Ship::new(ShipKind::Destroyer, (6, 2), Orientation::Horizontal)), + ); + assert_eq!(state.attack_grid.get(&(6, 2)), Cell::Destroyed); + assert_eq!(state.attack_grid.get(&(7, 2)), Cell::Destroyed); + assert_eq!(state.destroyed_rival_ships, 1); + } +} diff --git a/examples/battleship/bot.rs b/examples/battleship/bot.rs new file mode 100644 index 00000000..5bf40c29 --- /dev/null +++ b/examples/battleship/bot.rs @@ -0,0 +1,112 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use turtle::rand::random_range; + +use crate::{ + battlestate::{AttackOutcome, BattleState}, + channel::{Channel, Message}, + game::Turn, + grid::Cell, +}; + +pub struct Bot { + channel: Channel, + state: BattleState, + turn: Turn, +} + +impl Bot { + pub fn new(port: u16) -> Self { + Self { + channel: Channel::client(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)), + state: BattleState::new(), + turn: Turn::Opponent, + } + } + + fn random_attack_location(&self) -> (u8, u8) { + loop { + let x = random_range(0, 9); + let y = random_range(0, 9); + if self.state.can_bomb(&(x, y)) { + return (x, y); + } + } + } + + fn get_attack_location(&self) -> (u8, u8) { + // Iterator on positions of all the bombed (Hit, not Destroyed) locations in AttackGrid + let bombed_locations = self + .state + .attack_grid() + .iter() + .flatten() + .enumerate() + .filter(|(_, &cell)| cell == Cell::Bombed) + .map(|(loc, _)| ((loc as f32 / 10.0).floor() as i32, loc as i32 % 10)); + + // Iterate over each bombed location until an attackable position + // is found in the neighbourhood of the bombed location and return it + for loc in bombed_locations { + let attackable = [(-1, 0), (1, 0), (0, -1), (0, 1)] + .iter() + .map(|n| (n.0 + loc.0, n.1 + loc.1)) + .filter(|pos| matches!(pos.0, 0..=9) && matches!(pos.1, 0..=9)) + .map(|pos| (pos.0 as u8, pos.1 as u8)) + .find(|pos| self.state.can_bomb(&pos)); + + if let Some(pos) = attackable { + return pos; + } + } + // Otherwise return a random attack location if no bombed locations are present + self.random_attack_location() + } + + /// Similar to Game::run but without graphics + pub fn play(&mut self) { + loop { + match self.turn { + Turn::Me => { + let attack_location = self.get_attack_location(); + self.channel.send_message(&Message::AttackCoordinates(attack_location)); + match self.channel.receive_message() { + Message::AttackResult(outcome) => { + self.state.set_attack_outcome(&attack_location, outcome); + match outcome { + AttackOutcome::Miss | AttackOutcome::Destroyed(_) => { + self.turn.flip(); + } + _ => (), + } + } + _ => panic!("Expected Message of AttackResult from Opponent."), + } + } + Turn::Opponent => match self.channel.receive_message() { + Message::AttackCoordinates(p) => { + let outcome = self.state.incoming_attack(&p); + self.channel.send_message(&Message::AttackResult(outcome)); + match outcome { + AttackOutcome::Miss | AttackOutcome::Destroyed(_) => { + self.turn.flip(); + } + AttackOutcome::Hit => (), + } + } + _ => panic!("Expected Message of AttackCoordinates from Opponent"), + }, + } + + match (self.state.ships_lost, self.state.destroyed_rival_ships) { + (5, _) => { + break; + } + (_, 5) => { + break; + } + (_, _) => continue, + } + } + } +} diff --git a/examples/battleship/channel.rs b/examples/battleship/channel.rs new file mode 100644 index 00000000..673bf9c9 --- /dev/null +++ b/examples/battleship/channel.rs @@ -0,0 +1,52 @@ +use std::net::{SocketAddr, TcpListener, TcpStream}; + +use crate::battlestate::AttackOutcome; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub enum Message { + AttackCoordinates((u8, u8)), + AttackResult(AttackOutcome), +} + +pub enum ChannelType { + Server, + Client(SocketAddr), + UseListener(TcpListener), +} + +pub struct Channel { + stream: TcpStream, +} + +impl Channel { + pub fn client(socket_addr: SocketAddr) -> Self { + Self { + stream: TcpStream::connect(socket_addr).expect("Couldn't connect to the server"), + } + } + + pub fn server() -> Self { + let listener = TcpListener::bind("0.0.0.0:0").expect("Failed to bind to port"); + println!( + "Listening on port: {}, Waiting for connection..(See help: -h, --help)", + listener.local_addr().unwrap().port() + ); + let (stream, _) = listener.accept().expect("Couldn't connect to the client"); + Self { stream } + } + + pub fn serve_using_listener(listener: TcpListener) -> Self { + let (stream, _) = listener.accept().expect("Couldn't connect to the client"); + Self { stream } + } + + pub fn send_message(&mut self, msg: &Message) { + serde_json::to_writer(&self.stream, &msg).expect("Failed to send message"); + } + + pub fn receive_message(&mut self) -> Message { + let mut de = serde_json::Deserializer::from_reader(&self.stream); + Message::deserialize(&mut de).expect("Failed to deserialize message") + } +} diff --git a/examples/battleship/config.rs b/examples/battleship/config.rs new file mode 100644 index 00000000..8516080d --- /dev/null +++ b/examples/battleship/config.rs @@ -0,0 +1,46 @@ +use crate::{grid::Cell, ship::ShipKind}; +use turtle::Color; + +pub struct Config {} + +impl Config { + pub const EMPTY_COLOR: &'static str = "#55dde0"; + pub const UNATTACKED_COLOR: &'static str = "#55dde0"; + pub const CARRIER_COLOR: &'static str = "#fde74c"; + pub const BATTLESHIP_COLOR: &'static str = "#f4d58d"; + pub const CRUISER_COLOR: &'static str = "#947757"; + pub const SUBMARINE_COLOR: &'static str = "#9bc53d"; + pub const DESTROYER_COLOR: &'static str = "#238cf4"; + pub const MISSED_COLOR: &'static str = "#33658a"; + pub const BOMBED_COLOR: &'static str = "#f26419"; + pub const DESTROYED_COLOR: &'static str = "#349c9e"; + + pub const CELL_SIZE: f64 = 40.0; + pub const SPACE_BETWEEN_GRIDS: f64 = 50.0; + + pub const SHIP_GRID_TOP_LEFT: (f64, f64) = (-Self::SPACE_BETWEEN_GRIDS / 2.0 - 10.0 * Self::CELL_SIZE, 5.0 * Self::CELL_SIZE); + pub const ATTACK_GRID_TOP_LEFT: (f64, f64) = (Self::SPACE_BETWEEN_GRIDS / 2.0, 5.0 * Self::CELL_SIZE); + + pub const MISSED_CIRCLE_RADIUS: f64 = 0.25 * Self::CELL_SIZE * 0.5; + pub const BOMBED_CIRCLE_RADIUS: f64 = 0.75 * Self::CELL_SIZE * 0.5; + + pub const CROSSHAIR_SIZE: f64 = 0.2 * Self::CELL_SIZE; + pub const CROSSHAIR_PEN_SIZE: f64 = 4.0; + pub const CROSSHAIR_COLOR: &'static str = "#f26419"; + pub const DISABLED_CROSSHAIR_COLOR: &'static str = "#000000"; + + pub fn cell_color(cell: &Cell) -> Color { + match cell { + Cell::Ship(ShipKind::Carrier) => Self::CARRIER_COLOR.into(), + Cell::Ship(ShipKind::Battleship) => Self::BATTLESHIP_COLOR.into(), + Cell::Ship(ShipKind::Cruiser) => Self::CRUISER_COLOR.into(), + Cell::Ship(ShipKind::Submarine) => Self::SUBMARINE_COLOR.into(), + Cell::Ship(ShipKind::Destroyer) => Self::DESTROYER_COLOR.into(), + Cell::Empty => Self::EMPTY_COLOR.into(), + Cell::Unattacked => Self::UNATTACKED_COLOR.into(), + Cell::Missed => Self::MISSED_COLOR.into(), + Cell::Bombed => Self::BOMBED_COLOR.into(), + Cell::Destroyed => Self::DESTROYED_COLOR.into(), + } + } +} diff --git a/examples/battleship/crosshair.rs b/examples/battleship/crosshair.rs new file mode 100644 index 00000000..3c5d8efe --- /dev/null +++ b/examples/battleship/crosshair.rs @@ -0,0 +1,110 @@ +use crate::{ + battlestate::{BattleState, Position}, + config::Config, + game::Game, +}; +use turtle::Turtle; + +pub enum CrosshairType { + LockTarget, + Disabled, +} + +pub struct Crosshair<'a> { + pos: (u8, u8), + state: &'a BattleState, + turtle: &'a mut Turtle, +} + +impl<'a> Crosshair<'a> { + pub fn new(state: &'a BattleState, turtle: &'a mut Turtle, last_attacked_pos: Option<(u8, u8)>) -> Self { + let pos; + // Draw crosshair in disabled mode on last attacked position + if let Some(attacked_pos) = last_attacked_pos { + pos = attacked_pos; + Self::draw_crosshair(pos, turtle, CrosshairType::Disabled); + // There's no last attacked position -> player's first chance + // Draw crosshair in abled mode at the center. + } else { + pos = (4, 4); + Self::draw_crosshair(pos, turtle, CrosshairType::LockTarget); + } + Self { pos, state, turtle } + } + + fn draw_crosshair(pos: (u8, u8), turtle: &mut Turtle, crosshair: CrosshairType) { + let (x, y) = Config::ATTACK_GRID_TOP_LEFT; + turtle.set_pen_color(if matches!(crosshair, CrosshairType::Disabled) { + Config::DISABLED_CROSSHAIR_COLOR + } else { + Config::CROSSHAIR_COLOR + }); + turtle.set_pen_size(Config::CROSSHAIR_PEN_SIZE); + let start = ( + x + Config::CELL_SIZE * (0.5 + pos.1 as f64), + y - Config::CELL_SIZE * (0.5 + pos.0 as f64), + ); + turtle.pen_up(); + turtle.go_to(start); + turtle.pen_down(); + turtle.set_heading(0.0); + for _ in 0..4 { + turtle.forward(Config::CROSSHAIR_SIZE); + turtle.pen_up(); + turtle.backward(Config::CROSSHAIR_SIZE); + turtle.pen_down(); + turtle.right(90.0); + } + turtle.set_pen_color("black"); + turtle.set_pen_size(1.0); + } + + fn move_to(&mut self, pos: (u8, u8), game: &Game) { + // Remove crosshair on previous pos by redrawing the Cell at that pos. + let cell = self.state.attack_grid().get(&self.pos); + game.draw_cell(cell, Position::AttackGrid(self.pos), self.turtle); + + // Set Crosshair in disabled or abled mode based on new pos + let crosshair = match self.state.can_bomb(&pos) { + true => CrosshairType::LockTarget, + false => CrosshairType::Disabled, + }; + + // Draw Crosshair at new pos + Self::draw_crosshair(pos, self.turtle, crosshair); + self.pos = pos; + } + + pub fn move_left(&mut self, game: &Game) { + if self.pos.1 > 0 { + self.move_to((self.pos.0, self.pos.1 - 1), game); + } + } + + pub fn move_right(&mut self, game: &Game) { + if self.pos.1 < 9 { + self.move_to((self.pos.0, self.pos.1 + 1), game); + } + } + + pub fn move_up(&mut self, game: &Game) { + if self.pos.0 > 0 { + self.move_to((self.pos.0 - 1, self.pos.1), game); + } + } + + pub fn move_down(&mut self, game: &Game) { + if self.pos.0 < 9 { + self.move_to((self.pos.0 + 1, self.pos.1), game); + } + } + + // Retuns Some(pos) if the crosshair pos is attackable + // None otherwise + pub fn try_bomb(&mut self) -> Option<(u8, u8)> { + if self.state.can_bomb(&self.pos) { + return Some(self.pos); + } + None + } +} diff --git a/examples/battleship/game.rs b/examples/battleship/game.rs new file mode 100644 index 00000000..d9535475 --- /dev/null +++ b/examples/battleship/game.rs @@ -0,0 +1,243 @@ +use turtle::{ + event::{Key, PressedState}, + Drawing, Event, Turtle, +}; + +use crate::{ + battlestate::{AttackOutcome, BattleState, Position}, + channel::{Channel, ChannelType, Message}, + config::Config, + crosshair::Crosshair, + grid::Cell, +}; + +use std::{thread, time::Duration}; + +pub enum Turn { + Me, + Opponent, +} + +impl Turn { + pub fn flip(&mut self) { + match self { + Turn::Me => *self = Turn::Opponent, + Turn::Opponent => *self = Turn::Me, + } + } +} + +pub struct Game { + state: BattleState, + channel: Channel, + turn: Turn, +} + +impl Game { + pub fn init(channel_type: ChannelType) -> Self { + let state = BattleState::new(); + let (channel, turn) = match channel_type { + ChannelType::Server => (Channel::server(), Turn::Me), + ChannelType::Client(addr) => (Channel::client(addr), Turn::Opponent), + ChannelType::UseListener(listener) => (Channel::serve_using_listener(listener), Turn::Me), + }; + + Self { state, channel, turn } + } + + pub fn draw_cell(&self, cell: Cell, loc: Position, turtle: &mut Turtle) { + fn draw_circle(turtle: &mut Turtle, radius: f64) { + let pen_color = turtle.pen_color(); + turtle.set_pen_color("transparent"); + turtle.set_heading(0.0); + turtle.begin_fill(); + turtle.arc_right(radius, 360.0); + turtle.end_fill(); + turtle.set_pen_color(pen_color); + } + fn draw_square(turtle: &mut Turtle, size: f64) { + turtle.set_heading(0.0); + turtle.begin_fill(); + for _ in 0..4 { + turtle.forward(size); + turtle.right(90.0); + } + turtle.end_fill(); + } + let (x, y) = match loc { + Position::ShipGrid(_) => Config::SHIP_GRID_TOP_LEFT, + Position::AttackGrid(_) => Config::ATTACK_GRID_TOP_LEFT, + }; + + let pos = loc.get(); + + match cell { + Cell::Missed | Cell::Bombed => { + if cell == Cell::Missed { + // Crosshair::move_to calls Game::draw_cell method to remove crosshair from previous pos. + // When Cell::Missed drawn, the circle is so small that it leaves some parts of crosshair + // This redundant call to draw an empty cell is made so that it won't happen + self.draw_cell(Cell::Empty, loc, turtle); + } + let radius = if cell == Cell::Missed { + Config::MISSED_CIRCLE_RADIUS + } else { + Config::BOMBED_CIRCLE_RADIUS + }; + let start = ( + x + Config::CELL_SIZE * (pos.1 as f64 + 0.5), + y - Config::CELL_SIZE * pos.0 as f64 - (Config::CELL_SIZE / 2.0 - radius), + ); + turtle.pen_up(); + turtle.go_to(start); + turtle.pen_down(); + turtle.set_fill_color(Config::cell_color(&cell)); + draw_circle(turtle, radius); + } + _ => { + let start = (x + Config::CELL_SIZE * pos.1 as f64, y - Config::CELL_SIZE * pos.0 as f64); + turtle.pen_up(); + turtle.go_to(start); + turtle.pen_down(); + turtle.set_fill_color(Config::cell_color(&cell)); + draw_square(turtle, Config::CELL_SIZE); + } + } + } + + fn draw_board(&self, turtle: &mut Turtle) { + let ship_grid = self.state.ship_grid(); + let attack_grid = self.state.attack_grid(); + + for x in 0..10 { + for y in 0..10 { + self.draw_cell(ship_grid.get(&(x, y)), Position::ShipGrid((x, y)), turtle); + self.draw_cell(attack_grid.get(&(x, y)), Position::AttackGrid((x, y)), turtle); + } + } + } + + fn get_attack_location(&self, drawing: &mut Drawing, turtle: &mut Turtle, last_attacked_location: Option<(u8, u8)>) -> (u8, u8) { + let mut crosshair = Crosshair::new(&self.state, turtle, last_attacked_location); + // Event loop that busy waits for user input and moves crosshair based on user actions + // Exits and returns attack location when user presses "Enter", if location is attackable + loop { + // TODO: Replace poll_event with blocking next_event after Events API is stabilized + // https://github.com/sunjay/turtle/issues/178 + while let Some(event) = drawing.poll_event() { + use Key::{DownArrow, LeftArrow, Return, RightArrow, Space, UpArrow}; + if let Event::Key(key, PressedState::Pressed) = event { + match key { + LeftArrow => crosshair.move_left(self), + RightArrow => crosshair.move_right(self), + UpArrow => crosshair.move_up(self), + DownArrow => crosshair.move_down(self), + Return | Space => { + if let Some(pos) = crosshair.try_bomb() { + return pos; + } + } + _ => (), + } + } + } + // reduce CPU usage by putting the thread to sleep + // The sleep time is small enough so the user won't notice input lag + thread::sleep(Duration::from_millis(16)); + } + } + + /// Handles player's turn + /// * Determine attack location + /// * Send attack coordinates to Opponent + /// * Receive attack outcome from Opponent + /// * Update state, render changes and change turn if necessary + fn my_chance(&mut self, drawing: &mut Drawing, turtle: &mut Turtle, last_bombed_location: &mut Option<(u8, u8)>) { + let attack_location = self.get_attack_location(drawing, turtle, *last_bombed_location); + *last_bombed_location = Some(attack_location); + self.channel.send_message(&Message::AttackCoordinates(attack_location)); + match self.channel.receive_message() { + Message::AttackResult(outcome) => { + self.state.set_attack_outcome(&attack_location, outcome); + match outcome { + AttackOutcome::Miss => { + self.draw_cell(Cell::Missed, Position::AttackGrid(attack_location), turtle); + self.turn.flip(); + } + AttackOutcome::Hit => { + self.draw_cell(Cell::Bombed, Position::AttackGrid(attack_location), turtle); + } + AttackOutcome::Destroyed(ship) => { + ship.coordinates() + .into_iter() + .for_each(|pos| self.draw_cell(Cell::Destroyed, Position::AttackGrid(pos), turtle)); + self.turn.flip(); + } + } + } + _ => panic!("Expected Message of AttackResult from Opponent."), + } + } + + /// Handles opponent's turn + /// * Receive attack coordinates from Opponent + /// * Update state and send outcome of attack to Opponent + /// * Render changes and change turn if necessary + fn opponent_chance(&mut self, turtle: &mut Turtle) { + match self.channel.receive_message() { + Message::AttackCoordinates(p) => { + let outcome = self.state.incoming_attack(&p); + self.channel.send_message(&Message::AttackResult(outcome)); + match outcome { + AttackOutcome::Miss => { + self.draw_cell(Cell::Missed, Position::ShipGrid(p), turtle); + self.turn.flip(); + } + AttackOutcome::Hit => { + self.draw_cell(Cell::Bombed, Position::ShipGrid(p), turtle); + } + AttackOutcome::Destroyed(ship) => { + ship.coordinates() + .into_iter() + .for_each(|pos| self.draw_cell(Cell::Destroyed, Position::ShipGrid(pos), turtle)); + self.turn.flip(); + } + } + } + _ => panic!("Expected Message of AttackCoordinates from Opponent"), + } + } + + pub fn run(&mut self) { + let mut drawing = Drawing::new(); + let mut turtle = drawing.add_turtle(); + let mut last_bombed_location = None; + + turtle.hide(); + turtle.set_speed("instant"); + self.draw_board(&mut turtle); + + // Game loop + loop { + match self.turn { + Turn::Me => self.my_chance(&mut drawing, &mut turtle, &mut last_bombed_location), + Turn::Opponent => self.opponent_chance(&mut turtle), + } + + // Break Game loop (exit game), when either player destroys opponent's fleet or loses their fleet + match (self.state.ships_lost, self.state.destroyed_rival_ships) { + (5, _) => { + println!("NT"); + break; + } + (_, 5) => { + println!("GG"); + break; + } + (_, _) => continue, + } + } + + drawing.destroy(); + } +} diff --git a/examples/battleship/grid.rs b/examples/battleship/grid.rs new file mode 100644 index 00000000..87768f60 --- /dev/null +++ b/examples/battleship/grid.rs @@ -0,0 +1,74 @@ +use crate::ship::ShipKind; +use std::{fmt::Display, ops::Deref}; + +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum Cell { + Ship(ShipKind), + /// clear cell on ShipGrid + Empty, + /// clear cell on AttackGrid + Unattacked, + Missed, + Bombed, + /// Denotes a ship cell of a completely destroyed ship + Destroyed, +} + +impl Display for Cell { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Cell::Ship(ShipKind::Carrier) => write!(f, "C"), + Cell::Ship(ShipKind::Battleship) => write!(f, "B"), + Cell::Ship(ShipKind::Cruiser) => write!(f, "R"), + Cell::Ship(ShipKind::Submarine) => write!(f, "S"), + Cell::Ship(ShipKind::Destroyer) => write!(f, "D"), + Cell::Empty | Cell::Unattacked => write!(f, "."), + Cell::Missed => write!(f, ","), + Cell::Bombed => write!(f, "*"), + Cell::Destroyed => write!(f, "#"), + } + } +} + +impl ShipKind { + pub fn to_cell(self) -> Cell { + Cell::Ship(self) + } +} + +#[derive(Debug, Copy, Clone)] +pub struct Grid([[Cell; 10]; 10]); + +impl Deref for Grid { + type Target = [[Cell; 10]; 10]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Display for Grid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for i in 0..10 { + for j in 0..10 { + write!(f, "{}", self.get(&(j, i)))? + } + writeln!(f)? + } + Ok(()) + } +} + +impl Grid { + pub fn new(cell: Cell) -> Self { + Self { 0: [[cell; 10]; 10] } + } + pub fn get(&self, pos: &(u8, u8)) -> Cell { + self.0[pos.0 as usize][pos.1 as usize] + } + pub fn get_mut(&mut self, pos: &(u8, u8)) -> &mut Cell { + &mut self.0[pos.0 as usize][pos.1 as usize] + } + pub fn count(&mut self, cell: &Cell) -> usize { + self.iter().flatten().filter(|&c| c == cell).count() + } +} diff --git a/examples/battleship/main.rs b/examples/battleship/main.rs new file mode 100644 index 00000000..09dc1f36 --- /dev/null +++ b/examples/battleship/main.rs @@ -0,0 +1,83 @@ +//! Battleship is a two-player strategic guessing game. +//! There are two grids - let's call them - ShipGrid (left) and AttackGrid (right). +//! The ShipGrid, is where the player's fleet of ships is situated and marked. +//! The AttackGrid, is where the opponent's fleet is situated but concealed. +//! The goal is to destroy the opponents fleet by shooting(guessing all tiles) containing a ship of his fleet. +//! Whenever a shot was fired the player gets a feedback: either miss (•) or hit (a red ⚫). +//! That feedback enables some optimization strategies - to guess strategically optimal. +//! +//! You can use the arrow keys (←, ↑, ↓, →) to move the cross-hair in AttackGrid +//! and press the `Enter ⏎` or Space key to attack. +//! +//! To play in single player mode, you can pass `bot` as an argument to the program +//! +//! $> ./battleship bot +//! $> cargo run --features unstable --example battleship bot # From within the turtle source-code. +//! +//! To play in multiplayer mode, one player needs to act as the server and the other as client. +//! To act as a server, run the program without any arguments. +//! +//! $> ./battleship # No arguments +//! $> cargo run --features unstable --example battleship # From within the turtle source-code. +//! +//! This will output something like "Listening on port: , Waiting for connection..". +//! As soon as an opponent connects the game starts. +//! +//! If the other player is also within the same LAN, you can share your private IP address and +//! which they can use to connect to your game by running the program with these arguments: +//! +//! $> ./battleship # eg: ./battleship 192.168.0.120:35765 +//! $> cargo run --features unstable --example battleship +//! +//! If not in same LAN, you can try DynamicDNS or a publicly routable IP address +//! but there are many possible problems that could arise. + +// To run, use the command: cargo run --features unstable --example battleship bot +#[cfg(all(not(feature = "unstable")))] +compile_error!("This example relies on unstable features. Run with `--features unstable`"); + +mod battlestate; +mod bot; +mod channel; +mod config; +mod crosshair; +mod game; +mod grid; +mod ship; +mod utils; + +use bot::Bot; +use channel::ChannelType; +use game::Game; +use std::{thread, time::Duration}; +use utils::*; + +fn main() { + let opt = parse_args(); + match opt { + Opt::Server => { + let mut game = Game::init(ChannelType::Server); + game.run(); + } + Opt::Client(addr) => { + let mut game = Game::init(ChannelType::Client(addr)); + game.run(); + } + Opt::PlayWithBot => { + // Create a TCP listener on a free port and use it to make a game server. + // The game server will start listening on that port while we spawn + // a bot instance in a separate thread which would later connnect to the server. + let listener = get_tcp_listener(); + let port = listener.local_addr().unwrap().port(); + let handle = thread::spawn(move || { + // delay to let the game server start listening on port + thread::sleep(Duration::from_millis(10)); + let mut bot = Bot::new(port); + bot.play(); + }); + let mut game = Game::init(ChannelType::UseListener(listener)); + game.run(); + handle.join().unwrap(); + } + } +} diff --git a/examples/battleship/ship.rs b/examples/battleship/ship.rs new file mode 100644 index 00000000..d31917ca --- /dev/null +++ b/examples/battleship/ship.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; + +// This implementation is based on 1990 Milton Bradley version of Battleship +// https://en.wikipedia.org/wiki/Battleship_(game)#Description +#[derive(Debug, Copy, Clone, PartialEq, Deserialize, Serialize)] +pub enum ShipKind { + Carrier, + Battleship, + Cruiser, + Submarine, + Destroyer, +} + +impl ShipKind { + // returns the length of the ship + pub fn size(&self) -> u8 { + match self { + Self::Carrier => 5, + Self::Battleship => 4, + Self::Cruiser => 3, + Self::Submarine => 3, + Self::Destroyer => 2, + } + } +} + +// Specifies the alignment of a ship in the Grid +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Orientation { + Horizontal, + Veritcal, +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Ship { + pub kind: ShipKind, + top_left: (u8, u8), + orientation: Orientation, +} + +impl Ship { + pub fn new(kind: ShipKind, top_left: (u8, u8), orientation: Orientation) -> Self { + Self { + kind, + top_left, + orientation, + } + } + pub fn coordinates(&self) -> Vec<(u8, u8)> { + let (x, y) = self.top_left; + + (0..self.kind.size()) + .map(|i| match self.orientation { + Orientation::Horizontal => (x + i, y), + Orientation::Veritcal => (x, y + i), + }) + .collect() + } + pub fn is_located_over(&self, pos: &(u8, u8)) -> bool { + self.coordinates().contains(pos) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn ship_intersection() { + let carrier = Ship::new(ShipKind::Carrier, (1, 2), Orientation::Veritcal); + let cspan: Vec<_> = (2..=6).map(|y| (1, y)).collect(); + + let battleship = Ship::new(ShipKind::Battleship, (3, 2), Orientation::Horizontal); + let bspan: Vec<_> = (3..=6).map(|x| (x, 2)).collect(); + + for x in 0..10 { + for y in 0..10 { + let pos = (x as u8, y as u8); + if cspan.contains(&pos) { + assert!(carrier.is_located_over(&pos)); + } else { + assert!(!carrier.is_located_over(&pos)); + } + if bspan.contains(&pos) { + assert!(battleship.is_located_over(&pos)); + } else { + assert!(!battleship.is_located_over(&pos)); + } + } + } + } +} diff --git a/examples/battleship/utils.rs b/examples/battleship/utils.rs new file mode 100644 index 00000000..8c8cae2b --- /dev/null +++ b/examples/battleship/utils.rs @@ -0,0 +1,56 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; + +/// Scans the private port range for an open port and creates a TCP listener +pub fn get_tcp_listener() -> TcpListener { + let localhost = IpAddr::V4(Ipv4Addr::LOCALHOST); + for port in 49152..=65535 { + if let Ok(listener) = TcpListener::bind(SocketAddr::new(localhost, port)) { + return listener; + } + } + panic!("No ports available!"); +} + +/// Command-line arguments config +pub enum Opt { + /// server - multiplayer mode + Server, + /// client - multiplayer mode + Client(SocketAddr), + /// single player mode + PlayWithBot, +} + +fn print_help() { + let help = r#" +battleship + +USAGE: + battleship # no arguments - acts as server + battleship bot # single player mode + battleship # connect to server eg: battleship 192.168.0.120:36354 + +FLAGS: + -h, --help Prints help information + +"#; + println!("{}", help); +} + +/// Parses command line arguments +pub fn parse_args() -> Opt { + let mut args = std::env::args().skip(1); + + match args.next().as_ref().map(String::as_ref) { + Some("-h") | Some("--help") => { + print_help(); + std::process::exit(0); + } + Some("bot") => Opt::PlayWithBot, + Some(addr) => { + let socket_addr = addr.parse().expect("Invalid IP:PORT, See help (-h, --help)"); + Opt::Client(socket_addr) + } + None => Opt::Server, + } +}