Merge pull request 'MIL-003: Continuous play and high score' (#37) from mil-003-continuous-play-and-high-score into main
This commit was merged in pull request #37.
This commit is contained in:
@@ -19,11 +19,12 @@ This repository continues
|
||||
and starts from its finished game; `docs/project-plan.md` tells how the work is
|
||||
split into milestones.
|
||||
|
||||
> **Status:** in progress. The finished game of day 21 is adopted and runs: the
|
||||
> snake moves, eats and grows, the score is shown and the game ends at the wall
|
||||
> or at the tail. Continuous play and the high score (milestone 3), the high
|
||||
> score file (milestone 4) and the wiki (milestone 5) follow. The sections below
|
||||
> describe the game as it is now; they are updated in each milestone.
|
||||
> **Status:** in progress. The game of day 21 is adopted and now follows the
|
||||
> day-24 lectures on continuous play: the scoreboard shows the score and the high
|
||||
> score, and a collision resets the snake and the score instead of ending the
|
||||
> game. The high score is kept in memory only; the high score file (milestone 4)
|
||||
> and the wiki (milestone 5) follow. The sections below describe the game as it
|
||||
> is now; they are updated in each milestone.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -104,11 +105,16 @@ closer to it than 15 pixels the snake eats it: the food moves to a new random
|
||||
place, the snake grows by one segment, and the score at the top of the window
|
||||
goes up by 1.
|
||||
|
||||
The game is over when the head passes the wall (more than 280 pixels from the
|
||||
centre on any side) or touches the tail. The snake stops, `GAME OVER` appears in
|
||||
the middle of the window, and a click on the window closes it. You can also close
|
||||
the window with its close button at any time. (Milestone 3 replaces this with
|
||||
continuous play and a high score.)
|
||||
The scoreboard at the top of the window reads `Score: 0 High Score: 0`. The score
|
||||
counts the foods eaten since the last reset; the high score is the best score so
|
||||
far.
|
||||
|
||||
The game does not end. When the head passes the wall (more than 280 pixels from
|
||||
the centre on any side) or touches the tail, the old snake is sent off the
|
||||
screen, a new snake of three segments appears in the middle, the score goes back
|
||||
to 0, and the high score is raised if the score was higher. Close the window with
|
||||
its close button when you have had enough. The high score is lost when the game
|
||||
is closed until milestone 4 stores it in a file.
|
||||
|
||||
## Run the tests
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
|
||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 |
|
||||
|
||||
## Languages
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# RC-011: Code review of MIL-003 (continuous play and high score)
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-011 |
|
||||
| CrossReference | [MIL-003], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-13 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [8c81988] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: the source and tests that [MIL-003] delivers (continuous play and high score)
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review of the files changed on the branch of the milestone
|
||||
- Language and domain: n/a
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the assistant also wrote the code)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | `Snake.reset`, `Scoreboard.reset`, `reset_if_collision`, `high_score` and `SEGMENT_PARKING_POSITION` follow PEP 8; `reset` and `high_score` are the lecture's names. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose; `HIGH_SCORE_LABEL` and `SEGMENT_PARKING_POSITION` say what they hold. |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` exit 0; no `noqa`. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy` strict reports no issues in 13 files; every new def is annotated with `-> None` or `-> bool`. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added; the one handler in `main.main` is unchanged. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default argument; no shadowed builtin. |
|
||||
| 7 | Files, locks and connections are managed with context managers | N-A | No file is opened in this milestone (MIL-004). |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | `reset`, `reset_if_collision` and the new constants have Doxygen comments; `doxygen Doxyfile` ends with 0 warnings. |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` and no logging. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No Design Class Diagram exists (deviation recorded in RC-010 and the pull request); the change adds `reset` to the base classes as the lecture does. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 149 tests run without a display; new tests cover higher, equal and lower score on `reset`, the parking of old segments, the new head, the forgotten direction, and `main` going on after the wall and the tail. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `strict = true`; `ScoreboardLike` stays a narrow protocol (`increase_score`, `reset`); no `Any`. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []` is unchanged. |
|
||||
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The label, the parking place and the other values are defined once in `constants.py`; `GAME_OVER_*` is removed, not duplicated. |
|
||||
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `snake.py` and `main.py` still import no `turtle` or `tkinter` at module level (tested); no import cycle. |
|
||||
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `Snake.reset` reuses `create_snake`, so the starting positions are one piece of knowledge; `main` depends on the `ScoreboardLike` protocol. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — all Mandatory criteria pass or are not applicable. Assessed on 2026-10-13 by the assistant that wrote the code, as stand-in for S01 in autonomous mode. Mode: autonomous, not independent. The pull request is the second look.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Challenge this `Go` in the run summary or on the pull request if the stand-in decision is not wanted | S01 | 2026-10-20 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-003]: ../../milestones/mil-003-continuous-play-and-high-score.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[8c81988]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/024-snake/commit/8c8198827bfc69c75a4f8ff8a393376d073e94c1
|
||||
@@ -117,8 +117,14 @@ SCOREBOARD_FONT: Final[tuple[str, int, str]] = ("Arial", 24, "normal")
|
||||
## @brief The head touches the tail when it is closer to a segment than this, in pixels.
|
||||
TAIL_COLLISION_DISTANCE: Final[int] = 10
|
||||
|
||||
## @brief Text shown when the game is over.
|
||||
GAME_OVER_TEXT: Final[str] = "GAME OVER"
|
||||
## @brief Text in front of the high score on the scoreboard.
|
||||
#
|
||||
# The scoreboard writes `Score: <score> High Score: <high_score>`: the score
|
||||
# label, the score, a space, this label and the high score.
|
||||
HIGH_SCORE_LABEL: Final[str] = "High Score: "
|
||||
|
||||
## @brief Where the game-over text is written: the centre of the screen.
|
||||
GAME_OVER_POSITION: Final[tuple[int, int]] = (0, 0)
|
||||
## @brief Where a segment of the old snake is sent when the snake is reset.
|
||||
#
|
||||
# The place is far outside the 600 by 600 screen, so the old segments are out
|
||||
# of sight, as the day-24 lecture does it.
|
||||
SEGMENT_PARKING_POSITION: Final[tuple[int, int]] = (1000, 1000)
|
||||
|
||||
+246
-248
@@ -1,248 +1,246 @@
|
||||
"""! @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 TYPE_CHECKING, Protocol
|
||||
|
||||
from snake_game.constants import (
|
||||
FOOD_COLLISION_DISTANCE,
|
||||
HEAD_COLOR,
|
||||
HEAD_OUTLINE,
|
||||
HEAD_SHAPE,
|
||||
MOUTH_COLOR,
|
||||
MOUTH_OUTLINE,
|
||||
REFRESH_DELAY_SECONDS,
|
||||
SCREEN_BACKGROUND_COLOR,
|
||||
SCREEN_HEIGHT,
|
||||
SCREEN_TITLE,
|
||||
SCREEN_WIDTH,
|
||||
)
|
||||
from snake_game.snake import Snake
|
||||
|
||||
if TYPE_CHECKING: # for the annotation only: nothing is imported when running
|
||||
from turtle import Shape
|
||||
|
||||
|
||||
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 register_shape(self, name: str, shape: "Shape", /) -> None:
|
||||
"""! @brief Make a shape known under a name, so that a turtle can use it.
|
||||
|
||||
@param name The name of the shape.
|
||||
@param shape The shape to register.
|
||||
"""
|
||||
...
|
||||
|
||||
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 register_head_shape(screen: ScreenLike) -> None:
|
||||
"""! @brief Build the shape of the head and register it as `HEAD_SHAPE`.
|
||||
|
||||
The shape is compound: the head outline in `HEAD_COLOR` and, on top of it, the
|
||||
mouth in `MOUTH_COLOR`. Turtle shape coordinates have their front in the
|
||||
positive y direction, which is what the constants use, so the mouth turns with
|
||||
the head and no conversion is needed.
|
||||
|
||||
`turtle.Shape` is imported here and not at the top of the module, so that
|
||||
importing this module needs neither a display nor `tkinter`.
|
||||
|
||||
@param screen The screen that learns the shape.
|
||||
"""
|
||||
from turtle import Shape
|
||||
|
||||
shape = Shape("compound")
|
||||
shape.addcomponent(HEAD_OUTLINE, HEAD_COLOR)
|
||||
shape.addcomponent(MOUTH_OUTLINE, MOUTH_COLOR)
|
||||
screen.register_shape(HEAD_SHAPE, shape)
|
||||
|
||||
|
||||
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)
|
||||
register_head_shape(screen)
|
||||
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
|
||||
"""! @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", the day-21 steps in which the snake eats food and the score rises,
|
||||
and the day-24 steps in which a collision resets the game and it goes on.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from snake_game.constants import (
|
||||
FOOD_COLLISION_DISTANCE,
|
||||
HEAD_COLOR,
|
||||
HEAD_OUTLINE,
|
||||
HEAD_SHAPE,
|
||||
MOUTH_COLOR,
|
||||
MOUTH_OUTLINE,
|
||||
REFRESH_DELAY_SECONDS,
|
||||
SCREEN_BACKGROUND_COLOR,
|
||||
SCREEN_HEIGHT,
|
||||
SCREEN_TITLE,
|
||||
SCREEN_WIDTH,
|
||||
)
|
||||
from snake_game.snake import Snake
|
||||
|
||||
if TYPE_CHECKING: # for the annotation only: nothing is imported when running
|
||||
from turtle import Shape
|
||||
|
||||
|
||||
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 register_shape(self, name: str, shape: "Shape", /) -> None:
|
||||
"""! @brief Make a shape known under a name, so that a turtle can use it.
|
||||
|
||||
@param name The name of the shape.
|
||||
@param shape The shape to register.
|
||||
"""
|
||||
...
|
||||
|
||||
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 reset 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 reset(self) -> None:
|
||||
"""! @brief Keep the best score and start the score again at 0."""
|
||||
...
|
||||
|
||||
|
||||
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 register_head_shape(screen: ScreenLike) -> None:
|
||||
"""! @brief Build the shape of the head and register it as `HEAD_SHAPE`.
|
||||
|
||||
The shape is compound: the head outline in `HEAD_COLOR` and, on top of it, the
|
||||
mouth in `MOUTH_COLOR`. Turtle shape coordinates have their front in the
|
||||
positive y direction, which is what the constants use, so the mouth turns with
|
||||
the head and no conversion is needed.
|
||||
|
||||
`turtle.Shape` is imported here and not at the top of the module, so that
|
||||
importing this module needs neither a display nor `tkinter`.
|
||||
|
||||
@param screen The screen that learns the shape.
|
||||
"""
|
||||
from turtle import Shape
|
||||
|
||||
shape = Shape("compound")
|
||||
shape.addcomponent(HEAD_OUTLINE, HEAD_COLOR)
|
||||
shape.addcomponent(MOUTH_OUTLINE, MOUTH_COLOR)
|
||||
screen.register_shape(HEAD_SHAPE, shape)
|
||||
|
||||
|
||||
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 reset_if_collision(snake: Snake, scoreboard: ScoreboardLike) -> bool:
|
||||
"""! @brief Start again when the head passes the wall or touches the tail.
|
||||
|
||||
The game does not end: the scoreboard keeps the best score and starts the
|
||||
score again, and the snake is made again at the starting positions.
|
||||
|
||||
@param snake The snake.
|
||||
@param scoreboard The scoreboard that keeps the high score.
|
||||
@return True when the snake and the score were reset.
|
||||
"""
|
||||
if snake.hits_wall() or snake.hits_tail():
|
||||
scoreboard.reset()
|
||||
snake.reset()
|
||||
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 score and the snake are
|
||||
reset and the game goes on, so the loop runs until the window is closed.
|
||||
|
||||
`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)
|
||||
register_head_shape(screen)
|
||||
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)
|
||||
reset_if_collision(snake, scoreboard)
|
||||
except (Terminator, TclError):
|
||||
return # the window was closed: there is nothing left to do
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
"""! @file
|
||||
@brief The scoreboard: the text at the top of the screen that shows the score.
|
||||
|
||||
The class follows the day-21 lecture on inheritance: `Scoreboard` is a `Turtle`
|
||||
that writes text. Like `food`, this module needs `turtle` when it is imported;
|
||||
the game imports it only when it starts, and the tests install a fake `turtle`
|
||||
module first.
|
||||
"""
|
||||
|
||||
from turtle import Turtle
|
||||
|
||||
from snake_game.constants import (
|
||||
GAME_OVER_POSITION,
|
||||
GAME_OVER_TEXT,
|
||||
SCORE_LABEL,
|
||||
SCOREBOARD_ALIGNMENT,
|
||||
SCOREBOARD_COLOR,
|
||||
SCOREBOARD_FONT,
|
||||
SCOREBOARD_POSITION,
|
||||
)
|
||||
|
||||
|
||||
class Scoreboard(Turtle):
|
||||
"""! @brief Shows the score as text at the top centre of the screen."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""! @brief Set the scoreboard up and write the score 0."""
|
||||
super().__init__()
|
||||
## @brief The number of foods the snake has eaten in this game.
|
||||
self.score = 0
|
||||
self.color(SCOREBOARD_COLOR)
|
||||
self.penup()
|
||||
self.hideturtle()
|
||||
self.goto(SCOREBOARD_POSITION)
|
||||
self.update_scoreboard()
|
||||
|
||||
def update_scoreboard(self) -> None:
|
||||
"""! @brief Wipe the old text and write the label and the score."""
|
||||
self.clear()
|
||||
self.write(
|
||||
f"{SCORE_LABEL}{self.score}",
|
||||
align=SCOREBOARD_ALIGNMENT,
|
||||
font=SCOREBOARD_FONT,
|
||||
)
|
||||
|
||||
def increase_score(self) -> None:
|
||||
"""! @brief Add 1 to the score and write it."""
|
||||
self.score += 1
|
||||
self.update_scoreboard()
|
||||
|
||||
def game_over(self) -> None:
|
||||
"""! @brief Write the game-over text at the centre of the screen.
|
||||
|
||||
The score stays where it is: the old text is not wiped. Automatic drawing is
|
||||
off in the game, so the screen must be updated after this call.
|
||||
"""
|
||||
self.goto(GAME_OVER_POSITION)
|
||||
self.write(GAME_OVER_TEXT, align=SCOREBOARD_ALIGNMENT, font=SCOREBOARD_FONT)
|
||||
"""! @file
|
||||
@brief The scoreboard: the text at the top of the screen that shows the score.
|
||||
|
||||
The class follows the day-21 lecture on inheritance: `Scoreboard` is a `Turtle`
|
||||
that writes text. Like `food`, this module needs `turtle` when it is imported;
|
||||
the game imports it only when it starts, and the tests install a fake `turtle`
|
||||
module first.
|
||||
"""
|
||||
|
||||
from turtle import Turtle
|
||||
|
||||
from snake_game.constants import (
|
||||
HIGH_SCORE_LABEL,
|
||||
SCORE_LABEL,
|
||||
SCOREBOARD_ALIGNMENT,
|
||||
SCOREBOARD_COLOR,
|
||||
SCOREBOARD_FONT,
|
||||
SCOREBOARD_POSITION,
|
||||
)
|
||||
|
||||
|
||||
class Scoreboard(Turtle):
|
||||
"""! @brief Shows the score as text at the top centre of the screen."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""! @brief Set the scoreboard up and write the score 0."""
|
||||
super().__init__()
|
||||
## @brief The number of foods the snake has eaten since the last reset.
|
||||
self.score = 0
|
||||
## @brief The largest score reached so far.
|
||||
self.high_score = 0
|
||||
self.color(SCOREBOARD_COLOR)
|
||||
self.penup()
|
||||
self.hideturtle()
|
||||
self.goto(SCOREBOARD_POSITION)
|
||||
self.update_scoreboard()
|
||||
|
||||
def update_scoreboard(self) -> None:
|
||||
"""! @brief Wipe the old text and write the score and the high score."""
|
||||
self.clear()
|
||||
self.write(
|
||||
f"{SCORE_LABEL}{self.score} {HIGH_SCORE_LABEL}{self.high_score}",
|
||||
align=SCOREBOARD_ALIGNMENT,
|
||||
font=SCOREBOARD_FONT,
|
||||
)
|
||||
|
||||
def increase_score(self) -> None:
|
||||
"""! @brief Add 1 to the score and write it."""
|
||||
self.score += 1
|
||||
self.update_scoreboard()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""! @brief Keep the best score, start a new game and write the scoreboard.
|
||||
|
||||
When the score is higher than the high score, it becomes the new high
|
||||
score. The score then goes back to 0.
|
||||
"""
|
||||
if self.score > self.high_score:
|
||||
self.high_score = self.score
|
||||
self.score = 0
|
||||
self.update_scoreboard()
|
||||
|
||||
+264
-248
@@ -1,248 +1,264 @@
|
||||
"""! @file
|
||||
@brief The snake: a list of segments that the game draws and moves.
|
||||
|
||||
The class follows the lectures "Create a Snake Class & Move to OOP", "Animating
|
||||
the Snake Segments on Screen" and "Controlling the Snake with Keypresses", and
|
||||
the day-21 steps in which the snake grows and the game ends. It keeps the lectures'
|
||||
names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`,
|
||||
`up`, `down`, `left` and `right`. The only differences are that the snake gets
|
||||
the function that makes a segment as a parameter, so that a test can pass a fake
|
||||
and needs no window, and how a reversal is refused (see Snake).
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from snake_game.constants import (
|
||||
BODY_COLOR,
|
||||
DOWN,
|
||||
HEAD_SHAPE,
|
||||
LEFT,
|
||||
MOVE_DISTANCE,
|
||||
RIGHT,
|
||||
SEGMENT_SHAPE,
|
||||
STARTING_POSITIONS,
|
||||
TAIL_COLLISION_DISTANCE,
|
||||
UP,
|
||||
WALL_LIMIT,
|
||||
)
|
||||
|
||||
|
||||
class Segment(Protocol):
|
||||
"""! @brief What the snake needs from one of its segments.
|
||||
|
||||
A `turtle.Turtle` fits this description, and so does a fake in a test.
|
||||
"""
|
||||
|
||||
def shape(self, name: str, /) -> object:
|
||||
"""! @brief Set the shape of the segment.
|
||||
|
||||
@param name Name of the shape, for example `square`.
|
||||
@return Whatever the implementation returns; the snake ignores it.
|
||||
"""
|
||||
...
|
||||
|
||||
def color(self, color: str, /) -> object:
|
||||
"""! @brief Set the colour of the segment.
|
||||
|
||||
@param color Name of the colour, for example `light green`.
|
||||
@return Whatever the implementation returns; the snake ignores it.
|
||||
"""
|
||||
...
|
||||
|
||||
def penup(self) -> None:
|
||||
"""! @brief Lift the pen, so that moving the segment draws no line."""
|
||||
...
|
||||
|
||||
def goto(self, x: float, y: float, /) -> None:
|
||||
"""! @brief Send the segment to a position.
|
||||
|
||||
@param x The x coordinate.
|
||||
@param y The y coordinate.
|
||||
"""
|
||||
...
|
||||
|
||||
def xcor(self) -> float:
|
||||
"""! @brief Tell the x coordinate of the segment.
|
||||
|
||||
@return The x coordinate.
|
||||
"""
|
||||
...
|
||||
|
||||
def ycor(self) -> float:
|
||||
"""! @brief Tell the y coordinate of the segment.
|
||||
|
||||
@return The y coordinate.
|
||||
"""
|
||||
...
|
||||
|
||||
def forward(self, distance: float, /) -> None:
|
||||
"""! @brief Move the segment forward, in the direction it points.
|
||||
|
||||
@param distance How far to move, in pixels.
|
||||
"""
|
||||
...
|
||||
|
||||
def position(self) -> tuple[float, float]:
|
||||
"""! @brief Tell where the segment is.
|
||||
|
||||
@return The x and y coordinates.
|
||||
"""
|
||||
...
|
||||
|
||||
def distance(self, x: tuple[float, float], /) -> float:
|
||||
"""! @brief Tell how far the segment is from a position.
|
||||
|
||||
@param x The x and y coordinates of the other place.
|
||||
@return The distance in pixels.
|
||||
"""
|
||||
...
|
||||
|
||||
def heading(self) -> float:
|
||||
"""! @brief Tell the direction the segment points, in degrees.
|
||||
|
||||
@return The heading, 0 for right and growing counter-clockwise.
|
||||
"""
|
||||
...
|
||||
|
||||
def setheading(self, to_angle: float, /) -> None:
|
||||
"""! @brief Turn the segment to point in a direction.
|
||||
|
||||
@param to_angle The heading in degrees.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def make_turtle_segment() -> Segment:
|
||||
"""! @brief Make a real turtle, the default way to get a segment.
|
||||
|
||||
`turtle` is imported here and not at the top of the module, so that
|
||||
importing the snake needs neither a display nor `tkinter`.
|
||||
|
||||
@return A new `turtle.Turtle`.
|
||||
"""
|
||||
from turtle import Turtle
|
||||
|
||||
return Turtle()
|
||||
|
||||
|
||||
class Snake:
|
||||
"""! @brief The snake of the game: a dark green head and a light green body.
|
||||
|
||||
The snake cannot reverse onto itself. Like the lecture, a turn is refused when
|
||||
it points opposite to the way the snake is going. Unlike the lecture, "the way
|
||||
the snake is going" is the direction of its last move and not the current
|
||||
direction of the head: otherwise two key presses within one move (Up then Left
|
||||
while moving right) would turn the head twice and reverse the snake.
|
||||
"""
|
||||
|
||||
def __init__(self, segment_factory: Callable[[], Segment] | None = None) -> None:
|
||||
"""! @brief Create the snake with its three starting segments.
|
||||
|
||||
@param segment_factory Makes one new segment; defaults to a real turtle.
|
||||
"""
|
||||
## @brief Makes one new segment.
|
||||
self._segment_factory = segment_factory or make_turtle_segment
|
||||
## @brief The segments of the snake, the head first.
|
||||
self.segments: list[Segment] = []
|
||||
self.create_snake()
|
||||
## @brief The first segment, which leads the snake.
|
||||
self.head: Segment = self.segments[0]
|
||||
## @brief The direction of the last move; a turn against it is refused.
|
||||
self._direction_of_travel: float = self.head.heading()
|
||||
|
||||
def create_snake(self) -> None:
|
||||
"""! @brief Draw one segment at each starting position.
|
||||
|
||||
The first one is the head, with the shape `HEAD_SHAPE`; the others are light
|
||||
green squares. The segments are kept in `segments`, the head first.
|
||||
"""
|
||||
for position in STARTING_POSITIONS:
|
||||
self.add_segment(position)
|
||||
|
||||
def add_segment(self, position: tuple[float, float]) -> None:
|
||||
"""! @brief Add a segment at the end of the snake.
|
||||
|
||||
The first segment of an empty snake is the head: it gets the shape
|
||||
`HEAD_SHAPE`, whose colours are part of the shape. Every other segment is a
|
||||
square in `BODY_COLOR`.
|
||||
|
||||
The pen is lifted before the segment is sent to its position, so no line is
|
||||
drawn.
|
||||
|
||||
@param position The x and y coordinates of the new segment.
|
||||
"""
|
||||
new_segment = self._segment_factory()
|
||||
if self.segments:
|
||||
new_segment.shape(SEGMENT_SHAPE)
|
||||
new_segment.color(BODY_COLOR)
|
||||
else:
|
||||
new_segment.shape(HEAD_SHAPE)
|
||||
new_segment.penup()
|
||||
new_segment.goto(*position)
|
||||
self.segments.append(new_segment)
|
||||
|
||||
def extend(self) -> None:
|
||||
"""! @brief Make the snake one segment longer.
|
||||
|
||||
The new segment appears where the last segment is, and follows it on the
|
||||
next move.
|
||||
"""
|
||||
self.add_segment(self.segments[-1].position())
|
||||
|
||||
def move(self) -> None:
|
||||
"""! @brief Move the snake one step along its path.
|
||||
|
||||
Each segment, from the last to the second, goes to the place of the
|
||||
segment before it; then the head goes forward by `MOVE_DISTANCE`. Moving
|
||||
the tail first keeps the body joined while turning, however many segments
|
||||
there are.
|
||||
"""
|
||||
for index in range(len(self.segments) - 1, 0, -1):
|
||||
ahead = self.segments[index - 1]
|
||||
self.segments[index].goto(ahead.xcor(), ahead.ycor())
|
||||
self.head.forward(MOVE_DISTANCE)
|
||||
self._direction_of_travel = self.head.heading()
|
||||
|
||||
def hits_wall(self) -> bool:
|
||||
"""! @brief Tell whether the head has passed the wall on any side.
|
||||
|
||||
The head is outside when its x or its y is beyond `WALL_LIMIT`.
|
||||
|
||||
@return True when the head is outside the wall.
|
||||
"""
|
||||
return abs(self.head.xcor()) > WALL_LIMIT or abs(self.head.ycor()) > WALL_LIMIT
|
||||
|
||||
def hits_tail(self) -> bool:
|
||||
"""! @brief Tell whether the head touches the tail.
|
||||
|
||||
The tail is every segment behind the head (a slice, `segments[1:]`). The head
|
||||
touches it when it is closer than `TAIL_COLLISION_DISTANCE` to one of them.
|
||||
A snake that has only a head has no tail to touch.
|
||||
|
||||
@return True when the head touches a segment of the tail.
|
||||
"""
|
||||
return any(
|
||||
self.head.distance(segment.position()) < TAIL_COLLISION_DISTANCE
|
||||
for segment in self.segments[1:]
|
||||
)
|
||||
|
||||
def up(self) -> None:
|
||||
"""! @brief Turn the head up, unless the snake is moving down."""
|
||||
if self._direction_of_travel != DOWN:
|
||||
self.head.setheading(UP)
|
||||
|
||||
def down(self) -> None:
|
||||
"""! @brief Turn the head down, unless the snake is moving up."""
|
||||
if self._direction_of_travel != UP:
|
||||
self.head.setheading(DOWN)
|
||||
|
||||
def left(self) -> None:
|
||||
"""! @brief Turn the head left, unless the snake is moving right."""
|
||||
if self._direction_of_travel != RIGHT:
|
||||
self.head.setheading(LEFT)
|
||||
|
||||
def right(self) -> None:
|
||||
"""! @brief Turn the head right, unless the snake is moving left."""
|
||||
if self._direction_of_travel != LEFT:
|
||||
self.head.setheading(RIGHT)
|
||||
"""! @file
|
||||
@brief The snake: a list of segments that the game draws and moves.
|
||||
|
||||
The class follows the lectures "Create a Snake Class & Move to OOP", "Animating
|
||||
the Snake Segments on Screen" and "Controlling the Snake with Keypresses", and
|
||||
the day-21 steps in which the snake grows, and the day-24 step in which it is reset
|
||||
after a collision. It keeps the lectures'
|
||||
names: `Snake`, `segments`, `create_snake`, `add_segment`, `extend`, `move`, `head`,
|
||||
`reset`, `up`, `down`, `left` and `right`. The only differences are that the snake gets
|
||||
the function that makes a segment as a parameter, so that a test can pass a fake
|
||||
and needs no window, and how a reversal is refused (see Snake).
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from snake_game.constants import (
|
||||
BODY_COLOR,
|
||||
DOWN,
|
||||
HEAD_SHAPE,
|
||||
LEFT,
|
||||
MOVE_DISTANCE,
|
||||
RIGHT,
|
||||
SEGMENT_PARKING_POSITION,
|
||||
SEGMENT_SHAPE,
|
||||
STARTING_POSITIONS,
|
||||
TAIL_COLLISION_DISTANCE,
|
||||
UP,
|
||||
WALL_LIMIT,
|
||||
)
|
||||
|
||||
|
||||
class Segment(Protocol):
|
||||
"""! @brief What the snake needs from one of its segments.
|
||||
|
||||
A `turtle.Turtle` fits this description, and so does a fake in a test.
|
||||
"""
|
||||
|
||||
def shape(self, name: str, /) -> object:
|
||||
"""! @brief Set the shape of the segment.
|
||||
|
||||
@param name Name of the shape, for example `square`.
|
||||
@return Whatever the implementation returns; the snake ignores it.
|
||||
"""
|
||||
...
|
||||
|
||||
def color(self, color: str, /) -> object:
|
||||
"""! @brief Set the colour of the segment.
|
||||
|
||||
@param color Name of the colour, for example `light green`.
|
||||
@return Whatever the implementation returns; the snake ignores it.
|
||||
"""
|
||||
...
|
||||
|
||||
def penup(self) -> None:
|
||||
"""! @brief Lift the pen, so that moving the segment draws no line."""
|
||||
...
|
||||
|
||||
def goto(self, x: float, y: float, /) -> None:
|
||||
"""! @brief Send the segment to a position.
|
||||
|
||||
@param x The x coordinate.
|
||||
@param y The y coordinate.
|
||||
"""
|
||||
...
|
||||
|
||||
def xcor(self) -> float:
|
||||
"""! @brief Tell the x coordinate of the segment.
|
||||
|
||||
@return The x coordinate.
|
||||
"""
|
||||
...
|
||||
|
||||
def ycor(self) -> float:
|
||||
"""! @brief Tell the y coordinate of the segment.
|
||||
|
||||
@return The y coordinate.
|
||||
"""
|
||||
...
|
||||
|
||||
def forward(self, distance: float, /) -> None:
|
||||
"""! @brief Move the segment forward, in the direction it points.
|
||||
|
||||
@param distance How far to move, in pixels.
|
||||
"""
|
||||
...
|
||||
|
||||
def position(self) -> tuple[float, float]:
|
||||
"""! @brief Tell where the segment is.
|
||||
|
||||
@return The x and y coordinates.
|
||||
"""
|
||||
...
|
||||
|
||||
def distance(self, x: tuple[float, float], /) -> float:
|
||||
"""! @brief Tell how far the segment is from a position.
|
||||
|
||||
@param x The x and y coordinates of the other place.
|
||||
@return The distance in pixels.
|
||||
"""
|
||||
...
|
||||
|
||||
def heading(self) -> float:
|
||||
"""! @brief Tell the direction the segment points, in degrees.
|
||||
|
||||
@return The heading, 0 for right and growing counter-clockwise.
|
||||
"""
|
||||
...
|
||||
|
||||
def setheading(self, to_angle: float, /) -> None:
|
||||
"""! @brief Turn the segment to point in a direction.
|
||||
|
||||
@param to_angle The heading in degrees.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def make_turtle_segment() -> Segment:
|
||||
"""! @brief Make a real turtle, the default way to get a segment.
|
||||
|
||||
`turtle` is imported here and not at the top of the module, so that
|
||||
importing the snake needs neither a display nor `tkinter`.
|
||||
|
||||
@return A new `turtle.Turtle`.
|
||||
"""
|
||||
from turtle import Turtle
|
||||
|
||||
return Turtle()
|
||||
|
||||
|
||||
class Snake:
|
||||
"""! @brief The snake of the game: a dark green head and a light green body.
|
||||
|
||||
The snake cannot reverse onto itself. Like the lecture, a turn is refused when
|
||||
it points opposite to the way the snake is going. Unlike the lecture, "the way
|
||||
the snake is going" is the direction of its last move and not the current
|
||||
direction of the head: otherwise two key presses within one move (Up then Left
|
||||
while moving right) would turn the head twice and reverse the snake.
|
||||
"""
|
||||
|
||||
def __init__(self, segment_factory: Callable[[], Segment] | None = None) -> None:
|
||||
"""! @brief Create the snake with its three starting segments.
|
||||
|
||||
@param segment_factory Makes one new segment; defaults to a real turtle.
|
||||
"""
|
||||
## @brief Makes one new segment.
|
||||
self._segment_factory = segment_factory or make_turtle_segment
|
||||
## @brief The segments of the snake, the head first.
|
||||
self.segments: list[Segment] = []
|
||||
self.create_snake()
|
||||
## @brief The first segment, which leads the snake.
|
||||
self.head: Segment = self.segments[0]
|
||||
## @brief The direction of the last move; a turn against it is refused.
|
||||
self._direction_of_travel: float = self.head.heading()
|
||||
|
||||
def create_snake(self) -> None:
|
||||
"""! @brief Draw one segment at each starting position.
|
||||
|
||||
The first one is the head, with the shape `HEAD_SHAPE`; the others are light
|
||||
green squares. The segments are kept in `segments`, the head first.
|
||||
"""
|
||||
for position in STARTING_POSITIONS:
|
||||
self.add_segment(position)
|
||||
|
||||
def add_segment(self, position: tuple[float, float]) -> None:
|
||||
"""! @brief Add a segment at the end of the snake.
|
||||
|
||||
The first segment of an empty snake is the head: it gets the shape
|
||||
`HEAD_SHAPE`, whose colours are part of the shape. Every other segment is a
|
||||
square in `BODY_COLOR`.
|
||||
|
||||
The pen is lifted before the segment is sent to its position, so no line is
|
||||
drawn.
|
||||
|
||||
@param position The x and y coordinates of the new segment.
|
||||
"""
|
||||
new_segment = self._segment_factory()
|
||||
if self.segments:
|
||||
new_segment.shape(SEGMENT_SHAPE)
|
||||
new_segment.color(BODY_COLOR)
|
||||
else:
|
||||
new_segment.shape(HEAD_SHAPE)
|
||||
new_segment.penup()
|
||||
new_segment.goto(*position)
|
||||
self.segments.append(new_segment)
|
||||
|
||||
def extend(self) -> None:
|
||||
"""! @brief Make the snake one segment longer.
|
||||
|
||||
The new segment appears where the last segment is, and follows it on the
|
||||
next move.
|
||||
"""
|
||||
self.add_segment(self.segments[-1].position())
|
||||
|
||||
def move(self) -> None:
|
||||
"""! @brief Move the snake one step along its path.
|
||||
|
||||
Each segment, from the last to the second, goes to the place of the
|
||||
segment before it; then the head goes forward by `MOVE_DISTANCE`. Moving
|
||||
the tail first keeps the body joined while turning, however many segments
|
||||
there are.
|
||||
"""
|
||||
for index in range(len(self.segments) - 1, 0, -1):
|
||||
ahead = self.segments[index - 1]
|
||||
self.segments[index].goto(ahead.xcor(), ahead.ycor())
|
||||
self.head.forward(MOVE_DISTANCE)
|
||||
self._direction_of_travel = self.head.heading()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""! @brief Start again with a new snake of three segments.
|
||||
|
||||
Every old segment is sent far outside the screen, so it can no longer be
|
||||
seen, the list is emptied, and the snake is created again at the starting
|
||||
positions. The new head is the first new segment, and it points right.
|
||||
"""
|
||||
for segment in self.segments:
|
||||
segment.goto(*SEGMENT_PARKING_POSITION)
|
||||
self.segments.clear()
|
||||
self.create_snake()
|
||||
self.head = self.segments[0]
|
||||
self._direction_of_travel = self.head.heading()
|
||||
|
||||
def hits_wall(self) -> bool:
|
||||
"""! @brief Tell whether the head has passed the wall on any side.
|
||||
|
||||
The head is outside when its x or its y is beyond `WALL_LIMIT`.
|
||||
|
||||
@return True when the head is outside the wall.
|
||||
"""
|
||||
return abs(self.head.xcor()) > WALL_LIMIT or abs(self.head.ycor()) > WALL_LIMIT
|
||||
|
||||
def hits_tail(self) -> bool:
|
||||
"""! @brief Tell whether the head touches the tail.
|
||||
|
||||
The tail is every segment behind the head (a slice, `segments[1:]`). The head
|
||||
touches it when it is closer than `TAIL_COLLISION_DISTANCE` to one of them.
|
||||
A snake that has only a head has no tail to touch.
|
||||
|
||||
@return True when the head touches a segment of the tail.
|
||||
"""
|
||||
return any(
|
||||
self.head.distance(segment.position()) < TAIL_COLLISION_DISTANCE
|
||||
for segment in self.segments[1:]
|
||||
)
|
||||
|
||||
def up(self) -> None:
|
||||
"""! @brief Turn the head up, unless the snake is moving down."""
|
||||
if self._direction_of_travel != DOWN:
|
||||
self.head.setheading(UP)
|
||||
|
||||
def down(self) -> None:
|
||||
"""! @brief Turn the head down, unless the snake is moving up."""
|
||||
if self._direction_of_travel != UP:
|
||||
self.head.setheading(DOWN)
|
||||
|
||||
def left(self) -> None:
|
||||
"""! @brief Turn the head left, unless the snake is moving right."""
|
||||
if self._direction_of_travel != RIGHT:
|
||||
self.head.setheading(LEFT)
|
||||
|
||||
def right(self) -> None:
|
||||
"""! @brief Turn the head right, unless the snake is moving left."""
|
||||
if self._direction_of_travel != LEFT:
|
||||
self.head.setheading(RIGHT)
|
||||
|
||||
+315
-315
@@ -1,315 +1,315 @@
|
||||
"""Fakes that stand in for turtles and the screen, so that no test opens a window."""
|
||||
|
||||
import importlib
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from snake_game.snake import Snake
|
||||
|
||||
SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
# Modules whose classes inherit from `Turtle`: they bind their base class when they
|
||||
# are imported, so each test that installs the fake `turtle` module imports them anew.
|
||||
INHERITING_MODULES = ("snake_game.food", "snake_game.scoreboard")
|
||||
|
||||
|
||||
class FakeTerminatorError(Exception):
|
||||
"""Stands in for `turtle.Terminator`."""
|
||||
|
||||
|
||||
class FakeTclError(Exception):
|
||||
"""Stands in for `tkinter.TclError`."""
|
||||
|
||||
|
||||
class FakeSegment:
|
||||
"""A turtle that records what is done to it, in order, and keeps its state.
|
||||
|
||||
It is a segment of the snake, and also the base class that stands in for
|
||||
`turtle.Turtle` when `Food` and `Scoreboard` are tested.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
self.x = 0.0
|
||||
self.y = 0.0
|
||||
self.angle = 0.0
|
||||
|
||||
def shape(self, name: str, /) -> None:
|
||||
self.calls.append(("shape", (name,)))
|
||||
|
||||
def shapesize(self, stretch_wid: float, stretch_len: float) -> None:
|
||||
self.calls.append(("shapesize", (stretch_wid, stretch_len)))
|
||||
|
||||
def color(self, color: str, /) -> None:
|
||||
self.calls.append(("color", (color,)))
|
||||
|
||||
def speed(self, speed: str) -> None:
|
||||
self.calls.append(("speed", (speed,)))
|
||||
|
||||
def penup(self) -> None:
|
||||
self.calls.append(("penup", ()))
|
||||
|
||||
def hideturtle(self) -> None:
|
||||
self.calls.append(("hideturtle", ()))
|
||||
|
||||
def clear(self) -> None:
|
||||
self.calls.append(("clear", ()))
|
||||
|
||||
def write(
|
||||
self,
|
||||
arg: str,
|
||||
align: str = "left",
|
||||
font: tuple[str, int, str] = ("Arial", 8, "normal"),
|
||||
) -> None:
|
||||
self.calls.append(("write", (arg, align, font)))
|
||||
|
||||
def goto(self, x: float | tuple[float, float], y: float | None = None, /) -> None:
|
||||
if isinstance(x, tuple):
|
||||
x, y = x
|
||||
assert y is not None
|
||||
self.calls.append(("goto", (x, y)))
|
||||
self.x, self.y = x, y
|
||||
|
||||
def xcor(self) -> float:
|
||||
return self.x
|
||||
|
||||
def ycor(self) -> float:
|
||||
return self.y
|
||||
|
||||
def position(self) -> tuple[float, float]:
|
||||
return (self.x, self.y)
|
||||
|
||||
def distance(self, x: tuple[float, float], /) -> float:
|
||||
return math.hypot(self.x - x[0], self.y - x[1])
|
||||
|
||||
def forward(self, distance: float, /) -> None:
|
||||
self.calls.append(("forward", (distance,)))
|
||||
radians = math.radians(self.angle)
|
||||
self.x += round(distance * math.cos(radians), 10)
|
||||
self.y += round(distance * math.sin(radians), 10)
|
||||
|
||||
def heading(self) -> float:
|
||||
return self.angle
|
||||
|
||||
def setheading(self, to_angle: float, /) -> None:
|
||||
self.calls.append(("setheading", (to_angle,)))
|
||||
self.angle = float(to_angle) % 360
|
||||
|
||||
def call_names(self) -> list[str]:
|
||||
"""Return the names of the calls, in the order they were made."""
|
||||
return [name for name, _ in self.calls]
|
||||
|
||||
|
||||
class FakeShape:
|
||||
"""Stands in for `turtle.Shape`: it records its type and its components."""
|
||||
|
||||
def __init__(self, type_: str, data: object = None) -> None:
|
||||
self.type = type_
|
||||
self.components: list[tuple[object, object, object]] = []
|
||||
|
||||
def addcomponent(self, poly: object, fill: object, outline: object = None) -> None:
|
||||
self.components.append((poly, fill, outline))
|
||||
|
||||
|
||||
class FakeFood:
|
||||
"""A food that counts how often it was moved."""
|
||||
|
||||
def __init__(self, x: float, y: float) -> None:
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.refreshes = 0
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.refreshes += 1
|
||||
|
||||
def position(self) -> tuple[float, float]:
|
||||
return (self.x, self.y)
|
||||
|
||||
|
||||
class FakeScoreboard:
|
||||
"""A scoreboard that counts how often the score was raised."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.increases = 0
|
||||
self.game_overs = 0
|
||||
|
||||
def increase_score(self) -> None:
|
||||
self.increases += 1
|
||||
|
||||
def game_over(self) -> None:
|
||||
self.game_overs += 1
|
||||
|
||||
|
||||
class FakeScreen:
|
||||
"""A screen that records what is done to it, in order.
|
||||
|
||||
After `frames_before_close` updates it behaves like a closed window: the next
|
||||
`update` raises `closing_error`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
frames_before_close: int = 0,
|
||||
closing_error: type[Exception] = FakeTerminatorError,
|
||||
) -> None:
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
self.bindings: dict[str, Callable[[], object]] = {}
|
||||
self._updates_left = frames_before_close
|
||||
self._closing_error = closing_error
|
||||
|
||||
def setup(self, width: float, height: float) -> None:
|
||||
self.calls.append(("setup", (width, height)))
|
||||
|
||||
def bgcolor(self, color: str, /) -> None:
|
||||
self.calls.append(("bgcolor", (color,)))
|
||||
|
||||
def title(self, titlestring: str, /) -> None:
|
||||
self.calls.append(("title", (titlestring,)))
|
||||
|
||||
def register_shape(self, name: str, shape: object, /) -> None:
|
||||
self.calls.append(("register_shape", (name, shape)))
|
||||
|
||||
def tracer(self, n: int, /) -> None:
|
||||
self.calls.append(("tracer", (n,)))
|
||||
|
||||
def listen(self) -> None:
|
||||
self.calls.append(("listen", ()))
|
||||
|
||||
def onkey(self, fun: Callable[[], object], key: str) -> None:
|
||||
self.calls.append(("onkey", (key,)))
|
||||
self.bindings[key] = fun
|
||||
|
||||
def update(self) -> None:
|
||||
self.calls.append(("update", ()))
|
||||
if self._updates_left == 0:
|
||||
raise self._closing_error
|
||||
self._updates_left -= 1
|
||||
|
||||
def exitonclick(self) -> None:
|
||||
self.calls.append(("exitonclick", ()))
|
||||
|
||||
def call_names(self) -> list[str]:
|
||||
"""Return the names of the calls, in the order they were made."""
|
||||
return [name for name, _ in self.calls]
|
||||
|
||||
|
||||
def make_snake() -> tuple[Snake, list[FakeSegment]]:
|
||||
"""Make a snake whose segments are fakes, and return the fakes too."""
|
||||
created: list[FakeSegment] = []
|
||||
|
||||
def factory() -> FakeSegment:
|
||||
segment = FakeSegment()
|
||||
created.append(segment)
|
||||
return segment
|
||||
|
||||
return Snake(segment_factory=factory), created
|
||||
|
||||
|
||||
def install_fake_turtle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
frames_before_close: int = 0,
|
||||
closing_error: type[Exception] = FakeTerminatorError,
|
||||
) -> tuple[list[FakeSegment], list[FakeScreen]]:
|
||||
"""Replace the `turtle` and `tkinter` modules with fakes for one test.
|
||||
|
||||
The fake screen acts like a window that the player closes after
|
||||
`frames_before_close` updates, by raising `closing_error` from `update`.
|
||||
Every turtle the code under test creates, including a `Food` or a `Scoreboard`
|
||||
whose base class is the fake `Turtle`, is added to the first list returned;
|
||||
every screen is added to the second. `food` and `scoreboard` are forgotten, so
|
||||
that they are imported again against the fake and removed again after the test.
|
||||
"""
|
||||
segments: list[FakeSegment] = []
|
||||
screens: list[FakeScreen] = []
|
||||
|
||||
class RegisteredSegment(FakeSegment):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
segments.append(self)
|
||||
|
||||
def make_screen() -> FakeScreen:
|
||||
screen = FakeScreen(frames_before_close, closing_error)
|
||||
screens.append(screen)
|
||||
return screen
|
||||
|
||||
turtle_module = types.ModuleType("turtle")
|
||||
turtle_module.__dict__["Turtle"] = RegisteredSegment
|
||||
turtle_module.__dict__["Screen"] = make_screen
|
||||
turtle_module.__dict__["Shape"] = FakeShape
|
||||
turtle_module.__dict__["Terminator"] = FakeTerminatorError
|
||||
tkinter_module = types.ModuleType("tkinter")
|
||||
tkinter_module.__dict__["TclError"] = FakeTclError
|
||||
monkeypatch.setitem(sys.modules, "turtle", turtle_module)
|
||||
monkeypatch.setitem(sys.modules, "tkinter", tkinter_module)
|
||||
for name in INHERITING_MODULES:
|
||||
# Set, then delete: when the test ends monkeypatch undoes both in reverse
|
||||
# order and the key is gone again, whatever the test imported meanwhile.
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
monkeypatch.delitem(sys.modules, name)
|
||||
return segments, screens
|
||||
|
||||
|
||||
def script_randint(
|
||||
monkeypatch: pytest.MonkeyPatch, values: Sequence[int]
|
||||
) -> list[tuple[int, int]]:
|
||||
"""Make `random.randint` return the given values in turn, then the upper bound.
|
||||
|
||||
Returns the list that collects the bounds of every call.
|
||||
"""
|
||||
queue = iter(values)
|
||||
bounds: list[tuple[int, int]] = []
|
||||
|
||||
def fake_randint(low: int, high: int) -> int:
|
||||
bounds.append((low, high))
|
||||
return next(queue, high)
|
||||
|
||||
monkeypatch.setattr("random.randint", fake_randint)
|
||||
return bounds
|
||||
|
||||
|
||||
def make_food(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
"""Make a real `Food` whose base class is the fake `Turtle`.
|
||||
|
||||
The result is typed `Any` because it has the methods of `Food` (`refresh`) and
|
||||
the recording methods of the fake base class (`calls`, `position`) at once.
|
||||
"""
|
||||
install_fake_turtle(monkeypatch)
|
||||
module = importlib.import_module("snake_game.food")
|
||||
return module.Food()
|
||||
|
||||
|
||||
def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
"""Make a real `Scoreboard` whose base class is the fake `Turtle`.
|
||||
|
||||
The result is typed `Any` because it has the methods of `Scoreboard`
|
||||
(`increase_score`) and the recording methods of the fake base class (`calls`)
|
||||
at once.
|
||||
"""
|
||||
install_fake_turtle(monkeypatch)
|
||||
module = importlib.import_module("snake_game.scoreboard")
|
||||
return module.Scoreboard()
|
||||
|
||||
|
||||
def imports_turtle_or_tkinter(module_name: str) -> bool:
|
||||
"""Tell whether importing a module in a fresh interpreter loads a display module."""
|
||||
code = (
|
||||
f"import sys, {module_name}; "
|
||||
"print('turtle' in sys.modules or 'tkinter' in sys.modules)"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
env={**os.environ, "PYTHONPATH": str(SRC)},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip() == "True"
|
||||
"""Fakes that stand in for turtles and the screen, so that no test opens a window."""
|
||||
|
||||
import importlib
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from snake_game.snake import Snake
|
||||
|
||||
SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
# Modules whose classes inherit from `Turtle`: they bind their base class when they
|
||||
# are imported, so each test that installs the fake `turtle` module imports them anew.
|
||||
INHERITING_MODULES = ("snake_game.food", "snake_game.scoreboard")
|
||||
|
||||
|
||||
class FakeTerminatorError(Exception):
|
||||
"""Stands in for `turtle.Terminator`."""
|
||||
|
||||
|
||||
class FakeTclError(Exception):
|
||||
"""Stands in for `tkinter.TclError`."""
|
||||
|
||||
|
||||
class FakeSegment:
|
||||
"""A turtle that records what is done to it, in order, and keeps its state.
|
||||
|
||||
It is a segment of the snake, and also the base class that stands in for
|
||||
`turtle.Turtle` when `Food` and `Scoreboard` are tested.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
self.x = 0.0
|
||||
self.y = 0.0
|
||||
self.angle = 0.0
|
||||
|
||||
def shape(self, name: str, /) -> None:
|
||||
self.calls.append(("shape", (name,)))
|
||||
|
||||
def shapesize(self, stretch_wid: float, stretch_len: float) -> None:
|
||||
self.calls.append(("shapesize", (stretch_wid, stretch_len)))
|
||||
|
||||
def color(self, color: str, /) -> None:
|
||||
self.calls.append(("color", (color,)))
|
||||
|
||||
def speed(self, speed: str) -> None:
|
||||
self.calls.append(("speed", (speed,)))
|
||||
|
||||
def penup(self) -> None:
|
||||
self.calls.append(("penup", ()))
|
||||
|
||||
def hideturtle(self) -> None:
|
||||
self.calls.append(("hideturtle", ()))
|
||||
|
||||
def clear(self) -> None:
|
||||
self.calls.append(("clear", ()))
|
||||
|
||||
def write(
|
||||
self,
|
||||
arg: str,
|
||||
align: str = "left",
|
||||
font: tuple[str, int, str] = ("Arial", 8, "normal"),
|
||||
) -> None:
|
||||
self.calls.append(("write", (arg, align, font)))
|
||||
|
||||
def goto(self, x: float | tuple[float, float], y: float | None = None, /) -> None:
|
||||
if isinstance(x, tuple):
|
||||
x, y = x
|
||||
assert y is not None
|
||||
self.calls.append(("goto", (x, y)))
|
||||
self.x, self.y = x, y
|
||||
|
||||
def xcor(self) -> float:
|
||||
return self.x
|
||||
|
||||
def ycor(self) -> float:
|
||||
return self.y
|
||||
|
||||
def position(self) -> tuple[float, float]:
|
||||
return (self.x, self.y)
|
||||
|
||||
def distance(self, x: tuple[float, float], /) -> float:
|
||||
return math.hypot(self.x - x[0], self.y - x[1])
|
||||
|
||||
def forward(self, distance: float, /) -> None:
|
||||
self.calls.append(("forward", (distance,)))
|
||||
radians = math.radians(self.angle)
|
||||
self.x += round(distance * math.cos(radians), 10)
|
||||
self.y += round(distance * math.sin(radians), 10)
|
||||
|
||||
def heading(self) -> float:
|
||||
return self.angle
|
||||
|
||||
def setheading(self, to_angle: float, /) -> None:
|
||||
self.calls.append(("setheading", (to_angle,)))
|
||||
self.angle = float(to_angle) % 360
|
||||
|
||||
def call_names(self) -> list[str]:
|
||||
"""Return the names of the calls, in the order they were made."""
|
||||
return [name for name, _ in self.calls]
|
||||
|
||||
|
||||
class FakeShape:
|
||||
"""Stands in for `turtle.Shape`: it records its type and its components."""
|
||||
|
||||
def __init__(self, type_: str, data: object = None) -> None:
|
||||
self.type = type_
|
||||
self.components: list[tuple[object, object, object]] = []
|
||||
|
||||
def addcomponent(self, poly: object, fill: object, outline: object = None) -> None:
|
||||
self.components.append((poly, fill, outline))
|
||||
|
||||
|
||||
class FakeFood:
|
||||
"""A food that counts how often it was moved."""
|
||||
|
||||
def __init__(self, x: float, y: float) -> None:
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.refreshes = 0
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.refreshes += 1
|
||||
|
||||
def position(self) -> tuple[float, float]:
|
||||
return (self.x, self.y)
|
||||
|
||||
|
||||
class FakeScoreboard:
|
||||
"""A scoreboard that counts how often the score was raised."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.increases = 0
|
||||
self.resets = 0
|
||||
|
||||
def increase_score(self) -> None:
|
||||
self.increases += 1
|
||||
|
||||
def reset(self) -> None:
|
||||
self.resets += 1
|
||||
|
||||
|
||||
class FakeScreen:
|
||||
"""A screen that records what is done to it, in order.
|
||||
|
||||
After `frames_before_close` updates it behaves like a closed window: the next
|
||||
`update` raises `closing_error`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
frames_before_close: int = 0,
|
||||
closing_error: type[Exception] = FakeTerminatorError,
|
||||
) -> None:
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
self.bindings: dict[str, Callable[[], object]] = {}
|
||||
self._updates_left = frames_before_close
|
||||
self._closing_error = closing_error
|
||||
|
||||
def setup(self, width: float, height: float) -> None:
|
||||
self.calls.append(("setup", (width, height)))
|
||||
|
||||
def bgcolor(self, color: str, /) -> None:
|
||||
self.calls.append(("bgcolor", (color,)))
|
||||
|
||||
def title(self, titlestring: str, /) -> None:
|
||||
self.calls.append(("title", (titlestring,)))
|
||||
|
||||
def register_shape(self, name: str, shape: object, /) -> None:
|
||||
self.calls.append(("register_shape", (name, shape)))
|
||||
|
||||
def tracer(self, n: int, /) -> None:
|
||||
self.calls.append(("tracer", (n,)))
|
||||
|
||||
def listen(self) -> None:
|
||||
self.calls.append(("listen", ()))
|
||||
|
||||
def onkey(self, fun: Callable[[], object], key: str) -> None:
|
||||
self.calls.append(("onkey", (key,)))
|
||||
self.bindings[key] = fun
|
||||
|
||||
def update(self) -> None:
|
||||
self.calls.append(("update", ()))
|
||||
if self._updates_left == 0:
|
||||
raise self._closing_error
|
||||
self._updates_left -= 1
|
||||
|
||||
def exitonclick(self) -> None:
|
||||
self.calls.append(("exitonclick", ()))
|
||||
|
||||
def call_names(self) -> list[str]:
|
||||
"""Return the names of the calls, in the order they were made."""
|
||||
return [name for name, _ in self.calls]
|
||||
|
||||
|
||||
def make_snake() -> tuple[Snake, list[FakeSegment]]:
|
||||
"""Make a snake whose segments are fakes, and return the fakes too."""
|
||||
created: list[FakeSegment] = []
|
||||
|
||||
def factory() -> FakeSegment:
|
||||
segment = FakeSegment()
|
||||
created.append(segment)
|
||||
return segment
|
||||
|
||||
return Snake(segment_factory=factory), created
|
||||
|
||||
|
||||
def install_fake_turtle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
frames_before_close: int = 0,
|
||||
closing_error: type[Exception] = FakeTerminatorError,
|
||||
) -> tuple[list[FakeSegment], list[FakeScreen]]:
|
||||
"""Replace the `turtle` and `tkinter` modules with fakes for one test.
|
||||
|
||||
The fake screen acts like a window that the player closes after
|
||||
`frames_before_close` updates, by raising `closing_error` from `update`.
|
||||
Every turtle the code under test creates, including a `Food` or a `Scoreboard`
|
||||
whose base class is the fake `Turtle`, is added to the first list returned;
|
||||
every screen is added to the second. `food` and `scoreboard` are forgotten, so
|
||||
that they are imported again against the fake and removed again after the test.
|
||||
"""
|
||||
segments: list[FakeSegment] = []
|
||||
screens: list[FakeScreen] = []
|
||||
|
||||
class RegisteredSegment(FakeSegment):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
segments.append(self)
|
||||
|
||||
def make_screen() -> FakeScreen:
|
||||
screen = FakeScreen(frames_before_close, closing_error)
|
||||
screens.append(screen)
|
||||
return screen
|
||||
|
||||
turtle_module = types.ModuleType("turtle")
|
||||
turtle_module.__dict__["Turtle"] = RegisteredSegment
|
||||
turtle_module.__dict__["Screen"] = make_screen
|
||||
turtle_module.__dict__["Shape"] = FakeShape
|
||||
turtle_module.__dict__["Terminator"] = FakeTerminatorError
|
||||
tkinter_module = types.ModuleType("tkinter")
|
||||
tkinter_module.__dict__["TclError"] = FakeTclError
|
||||
monkeypatch.setitem(sys.modules, "turtle", turtle_module)
|
||||
monkeypatch.setitem(sys.modules, "tkinter", tkinter_module)
|
||||
for name in INHERITING_MODULES:
|
||||
# Set, then delete: when the test ends monkeypatch undoes both in reverse
|
||||
# order and the key is gone again, whatever the test imported meanwhile.
|
||||
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
||||
monkeypatch.delitem(sys.modules, name)
|
||||
return segments, screens
|
||||
|
||||
|
||||
def script_randint(
|
||||
monkeypatch: pytest.MonkeyPatch, values: Sequence[int]
|
||||
) -> list[tuple[int, int]]:
|
||||
"""Make `random.randint` return the given values in turn, then the upper bound.
|
||||
|
||||
Returns the list that collects the bounds of every call.
|
||||
"""
|
||||
queue = iter(values)
|
||||
bounds: list[tuple[int, int]] = []
|
||||
|
||||
def fake_randint(low: int, high: int) -> int:
|
||||
bounds.append((low, high))
|
||||
return next(queue, high)
|
||||
|
||||
monkeypatch.setattr("random.randint", fake_randint)
|
||||
return bounds
|
||||
|
||||
|
||||
def make_food(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
"""Make a real `Food` whose base class is the fake `Turtle`.
|
||||
|
||||
The result is typed `Any` because it has the methods of `Food` (`refresh`) and
|
||||
the recording methods of the fake base class (`calls`, `position`) at once.
|
||||
"""
|
||||
install_fake_turtle(monkeypatch)
|
||||
module = importlib.import_module("snake_game.food")
|
||||
return module.Food()
|
||||
|
||||
|
||||
def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
"""Make a real `Scoreboard` whose base class is the fake `Turtle`.
|
||||
|
||||
The result is typed `Any` because it has the methods of `Scoreboard`
|
||||
(`increase_score`) and the recording methods of the fake base class (`calls`)
|
||||
at once.
|
||||
"""
|
||||
install_fake_turtle(monkeypatch)
|
||||
module = importlib.import_module("snake_game.scoreboard")
|
||||
return module.Scoreboard()
|
||||
|
||||
|
||||
def imports_turtle_or_tkinter(module_name: str) -> bool:
|
||||
"""Tell whether importing a module in a fresh interpreter loads a display module."""
|
||||
code = (
|
||||
f"import sys, {module_name}; "
|
||||
"print('turtle' in sys.modules or 'tkinter' in sys.modules)"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
env={**os.environ, "PYTHONPATH": str(SRC)},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip() == "True"
|
||||
|
||||
+171
-163
@@ -1,163 +1,171 @@
|
||||
"""Tests of the constants that the day-20, day-21 and day-24 lectures fix."""
|
||||
|
||||
from snake_game import constants
|
||||
|
||||
HALF_WIDTH = constants.SCREEN_WIDTH / 2
|
||||
HALF_HEIGHT = constants.SCREEN_HEIGHT / 2
|
||||
|
||||
|
||||
def test_screen_size_matches_lecture() -> None:
|
||||
assert (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT) == (600, 600)
|
||||
|
||||
|
||||
def test_screen_is_black_and_titled_as_in_the_lecture() -> None:
|
||||
assert constants.SCREEN_BACKGROUND_COLOR == "black"
|
||||
assert constants.SCREEN_TITLE == "My Snake Game"
|
||||
|
||||
|
||||
def test_body_segments_are_light_green_squares() -> None:
|
||||
assert constants.SEGMENT_SHAPE == "square"
|
||||
assert constants.BODY_COLOR == "light green"
|
||||
|
||||
|
||||
def test_segment_color_no_longer_exists() -> None:
|
||||
assert not hasattr(constants, "SEGMENT_COLOR")
|
||||
|
||||
|
||||
def test_head_is_dark_green_and_differs_from_the_body() -> None:
|
||||
assert constants.HEAD_COLOR == "dark green"
|
||||
assert constants.HEAD_COLOR != constants.BODY_COLOR
|
||||
|
||||
|
||||
def test_head_shape_has_a_name_that_is_not_a_built_in_shape() -> None:
|
||||
assert constants.HEAD_SHAPE == "snake_head"
|
||||
assert constants.HEAD_SHAPE != constants.SEGMENT_SHAPE
|
||||
|
||||
|
||||
def test_head_is_the_20_by_20_square() -> None:
|
||||
assert constants.HEAD_OUTLINE == ((-10, -10), (-10, 10), (10, 10), (10, -10))
|
||||
xs = {x for x, _ in constants.HEAD_OUTLINE}
|
||||
ys = {y for _, y in constants.HEAD_OUTLINE}
|
||||
assert max(xs) - min(xs) == max(ys) - min(ys) == 20
|
||||
|
||||
|
||||
def test_mouth_lies_on_the_front_edge_and_inside_the_head() -> None:
|
||||
front = max(y for _, y in constants.HEAD_OUTLINE)
|
||||
half = max(x for x, _ in constants.HEAD_OUTLINE)
|
||||
|
||||
assert constants.MOUTH_OUTLINE == ((-6, 10), (0, 2), (6, 10))
|
||||
assert any(y == front for _, y in constants.MOUTH_OUTLINE)
|
||||
assert all(
|
||||
abs(x) <= half and -half <= y <= front for x, y in constants.MOUTH_OUTLINE
|
||||
)
|
||||
|
||||
|
||||
def test_mouth_is_the_colour_of_the_background() -> None:
|
||||
assert constants.MOUTH_COLOR == constants.SCREEN_BACKGROUND_COLOR
|
||||
|
||||
|
||||
def test_snake_starts_with_three_segments_at_the_lecture_positions() -> None:
|
||||
assert constants.STARTING_POSITIONS == ((0, 0), (-20, 0), (-40, 0))
|
||||
|
||||
|
||||
def test_starting_positions_are_distinct_on_one_row() -> None:
|
||||
positions = constants.STARTING_POSITIONS
|
||||
|
||||
assert len(set(positions)) == len(positions)
|
||||
assert len({y for _, y in positions}) == 1
|
||||
|
||||
|
||||
def test_starting_positions_run_from_head_to_tail_leftwards() -> None:
|
||||
x_values = [x for x, _ in constants.STARTING_POSITIONS]
|
||||
|
||||
assert x_values == sorted(x_values, reverse=True)
|
||||
|
||||
|
||||
def test_starting_positions_are_one_move_distance_apart() -> None:
|
||||
x_values = [x for x, _ in constants.STARTING_POSITIONS]
|
||||
gaps = {left - right for left, right in zip(x_values, x_values[1:], strict=False)}
|
||||
|
||||
assert gaps == {constants.MOVE_DISTANCE}
|
||||
|
||||
|
||||
def test_starting_positions_are_inside_the_screen() -> None:
|
||||
assert all(
|
||||
abs(x) < HALF_WIDTH and abs(y) < HALF_HEIGHT
|
||||
for x, y in constants.STARTING_POSITIONS
|
||||
)
|
||||
|
||||
|
||||
def test_move_distance_is_twenty_pixels() -> None:
|
||||
assert constants.MOVE_DISTANCE == 20
|
||||
|
||||
|
||||
def test_directions_match_the_lecture_headings() -> None:
|
||||
assert constants.UP == 90
|
||||
assert constants.DOWN == 270
|
||||
assert constants.LEFT == 180
|
||||
assert constants.RIGHT == 0
|
||||
|
||||
|
||||
def test_four_directions_are_distinct() -> None:
|
||||
directions = (constants.UP, constants.DOWN, constants.LEFT, constants.RIGHT)
|
||||
|
||||
assert len(set(directions)) == len(directions)
|
||||
|
||||
|
||||
def test_opposite_directions_differ_by_half_a_turn() -> None:
|
||||
assert (constants.UP - constants.DOWN) % 360 == 180
|
||||
assert (constants.LEFT - constants.RIGHT) % 360 == 180
|
||||
|
||||
|
||||
def test_refresh_delay_is_a_tenth_of_a_second() -> None:
|
||||
assert constants.REFRESH_DELAY_SECONDS == 0.1
|
||||
|
||||
|
||||
def test_food_is_a_small_blue_circle_without_animation() -> None:
|
||||
assert constants.FOOD_SHAPE == "circle"
|
||||
assert constants.FOOD_SIZE == 0.5
|
||||
assert constants.FOOD_COLOR == "blue"
|
||||
assert constants.FOOD_SPEED == "fastest"
|
||||
|
||||
|
||||
def test_wall_is_inside_the_screen() -> None:
|
||||
assert 0 < constants.WALL_LIMIT < HALF_WIDTH
|
||||
assert constants.WALL_LIMIT < HALF_HEIGHT
|
||||
|
||||
|
||||
def test_wall_is_280_pixels_from_the_centre() -> None:
|
||||
assert constants.WALL_LIMIT == 280
|
||||
|
||||
|
||||
def test_food_is_eaten_closer_than_15_pixels_which_is_less_than_a_move() -> None:
|
||||
assert constants.FOOD_COLLISION_DISTANCE == 15
|
||||
assert constants.FOOD_COLLISION_DISTANCE < constants.MOVE_DISTANCE
|
||||
|
||||
|
||||
def test_scoreboard_is_white_text_at_the_top_centre_inside_the_screen() -> None:
|
||||
x, y = constants.SCOREBOARD_POSITION
|
||||
|
||||
assert constants.SCOREBOARD_COLOR == "white"
|
||||
assert constants.SCOREBOARD_ALIGNMENT == "center"
|
||||
assert (x, y) == (0, 270)
|
||||
assert abs(x) < HALF_WIDTH
|
||||
assert 0 < y < HALF_HEIGHT
|
||||
|
||||
|
||||
def test_scoreboard_text_is_score_label_and_arial_24() -> None:
|
||||
assert constants.SCORE_LABEL == "Score: "
|
||||
assert constants.SCOREBOARD_FONT == ("Arial", 24, "normal")
|
||||
|
||||
|
||||
def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> None:
|
||||
assert constants.TAIL_COLLISION_DISTANCE == 10
|
||||
assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE
|
||||
|
||||
|
||||
def test_game_over_text_is_written_at_the_centre_inside_the_wall() -> None:
|
||||
x, y = constants.GAME_OVER_POSITION
|
||||
|
||||
assert constants.GAME_OVER_TEXT == "GAME OVER"
|
||||
assert (x, y) == (0, 0)
|
||||
assert abs(x) < constants.WALL_LIMIT
|
||||
assert abs(y) < constants.WALL_LIMIT
|
||||
"""Tests of the constants that the day-20, day-21 and day-24 lectures fix."""
|
||||
|
||||
from snake_game import constants
|
||||
|
||||
HALF_WIDTH = constants.SCREEN_WIDTH / 2
|
||||
HALF_HEIGHT = constants.SCREEN_HEIGHT / 2
|
||||
|
||||
|
||||
def test_screen_size_matches_lecture() -> None:
|
||||
assert (constants.SCREEN_WIDTH, constants.SCREEN_HEIGHT) == (600, 600)
|
||||
|
||||
|
||||
def test_screen_is_black_and_titled_as_in_the_lecture() -> None:
|
||||
assert constants.SCREEN_BACKGROUND_COLOR == "black"
|
||||
assert constants.SCREEN_TITLE == "My Snake Game"
|
||||
|
||||
|
||||
def test_body_segments_are_light_green_squares() -> None:
|
||||
assert constants.SEGMENT_SHAPE == "square"
|
||||
assert constants.BODY_COLOR == "light green"
|
||||
|
||||
|
||||
def test_segment_color_no_longer_exists() -> None:
|
||||
assert not hasattr(constants, "SEGMENT_COLOR")
|
||||
|
||||
|
||||
def test_head_is_dark_green_and_differs_from_the_body() -> None:
|
||||
assert constants.HEAD_COLOR == "dark green"
|
||||
assert constants.HEAD_COLOR != constants.BODY_COLOR
|
||||
|
||||
|
||||
def test_head_shape_has_a_name_that_is_not_a_built_in_shape() -> None:
|
||||
assert constants.HEAD_SHAPE == "snake_head"
|
||||
assert constants.HEAD_SHAPE != constants.SEGMENT_SHAPE
|
||||
|
||||
|
||||
def test_head_is_the_20_by_20_square() -> None:
|
||||
assert constants.HEAD_OUTLINE == ((-10, -10), (-10, 10), (10, 10), (10, -10))
|
||||
xs = {x for x, _ in constants.HEAD_OUTLINE}
|
||||
ys = {y for _, y in constants.HEAD_OUTLINE}
|
||||
assert max(xs) - min(xs) == max(ys) - min(ys) == 20
|
||||
|
||||
|
||||
def test_mouth_lies_on_the_front_edge_and_inside_the_head() -> None:
|
||||
front = max(y for _, y in constants.HEAD_OUTLINE)
|
||||
half = max(x for x, _ in constants.HEAD_OUTLINE)
|
||||
|
||||
assert constants.MOUTH_OUTLINE == ((-6, 10), (0, 2), (6, 10))
|
||||
assert any(y == front for _, y in constants.MOUTH_OUTLINE)
|
||||
assert all(
|
||||
abs(x) <= half and -half <= y <= front for x, y in constants.MOUTH_OUTLINE
|
||||
)
|
||||
|
||||
|
||||
def test_mouth_is_the_colour_of_the_background() -> None:
|
||||
assert constants.MOUTH_COLOR == constants.SCREEN_BACKGROUND_COLOR
|
||||
|
||||
|
||||
def test_snake_starts_with_three_segments_at_the_lecture_positions() -> None:
|
||||
assert constants.STARTING_POSITIONS == ((0, 0), (-20, 0), (-40, 0))
|
||||
|
||||
|
||||
def test_starting_positions_are_distinct_on_one_row() -> None:
|
||||
positions = constants.STARTING_POSITIONS
|
||||
|
||||
assert len(set(positions)) == len(positions)
|
||||
assert len({y for _, y in positions}) == 1
|
||||
|
||||
|
||||
def test_starting_positions_run_from_head_to_tail_leftwards() -> None:
|
||||
x_values = [x for x, _ in constants.STARTING_POSITIONS]
|
||||
|
||||
assert x_values == sorted(x_values, reverse=True)
|
||||
|
||||
|
||||
def test_starting_positions_are_one_move_distance_apart() -> None:
|
||||
x_values = [x for x, _ in constants.STARTING_POSITIONS]
|
||||
gaps = {left - right for left, right in zip(x_values, x_values[1:], strict=False)}
|
||||
|
||||
assert gaps == {constants.MOVE_DISTANCE}
|
||||
|
||||
|
||||
def test_starting_positions_are_inside_the_screen() -> None:
|
||||
assert all(
|
||||
abs(x) < HALF_WIDTH and abs(y) < HALF_HEIGHT
|
||||
for x, y in constants.STARTING_POSITIONS
|
||||
)
|
||||
|
||||
|
||||
def test_move_distance_is_twenty_pixels() -> None:
|
||||
assert constants.MOVE_DISTANCE == 20
|
||||
|
||||
|
||||
def test_directions_match_the_lecture_headings() -> None:
|
||||
assert constants.UP == 90
|
||||
assert constants.DOWN == 270
|
||||
assert constants.LEFT == 180
|
||||
assert constants.RIGHT == 0
|
||||
|
||||
|
||||
def test_four_directions_are_distinct() -> None:
|
||||
directions = (constants.UP, constants.DOWN, constants.LEFT, constants.RIGHT)
|
||||
|
||||
assert len(set(directions)) == len(directions)
|
||||
|
||||
|
||||
def test_opposite_directions_differ_by_half_a_turn() -> None:
|
||||
assert (constants.UP - constants.DOWN) % 360 == 180
|
||||
assert (constants.LEFT - constants.RIGHT) % 360 == 180
|
||||
|
||||
|
||||
def test_refresh_delay_is_a_tenth_of_a_second() -> None:
|
||||
assert constants.REFRESH_DELAY_SECONDS == 0.1
|
||||
|
||||
|
||||
def test_food_is_a_small_blue_circle_without_animation() -> None:
|
||||
assert constants.FOOD_SHAPE == "circle"
|
||||
assert constants.FOOD_SIZE == 0.5
|
||||
assert constants.FOOD_COLOR == "blue"
|
||||
assert constants.FOOD_SPEED == "fastest"
|
||||
|
||||
|
||||
def test_wall_is_inside_the_screen() -> None:
|
||||
assert 0 < constants.WALL_LIMIT < HALF_WIDTH
|
||||
assert constants.WALL_LIMIT < HALF_HEIGHT
|
||||
|
||||
|
||||
def test_wall_is_280_pixels_from_the_centre() -> None:
|
||||
assert constants.WALL_LIMIT == 280
|
||||
|
||||
|
||||
def test_food_is_eaten_closer_than_15_pixels_which_is_less_than_a_move() -> None:
|
||||
assert constants.FOOD_COLLISION_DISTANCE == 15
|
||||
assert constants.FOOD_COLLISION_DISTANCE < constants.MOVE_DISTANCE
|
||||
|
||||
|
||||
def test_scoreboard_is_white_text_at_the_top_centre_inside_the_screen() -> None:
|
||||
x, y = constants.SCOREBOARD_POSITION
|
||||
|
||||
assert constants.SCOREBOARD_COLOR == "white"
|
||||
assert constants.SCOREBOARD_ALIGNMENT == "center"
|
||||
assert (x, y) == (0, 270)
|
||||
assert abs(x) < HALF_WIDTH
|
||||
assert 0 < y < HALF_HEIGHT
|
||||
|
||||
|
||||
def test_scoreboard_text_is_score_label_and_arial_24() -> None:
|
||||
assert constants.SCORE_LABEL == "Score: "
|
||||
assert constants.SCOREBOARD_FONT == ("Arial", 24, "normal")
|
||||
|
||||
|
||||
def test_tail_is_touched_closer_than_10_pixels_which_is_less_than_a_move() -> None:
|
||||
assert constants.TAIL_COLLISION_DISTANCE == 10
|
||||
assert 0 < constants.TAIL_COLLISION_DISTANCE < constants.MOVE_DISTANCE
|
||||
|
||||
|
||||
def test_game_over_constants_no_longer_exist() -> None:
|
||||
assert not hasattr(constants, "GAME_OVER_TEXT")
|
||||
assert not hasattr(constants, "GAME_OVER_POSITION")
|
||||
|
||||
|
||||
def test_high_score_label_follows_the_score_label_as_in_the_lecture() -> None:
|
||||
assert constants.HIGH_SCORE_LABEL == "High Score: "
|
||||
assert constants.HIGH_SCORE_LABEL != constants.SCORE_LABEL
|
||||
|
||||
|
||||
def test_old_segments_are_parked_outside_the_screen() -> None:
|
||||
x, y = constants.SEGMENT_PARKING_POSITION
|
||||
|
||||
assert abs(x) > HALF_WIDTH
|
||||
assert abs(y) > HALF_HEIGHT
|
||||
|
||||
+52
-45
@@ -20,10 +20,10 @@ from snake_game.main import (
|
||||
bind_keys,
|
||||
configure_screen,
|
||||
eat_food_if_close,
|
||||
end_game_if_over,
|
||||
main,
|
||||
play_frame,
|
||||
register_head_shape,
|
||||
reset_if_collision,
|
||||
)
|
||||
from snake_game.snake import Snake
|
||||
|
||||
@@ -276,7 +276,7 @@ def test_main_lets_the_snake_eat_the_food_that_lies_on_its_way(
|
||||
("goto", (20, 0)),
|
||||
("goto", (constants.WALL_LIMIT, constants.WALL_LIMIT)),
|
||||
]
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 1"
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 1 High Score: 0"
|
||||
assert new_segment.position() == (-20, 0) # where the last segment was
|
||||
|
||||
|
||||
@@ -288,86 +288,93 @@ def test_main_does_not_raise_the_score_when_the_food_is_far_away(
|
||||
main()
|
||||
|
||||
scoreboard = turtles[4]
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 0"
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 0 High 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()
|
||||
def test_nothing_is_reset_while_the_snake_is_inside_and_clear_of_its_tail() -> None:
|
||||
snake, created = make_snake()
|
||||
scoreboard = FakeScoreboard()
|
||||
|
||||
assert not end_game_if_over(snake, scoreboard)
|
||||
assert scoreboard.game_overs == 0
|
||||
assert not reset_if_collision(snake, scoreboard)
|
||||
assert scoreboard.resets == 0
|
||||
assert len(created) == len(constants.STARTING_POSITIONS)
|
||||
|
||||
|
||||
def test_the_game_is_over_when_the_head_passes_the_wall() -> None:
|
||||
snake, _ = make_snake()
|
||||
def test_the_game_is_reset_when_the_head_passes_the_wall() -> None:
|
||||
snake, created = make_snake()
|
||||
snake.head.goto(constants.WALL_LIMIT + 1, 0)
|
||||
scoreboard = FakeScoreboard()
|
||||
|
||||
assert end_game_if_over(snake, scoreboard)
|
||||
assert scoreboard.game_overs == 1
|
||||
assert reset_if_collision(snake, scoreboard)
|
||||
assert scoreboard.resets == 1
|
||||
assert len(created) == 2 * len(constants.STARTING_POSITIONS)
|
||||
|
||||
|
||||
def test_the_game_is_over_when_the_head_touches_the_tail() -> None:
|
||||
def test_the_game_is_reset_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
|
||||
assert reset_if_collision(snake, scoreboard)
|
||||
assert scoreboard.resets == 1
|
||||
assert snake.head is not created[0]
|
||||
|
||||
|
||||
def test_main_ends_the_game_at_the_wall_shows_game_over_and_waits_for_a_click(
|
||||
def test_main_goes_on_after_the_wall_with_a_new_snake_and_no_game_over(
|
||||
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
|
||||
) -> None:
|
||||
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
|
||||
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 + 2
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
old_head, scoreboard = turtles[0], turtles[4]
|
||||
new_head = turtles[5]
|
||||
written = [call[1][0] for call in scoreboard.calls if call[0] == "write"]
|
||||
assert old_head.position() == constants.SEGMENT_PARKING_POSITION
|
||||
assert new_head.call_names().count("forward") == 2
|
||||
assert new_head.position() == (2 * constants.MOVE_DISTANCE, 0)
|
||||
assert scoreboard.calls[-1] == (
|
||||
"write",
|
||||
("GAME OVER", "center", ("Arial", 24, "normal")),
|
||||
("Score: 0 High Score: 0", "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
|
||||
assert "GAME OVER" not in written
|
||||
assert "exitonclick" not in screens[0].call_names()
|
||||
|
||||
|
||||
def test_main_ends_the_game_when_the_snake_touches_its_tail(
|
||||
def test_main_shows_the_best_score_after_a_reset(
|
||||
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
|
||||
) -> None:
|
||||
script_randint(monkeypatch, [20, 0]) # the first food lies one move ahead
|
||||
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1
|
||||
turtles, _ = install_fake_turtle(
|
||||
monkeypatch, frames_before_close=moves_to_pass_the_wall
|
||||
)
|
||||
|
||||
main()
|
||||
|
||||
scoreboard = turtles[4]
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 0 High Score: 1"
|
||||
|
||||
|
||||
def test_main_resets_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)
|
||||
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=2)
|
||||
|
||||
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
|
||||
assert turtles[0].position() == constants.SEGMENT_PARKING_POSITION
|
||||
assert "exitonclick" not in screens[0].call_names()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError])
|
||||
def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
|
||||
def test_main_ends_quietly_when_the_window_is_closed_after_a_reset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sleeps: list[float],
|
||||
closing_error: type[Exception],
|
||||
@@ -381,7 +388,7 @@ def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
|
||||
|
||||
main()
|
||||
|
||||
assert turtles[4].calls[-1][1][0] == "GAME OVER"
|
||||
assert turtles[4].calls[-1][1][0] == "Score: 0 High Score: 0"
|
||||
assert "exitonclick" not in screens[0].call_names()
|
||||
|
||||
|
||||
|
||||
+137
-85
@@ -1,85 +1,137 @@
|
||||
"""Tests of the scoreboard, with a fake `Turtle` as its base class."""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from fakes import install_fake_turtle, make_scoreboard
|
||||
from snake_game import constants
|
||||
|
||||
START_TEXT = ("write", ("Score: 0", "center", ("Arial", 24, "normal")))
|
||||
|
||||
|
||||
def test_scoreboard_is_white_hidden_and_at_the_top_centre(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
assert ("color", ("white",)) in scoreboard.calls
|
||||
assert "penup" in scoreboard.call_names()
|
||||
assert "hideturtle" in scoreboard.call_names()
|
||||
assert ("goto", constants.SCOREBOARD_POSITION) in scoreboard.calls
|
||||
|
||||
|
||||
def test_scoreboard_inherits_from_turtle(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
install_fake_turtle(monkeypatch)
|
||||
module = importlib.import_module("snake_game.scoreboard")
|
||||
|
||||
assert issubclass(module.Scoreboard, sys.modules["turtle"].Turtle)
|
||||
|
||||
|
||||
def test_scoreboard_starts_by_writing_score_0_centred_in_arial_24(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
assert scoreboard.calls[-1] == START_TEXT
|
||||
|
||||
|
||||
def test_increase_score_wipes_the_old_text_and_writes_the_new_score(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
scoreboard.increase_score()
|
||||
|
||||
assert scoreboard.call_names()[-2:] == ["clear", "write"]
|
||||
assert scoreboard.calls[-1] == (
|
||||
"write",
|
||||
("Score: 1", "center", ("Arial", 24, "normal")),
|
||||
)
|
||||
|
||||
|
||||
def test_every_food_adds_one_to_the_score(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
for _ in range(3):
|
||||
scoreboard.increase_score()
|
||||
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 3"
|
||||
|
||||
|
||||
def test_game_over_writes_the_text_at_the_centre_in_the_scoreboard_font(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
scoreboard.game_over()
|
||||
|
||||
assert scoreboard.calls[-2:] == [
|
||||
("goto", constants.GAME_OVER_POSITION),
|
||||
("write", ("GAME OVER", "center", ("Arial", 24, "normal"))),
|
||||
]
|
||||
|
||||
|
||||
def test_game_over_leaves_the_score_on_the_screen(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
scoreboard.increase_score()
|
||||
calls_before = len(scoreboard.calls)
|
||||
|
||||
scoreboard.game_over()
|
||||
|
||||
assert "clear" not in scoreboard.call_names()[calls_before:]
|
||||
"""Tests of the scoreboard, with a fake `Turtle` as its base class."""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from fakes import install_fake_turtle, make_scoreboard
|
||||
from snake_game import constants
|
||||
|
||||
START_TEXT = (
|
||||
"write",
|
||||
("Score: 0 High Score: 0", "center", ("Arial", 24, "normal")),
|
||||
)
|
||||
|
||||
|
||||
def test_scoreboard_is_white_hidden_and_at_the_top_centre(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
assert ("color", ("white",)) in scoreboard.calls
|
||||
assert "penup" in scoreboard.call_names()
|
||||
assert "hideturtle" in scoreboard.call_names()
|
||||
assert ("goto", constants.SCOREBOARD_POSITION) in scoreboard.calls
|
||||
|
||||
|
||||
def test_scoreboard_inherits_from_turtle(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
install_fake_turtle(monkeypatch)
|
||||
module = importlib.import_module("snake_game.scoreboard")
|
||||
|
||||
assert issubclass(module.Scoreboard, sys.modules["turtle"].Turtle)
|
||||
|
||||
|
||||
def test_scoreboard_starts_by_writing_both_scores_0_centred_in_arial_24(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
assert scoreboard.calls[-1] == START_TEXT
|
||||
|
||||
|
||||
def test_increase_score_wipes_the_old_text_and_writes_the_new_score(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
scoreboard.increase_score()
|
||||
|
||||
assert scoreboard.call_names()[-2:] == ["clear", "write"]
|
||||
assert scoreboard.calls[-1] == (
|
||||
"write",
|
||||
("Score: 1 High Score: 0", "center", ("Arial", 24, "normal")),
|
||||
)
|
||||
|
||||
|
||||
def test_every_food_adds_one_to_the_score(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
for _ in range(3):
|
||||
scoreboard.increase_score()
|
||||
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 3 High Score: 0"
|
||||
|
||||
|
||||
def test_high_score_starts_at_0(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
assert scoreboard.high_score == 0
|
||||
assert scoreboard.score == 0
|
||||
|
||||
|
||||
def test_reset_keeps_a_higher_score_as_the_high_score_and_starts_again(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
for _ in range(3):
|
||||
scoreboard.increase_score()
|
||||
|
||||
scoreboard.reset()
|
||||
|
||||
assert scoreboard.high_score == 3
|
||||
assert scoreboard.score == 0
|
||||
assert scoreboard.call_names()[-2:] == ["clear", "write"]
|
||||
assert scoreboard.calls[-1] == (
|
||||
"write",
|
||||
("Score: 0 High Score: 3", "center", ("Arial", 24, "normal")),
|
||||
)
|
||||
|
||||
|
||||
def test_reset_keeps_the_high_score_when_the_score_is_lower(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
for _ in range(3):
|
||||
scoreboard.increase_score()
|
||||
scoreboard.reset()
|
||||
scoreboard.increase_score()
|
||||
|
||||
scoreboard.reset()
|
||||
|
||||
assert scoreboard.high_score == 3
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 0 High Score: 3"
|
||||
|
||||
|
||||
def test_reset_keeps_the_high_score_when_the_score_is_equal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
scoreboard.increase_score()
|
||||
scoreboard.reset()
|
||||
scoreboard.increase_score()
|
||||
|
||||
scoreboard.reset()
|
||||
|
||||
assert scoreboard.high_score == 1
|
||||
assert scoreboard.score == 0
|
||||
|
||||
|
||||
def test_the_score_text_shows_both_scores_while_playing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
scoreboard.increase_score()
|
||||
scoreboard.increase_score()
|
||||
scoreboard.reset()
|
||||
scoreboard.increase_score()
|
||||
|
||||
assert scoreboard.calls[-1][1][0] == "Score: 1 High Score: 2"
|
||||
|
||||
|
||||
def test_scoreboard_has_no_game_over_any_more(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scoreboard = make_scoreboard(monkeypatch)
|
||||
|
||||
assert not hasattr(scoreboard, "game_over")
|
||||
|
||||
+467
-409
@@ -1,409 +1,467 @@
|
||||
"""Tests of the snake, with fakes instead of real turtles."""
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from fakes import (
|
||||
FakeSegment,
|
||||
imports_turtle_or_tkinter,
|
||||
install_fake_turtle,
|
||||
make_snake,
|
||||
)
|
||||
from snake_game import constants
|
||||
from snake_game.snake import Segment, Snake
|
||||
|
||||
DIRECTIONS = {
|
||||
"up": constants.UP,
|
||||
"down": constants.DOWN,
|
||||
"left": constants.LEFT,
|
||||
"right": constants.RIGHT,
|
||||
}
|
||||
OPPOSITE = {
|
||||
constants.UP: constants.DOWN,
|
||||
constants.DOWN: constants.UP,
|
||||
constants.LEFT: constants.RIGHT,
|
||||
constants.RIGHT: constants.LEFT,
|
||||
}
|
||||
|
||||
|
||||
def positions(segments: Sequence[Segment]) -> list[tuple[float, float]]:
|
||||
"""Return where every segment is, head first."""
|
||||
return [segment.position() for segment in segments]
|
||||
|
||||
|
||||
def make_snake_moving(direction: int) -> tuple[Snake, list[FakeSegment]]:
|
||||
"""Make a snake whose last move went in the given direction."""
|
||||
snake, created = make_snake()
|
||||
created[0].setheading(direction)
|
||||
snake.move()
|
||||
return snake, created
|
||||
|
||||
|
||||
def test_snake_has_one_segment_per_starting_position() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
assert len(snake.segments) == len(constants.STARTING_POSITIONS) == 3
|
||||
|
||||
|
||||
def test_segments_are_the_created_turtles_in_order() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
assert snake.segments == created
|
||||
|
||||
|
||||
def test_head_has_the_head_shape_and_no_colour_call() -> None:
|
||||
_, created = make_snake()
|
||||
|
||||
head = created[0]
|
||||
assert ("shape", (constants.HEAD_SHAPE,)) in head.calls
|
||||
assert "color" not in head.call_names()
|
||||
|
||||
|
||||
def test_body_segments_are_light_green_squares() -> None:
|
||||
_, created = make_snake()
|
||||
|
||||
assert len(created) > 1
|
||||
for segment in created[1:]:
|
||||
assert ("shape", (constants.SEGMENT_SHAPE,)) in segment.calls
|
||||
assert ("color", (constants.BODY_COLOR,)) in segment.calls
|
||||
|
||||
|
||||
def test_segments_are_placed_at_the_starting_positions_head_first() -> None:
|
||||
_, created = make_snake()
|
||||
|
||||
assert positions(created) == [
|
||||
(float(x), float(y)) for x, y in constants.STARTING_POSITIONS
|
||||
]
|
||||
|
||||
|
||||
def test_pen_is_lifted_before_a_segment_is_moved() -> None:
|
||||
_, created = make_snake()
|
||||
|
||||
for segment in created:
|
||||
names = segment.call_names()
|
||||
assert names.count("goto") == 1
|
||||
assert names.index("penup") < names.index("goto")
|
||||
|
||||
|
||||
def test_create_snake_adds_three_more_segments_when_called_again() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.create_snake()
|
||||
|
||||
assert len(snake.segments) == 2 * len(constants.STARTING_POSITIONS)
|
||||
|
||||
|
||||
def test_default_segment_factory_makes_turtles(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
turtles, _ = install_fake_turtle(monkeypatch)
|
||||
|
||||
snake = Snake()
|
||||
|
||||
assert snake.segments == turtles
|
||||
assert len(turtles) == len(constants.STARTING_POSITIONS)
|
||||
|
||||
|
||||
def test_importing_the_snake_module_does_not_import_turtle_or_tkinter() -> None:
|
||||
assert not imports_turtle_or_tkinter("snake_game.snake")
|
||||
|
||||
|
||||
def test_head_is_the_first_segment() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
assert snake.head is created[0]
|
||||
|
||||
|
||||
def test_snake_starts_moving_to_the_right() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
assert snake.head.heading() == constants.RIGHT
|
||||
|
||||
|
||||
def test_move_goes_forward_by_the_move_distance() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.move()
|
||||
|
||||
assert ("forward", (constants.MOVE_DISTANCE,)) in created[0].calls
|
||||
assert created[0].position() == (constants.MOVE_DISTANCE, 0)
|
||||
|
||||
|
||||
def test_move_takes_each_segment_to_the_place_of_the_one_before_it() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.move()
|
||||
|
||||
assert positions(created) == [(20, 0), (0, 0), (-20, 0)]
|
||||
|
||||
|
||||
def test_move_keeps_the_segments_joined_while_turning() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.up()
|
||||
snake.move()
|
||||
snake.move()
|
||||
|
||||
assert positions(created) == [(0, 40), (0, 20), (0, 0)]
|
||||
|
||||
|
||||
def test_move_works_for_any_number_of_segments() -> None:
|
||||
snake, created = make_snake()
|
||||
for index in range(3, 6):
|
||||
extra = FakeSegment()
|
||||
extra.goto(-20 * index, 0)
|
||||
snake.segments.append(extra)
|
||||
created.append(extra)
|
||||
|
||||
snake.move()
|
||||
|
||||
assert positions(created) == [
|
||||
(20, 0),
|
||||
(0, 0),
|
||||
(-20, 0),
|
||||
(-40, 0),
|
||||
(-60, 0),
|
||||
(-80, 0),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("travel", list(OPPOSITE))
|
||||
@pytest.mark.parametrize("method", list(DIRECTIONS))
|
||||
def test_every_turn_is_accepted_unless_it_is_a_reversal(
|
||||
method: str, travel: int
|
||||
) -> None:
|
||||
snake, _ = make_snake_moving(travel)
|
||||
|
||||
getattr(snake, method)()
|
||||
|
||||
wanted = DIRECTIONS[method]
|
||||
is_reversal = wanted == OPPOSITE[travel]
|
||||
assert snake.head.heading() == (travel if is_reversal else wanted)
|
||||
|
||||
|
||||
def test_up_sets_the_head_to_90_degrees() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.up()
|
||||
|
||||
assert snake.head.heading() == 90
|
||||
|
||||
|
||||
def test_down_sets_the_head_to_270_degrees() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.down()
|
||||
|
||||
assert snake.head.heading() == 270
|
||||
|
||||
|
||||
def test_right_keeps_the_head_at_0_degrees() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.right()
|
||||
|
||||
assert snake.head.heading() == 0
|
||||
|
||||
|
||||
def test_left_is_ignored_while_moving_right() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.left()
|
||||
|
||||
assert snake.head.heading() == 0
|
||||
|
||||
|
||||
def test_two_key_presses_within_one_move_do_not_reverse_the_snake() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.up()
|
||||
snake.left()
|
||||
|
||||
assert snake.head.heading() == constants.UP
|
||||
|
||||
|
||||
def test_a_turn_is_accepted_after_the_move_that_followed_the_first_turn() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.up()
|
||||
snake.move()
|
||||
snake.left()
|
||||
|
||||
assert snake.head.heading() == constants.LEFT
|
||||
|
||||
|
||||
def test_add_segment_puts_a_light_green_square_with_the_pen_up_at_the_position() -> (
|
||||
None
|
||||
):
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.add_segment((100, -60))
|
||||
|
||||
new = created[-1]
|
||||
assert snake.segments[-1] is new
|
||||
assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls
|
||||
assert ("color", (constants.BODY_COLOR,)) in new.calls
|
||||
assert new.call_names().index("penup") < new.call_names().index("goto")
|
||||
assert new.position() == (100, -60)
|
||||
|
||||
|
||||
def test_extend_adds_one_segment_where_the_last_segment_is() -> None:
|
||||
snake, created = make_snake()
|
||||
last_place = created[-1].position()
|
||||
|
||||
snake.extend()
|
||||
|
||||
assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 1
|
||||
assert snake.segments[-1].position() == last_place
|
||||
|
||||
|
||||
def test_extend_adds_a_light_green_square_and_not_a_head() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.extend()
|
||||
|
||||
new = snake.segments[-1]
|
||||
assert isinstance(new, FakeSegment)
|
||||
assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls
|
||||
assert ("color", (constants.BODY_COLOR,)) in new.calls
|
||||
assert ("shape", (constants.HEAD_SHAPE,)) not in new.calls
|
||||
|
||||
|
||||
def test_extend_twice_adds_two_segments() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.extend()
|
||||
snake.extend()
|
||||
|
||||
assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 2
|
||||
|
||||
|
||||
def test_head_stays_the_first_segment_after_extend() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.extend()
|
||||
|
||||
assert snake.head is created[0]
|
||||
assert snake.segments[0] is created[0]
|
||||
|
||||
|
||||
def test_the_new_segment_follows_the_snake_and_the_body_stays_joined_when_turning() -> (
|
||||
None
|
||||
):
|
||||
snake, _ = make_snake()
|
||||
snake.up()
|
||||
snake.move()
|
||||
snake.extend()
|
||||
|
||||
snake.move()
|
||||
|
||||
places = positions(snake.segments)
|
||||
gaps = [
|
||||
math.dist(places[index], places[index + 1]) for index in range(len(places) - 1)
|
||||
]
|
||||
assert gaps == [constants.MOVE_DISTANCE] * (len(places) - 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"place", [(281, 0), (-281, 0), (0, 281), (0, -281), (300, 300), (0, 1000)]
|
||||
)
|
||||
def test_the_head_has_passed_the_wall_beyond_the_limit_on_any_side(
|
||||
place: tuple[int, int],
|
||||
) -> None:
|
||||
snake, _ = make_snake()
|
||||
snake.head.goto(*place)
|
||||
|
||||
assert snake.hits_wall()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"place", [(0, 0), (280, 0), (-280, 0), (0, 280), (0, -280), (280, -280)]
|
||||
)
|
||||
def test_the_head_has_not_passed_the_wall_at_or_inside_the_limit(
|
||||
place: tuple[int, int],
|
||||
) -> None:
|
||||
snake, _ = make_snake()
|
||||
snake.head.goto(*place)
|
||||
|
||||
assert not snake.hits_wall()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gap", [0, 5, 9])
|
||||
def test_the_head_touches_the_tail_closer_than_the_touch_distance(gap: int) -> None:
|
||||
snake, created = make_snake()
|
||||
created[1].goto(gap, 0)
|
||||
|
||||
assert snake.hits_tail()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gap", [10, 11, 20, 100])
|
||||
def test_the_head_does_not_touch_the_tail_at_or_beyond_the_touch_distance(
|
||||
gap: int,
|
||||
) -> None:
|
||||
snake, created = make_snake()
|
||||
created[1].goto(gap, 0)
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_the_head_touches_any_segment_of_a_long_tail() -> None:
|
||||
snake, created = make_snake()
|
||||
for index in range(3, 8):
|
||||
extra = FakeSegment()
|
||||
extra.goto(-20 * index, 0)
|
||||
snake.segments.append(extra)
|
||||
created.append(extra)
|
||||
created[6].goto(3, 4) # a distance of 5 from the head, far down the tail
|
||||
|
||||
assert snake.hits_tail()
|
||||
|
||||
|
||||
def test_a_new_snake_does_not_touch_its_tail() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_a_snake_with_only_a_head_has_no_tail_to_touch() -> None:
|
||||
snake, created = make_snake()
|
||||
snake.segments[:] = [created[0]]
|
||||
created[0].goto(0, 0)
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_the_new_segment_after_growing_does_not_touch_the_head() -> None:
|
||||
snake, _ = make_snake()
|
||||
snake.extend()
|
||||
snake.move()
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_the_snake_hits_its_tail_when_it_turns_into_it() -> None:
|
||||
snake, created = make_snake()
|
||||
for index in range(3, 5): # a snake of five segments
|
||||
extra = FakeSegment()
|
||||
extra.goto(-20 * index, 0)
|
||||
snake.segments.append(extra)
|
||||
created.append(extra)
|
||||
# Up, left and down, one move each, walk the head round a square of 20 by 20
|
||||
# pixels and back onto the place where the fifth segment is.
|
||||
for turn in (snake.up, snake.left, snake.down):
|
||||
turn()
|
||||
snake.move()
|
||||
|
||||
assert snake.hits_tail()
|
||||
|
||||
|
||||
def test_a_snake_of_four_segments_cannot_touch_its_tail_in_a_small_square() -> None:
|
||||
snake, created = make_snake()
|
||||
extra = FakeSegment()
|
||||
extra.goto(-60, 0)
|
||||
snake.segments.append(extra)
|
||||
created.append(extra)
|
||||
for turn in (snake.up, snake.left, snake.down):
|
||||
turn()
|
||||
snake.move()
|
||||
|
||||
assert not snake.hits_tail()
|
||||
"""Tests of the snake, with fakes instead of real turtles."""
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from fakes import (
|
||||
FakeSegment,
|
||||
imports_turtle_or_tkinter,
|
||||
install_fake_turtle,
|
||||
make_snake,
|
||||
)
|
||||
from snake_game import constants
|
||||
from snake_game.snake import Segment, Snake
|
||||
|
||||
DIRECTIONS = {
|
||||
"up": constants.UP,
|
||||
"down": constants.DOWN,
|
||||
"left": constants.LEFT,
|
||||
"right": constants.RIGHT,
|
||||
}
|
||||
OPPOSITE = {
|
||||
constants.UP: constants.DOWN,
|
||||
constants.DOWN: constants.UP,
|
||||
constants.LEFT: constants.RIGHT,
|
||||
constants.RIGHT: constants.LEFT,
|
||||
}
|
||||
|
||||
|
||||
def positions(segments: Sequence[Segment]) -> list[tuple[float, float]]:
|
||||
"""Return where every segment is, head first."""
|
||||
return [segment.position() for segment in segments]
|
||||
|
||||
|
||||
def make_snake_moving(direction: int) -> tuple[Snake, list[FakeSegment]]:
|
||||
"""Make a snake whose last move went in the given direction."""
|
||||
snake, created = make_snake()
|
||||
created[0].setheading(direction)
|
||||
snake.move()
|
||||
return snake, created
|
||||
|
||||
|
||||
def test_snake_has_one_segment_per_starting_position() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
assert len(snake.segments) == len(constants.STARTING_POSITIONS) == 3
|
||||
|
||||
|
||||
def test_segments_are_the_created_turtles_in_order() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
assert snake.segments == created
|
||||
|
||||
|
||||
def test_head_has_the_head_shape_and_no_colour_call() -> None:
|
||||
_, created = make_snake()
|
||||
|
||||
head = created[0]
|
||||
assert ("shape", (constants.HEAD_SHAPE,)) in head.calls
|
||||
assert "color" not in head.call_names()
|
||||
|
||||
|
||||
def test_body_segments_are_light_green_squares() -> None:
|
||||
_, created = make_snake()
|
||||
|
||||
assert len(created) > 1
|
||||
for segment in created[1:]:
|
||||
assert ("shape", (constants.SEGMENT_SHAPE,)) in segment.calls
|
||||
assert ("color", (constants.BODY_COLOR,)) in segment.calls
|
||||
|
||||
|
||||
def test_segments_are_placed_at_the_starting_positions_head_first() -> None:
|
||||
_, created = make_snake()
|
||||
|
||||
assert positions(created) == [
|
||||
(float(x), float(y)) for x, y in constants.STARTING_POSITIONS
|
||||
]
|
||||
|
||||
|
||||
def test_pen_is_lifted_before_a_segment_is_moved() -> None:
|
||||
_, created = make_snake()
|
||||
|
||||
for segment in created:
|
||||
names = segment.call_names()
|
||||
assert names.count("goto") == 1
|
||||
assert names.index("penup") < names.index("goto")
|
||||
|
||||
|
||||
def test_create_snake_adds_three_more_segments_when_called_again() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.create_snake()
|
||||
|
||||
assert len(snake.segments) == 2 * len(constants.STARTING_POSITIONS)
|
||||
|
||||
|
||||
def test_default_segment_factory_makes_turtles(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
turtles, _ = install_fake_turtle(monkeypatch)
|
||||
|
||||
snake = Snake()
|
||||
|
||||
assert snake.segments == turtles
|
||||
assert len(turtles) == len(constants.STARTING_POSITIONS)
|
||||
|
||||
|
||||
def test_importing_the_snake_module_does_not_import_turtle_or_tkinter() -> None:
|
||||
assert not imports_turtle_or_tkinter("snake_game.snake")
|
||||
|
||||
|
||||
def test_head_is_the_first_segment() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
assert snake.head is created[0]
|
||||
|
||||
|
||||
def test_snake_starts_moving_to_the_right() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
assert snake.head.heading() == constants.RIGHT
|
||||
|
||||
|
||||
def test_move_goes_forward_by_the_move_distance() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.move()
|
||||
|
||||
assert ("forward", (constants.MOVE_DISTANCE,)) in created[0].calls
|
||||
assert created[0].position() == (constants.MOVE_DISTANCE, 0)
|
||||
|
||||
|
||||
def test_move_takes_each_segment_to_the_place_of_the_one_before_it() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.move()
|
||||
|
||||
assert positions(created) == [(20, 0), (0, 0), (-20, 0)]
|
||||
|
||||
|
||||
def test_move_keeps_the_segments_joined_while_turning() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.up()
|
||||
snake.move()
|
||||
snake.move()
|
||||
|
||||
assert positions(created) == [(0, 40), (0, 20), (0, 0)]
|
||||
|
||||
|
||||
def test_move_works_for_any_number_of_segments() -> None:
|
||||
snake, created = make_snake()
|
||||
for index in range(3, 6):
|
||||
extra = FakeSegment()
|
||||
extra.goto(-20 * index, 0)
|
||||
snake.segments.append(extra)
|
||||
created.append(extra)
|
||||
|
||||
snake.move()
|
||||
|
||||
assert positions(created) == [
|
||||
(20, 0),
|
||||
(0, 0),
|
||||
(-20, 0),
|
||||
(-40, 0),
|
||||
(-60, 0),
|
||||
(-80, 0),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("travel", list(OPPOSITE))
|
||||
@pytest.mark.parametrize("method", list(DIRECTIONS))
|
||||
def test_every_turn_is_accepted_unless_it_is_a_reversal(
|
||||
method: str, travel: int
|
||||
) -> None:
|
||||
snake, _ = make_snake_moving(travel)
|
||||
|
||||
getattr(snake, method)()
|
||||
|
||||
wanted = DIRECTIONS[method]
|
||||
is_reversal = wanted == OPPOSITE[travel]
|
||||
assert snake.head.heading() == (travel if is_reversal else wanted)
|
||||
|
||||
|
||||
def test_up_sets_the_head_to_90_degrees() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.up()
|
||||
|
||||
assert snake.head.heading() == 90
|
||||
|
||||
|
||||
def test_down_sets_the_head_to_270_degrees() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.down()
|
||||
|
||||
assert snake.head.heading() == 270
|
||||
|
||||
|
||||
def test_right_keeps_the_head_at_0_degrees() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.right()
|
||||
|
||||
assert snake.head.heading() == 0
|
||||
|
||||
|
||||
def test_left_is_ignored_while_moving_right() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.left()
|
||||
|
||||
assert snake.head.heading() == 0
|
||||
|
||||
|
||||
def test_two_key_presses_within_one_move_do_not_reverse_the_snake() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.up()
|
||||
snake.left()
|
||||
|
||||
assert snake.head.heading() == constants.UP
|
||||
|
||||
|
||||
def test_a_turn_is_accepted_after_the_move_that_followed_the_first_turn() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.up()
|
||||
snake.move()
|
||||
snake.left()
|
||||
|
||||
assert snake.head.heading() == constants.LEFT
|
||||
|
||||
|
||||
def test_add_segment_puts_a_light_green_square_with_the_pen_up_at_the_position() -> (
|
||||
None
|
||||
):
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.add_segment((100, -60))
|
||||
|
||||
new = created[-1]
|
||||
assert snake.segments[-1] is new
|
||||
assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls
|
||||
assert ("color", (constants.BODY_COLOR,)) in new.calls
|
||||
assert new.call_names().index("penup") < new.call_names().index("goto")
|
||||
assert new.position() == (100, -60)
|
||||
|
||||
|
||||
def test_extend_adds_one_segment_where_the_last_segment_is() -> None:
|
||||
snake, created = make_snake()
|
||||
last_place = created[-1].position()
|
||||
|
||||
snake.extend()
|
||||
|
||||
assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 1
|
||||
assert snake.segments[-1].position() == last_place
|
||||
|
||||
|
||||
def test_extend_adds_a_light_green_square_and_not_a_head() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.extend()
|
||||
|
||||
new = snake.segments[-1]
|
||||
assert isinstance(new, FakeSegment)
|
||||
assert ("shape", (constants.SEGMENT_SHAPE,)) in new.calls
|
||||
assert ("color", (constants.BODY_COLOR,)) in new.calls
|
||||
assert ("shape", (constants.HEAD_SHAPE,)) not in new.calls
|
||||
|
||||
|
||||
def test_extend_twice_adds_two_segments() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
snake.extend()
|
||||
snake.extend()
|
||||
|
||||
assert len(snake.segments) == len(constants.STARTING_POSITIONS) + 2
|
||||
|
||||
|
||||
def test_head_stays_the_first_segment_after_extend() -> None:
|
||||
snake, created = make_snake()
|
||||
|
||||
snake.extend()
|
||||
|
||||
assert snake.head is created[0]
|
||||
assert snake.segments[0] is created[0]
|
||||
|
||||
|
||||
def test_the_new_segment_follows_the_snake_and_the_body_stays_joined_when_turning() -> (
|
||||
None
|
||||
):
|
||||
snake, _ = make_snake()
|
||||
snake.up()
|
||||
snake.move()
|
||||
snake.extend()
|
||||
|
||||
snake.move()
|
||||
|
||||
places = positions(snake.segments)
|
||||
gaps = [
|
||||
math.dist(places[index], places[index + 1]) for index in range(len(places) - 1)
|
||||
]
|
||||
assert gaps == [constants.MOVE_DISTANCE] * (len(places) - 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"place", [(281, 0), (-281, 0), (0, 281), (0, -281), (300, 300), (0, 1000)]
|
||||
)
|
||||
def test_the_head_has_passed_the_wall_beyond_the_limit_on_any_side(
|
||||
place: tuple[int, int],
|
||||
) -> None:
|
||||
snake, _ = make_snake()
|
||||
snake.head.goto(*place)
|
||||
|
||||
assert snake.hits_wall()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"place", [(0, 0), (280, 0), (-280, 0), (0, 280), (0, -280), (280, -280)]
|
||||
)
|
||||
def test_the_head_has_not_passed_the_wall_at_or_inside_the_limit(
|
||||
place: tuple[int, int],
|
||||
) -> None:
|
||||
snake, _ = make_snake()
|
||||
snake.head.goto(*place)
|
||||
|
||||
assert not snake.hits_wall()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gap", [0, 5, 9])
|
||||
def test_the_head_touches_the_tail_closer_than_the_touch_distance(gap: int) -> None:
|
||||
snake, created = make_snake()
|
||||
created[1].goto(gap, 0)
|
||||
|
||||
assert snake.hits_tail()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gap", [10, 11, 20, 100])
|
||||
def test_the_head_does_not_touch_the_tail_at_or_beyond_the_touch_distance(
|
||||
gap: int,
|
||||
) -> None:
|
||||
snake, created = make_snake()
|
||||
created[1].goto(gap, 0)
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_the_head_touches_any_segment_of_a_long_tail() -> None:
|
||||
snake, created = make_snake()
|
||||
for index in range(3, 8):
|
||||
extra = FakeSegment()
|
||||
extra.goto(-20 * index, 0)
|
||||
snake.segments.append(extra)
|
||||
created.append(extra)
|
||||
created[6].goto(3, 4) # a distance of 5 from the head, far down the tail
|
||||
|
||||
assert snake.hits_tail()
|
||||
|
||||
|
||||
def test_a_new_snake_does_not_touch_its_tail() -> None:
|
||||
snake, _ = make_snake()
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_a_snake_with_only_a_head_has_no_tail_to_touch() -> None:
|
||||
snake, created = make_snake()
|
||||
snake.segments[:] = [created[0]]
|
||||
created[0].goto(0, 0)
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_the_new_segment_after_growing_does_not_touch_the_head() -> None:
|
||||
snake, _ = make_snake()
|
||||
snake.extend()
|
||||
snake.move()
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_the_snake_hits_its_tail_when_it_turns_into_it() -> None:
|
||||
snake, created = make_snake()
|
||||
for index in range(3, 5): # a snake of five segments
|
||||
extra = FakeSegment()
|
||||
extra.goto(-20 * index, 0)
|
||||
snake.segments.append(extra)
|
||||
created.append(extra)
|
||||
# Up, left and down, one move each, walk the head round a square of 20 by 20
|
||||
# pixels and back onto the place where the fifth segment is.
|
||||
for turn in (snake.up, snake.left, snake.down):
|
||||
turn()
|
||||
snake.move()
|
||||
|
||||
assert snake.hits_tail()
|
||||
|
||||
|
||||
def test_a_snake_of_four_segments_cannot_touch_its_tail_in_a_small_square() -> None:
|
||||
snake, created = make_snake()
|
||||
extra = FakeSegment()
|
||||
extra.goto(-60, 0)
|
||||
snake.segments.append(extra)
|
||||
created.append(extra)
|
||||
for turn in (snake.up, snake.left, snake.down):
|
||||
turn()
|
||||
snake.move()
|
||||
|
||||
assert not snake.hits_tail()
|
||||
|
||||
|
||||
def test_reset_sends_every_old_segment_off_the_screen() -> None:
|
||||
snake, created = make_snake()
|
||||
snake.extend()
|
||||
old = list(created)
|
||||
|
||||
snake.reset()
|
||||
|
||||
assert len(old) == len(constants.STARTING_POSITIONS) + 1
|
||||
assert all(
|
||||
segment.position() == constants.SEGMENT_PARKING_POSITION for segment in old
|
||||
)
|
||||
|
||||
|
||||
def test_reset_makes_the_snake_again_at_the_starting_positions() -> None:
|
||||
snake, created = make_snake()
|
||||
snake.extend()
|
||||
snake.extend()
|
||||
old_count = len(created)
|
||||
|
||||
snake.reset()
|
||||
|
||||
assert len(snake.segments) == len(constants.STARTING_POSITIONS)
|
||||
assert positions(snake.segments) == [
|
||||
(float(x), float(y)) for x, y in constants.STARTING_POSITIONS
|
||||
]
|
||||
assert snake.segments == created[old_count:]
|
||||
|
||||
|
||||
def test_reset_makes_the_first_new_segment_the_head() -> None:
|
||||
snake, created = make_snake()
|
||||
old_head = snake.head
|
||||
|
||||
snake.reset()
|
||||
|
||||
new_head = created[len(constants.STARTING_POSITIONS)]
|
||||
assert snake.head is snake.segments[0] is new_head
|
||||
assert snake.head is not old_head
|
||||
assert ("shape", (constants.HEAD_SHAPE,)) in new_head.calls
|
||||
|
||||
|
||||
def test_reset_snake_moves_right_from_the_starting_positions() -> None:
|
||||
snake, _ = make_snake_moving(constants.UP)
|
||||
|
||||
snake.reset()
|
||||
snake.move()
|
||||
|
||||
assert snake.head.position() == (constants.MOVE_DISTANCE, 0)
|
||||
|
||||
|
||||
def test_reset_forgets_the_direction_of_the_last_move() -> None:
|
||||
snake, _ = make_snake_moving(constants.DOWN)
|
||||
|
||||
snake.reset()
|
||||
snake.up() # allowed again: the new snake moves right, not down
|
||||
|
||||
assert snake.head.heading() == constants.UP
|
||||
|
||||
Reference in New Issue
Block a user