Hangman—PyQt5 (Part 4)

This is part 4 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:

We have 4 small classes that we are going to cover in this potion of the tutorial. They are WinsPanel, DisplayPanel, WordPanel, and GuessedLetterPanel. In this portion, we will see QVBoxLayout, join a string together, create mock objects for unit testing, and us interhitence to promote code reuse.

Here is the complete module code for reference followed by a discussion of the individual classes for this portion of the tutorial.


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

WinsPanel

Our application supports tracking the number of times the user wins the game. We can display this information with a panel that creates two QLabels and displays them horizontally.

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

As always, we can test this panel in isolation by using the following code snippet.

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

When run, the panel looks like the following screen shot
WinsPanel copy

Let’s walk through the code

__init__

The WinPanel’s __init__ method does the job of creating two QLabel objects and a QHBoxLayout. Here is the code for __init__ followed by an explanation.

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)

There really isn’t anything here that we haven’t seen before. We begin by calling QWidget’s __init__ method so that we can add widgets to this panel. This panel also takes a Hangman object. The next two lines create a QLabel with the text ‘Wins’.

The following the creation of the first QLabel, we create another QLabel and assign it to self.win_label with a text of ‘0’. This label will get updated in the update_wins function below. It will display the number of games of Hangman the user won.

The final three lines create a QHBoxLayout to arrange controls from left to right and then we use addWidget to add the two QLabel objects that we created.

update_wins

The update_wins function get’s called by the application to update the win_label’s text.

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

The Hangman object has a wins property. Remember that our application’s logic is seperate from the presentation (the UI), which is why this class is grabbing the value from Hangman rather than keeping it’s own win count. All we are doing is using QLabel’s setText method to update the text in the label. Since hangman.wins is an integer, we need to convert it to a string using the str function to avoid a type error.

DisplayPanel

WordPanel and GuessedLetterPanel are incredibly similar and even use the same controls and layouts. Whenever we have patterns in our code, we should use the “Do Not Repeat” principle. In this case, we are going to define a class that is only used as a base class for WordPanel and GuessedLetterPanel. Here is the code followed by an explanation.

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)

__init__

The DisplayPanel class only has one method __init__. This class does the job of assigning a hagnman object for later use and creating two QLabel objects. You will notice that the QLabel objects do not have their text set.

We also meet a new layout manager here. The QVBoxLayout is used to stack widgets from top to bottom. We create the QVBoxLayout and then add both of our labels to the layout manager.

WordPanel

WordPanel does the job of displaying the user’s correct guesses. It works with Hangman’s display_letters list object to display the letters the user has guessed correctly or show’s dashes (‘-‘) for letters that are still unknown to the user. Here is the code for this class.

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

Using this code, we can run this panel in isolation

if __name__ == '__main__':
    class MockHangman:
        def __init__(self):
            self.display_letters = ['-', '-', 'A', '-', '-']

    app = QApplication(sys.argv)
    win = WordPanel(MockHangman())
    win.show()
    sys.exit(app.exec_())

You will notice in this case we need a special MockHangman class to test WordPanel. Python is a dynamically typed language. As long as objects satisfy an interface, Python could care less about the kind of object. This feature (often called duck typing) is a major advantage of Python.

WordPanel needs a Hangman object that has a display_letters variable. Since our MackHangman has this variable, it can satisfy WordPanel’s Hangman dependency. Defining classes on the fly like we did above makes it really easy to test Python objects in isolation.

Our panel looks like this when it’s run
WordPanel copy

Now let’s discuss the code

__init__

What is special about WordPanel’s __init__ is that it is so short. Here is the code for the __init__ method for this class.

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

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

You will notice that we call DisplayPanel’s __init__ and pass the hangman object to the parent class’s constructor. We set the text of the label also, but we never create the QLabel object here. That’s already been done for us in DisplayPanel’s __init__. We have direct access to the QLabel right away. We don’t even worry about creating a layout manager here since that also was taken care of for us already.

update_word

We are going to see how to combine a list into a string.

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

The ' '.join(self.hangman.display_letters) is the code of interest here. Whenever we want to combine elements of an iterable (lists are iterables) into a string, we start with a string that is used as a seperator. In our case, we are using a blank space ‘ ‘ that appears between every letter. If you don’t want a space, just use an empty string ”. Now we use the join method on string, which accepts any iterable. Our iterable is self.hangman.display_letters. So each element in self.hangman.display_letters is joined into a single string.

At this point, we just use QLabels.setText method to update the text in the string.

GuessedLetterPanel

GuessedLetterPanel’s job is to display the letters the user has guessed so far. Here is the code for this class.

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

We can test this class as a stand alone component. However, to do so, we need a Mock Hangman object. Here is the code to run this panel in isolation.

if __name__ == '__main__':
    class MockHangman:
        def __init__(self):
            self.guessed_letters = ['A', 'E', 'I', 'O', 'U']

    app = QApplication(sys.argv)
    win = GuessedLetterPanel(MockHangman())
    win.show()
    sys.exit(app.exec_())

This is what our panel looks like when it’s ran in isolation.
GuessedLettersPanel copy

__init__

Once again, GuessedLetterPanel is a child class of DisplayPanel. For this reason, we don’t need to worry about things like creating our QLabel objects or our layout. We just set the variables to the values we need them at and let the parent class do the work for us.

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

    self.label.setText("Letters Already Guessed")
    self.update_letters()
[/code

<h3>update_letters<h3>
This method is another example of how to join an iterable into a string.

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

In this case, we use ', ' as our letter seperator rather than a blank space.

Conclusion

We convered quite a bit of ground in this section. The code in these classes showed us how to use QVBoxLayout. We learned how to join strings together into a single string using String's join method. Furthermore, we saw how to create mock objects that help us with unit testing our classes. Finally, we saw how to use inheritance to group similar code together.

Advertisements

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

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