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
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
+77
View File
@@ -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"""
_ _
| | | | __ _ _ __ __ _ _ __ ___ __ _ _ __
| |_| |/ _` | '_ \ / _` | '_ ` _ \ / _` | '_ \
| _ | (_| | | | | (_| | | | | | | (_| | | | |
|_| |_|\__,_|_| |_|\__, |_| |_| |_|\__,_|_| |_|
|___/
"""
+8 -2
View File
@@ -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:
+58 -7
View File
@@ -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()))
+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
@@ -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
+31 -14
View File
@@ -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