diff --git a/Snake/.gitignore b/Snake/.gitignore new file mode 100644 index 00000000000..505a3b1ca27 --- /dev/null +++ b/Snake/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/Snake/.python-version b/Snake/.python-version new file mode 100644 index 00000000000..24ee5b1be99 --- /dev/null +++ b/Snake/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Snake/README.md b/Snake/README.md new file mode 100644 index 00000000000..f886d3f321a --- /dev/null +++ b/Snake/README.md @@ -0,0 +1,66 @@ +# SnakePix + +SnakePix is a terminal-based clone of the classic Snake game built with Python and `curses`. + +## Description + +A polished terminal game with a fixed-size board, responsive controls, score tracking, and a main menu for choosing difficulty. The game renders a bordered arena, displays score live, and shows a game over screen with restart options. + +## Features + +- Fixed board size: `72 x 36` +- Live score display during gameplay +- Difficulty menu with three speeds: `EASY`, `MEDIUM`, `HARD` +- `WASD` and arrow key movement +- Auto-pause when terminal window is too small +- Simple snake body rendered as `@` +- Food rendered as `*` +- Game over screen with `MAIN MENU` and `QUIT` options +- No external dependencies beyond Python and `curses` + +## Requirements + +- Python 3.13+ +- Terminal with `curses` support + +## Installation + +1. Linux or Mac - Curses comes pre-installed with python3 + +2. Windows - Run + ```bash + pip3 install -r requirements.txt + +## Running the Game + +From the project root: + + ```bash + python main.py + ``` + +## Controls + +- `W` or `Up Arrow`: move up +- `S` or `Down Arrow`: move down +- `A` or `Left Arrow`: move left +- `D` or `Right Arrow`: move right +- `Q`: quit from the main menu screen +- `Enter`: select menu options + +## Project Structure + +- `main.py` — application entry point and main loop +- `menu.py` — difficulty selection menu +- `game.py` — main game loop, snake movement, collision detection, and food handling +- `arena.py` — arena rendering, score display, and boundary drawing +- `snake.py` — snake body representation, movement, and collision logic +- `utils.py` — food generation and drawing utilities +- `pause.py` — resize handling when the terminal is too small +- `constants.py` — board dimensions and input key constants + +## Notes + +- The terminal should be at least `72` columns by `36` rows for the game to display correctly. +- The game will pause if the terminal is resized smaller than the required board dimensions. +- The snake cannot reverse directly into itself (up/down and left/right inversions are blocked). diff --git a/Snake/arena.py b/Snake/arena.py new file mode 100644 index 00000000000..cc29a2d2ae7 --- /dev/null +++ b/Snake/arena.py @@ -0,0 +1,40 @@ +import curses +from constants import * +from pause import pause + +def arena(screen, score = 0): + height, width = screen.getmaxyx() + + if height < HEIGHT or width < WIDTH: + pause(screen, height, width) + screen.refresh() + else: + try: + for r in range(HEIGHT): + if r == 0: + screen.addch(r, 0, curses.ACS_ULCORNER) + for c in range(1, WIDTH - 1): + screen.addch(r, c, curses.ACS_HLINE) + screen.addch(r, WIDTH - 1, curses.ACS_URCORNER) + elif r == 1: + score_string = f"SCORE : {score}" + screen.addstr(r, 1, score_string) + for c in (0, WIDTH-1): + screen.addch(r, c, curses.ACS_VLINE) + elif r == HEIGHT - 1: + screen.addch(r, 0, curses.ACS_LLCORNER) + for c in range(1, WIDTH - 1): + screen.addch(r, c, curses.ACS_HLINE) + screen.addch(r, WIDTH - 1, curses.ACS_LRCORNER) + elif r == 2: + screen.addch(r, 0, curses.ACS_LTEE) + for c in range(1, WIDTH - 1): + screen.addch(r, c, curses.ACS_HLINE) + screen.addch(r, WIDTH - 1, curses.ACS_RTEE) + else: + screen.addch(r, 0, curses.ACS_VLINE) + for c in range(1, WIDTH - 1): + screen.addch(r, c, " ") + screen.addch(r, WIDTH - 1, curses.ACS_VLINE) + except curses.error: + pass diff --git a/Snake/constants.py b/Snake/constants.py new file mode 100644 index 00000000000..9ecc5f06504 --- /dev/null +++ b/Snake/constants.py @@ -0,0 +1,18 @@ +#board +WIDTH = 72 +HEIGHT = 36 + +# wasd movement + +W = ord('w') +A = ord('a') +S = ord('s') +D = ord('d') + +# directions +UP = (0, 1) +DW = (0, -1) +LT = (-1, 0) +RT = (1, 0) + + diff --git a/Snake/game.py b/Snake/game.py new file mode 100644 index 00000000000..34f8312581a --- /dev/null +++ b/Snake/game.py @@ -0,0 +1,69 @@ +import curses +from arena import arena +import constants +from utils import * +from collections import deque +from time import sleep +from pause import * +from snake import Snake + +""" +order of execution +1. check if the previous state produced a collision +2. if it does then do not render the next state and quit to over screen +3. if no collision, then render the state on the screen +""" + + +def game(screen, setting): + if setting is None: + return + + snake = Snake([(12, 12), (12, 11), (12, 10)]) + score = 0 + + screen.nodelay(True) + try: + food = gen_food(snake) + + while True: + + r, c = screen.getmaxyx() + if r < HEIGHT or c < WIDTH: + pause(screen, r, c) + screen.refresh() + sleep(0.1) + continue + + key = screen.getch() + snake.change_direction(key) + snake.move() + + if snake.head() != food: + snake.removeTail() + else: + score += 1 + food = gen_food(snake) + + if snake.is_collision(): + screen.clear() + break + + screen.clear() + arena(screen, score) + snake.draw(screen) + draw_food(screen, food) + + screen.refresh() + + sleep(setting) + finally: + screen.nodelay(False) + return score + + + + + + + diff --git a/Snake/main.py b/Snake/main.py new file mode 100644 index 00000000000..ed7347a910b --- /dev/null +++ b/Snake/main.py @@ -0,0 +1,42 @@ +import curses +from time import sleep +from menu import * +from arena import arena +from pause import * +from game import * +from over import * +from sys import exit + +def main(screen): + screen.keypad(True) + curses.noecho() + curses.curs_set(0) + try: + while True: + screen.clear() + + height, width = screen.getmaxyx() + + if height < HEIGHT or width < WIDTH: + pause(screen, height, width) + else: + arena(screen) + screen.refresh() + setting = menu(screen) + score = game(screen, setting) + selection = over(screen, score) + + if selection == "MAIN MENU": + continue + else: + exit(0) + + screen.refresh() + key = screen.getch() + if key == ord('q'): + break + finally: + screen.keypad(False) + +if __name__ == "__main__": + curses.wrapper(main) diff --git a/Snake/menu.py b/Snake/menu.py new file mode 100644 index 00000000000..f842405ce8d --- /dev/null +++ b/Snake/menu.py @@ -0,0 +1,45 @@ +import curses +from constants import * +from arena import * + +def menu(screen): + menu_options = ['EASY', 'MEDIUM', 'HARD'] + + settings = { + 'EASY': 0.20, + 'MEDIUM': 0.10, + 'HARD': 0.05 + } + + heading = "SNAKE PIX!" + selection = 0 + + while True: + screen.clear() + height, width = screen.getmaxyx() + arena(screen) + try: + + if height < HEIGHT or width < WIDTH: + pause(screen, height, width) + screen.refresh() + continue + + screen.addstr(HEIGHT//3 - 6, (WIDTH // 2) - len(heading)//2 , heading) + screen.addstr(HEIGHT//3 + 6 + selection * 2, (WIDTH // 2) - 10, ">>") + for i, option in enumerate(menu_options): + screen.addstr(HEIGHT//3 + 6 + i * 2, (WIDTH // 2) - len(option)//2, option) + + screen.refresh() + except curses.error: + return + key = screen.getch() + + if key in (S, curses.KEY_DOWN): + selection = selection + 1 if selection < 2 else selection + elif key in (W, curses.KEY_UP): + selection = selection - 1 if selection > 0 else selection + elif key == ord('\n'): + return settings[menu_options[selection]] + + diff --git a/Snake/over.py b/Snake/over.py new file mode 100644 index 00000000000..f9316020eca --- /dev/null +++ b/Snake/over.py @@ -0,0 +1,36 @@ +from constants import * +from arena import * + +def over(screen, score): + + heading = "GAME OVER!" + selection = 0 + menu_options = ['MAIN MENU', 'QUIT'] + + while True: + screen.clear() + height, width = screen.getmaxyx() + arena(screen, score) + try: + + if height < HEIGHT or width < WIDTH: + raise curses.error + + score_string = f"FINAL SCORE : {score}" + screen.addstr(HEIGHT//3 - 6, (WIDTH // 2) - len(heading)//2 , heading) + screen.addstr(HEIGHT//3 - 3, (WIDTH // 2) - len(score_string)//2 , score_string) + screen.addstr(HEIGHT//3 + 6 + selection * 2, (WIDTH // 2) - 10, ">>") + for i, option in enumerate(menu_options): + screen.addstr(HEIGHT//3 + 6 + i * 2, (WIDTH // 2) - len(option)//2, option) + + screen.refresh() + except curses.error: + return + key = screen.getch() + + if key in (S, curses.KEY_DOWN): + selection = selection + 1 if selection < 1 else selection + elif key in (W, curses.KEY_UP): + selection = selection - 1 if selection > 0 else selection + elif key == ord('\n'): + return menu_options[selection] diff --git a/Snake/pause.py b/Snake/pause.py new file mode 100644 index 00000000000..102ca5bece8 --- /dev/null +++ b/Snake/pause.py @@ -0,0 +1,20 @@ +import curses +from constants import * +from arena import * + +def pause(screen, r, c): + + screen.clear() + + message = "--FIX THE TERMINAL SIZE--" + + if len('Pause') < c < len(message): + message = "Pause" + + if c < len(message): + message = "P" + + try: + screen.addstr(r // 2, max(0, c // 2 - len(message)//2), message) + except curses.error: + pass \ No newline at end of file diff --git a/Snake/requirements.txt b/Snake/requirements.txt new file mode 100644 index 00000000000..2a75d731103 --- /dev/null +++ b/Snake/requirements.txt @@ -0,0 +1,6 @@ +# Standard dependencies (if you have any) +# requests>=2.31.0 + +# Install curses wrapper ONLY if the target system is Windows +windows-curses; sys_platform == 'win32' + diff --git a/Snake/snake.py b/Snake/snake.py new file mode 100644 index 00000000000..7a75198e810 --- /dev/null +++ b/Snake/snake.py @@ -0,0 +1,51 @@ +from constants import * +from collections import deque +import curses + +class Snake: + + def __init__(self, starting_positions): + self.__body = deque(starting_positions) + self.__direction = (0, 1) + + def is_part_of(self, point): + return point in self.__body + + def draw(self, screen): + for r, c in self.__body: + try: + screen.addstr(r, c, "@") + except curses.error: + pass + + def move(self): + r, c = self.__body[0] + new_head = (r + self.__direction[0], c + self.__direction[1]) + self.__body.appendleft(new_head) + + def removeTail(self): + self.__body.pop() + + def change_direction(self, key): + if key in (W, curses.KEY_UP ) and self.__direction != (1, 0): + self.__direction = (-1, 0) + elif key in (S, curses.KEY_DOWN ) and self.__direction != (-1, 0): + self.__direction = (1, 0) + elif key in (A, curses.KEY_LEFT ) and self.__direction != (0, 1): + self.__direction = (0, -1) + elif key in (D, curses.KEY_RIGHT) and self.__direction != (0, -1): + self.__direction = (0, 1) + + def head(self): + return self.__body[0] + + def is_collision(self): + head = self.__body[0] + r, c = head + if r == 2 or r == HEIGHT-1 or c == 0 or c == WIDTH - 1: + return True + + if head in list(self.__body)[1:]: + return True + + return False \ No newline at end of file diff --git a/Snake/utils.py b/Snake/utils.py new file mode 100644 index 00000000000..de541db4a69 --- /dev/null +++ b/Snake/utils.py @@ -0,0 +1,12 @@ +from constants import * +import random + +def gen_food(snake): + while True: + pos = (random.randint(3, HEIGHT-2), random.randint(2, WIDTH-2)) + if not snake.is_part_of(pos): + return pos + +def draw_food(screen, food): + r, c = food + screen.addstr(r, c, "*") \ No newline at end of file