Add Turtle screen, game loop and entry point (MIL-003)

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
Task: MIL-003#5
Task: MIL-003#6
This commit is contained in:
2026-10-13 15:50:45 +08:00
parent 896c807cc3
commit 02b537e538
9 changed files with 483 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
"""Runs the game with `python -m us_states_game`.
@file __main__.py
@brief Module entry point that calls main().
"""
from us_states_game.main import main
raise SystemExit(main())
+22
View File
@@ -37,3 +37,25 @@ NUMBER_OF_STATES = 50
## Text the player types to leave the game early.
EXIT_COMMAND = "exit"
## Title shown while playing; filled with the guessed count and the total.
TITLE_FORMAT = "{guessed}/{total} States Correct"
## Prompt of the answer dialog.
ANSWER_PROMPT = "What's another state's name?"
## Name under which the map image is registered as a Turtle shape.
MAP_SHAPE_NAME = str(MAP_IMAGE_PATH)
## Size in pixels of the map image, and so of the game window.
SCREEN_WIDTH = 725
SCREEN_HEIGHT = 491
## Font of the state names written on the map: family, size and style.
STATE_FONT = ("Courier", 8, "normal")
## Alignment of a state name written at its coordinates.
STATE_ALIGNMENT = "center"
## Name of the logger that reports mouse click coordinates.
LOGGER_NAME = "us_states_game"
+81
View File
@@ -0,0 +1,81 @@
"""Game loop of the U.S. States Game, independent of any screen technology.
@file game.py
@brief Guess loop and the end-of-game export of the states to learn.
The loop talks to the player through the `GameScreen` port, so it runs under
test with a fake screen and no display.
"""
from collections.abc import Sequence
from pathlib import Path
from typing import Protocol
from us_states_game.constants import (
ANSWER_PROMPT,
EXIT_COMMAND,
NUMBER_OF_STATES,
STATES_TO_LEARN_FILENAME,
TITLE_FORMAT,
)
from us_states_game.states import State, find_state, get_missing_states
from us_states_game.storage import save_states_to_learn
class GameScreen(Protocol):
"""What the game loop needs from the screen."""
def ask_state_name(self, title: str, prompt: str) -> str | None:
"""Ask the player for a state name.
@param title Title of the dialog, showing the progress.
@param prompt Question shown to the player.
@return The typed text, or None if the player cancelled.
"""
...
def write_state(self, state: State) -> None:
"""Write the name of a state at its coordinates on the map.
@param state The state to write.
"""
...
def play(screen: GameScreen, states: Sequence[State]) -> list[str]:
"""Run the guess loop until all states are guessed or the player exits.
@param screen Screen used to ask for answers and to write names.
@param states All states of the game.
@return Names of the states guessed, in guessing order.
"""
guessed_states: list[str] = []
while len(guessed_states) < NUMBER_OF_STATES:
title = TITLE_FORMAT.format(guessed=len(guessed_states), total=NUMBER_OF_STATES)
answer = screen.ask_state_name(title, ANSWER_PROMPT)
if answer is None or answer.strip().casefold() == EXIT_COMMAND:
break
state = find_state(answer, states)
if state is None or state.name in guessed_states:
continue
screen.write_state(state)
guessed_states.append(state.name)
return guessed_states
def run_game(
screen: GameScreen,
states: Sequence[State],
output_path: Path = Path(STATES_TO_LEARN_FILENAME),
) -> list[str]:
"""Play the game and save the states the player missed.
@param screen Screen used to ask for answers and to write names.
@param states All states of the game.
@param output_path File that receives the states to learn.
@return Names of the states not guessed.
"""
guessed_states = play(screen, states)
missing_states = get_missing_states(states, guessed_states)
save_states_to_learn(missing_states, output_path)
return missing_states
+43
View File
@@ -0,0 +1,43 @@
"""Entry point of the U.S. States Game and its composition root.
@file main.py
@brief Wires the Turtle screen, the states and the game loop together.
"""
import argparse
import logging
from collections.abc import Sequence
from us_states_game.game import run_game
from us_states_game.storage import load_states
from us_states_game.turtle_screen import TurtleScreen
def main(argv: Sequence[str] | None = None) -> int:
"""Play the game once.
@param argv Command line arguments; defaults to `sys.argv[1:]`.
@return Process exit code, 0 on success.
"""
parser = argparse.ArgumentParser(description="U.S. States Game")
parser.add_argument(
"--show-coordinates",
action="store_true",
help="log the coordinates of every mouse click on the map",
)
args = parser.parse_args(argv)
states = load_states()
screen = TurtleScreen()
if args.show_coordinates:
logging.basicConfig(level=logging.INFO)
screen.enable_click_coordinates()
try:
run_game(screen, states)
finally:
screen.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+72
View File
@@ -0,0 +1,72 @@
"""Turtle implementation of the game screen.
@file turtle_screen.py
@brief The only module that imports turtle; draws the map and asks questions.
"""
import logging
import turtle
from us_states_game.constants import (
LOGGER_NAME,
MAP_SHAPE_NAME,
SCREEN_HEIGHT,
SCREEN_TITLE,
SCREEN_WIDTH,
STATE_ALIGNMENT,
STATE_FONT,
)
from us_states_game.states import State
logger = logging.getLogger(LOGGER_NAME)
def get_mouse_click_coor(x: float, y: float) -> None:
"""Log the coordinates of a mouse click on the map.
@param x Horizontal coordinate of the click.
@param y Vertical coordinate of the click.
"""
logger.info("Clicked at x=%s, y=%s", x, y)
class TurtleScreen:
"""Game screen drawn with Turtle graphics; implements `GameScreen`."""
def __init__(self) -> None:
"""Open the window with the blank map."""
self.screen = turtle.Screen()
self.screen.setup(SCREEN_WIDTH, SCREEN_HEIGHT)
self.screen.title(SCREEN_TITLE)
self.screen.addshape(MAP_SHAPE_NAME)
self._map = turtle.Turtle()
self._map.shape(MAP_SHAPE_NAME)
self._pen = turtle.Turtle()
self._pen.hideturtle()
self._pen.penup()
def ask_state_name(self, title: str, prompt: str) -> str | None:
"""Ask the player for a state name in a text dialog.
@param title Title of the dialog.
@param prompt Question shown to the player.
@return The typed text, or None if the player cancelled.
"""
answer: str | None = self.screen.textinput(title, prompt)
return answer
def write_state(self, state: State) -> None:
"""Write the state name on the map at its coordinates.
@param state The state to write.
"""
self._pen.goto(state.x, state.y)
self._pen.write(state.name, align=STATE_ALIGNMENT, font=STATE_FONT)
def enable_click_coordinates(self) -> None:
"""Log the coordinates of every mouse click on the map."""
self.screen.onscreenclick(get_mouse_click_coor)
def close(self) -> None:
"""Close the window."""
self.screen.bye()