Adopt the main flow and the entry point

Copied unchanged from the base: screen set-up, key bindings, the animation loop with game_is_on, the eating check, the game-over check, the wait for a click and the quiet exit when the window is closed. main imports food and scoreboard only when it runs, so importing it needs neither turtle nor tkinter.

Task: MIL-002#5
Closes #12
This commit is contained in:
2026-10-08 18:31:06 +08:00
parent ca65ba7830
commit 6b0ee0cc0d
3 changed files with 560 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
"""! @file
@brief Allows `python -m snake_game`.
"""
from snake_game.main import main
if __name__ == "__main__":
main()
+210
View File
@@ -0,0 +1,210 @@
"""! @file
@brief The main flow of the game: set up the screen, draw the snake and run it.
The flow follows the lectures "Screen Setup and Creating a Snake Body",
"Animating the Snake Segments on Screen" and "Controlling the Snake with
Keypresses", and the day-21 steps in which the snake eats food, the score rises and
the game ends.
"""
import time
from collections.abc import Callable
from typing import Protocol
from snake_game.constants import (
FOOD_COLLISION_DISTANCE,
REFRESH_DELAY_SECONDS,
SCREEN_BACKGROUND_COLOR,
SCREEN_HEIGHT,
SCREEN_TITLE,
SCREEN_WIDTH,
)
from snake_game.snake import Snake
class ScreenLike(Protocol):
"""! @brief What the helper functions need from the screen.
A `turtle.Screen` fits this description, and so does a fake in a test.
"""
def setup(self, width: float, height: float) -> None:
"""! @brief Set the size of the window.
@param width Width in pixels.
@param height Height in pixels.
"""
...
def bgcolor(self, color: str, /) -> None:
"""! @brief Set the background colour.
@param color Name of the colour, for example `black`.
"""
...
def title(self, titlestring: str, /) -> None:
"""! @brief Set the title of the window.
@param titlestring The title.
"""
...
def update(self) -> None:
"""! @brief Draw everything that has changed since the last update."""
...
def listen(self) -> None:
"""! @brief Make the screen receive the key presses."""
...
def onkey(self, fun: Callable[[], object], key: str) -> None:
"""! @brief Call a function when a key is pressed.
@param fun The function to call.
@param key Name of the key, for example `Up`.
"""
...
class FoodLike(Protocol):
"""! @brief What `eat_food_if_close` needs from the food.
A `Food` fits this description, and so does a fake in a test.
"""
def refresh(self) -> None:
"""! @brief Move the food to a new random place."""
...
def position(self) -> tuple[float, float]:
"""! @brief Tell where the food is.
@return The x and y coordinates.
"""
...
class ScoreboardLike(Protocol):
"""! @brief What the eating and game-over checks need from the scoreboard.
A `Scoreboard` fits this description, and so does a fake in a test.
"""
def increase_score(self) -> None:
"""! @brief Add 1 to the score and write it."""
...
def game_over(self) -> None:
"""! @brief Write the game-over text."""
...
def configure_screen(screen: ScreenLike) -> None:
"""! @brief Give the screen the size, background colour and title of the game.
@param screen The screen to set up.
"""
screen.setup(width=SCREEN_WIDTH, height=SCREEN_HEIGHT)
screen.bgcolor(SCREEN_BACKGROUND_COLOR)
screen.title(SCREEN_TITLE)
def bind_keys(screen: ScreenLike, snake: Snake) -> None:
"""! @brief Turn the snake with the arrow keys.
@param screen The screen that receives the key presses.
@param snake The snake to steer.
"""
screen.listen()
screen.onkey(snake.up, "Up")
screen.onkey(snake.down, "Down")
screen.onkey(snake.left, "Left")
screen.onkey(snake.right, "Right")
def play_frame(screen: ScreenLike, snake: Snake) -> None:
"""! @brief Show the snake, wait for `REFRESH_DELAY_SECONDS`, then move it.
This is one pass of the animation loop. The screen is updated by hand because
automatic drawing is off, so the whole snake appears at once.
@param screen The screen to update.
@param snake The snake to move.
"""
screen.update()
time.sleep(REFRESH_DELAY_SECONDS)
snake.move()
def eat_food_if_close(snake: Snake, food: FoodLike, scoreboard: ScoreboardLike) -> None:
"""! @brief Let the snake eat the food when the head is close enough to it.
Eating moves the food to a new place, makes the snake one segment longer and
adds 1 to the score. The head must be closer than `FOOD_COLLISION_DISTANCE`.
@param snake The snake.
@param food The food.
@param scoreboard The scoreboard that shows the score.
"""
if snake.head.distance(food.position()) < FOOD_COLLISION_DISTANCE:
food.refresh()
snake.extend()
scoreboard.increase_score()
def end_game_if_over(snake: Snake, scoreboard: ScoreboardLike) -> bool:
"""! @brief End the game when the head passes the wall or touches the tail.
When the game is over the scoreboard writes the game-over text.
@param snake The snake.
@param scoreboard The scoreboard that writes the game-over text.
@return True when the game is over.
"""
if snake.hits_wall() or snake.hits_tail():
scoreboard.game_over()
return True
return False
def main() -> None:
"""! @brief Open the game window and run the snake until the window is closed.
Closing the window during the animation loop makes `screen.update()` raise
`tkinter.TclError` ("invalid command name"), and `turtle` raises
`turtle.Terminator` in some other calls once its window is gone. Both mean
"the player closed the window", so the game ends quietly with exit code 0.
When the snake passes the wall or touches its tail the loop ends, the screen is
updated so that the game-over text shows, and `screen.exitonclick()` waits for a
click.
`turtle` and `tkinter` are imported here and not at the top of the module, and
so are `food` and `scoreboard`, whose classes inherit from `Turtle`: importing
this module needs neither a display nor `tkinter`.
"""
from tkinter import TclError
from turtle import Screen, Terminator
from snake_game.food import Food
from snake_game.scoreboard import Scoreboard
screen = Screen()
configure_screen(screen)
screen.tracer(0)
snake = Snake()
food = Food()
scoreboard = Scoreboard()
bind_keys(screen, snake)
game_is_on = True
try:
while game_is_on:
play_frame(screen, snake)
eat_food_if_close(snake, food, scoreboard)
if end_game_if_over(snake, scoreboard):
game_is_on = False
screen.update()
screen.exitonclick()
except (Terminator, TclError):
return # the window was closed: there is nothing left to do
+342
View File
@@ -0,0 +1,342 @@
"""Tests of the main flow, with fakes instead of a real screen."""
import pytest
from fakes import (
FakeFood,
FakeScoreboard,
FakeScreen,
FakeTclError,
FakeTerminatorError,
imports_turtle_or_tkinter,
install_fake_turtle,
make_snake,
script_randint,
)
from snake_game import constants
from snake_game.main import (
bind_keys,
configure_screen,
eat_food_if_close,
end_game_if_over,
main,
play_frame,
)
ARROW_KEYS = ("Up", "Down", "Left", "Right")
@pytest.fixture(autouse=True)
def food_far_away(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make the food appear at the top right corner of the wall, far from the snake."""
script_randint(monkeypatch, [])
@pytest.fixture
def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
"""Replace `time.sleep` for one test and collect the waits requested."""
waited: list[float] = []
monkeypatch.setattr("time.sleep", waited.append)
return waited
def test_configure_screen_sets_size_background_and_title_and_nothing_else() -> None:
screen = FakeScreen()
configure_screen(screen)
assert screen.calls == [
("setup", (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT)),
("bgcolor", (constants.SCREEN_BACKGROUND_COLOR,)),
("title", (constants.SCREEN_TITLE,)),
]
def test_bind_keys_listens_and_binds_the_four_arrow_keys_to_the_snake() -> None:
screen = FakeScreen()
snake, _ = make_snake()
bind_keys(screen, snake)
assert screen.call_names() == ["listen", "onkey", "onkey", "onkey", "onkey"]
assert screen.bindings == {
"Up": snake.up,
"Down": snake.down,
"Left": snake.left,
"Right": snake.right,
}
@pytest.mark.parametrize(
("key", "degrees"),
[("Up", constants.UP), ("Down", constants.DOWN), ("Right", constants.RIGHT)],
)
def test_pressing_a_bound_key_turns_the_head(key: str, degrees: int) -> None:
screen = FakeScreen()
snake, _ = make_snake()
bind_keys(screen, snake)
screen.bindings[key]()
assert snake.head.heading() == degrees
def test_play_frame_updates_the_screen_then_waits_then_moves(
monkeypatch: pytest.MonkeyPatch,
) -> None:
screen = FakeScreen(frames_before_close=1)
snake, created = make_snake()
seen: list[tuple[float, list[str], list[str]]] = []
def record_sleep(seconds: float) -> None:
seen.append((seconds, screen.call_names(), created[0].call_names()))
monkeypatch.setattr("time.sleep", record_sleep)
play_frame(screen, snake)
assert len(seen) == 1
seconds, screen_calls_at_sleep, head_calls_at_sleep = seen[0]
assert seconds == constants.REFRESH_DELAY_SECONDS
assert screen_calls_at_sleep == ["update"]
assert "forward" not in head_calls_at_sleep
assert "forward" in created[0].call_names()
def test_main_sets_up_the_screen_turns_off_drawing_and_binds_the_keys(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
_, screens = install_fake_turtle(monkeypatch)
main()
assert len(screens) == 1
assert screens[0].call_names()[:9] == [
"setup",
"bgcolor",
"title",
"tracer",
"listen",
"onkey",
"onkey",
"onkey",
"onkey",
]
assert ("tracer", (0,)) in screens[0].calls
assert sorted(screens[0].bindings) == sorted(ARROW_KEYS)
def test_main_draws_the_snake_before_the_first_frame(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, _ = install_fake_turtle(monkeypatch)
main()
snake_segments = turtles[: len(constants.STARTING_POSITIONS)]
assert len(turtles) == len(constants.STARTING_POSITIONS) + 2 # food, scoreboard
assert all(
("goto", position) in turtle.calls
for turtle, position in zip(
snake_segments, constants.STARTING_POSITIONS, strict=True
)
)
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError])
def test_main_ends_quietly_when_the_window_is_closed(
monkeypatch: pytest.MonkeyPatch,
sleeps: list[float],
closing_error: type[Exception],
) -> None:
turtles, screens = install_fake_turtle(
monkeypatch, frames_before_close=3, closing_error=closing_error
)
main()
assert screens[0].call_names().count("update") == 3 + 1
assert turtles[0].call_names().count("forward") == 3
assert sleeps == [constants.REFRESH_DELAY_SECONDS] * 3
assert "exitonclick" not in screens[0].call_names()
def test_main_lets_other_errors_through(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
install_fake_turtle(monkeypatch, closing_error=KeyError)
with pytest.raises(KeyError):
main()
def test_the_snake_eats_food_closer_than_the_eating_distance() -> None:
snake, created = make_snake()
food = FakeFood(constants.FOOD_COLLISION_DISTANCE - 1, 0)
scoreboard = FakeScoreboard()
eat_food_if_close(snake, food, scoreboard)
assert food.refreshes == 1
assert scoreboard.increases == 1
assert len(snake.segments) == len(created) == len(constants.STARTING_POSITIONS) + 1
@pytest.mark.parametrize(
"place",
[
(constants.FOOD_COLLISION_DISTANCE, 0),
(0, -constants.FOOD_COLLISION_DISTANCE),
(2 * constants.FOOD_COLLISION_DISTANCE, 0),
(200, 200),
],
)
def test_the_snake_does_not_eat_food_at_or_beyond_the_eating_distance(
place: tuple[int, int],
) -> None:
snake, _ = make_snake()
food = FakeFood(*place)
scoreboard = FakeScoreboard()
eat_food_if_close(snake, food, scoreboard)
assert food.refreshes == 0
assert scoreboard.increases == 0
assert len(snake.segments) == len(constants.STARTING_POSITIONS)
def test_the_snake_eats_food_that_is_close_diagonally() -> None:
snake, _ = make_snake()
food = FakeFood(10, 10) # a distance of about 14.1
scoreboard = FakeScoreboard()
eat_food_if_close(snake, food, scoreboard)
assert scoreboard.increases == 1
def test_main_lets_the_snake_eat_the_food_that_lies_on_its_way(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
script_randint(monkeypatch, [20, 0]) # the first food lies one move ahead
turtles, _ = install_fake_turtle(monkeypatch, frames_before_close=1)
main()
snake_head, _, _, food, scoreboard, new_segment = turtles
assert snake_head.position() == (20, 0)
assert [call for call in food.calls if call[0] == "goto"] == [
("goto", (20, 0)),
("goto", (constants.WALL_LIMIT, constants.WALL_LIMIT)),
]
assert scoreboard.calls[-1][1][0] == "Score: 1"
assert new_segment.position() == (-20, 0) # where the last segment was
def test_main_does_not_raise_the_score_when_the_food_is_far_away(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, _ = install_fake_turtle(monkeypatch, frames_before_close=3)
main()
scoreboard = turtles[4]
assert scoreboard.calls[-1][1][0] == "Score: 0"
assert len(turtles) == len(constants.STARTING_POSITIONS) + 2
def test_the_game_is_not_over_while_the_snake_is_inside_and_clear_of_its_tail() -> None:
snake, _ = make_snake()
scoreboard = FakeScoreboard()
assert not end_game_if_over(snake, scoreboard)
assert scoreboard.game_overs == 0
def test_the_game_is_over_when_the_head_passes_the_wall() -> None:
snake, _ = make_snake()
snake.head.goto(constants.WALL_LIMIT + 1, 0)
scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard)
assert scoreboard.game_overs == 1
def test_the_game_is_over_when_the_head_touches_the_tail() -> None:
snake, created = make_snake()
created[1].goto(3, 4)
scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard)
assert scoreboard.game_overs == 1
def test_main_ends_the_game_at_the_wall_shows_game_over_and_waits_for_a_click(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
main()
snake_head, scoreboard = turtles[0], turtles[4]
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1
assert snake_head.call_names().count("forward") == moves_to_pass_the_wall
assert snake_head.position() == (
constants.MOVE_DISTANCE * moves_to_pass_the_wall,
0,
)
assert scoreboard.calls[-1] == (
"write",
("GAME OVER", "center", ("Arial", 24, "normal")),
)
assert screens[0].call_names()[-2:] == ["update", "exitonclick"]
assert screens[0].call_names().count("exitonclick") == 1
assert sleeps == [constants.REFRESH_DELAY_SECONDS] * moves_to_pass_the_wall
def test_main_ends_the_game_when_the_snake_touches_its_tail(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
monkeypatch.setattr("snake_game.snake.Snake.hits_tail", lambda self: True)
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
main()
assert turtles[0].call_names().count("forward") == 1
assert turtles[4].calls[-1][1][0] == "GAME OVER"
assert screens[0].call_names()[-1] == "exitonclick"
def test_main_does_not_move_the_snake_after_game_over(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
main()
updates = screens[0].call_names().count("update")
moves = turtles[0].call_names().count("forward")
assert updates == moves + 1 # one update per frame, and one for the last text
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError])
def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
monkeypatch: pytest.MonkeyPatch,
sleeps: list[float],
closing_error: type[Exception],
) -> None:
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1
turtles, screens = install_fake_turtle(
monkeypatch,
frames_before_close=moves_to_pass_the_wall,
closing_error=closing_error,
)
main()
assert turtles[4].calls[-1][1][0] == "GAME OVER"
assert "exitonclick" not in screens[0].call_names()
def test_importing_the_main_module_does_not_import_turtle_or_tkinter() -> None:
assert not imports_turtle_or_tkinter("snake_game.main")