Tutorial 9: Battleship
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…”):
- graphics.py,
- grid1.txt, and
- grid2.txt
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, andhits
, 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 usingset()
).
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
:
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
andy_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
"""
Ships should not overlap! 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
:
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
, wherex
andy
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
:
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
:
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
:
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
andy_size
contain the same values as ingrid
,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, andsunken_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 Ship
s 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
:
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 Ship
s are, so you can sink them
without any misses.
To make the game more interesting, write a function that places
Ship
s 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
= [('Battleship',4),('Carrier',5),('Cruiser',3),('Destroyer',2),('Submarine',3)] ship_types
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
:
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
: