step 5
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s

Improving the user experience
This commit is contained in:
2026-10-04 12:46:03 +08:00
parent a76df46b79
commit 16cd9d42b9
6 changed files with 222 additions and 30 deletions
+26 -6
View File
@@ -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 1. Picking a random word and checking answers
2. Replacing blanks with guesses 2. Replacing blanks with guesses
3. Checking if the player has won 3. Checking if the player has won
4. Keeping track of the player's lives *(this step)* 4. Keeping track of the player's lives
5. Improving the user experience 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 ## 📋 Requirements
+77
View File
@@ -26,7 +26,84 @@ WORD_LIST = [
"tortoise", "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. ## Message shown when the player wins.
MSG_WIN = "You win!" MSG_WIN = "You win!"
## Message shown when the player loses (the word is appended). ## Message shown when the player loses (the word is appended).
MSG_LOSE = "You lose. The word was: " 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"""
_ _
| | | | __ _ _ __ __ _ _ __ ___ __ _ _ __
| |_| |/ _` | '_ \ / _` | '_ ` _ \ / _` | '_ \
| _ | (_| | | | | (_| | | | | | | (_| | | | |
|_| |_|\__,_|_| |_|\__, |_| |_| |_|\__,_|_| |_|
|___/
"""
+8 -2
View File
@@ -45,13 +45,19 @@ class Game:
## Letters guessed so far. ## Letters guessed so far.
self.guesses: set[str] = set() 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. @brief Process a guess; a wrong guess costs a life.
@param letter A single letter. @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() 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) self.guesses.add(letter)
correct = is_in_word(self.word, letter) correct = is_in_word(self.word, letter)
if not correct: if not correct:
+58 -7
View File
@@ -3,7 +3,18 @@
@brief Command-line interface for Hangman. @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 from hangman.game import Game, choose_word
@@ -15,19 +26,58 @@ def say(message: str = "") -> None:
print(message) 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: def play(game: Game) -> None:
"""! """!
@brief Ask for letters until the word is guessed or the lives run out. @brief Ask for letters until the word is guessed or the lives run out.
@param game The game to play. @param game The game to play.
""" """
say(game.display())
while not game.over: while not game.over:
letter = input("Guess a letter: ").strip() show(game)
if game.guess(letter): raw = input("Guess a letter: ").strip()
say(f"Right, '{letter.lower()}' is in the word.") 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: else:
say(f"Wrong, '{letter.lower()}' is not in the word. Lives left: {game.lives}") say(f"'{letter}' is not in the word. You lose a life.")
say(game.display()) show(game)
say(MSG_WIN if game.won else MSG_LOSE + game.word) 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. @brief Entry point: start a game with a random word.
""" """
show_title()
play(Game(choose_word())) play(Game(choose_word()))
+22 -1
View File
@@ -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 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 assert not g.lost
g.guess("y") g.guess("y")
assert g.lost and g.over and not g.won 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
+31 -14
View File
@@ -1,4 +1,5 @@
from hangman import main as cli from hangman import main as cli
from hangman.constants import ANSI_BOLD, ANSI_RED, ANSI_RESET, STAGES
from hangman.game import Game from hangman.game import Game
@@ -11,24 +12,40 @@ def run(monkeypatch, capsys, word, inputs):
def test_play_until_won(monkeypatch, capsys): def test_play_until_won(monkeypatch, capsys):
out = run(monkeypatch, capsys, "hi", ["z", "h", "i"]) out = run(monkeypatch, capsys, "hi", ["z", "h", "i"])
assert out.splitlines() == [ assert "'z' is not in the word. You lose a life." in out
"_ _", assert "h i" in out
"Wrong, 'z' is not in the word. Lives left: 5", assert out.splitlines()[-1] == "You win!"
"_ _",
"Right, 'h' is in the word.",
"h _",
"Right, 'i' is in the word.",
"h i",
"You win!",
]
def test_repeated_letter_fills_all_blanks(monkeypatch, capsys): def test_repeated_and_invalid_input(monkeypatch, capsys):
out = run(monkeypatch, capsys, "aa", ["a"]) out = run(monkeypatch, capsys, "hi", ["h", "h", "?", "ab", "i"])
assert out.splitlines()[-2:] == ["a a", "You win!"] 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): def test_play_until_lost(monkeypatch, capsys):
out = run(monkeypatch, capsys, "a", list("bcdefg")) 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" 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