574 lines
18 KiB
Python
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)
|
|
|