4
\$\begingroup\$

I decided to implement a Hangman game using an object oriented State Pattern design. This is one of my first actual OOP as I normally write functional code.

Code use:

if __name__ == '__main__':
    game = Context('represents', lives=5) # hangman word is represents
    game.startGame()

I'm mostly happy with this but wanted to check:

  • is there a nicer way of having DeadState / WonState where I don't have to use pass for the process
  • I've copied the style of docstrings from Flask Restless Github. Is this correct use of docstrings?
  • is there better way of combining user input and the Context class than the .startGame() method? I've seen it mentioned on Stack Overflow you shouldn't have input statements inside classes.
class Context(object):
    """A class that keeps track of the hangman game context and current state.
    This follows a State Design Pattern.

    `word` is string. This is the word to guess.
    `guessed_letters` is a list containing letters guessed
    `lives` is a number that is the number of incorrect guesses allowed.
    `state` is class representing the state of the game.
    `message` is a string message from processing the letter guess.

    The :meth:`.process` method processes guessed letters, updating the
    lives, guessed_letters and message for the gameplay.
    """
    def __init__(self, word, lives=10):
        self.word = word
        self.guessed_letters = []
        self.lives = lives
        self.state = HoldingState()
        self.message = ''

    def won(self):
        """Tests if all letters of in word are in guessed_letters"""
        return all(l in self.guessed_letters for l in self.word)

    def lost(self):
        """Tests if we have run out of lives"""
        return self.lives <= 0

    def hangman_word(self):
        """Returns a string in hangman form where letters are hyphens
        or guessed letters. Letters or hyphens are space separated.

        `word` is a string. This is the word to be printed in hangman form.

        `guessed_letters` is a list of the letters guessed that should be shown.

        For example::

            >>> hangman_word('bordereau', ['o', 'd', 'e'])
            '_ o _ d e _ e _ _'

        """
        return ' '.join(l if l in self.guessed_letters else '_' for l in self.word)

    def process(self, letter):
        """Requests the state to process the guessed letter.

        The State takes care of updating the lives, guessed_letters and providing a
        message for the gameplay"""
        self.state.process(letter, self)

    def startGame(self):
        """Runs the game.

        Checks for final states (WonState / DeadState) else shows the hangman
        word, letters guessed and takes as input the letter guessed and 
        processes this.

        """
        while True:
            if self.state == WonState():
                print(f'You win! The word was indeed {self.word}')
                return
            if self.state == DeadState():
                print(f'You lose! The word was {self.word}')
                return
            print('Hangman Word:', self.hangman_word() + '\n')
            print('Letters guessed: ' + ' '.join(sorted(self.guessed_letters)) + '\n')
            letter = input('Guess a letter: ')
            self.process(letter)
            print('\n' + self.message + '\n')


class BaseState(object):
    """Represents a state of the context.

    This is an abstract base class. Subclasses must override and
    implement the :meth:`.process` method.

    The :meth:`.process` method updates the context variables and changes the
    context state.

    """
    def process(self, letter, context):
        """Updates the context variables and changes the context state..

        **This method is not implemented in this base class; subclasses
        must override this method.**

        """
        raise NotImplementedError

    def __eq__(self, other):
        """Overrides the default implementation

        Tests equality of states by testing if there are the same class

        """
        if isinstance(other, self.__class__):
            return True
        return False

class HoldingState(BaseState):
    """The default state."""
    def process(self, letter, context):
        if letter in context.guessed_letters:
            context.state = RepeatedGuessState()
        elif letter in context.word:
            context.state = CorrectGuessState()
        else:
            context.state = IncorrectGuessState()
        # Calls process again to return to HoldingState / WonState / DeadState
        context.state.process(letter, context)

class RepeatedGuessState(BaseState):
    """An intermediate state."""
    def process(self, letter, context):
        context.message = "You've repeated a guess!"
        context.state = HoldingState()

class CorrectGuessState(BaseState):
    """An intermediate state."""
    def process(self, letter, context):
        context.message = "Correct guess!"
        context.guessed_letters.append(letter)
        if context.won():
            context.state = WonState()
        else:
            context.state = HoldingState()

class IncorrectGuessState(BaseState):
    """An intermediate state."""
    def process(self, letter, context):
        context.lives -= 1
        context.message = f"Incorrect guess! You've lost a life. {context.lives} lives remaining"
        context.guessed_letters.append(letter)
        if context.lost():
            context.state = DeadState()
        else:
            context.state = HoldingState()

class DeadState(BaseState):
    """A final state representing a lost game."""
    def process(self, letter, context):
        pass

class WonState(BaseState):
    """A final state representing a game won."""
    def process(self, letter, context):
        pass

if __name__ == '__main__':
    game = Context('represents')
    game.startGame()
\$\endgroup\$
2
\$\begingroup\$

Neither BaseState nor any of its derived classes contain any data. You don’t need to keep creating instances of these state objects. You could create one of each as global constants. Then, you wouldn’t need the special __eq__ method, since you could simply test identity equality.

You could make BaseState and its derived classes callable objects, by defining a __call__(self, letter, context) method, instead of a process(self, letter, context) method. You could then invoke the state processing by calling self.state(letter, self).

Of course, an class with no data and only a single function is effectively just a function, and functions are first class objects in Python, so you can ditch the classes all together, and store the function as the “state”. Eg)

def repeated_guess_state(letter, context):
    """An intermediate state"""
    context.message = "You’ve repeated a guess!"
    context.state = holding_state

But wait! Why are these outside of the Context class? We have to pass context to each state handler. If these were methods defined on the Context class, they’d automatically get self, which is the context, passed to them effectively for free:

class Context:

    # ... __init__, won, lost, hangman_word methods omitted

    def process(self, letter):
        self.state(letter)

    def repeated_guess_state(self, letter):
        """An intermediate state"""
        self.message = "You’ve repeated a guess!"
        self.state = self.holding_state

Now these state processors (methods) actually part of the Context class, so that don’t have to reach into another class and manipulate the private internals. They have legitimate access to the internal details. So these data members should be declared private ... which in Python is little more than the convention of a leading underscore (eg, self._lives, self._state, and so on).


is there a nicer way of having DeadState / WonState where I don't have to use pass for the process

Nothing wrong with using pass. You have a function the does nothing.

You could make the states actually print the win/loss messages, and then transition to a “finished” state. The finished state would likely have the pass statement, though. Alternately, you could use None as the finish state, and loop while self.state: instead of while True:

I've copied the style of docstrings from Flask Restless Github. Is this correct use of docstrings?

The exact format of the docstrings is dependant on the tool you use to automatically generate documentation with. Codes like:

:meth:`.process`

are used by “Sphinx” documentation generator. See it for details.

is there better way of combining user input and the Context class than the .startGame() method? I've seen it mentioned on Stack Overflow you shouldn't have input statements inside classes.

There is nothing wrong with asking for input inside of a class. But ...

Separating I/O from game logic allows you to ...

  • create a text-based console game,
  • create a Graphical UI based game, using the same game logic
  • test the game code using automated test software

In the first case, the input comes from input() and goes to print() statements, in the second it comes from & goes to (say) TkInter, and the third it comes from and goes to test code.

You’ve mostly separated the I/O already. Wherever the input comes from, you need to pass it to self.process(...), and the output (mostly) goes to self.message. You just need to factor out the console I/O, and maybe places that into class ConsoleHangman(Context):.

class TkInterHangman(Context): left as exercise to student.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.