Play on after a collision in the main flow

Refs #20
Task: MIL-003#5
This commit is contained in:
2026-10-13 14:33:58 +08:00
parent 19bc5c1431
commit bfbb58ba8c
3 changed files with 613 additions and 608 deletions
+16 -18
View File
@@ -3,8 +3,8 @@
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.
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
@@ -102,7 +102,7 @@ class FoodLike(Protocol):
class ScoreboardLike(Protocol):
"""! @brief What the eating and game-over checks need from the scoreboard.
"""! @brief What the eating and reset checks need from the scoreboard.
A `Scoreboard` fits this description, and so does a fake in a test.
"""
@@ -111,8 +111,8 @@ class ScoreboardLike(Protocol):
"""! @brief Add 1 to the score and write it."""
...
def game_over(self) -> None:
"""! @brief Write the game-over text."""
def reset(self) -> None:
"""! @brief Keep the best score and start the score again at 0."""
...
@@ -190,17 +190,19 @@ def eat_food_if_close(snake: Snake, food: FoodLike, scoreboard: ScoreboardLike)
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.
def reset_if_collision(snake: Snake, scoreboard: ScoreboardLike) -> bool:
"""! @brief Start again when the head passes the wall or touches the tail.
When the game is over the scoreboard writes the game-over text.
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 writes the game-over text.
@return True when the game is over.
@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.game_over()
scoreboard.reset()
snake.reset()
return True
return False
@@ -212,9 +214,8 @@ def main() -> None:
`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.
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
@@ -240,9 +241,6 @@ def main() -> None:
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()
reset_if_collision(snake, scoreboard)
except (Terminator, TclError):
return # the window was closed: there is nothing left to do
+3 -3
View File
@@ -138,13 +138,13 @@ class FakeScoreboard:
def __init__(self) -> None:
self.increases = 0
self.game_overs = 0
self.resets = 0
def increase_score(self) -> None:
self.increases += 1
def game_over(self) -> None:
self.game_overs += 1
def reset(self) -> None:
self.resets += 1
class FakeScreen:
+52 -45
View File
@@ -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()