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:
@@ -0,0 +1,8 @@
|
||||
"""! @file
|
||||
@brief Allows `python -m snake_game`.
|
||||
"""
|
||||
|
||||
from snake_game.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user