Hackbright Code Challenges

Battleship!

Battleship!

Challenge

Harder

Concepts

Object Orientation, Reading Code

Download

battleship.zip

Solution

Battleship!: Solution


This exercise is a challenge in reading code, understanding data structures created by others, and using a good object-oriented design.

We’ve written most of an implementation of 2-player Battleship in battleship.py—but it’s not complete.

There are two methods that are entirely unimplemented.

Your Task

Read and understand the code. This code has good examples of style and object-oriented design.

"""Hackbright Battleship!

This is an object-oriented version of Battleship. It's a good demonstration
of class-based game design.

The Game class represents a game and holds the state of the game
(player1, player2, and the current player.

The Player class represents an individual player. It holds information about
their name, board, and ships. It also has a link to the other player ("opponent"),
which makes it easy to find the other player.

The ships are all subclasses of the Ship class, which handles logic around
knowing when a ship is sunk, how to place a ship on the board, etc.

Joel Burton <joel@joelburton.com>
"""

import random
import sys


# If set to true, print board without so much spacing
# On bigger screens, you can set this to False
TIGHT = True

# Colors
#
# You can safely ignore this. These functions print out a message
# in a console color if the console appears to be a live TTY (that is, not
# being piped out/in a test/etc.)

NO_COLOR = False  # If the colors annoy you, set this to True

WATER = lambda msg: ('\033[34m%s\033[0m' % msg
                     if sys.stdout.isatty() and not NO_COLOR else msg)
SUNK = lambda msg: ('\033[91m%s\033[0m' % msg
                    if sys.stdout.isatty() and not NO_COLOR else msg)
HIT = lambda msg: ('\033[31m%s\033[0m' % msg
                   if sys.stdout.isatty() and not NO_COLOR else msg)


#####################################################################################


class Ship(object):
    """Type of ship."""

    _length = 0  # length of ship
    name = ""  # name of ship
    coords = []  # list of (col, row) coordinates this ship occupies

    def __init__(self):
        assert self._length > 0 and self.name, "Must subclass with length and name!"

        self.hits = 0
        self.coords = []

    def is_sunk(self):
        """Is this ship sunk?"""

        return self.hits == self._length

    def place(self, col, row, direction):
        """Place ship.

        Given a row and column and direction, determine coordinates
        ship will occupy and update it's coordinates property.

        Raises an exception for an illegal direction.

        This is meant to be an abstract class--you should subclass it
        for individual ship types.

            >>> class TestShip(Ship):
            ...     _length = 3
            ...     name = "Test Ship"

        Let's make a ship and place it:

            >>> ship = TestShip()

            >>> ship.place(1, 2, "H")
            >>> ship.coords
            [(1, 2), (2, 2), (3, 2)]

            >>> ship.place(1, 2, "V")
            >>> ship.coords
            [(1, 2), (1, 3), (1, 4)]

        Illegal directions raise an error:

            >>> ship.place(1, 2, "Z")
            Traceback (most recent call last):
            ...
            ValueError: Illegal direction
        """


class AircraftCarrier(Ship):
    _length = 5
    name = "Aircraft Carrier"


class Destroyer(Ship):
    _length = 2
    name = "Destroyer"


class Submarine(Ship):
    _length = 3
    name = "Submarine"


class Battleship(Ship):
    _length = 4
    name = "Battleship"


# List of ship types, in the order player shoud place them
SHIP_TYPES = [AircraftCarrier, Battleship, Submarine, Destroyer]


class Player(object):
    """Object for a player.

    Players have a board with their ships/opponent's moves, their ships,
    their name, and a link to their opponent.
    """

    name = ""  # will be name for player
    opponent = None  # will be the opponent object for this player

    # These attributes are private -- other classes shouldn't
    # need to inspect or change them directly. It's smart to note
    # which attributes/methods are private, so it's more clear for others
    # how to use your class. In this case, this says clearly that
    # the other classes shouldn't mess with this stuff.

    _board = None  # will be 10x10 matrix (list-of-lists) for board
    _ships = None  # will be list of living ships for user

    def __init__(self, name):
        """Create player:

        - set up their board
        - set up their (empty initially) list of ships

        Let's create a player:

            >>> player = Player('Jane')

        They should have an empty board:

            >>> player._board   # doctest: +NORMALIZE_WHITESPACE
            [[False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False],
             [False, False, False, False, False, False, False, False, False, False]]

        THey should have no ships:

            >>> player._ships
            []

        They should have no opponent (yet!):

            >>> player.opponent is None
            True

        """

        self.name = name

        self._board = []
        for row in range(10):
            self._board.append([False] * 10)

        self._ships = []

    def place_ship(self, ship, col, row, direction):
        """Place a ship on the board at col, row, going in direction.

        :col: 0-based column
        :row: 0-based row
        :direction: "H" or "V"

        LEt's create a player and place a ship:

            >>> player = Player('Jane')
            >>> destroyer = Destroyer()
            >>> player.place_ship(destroyer, 1, 2, "V")

        Did it place the destroyer on all the cells for it?

            >>> player._board[1][2] is destroyer
            True
            >>> player._board[1][3] is destroyer
            True

        Did it get the coordinates right?

            >>> destroyer.coords
            [(1, 2), (1, 3)]

        Is the destroyer the only ship in their player's arsenal?

            >>> player._ships == [destroyer]
            True
        """

        # Have ship determine it's own coordinates
        ship.place(col, row, direction)

        for col, row in ship.coords:
            # For each coord ship occupies, put it on board

            if self._board[col][row]:
                raise ValueError("Already a ship there")

            else:
                self._board[col][row] = ship

        # Add it to the player's arsenal
        self._ships.append(ship)

    def place_ships(self):
        """Prompt a player to place their ships."""

        for ship in SHIP_TYPES:
            # Prompt the player for each type of ship

            while True:
                # Keep looping until a legal placement is made

                try:
                    place = raw_input(
                        "\nPlace your %s (col row H/V, eg '00H') >" % ship.name)
                    col = int(place[0])
                    row = int(place[1])
                    direction = place[2].upper()
                    self.place_ship(ship(), col, row, direction)
                    print()
                    self.show_board(show_hidden=True)

                    # A legal placement was made, so we can break
                    break

                except (IndexError, ValueError) as e:
                    print("\n(%s: try again)" % e)

        print("\nDone!\n")

    def show_board(self, show_hidden=False, tight=TIGHT):
        """Print out board:

        _ = missed here
        * = hit here
        # = destroyed ship here
        . = nothing here
        ELSE = first letter of ship type hidden here

        If `show_hidden` is False, hidden ships are shown as nothing-here.
        This is the view shown to opponents.

        If `tight` is True, skip some of the space in the board
        (good for tests and smaller screens)

        So we can see this, let's create a player and make a few
        shots:

            >>> player = Player('Jane')
            >>> destroyer = Destroyer()
            >>> player._board[1][2] = destroyer
            >>> player._board[1][3] = destroyer
            >>> player._board[0][0] = "_"  # miss
            >>> player._board[1][2] = "*"  # hit

        Here's what the opponent should see:

            >>> player.show_board(tight=True)  # doctest: +NORMALIZE_WHITESPACE
              0 1 2 3 4 5 6 7 8 9
            0 _ . . . . . . . . .
            1 . . . . . . . . . .
            2 . * . . . . . . . .
            3 . . . . . . . . . .
            4 . . . . . . . . . .
            5 . . . . . . . . . .
            6 . . . . . . . . . .
            7 . . . . . . . . . .
            8 . . . . . . . . . .
            9 . . . . . . . . . .

        The player can see their own ships:

            >>> player.show_board(show_hidden=True, tight=True
            ...     ) # doctest: +NORMALIZE_WHITESPACE
              0 1 2 3 4 5 6 7 8 9
            0 _ . . . . . . . . .
            1 . . . . . . . . . .
            2 . * . . . . . . . .
            3 . D . . . . . . . .
            4 . . . . . . . . . .
            5 . . . . . . . . . .
            6 . . . . . . . . . .
            7 . . . . . . . . . .
            8 . . . . . . . . . .
            9 . . . . . . . . . .

        """

        # Print col numbers across top
        if tight:
            print(" ", end=' ')
            for coli in range(10):
                print("%s" % coli, end=' ')
            print()
        else:
            print("  ", end=' ')
            for coli in range(10):
                print("%s  " % coli, end=' ')
            print("\n")

        for rowi in range(10):

            # Print row numbers across left
            print(rowi, end=' ')
            if not tight:
                print("", end=' ')

            # Print cells
            for coli in range(10):
                ship = self._board[coli][rowi]

                if ship == "*":
                    print(HIT("*"), end=' ')

                elif ship == "#":
                    print(SUNK("#"), end=' ')

                elif ship == "_":
                    print(WATER("_"), end=' ')

                elif not ship or not show_hidden:
                    # Square is empty OR is enemy ship & you can't see it yet
                    print(WATER("."), end=' ')

                else:
                    # Cell has ship and you're allowed to see it
                    print("%s" % ship.name[0], end=' ')

                if not tight:
                    print(" ", end=' ')  # add a space

            print("" if tight else "\n")

    def handle_shot(self, col, row):
        """Handle being shot at at col, row.

        If the shot is in an already-tried spot:
        - raise ValueError("You've already played there")

        If the shot misses:
         - update the board to show '_' for that cell

        Else, If the shot hits a ship:
        - update the board to '*' for that cell
        - update the ship's hits
        - check to see if it's destroyed
        - report a hit

        If the shot destroys a ship:
        - update ALL the ship's coordinates to '#'
        - report the sunk ship

        Let's create a player and add a ship:

            >>> player = Player('Jane')
            >>> destroyer = Destroyer()
            >>> player._board[1][2] = destroyer
            >>> player._board[1][3] = destroyer

        Handle misses:

            >>> player.handle_shot(0, 0)
            Miss

            >>> player.handle_shot(0, 0)
            Traceback (most recent call last):
            ...
            ValueError: You've already played there

        Handle hits:

            >>> player.handle_shot(1, 2)
            Hit!

            >>> player.handle_shot(1, 2)
            Traceback (most recent call last):
            ...
            ValueError: You've already played there


        Handle sinking things:

            >>> player.handle_shot(1, 3)
            Hit!
            You sunk my Destroyer
        """

        ship = self._board[col][row]

        if not ship:
            self._board[col][row] = "_"
            print("Miss")

        elif isinstance(ship, Ship):
            # Hit a real ship (anything subclassing Ship)
            ship.hits += 1
            print(HIT("Hit!"))
            self._board[col][row] = "*"

            if ship.is_sunk():
                for col, row in ship.coords:
                    self._board[col][row] = "#"
                print(SUNK("You sunk my " + ship.name))

        else:
            raise ValueError("You've already played there")

    def is_dead(self):
        """Is player dead (out of ships)?

            >>> player = Player('Jane')

        Since they have no placed ships, they're technically dead:

            >>> player.is_dead()
            True

        Let's place some ships:

            >>> destroyer = Destroyer()
            >>> player.place_ship(destroyer, 1, 2, "V")
            >>> player.is_dead()
            False

        And we'll hit a ship:

            >>> player.handle_shot(1, 2)
            Hit!

            >>> player.is_dead()
            False

        And destroy it (and it's their only ship, too1)

            >>> player.handle_shot(1, 3)
            Hit!
            You sunk my Destroyer

            >>> player.is_dead()
            True
        """


class Game(object):
    """Battleship game."""

    player1 = None
    player2 = None
    player = None  # current player

    def __init__(self, player1_name, player2_name):
        """Create game.

        Create players, assign each other as opponents, and pick a start player.
        """

        self.player1 = Player(player1_name)
        self.player2 = Player(player2_name)

        self.player1.opponent = self.player2
        self.player2.opponent = self.player1

    def pick_start_player(self):
        """Pick a starting player randomly."""

        self.player = random.choice([self.player1, self.player2])

    def setup_ships(self):
        """Prompt both players to place their ships."""

        for i in range(2):
            print("=" * 60)
            print("%s: Place your ships (%s, turn away!)" % (
                self.player.name, self.player.opponent.name))
            self.player.place_ships()
            raw_input("\nPress ENTER to switch players >")
            print("\n" * 80)  # scroll private stuff off screen
            self.player = self.player.opponent

    def play(self):
        """Main game loop.

        Keep playing until a player has lost all their ships.

        - show the player their private board
        - show the player their opponent's non-secret board
        - prompt for a move
        - the opponent handles the player's move
        """

        while True:
            # Loop until someone wins

            print()
            print("=" * 40)
            print("\n%s, let's attack %s\n" % (
                self.player.name, self.player.opponent.name))
            self.player.opponent.show_board()

            while True:
                # Loop until a valid move is made

                try:
                    move = raw_input("\nMove (col row, e.g. '00') >")
                    col = int(move[0])
                    row = int(move[1])
                    print()
                    self.player.opponent.handle_shot(col, row)

                    # Valid move was made, so we can break out
                    break

                except (ValueError, IndexError) as e:
                    print("\n(%s: try again)" % e)

            print()
            self.player.opponent.show_board()

            if self.player.opponent.is_dead():
                break

            self.player = self.player.opponent

        print("\n *** YOU WIN!")


def play(player1_name, player2_name):
    """PLay battleship!

    This creates a game, picks a start player, prompts the players for
    setup, and shows the instructions. It then enters the main
    game loop.
    """

    game = Game(player1_name, player2_name)
    game.pick_start_player()

    print(HIT("\n\n*** HACKBRIGHT BATTLESHIP: %s vs %s ***" % (
        game.player.name, game.player.opponent.name)))

    print("""
Battleship is played on a 10x10 grid. Each player has a separate,
secret board. Your goal is to sink your opponent's ships before
she sinks yours.

Each player will place their four ships secretly. To place a ship,
you'll be prompted for a location. Enter it like '12H', where that is
the column=1, the row=2, and going horizontally ("V" for vertically).

""")
    game.setup_ships()

    print("""
How, play begins. On your turn, you'll be shown your opponent's board.
Enter coordinates for an attack on your opponent. Enter it like "12",
where column=1 and row=2.

You will receive a report on whether you missed, hit a ship, or
sunk a ship.
""")
    game.play()

    print("""
That was fun! Let's play again soon!
""")


if __name__ == '__main__':
    import doctest

    # When called, this always runs its tests
    mod_fail = doctest.testmod().failed
    story_fail = doctest.testfile('battleship.txt',
                                  optionflags=doctest.NORMALIZE_WHITESPACE).failed
    if mod_fail + story_fail == 0:
        print("\n*** ALL TEST PASSED. AMAZING!\n")

        # If you run this with two arguments, it also starts an interactive game
        #
        # for example:
        #
        #   python battleship.py Balloonicorn Joel

        if len(sys.argv) == 3:
            play(sys.argv[1], sys.argv[2])

Read and understand the tests, including the docfile test in battleship.txt.

Battleship Test
===============

Let's make a player::

    >>> from battleship import Player
    >>> jane = Player('Jane')

Let's add some ships for her::

    >>> from battleship import Destroyer, AircraftCarrier, Submarine, Battleship
    >>> jane.place_ship(Destroyer(), 0, 0, "H")
    >>> jane.place_ship(AircraftCarrier(), 1, 1, "H")
    >>> jane.place_ship(Submarine(), 6, 6, "V")
    >>> jane.place_ship(Battleship(), 3, 3, "V")

Let's look at the board with the ships on it::

    >>> jane.show_board(show_hidden=True, tight=True)
      0 1 2 3 4 5 6 7 8 9
    0 D D . . . . . . . .
    1 . A A A A A . . . .
    2 . . . . . . . . . .
    3 . . . B . . . . . .
    4 . . . B . . . . . .
    5 . . . B . . . . . .
    6 . . . B . . S . . .
    7 . . . . . . S . . .
    8 . . . . . . S . . .
    9 . . . . . . . . . .


Her opponent shoots at her a few times and misses::

    >>> jane.handle_shot(8, 8)
    Miss

    >>> jane.handle_shot(7, 7)
    Miss

Here's how her opponent would see the board::

    >>> jane.show_board(tight=True)
      0 1 2 3 4 5 6 7 8 9
    0 . . . . . . . . . .
    1 . . . . . . . . . .
    2 . . . . . . . . . .
    3 . . . . . . . . . .
    4 . . . . . . . . . .
    5 . . . . . . . . . .
    6 . . . . . . . . . .
    7 . . . . . . . _ . .
    8 . . . . . . . . _ .
    9 . . . . . . . . . .

Oh no!::

    >>> jane.handle_shot(0, 0)
    Hit!

    >>> jane.show_board(tight=True)
      0 1 2 3 4 5 6 7 8 9
    0 * . . . . . . . . .
    1 . . . . . . . . . .
    2 . . . . . . . . . .
    3 . . . . . . . . . .
    4 . . . . . . . . . .
    5 . . . . . . . . . .
    6 . . . . . . . . . .
    7 . . . . . . . _ . .
    8 . . . . . . . . _ .
    9 . . . . . . . . . .

    >>> jane.handle_shot(1, 0)
    Hit!
    You sunk my Destroyer

    >>> jane.show_board(tight=True)
      0 1 2 3 4 5 6 7 8 9
    0 # # . . . . . . . .
    1 . . . . . . . . . .
    2 . . . . . . . . . .
    3 . . . . . . . . . .
    4 . . . . . . . . . .
    5 . . . . . . . . . .
    6 . . . . . . . . . .
    7 . . . . . . . _ . .
    8 . . . . . . . . _ .
    9 . . . . . . . . . .


And, of course, it should tell her opponent when they repeat actions:

    >>> jane.handle_shot(0, 0)
    Traceback (most recent call last):
    ...
    ValueError: You've already played there

    >>> jane.handle_shot(8, 8)
    Traceback (most recent call last):
    ...
    ValueError: You've already played there

Since Jane has some ships left, she hasn't lost::

    >>> jane.is_dead()
    False

There are two unimplemented methods:

The Ship.place method:

class Ship(object):  # ...
    def place(self, col, row, direction):
        """Place ship.

        Given a row and column and direction, determine coordinates
        ship will occupy and update it's coordinates property.

        Raises an exception for an illegal direction.

        This is meant to be an abstract class--you should subclass it
        for individual ship types.

            >>> class TestShip(Ship):
            ...     _length = 3
            ...     name = "Test Ship"

        Let's make a ship and place it:

            >>> ship = TestShip()

            >>> ship.place(1, 2, "H")
            >>> ship.coords
            [(1, 2), (2, 2), (3, 2)]

            >>> ship.place(1, 2, "V")
            >>> ship.coords
            [(1, 2), (1, 3), (1, 4)]

        Illegal directions raise an error:

            >>> ship.place(1, 2, "Z")
            Traceback (most recent call last):
            ...
            ValueError: Illegal direction
        """

and the Player.is_dead:

class Player(object):  # ...
    def is_dead(self):
        """Is player dead (out of ships)?

            >>> player = Player('Jane')

        Since they have no placed ships, they're technically dead:

            >>> player.is_dead()
            True

        Let's place some ships:

            >>> destroyer = Destroyer()
            >>> player.place_ship(destroyer, 1, 2, "V")
            >>> player.is_dead()
            False

        And we'll hit a ship:

            >>> player.handle_shot(1, 2)
            Hit!

            >>> player.is_dead()
            False

        And destroy it (and it's their only ship, too1)

            >>> player.handle_shot(1, 3)
            Hit!
            You sunk my Destroyer

            >>> player.is_dead()
            True
        """

Using the other code as a guide and and the already-written tests for the missing methods, implemented the two unimplemented methods.

Good luck!