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", The flow follows the lectures "Screen Setup and Creating a Snake Body",
"Animating the Snake Segments on Screen" and "Controlling the Snake with "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 Keypresses", the day-21 steps in which the snake eats food and the score rises,
the game ends. and the day-24 steps in which a collision resets the game and it goes on.
""" """
import time import time
@@ -102,7 +102,7 @@ class FoodLike(Protocol):
class ScoreboardLike(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. 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.""" """! @brief Add 1 to the score and write it."""
... ...
def game_over(self) -> None: def reset(self) -> None:
"""! @brief Write the game-over text.""" """! @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() scoreboard.increase_score()
def end_game_if_over(snake: Snake, scoreboard: ScoreboardLike) -> bool: def reset_if_collision(snake: Snake, scoreboard: ScoreboardLike) -> bool:
"""! @brief End the game when the head passes the wall or touches the tail. """! @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 snake The snake.
@param scoreboard The scoreboard that writes the game-over text. @param scoreboard The scoreboard that keeps the high score.
@return True when the game is over. @return True when the snake and the score were reset.
""" """
if snake.hits_wall() or snake.hits_tail(): if snake.hits_wall() or snake.hits_tail():
scoreboard.game_over() scoreboard.reset()
snake.reset()
return True return True
return False return False
@@ -212,9 +214,8 @@ def main() -> None:
`tkinter.TclError` ("invalid command name"), and `turtle` raises `tkinter.TclError` ("invalid command name"), and `turtle` raises
`turtle.Terminator` in some other calls once its window is gone. Both mean `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. "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 When the snake passes the wall or touches its tail the score and the snake are
updated so that the game-over text shows, and `screen.exitonclick()` waits for a reset and the game goes on, so the loop runs until the window is closed.
click.
`turtle` and `tkinter` are imported here and not at the top of the module, and `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 so are `food` and `scoreboard`, whose classes inherit from `Turtle`: importing
@@ -240,9 +241,6 @@ def main() -> None:
while game_is_on: while game_is_on:
play_frame(screen, snake) play_frame(screen, snake)
eat_food_if_close(snake, food, scoreboard) eat_food_if_close(snake, food, scoreboard)
if end_game_if_over(snake, scoreboard): reset_if_collision(snake, scoreboard)
game_is_on = False
screen.update()
screen.exitonclick()
except (Terminator, TclError): except (Terminator, TclError):
return # the window was closed: there is nothing left to do 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: def __init__(self) -> None:
self.increases = 0 self.increases = 0
self.game_overs = 0 self.resets = 0
def increase_score(self) -> None: def increase_score(self) -> None:
self.increases += 1 self.increases += 1
def game_over(self) -> None: def reset(self) -> None:
self.game_overs += 1 self.resets += 1
class FakeScreen: class FakeScreen:
+52 -45
View File
@@ -20,10 +20,10 @@ from snake_game.main import (
bind_keys, bind_keys,
configure_screen, configure_screen,
eat_food_if_close, eat_food_if_close,
end_game_if_over,
main, main,
play_frame, play_frame,
register_head_shape, register_head_shape,
reset_if_collision,
) )
from snake_game.snake import Snake 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", (20, 0)),
("goto", (constants.WALL_LIMIT, constants.WALL_LIMIT)), ("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 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() main()
scoreboard = turtles[4] 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 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: def test_nothing_is_reset_while_the_snake_is_inside_and_clear_of_its_tail() -> None:
snake, _ = make_snake() snake, created = make_snake()
scoreboard = FakeScoreboard() scoreboard = FakeScoreboard()
assert not end_game_if_over(snake, scoreboard) assert not reset_if_collision(snake, scoreboard)
assert scoreboard.game_overs == 0 assert scoreboard.resets == 0
assert len(created) == len(constants.STARTING_POSITIONS)
def test_the_game_is_over_when_the_head_passes_the_wall() -> None: def test_the_game_is_reset_when_the_head_passes_the_wall() -> None:
snake, _ = make_snake() snake, created = make_snake()
snake.head.goto(constants.WALL_LIMIT + 1, 0) snake.head.goto(constants.WALL_LIMIT + 1, 0)
scoreboard = FakeScoreboard() scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard) assert reset_if_collision(snake, scoreboard)
assert scoreboard.game_overs == 1 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() snake, created = make_snake()
created[1].goto(3, 4) created[1].goto(3, 4)
scoreboard = FakeScoreboard() scoreboard = FakeScoreboard()
assert end_game_if_over(snake, scoreboard) assert reset_if_collision(snake, scoreboard)
assert scoreboard.game_overs == 1 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] monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None: ) -> 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() main()
snake_head, scoreboard = turtles[0], turtles[4] old_head, scoreboard = turtles[0], turtles[4]
moves_to_pass_the_wall = constants.WALL_LIMIT // constants.MOVE_DISTANCE + 1 new_head = turtles[5]
assert snake_head.call_names().count("forward") == moves_to_pass_the_wall written = [call[1][0] for call in scoreboard.calls if call[0] == "write"]
assert snake_head.position() == ( assert old_head.position() == constants.SEGMENT_PARKING_POSITION
constants.MOVE_DISTANCE * moves_to_pass_the_wall, assert new_head.call_names().count("forward") == 2
0, assert new_head.position() == (2 * constants.MOVE_DISTANCE, 0)
)
assert scoreboard.calls[-1] == ( assert scoreboard.calls[-1] == (
"write", "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 "GAME OVER" not in written
assert screens[0].call_names().count("exitonclick") == 1 assert "exitonclick" not in screens[0].call_names()
assert sleeps == [constants.REFRESH_DELAY_SECONDS] * moves_to_pass_the_wall
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] monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None: ) -> None:
monkeypatch.setattr("snake_game.snake.Snake.hits_tail", lambda self: True) 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() main()
assert turtles[0].call_names().count("forward") == 1 assert turtles[0].call_names().count("forward") == 1
assert turtles[4].calls[-1][1][0] == "GAME OVER" assert turtles[0].position() == constants.SEGMENT_PARKING_POSITION
assert screens[0].call_names()[-1] == "exitonclick" assert "exitonclick" not in screens[0].call_names()
def test_main_does_not_move_the_snake_after_game_over(
monkeypatch: pytest.MonkeyPatch, sleeps: list[float]
) -> None:
turtles, screens = install_fake_turtle(monkeypatch, frames_before_close=100)
main()
updates = screens[0].call_names().count("update")
moves = turtles[0].call_names().count("forward")
assert updates == moves + 1 # one update per frame, and one for the last text
@pytest.mark.parametrize("closing_error", [FakeTerminatorError, FakeTclError]) @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, monkeypatch: pytest.MonkeyPatch,
sleeps: list[float], sleeps: list[float],
closing_error: type[Exception], closing_error: type[Exception],
@@ -381,7 +388,7 @@ def test_main_ends_quietly_when_the_window_is_closed_after_game_over(
main() 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() assert "exitonclick" not in screens[0].call_names()