This assignment has been closed on December 12, 2023.
You must be authenticated to submit your files

Tutorial 9: Battleship

N. Aubrun d’après S. Mengel

Setup: before you start

Create a new empty project in Spyder, and call it “Tutorial_9”.

Now create a new program file, battleship.py. All the functions you write have to be added to this file.

Files to download

You will need to download these files (by right-clicking on each link and selecting “Save link as…”):

Save each file in your Tutorial_9 project directory.

Context and Objectives

As you might already have noticed, some of us use CSE101 tutorials to relive parts of their childhood. In this week, we will do so by playing Battleship, a game that was wildly popular in our elementary schools. For those who do not know the rules, we will not explain them here but refer you to Wikipedia.

In this tutorial we will program the grids, the ships, and the basic game logic for shooting, missing, hitting and sinking ships. You will also be given a visualization to play. Finally, we will give you the opportunity to program different versions of an artificial intelligence for Battleship.

Contrary to the description on Wikipedia, we will not use letters to encode the rows of the grid. Instead, we encode rows and columns using integers, which simplifies dealing with them in Python.

Skills we will practice today: handling classes, objects, and their interactions; working with lists and dictionaries; reading input from files.

Exercises

Exercise 1: Ships

The most important thing in Battleship are of course ships. So let us create a class for those first.

To this end, create a new class Ship. Its __init__() method must initialize

  • name, the name of the ship (a string)
  • positions, a set containing the positions that the ship occupies on the grid, and
  • hits, a set of positions of the ship that have already been hit (starting empty of course; remember that you can create an empty set in Python using set()).

Your class Ship should begin as follows:

class Ship:
    """A ship that can be placed on the grid."""

    def __repr__(self):
        return f"Ship('{self.name}', {self.positions})"

    def __str__(self):
        return f'{repr(self)} with hits {self.hits}'

    def __init__(self, name, positions):

Test the construction of your ships as follows:

In [1]: s = Ship('Destroyer', {(1, 2), (2, 2)})

In [2]: s.name
Out[2]: 'Destroyer'

In [3]: s.positions
Out[3]: {(1, 2), (2, 2)}

In [4]: s.hits
Out[4]: set()

In [5]: str(s)
Out[5]: "Ship('Destroyer', {(1, 2), (2, 2)}) with hits set()"

Note that here, and in all other places where we deal with sets, the elements in the sets may be permuted in the output.

Now add a method __eq__() to compare two ships. Two ships are considered to be equal if their three data attributes name, positions, and hits are equal.

Test your __eq__ method:

In [6]: s1 = Ship('Destroyer', {(1, 2), (2, 2)})

In [7]: s2 = Ship('Destroyer', {(1, 2), (2, 2)})

In [8]: s1 == s2
Out[8]: True

In [9]: s2.name = 'Different'

In [10]: s1 == s2
Out[10]: False

Finally, write a method is_afloat that checks if the ship is still afloat, i.e, if there are any positions of the ship that have not been hit.

Test your is_afloat() method in the console:

In [11]: s1 = Ship('Destroyer', {(1, 2), (2, 2)})

In [12]: s1.is_afloat()
Out[12]: True

In [13]: s1.hits.add((1, 2))

In [14]: s1.hits
Out[14]: {(1, 2)}

In [15]: s1.is_afloat()
Out[15]: True

In [16]: s1.hits.add((2, 2))

In [17]: s1.is_afloat()
Out[17]: False

Afterwards, upload your file battleship.py:

Upload form is only available when connected

Exercise 2: Grids

Now that we have a way to build Ship objects, we will write a class Grid to place the ships on. Grid objects will have the following attributes:

  • two integer attributes x_size and y_size encoding the size in the x- and y-directions, respectively;
  • a list ships containing the ships on the board;
  • a set misses containing the shots that have been fired on the grid, but that have not hit any of the ships.

Note: whenever we shoot at the grid later, the shot will be recorded. If it hits any Ship in ships, then we record the hit in that ship object. Otherwise, we record the shot in misses in the Grid object.

Create the class Grid and add a method __init__ that initializes x_size and y_size to given values and the other attributes (ships and misses) as empty. Your class should begin as follows:

class Grid:
    """Encodes the grid on which the Ships are placed.
    Also remembers the shots fired that missed all of the Ships.
    """
    
    def __init__(self, x_size, y_size):

Test your class, for example as follows:

In [18]: g = Grid(10, 11)

In [19]: g.x_size
Out[19]: 10

In [20]: g.y_size
Out[20]: 11

In [21]: g.ships
Out[21]: []

In [22]: g.misses
Out[22]: set()

The game won’t be very interesting if we can’t add some ships to a grid, so now define a method add_ship to your Grid class:

    def add_ship(self, ship):
        """
        Add a Ship to the grid at the end of the ships list if it does not
        collide with other ships already there
        """

This method should add the ship at the end of the ships list only if the positions of this new ship do not intersect positions of ships of the grid. Continuing the example above, we should see

In [23]: g = Grid(10, 11)

In [24]: g.ships
Out[24]: []

In [25]: s1 = Ship('Destroyer', {(1, 2), (2, 2)})

In [26]: g.add_ship(s1)

In [26]: g.ships
Out[26]: [Ship('Destroyer', {(1, 2), (2, 2)})]

In [27]: s2 = Ship('Destroyer', {(3, 2), (2, 2)})

In [28]: g.add_ship(s2)

In [28]: g.ships
Out[28]: [Ship('Destroyer', {(1, 2), (2, 2)})]

In [29]: s3 = Ship('Destroyer', {(4, 2), (3, 2)})

In [30]: g.add_ship(s3)

In [30]: g.ships
Out[30]: [Ship('Destroyer', {(1, 2), (2, 2)}), Ship('Destroyer', {(4, 2), (3, 2)})]

Afterwards, upload your file battleship.py:

Upload form is only available when connected

Exercise 3: Loading grids from files

To make adding ships to a grid an easier process, we will now write a function to load them from a text file. To this end, first write a function create_ship_from_line that returns a ship encoded as a line as follows:

  • Each line consists of several entries separated by spaces.
  • The first entry on a line is the name of the ship.
  • The other entries on the line are of the form x:y, where x and y are integers that encode the position of part of the ship on the grid.

For example: the line Destroyer 1:2 2:2 encodes a ship to be created by Ship('Destroyer', {(1, 2), (2, 2)}.

Write the function create_ship_from_line (in battleship.py, outside the Ship and Grid classes), and test it, for example as follows:

In [31]: s = create_ship_from_line('Destroyer 1:2 2:2')

In [32]: s == Ship('Destroyer', {(1, 2), (2, 2)})
Out[32]: True

Now add a function load_grid_from_file(filename) to your file battleship.py (outside the Grid and Ship classes) which reads a file containing in the first line the dimensions of a grid separated by : and then several lines describing ships as above. Your function should return a Grid of the prescribed dimensions with the given ships. You can use your create_ship_from_line function to handle each of the lines in the file.

Test your function in the console:

In [33]: g = load_grid_from_file('grid1.txt')

In [34]: g.x_size
Out[34]: 5

In [35]: g.y_size
Out[35]: 5

In [36]: g.ships
Out[36]: [Ship('Destroyer', {(1, 2), (2, 2)})]

Afterwards, upload your file battleship.py:

Upload form is only available when connected

Exercise 4: Shooting at ships

So far, there has not been a lot of action in our battleship game. We will now start working on the actual shooting.

Add a method take_shot to your class Ship. It should begin as follows:

    def take_shot(self, shot):
        """Check if the shot hits the ship. If so, remember the hit.
        Returns one of 'MISS', 'HIT', or 'DESTROYED'.
        """

The method take_shot() checks if the coordinates given in shot are those of a part of the ship that has not been hit before. If so, we add those coordinates to the hits of the ship; then, if this was the last intact part of the ship then we return the string 'DESTROYED', and otherwise we return 'HIT'. If the shot did not hit the ship (or if it hits a place that was already hit), then take_shot() returns 'MISS'. In particular if the same coordinates are shot twice then the second shot is always 'MISS'.

Test your function in the console:

In [37]: s = Ship('Destroyer', {(2, 2), (2, 3)})

In [38]: s.take_shot((1, 1))
Out[38]: 'MISS'

In [39]: s.take_shot((2, 2))
Out[39]: 'HIT'

In [40]: s.hits
Out[40]: {(2, 2)}

In [41]: s.take_shot((2, 2))
Out[41]: 'MISS'

In [42]: s.take_shot((2, 3))
Out[42]: 'DESTROYED'

Afterwards, upload your file battleship.py:

Upload form is only available when connected

Exercise 5: Shooting at grids

Now that we can shoot at individual ships, let us shoot at the grid next. To this end, add a method shoot to the class Grid that takes an argument position, checks if the shot hits any of the ships by calling take_shot, and returns a tuple as follows:

  • if the shot hits no ship, then we add the miss to misses and return ('MISS', None)
  • if the shot hits a ship that is not destroyed by the shot, then we return ('HIT', None), and
  • if the shot destroys a ship s, then we return ('DESTROYED', s).

Test your methods as follows:

In [43]: g = load_grid_from_file('grid1.txt')

In [44]: g.shoot((1, 1))
Out[44]: ('MISS', None)

In [45]: g.misses
Out[45]: {(1, 1)}

In [46]: g.shoot((1, 2))
Out[46]: ('HIT', None)

In [47]: g.shoot((2, 2))
Out[47]: ('DESTROYED', Ship('Destroyer', {(1, 2), (2, 2)}))

Afterwards, upload your file battleship.py:

Upload form is only available when connected

Exercise 6: Blind grids

We are now nearly ready to play Battleship. The only thing we still have to do is to create a way to show our opponent the grid without divulging the position of our ships. To this end, we now introduce a second grid class which we call BlindGrid. On a BlindGrid, one can only see which positions were shot at, where there were hits, and which ships have been destroyed; in particular, one cannot see ships that have not yet been destroyed. Note that these are exactly the facts one knows about the opponent’s grid when playing Battleship.

Write the class BlindGrid. It should begin as follows:

class BlindGrid:
    """Encodes the opponent's view of the grid."""

    def __init__(self, grid):

The method __init__() should store the following values in attributes:

  • x_size and y_size contain the same values as in grid,
  • misses, a set containing the positions that have been shot at but that were misses,
  • hits, a set containing the positions in which there were hits, and
  • sunken_ships, a list containing the ships that are not afloat anymore.

Note that crucially, this is all of the information that we will store in the BlindGrid. In particular, we do not store the original Grid, nor any of its Ships that are still afloat. (Doing so might allow our opponent to cheat, by knowing where to shoot after all.)

Test your class BlindGrid as follows:

In [48]: g = load_grid_from_file('grid1.txt')

In [49]: g.shoot((1, 1))
Out[49]: ('MISS', None)

In [50]: g.shoot((1, 2))
Out[50]: ('HIT', None)

In [51]: g.shoot((2, 2))
Out[51]: ('DESTROYED', Ship('Destroyer', {(1, 2), (2, 2)}))

In [52]: b = BlindGrid(g)

In [53]: b.misses
Out[53]: {(1, 1)}

In [54]: b.hits
Out[54]: {(1, 2), (2, 2)}

In [55]: b.sunken_ships
Out[55]: [Ship('Destroyer', {(1, 2), (2, 2)})]

Afterwards, upload your file battleship.py:

Upload form is only available when connected

Exercise 7: Random ship positions

Playing our implementation of Battleship is not very challenging: since you create the Grid for the opponent yourself, you always know where the Ships are, so you can sink them without any misses.

To make the game more interesting, write a function that places Ships randomly.

To this end, add import random to the beginning of your battleship.py file, and then use the randint method of the random module. Immediately below this, add this line to your battleship.py file.

# List of tuples: (name, length) where length is the number of positions of your ship
ship_types = [('Battleship',4),('Carrier',5),('Cruiser',3),('Destroyer',2),('Submarine',3)]

You can now add a method random_ship to the class Grid, that returns a new random ship. The random ships are always located either vertically or horizontally, and are connected. It should begin as follows:

    def random_ship(self):

Test your method

In [56]: g = Grid(10,10)

In [57]: g.random_ship()
Out[58]: Ship('Battleship', {(9, 5), (9, 6), (9, 3), (9, 4)})

In [59]: g.random_ship()
Out[60]: Ship('Destroyer', {(2, 3), (2, 2)})

Add a method create_random to the class Grid, that adds random ships to a grid until the desired number of ships is reached:

    def create_random(self,n):

Test your method

In [61]: g = Grid(10,10)

In [62]: g.create_random(5)

In [63]: print(g.ships)
[Ship('Destroyer', {(8, 2), (8, 3)}), Ship('Destroyer', {(3, 3), (3, 4)}), Ship('Battleship', {(0, 1), (0, 2), (0, 3), (0, 0)}), Ship('Submarine', {(8, 7), (8, 8), (8, 6)}), Ship('Submarine', {(6, 1), (6, 2), (6, 0)})]

Upload your file battleship.py:

Upload form is only available when connected

Playing the game

We now have everything in place to play Battleship. There is a visualization in graphics.py that you can use as follows:

In [64]: g1 = Grid(10,10)

In [65]: g2 = Grid(10,10)

In [66]: g1.create_random(5)

In [67]: g2.create_random(5)

In [68]: Battleship(g1, g2).mainloop()

The program show two grids the grids g1 and g2: the grid g1 is the upper grid and shows your ships. The lower grid shows what you know about your opponents grid (which in the beginning is nothing).

You can shoot by clicking a cell in the opponents grid. If you hit, a red circle will be shown, otherwise a blue one. Once you have destroyed a ship, the red circles will turn into squares. After each of your shots, the opponent will answer with a shot. Blue circles are misses, red circles are hits.

Exercise 8 (optional): Artificial intelligence

As you might have noticed while playing, the artificial intelligence of the opponent is not very smart: it just shoots randomly at every turn and does not take any information into account besides the dimensions of the grid. The algorithm for this can be found in the function random_shoot() in graphics.py.

Try to improve the behavior of the AI. To do so, write other functions that take as an input a BlindGrid and return a position to shoot at. You can test your functions by assigning them to the strategy attribute of the Battleship object.

Upload your file battleship.py:

Upload form is only available when connected