Hangman—PyQt5 (Part 5)

This is the part in a serious of tutorials that describe how to make a Hangman game using Python and PyQt5. You can view the other parts of this series at the following links:

Putting it all together

So far, we have been making panels that inherit from QWidget. Although we tested each of those panels in isolation, the intended use of these panels is to group them together into a main application window. The other job we have to do is wire up our controls. Right now, none of our QPushButtons do anything when they are clicked. This section of the tutorial will demonstrate how to connect click events to functions that handle the event.

For reference, here is the module code

import sys

from PyQt5.QtCore import QCoreApplication
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import (QWidget, QPushButton, QGridLayout, QApplication, QLabel, QHBoxLayout, QVBoxLayout,
                             QMainWindow, QInputDialog)

from hangman import Hangman


class HangmanPanel(QWidget):
    def __init__(self, hangman, frames):
        super().__init__()
        self.hangman = hangman
        self.frame_index = 0
        self.frames = self.create_frames(frames)
        self.label = QLabel(self)
        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.advance_frame()

    def create_frames(self, frames):
        frm = []
        for f in frames:
            frm.append(QPixmap(f))
        return tuple(frm)

    def advance_frame(self):
        self.label.setPixmap(self.frames[self.frame_index])
        self.frame_index += 1

        if self.frame_index >= len(self.frames):
            self.frame_index = 0

    def reset_hangman(self):
        self.frame_index = 0
        self.advance_frame()


class LetterPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman
        self.create_buttons()
        self.grid = QGridLayout(self)
        self.position_buttons()

    def create_buttons(self):
        letters = ('A', 'B', 'C', 'D', 'E',
                   'F', 'G', 'H', 'I', 'J',
                   'K', 'L', 'M', 'N', 'O',
                   'P', 'Q', 'R', 'S', 'T',
                   'U', 'V', 'W', 'X', 'Y', 'Z')
        buttons = []
        for l in letters:
            buttons.append(QPushButton(l, self))
        self.buttons = tuple(buttons)

    def position_buttons(self):
        positions = [(i, j) for i in range(7) for j in range(4)]
        buttons = list(self.buttons)
        buttons.reverse()

        for pos in positions:
            if buttons:
                self.grid.addWidget(buttons.pop(), *pos)

    def activate_all(self):
        for button in self.buttons:
            button.setEnabled(True)


class WinsPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.label.setText('Wins')

        self.win_label = QLabel(self)
        self.win_label.setText('0')

        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.win_label)

    def update_wins(self):
        self.win_label.setText(str(hangman.wins))


class DisplayPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.word_label = QLabel(self)

        self.layout = QVBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.word_label)


class WordPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText('Current Word')
        self.update_word()

    def update_word(self):
        self.word_label.setText(' '.join(self.hangman.display_letters))


class GuessedLetterPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText("Letters Already Guessed")
        self.update_letters()

    def update_letters(self):
        self.word_label.setText(', '.join(self.hangman.guessed_letters))


class HangmanWindow(QMainWindow):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.wins_panel = WinsPanel(hangman)
        self.hangman_panel = HangmanPanel(hangman, ['hangman_0.png',
                                                    'hangman_1.png',
                                                    'hangman_2.png',
                                                    'hangman_3.png',
                                                    'hangman_4.png',
                                                    'hangman_5.png',
                                                    'hangman_6.png',
                                                    'hangman_7.png'])
        self.word_panel = WordPanel(hangman)
        self.guessed_letter_panel = GuessedLetterPanel(hangman)
        self.letter_panel = LetterPanel(hangman)

        central_widget = QWidget()
        central_layout = QHBoxLayout(central_widget)

        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.addWidget(self.wins_panel)
        left_layout.addWidget(self.hangman_panel)
        left_layout.addWidget(self.word_panel)
        left_layout.addWidget(self.guessed_letter_panel)

        right_widget = QWidget()
        right_layout = QHBoxLayout(right_widget)
        right_layout.addWidget(self.letter_panel)

        central_layout.addWidget(left_widget)
        central_layout.addWidget(right_widget)

        self.connect_listeners()
        self.setCentralWidget(central_widget)
        self.setWindowTitle('Hangman')
        self.show()

    def connect_listeners(self):
        for button in self.letter_panel.buttons:
            button.clicked.connect(self.on_letter_button_click)

    def on_letter_button_click(self):
        sender = self.sender()

        letter = sender.text()
        sender.setEnabled(False)

        current_guess = hangman.guesses

        self.hangman.guess_letter(letter)
        self.guessed_letter_panel.update_letters()
        self.word_panel.update_word()

        if current_guess < hangman.guesses:
            self.hangman_panel.advance_frame()

        if hangman.check_win():
            self.wins_panel.update_wins()
            self.show_win()
        elif hangman.check_lose():
            self.show_lose()

    def show_win(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Win! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def show_lose(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Lost! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def reset_game(self):
        hangman.start_game()
        self.wins_panel.update_wins()
        self.hangman_panel.reset_hangman()
        self.word_panel.update_word()
        self.guessed_letter_panel.update_letters()
        self.letter_panel.activate_all()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    hangman = Hangman('words.txt', 6)
    win = HangmanWindow(hangman)
    sys.exit(app.exec_())

HangmanWindow

HangmanWindow is our main application window. One difference you will notice right off of the bat is that this class inherits from QMainWindow instead of QWidget.

class HangmanWindow(QMainWindow):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.wins_panel = WinsPanel(hangman)
        self.hangman_panel = HangmanPanel(hangman, ['hangman_0.png',
                                                    'hangman_1.png',
                                                    'hangman_2.png',
                                                    'hangman_3.png',
                                                    'hangman_4.png',
                                                    'hangman_5.png',
                                                    'hangman_6.png',
                                                    'hangman_7.png'])
        self.word_panel = WordPanel(hangman)
        self.guessed_letter_panel = GuessedLetterPanel(hangman)
        self.letter_panel = LetterPanel(hangman)

        central_widget = QWidget()
        central_layout = QHBoxLayout(central_widget)

        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.addWidget(self.wins_panel)
        left_layout.addWidget(self.hangman_panel)
        left_layout.addWidget(self.word_panel)
        left_layout.addWidget(self.guessed_letter_panel)

        right_widget = QWidget()
        right_layout = QHBoxLayout(right_widget)
        right_layout.addWidget(self.letter_panel)

        central_layout.addWidget(left_widget)
        central_layout.addWidget(right_widget)

        self.connect_listeners()
        self.setCentralWidget(central_widget)
        self.setWindowTitle('Hangman')
        self.show()

    def connect_listeners(self):
        for button in self.letter_panel.buttons:
            button.clicked.connect(self.on_letter_button_click)

    def on_letter_button_click(self):
        sender = self.sender()

        letter = sender.text()
        sender.setEnabled(False)

        current_guess = hangman.guesses

        self.hangman.guess_letter(letter)
        self.guessed_letter_panel.update_letters()
        self.word_panel.update_word()

        if current_guess < hangman.guesses:
            self.hangman_panel.advance_frame()

        if hangman.check_win():
            self.wins_panel.update_wins()
            self.show_win()
        elif hangman.check_lose():
            self.show_lose()

    def show_win(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Win! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def show_lose(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Lost! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def reset_game(self):
        hangman.start_game()
        self.wins_panel.update_wins()
        self.hangman_panel.reset_hangman()
        self.word_panel.update_word()
        self.guessed_letter_panel.update_letters()
        self.letter_panel.activate_all()

This class is much larger than our other panel classes for the simple reason that it creates all of our panels and then connects the controls to event handler functions.

__init__

We begin with the __init__ method. There is a lot going on in this method so we are going to take it a little bit at a time.

def __init__(self, hangman):
    super().__init__()
    self.hangman = hangman

    self.wins_panel = WinsPanel(hangman)
    self.hangman_panel = HangmanPanel(hangman, ['hangman_0.png',
                                                'hangman_1.png',
                                                'hangman_2.png',
                                                'hangman_3.png',
                                                'hangman_4.png',
                                                'hangman_5.png',
                                                'hangman_6.png',
                                                'hangman_7.png'])
    self.word_panel = WordPanel(hangman)
    self.guessed_letter_panel = GuessedLetterPanel(hangman)
    self.letter_panel = LetterPanel(hangman)

    central_widget = QWidget()
    central_layout = QHBoxLayout(central_widget)

    left_widget = QWidget()
    left_layout = QVBoxLayout(left_widget)
    left_layout.addWidget(self.wins_panel)
    left_layout.addWidget(self.hangman_panel)
    left_layout.addWidget(self.word_panel)
    left_layout.addWidget(self.guessed_letter_panel)

    right_widget = QWidget()
    right_layout = QHBoxLayout(right_widget)
    right_layout.addWidget(self.letter_panel)

    central_layout.addWidget(left_widget)
    central_layout.addWidget(right_widget)

    self.connect_listeners()
    self.setCentralWidget(central_widget)
    self.setWindowTitle('Hangman')
    self.show()

As always, we start with calling super().__init__() to intialize the Qt window. Our next job is to assign the hangman variable to self.hangman. Doing this allows use to use the Hangman class to control the application’s logic.

Our next job is create our panels. We create a WinPanel (TODO: Link) (self.win_panel) and then we create a HangmanPanel (TODO: Link) (assign it to self.hangman_panel) and supply it with the file names of our frames. Then we create a WordPanel (TODO: Link) (assign it to self.word_panel), then a GuessedLetterPanel (self.guessed_letter_panel), and a LetterPanel (TODO: Link) (self.letter_panel).

Now, we can’t simply add these widgets to our window. QMainWindow uses a centeral widget that contains all of our widgets. We create a central_widget object and then on the following line assign a QHBoxLayout to this widget. Next we create two more nested QWidgets. The first widget is left_widget. This widget gets a QVBoxLayout and then we add self.win_panel (WinPanel), self.hangman_panel (HangmanPanel), self.word_panel (WordPanel), and self.guessed_letter_panel (GuessedLetterPanel) to this widget. That makes everything but the letter buttons appear on the left hand side of the screen.

Now we make another QWidget called right_widget. This widget gets a QHBoxLayout and then we added self.letter_panel (LetterPanel) to this widget. Now that we have our left_widget and right_widget, we can add them both to central_widget. The two widgets will stack up left to right.

Now that we have layed our screen, we call self.connect_listeners() to wire up the buttons to their event handling code. We then call setCentralWidget and pass central_widget to it. This places all of the controls on the QMainWindow. The next line of code sets the title of the window to “Hangman”. Finally, we call self.show() to actually show the window.

connect_listeners

Right now we have 26 QPushButtons on our window that don’t do anything. We are going to address that issue in this method.

def connect_listeners(self):
    for button in self.letter_panel.buttons:
        button.clicked.connect(self.on_letter_button_click)

If you remember from LetterPanel (TODO: Link), LetterPanel has a tuple of 26 buttons. QPushButton maintains a clicked object that has a connect method. The clicked is the name of the event (which fires when the user clicks on the button). The connect is a method that takes a Callable, (functions for the most part, but can be lambdas or classes that implement Callable). In our case, we are going to connect a click to our class’s on_letter_button_click method.

on_letter_button_click

This function gets called anytime we we click on a button. We connected this function to QPushButton’s clicked event in the connect_listeners function.

def on_letter_button_click(self):
    sender = self.sender()

    letter = sender.text()
    sender.setEnabled(False)

    current_guess = hangman.guesses

    self.hangman.guess_letter(letter)
    self.guessed_letter_panel.update_letters()
    self.word_panel.update_word()

    if current_guess < hangman.guesses:
        self.hangman_panel.advance_frame()

    if hangman.check_win():
        self.wins_panel.update_wins()
        self.show_win()
    elif hangman.check_lose():
        self.show_lose()

The first thing we need to do is get the source of the click event. We can get this using QMainWindow's sender() method. The next thing to do is get the letter. Remember that we set the label of each QPushButton to a letter in the alphabit. Therefore, we can get the guessed letter but using the text() method on QPushButton. Next we call sender.setEnabled to false so that they can't guess the same letter twice.

Now it's time for our HangmanObject to do some work. We start by grabbing the current number of wrong guesses but accessing hangman.guesses. On the next line, we pass the guessed letter to hangman.guess_letter. It's going to do the work of making a determination if the letter was a correct guess or not.

After guessing a letter, we call self.guessed_letter_panel.update_letters() to update the guessed letter portion of the UI. Then we call self.word_panel.update_word() to update the current word portion of the UI.

Next we need to see if we need to show the man in hangman getting hanged. We compare the current_guess against hangman.guesses. If they guessed wrong, then hangman.guessess will be larger than current_guess. Otherwise the are equal. If they guessed wrong, we update the HangmanPanel of the UI by calling self.hangman_panel.advance_frame()

Finally we need to check if the user has won or lost the game. We use hangman.check_win to see if they have won. If they have won, we need to update the WinsPanel by calling self.wins_panel.update_wins() and then call self.show_win() to notify the user that they have won.

Alternatively, the user may have lost the game. If that's the case, we call self.show_lose()

show_win

If the user wins, we want to show a dialog window that tells them they have won. It looks like this screen shot
wins copy.png
Here is the code for this function

def show_win(self):
    items = ('Yes', 'No')
    item, ok = QInputDialog.getItem(self, 'You Win! Play again?', 'Choice:', items, 0, False)
    if ok and item == 'Yes':
        self.reset_game()
    else:
        QCoreApplication.instance().quit()

This function starts out by creating a tuple that contains the two choices we are presenting to the user. Then we call QInputDialog.getItem to get the users choice. This creates a dialog box that is populated with the choices ‘Yes’ or ‘No’. It returns a tuple that we unpack into the variables item and ok. Item is the users choice and ok if if they clicked the Ok button rather than cancel.

If they clicked ok and selected Yes, we call self.reset_game() to start a new game. Otherwise we use QCoreApplication.instance().quit() to exit the application.

show_lose

This code does the exact same thing as show_win but it shows a different error message. Here is the code, but there isn’t anything new to explain.

def show_lose(self):
    items = ('Yes', 'No')
    item, ok = QInputDialog.getItem(self, 'You Lost! Play again?', 'Choice:', items, 0, False)
    if ok and item == 'Yes':
        self.reset_game()
    else:
        QCoreApplication.instance().quit()

reset_game

The final function in this class is the reset_game function. It does the job of reseting the UI and Hangman to their default states so that we can play a new game.

def reset_game(self):
    hangman.start_game()
    self.wins_panel.update_wins()
    self.hangman_panel.reset_hangman()
    self.word_panel.update_word()
    self.guessed_letter_panel.update_letters()
    self.letter_panel.activate_all()

We call hangman.start_game to reset Hangman. We also update the WinsPanel portion of the UI. Next we need to update the HangmanPanel to it’s first frame by calling reset_hangman().

Since Hangman has picked a new word at this point, we need to call WordPanel.update_word() to replace the word with dashes again. We also need to clear out the guessed letters so we call GuessedLetterPanel.update_letters() to erase all of the guessed letters. Finally we have to enable all of the QPushButtons so use LetterPanel.activate_all() to re-enable all of our QPushButtons.

Run the program

We run the program like this

if __name__ == '__main__':
    app = QApplication(sys.argv)
    hangman = Hangman('words.txt', 6)
    win = HangmanWindow(hangman)
    sys.exit(app.exec_())

We begin by initializing Qt by creating a QApplication object and passing the command line arguments to it. Next we create a Hangman object and pass the files with our word bank and the number of allowed guesses to it. The next line creates our HangmanWindow. Finally we call sys.exit and pass the QApplication.exec_() function to it so that the application exits properly.

Conclusion

We came a long way in this tutorial. We had a lot of practice with object orientated programming (OOP) and we got a tour of PyQt5. This program read a file line by line, joined strings, and even handled GUI event programming. Here is alink to the complete project! Enjoy playing Hangman!

Advertisement

5 thoughts on “Hangman—PyQt5 (Part 5)”

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 )

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: