diff --git a/src/snake_game/__init__.py b/src/snake_game/__init__.py new file mode 100644 index 0000000..ba7d3fa --- /dev/null +++ b/src/snake_game/__init__.py @@ -0,0 +1,13 @@ +## @package snake_game +# @brief Snake game from Udemy's 100 Days of Code (day 21). +# +# @mainpage Snake Game +# +# A snake of three square segments moves across a Python turtle screen by +# itself and is steered with the arrow keys. It eats food, grows and keeps a +# score, and the game is over when the head passes the wall or touches the +# tail. The game uses the standard library only. +# +# The constants of the game are in constants.py. + +"""Snake game from Udemy's 100 Days of Code (day 21).""" diff --git a/src/snake_game/constants.py b/src/snake_game/constants.py new file mode 100644 index 0000000..4feb855 --- /dev/null +++ b/src/snake_game/constants.py @@ -0,0 +1,98 @@ +## @file constants.py +# @brief Constants of the snake game. +# +# Every value that the day-20 and day-21 lectures fix lives here, so that no other +# module contains a magic number. The turtle coordinate system has its centre +# at (0, 0); the 600 by 600 screen therefore reaches from -300 to 300 on both +# axes. A direction is a turtle heading in degrees: 0 points right and the +# angle grows counter-clockwise. + +"""Constants of the snake game, as fixed by the day-20 and day-21 lectures.""" + +from typing import Final + +## @brief Width of the screen in pixels. +SCREEN_WIDTH: Final[int] = 600 + +## @brief Height of the screen in pixels. +SCREEN_HEIGHT: Final[int] = 600 + +## @brief Background colour of the screen. +SCREEN_BACKGROUND_COLOR: Final[str] = "black" + +## @brief Title of the game window. +SCREEN_TITLE: Final[str] = "My Snake Game" + +## @brief Shape of every segment of the snake. +SEGMENT_SHAPE: Final[str] = "square" + +## @brief Colour of every segment of the snake. +SEGMENT_COLOR: Final[str] = "white" + +## @brief Where the segments are drawn when the game starts, from head to tail. +# +# The positions lie on one row, 20 pixels apart, which is the width of a +# default turtle square, so the segments touch without overlapping. +STARTING_POSITIONS: Final[tuple[tuple[int, int], ...]] = ((0, 0), (-20, 0), (-40, 0)) + +## @brief Distance in pixels that the head moves in one move. +MOVE_DISTANCE: Final[int] = 20 + +## @brief Direction up, as a turtle heading in degrees. +UP: Final[int] = 90 + +## @brief Direction down, as a turtle heading in degrees. +DOWN: Final[int] = 270 + +## @brief Direction left, as a turtle heading in degrees. +LEFT: Final[int] = 180 + +## @brief Direction right, as a turtle heading in degrees. +RIGHT: Final[int] = 0 + +## @brief Wait in seconds between two moves. +REFRESH_DELAY_SECONDS: Final[float] = 0.1 + +## @brief Shape of the food. +FOOD_SHAPE: Final[str] = "circle" + +## @brief Stretch factor of the food in both directions; 0.5 is half a default turtle. +FOOD_SIZE: Final[float] = 0.5 + +## @brief Colour of the food. +FOOD_COLOR: Final[str] = "blue" + +## @brief Speed of the food; `fastest` switches its animation off. +FOOD_SPEED: Final[str] = "fastest" + +## @brief How far from the centre the wall is, on every side, in pixels. +# +# The head must stay inside it, and the food is shown inside it. +WALL_LIMIT: Final[int] = 280 + +## @brief The snake eats the food when the head is closer to it than this, in pixels. +FOOD_COLLISION_DISTANCE: Final[int] = 15 + +## @brief Text in front of the score on the scoreboard. +SCORE_LABEL: Final[str] = "Score: " + +## @brief Colour of the scoreboard text. +SCOREBOARD_COLOR: Final[str] = "white" + +## @brief Where the scoreboard text is written: the top centre of the screen. +SCOREBOARD_POSITION: Final[tuple[int, int]] = (0, 270) + +## @brief Alignment of the scoreboard text around its position. +SCOREBOARD_ALIGNMENT: Final[str] = "center" + +## @brief Font of the scoreboard text: family, size and style. +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 Where the game-over text is written: the centre of the screen. +GAME_OVER_POSITION: Final[tuple[int, int]] = (0, 0) diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..89a3bc2 --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,127 @@ +"""Tests of the constants that the day-20 and day-21 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_segments_are_white_squares() -> None: + assert constants.SEGMENT_SHAPE == "square" + assert constants.SEGMENT_COLOR == "white" + + +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