"""Snake: A CLI-based Snake game using curses.""" import curses import random import time import signal import argparse import sys from typing import Tuple, List, Optional from multiprocessing import Process, Queue from constants import ( DEFAULT_SNAKE_LENGTH, DEFAULT_SNAKE_SPEED, MIN_SNAKE_SPEED, MAX_SNAKE_SPEED, HEAD_CHAR, BODY_CHAR, FOOD_CHAR, MIN_SCREEN_HEIGHT, MIN_SCREEN_WIDTH, SPEED_TO_MS, SCREEN_PADDING, MIN_SNAKE_LENGTH ) from theme import ThemeManager from sound_manager import SoundManager from high_score import HighScoreManager from game_config import GameConfig class Game: """Main game class.""" def __init__(self, stdscr, queue: Queue, args: argparse.Namespace): self.stdscr = stdscr self.queue = queue self.args = args # Load configuration self.config = GameConfig() self.high_score_manager = HighScoreManager() # Theme self.theme = ThemeManager() self.theme.initialize(stdscr) # Game state self.snake_length = self.config.get('snake_length', DEFAULT_SNAKE_LENGTH) self.snake_speed = self.config.get('snake_speed', DEFAULT_SNAKE_SPEED) self.enable_effects = args.no_effects is False and self.config.get('enable_effects', True) self.enable_music = args.music and self.config.get('enable_music', False) self.music_started_once = False # Game statistics self.game_start_time = 0.0 # Character customization self.head_char = self.config.get('head_char', HEAD_CHAR) self.body_char = self.config.get('body_char', BODY_CHAR) self.food_char = self.config.get('food_char', FOOD_CHAR) # Screen dimensions self.screen_height = 0 self.screen_width = 0 # Player name self.player_name = "" def load(self) -> None: """Load game and show setup menu.""" curses.set_escdelay(1) self.screen_height, self.screen_width = self.stdscr.getmaxyx() # Show welcome page self.effect('start') self.load_welcome() # Ask for player name self.player_name = self.ask_question( "Enter your name: ", str, "Player" ) # Show high score for this player if exists stats = self.high_score_manager.get_player_stats(self.player_name) if stats['total_games'] > 0: self.print_center( f"High Score: {stats['high_score']} | Games: {stats['total_games']}", 1, self.theme.get_color_pair('text') ) time.sleep(1) # Ask for snake length self.snake_length = self.ask_question( f"Snake Length (1 to {self.screen_width - 2*SCREEN_PADDING}, Default {self.snake_length}): ", int, self.snake_length, MIN_SNAKE_LENGTH, self.screen_width - 2*SCREEN_PADDING ) # Ask for snake speed self.snake_speed = self.ask_question( f"Snake Speed (1 to 10, Default {self.snake_speed}): ", int, self.snake_speed, MIN_SNAKE_SPEED, MAX_SNAKE_SPEED ) # Ask for Music (only if not disabled via CLI) if not args.no_music: enable_music = (self.ask_question( f"Turn on Music (Y/n): ", str, 'Y' )).upper() == 'Y' self.enable_music = enable_music self.config.set('enable_music', enable_music) # Ask for Effects (only if not disabled via CLI) if not args.no_effects: enable_effects = (self.ask_question( f"Turn on Sound Effects (Y/n): ", str, 'Y' )).upper() == 'Y' self.enable_effects = enable_effects self.config.set('enable_effects', enable_effects) # Save preferences self.config.set('snake_length', self.snake_length) self.config.set('snake_speed', self.snake_speed) # Start music if self.enable_music: if self.music_started_once: self.music_unpause() else: self.music_start() else: self.music_pause() # Start game self.start_game() def start_game(self) -> None: """Main game loop.""" self.hide_cursor() self.stdscr.nodelay(1) # Validate screen size if self.screen_height < MIN_SCREEN_HEIGHT or self.screen_width < MIN_SCREEN_WIDTH: self.print_center("Terminal window is too small!", 0, self.theme.get_color_pair('food')) time.sleep(2) return # Set timeout based on snake speed self.stdscr.timeout(SPEED_TO_MS[self.snake_speed]) # Initialize snake at center snake: List[List[int]] = [ [self.screen_height // 2, self.snake_length - i] for i in range(self.snake_length) ] # Get food def get_new_food() -> List[int]: while True: nf = [ random.randint(1, self.screen_height - 2), random.randint(1, self.screen_width - 2) ] if nf not in snake: return nf food = get_new_food() # Game state key = curses.KEY_RIGHT score = self.snake_length self.game_start_time = time.time() # Main game loop while True: next_key = self.stdscr.getch() # Handle input if next_key != -1: key = self._handle_input(key, next_key) if key is None: # Quit from menu return # Calculate new head position head = snake[0] new_head = self._calculate_new_head(head, key) # Check wall collisions if self._check_wall_collision(new_head): self.effect('wall') break # Check self collisions if new_head in snake: self.effect('self') break # Add new head snake.insert(0, new_head) # Check if food eaten if snake[0] == food: self.effect('eat') score += 1 food = get_new_food() else: snake.pop() # Render frame self._render_frame(snake, food, score) # Game over self._handle_game_over(score, self.snake_length) def _handle_input(self, current_key: int, next_key: int) -> Optional[int]: """Handle player input and return new direction, or None if quitting.""" # Arrow key handling if next_key == curses.KEY_DOWN and current_key != curses.KEY_UP: return next_key elif next_key == curses.KEY_UP and current_key != curses.KEY_DOWN: return next_key elif next_key == curses.KEY_LEFT and current_key != curses.KEY_RIGHT: return next_key elif next_key == curses.KEY_RIGHT and current_key != curses.KEY_LEFT: return next_key elif next_key == 27: # ESC - pause menu return self._handle_pause_menu(current_key) elif next_key == ord('r'): # Restart self.start_game() return None return current_key def _handle_pause_menu(self, previous_key: int) -> Optional[int]: """Handle pause menu. Returns new key or None if quitting.""" self._apply_music_state(paused=True) duration = int(time.time() - self.game_start_time) menu_text = f"GAME PAUSED (elapsed: {duration}s)" music_status = "ON" if self.enable_music else "OFF" effects_status = "ON" if self.enable_effects else "OFF" options = f"ESC Resume | R Restart | M Menu | M(U)sic {music_status} | (E)ffects {effects_status} | Q Quit" self.print_center(menu_text, 0, self.theme.get_color_pair('food')) self.print_center(options, 2, self.theme.get_color_pair('snake')) while True: key = self.stdscr.getch() if key == 27: # ESC - resume self._apply_music_state(paused=False) return previous_key elif key == ord('r'): # Restart self._apply_music_state(paused=False) self.start_game() return None elif key == ord('m'): # Menu self.load() return None elif key == ord('u'): # Toggle music self.enable_music = not self.enable_music self.config.set('enable_music', self.enable_music) self._apply_music_state(paused=True) music_status = "ON" if self.enable_music else "OFF" effects_status = "ON" if self.enable_effects else "OFF" options = f"ESC Resume | R Restart | M Menu | M(U)sic {music_status} | (E)ffects {effects_status} | Q Quit" self.print_center(menu_text, 0, self.theme.get_color_pair('food')) self.print_center(options, 2, self.theme.get_color_pair('snake')) elif key == ord('e'): # Toggle effects self.enable_effects = not self.enable_effects self.config.set('enable_effects', self.enable_effects) music_status = "ON" if self.enable_music else "OFF" effects_status = "ON" if self.enable_effects else "OFF" options = f"ESC Resume | R Restart | M Menu | M(U)sic {music_status} | (E)ffects {effects_status} | Q Quit" self.print_center(menu_text, 0, self.theme.get_color_pair('food')) self.print_center(options, 2, self.theme.get_color_pair('snake')) elif key == ord('q'): # Quit self.exit_game() def _calculate_new_head(self, head: List[int], key: int) -> List[int]: """Calculate new head position based on direction.""" y, x = head if key == curses.KEY_DOWN: return [y + 1, x] elif key == curses.KEY_UP: return [y - 1, x] elif key == curses.KEY_LEFT: return [y, x - 1] elif key == curses.KEY_RIGHT: return [y, x + 1] return head def _check_wall_collision(self, head: List[int]) -> bool: """Check if head hit a wall.""" return (head[0] in [0, self.screen_height - 1] or head[1] in [0, self.screen_width - 1]) def _render_frame(self, snake: List[List[int]], food: List[int], score: int) -> None: """Render the game frame.""" self.clear_screen() # Draw border self.stdscr.attron(curses.color_pair(self.theme.get_color_pair('text'))) self.stdscr.border(0) self.stdscr.attroff(curses.color_pair(self.theme.get_color_pair('text'))) # Draw snake for i, (y, x) in enumerate(snake): char = self.head_char if i == 0 else self.body_char self.stdscr.addch(y, x, char, curses.color_pair(self.theme.get_color_pair('snake'))) # Draw food self.stdscr.addch( food[0], food[1], self.food_char, curses.color_pair(self.theme.get_color_pair('food')) ) # Draw score elapsed = int(time.time() - self.game_start_time) score_text = f' {self.player_name} | Length: {score} @ {self.snake_speed}MPH {elapsed}s ' self.stdscr.addstr( 0, 2, score_text, curses.color_pair(self.theme.get_color_pair('text')) ) self.refresh_screen() def _handle_game_over(self, score: int, initial_length: int) -> None: """Handle game over screen.""" self.effect('over') # Save game self.high_score_manager.save_game(self.player_name, score, initial_length, self.snake_speed) stats = self.high_score_manager.get_player_stats(self.player_name) elapsed = int(time.time() - self.game_start_time) self.print_center( f"{self.player_name} - GAME OVER! SCORE: {score} (Time: {elapsed}s)", -1, self.theme.get_color_pair('food') ) self.print_center( f"Best: {stats['high_score']} | Games: {stats['total_games']}", 0, self.theme.get_color_pair('text') ) self.print_center( "(R)estart | (M)enu | (Q)uit", 2, self.theme.get_color_pair('snake') ) self.stdscr.nodelay(0) while True: key = self.stdscr.getch() if key == ord('r'): self.start_game() return elif key == ord('q'): self.exit_game() elif key == ord('m'): self.load() return def print_center( self, value: str, y_offset: int = 0, color: int = 3 ) -> None: """Print text centered on screen.""" y = self.screen_height // 2 x = (self.screen_width - len(value)) // 2 if x < 0: x = 1 self.stdscr.addstr(int(y + y_offset), int(x), value, curses.color_pair(color)) self.refresh_screen() def ask_question( self, question: str, value_type: type, value_default, value_min: int = 0, value_max: int = 0 ): """Ask user a question and validate the response.""" curses.curs_set(1) y = self.screen_height // 2 x = (self.screen_width - len(question)) // 2 if x < 0: x = 1 while True: self.clear_screen() self.stdscr.addstr(y, x, question) self.refresh_screen() curses.noecho() curses.cbreak() answer = "" while True: key = self.stdscr.getch() if key == curses.KEY_ENTER or key in [10, 13]: break elif key == curses.KEY_BACKSPACE or key == 127: if len(answer) > 0: answer = answer[:-1] self.stdscr.addch(ord('\b')) self.stdscr.addch(ord(' ')) self.stdscr.addch(ord('\b')) elif 32 <= key <= 126: answer += chr(key) self.stdscr.addch(key) self.refresh_screen() # Validate input if value_type == int: if not answer: # Empty answer, use default curses.curs_set(0) return value_default try: answer = int(answer) if value_min <= answer <= value_max: curses.curs_set(0) return answer except ValueError: pass # Invalid input, show error and retry self.print_center( f"ERROR: Must be {value_min}-{value_max}", 1, self.theme.get_color_pair('food') ) time.sleep(2) else: # String input if not answer: answer = value_default curses.curs_set(0) return answer def load_welcome(self) -> None: """Show welcome screen.""" self.hide_cursor() self.clear_screen() self.print_center("█▓▒░ Welcome to Snake CLI! ░▒▓█", 0, self.theme.get_color_pair('food')) self.print_center("by mReschke Productions", 2, self.theme.get_color_pair('snake')) time.sleep(2) def hide_cursor(self) -> None: """Hide the cursor.""" curses.curs_set(0) def show_cursor(self) -> None: """Show the cursor.""" curses.curs_set(1) def clear_screen(self) -> None: """Clear the screen.""" self.stdscr.clear() def refresh_screen(self) -> None: """Refresh the screen.""" self.stdscr.refresh() def exit_game(self) -> None: """Exit the game.""" self.queue.put('quit') exit(0) def effect(self, effect: str) -> None: """Play a sound effect.""" if self.enable_effects: self.queue.put(effect) def _apply_music_state(self, paused: bool = False) -> None: """Apply current music setting and requested pause state.""" if not self.enable_music: self.music_pause() return if not self.music_started_once: self.music_start() if paused: self.music_pause() return if paused: self.music_pause() else: self.music_unpause() def music_start(self) -> None: """Start background music.""" self.music_started_once = True self.queue.put('music.start') def music_pause(self) -> None: """Pause music.""" self.queue.put('music.pause') def music_unpause(self) -> None: """Resume music.""" self.queue.put('music.unpause') def main(stdscr, queue: Queue, args: argparse.Namespace) -> None: """Main entry point.""" try: game = Game(stdscr, queue, args) game.load() except KeyboardInterrupt: exit(0) def parse_args() -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser(description='Snake CLI Game') parser.add_argument( '--music', action='store_true', help='Enable background music' ) parser.add_argument( '--no-effects', action='store_true', help='Disable sound effects' ) parser.add_argument( '--no-music', action='store_true', help='Disable music option in menu' ) return parser.parse_args() if __name__ == "__main__": try: signal.signal(signal.SIGINT, lambda sig, frame: exit(0)) args = parse_args() queue = Queue() # Start sound process sound_manager = SoundManager() p = Process(target=sound_manager.process_queue, args=(queue,)) p.daemon = True p.start() # Start game curses.wrapper(main, queue, args) # Cleanup queue.put('quit') p.join(timeout=2) if p.is_alive(): p.terminate() except KeyboardInterrupt: exit(0)