diff --git a/battleship.py b/battleship.py deleted file mode 100644 index 2a8285b..0000000 --- a/battleship.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -from os import system -from typing import Tuple, List, Set, Optional -from enum import Enum, auto - - -class ShipOrientation(Enum): - RIGHT = auto() - DOWN = auto() - - -class ShipType(Enum): - PATROL_BOAT = (2, "P") - DESTROYER = (3, "D") - SUBMARINE = (3, "S") - BATTLESHIP = (4, "B") - CARRIER = (5, "C") - - @property - def size(self) -> int: - return self.value[0] - - @property - def symbol(self) -> str: - return self.value[1] - - - -class Ship: - x: int - y: int - ship_type: ShipType - orientation: ShipOrientation - - def __init__(self, x: int, y: int, ship_type: ShipType, orientation: ShipOrientation): - self.x = x - self.y = y - self.ship_type = ship_type - self.orientation = orientation - - if self.orientation == ShipOrientation.RIGHT: - self.squares = {(x_i, self.y) for x_i in range(self.x, self.x+self.ship_type.size)} - else: - self.squares = {(self.x, y_i) for y_i in range(self.y, self.y+self.ship_type.size)} - - - def sunk(self, guesses: Set[Tuple[int, int]]) -> bool: - return self.squares.issubset(guesses) - - def hit(self, guess) -> bool: - return guess in self.squares - - - -class Player: - def __init__(self, name: str, board_shape: Tuple[int, int]): - self.name = name - self.board_shape = board_shape - self.guesses = set() - self.ships: List[Ship] = [] - - def place_ships(self): - pass - - def symbol_at(self, position: Tuple[int, int]) -> str: - pass - - def guess(self, opponent: Player) -> Tuple[int, int]: - pass - - def defeated(self, guesses: Set[Tuple[int, int]]): - return all(ship.sunk(guesses) for ship in self.ships) - - def hit(self, position: Tuple[int, int]) -> Optional[Ship]: - for ship in self.ships: - if ship.hit(position): - return ship - return None - - - -class HumanPlayer(Player): - pass - - - -class AIPlayer(Player): - pass - - -def grid_input(prompt: str, board_shape: Tuple[int, int]) -> Tuple[int, int]: - pass - - -def format_board(viewer: Player, opponent: Player) -> str: - pass - - -def play_turn(player: Player, opponent: Player): - print(format_board(player, opponent)) - player.guesses.add(player.guess(opponent)) -window if isinstance(player, HumanPlayer) and isinstance(opponent, HumanPlayer): - system("clear") - - -def play_battleship(player_1: Player, player_2: Player): - player_1.place_ships() - player_2.place_ships() - - while not (player_1.defeated(player_2.guesses) or player_2.defeated(player_1.guesses)): - play_turn(player_1, player_2) - play_turn(player_2, player_1) - - if player_1.defeated(player_2.guesses) and player_2.defeated(player_1.guesses): - print("It's a draw!") - elif player_1.defeated(player_2.guesses): - print(f"{player_2.name} wins!") - else: - print(f"{player_1.name} wins!") diff --git a/battleship/__init__.py b/battleship/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/battleship/__main__.py b/battleship/__main__.py new file mode 100644 index 0000000..c63607d --- /dev/null +++ b/battleship/__main__.py @@ -0,0 +1,12 @@ +from .battleship import play_battleship +from .board import Board +from .human import HumanPlayer + +board = Board(10, 10) +p1 = HumanPlayer("Player 1", board) +p2 = HumanPlayer("Player 2", board) + +try: + play_battleship(p1, p2) +except KeyboardInterrupt: + print("\n") diff --git a/battleship/ai.py b/battleship/ai.py new file mode 100644 index 0000000..221c6a8 --- /dev/null +++ b/battleship/ai.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .player import Player + + +class AIPlayer(Player): + pass \ No newline at end of file diff --git a/battleship/battleship.py b/battleship/battleship.py new file mode 100644 index 0000000..aae104e --- /dev/null +++ b/battleship/battleship.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from .player import Player +from .ship import ShipType +from .ui import clear_screen, format_board, opponent_cell_fn + + +def play_turn(player: Player, opponent: Player): + guess = player.guess(opponent) + player.guesses.add(guess) + + ship = opponent.ship_at(guess) + + clear_screen() + print(format_board(opponent_cell_fn(player, opponent), opponent.board)) + print() + + if not ship: + print("Miss!") + elif ship.sunk(player.guesses): + print(f"{player.name} sunk the enemy {ship.name}") + else: + print("Hit!") + + print("\n") + input("Press ENTER to continue.") + + +def play_battleship(player_1: Player, player_2: Player): + clear_screen() + ship_types = list(sorted(ShipType, key=lambda t: t.size, reverse=True))[-1:] + player_1.place_ships(ship_types) + player_2.place_ships(ship_types) + + while not player_1.defeated(player_2.guesses) and not player_2.defeated(player_1.guesses): + play_turn(player_1, player_2) + play_turn(player_2, player_1) + + clear_screen() + if player_1.defeated(player_2.guesses) and player_2.defeated(player_1.guesses): + print("It's a draw!") + elif player_1.defeated(player_2.guesses): + print(f"{player_2.name} wins!") + else: + print(f"{player_1.name} wins!") + + input("Press ENTER to exit.") diff --git a/battleship/board.py b/battleship/board.py new file mode 100644 index 0000000..573ee0b --- /dev/null +++ b/battleship/board.py @@ -0,0 +1,22 @@ +from .bs_types import Coordinate + + +class Board: + """ + Represents the shape of a board and provides utility functions related its coordinate space. + """ + def __init__(self, width, height): + self.width = width + self.height = height + + @property + def rows(self): + return range(self.height) + + @property + def columns(self): + return range(self.width) + + def valid(self, coordinate: Coordinate) -> bool: + x, y = coordinate + return x in self.columns and y in self.rows diff --git a/battleship/bs_types.py b/battleship/bs_types.py new file mode 100644 index 0000000..96084e9 --- /dev/null +++ b/battleship/bs_types.py @@ -0,0 +1,4 @@ +from typing import Tuple, Callable + +Coordinate = Tuple[int, int] +CellFn = Callable[[Coordinate], str] diff --git a/battleship/human.py b/battleship/human.py new file mode 100644 index 0000000..4b04fe9 --- /dev/null +++ b/battleship/human.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import List + +from .bs_types import Coordinate +from .player import Player +from .ship import Ship, ShipType +from .ui import read_coordinate, format_board, player_cell_fn, read_orientation, opponent_cell_fn, \ + clear_screen + + +class HumanPlayer(Player): + def place_ships(self, ship_types: List[ShipType]): + print(f"{self.name}, time to place your ships. Press ENTER to continue.") + input() + super().place_ships(ship_types) + clear_screen() + + def place_ship(self, ship_type: ShipType) -> Ship: + current_board = format_board(player_cell_fn(self), self.board) + + while True: + clear_screen() + print(current_board) + print() + + print(f"Please place your {ship_type.name} (size {ship_type.size}).") + coord = read_coordinate(f"What is the top left coordinate of the ship? ", self.board) + orientation = read_orientation("Does the ship extend [D]own or to the [R]ight? ") + print() + + ship = Ship(coord, ship_type, orientation) + + if self.validate_placement(ship): + return ship + else: + print("Press ENTER to try again.\n") + input() + + def guess(self, opponent: Player) -> Coordinate: + current_board = format_board(opponent_cell_fn(self, opponent), opponent.board) + while True: + clear_screen() + print(current_board) + print() + + coord = read_coordinate(f"{self.name}, where would you like to aim your cannons? ", opponent.board) + + if coord not in self.guesses: + return coord + + print("You've already tried that coordinate!") + print("Press ENTER to try again.\n") + input() diff --git a/battleship/player.py b/battleship/player.py new file mode 100644 index 0000000..feae8b1 --- /dev/null +++ b/battleship/player.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import List, Set, Optional + +from .board import Board +from .ship import Ship, ShipType +from .bs_types import Coordinate +from .ui import format_coordinate + + +class Player: + def __init__(self, name: str, board: Board): + self.name = name + self.board = board + self.guesses: Set[Coordinate] = set() + self.ships: List[Ship] = [] + + def place_ships(self, ship_types: List[ShipType]): + for ship_type in ship_types: + self.ships.append(self.place_ship(ship_type)) + + def place_ship(self, ship_type: ShipType) -> Ship: + """ + Return a new ship of the given type which does not collide with any existing ships. + """ + + def validate_placement(self, ship: Ship) -> bool: + if not all(self.board.valid(coord) for coord in ship.coordinates): + print("Ship does not fit on the board.\n") + return False + + for other in self.ships: + if ship.coordinates.intersection(other.coordinates): + print(f"Ship collides with {other.name} at {format_coordinate(other.origin)}.\n") + return False + + return True + + def guess(self, opponent: Player) -> Coordinate: + """ + Subclasses should override this with a method that returns a coordinate the player will try to attack. + :param opponent: The opponent being attacked. + """ + + def ship_cells(self) -> Set[Coordinate]: + cells = set() + for ship in self.ships: + cells = cells.union(ship.coordinates) + return cells + + def ship_at(self, coordinate: Coordinate) -> Optional[Ship]: + for ship in self.ships: + if ship.hit(coordinate): + return ship + return None + + def defeated(self, guesses: Set[Coordinate]): + return all(ship.sunk(guesses) for ship in self.ships) diff --git a/battleship/ship.py b/battleship/ship.py new file mode 100644 index 0000000..cb1a11b --- /dev/null +++ b/battleship/ship.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from enum import Enum, auto +from typing import Set + +from .bs_types import Coordinate + + +class ShipOrientation(Enum): + RIGHT = auto() + DOWN = auto() + + +class ShipType(Enum): + PATROL_BOAT = (2, "P") + DESTROYER = (3, "D") + SUBMARINE = (3, "S") + BATTLESHIP = (4, "B") + CARRIER = (5, "C") + + @property + def size(self) -> int: + return self.value[0] + + @property + def symbol(self) -> str: + return self.value[1] + + +class Ship: + origin: Coordinate + ship_type: ShipType + orientation: ShipOrientation + coordinates: Set[Coordinate] + + def __init__(self, origin: Coordinate, ship_type: ShipType, orientation: ShipOrientation): + self.origin = origin + self.ship_type = ship_type + self.orientation = orientation + + x, y = origin + if self.orientation == ShipOrientation.RIGHT: + self.coordinates = {(x_i, y) for x_i in range(x, x + self.ship_type.size)} + else: + self.coordinates = {(x, y_i) for y_i in range(y, y + self.ship_type.size)} + + @property + def name(self) -> str: + return self.ship_type.name.capitalize() + + def sunk(self, guesses: Set[Coordinate]) -> bool: + return self.coordinates.issubset(guesses) + + def hit(self, guess: Coordinate) -> bool: + return guess in self.coordinates diff --git a/battleship/ui.py b/battleship/ui.py new file mode 100644 index 0000000..05650bb --- /dev/null +++ b/battleship/ui.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import os +import re +from string import ascii_uppercase + +from .board import Board +from .ship import Ship, ShipOrientation, ShipType +from .bs_types import Coordinate, CellFn + +GUESS_REGEX = re.compile(r"(?P[A-Z])(?P\d+)") + + +def clear_screen(): + if os.name == "nt": + os.system("cls") + else: + os.system("clear") + + +def read_coordinate(prompt: str, board: Board) -> Coordinate: + while True: + guess = input(prompt) + match: re.Match = GUESS_REGEX.match(guess.strip().upper()) + + if not match: + print("Coordinates should be of the form [LETTER][NUMBER], for example: B5.\n") + continue + + x, y = match.groups() + x = ascii_uppercase.find(x) + y = int(y) - 1 + coordinate = (x, y) + + if not board.valid(coordinate): + print(f"Coordinate {guess} is outside the board.\n") + continue + + return coordinate + + +def format_coordinate(coordinate: Coordinate) -> str: + x, y = coordinate + return f"{ascii_uppercase[x]}{y+1}" + + +def read_orientation(prompt: str) -> ShipOrientation: + while True: + orientation = input(prompt).strip().upper()[:1] + + if orientation == "D": + return ShipOrientation.DOWN + elif orientation == "R": + return ShipOrientation.RIGHT + else: + print("Please answer D or R.\n") + + +def player_cell_fn(player) -> CellFn: + """ + Creates a cell renderer for the player's view of their own board. + """ + def _cell(coordinate: Coordinate) -> str: + ship = player.ship_at(coordinate) + if not ship: + return " " + return ship.ship_type.symbol + + return _cell + + +def opponent_cell_fn(player, opponent) -> CellFn: + """ + Creates a cell renderer for a player's view of their opponent's board. + """ + def _cell(coordinate: Coordinate) -> str: + if coordinate not in player.guesses: + return " " + else: + ship = opponent.ship_at(coordinate) + + if not ship: + return "x" + + if ship.sunk(player.guesses): + return ship.ship_type.symbol + else: + return "*" + + return _cell + + +def format_board(cell_fn: CellFn, board: Board, row_separator="-", column_separator="|", intersection="+") -> str: + """ + Formats the current board state as a string. + + :param cell_fn: A rendering function that takes a cell coordinate and returns a string. + :param board: The board we're rendering. + :param row_separator: A character to render between rows. + :param column_separator: A character to render between columns. + :param intersection: A character to render at intersections. + :return: A string representing the current board state. + """ + y_label_width = len(str(board.width+1)) + cell_width = 3 + + rendered_board = "" + divider = " " * y_label_width + column_separator + (row_separator*cell_width + intersection) * board.width + "\n" + + # Render the header with tickmarks for the X axis + # We use letters to represent the X axis so that each header will always be a single cell + rendered_board += " " * y_label_width + column_separator + for tick in ascii_uppercase[:board.width]: + rendered_board += tick.center(cell_width) + column_separator + rendered_board += "\n" + + for y in board.rows: + rendered_board += divider + row_label = str(y+1).rjust(y_label_width) + + rendered_board += f"{row_label}{column_separator}" + + for x in board.columns: + coordinate = (x, y) + cell = cell_fn(coordinate) + rendered_board += cell.center(cell_width) + column_separator + + rendered_board += "\n" + + rendered_board += divider + + return rendered_board + + +def print_test_board(): + from .player import Player + board = Board(7, 7) + p1 = Player("P1", board) + p1.guesses.add((0, 0)) + p1.guesses.add((2, 0)) + p2 = Player("P2", board) + p2.ships.append(Ship((0, 0), ShipType.DESTROYER, ShipOrientation.RIGHT)) + + status_fn = opponent_cell_fn(p1, p2) + player_cell = player_cell_fn(p2) + + print(format_board(status_fn, board)) + print(format_board(player_cell, board)) + + +if __name__ == "__main__": + print_test_board()