Hangman—PyQt5 (Part 3)

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

The next step…

In Part 2, we created a panel that shows our man getting hanged as the user inputs incorrect guesses. We are now going to continue by discussing the LetterPanel, which is a panel that holds 26 QPushButtons. Each QPushButton will correspond to a letter of the alphabit.

This is the code for the entire module for reference.

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_())

We are going to focus on the LetterPanel of this code. The LetterPanel explores some other features of PyQt5 which include GridLayout and QPushButton.

Letter Panel

This is the code for the letter panel

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)

Just like HangmanPanel, this panel inherits QWidget so that it can be used a GUI component. This allows us to nest this panel into an application window or run it as a stand alone window.

We can run it in a standalone window using the following code

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = LetterPanel(None)
    win.show()
    sys.exit(app.exec_())

This is what the panel looks like when run in isolation.
LetterPanel copy

Now let’s discuss the code in this class.

__init__

By now you should be familiar with Python’s __init__ method. Here is the code for LetterPanel’s __init__ followed by an explanation.

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

We start by calling QWidget’s __init__ function so that we can add controls to this panel. We also supply a Hangman object to this class also.

The next line does the work of creating 26 buttons that get placed on this panel. We are going to do this in a seperate create_buttons() function. Next we need to layout the buttons into a grid pattern. QGridLayout is a powerful class that does the job of laying out components in a grid like fashion.

At this point, we have 26 buttons and a grid, but we need to actually add the buttons to the grid. Once again, we are going to use a seperate function position_buttons() and delegate this work to that function.

create_buttons

We met the create_buttons function in __init__. Here is the code followed by an explanation.

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)

We begin by creating a tuple of upper case letters, followed by an empty list of buttons. Since all of the buttons are going to do a similar job (which is sending a guessed letter to Hangman), it is much easier and much more maintainable to use a list to hold our QPushButton objects rather than using 26 or more variables for each button.

The next thing to do is iterate through each letter an create a QPushButton. The first argument of QPushButton’s __init__ method takes a string that will eventually contains the button’s text. So when we do QPushButton(l, self) we are going to set the label of the button to the current letter. Then we add the newly created QPushButton to the buttons list.

We don’t plan on adding, changing, or shuffling our buttons after the GUI is created. To write protect our data, we convert buttons to a tuple and then assign it to self.buttons.

position_buttons

We met position_buttons in the __init__ method. This function does the job of adding the buttons created in create_button to the screen.

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)

QGridLayout uses an x,y position to add controls to the grid. So our first line is using Python’s generator statement to create a 2D list of x,y positions. We will end up with a list that is 7 rows deep and 4 columns wide.

Now, this is the one case where we need to manipulate the order of our buttons. We create a buttons list out of self.buttons (remember that self.buttons is a tuple) so that we can reverse the order of the buttons. If we forget to do this, we would have the letter ‘Z’ appear first in our grid rather than last in grid. This is becuase we are going to use the pop() method to remove the last element in a list. Therefore, we need ‘Z’ to be at the first position in the list and ‘A’ at the last position.

Now we are going to add the buttons to the grid. We are going to iterate through the positions list object. The variable pos is a tuple that contains the x,y pair of coordinates.

Now we need to check that buttons isn’t empty, so we use if buttons first to make sure we aren’t calling pop() on an empty list. As long as we still have buttons, we can add them to self.grid.addWidget. The *pos is a shortcut way of adding the x,y pair into the addWidget’s method without having to create two variables.

activate_all

This last method is used later on when we want to reset the game. It simply re-enables all disabled buttons (which get disabled after the user clicks on them).

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

Conclusion

We covered some new ground in this portion of the tutorial. QPushButton is a widget that creates a regular push button on the street. QGridLayout is a layout manager that positions controls on the screen in a grid like fashion. When you add widgets to the screen using grid layout, you need to supply an x,y pair so that the grid layout knows where to position each control.

Advertisements

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

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 )

Google+ photo

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

Connecting to %s