Battleship with GUI in Python

Following up the last post, where a Battleship game was built entirely with Python, the next step is to give the complete experience to the player with a graphic user interface (GUI). Tkinter is the standard Python package to build GUIs, having a lot of support and working on different operating systems (Windows, macOS, Linux, …).

Before starting, these are all the needed imports to make this project:

import numpy as np 
import random
import scipy.ndimage as ndimage
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use("Agg")
import tkinter as tk
import os
from PIL import ImageTk, Image
import time
from tkinter import messagebox
import shutil

The backend of this game is the same as the last post and the main structure will remain as a class “Battleship”, which possesses several methods:

  • __init__ (constructor) initialize the game window and add some start widgets inside
  • on_closing – manage the closing action (click on exit cross)
  • start – manage game initialization and insert the remaining widgets in the window
  • create_boards – create the solution and player’s boards
  • add_boat – add one 3×3 boat to the board (not changed)
  • save_board_pic – save a board as PNG in a defined directory
  • show_board – show the player’s board in the window
  • add_guess – get a new guess, validate it and manage the state of the game
  • check_boats_destroyed – check the number of boats destroyed (not changed)
  • check_win – check if the player has won (not changed)
  • see_solution – momentarily show the solution board in the window

__init__ (constructor)

The constructor creates the game window, maximizes it, configures the closing action to call the on_closing method and then inserts the first widgets. These widgets let the player choose the number of boats (1 to 5) and click on the start button to begin the game (calling the start method).

def __init__(self):

    #create root window, and configure it
    
    self.root = tk.Tk()
    
    self.root.configure(bg="white")
    self.root.state('zoomed')
    
    self.root.title("Battleship")

    self.root.protocol("WM_DELETE_WINDOW", self.on_closing)

    #create frames and initial widgets
    
    frame1 = tk.Frame(self.root, bg="#0e0ba7")
    frame1.pack(anchor="n", fill="x")
    
    label = tk.Label(frame1, text="Battleship", font="none 18 bold", bg="#0e0ba7", fg="white")
    label.pack(anchor="n", pady=20, fill="x")
    
    frame2 = tk.Frame(self.root, bg="white")
    frame2.pack(anchor="n", pady=30)
    
    label = tk.Label(frame2, text="Number of boats: ", font="none 12 bold", bg="white")
    label.pack(side="left", anchor="c", fill="y", padx=5)
    
    options = [ 
        "1", 
        "2", 
        "3", 
        "4", 
        "5"
    ] 

    self.option_boats = tk.StringVar()  
    self.option_boats.set("3") 
    drop = tk.OptionMenu(frame2, self.option_boats , *options ) 
    drop.pack(side="left", anchor="c", padx=5) 
    
    button = tk.Button(frame2, text="Start", width=8, command=self.start)
    button.pack(side="left", anchor="c", padx=5)
    
    self.game_frame = tk.Frame(self.root, bg="white")
    self.game_frame.pack(anchor="n")
    
    self.root.mainloop()

The on_closing method shows a pop up to the player to confirm the exit and then deletes the temporary folder that is created to store the board images:

def on_closing(self):

    #deal with closing event 

    if messagebox.askokcancel("Quit", "Do you want to quit?"):
        shutil.rmtree("temporary")
        self.root.destroy()

start

This method checks the number of boats to create, calls the method create_boards and insert the remaining widgets in the window, including the player’s board (show_board method).

def start(self):

    #create the remaining widgets
    
    for widget in self.game_frame.winfo_children():
        widget.destroy()
    
    self.num_boats = int(self.option_boats.get())
    
    self.create_boards()
    
    self.solution = False
    
    frame3 = tk.Frame(self.game_frame, bg="white")
    frame3.pack(anchor="n")

    label = tk.Label(frame3, text="Your guess: ", font="none 13 bold", bg="white")
    label.pack(side="left", anchor="c", fill="y", pady=30)
    
    self.entry_guess = tk.Entry(frame3, width=5, font="none 12", selectborderwidth=3, bd=3, justify=tk.CENTER)
    self.entry_guess.pack(side="right", anchor="c")
    
    btn_frame = tk.Frame(self.game_frame, bg="white")
    btn_frame.pack(anchor="n")
    
    button = tk.Button(btn_frame, text="Try guess", width=10, command=lambda: self.add_guess(self.entry_guess.get()))
    button.pack(side='left', anchor="n", padx=5)
    
    self.entry_guess.bind("<Return>", lambda x: self.add_guess(self.entry_guess.get()))
    
    self.see_sol_button = tk.Button(btn_frame, text="See solution", width=10, command=self.see_solution)
    self.see_sol_button.pack(side='left', anchor="n", padx=5)
    
    button = tk.Button(btn_frame, text="Restart", width=10, command=self.start)
    button.pack(side='left', anchor="n", padx=5)
    
    self.result = tk.Label(self.game_frame, text="", font="none 11 bold", bg="white")
    self.result.pack(anchor="n", pady=10, fill="x")     
    
    self.boats_destroyed_label = tk.Label(self.game_frame, text="{} / {} boats destroyed".format(len(self.boats_destroyed), self.num_boats), font="none 10", bg="white")
    self.boats_destroyed_label.pack(anchor="n", pady=10, fill="x")
    
    self.board_frame = tk.Frame(self.game_frame, bg="blue")
    self.board_frame.pack(anchor="n")
    
    self.show_board()

So, the player can insert a guess (letter and number) in the entry box and click on the try guess button (or simply click enter – check line 29), can see the solution and restart the game (with a new board).


create_boards

On this version of the game, with a GUI, the player’s board is no longer a string to show in a console but a 9×9 matrix just like the secret_board. The two boards are saved in a temporary folder.

def create_boards(self):
    
    #the board starts as 2D array with 9 rows, 9 columns and all values set to zero
    self.main_board = np.zeros((9,9))
    
    #initialization of some variables of the game
    self.win = False
    self.hits = []
    self.attempts = []
    self.boats_destroyed = []
    
    #create the board, getting the list of boats and the list of blocked cells (not possible to insert a boat cell) 
    self.boat_list = []
    self.not_possible = []
    for _ in range(self.num_boats):
        self.boat_list.append(self.add_boat())
        rows, cols = np.where(self.main_board == -1)
        self.not_possible = [(rows[i], cols[i]) for i in range(len(rows))]
    
    #sort the cells of each boat
    for boat in self.boat_list:
        boat = boat.sort()
    
    #replace all -1 in the board by 0, so now a boat cell is represented by one and the rest is 0
    self.main_board = np.where(self.main_board!=1, 0, self.main_board)
    
    self.player_rows = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]
    
    #create the board that will be presented to the player
    self.player_board = np.zeros_like(self.main_board)
        
    if not os.path.exists('temporary'):
        os.makedirs('temporary')
        
    self.save_board_pic(self.main_board, 'temporary/secret_board.png')
    self.save_board_pic(self.player_board, 'temporary/player_board.png')

save_board_pic

Knowing that a 0 corresponds to water, a 1 to a boat, a 2 to a hit on water and a 3 to a hit on a boat and using a dictionary to give RGB values it’s possible to create an image like array. Using the capabilities of the matplotlib package, it’s possible to create the axis of the picture with the classical letters and numbers of the Battleship game, as well as the grid like board.

def save_board_pic(self, board, path):
    
    #create image and save it as png
    
    colors = {   0:  [90,  155,  255],
                 1:  [88,  88,  88],
                 2:  [14,  11,  167],
                 3:  [255,  0,  0]}
    
    image = np.array([[colors[val] for val in row] for row in board], dtype='B')

    fig = plt.figure()
    ax = fig.add_axes([0.1, 0.1, 0.8, 0.8]) 
    ax.set_xticks([0,1,2,3,4,5,6,7,8])
    ax.set_yticks([0,1,2,3,4,5,6,7,8])
    ax.invert_yaxis()
    ax.xaxis.tick_top()
    ax.imshow(image)
    ax.set_yticklabels(["A", "B", "C", "D", "E", "F", "G", "H", "I"])
    ax.set_xticklabels(["1", "2", "3", "4", "5", "6", "7", "8", "9"])
    for i in list(np.arange(0.5, 8.5, 1)):
        plt.axvline(x = i, color = 'black', linestyle = '-', lw=0.5) 
        plt.axhline(y = i, color = 'black', linestyle = '-', lw=0.5) 
    plt.tick_params(axis='both', labelsize=12, length = 0)
    plt.savefig(path)
Player’s board (in the beginning) versus secret board

show_board

This method is the one responsible to show the board to the user. In short, the saved PNG of the board is opened, resized and then added to a Tkinter Label that is inserted in the window.

def show_board(self):
    
    for widget in self.board_frame.winfo_children():
        widget.destroy()
    
    img = Image.open('temporary/player_board.png')
    
    basewidth = 800
    wpercent = (basewidth/float(img.size[0]))
    hsize = int((float(img.size[1])*float(wpercent)))
    img = img.resize((basewidth,hsize), Image.ANTIALIAS)
    
    self.img = ImageTk.PhotoImage(img)
    
    self.panel_board = tk.Label(self.board_frame, image = self.img, bg="white")
    self.panel_board.pack(fill = "both", expand = "yes", anchor="n")
    
    self.root.update_idletasks()

add_guess

This add_guess is almost equal to the one in the original Battleship post, but instead of adding symbols to the string player’s board, let’s add numbers to the 2D player’s board array, where a 2 represents a hit on water and a 3 represents a hit on a ship. This method also checks for the number of boats destroyed and if the game was won, like in the original version.

def add_guess(self, guess):
    
    guess = guess.upper()
    
    #insert a guess
    
    #validate the guess
    if len(guess) != 2:
        self.result.configure(text = "Invalid guess!", fg="red")
        return
    
    if not guess[0].isalpha() or not guess[1].isdigit():
        self.result.configure(text = "Invalid guess!", fg="red")
        return
    
    if guess[0] not in self.player_rows or int(guess[1]) < 0 or int(guess[1]) > 9:
        self.result.configure(text = "Invalid guess!", fg="red")
        return
    
    #get the row and column of the guess (correspondent to the game board, not the player board)
    guess_row = self.player_rows.index(guess[0])
    guess_col = int(guess[1])-1
    
    #check if is repeated guess
    if (guess_row, guess_col) in self.attempts:
        self.result.configure(text = "Repeated guess!", fg="red")
        return
    
    self.attempts.append((guess_row, guess_col))
    
    #check if the guess hit a boat
    is_boat = any((guess_row, guess_col) in sublist for sublist in self.boat_list) 
    if is_boat:       
        self.player_board[guess_row][guess_col] = "3"
        self.hits.append((guess_row, guess_col))
        self.result.configure(text = "You hit a boat!", fg="#075fea")
        self.check_boats_destroyed()
        
    else:
        self.player_board[guess_row][guess_col] = "2"
        self.result.configure(text = "Oops...whater!", fg="#081947")
        
    self.save_board_pic(self.player_board, 'temporary/player_board.png')
    self.show_board()
    
    self.entry_guess.delete(0, tk.END)
    
    self.root.update_idletasks()
        
    #check if game was won
    self.win = self.check_win()
    
    if self.win == True:
        self.result.configure(text = "You won with {} attempts!".format(len(self.attempts)), fg="green")
        
    return

In the gallery below you can see 5 different situations that can occur after a new guess: an invalid guess, a hit on water (notice that the entry box is cleaned after a valid guess), a hit on a ship, 4 ships destroyed and the end of the game!


see_solution

Finally, it’s possible for the player to “cheat” and see the solution board, which shows where the ships are placed. For this, the secret board PNG is loaded and shown in the window. After less than 1 second (counted using the time package), the solution board is deleted and the player’s board appears again.

def see_solution(self):
    
    for widget in self.board_frame.winfo_children():
        widget.destroy()
    
    img = Image.open('temporary/secret_board.png')
    
    basewidth = 800
    wpercent = (basewidth/float(img.size[0]))
    hsize = int((float(img.size[1])*float(wpercent)))
    img = img.resize((basewidth,hsize), Image.ANTIALIAS)
    
    self.img = ImageTk.PhotoImage(img)
    
    self.panel_board = tk.Label(self.board_frame, image = self.img, bg="white")
    self.panel_board.pack(fill = "both", expand = "yes", anchor="n")
    
    self.root.update_idletasks()
    
    time.sleep(0.2)
    
    self.show_board()

The end

And that’s it! Now we have a fully functional Battleship game with a cool GUI, everything done exclusively with Python. You can check a complete game in the video below and the entire code on my GitHub.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: