From 16cd9d42b9109b21fbdf4f1a626d3065c814a14b Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sun, 4 Oct 2026 12:46:03 +0800 Subject: [PATCH] step 5 Improving the user experience --- README.md | 32 +++++++++++++---- src/hangman/constants.py | 77 ++++++++++++++++++++++++++++++++++++++++ src/hangman/game.py | 10 ++++-- src/hangman/main.py | 65 +++++++++++++++++++++++++++++---- tests/test_game.py | 23 +++++++++++- tests/test_main.py | 45 +++++++++++++++-------- 6 files changed, 222 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2d99328..b84f945 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,36 @@ Day 7 of Udemy's *100 Days of Code™: The Complete Python Pro Bootcamp*. The ga 1. Picking a random word and checking answers 2. Replacing blanks with guesses 3. Checking if the player has won -4. Keeping track of the player's lives *(this step)* -5. Improving the user experience +4. Keeping track of the player's lives +5. Improving the user experience *(this step)* -**Step 1:** the program picks a random word, asks for one letter and tells you whether the letter is in the word. +The flowchart from the course: -**Step 2:** the word is shown as blanks (`_ _ _ _ _`). After your guess every matching blank is replaced by the letter. +```mermaid +flowchart TD + A([START]) --> B[Generate a random word] + B --> C[Generate as many blanks as letters in word] + C --> D[Ask the user to guess a letter] + D --> E{Is the guessed letter in the word?} + E -- Yes --> F[Replace the blank with the letter] + E -- No --> G[Lose a life] + F --> H{Are all the blanks filled?} + G --> I{Have they run out of lives?} + H -- No --> D + I -- No --> D + H -- Yes --> J([GAME OVER]) + I -- Yes --> J +``` -**Step 3:** the game keeps asking for letters until every blank is filled, then prints "You win!". (There are no lives yet, so wrong guesses cost nothing.) +What each step adds: -**Step 4:** you start with 6 lives. Every wrong guess costs one and shows the lives left. At 0 lives the game ends with "You lose" and reveals the word. +- **Step 1:** pick a random word, ask for one letter and say whether it is in the word. +- **Step 2:** show the word as blanks (`_ _ _ _ _`); a correct guess replaces every matching blank. +- **Step 3:** keep asking for letters until every blank is filled, then print "You win!". +- **Step 4:** start with 6 lives; every wrong guess costs one. At 0 lives the game ends with "You lose" and reveals the word. +- **Step 5:** a colored ASCII-art title (colors are skipped when the output is not a terminal or the `NO_COLOR` environment variable is set), an ASCII-art gallows that grows with each wrong guess, a friendly message for letters you already guessed, and input validation (only a single letter is accepted). These two are additions that are not in the flowchart. + +The game logic (`src/hangman/game.py`) is separate from the input/output (`src/hangman/main.py`), and all constants live in `src/hangman/constants.py`. ## 📋 Requirements diff --git a/src/hangman/constants.py b/src/hangman/constants.py index 91924d4..72fa00f 100644 --- a/src/hangman/constants.py +++ b/src/hangman/constants.py @@ -26,7 +26,84 @@ WORD_LIST = [ "tortoise", ] +## ASCII-art gallows, indexed by the number of lives left (0..MAX_LIVES). +STAGES = [ + r""" + +---+ + | | + O | + /|\ | + / \ | + | +=========""", + r""" + +---+ + | | + O | + /|\ | + / | + | +=========""", + r""" + +---+ + | | + O | + /|\ | + | + | +=========""", + r""" + +---+ + | | + O | + /| | + | + | +=========""", + r""" + +---+ + | | + O | + | | + | + | +=========""", + r""" + +---+ + | | + O | + | + | + | +=========""", + r""" + +---+ + | | + | + | + | + | +=========""", +] + ## Message shown when the player wins. MSG_WIN = "You win!" ## Message shown when the player loses (the word is appended). MSG_LOSE = "You lose. The word was: " + +## ANSI escape code that resets all colors and styles. +ANSI_RESET = "\033[0m" +## ANSI escape code for bold text. +ANSI_BOLD = "\033[1m" +## ANSI escape code for red text. +ANSI_RED = "\033[31m" + +## ASCII-art title shown on start-up. +TITLE_ART = r""" + _ _ +| | | | __ _ _ __ __ _ _ __ ___ __ _ _ __ +| |_| |/ _` | '_ \ / _` | '_ ` _ \ / _` | '_ \ +| _ | (_| | | | | (_| | | | | | | (_| | | | | +|_| |_|\__,_|_| |_|\__, |_| |_| |_|\__,_|_| |_| + |___/ +""" diff --git a/src/hangman/game.py b/src/hangman/game.py index e1bfcb2..b41e356 100644 --- a/src/hangman/game.py +++ b/src/hangman/game.py @@ -45,13 +45,19 @@ class Game: ## Letters guessed so far. self.guesses: set[str] = set() - def guess(self, letter: str) -> bool: + def guess(self, letter: str) -> bool | None: """! @brief Process a guess; a wrong guess costs a life. @param letter A single letter. - @return True if the letter is in the word. + @return True if correct, False if wrong, None if the letter was + already guessed (nothing changes). + @throws ValueError if @p letter is not a single alphabetic character. """ letter = letter.lower() + if len(letter) != 1 or not letter.isalpha(): + raise ValueError("Guess must be a single letter.") + if letter in self.guesses: + return None self.guesses.add(letter) correct = is_in_word(self.word, letter) if not correct: diff --git a/src/hangman/main.py b/src/hangman/main.py index a7afa14..2954809 100644 --- a/src/hangman/main.py +++ b/src/hangman/main.py @@ -3,7 +3,18 @@ @brief Command-line interface for Hangman. """ -from hangman.constants import MSG_LOSE, MSG_WIN +import os +import sys + +from hangman.constants import ( + ANSI_BOLD, + ANSI_RED, + ANSI_RESET, + MSG_LOSE, + MSG_WIN, + STAGES, + TITLE_ART, +) from hangman.game import Game, choose_word @@ -15,19 +26,58 @@ def say(message: str = "") -> None: print(message) +def _colorize(text: str, *codes: str) -> str: + """! + @brief Wrap text in ANSI color codes when the output supports colors. + @details Colors are skipped when stdout is not a terminal or NO_COLOR is set. + @param text Text to colorize. + @param codes ANSI escape codes to apply. + @return The (possibly) colorized text. + """ + if not sys.stdout.isatty() or "NO_COLOR" in os.environ: + return text + return "".join(codes) + text + ANSI_RESET + + +def show_title() -> None: + """! + @brief Show the colored ASCII-art title. + """ + if sys.stdout.isatty(): + os.system("") # Enables ANSI escape sequences in Windows terminals. + say(_colorize(TITLE_ART, ANSI_BOLD, ANSI_RED)) + + +def show(game: Game) -> None: + """! + @brief Show the gallows for the lives left and the word with blanks. + @param game The game to show. + """ + say(STAGES[max(game.lives, 0)]) + say(game.display()) + + def play(game: Game) -> None: """! @brief Ask for letters until the word is guessed or the lives run out. @param game The game to play. """ - say(game.display()) while not game.over: - letter = input("Guess a letter: ").strip() - if game.guess(letter): - say(f"Right, '{letter.lower()}' is in the word.") + show(game) + raw = input("Guess a letter: ").strip() + try: + result = game.guess(raw) + except ValueError: + say("Please enter a single letter.") + continue + letter = raw.lower() + if result is None: + say(f"You already guessed '{letter}'.") + elif result: + say(f"'{letter}' is in the word.") else: - say(f"Wrong, '{letter.lower()}' is not in the word. Lives left: {game.lives}") - say(game.display()) + say(f"'{letter}' is not in the word. You lose a life.") + show(game) say(MSG_WIN if game.won else MSG_LOSE + game.word) @@ -35,6 +85,7 @@ def main() -> None: """! @brief Entry point: start a game with a random word. """ + show_title() play(Game(choose_word())) diff --git a/tests/test_game.py b/tests/test_game.py index 898a9b9..811b4cc 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1,4 +1,6 @@ -from hangman.constants import MAX_LIVES, WORD_LIST +import pytest + +from hangman.constants import MAX_LIVES, STAGES, WORD_LIST from hangman.game import Game, choose_word, is_in_word @@ -66,3 +68,22 @@ def test_lost_when_out_of_lives(): assert not g.lost g.guess("y") assert g.lost and g.over and not g.won + + +def test_stages_cover_all_lives(): + assert len(STAGES) == MAX_LIVES + 1 + + +def test_repeated_guess_is_free(): + g = Game("cat") + g.guess("z") + assert g.guess("Z") is None + assert g.lives == MAX_LIVES - 1 + + +def test_invalid_guess(): + g = Game("cat") + for bad in ("", "ab", "1", " "): + with pytest.raises(ValueError): + g.guess(bad) + assert g.lives == MAX_LIVES diff --git a/tests/test_main.py b/tests/test_main.py index bfbce2a..2db7831 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,5 @@ from hangman import main as cli +from hangman.constants import ANSI_BOLD, ANSI_RED, ANSI_RESET, STAGES from hangman.game import Game @@ -11,24 +12,40 @@ def run(monkeypatch, capsys, word, inputs): def test_play_until_won(monkeypatch, capsys): out = run(monkeypatch, capsys, "hi", ["z", "h", "i"]) - assert out.splitlines() == [ - "_ _", - "Wrong, 'z' is not in the word. Lives left: 5", - "_ _", - "Right, 'h' is in the word.", - "h _", - "Right, 'i' is in the word.", - "h i", - "You win!", - ] + assert "'z' is not in the word. You lose a life." in out + assert "h i" in out + assert out.splitlines()[-1] == "You win!" -def test_repeated_letter_fills_all_blanks(monkeypatch, capsys): - out = run(monkeypatch, capsys, "aa", ["a"]) - assert out.splitlines()[-2:] == ["a a", "You win!"] +def test_repeated_and_invalid_input(monkeypatch, capsys): + out = run(monkeypatch, capsys, "hi", ["h", "h", "?", "ab", "i"]) + assert "You already guessed 'h'." in out + assert out.count("Please enter a single letter.") == 2 + assert out.splitlines()[-1] == "You win!" def test_play_until_lost(monkeypatch, capsys): out = run(monkeypatch, capsys, "a", list("bcdefg")) - assert "Lives left: 0" in out + assert STAGES[0] in out assert out.splitlines()[-1] == "You lose. The word was: a" + + +def test_gallows_grows_with_wrong_guesses(monkeypatch, capsys): + out = run(monkeypatch, capsys, "a", ["b", "a"]) + assert STAGES[6] in out and STAGES[5] in out + + +def test_title_plain_when_not_a_terminal(capsys): + cli.show_title() + out = capsys.readouterr().out + assert "" not in out + assert "|___/" in out + + +def test_title_colored_in_a_terminal(monkeypatch, capsys): + monkeypatch.setattr("sys.stdout.isatty", lambda: True, raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setattr(cli.os, "system", lambda _: 0) + cli.show_title() + out = capsys.readouterr().out + assert out.startswith(ANSI_BOLD + ANSI_RED) and ANSI_RESET in out