Files
snake/snake.py
T

574 lines
18 KiB
Python

"""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)