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:
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user