Improving the user experience
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user