"""SNAKE GAME - Colorful, feature-rich snake game with multiplayer, AI, and audio. Main Components: - Game loop: Main game state management (menu -> playing -> game_over) - Rendering: Grid-based rendering with interpolation for smooth movement - Audio: Synthetic sound generation (SFX + looped music tracks) - Settings: Categorized menu system with persistent JSON storage - Multiplayer: Solo/Duo modes with optional AI player 2 - Gameplay: Difficulty levels, obstacles, fruits with effects, timer mode """ import json import math import os import random import sys from array import array import pygame TOP_BAR = 60 FPS = 60 AUDIO_SAMPLE_RATE = 44100 UP = (0, -1) DOWN = (0, 1) LEFT = (-1, 0) RIGHT = (1, 0) DIFFICULTY_OPTIONS = [ {"name": "Easy", "move_interval": 145}, {"name": "Normal", "move_interval": 115}, {"name": "Hard", "move_interval": 90}, {"name": "Insane", "move_interval": 70}, ] GAME_MODE_OPTIONS = [ {"name": "Solo", "players": 1}, {"name": "Duo", "players": 2}, ] P2_CONTROL_OPTIONS = [ {"name": "Human", "ai": False}, {"name": "AI", "ai": True}, ] TIMER_OPTIONS = [ {"name": "Off", "seconds": 0}, {"name": "60s", "seconds": 60}, {"name": "90s", "seconds": 90}, {"name": "120s", "seconds": 120}, ] SNAKE_COLOR_OPTIONS = [ { "name": "Neon Green", "head": (0, 230, 118), "tail": (0, 120, 50), "outline": (0, 140, 60), }, { "name": "Ocean Blue", "head": (80, 175, 255), "tail": (30, 80, 180), "outline": (45, 95, 210), }, { "name": "Lava Red", "head": (255, 110, 75), "tail": (170, 35, 25), "outline": (190, 45, 35), }, { "name": "Cyber Gold", "head": (255, 205, 90), "tail": (160, 105, 20), "outline": (185, 130, 35), }, ] SKIN_OPTIONS = [ {"name": "Neon Green", "palette": SNAKE_COLOR_OPTIONS[0], "req_score": 0, "req_levels": 0}, {"name": "Ocean Blue", "palette": SNAKE_COLOR_OPTIONS[1], "req_score": 8, "req_levels": 0}, {"name": "Lava Red", "palette": SNAKE_COLOR_OPTIONS[2], "req_score": 14, "req_levels": 1}, {"name": "Cyber Gold", "palette": SNAKE_COLOR_OPTIONS[3], "req_score": 22, "req_levels": 2}, { "name": "Plasma Purple", "palette": {"head": (210, 110, 255), "tail": (110, 45, 165), "outline": (145, 70, 210)}, "req_score": 35, "req_levels": 4, }, { "name": "Frost White", "palette": {"head": (220, 245, 255), "tail": (120, 170, 205), "outline": (150, 190, 230)}, "req_score": 48, "req_levels": 6, }, ] TRAIL_OPTIONS = [ {"name": "Off", "req_score": 0, "req_levels": 0}, {"name": "Glow", "req_score": 10, "req_levels": 1}, {"name": "Dots", "req_score": 18, "req_levels": 2}, {"name": "Rainbow", "req_score": 30, "req_levels": 4}, ] EFFECT_OPTIONS = [ {"name": "Classic", "req_score": 0, "req_levels": 0}, {"name": "Pulse", "req_score": 15, "req_levels": 1}, {"name": "Spark", "req_score": 28, "req_levels": 3}, ] SFX_PACK_OPTIONS = [ {"name": "Classic", "pitch": 1.0, "bright": 1.0, "req_score": 0, "req_levels": 0}, {"name": "Retro", "pitch": 1.08, "bright": 1.15, "req_score": 16, "req_levels": 2}, {"name": "Crystal", "pitch": 1.2, "bright": 1.3, "req_score": 34, "req_levels": 5}, ] MUSIC_STYLE_UNLOCK_OPTIONS = [ {"name": "Melodic", "req_score": 0, "req_levels": 0}, {"name": "Arcade", "req_score": 20, "req_levels": 2}, ] PROGRESSION_TABS = ["Skins", "Trails", "Effects", "SFX Packs", "Music"] SECOND_SNAKE_PALETTE = { "head": (255, 120, 140), "tail": (165, 60, 90), "outline": (190, 70, 105), } MAP_SIZE_OPTIONS = [ {"name": "Small", "grid_w": 16, "grid_h": 14, "cell_size": 34}, {"name": "Medium", "grid_w": 20, "grid_h": 18, "cell_size": 30}, {"name": "Large", "grid_w": 28, "grid_h": 22, "cell_size": 24}, ] OBSTACLE_OPTIONS = [ {"name": "Off", "ratio": 0.0}, {"name": "Low", "ratio": 0.03}, {"name": "Medium", "ratio": 0.06}, {"name": "High", "ratio": 0.09}, ] FRUIT_TYPES = [ { "name": "Apple", "color": (255, 70, 100), "glow": (255, 70, 100, 50), "score": 1, "grow": 1, "speed_scale": 1.0, "speed_ticks": 0, "weight": 58, }, { "name": "Banana", "color": (255, 220, 90), "glow": (255, 220, 90, 60), "score": 2, "grow": 1, "speed_scale": 1.0, "speed_ticks": 0, "weight": 24, }, { "name": "Watermelon", "color": (115, 210, 100), "glow": (115, 210, 100, 60), "score": 3, "grow": 2, "speed_scale": 1.0, "speed_ticks": 0, "weight": 18, }, { "name": "Grape", "color": (145, 90, 210), "glow": (145, 90, 210, 60), "score": 2, "grow": 1, "speed_scale": 1.0, "speed_ticks": 0, "weight": 20, }, { "name": "Berry", "color": (180, 95, 255), "glow": (180, 95, 255, 55), "score": 2, "grow": 2, "speed_scale": 1.0, "speed_ticks": 0, "weight": 24, }, { "name": "Gold", "color": (255, 205, 70), "glow": (255, 205, 70, 65), "score": 5, "grow": 1, "speed_scale": 0.82, "speed_ticks": 80, "weight": 12, }, { "name": "Ice", "color": (120, 210, 255), "glow": (120, 210, 255, 60), "score": 2, "grow": 1, "speed_scale": 1.2, "speed_ticks": 65, "weight": 6, }, ] BAD_FOOD_TYPES = [ { "name": "Poison", "color": (70, 255, 160), "glow": (70, 255, 160, 70), "score": 0, "grow": 0, "weight": 1, "is_bad": True, } ] BAD_FOOD_CHANCE_OPTIONS = [ {"name": "Low", "chance": 0.08}, {"name": "Medium", "chance": 0.15}, {"name": "High", "chance": 0.25}, ] BAD_FOOD_EFFECT_OPTIONS = [ {"name": "Game Over", "mode": "game_over"}, {"name": "Penalty", "mode": "penalty"}, ] POWERUP_TYPES = [ { "name": "Star", "color": (255, 245, 90), "glow": (255, 245, 90, 75), "score_bonus": 4, "grow_bonus": 0, "speed_scale": 1.0, "speed_ticks": 0, "weight": 44, }, { "name": "Sprout", "color": (95, 245, 145), "glow": (95, 245, 145, 70), "score_bonus": 0, "grow_bonus": 3, "speed_scale": 1.0, "speed_ticks": 0, "weight": 34, }, { "name": "Clock", "color": (120, 205, 255), "glow": (120, 205, 255, 75), "score_bonus": 1, "grow_bonus": 0, "speed_scale": 1.28, "speed_ticks": 90, "weight": 22, }, ] # ============================================================================ # HELPER CLASSES # ============================================================================ class Particle: """Visual particle effect that moves and fades out over time.""" def __init__(self, x, y, color): angle = random.uniform(0, 2 * math.pi) speed = random.uniform(1.5, 4.0) self.x = x self.y = y self.vx = math.cos(angle) * speed self.vy = math.sin(angle) * speed self.life = random.randint(28, 56) self.max_life = self.life self.color = color self.radius = random.randint(2, 5) def update(self): self.x += self.vx * 0.4 self.y += self.vy * 0.4 self.vy += 0.05 self.life -= 1 def draw(self, surface): alpha = int(255 * (self.life / self.max_life)) r, g, b = self.color surf = pygame.Surface((self.radius * 2, self.radius * 2), pygame.SRCALPHA) pygame.draw.circle(surf, (r, g, b, alpha), (self.radius, self.radius), self.radius) surface.blit(surf, (int(self.x) - self.radius, int(self.y) - self.radius)) # ============================================================================ # MAIN GAME CLASS # ============================================================================ class SnakeGame: """Main game controller. Handles initialization, game loop, rendering, and input. State Management: - mode: 'menu', 'settings', 'playing', 'game_over' - snakes: List of snake bodies (list of grid positions) - scores: Current scores per player - obstacles: Set of blocked grid positions Settings: - All game settings (difficulty, colors, etc.) are saved to ~/snake_settings.json - Player must call _save_settings() after each setting change """ def __init__(self): pygame.mixer.pre_init(AUDIO_SAMPLE_RATE, -16, 1, 512) pygame.init() self.mode = "menu" self.menu_items = ["Start Game", "Progression", "Settings", "Quit"] self.menu_index = 0 self.settings_categories = ["Game", "Food", "Controls", "Sound", "Graphics", "Back"] self.settings_group_items = { "Game": ["Difficulty", "Game Mode", "Round Timer", "Sudden Death", "Obstacles", "Wrap Walls"], "Food": [ "Apple", "Banana", "Watermelon", "Grape", "Berry", "Gold", "Ice", "Poison Food", "Poison Chance", "Poison Effect", ], "Controls": ["P2 Control"], "Sound": ["Sound FX", "Music", "Music Volume"], "Graphics": ["Map Size"], } self.category_icons = { "Game": "[G]", "Food": "[F]", "Controls": "[C]", "Sound": "[S]", "Graphics": "[V]", "Back": "[<]", } self.settings_category_index = 0 self.settings_item_index = 0 self.settings_in_category = False self.progression_tab_index = 0 self.progression_item_index = 0 self.progression_rects = [] # Mouse interaction support self.menu_rects = [] # List of (pygame.Rect, action_callable) for menu items self.settings_rects = [] # List of (pygame.Rect, action_callable) for settings items self.difficulty_index = 1 self.game_mode_index = 0 self.p2_control_index = 0 self.snake_skin_index = 0 self.trail_style_index = 0 self.effect_style_index = 0 self.sfx_pack_index = 0 self.map_size_index = 1 self.obstacle_index = 0 self.wrap_walls = False self.timer_index = 2 self.sudden_death_enabled = True self.sound_enabled = True self.music_enabled = False self.music_volume = 0.75 self.music_style_index = 0 self.fruit_enabled = { "Apple": True, "Banana": True, "Watermelon": True, "Grape": True, "Berry": True, "Gold": True, "Ice": True, } self.bad_food_enabled = True self.bad_food_chance_index = 0 self.bad_food_effect_index = 1 self.levels_beaten_total = 0 self.levels_beaten_run = 0 self.next_level_score_target = 8 self.bg_dark = (18, 18, 30) self.bg_cell_a = (22, 22, 38) self.bg_cell_b = (26, 26, 42) self.score_col = (225, 225, 255) self.accent = (120, 135, 255) self.border_col = (50, 50, 80) self.hint_col = (165, 175, 225) self.obstacle_col = (85, 88, 120) self.tick = 0 self.move_timer = 0 self.paused = False self.speed_effect_scale = 1.0 self.speed_effect_ticks = 0 self.powerup = None self.powerup_spawn_timer_ms = random.randint(5500, 10000) self.powerup_lifetime_ms = 0 self.game_over_text = "" self.round_time_left_ms = 0 self.sudden_death_active = False self.sudden_death_counter = 0 self.particles = [] self.obstacles = set() self.cell_size = 30 self.grid_w = 20 self.grid_h = 18 self.width = 0 self.height = 0 self.screen = None self.clock = pygame.time.Clock() self.font = pygame.font.SysFont("consolas", 22, bold=True) self.small_font = pygame.font.SysFont("consolas", 18) self.big_font = pygame.font.SysFont("consolas", 48, bold=True) self.menu_font = pygame.font.SysFont("consolas", 30, bold=True) self.score_file = os.path.join(os.path.dirname(__file__), "snake_high_score.txt") self.high_score = self._load_high_score() self.settings_file = os.path.join(os.path.dirname(__file__), "snake_settings.json") self.audio_available = False self.sfx_eat = None self.sfx_game_over = None self.sfx_tick = None self.music_melodic = None self.music_arcade = None self.music_track = None self.music_channel = None self._apply_map_settings() self._init_audio() self._load_settings() self.reset_game() def _init_audio(self): try: if pygame.mixer.get_init() is None: pygame.mixer.init(AUDIO_SAMPLE_RATE, -16, 1, 512) pygame.mixer.set_num_channels(12) pygame.mixer.set_reserved(1) self.music_channel = pygame.mixer.Channel(0) self.music_channel.set_volume(self.music_volume) self.audio_available = True except pygame.error: self.audio_available = False return self.sfx_eat = self._make_sfx_eat() self.sfx_game_over = self._make_sfx_game_over() self.sfx_tick = self._make_sfx_tick() self.music_melodic = self._make_music_melodic() self.music_arcade = self._make_music_arcade() self._update_music_track() def _make_tone(self, frequency, duration_ms, volume): sample_count = int(AUDIO_SAMPLE_RATE * duration_ms / 1000) buffer = array("h") amplitude = int(32767 * max(0.0, min(1.0, volume))) for i in range(sample_count): sample = int(amplitude * math.sin(2.0 * math.pi * frequency * (i / AUDIO_SAMPLE_RATE))) buffer.append(sample) return pygame.mixer.Sound(buffer=buffer.tobytes()) def _make_sfx_eat(self): """Happy, zippy sound when eating food. Short ascending pitch with snappy envelope.""" pack = SFX_PACK_OPTIONS[self.sfx_pack_index] pitch = pack["pitch"] bright = pack["bright"] duration_ms = 85 sample_count = int(AUDIO_SAMPLE_RATE * duration_ms / 1000) buffer = array("h") master = 0.40 for i in range(sample_count): t = i / AUDIO_SAMPLE_RATE progress = i / sample_count # 0 to 1 # Pitch rises from 450Hz to 700Hz (ascending feel) freq_start = 450 * pitch freq_end = 700 * pitch frequency = freq_start + (freq_end - freq_start) * progress # Base tone with bright harmonics base = math.sin(2 * math.pi * frequency * t) harmonic_2 = (0.22 * bright) * math.sin(2 * math.pi * (frequency * 2) * t) harmonic_3 = (0.08 * bright) * math.sin(2 * math.pi * (frequency * 3) * t) raw = (base + harmonic_2 + harmonic_3) * 0.72 # Punchy attack, quick decay attack_ms = 8 decay_ms = 25 attack = min(1.0, (i / (AUDIO_SAMPLE_RATE * attack_ms / 1000))) decay = max(0.0, 1.0 - (i - AUDIO_SAMPLE_RATE * attack_ms / 1000) / (AUDIO_SAMPLE_RATE * decay_ms / 1000)) envelope = attack * decay sample = raw * envelope value = int(32767 * master * sample) value = max(-32767, min(32767, value)) buffer.append(value) return pygame.mixer.Sound(buffer=buffer.tobytes()) def _make_sfx_game_over(self): """Sad, descending sound when game ends. Falling pitch with dramatic envelope.""" pack = SFX_PACK_OPTIONS[self.sfx_pack_index] pitch = pack["pitch"] bright = pack["bright"] duration_ms = 260 sample_count = int(AUDIO_SAMPLE_RATE * duration_ms / 1000) buffer = array("h") master = 0.38 for i in range(sample_count): t = i / AUDIO_SAMPLE_RATE progress = i / sample_count # 0 to 1 # Two descending pitches: high then low if progress < 0.5: # First half: 520Hz descending to 400Hz freq_start = 520 * pitch freq_end = 400 * pitch local_progress = progress * 2 else: # Second half: 400Hz descending to 180Hz (more dramatic) freq_start = 400 * pitch freq_end = 180 * pitch local_progress = (progress - 0.5) * 2 frequency = freq_start + (freq_end - freq_start) * local_progress # Darker tone - focus on fundamentals base = math.sin(2 * math.pi * frequency * t) harmonic_2 = (0.15 * bright) * math.sin(2 * math.pi * (frequency * 2) * t) harmonic_sub = 0.20 * math.sin(2 * math.pi * (frequency * 0.5) * t) raw = (base + harmonic_2 + harmonic_sub) * 0.7 # Slow attack, gradual release (sad/dramatic feel) attack_time = AUDIO_SAMPLE_RATE * 40 / 1000 # 40ms release_time = AUDIO_SAMPLE_RATE * 120 / 1000 # 120ms attack = min(1.0, i / attack_time) if attack_time > 0 else 1.0 release = max(0.0, (sample_count - i) / release_time) if release_time > 0 else 0.0 envelope = min(attack, release) sample = raw * envelope value = int(32767 * master * sample) value = max(-32767, min(32767, value)) buffer.append(value) return pygame.mixer.Sound(buffer=buffer.tobytes()) def _make_sfx_tick(self): """Clean, plucky timer tick sound. High frequency with sharp attack.""" pack = SFX_PACK_OPTIONS[self.sfx_pack_index] pitch = pack["pitch"] bright = pack["bright"] duration_ms = 60 sample_count = int(AUDIO_SAMPLE_RATE * duration_ms / 1000) buffer = array("h") master = 0.32 for i in range(sample_count): t = i / AUDIO_SAMPLE_RATE # Two-tone beep: Higher frequency for brightness freq_main = 920 * pitch # Main tone freq_accent = 1200 * pitch # Accent frequency progress = i / sample_count # Blend between frequencies - start with accent, end with main frequency = freq_accent + (freq_main - freq_accent) * (progress * 1.5) frequency = max(100, min(1400, frequency)) # Clamp # Bright, crisp tone base = math.sin(2 * math.pi * frequency * t) harmonic = (0.18 * bright) * math.sin(2 * math.pi * (frequency * 2.5) * t) raw = (base + harmonic) * 0.80 # Very snappy attack and release (percussive) attack_ms = 4 release_ms = 12 attack = min(1.0, i / (AUDIO_SAMPLE_RATE * attack_ms / 1000)) release = max(0.0, (sample_count - i) / (AUDIO_SAMPLE_RATE * release_ms / 1000)) envelope = min(attack, release) sample = raw * envelope value = int(32767 * master * sample) value = max(-32767, min(32767, value)) buffer.append(value) return pygame.mixer.Sound(buffer=buffer.tobytes()) def _make_music_melodic(self): """Generate a professional melodic soundtrack (~30 seconds). A lush, engaging chiptune-style soundtrack with proper musical structure. Key: G major (warm, uplifting). Features harmonic progressions and memorable melody. """ # VERSE 1: Gentle, ascending intro (builds tension) v1 = [ (98.00, 250), # G low - foundation (196.00, 250), # G mid (246.94, 200), # B - leading tone (293.66, 200), # D - resolution (293.66, 300), # D - held ] # VERSE 2: Main catchy melody with good momentum v2 = [ (392.00, 130), # G - bright start (440.00, 130), # A - up (493.88, 160), # B - higher (440.00, 120), # A - down (392.00, 160), # G - resolve to (349.23, 140), # F# - smooth (330.00, 140), # E - melodic (330.00, 200), # E - held ] # VERSE 3: Second melody line (counter-melody feel) v3 = [ (329.63, 130), # E (392.00, 130), # G (440.00, 160), # A (493.88, 120), # B (440.00, 160), # A - top of arc (392.00, 140), # G (349.23, 140), # F# (329.63, 220), # E - resolution ] # BRIDGE: Builds energy with movement bridge = [ (184.00, 120), # F# low (246.94, 120), # B (293.66, 120), # D (349.23, 180), # F# (392.00, 100), # G (440.00, 100), # A (493.88, 120), # B (523.25, 280), # C# - big note ] # VERSE 4: Higher register, more ornamental v4 = [ (523.25, 120), # C# high (493.88, 120), # B (440.00, 160), # A (392.00, 140), # G (440.00, 140), # A again (493.88, 200), # B held ] # FINAL SECTION: Ascending to conclusion final = [ (392.00, 120), # G (440.00, 120), # A (493.88, 140), # B (392.00, 140), # G down (349.23, 140), # F# (293.66, 200), # D (246.94, 250), # B (196.00, 300), # G resolution ] # Build complete track - extended to ~30 seconds with variations section_a = v1 + v2 section_b = v3 + bridge section_c = v4 + final # Compose with multiple repetitions and variations for 30s total notes = section_a + section_b + section_c + section_a + section_b + section_c + section_a + v4 + final + section_b + section_a buffer = array("h") master = 0.25 for frequency, duration_ms in notes: sample_count = int(AUDIO_SAMPLE_RATE * duration_ms / 1000) for i in range(sample_count): t = i / AUDIO_SAMPLE_RATE if frequency <= 0: sample = 0.0 else: # Smooth, warm synthesis vibrato_rate = 4.2 if duration_ms > 180 else 5.8 vibrato = 1.0 + 0.0035 * math.sin(2 * math.pi * vibrato_rate * t) # Rich harmonic content for warmth base = math.sin(2 * math.pi * (frequency * vibrato) * t) harm_2 = 0.25 * math.sin(2 * math.pi * (frequency * 2.0) * t) harm_3 = 0.10 * math.sin(2 * math.pi * (frequency * 3.0) * t) harm_sub = 0.18 * math.sin(2 * math.pi * (frequency * 0.5) * t) raw = (base + harm_2 + harm_3 + harm_sub) * 0.68 # Smooth envelope attack_ms = 50 if duration_ms > 150 else 80 release_ms = 80 if duration_ms > 200 else 100 attack = min(1.0, (i / (AUDIO_SAMPLE_RATE * attack_ms / 1000))) release = min(1.0, ((sample_count - i) / (AUDIO_SAMPLE_RATE * release_ms / 1000))) envelope = min(attack, release) sample = raw * envelope value = int(32767 * master * sample) value = max(-32767, min(32767, value)) buffer.append(value) return pygame.mixer.Sound(buffer=buffer.tobytes()) def _make_music_arcade(self): """Generate an arcade-style soundtrack (~30 seconds) - catchy and energetic. Inspired by classic arcade games but with better composition and structure. Pentatonic scale in A: Upbeat, punchy, instantly memorable. """ # HOOK 1: Iconic opening riff (immediately catchy!) hook1 = [ (440.00, 110), # A - bright start (523.25, 110), # C# - up (440.00, 110), # A - back (392.00, 150), # G - down (493.88, 110), # B - up (440.00, 260), # A - resolution on strong beat ] # BUILD 1: Energy increases build1 = [ (523.25, 100), # C# (493.88, 100), # B (440.00, 100), # A (523.25, 100), # C# again (493.88, 100), # B (440.00, 140), # A (392.00, 140), # G - resolution (440.00, 180), # A - strong beat ] # HOOK 2: Variation of main theme (still catchy) hook2 = [ (329.63, 110), # E - higher pitch (392.00, 110), # G (440.00, 140), # A - peak (523.25, 100), # C# - jump (493.88, 110), # B (440.00, 140), # A (392.00, 220), # G - longer note ] # BUILD 2: Running pattern (climax) build2 = [ (440.00, 80), # A - fast (493.88, 80), # B - fast (523.25, 80), # C# - fast (493.88, 80), # B - fast (440.00, 80), # A - fast (392.00, 80), # G - fast (440.00, 80), # A - fast (523.25, 220), # C# - big note ] # OUTRO: Cool down to prepare for loop outro = [ (493.88, 130), # B (440.00, 130), # A (392.00, 160), # G (329.63, 200), # E - resolution (440.00, 200), # A - back to center ] # MIDDLE SECTION: Repeat melody with more spaciousness middle = [ (440.00, 140), # A (493.88, 140), # B (523.25, 160), # C# (493.88, 120), # B (440.00, 260), # A - held (392.00, 140), # G (440.00, 140), # A (523.25, 260), # C# - strong ] # CLIMAX: Double-time running passages climax = [ (523.25, 70), # C# (493.88, 70), # B (440.00, 70), # A (392.00, 70), # G (440.00, 70), # A (493.88, 70), # B (523.25, 140), # C# - top (493.88, 70), # B (440.00, 260), # A - resolve (392.00, 140), # G ] # FINAL RESOLUTION: Cool down to prepare for loop final_arc = [ (440.00, 130), # A (523.25, 130), # C# (440.00, 130), # A (329.63, 260), # E - resolution ] # Complete structure for ~30 seconds with extended variations notes = ( hook1 + build1 + # First phrase hook2 + build2 + # Second phrase middle + climax + # Build to climax hook1 + build1 + # Repeat first (reinforce hook) hook2 + build2 + # Second phrase again middle + climax + # Second climax build final_arc + outro + # Cool down hook1 + build1 + # Final repeat hook hook2 + build2 # Final outro with hook2 ) buffer = array("h") master = 0.28 for frequency, duration_ms in notes: sample_count = int(AUDIO_SAMPLE_RATE * duration_ms / 1000) for i in range(sample_count): t = i / AUDIO_SAMPLE_RATE if frequency <= 0: sample = 0.0 else: # Arcade tone: Square-wave-like with energy base = math.sin(2 * math.pi * frequency * t) harm_2 = 0.30 * math.sin(2 * math.pi * (frequency * 2.0) * t) harm_3 = 0.15 * math.sin(2 * math.pi * (frequency * 3.0) * t) harm_5 = 0.10 * math.sin(2 * math.pi * (frequency * 5.0) * t) harm_sub = 0.14 * math.sin(2 * math.pi * (frequency * 0.5) * t) raw = (base + harm_2 + harm_3 + harm_5 + harm_sub) * 0.64 # Snappy attack for arcade feel attack_ms = 40 if duration_ms > 120 else 60 release_ms = 70 if duration_ms > 150 else 90 attack = min(1.0, (i / (AUDIO_SAMPLE_RATE * attack_ms / 1000))) release = min(1.0, ((sample_count - i) / (AUDIO_SAMPLE_RATE * release_ms / 1000))) envelope = min(attack, release) sample = raw * envelope value = int(32767 * master * sample) value = max(-32767, min(32767, value)) buffer.append(value) return pygame.mixer.Sound(buffer=buffer.tobytes()) def _update_music_track(self): if self.music_style_index == 0: self.music_track = self.music_melodic else: self.music_track = self.music_arcade def _play_sound(self, sound): if not self.audio_available or not self.sound_enabled or sound is None: return channel = pygame.mixer.find_channel() if channel is not None: channel.play(sound) def _sync_music(self): if not self.audio_available or self.music_track is None or self.music_channel is None: return self.music_channel.set_volume(self.music_volume) if self.music_enabled and self.mode in {"menu", "settings", "playing"}: if not self.music_channel.get_busy(): self.music_channel.play(self.music_track, loops=-1) else: self.music_channel.stop() def _reload_sfx(self): if not self.audio_available: return self.sfx_eat = self._make_sfx_eat() self.sfx_game_over = self._make_sfx_game_over() self.sfx_tick = self._make_sfx_tick() def _load_high_score(self): try: with open(self.score_file, "r", encoding="utf-8") as score_handle: return max(0, int(score_handle.read().strip())) except (OSError, ValueError): return 0 def _save_high_score(self): """Save current high score to file.""" try: with open(self.score_file, "w", encoding="utf-8") as score_handle: score_handle.write(str(self.high_score)) except OSError: pass def _load_settings(self): """Load all game settings from JSON file. Silently uses defaults if file doesn't exist.""" try: if not os.path.exists(self.settings_file): return with open(self.settings_file, "r", encoding="utf-8") as f: data = json.load(f) # Game settings self.difficulty_index = max(0, min(len(DIFFICULTY_OPTIONS) - 1, data.get("difficulty_index", 1))) self.game_mode_index = max(0, min(len(GAME_MODE_OPTIONS) - 1, data.get("game_mode_index", 0))) self.obstacle_index = max(0, min(len(OBSTACLE_OPTIONS) - 1, data.get("obstacle_index", 0))) self.wrap_walls = data.get("wrap_walls", False) self.timer_index = max(0, min(len(TIMER_OPTIONS) - 1, data.get("timer_index", 2))) self.sudden_death_enabled = data.get("sudden_death_enabled", True) # Control settings self.p2_control_index = max(0, min(len(P2_CONTROL_OPTIONS) - 1, data.get("p2_control_index", 0))) # Sound settings self.sound_enabled = data.get("sound_enabled", True) self.music_enabled = data.get("music_enabled", False) self.music_volume = max(0.0, min(1.0, data.get("music_volume", 0.75))) self.music_style_index = max(0, min(1, data.get("music_style_index", 0))) # Food settings loaded_fruits = data.get("fruit_enabled", {}) for name in self.fruit_enabled: self.fruit_enabled[name] = bool(loaded_fruits.get(name, self.fruit_enabled[name])) self.bad_food_enabled = bool(data.get("bad_food_enabled", True)) self.bad_food_chance_index = max(0, min(len(BAD_FOOD_CHANCE_OPTIONS) - 1, data.get("bad_food_chance_index", 0))) self.bad_food_effect_index = max(0, min(len(BAD_FOOD_EFFECT_OPTIONS) - 1, data.get("bad_food_effect_index", 1))) # Progression settings self.levels_beaten_total = max(0, int(data.get("levels_beaten_total", 0))) # Graphics settings old_color_index = max(0, min(len(SKIN_OPTIONS) - 1, data.get("snake_color_index", 0))) self.snake_skin_index = max(0, min(len(SKIN_OPTIONS) - 1, data.get("snake_skin_index", old_color_index))) self.trail_style_index = max(0, min(len(TRAIL_OPTIONS) - 1, data.get("trail_style_index", 0))) self.effect_style_index = max(0, min(len(EFFECT_OPTIONS) - 1, data.get("effect_style_index", 0))) self.sfx_pack_index = max(0, min(len(SFX_PACK_OPTIONS) - 1, data.get("sfx_pack_index", 0))) self.map_size_index = max(0, min(len(MAP_SIZE_OPTIONS) - 1, data.get("map_size_index", 1))) self._ensure_unlocked_indices() self._update_music_track() self._reload_sfx() # Apply map settings if they changed from default if self.map_size_index != 1: self._apply_map_settings() except (OSError, json.JSONDecodeError): pass # Use default values if load fails def _save_settings(self): """Save all game settings to JSON file.""" try: data = { # Game "difficulty_index": self.difficulty_index, "game_mode_index": self.game_mode_index, "obstacle_index": self.obstacle_index, "wrap_walls": self.wrap_walls, "timer_index": self.timer_index, "sudden_death_enabled": self.sudden_death_enabled, # Controls "p2_control_index": self.p2_control_index, # Sound "sound_enabled": self.sound_enabled, "music_enabled": self.music_enabled, "music_volume": self.music_volume, "music_style_index": self.music_style_index, "sfx_pack_index": self.sfx_pack_index, # Food "fruit_enabled": self.fruit_enabled, "bad_food_enabled": self.bad_food_enabled, "bad_food_chance_index": self.bad_food_chance_index, "bad_food_effect_index": self.bad_food_effect_index, # Graphics "snake_skin_index": self.snake_skin_index, "trail_style_index": self.trail_style_index, "effect_style_index": self.effect_style_index, "map_size_index": self.map_size_index, # Progression "levels_beaten_total": self.levels_beaten_total, } with open(self.settings_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) except OSError: pass # Silently fail if can't save def _apply_map_settings(self): """Apply grid dimensions and window size based on current map_size_index.""" config = MAP_SIZE_OPTIONS[self.map_size_index] self.grid_w = config["grid_w"] self.grid_h = config["grid_h"] self.cell_size = config["cell_size"] self.width = self.grid_w * self.cell_size self.height = self.grid_h * self.cell_size + TOP_BAR self.screen = pygame.display.set_mode((self.width, self.height)) pygame.display.set_caption("Snake") def _grid_to_pixel(self, gx, gy): return gx * self.cell_size, gy * self.cell_size + TOP_BAR def _move_interval(self): base = DIFFICULTY_OPTIONS[self.difficulty_index]["move_interval"] sudden_bonus = 0.82 if self.sudden_death_active else 1.0 return max(45, int(base * self.speed_effect_scale * sudden_bonus)) def _snake_palette(self, index): if index == 0: return SKIN_OPTIONS[self.snake_skin_index]["palette"] return SECOND_SNAKE_PALETTE def _players_count(self): return GAME_MODE_OPTIONS[self.game_mode_index]["players"] def _is_ai_enabled(self): return self._players_count() == 2 and P2_CONTROL_OPTIONS[self.p2_control_index]["ai"] def _is_option_unlocked(self, option): return self.high_score >= option["req_score"] and self.levels_beaten_total >= option["req_levels"] def _unlocked_indices(self, options): unlocked = [i for i, option in enumerate(options) if self._is_option_unlocked(option)] return unlocked if unlocked else [0] def _cycle_unlocked_index(self, current, options, delta): unlocked = self._unlocked_indices(options) if current not in unlocked: return unlocked[0] pos = unlocked.index(current) return unlocked[(pos + delta) % len(unlocked)] def _ensure_unlocked_indices(self): self.snake_skin_index = self._cycle_unlocked_index(self.snake_skin_index, SKIN_OPTIONS, 0) self.trail_style_index = self._cycle_unlocked_index(self.trail_style_index, TRAIL_OPTIONS, 0) self.effect_style_index = self._cycle_unlocked_index(self.effect_style_index, EFFECT_OPTIONS, 0) self.sfx_pack_index = self._cycle_unlocked_index(self.sfx_pack_index, SFX_PACK_OPTIONS, 0) self.music_style_index = self._cycle_unlocked_index(self.music_style_index, MUSIC_STYLE_UNLOCK_OPTIONS, 0) def _progress_label(self): return f"HS {self.high_score} | LVL {self.levels_beaten_total}" def _progression_items_for_active_tab(self): tab = PROGRESSION_TABS[self.progression_tab_index] if tab == "Skins": return SKIN_OPTIONS if tab == "Trails": return TRAIL_OPTIONS if tab == "Effects": return EFFECT_OPTIONS if tab == "SFX Packs": return SFX_PACK_OPTIONS return MUSIC_STYLE_UNLOCK_OPTIONS def _set_progression_tab(self, delta): self.progression_tab_index = (self.progression_tab_index + delta) % len(PROGRESSION_TABS) items = self._progression_items_for_active_tab() self.progression_item_index = max(0, min(self.progression_item_index, len(items) - 1)) def _equip_progression_selection(self): items = self._progression_items_for_active_tab() if not items: return idx = self.progression_item_index item = items[idx] if not self._is_option_unlocked(item): return tab = PROGRESSION_TABS[self.progression_tab_index] if tab == "Skins": self.snake_skin_index = idx elif tab == "Trails": self.trail_style_index = idx elif tab == "Effects": self.effect_style_index = idx elif tab == "SFX Packs": self.sfx_pack_index = idx self._reload_sfx() elif tab == "Music": self.music_style_index = idx self._update_music_track() self._sync_music() self._save_settings() def _update_high_score(self): best_current = max(self.scores) if self.scores else 0 if best_current > self.high_score: self.high_score = best_current self._save_high_score() self._ensure_unlocked_indices() self._save_settings() def _update_level_progress(self): if self._players_count() != 1: return while self.scores[0] >= self.next_level_score_target: self.levels_beaten_run += 1 self.levels_beaten_total += 1 self.next_level_score_target += 6 self._ensure_unlocked_indices() self._save_settings() def _generate_obstacles(self): self.obstacles.clear() ratio = OBSTACLE_OPTIONS[self.obstacle_index]["ratio"] if ratio <= 0: return total_cells = self.grid_w * self.grid_h obstacle_count = int(total_cells * ratio) forbidden = set() for snake in self.snakes: forbidden.update(snake) center_x = self.grid_w // 2 center_y = self.grid_h // 2 for y in range(max(0, center_y - 2), min(self.grid_h, center_y + 3)): for x in range(max(0, center_x - 4), min(self.grid_w, center_x + 5)): forbidden.add((x, y)) placed = 0 attempts = 0 max_attempts = total_cells * 6 while placed < obstacle_count and attempts < max_attempts: attempts += 1 pos = (random.randint(0, self.grid_w - 1), random.randint(0, self.grid_h - 1)) if pos in forbidden or pos in self.obstacles: continue self.obstacles.add(pos) placed += 1 def _add_sudden_death_obstacle(self): occupied = self._occupied_cells() attempts = 0 while attempts < 120: attempts += 1 pos = (random.randint(0, self.grid_w - 1), random.randint(0, self.grid_h - 1)) if pos in occupied: continue self.obstacles.add(pos) return def _initial_timer_ms(self): return TIMER_OPTIONS[self.timer_index]["seconds"] * 1000 def reset_game(self): players = self._players_count() cy = self.grid_h // 2 p1_x = self.grid_w // 3 p2_x = (self.grid_w * 2) // 3 self.snakes = [ [(p1_x, cy), (p1_x - 1, cy), (p1_x - 2, cy)], ] if players == 2: self.snakes.append([(p2_x, cy), (p2_x + 1, cy), (p2_x + 2, cy)]) self.prev_snakes = [list(snake) for snake in self.snakes] self.directions = [RIGHT] self.next_directions = [RIGHT] if players == 2: self.directions.append(LEFT) self.next_directions.append(LEFT) self.scores = [0 for _ in range(players)] self.growth_pending = [0 for _ in range(players)] self.move_timer = 0 self.paused = False self.speed_effect_scale = 1.0 self.speed_effect_ticks = 0 self.powerup = None self.powerup_spawn_timer_ms = random.randint(4000, 8500) self.powerup_lifetime_ms = 0 self.game_over_text = "" self.round_time_left_ms = self._initial_timer_ms() self.sudden_death_active = False self.sudden_death_counter = 0 self.levels_beaten_run = 0 self.next_level_score_target = 8 self._generate_obstacles() self.food = self._random_food() self.particles.clear() def _enabled_good_fruits(self): fruits = [fruit for fruit in FRUIT_TYPES if self.fruit_enabled.get(fruit["name"], True)] if fruits: return fruits # Safety fallback: game must always have at least one eatable fruit. return [FRUIT_TYPES[0]] def _random_fruit_type(self): fruits = self._enabled_good_fruits() weights = [fruit["weight"] for fruit in fruits] return random.choices(fruits, weights=weights, k=1)[0] def _random_bad_food_type(self): weights = [food["weight"] for food in BAD_FOOD_TYPES] return random.choices(BAD_FOOD_TYPES, weights=weights, k=1)[0] def _random_powerup_type(self): weights = [powerup["weight"] for powerup in POWERUP_TYPES] return random.choices(POWERUP_TYPES, weights=weights, k=1)[0] def _occupied_cells(self): occupied = set(self.obstacles) for snake in self.snakes: occupied.update(snake) return occupied def _random_food(self): spawn_bad = False if self.bad_food_enabled: chance = BAD_FOOD_CHANCE_OPTIONS[self.bad_food_chance_index]["chance"] spawn_bad = random.random() < chance fruit_type = self._random_bad_food_type() if spawn_bad else self._random_fruit_type() occupied = self._occupied_cells() while True: pos = (random.randint(0, self.grid_w - 1), random.randint(0, self.grid_h - 1)) if pos not in occupied: return {"pos": pos, "type": fruit_type} def _spawn_powerup_if_needed(self, dt): if self.powerup is None: self.powerup_spawn_timer_ms -= dt if self.powerup_spawn_timer_ms > 0: return occupied = self._occupied_cells() occupied.add(self.food["pos"]) attempts = 0 while attempts < 160: attempts += 1 pos = (random.randint(0, self.grid_w - 1), random.randint(0, self.grid_h - 1)) if pos not in occupied: self.powerup = {"pos": pos, "type": self._random_powerup_type()} self.powerup_lifetime_ms = random.randint(6500, 10500) return # Retry spawn after a short delay when board is too crowded. self.powerup_spawn_timer_ms = 1500 return self.powerup_lifetime_ms -= dt if self.powerup_lifetime_ms <= 0: self.powerup = None self.powerup_lifetime_ms = 0 self.powerup_spawn_timer_ms = random.randint(4500, 9000) def _apply_powerup(self, player_index, powerup_type, position): self._spawn_particles(position, powerup_type["color"]) self.scores[player_index] += powerup_type["score_bonus"] self.growth_pending[player_index] += powerup_type["grow_bonus"] if powerup_type["speed_ticks"] > 0: self.speed_effect_scale = powerup_type["speed_scale"] self.speed_effect_ticks = max(self.speed_effect_ticks, powerup_type["speed_ticks"]) self._update_level_progress() self._play_sound(self.sfx_tick) def _spawn_particles(self, grid_pos, color): px, py = self._grid_to_pixel(*grid_pos) cx = px + self.cell_size // 2 cy = py + self.cell_size // 2 for _ in range(14): self.particles.append(Particle(cx, cy, color)) def _draw_checkerboard(self): for y in range(self.grid_h): for x in range(self.grid_w): color = self.bg_cell_a if (x + y) % 2 == 0 else self.bg_cell_b px, py = self._grid_to_pixel(x, y) pygame.draw.rect(self.screen, color, (px, py, self.cell_size, self.cell_size)) def _draw_cell(self, gx, gy, color, outline=None, radius=8): px, py = self._grid_to_pixel(gx, gy) rect = pygame.Rect(px + 1, py + 1, self.cell_size - 2, self.cell_size - 2) pygame.draw.rect(self.screen, color, rect, border_radius=radius) if outline: pygame.draw.rect(self.screen, outline, rect, width=2, border_radius=radius) def _draw_cell_px(self, px, py, color, outline=None, radius=8): rect = pygame.Rect(px + 1, py + 1, self.cell_size - 2, self.cell_size - 2) pygame.draw.rect(self.screen, color, rect, border_radius=radius) if outline: pygame.draw.rect(self.screen, outline, rect, width=2, border_radius=radius) def _draw_grid_border(self): rect = pygame.Rect(0, TOP_BAR, self.width, self.grid_h * self.cell_size) pygame.draw.rect(self.screen, self.border_col, rect, width=2) def _draw_obstacles(self): for gx, gy in self.obstacles: self._draw_cell(gx, gy, self.obstacle_col, outline=(60, 60, 90), radius=6) def _timer_label(self): if self.round_time_left_ms <= 0: return "SD" if self.sudden_death_active else "00" total_seconds = int(math.ceil(self.round_time_left_ms / 1000)) return f"{total_seconds:02d}" def _draw_score(self): pygame.draw.rect(self.screen, self.bg_dark, (0, 0, self.width, TOP_BAR)) pygame.draw.line(self.screen, self.border_col, (0, TOP_BAR - 1), (self.width, TOP_BAR - 1), 2) difficulty_name = DIFFICULTY_OPTIONS[self.difficulty_index]["name"] mode_name = GAME_MODE_OPTIONS[self.game_mode_index]["name"] timer_text = self._timer_label() best_text = self.font.render(f"BEST {self.high_score}", True, self.accent) middle_text = self.small_font.render( f"{difficulty_name} | {mode_name} | T:{timer_text}", True, self.hint_col, ) if self._players_count() == 1: left_text = self.font.render(f"SCORE {self.scores[0]}", True, self.score_col) else: left_text = self.font.render(f"P1 {self.scores[0]} P2 {self.scores[1]}", True, self.score_col) self.screen.blit(left_text, (16, TOP_BAR // 2 - left_text.get_height() // 2)) self.screen.blit(best_text, (self.width - best_text.get_width() - 16, TOP_BAR // 2 - best_text.get_height() // 2)) self.screen.blit(middle_text, (self.width // 2 - middle_text.get_width() // 2, TOP_BAR // 2 - middle_text.get_height() // 2)) def _draw_one_snake(self, snake_index, interp): snake = self.snakes[snake_index] prev_snake = self.prev_snakes[snake_index] palette = self._snake_palette(snake_index) head_col = palette["head"] tail_col = palette["tail"] outline = palette["outline"] if snake_index == 0: self._draw_snake_trail(prev_snake, palette) prev_len = len(prev_snake) if prev_snake else 0 for i, (gx, gy) in enumerate(snake): if prev_snake and i < prev_len: pgx, pgy = prev_snake[i] px = pgx * self.cell_size + (gx - pgx) * self.cell_size * interp py = pgy * self.cell_size + TOP_BAR + (gy - pgy) * self.cell_size * interp else: px, py = self._grid_to_pixel(gx, gy) t = i / max(len(snake) - 1, 1) r = int(head_col[0] * (1 - t) + tail_col[0] * t) g = int(head_col[1] * (1 - t) + tail_col[1] * t) b = int(head_col[2] * (1 - t) + tail_col[2] * t) self._draw_cell_px(px, py, (r, g, b), outline=outline, radius=10) if snake: hx, hy = self._grid_to_pixel(*snake[0]) if prev_snake: pgx, pgy = prev_snake[0] hgx, hgy = snake[0] hx = pgx * self.cell_size + (hgx - pgx) * self.cell_size * interp hy = pgy * self.cell_size + TOP_BAR + (hgy - pgy) * self.cell_size * interp cx = int(hx + self.cell_size // 2) cy = int(hy + self.cell_size // 2) eye_r = 3 if self.cell_size >= 26 else 2 off = max(4, self.cell_size // 6) pygame.draw.circle(self.screen, (255, 255, 255), (cx - off, cy - off), eye_r) pygame.draw.circle(self.screen, (255, 255, 255), (cx + off, cy - off), eye_r) pygame.draw.circle(self.screen, (15, 15, 15), (cx - off, cy - off), 1) pygame.draw.circle(self.screen, (15, 15, 15), (cx + off, cy - off), 1) if snake_index == 0: self._draw_head_effect(cx, cy, palette) def _draw_snake_trail(self, prev_snake, palette): style = TRAIL_OPTIONS[self.trail_style_index]["name"] if style == "Off" or not prev_snake: return for idx, (gx, gy) in enumerate(prev_snake[1:]): px, py = self._grid_to_pixel(gx, gy) cx = px + self.cell_size // 2 cy = py + self.cell_size // 2 if style == "Glow": radius = max(3, self.cell_size // 5) surf = pygame.Surface((radius * 4, radius * 4), pygame.SRCALPHA) pygame.draw.circle(surf, (*palette["head"], 70), (radius * 2, radius * 2), radius) self.screen.blit(surf, (cx - radius * 2, cy - radius * 2)) elif style == "Dots": if idx % 2 == 0: pygame.draw.circle(self.screen, palette["tail"], (cx, cy), max(2, self.cell_size // 8)) elif style == "Rainbow": hue = (self.tick * 4 + idx * 18) % 360 col = pygame.Color(0) col.hsva = (hue, 90, 100, 100) pygame.draw.circle(self.screen, (col.r, col.g, col.b), (cx, cy), max(2, self.cell_size // 8)) def _draw_head_effect(self, cx, cy, palette): style = EFFECT_OPTIONS[self.effect_style_index]["name"] if style == "Classic": return if style == "Pulse": radius = max(7, self.cell_size // 2 + int(2 * math.sin(self.tick * 0.12))) surf = pygame.Surface((radius * 4, radius * 4), pygame.SRCALPHA) pygame.draw.circle(surf, (*palette["head"], 55), (radius * 2, radius * 2), radius) self.screen.blit(surf, (cx - radius * 2, cy - radius * 2)) elif style == "Spark": for i in range(5): angle = self.tick * 0.12 + i * 1.257 sx = int(cx + math.cos(angle) * (self.cell_size * 0.38)) sy = int(cy + math.sin(angle) * (self.cell_size * 0.38)) pygame.draw.circle(self.screen, palette["head"], (sx, sy), 2) def _draw_food(self): gx, gy = self.food["pos"] fruit = self.food["type"] px, py = self._grid_to_pixel(gx, gy) cx = px + self.cell_size // 2 cy = py + self.cell_size // 2 pulse = 4 + int(2 * math.sin(self.tick * 0.05)) glow_surf = pygame.Surface((self.cell_size * 2, self.cell_size * 2), pygame.SRCALPHA) pygame.draw.circle( glow_surf, fruit["glow"], (self.cell_size, self.cell_size), self.cell_size // 2 + pulse, ) self.screen.blit(glow_surf, (cx - self.cell_size, cy - self.cell_size)) self._draw_cell(gx, gy, fruit["color"], radius=12) if fruit.get("is_bad", False): # Draw poison mark to make forbidden food obvious. mark_col = (15, 35, 20) off = max(4, self.cell_size // 6) pygame.draw.line(self.screen, mark_col, (cx - off, cy - off), (cx + off, cy + off), 3) pygame.draw.line(self.screen, mark_col, (cx + off, cy - off), (cx - off, cy + off), 3) else: pygame.draw.circle(self.screen, (255, 245, 245), (cx - 3, cy - 4), max(2, self.cell_size // 10)) def _draw_powerup(self): if self.powerup is None: return gx, gy = self.powerup["pos"] powerup = self.powerup["type"] px, py = self._grid_to_pixel(gx, gy) cx = px + self.cell_size // 2 cy = py + self.cell_size // 2 pulse = 5 + int(3 * math.sin(self.tick * 0.08)) glow_surf = pygame.Surface((self.cell_size * 2, self.cell_size * 2), pygame.SRCALPHA) pygame.draw.circle( glow_surf, powerup["glow"], (self.cell_size, self.cell_size), self.cell_size // 2 + pulse, ) self.screen.blit(glow_surf, (cx - self.cell_size, cy - self.cell_size)) self._draw_cell(gx, gy, powerup["color"], outline=(240, 240, 255), radius=6) marker_col = (30, 35, 45) size = max(4, self.cell_size // 5) if powerup["name"] == "Star": pygame.draw.circle(self.screen, marker_col, (cx, cy), size // 2 + 1) elif powerup["name"] == "Sprout": pygame.draw.line(self.screen, marker_col, (cx, cy + size), (cx, cy - size), 2) pygame.draw.circle(self.screen, marker_col, (cx - size // 2, cy - size // 2), 2) pygame.draw.circle(self.screen, marker_col, (cx + size // 2, cy - size // 2), 2) else: pygame.draw.circle(self.screen, marker_col, (cx, cy), size + 1, 2) pygame.draw.line(self.screen, marker_col, (cx, cy), (cx, cy - size + 1), 2) def _apply_bad_food_effect(self, player_index): effect = BAD_FOOD_EFFECT_OPTIONS[self.bad_food_effect_index]["mode"] players = self._players_count() if effect == "game_over": if players == 1: self._end_round("You ate poison. R: Restart Enter: Menu") else: loser = player_index + 1 winner = 2 if loser == 1 else 1 self._end_round(f"P{loser} ate poison. P{winner} wins") return # Penalty mode: lose score and one segment (if possible). self.scores[player_index] = max(0, self.scores[player_index] - 3) if len(self.snakes[player_index]) > 3: self.snakes[player_index].pop() self._play_sound(self.sfx_tick) def _draw_overlay_text(self, big_text, small_text): overlay = pygame.Surface((self.width, self.height), pygame.SRCALPHA) overlay.fill((0, 0, 0, 175)) self.screen.blit(overlay, (0, 0)) big_surf = self.big_font.render(big_text, True, (255, 95, 115)) small_surf = self.small_font.render(small_text, True, self.score_col) self.screen.blit(big_surf, (self.width // 2 - big_surf.get_width() // 2, self.height // 2 - 52)) self.screen.blit(small_surf, (self.width // 2 - small_surf.get_width() // 2, self.height // 2 + 22)) def _draw_menu(self): """Draw main menu with mouse-clickable items.""" self.screen.fill(self.bg_dark) self.menu_rects.clear() # Clear old rects for fresh interaction tracking subtitle_text = "WASD: P1 Arrows: P2" if self._is_ai_enabled(): subtitle_text = "WASD/Arrows: P1 P2 is AI" title = self.big_font.render("SNAKE", True, (255, 95, 115)) subtitle = self.small_font.render(subtitle_text, True, self.hint_col) self.screen.blit(title, (self.width // 2 - title.get_width() // 2, 80)) self.screen.blit(subtitle, (self.width // 2 - subtitle.get_width() // 2, 145)) for i, label in enumerate(self.menu_items): active = i == self.menu_index color = (255, 255, 255) if active else self.hint_col text = self.menu_font.render(label, True, color) y = 230 + i * 52 x = self.width // 2 - text.get_width() // 2 # Create clickable rectangle for this menu item item_rect = pygame.Rect(x - 15, y, text.get_width() + 30, text.get_height() + 10) self.menu_rects.append((item_rect, lambda idx=i: self._menu_click(idx))) if active: marker = self.small_font.render(">", True, (255, 205, 90)) self.screen.blit(marker, (x - 26, y + 8)) self.screen.blit(text, (x, y)) info = self.small_font.render("Enter: Select Esc: Quit", True, self.hint_col) self.screen.blit(info, (self.width // 2 - info.get_width() // 2, self.height - 40)) pygame.display.flip() def _draw_settings(self): """Draw settings menu with mouse-clickable items.""" self.screen.fill(self.bg_dark) self.settings_rects.clear() # Clear old rects title = self.big_font.render("SETTINGS", True, (120, 210, 255)) hint = self.small_font.render("Enter: Open Esc: Back Click: Adjust", True, self.hint_col) self.screen.blit(title, (self.width // 2 - title.get_width() // 2, 56)) self.screen.blit(hint, (self.width // 2 - hint.get_width() // 2, 120)) if not self.settings_in_category: # Categories view - clickable for i, label in enumerate(self.settings_categories): active = i == self.settings_category_index col = (255, 255, 255) if active else self.hint_col y = 190 + i * 42 text = self.menu_font.render(label, True, col) x = self.width // 2 - text.get_width() // 2 # Create clickable rectangle item_rect = pygame.Rect(x - 15, y, text.get_width() + 30, text.get_height() + 10) self.settings_rects.append((item_rect, lambda idx=i: self._settings_category_click(idx))) if active: marker = self.small_font.render(">", True, (255, 205, 90)) self.screen.blit(marker, (x - 24, y + 8)) self.screen.blit(text, (x, y)) else: # Items within category - clickable values category = self.settings_categories[self.settings_category_index] group_items = self.settings_group_items.get(category, []) category_text = self.small_font.render( f"Settings > {category} | Left/Right: Change Esc: Categories", True, self.hint_col, ) self.screen.blit(category_text, (self.width // 2 - category_text.get_width() // 2, 156)) for i, label in enumerate(group_items): active = i == self.settings_item_index left_col = (255, 255, 255) if active else self.hint_col right_col = (255, 205, 90) if active else (200, 200, 230) y = 204 + i * 38 left = self.small_font.render(label, True, left_col) self.screen.blit(left, (self.width * 0.12, y)) right = self.small_font.render(self._setting_value_text(label), True, right_col) self.screen.blit(right, (self.width * 0.62, y)) # Create clickable rectangles for both label and value label_rect = pygame.Rect(self.width * 0.10, y, self.width * 0.40, 30) value_rect = pygame.Rect(self.width * 0.60, y, self.width * 0.30, 30) self.settings_rects.append((label_rect, lambda idx=i: self._settings_item_click(idx))) self.settings_rects.append((value_rect, lambda idx=i, delta=1: self._settings_change_value(delta) or self._settings_update_index(idx))) if active: pygame.draw.line( self.screen, (120, 210, 255), (self.width * 0.10, y + 26), (self.width * 0.90, y + 26), 2, ) pygame.display.flip() def _draw_progression_preview(self, panel_rect): pygame.draw.rect(self.screen, (26, 26, 42), panel_rect, border_radius=12) pygame.draw.rect(self.screen, (68, 68, 98), panel_rect, width=2, border_radius=12) title = self.small_font.render("Preview", True, (230, 235, 255)) self.screen.blit(title, (panel_rect.x + 14, panel_rect.y + 12)) preview_skin = self.snake_skin_index preview_trail = self.trail_style_index preview_effect = self.effect_style_index preview_sfx = self.sfx_pack_index preview_music = self.music_style_index if PROGRESSION_TABS[self.progression_tab_index] == "Skins": preview_skin = self.progression_item_index elif PROGRESSION_TABS[self.progression_tab_index] == "Trails": preview_trail = self.progression_item_index elif PROGRESSION_TABS[self.progression_tab_index] == "Effects": preview_effect = self.progression_item_index elif PROGRESSION_TABS[self.progression_tab_index] == "SFX Packs": preview_sfx = self.progression_item_index elif PROGRESSION_TABS[self.progression_tab_index] == "Music": preview_music = self.progression_item_index preview_palette = SKIN_OPTIONS[preview_skin]["palette"] base_x = panel_rect.x + 34 base_y = panel_rect.y + 80 step = max(20, self.cell_size // 2) # Mini trail preview. trail_name = TRAIL_OPTIONS[preview_trail]["name"] for i in range(6): cx = base_x + i * step cy = base_y + 40 if trail_name == "Glow": surf = pygame.Surface((22, 22), pygame.SRCALPHA) pygame.draw.circle(surf, (*preview_palette["head"], 80), (11, 11), 7) self.screen.blit(surf, (cx - 11, cy - 11)) elif trail_name == "Dots" and i % 2 == 0: pygame.draw.circle(self.screen, preview_palette["tail"], (cx, cy), 4) elif trail_name == "Rainbow": col = pygame.Color(0) col.hsva = ((self.tick * 6 + i * 24) % 360, 90, 100, 100) pygame.draw.circle(self.screen, (col.r, col.g, col.b), (cx, cy), 4) # Mini snake body preview. for i in range(5): t = i / 4 r = int(preview_palette["head"][0] * (1 - t) + preview_palette["tail"][0] * t) g = int(preview_palette["head"][1] * (1 - t) + preview_palette["tail"][1] * t) b = int(preview_palette["head"][2] * (1 - t) + preview_palette["tail"][2] * t) rect = pygame.Rect(base_x + i * step, base_y, 26, 26) pygame.draw.rect(self.screen, (r, g, b), rect, border_radius=8) pygame.draw.rect(self.screen, preview_palette["outline"], rect, width=2, border_radius=8) hx = base_x + 4 * step + 13 hy = base_y + 13 pygame.draw.circle(self.screen, (255, 255, 255), (hx - 4, hy - 4), 2) pygame.draw.circle(self.screen, (255, 255, 255), (hx + 4, hy - 4), 2) effect_name = EFFECT_OPTIONS[preview_effect]["name"] if effect_name == "Pulse": radius = 16 + int(2 * math.sin(self.tick * 0.12)) surf = pygame.Surface((radius * 4, radius * 4), pygame.SRCALPHA) pygame.draw.circle(surf, (*preview_palette["head"], 55), (radius * 2, radius * 2), radius) self.screen.blit(surf, (hx - radius * 2, hy - radius * 2)) elif effect_name == "Spark": for i in range(5): angle = self.tick * 0.12 + i * 1.257 sx = int(hx + math.cos(angle) * 16) sy = int(hy + math.sin(angle) * 16) pygame.draw.circle(self.screen, preview_palette["head"], (sx, sy), 2) text_y = panel_rect.y + 150 lines = [ f"Skin: {SKIN_OPTIONS[preview_skin]['name']}", f"Trail: {TRAIL_OPTIONS[preview_trail]['name']}", f"Effect: {EFFECT_OPTIONS[preview_effect]['name']}", f"SFX Pack: {SFX_PACK_OPTIONS[preview_sfx]['name']}", f"Music: {MUSIC_STYLE_UNLOCK_OPTIONS[preview_music]['name']}", ] for line in lines: surf = self._fit_text(self.small_font, line, panel_rect.width - 28, (195, 205, 240)) self.screen.blit(surf, (panel_rect.x + 14, text_y)) text_y += 24 def _fit_text(self, font, text, max_width, color): """Render text that fits max_width by truncating with ellipsis if needed.""" if font.size(text)[0] <= max_width: return font.render(text, True, color) truncated = text while len(truncated) > 3 and font.size(truncated + "...")[0] > max_width: truncated = truncated[:-1] return font.render(truncated + "...", True, color) def _draw_progression(self): self.screen.fill((15, 17, 28)) self.progression_rects.clear() margin = max(10, self.width // 40) gap = max(6, self.width // 90) compact = self.width < 620 title = self.big_font.render("PROGRESSION", True, (255, 210, 120)) subtitle_text = "L/R tabs U/D items Enter equip Space preview Esc back" subtitle = self._fit_text(self.small_font, subtitle_text, self.width - margin * 2, self.hint_col) progress_text = f"High Score: {self.high_score} Levels Beaten: {self.levels_beaten_total}" progress = self._fit_text(self.small_font, progress_text, self.width - margin * 2, (180, 210, 255)) self.screen.blit(title, (self.width // 2 - title.get_width() // 2, 32)) self.screen.blit(subtitle, (self.width // 2 - subtitle.get_width() // 2, 86)) self.screen.blit(progress, (self.width // 2 - progress.get_width() // 2, 112)) # Tabs tab_y = 150 tab_x = margin tab_font = pygame.font.SysFont("consolas", 15, bold=True) tab_count = len(PROGRESSION_TABS) tab_w = max(72, (self.width - (margin * 2) - (gap * (tab_count - 1))) // tab_count) for i, tab in enumerate(PROGRESSION_TABS): active = i == self.progression_tab_index rect = pygame.Rect(tab_x + i * (tab_w + gap), tab_y, tab_w, 34) col = (76, 98, 170) if active else (40, 45, 70) pygame.draw.rect(self.screen, col, rect, border_radius=8) pygame.draw.rect(self.screen, (130, 150, 220), rect, width=1, border_radius=8) text = self._fit_text(tab_font, tab, rect.width - 12, (245, 248, 255) if active else (180, 190, 225)) self.screen.blit(text, (rect.centerx - text.get_width() // 2, rect.centery - text.get_height() // 2)) self.progression_rects.append((rect, lambda idx=i: self._set_progression_tab(idx - self.progression_tab_index))) items = self._progression_items_for_active_tab() if compact: list_rect = pygame.Rect(margin, 200, self.width - margin * 2, max(180, (self.height - 240) // 2)) panel_rect = pygame.Rect(margin, list_rect.bottom + 10, self.width - margin * 2, self.height - (list_rect.bottom + 20)) else: list_w = max(260, int(self.width * 0.46)) panel_w = self.width - (margin * 3 + list_w) if panel_w < 240: panel_w = 240 list_w = self.width - (margin * 3 + panel_w) list_rect = pygame.Rect(margin, 200, list_w, self.height - 230) panel_rect = pygame.Rect(list_rect.right + margin, 200, panel_w, self.height - 230) pygame.draw.rect(self.screen, (24, 24, 38), list_rect, border_radius=12) pygame.draw.rect(self.screen, (60, 60, 90), list_rect, width=2, border_radius=12) y = list_rect.y + 14 for i, item in enumerate(items): unlocked = self._is_option_unlocked(item) active = i == self.progression_item_index row_rect = pygame.Rect(list_rect.x + 10, y, list_rect.width - 20, 42) if active: pygame.draw.rect(self.screen, (62, 78, 120), row_rect, border_radius=8) name_col = (245, 245, 255) if unlocked else (130, 130, 155) req_text = "UNLOCKED" if unlocked else f"Need HS {item['req_score']} / LVL {item['req_levels']}" req_col = (140, 220, 140) if unlocked else (245, 155, 155) req_font = pygame.font.SysFont("consolas", 14) name_surf = self._fit_text(self.small_font, item["name"], row_rect.width - 16, name_col) req_surf = self._fit_text(req_font, req_text, row_rect.width - 16, req_col) self.screen.blit(name_surf, (row_rect.x + 8, row_rect.y + 2)) self.screen.blit(req_surf, (row_rect.x + 8, row_rect.y + 22)) self.progression_rects.append((row_rect, lambda idx=i: setattr(self, "progression_item_index", idx))) y += 46 if y + 46 > list_rect.bottom: break equip_rect = pygame.Rect(panel_rect.x + 14, panel_rect.bottom - 48, panel_rect.width - 28, 34) selected_item = items[self.progression_item_index] can_equip = self._is_option_unlocked(selected_item) btn_col = (60, 155, 95) if can_equip else (100, 70, 70) pygame.draw.rect(self.screen, btn_col, equip_rect, border_radius=8) btn_text = "Equip" if can_equip else "Locked" txt = self.small_font.render(btn_text, True, (245, 245, 245)) self.screen.blit(txt, (equip_rect.centerx - txt.get_width() // 2, equip_rect.centery - txt.get_height() // 2)) self.progression_rects.append((equip_rect, lambda: self._equip_progression_selection())) self._draw_progression_preview(panel_rect) pygame.display.flip() def _update_particles(self): for particle in self.particles: particle.update() particle.draw(self.screen) self.particles = [p for p in self.particles if p.life > 0] def _update_speed_effect(self): if self.speed_effect_ticks > 0: self.speed_effect_ticks -= 1 if self.speed_effect_ticks <= 0: self.speed_effect_ticks = 0 self.speed_effect_scale = 1.0 def _finish_by_timer(self): if self._players_count() == 1: self._end_round("Time up. R: Restart Enter: Menu") return if self.scores[0] > self.scores[1]: self._end_round("Time up. P1 wins") elif self.scores[1] > self.scores[0]: self._end_round("Time up. P2 wins") else: self._end_round("Time up. Draw") def _update_round_timer(self, dt): if self.round_time_left_ms <= 0: return previous_second = int(math.ceil(self.round_time_left_ms / 1000)) self.round_time_left_ms = max(0, self.round_time_left_ms - dt) current_second = int(math.ceil(self.round_time_left_ms / 1000)) if current_second <= 10 and current_second < previous_second: self._play_sound(self.sfx_tick) if self.round_time_left_ms > 0: return if self.sudden_death_enabled: self.sudden_death_active = True self._play_sound(self.sfx_tick) else: self._finish_by_timer() def _end_round(self, message): self.mode = "game_over" self.game_over_text = message self._update_high_score() self._play_sound(self.sfx_game_over) def _apply_move(self, head, direction): next_head = (head[0] + direction[0], head[1] + direction[1]) if self.wrap_walls: return (next_head[0] % self.grid_w, next_head[1] % self.grid_h) return next_head def _score_ai_move(self, direction): head = self.snakes[1][0] opposite = (-self.directions[1][0], -self.directions[1][1]) if direction == opposite: return 10_000 next_head = self._apply_move(head, direction) if not self.wrap_walls: if not (0 <= next_head[0] < self.grid_w and 0 <= next_head[1] < self.grid_h): return 9_000 score = abs(next_head[0] - self.food["pos"][0]) + abs(next_head[1] - self.food["pos"][1]) danger_cells = set(self.obstacles) danger_cells.update(self.snakes[0]) danger_cells.update(self.snakes[1]) if next_head in danger_cells: score += 7_500 nearby_penalty = 0 for dx, dy in (UP, DOWN, LEFT, RIGHT): test = self._apply_move(next_head, (dx, dy)) if test in danger_cells: nearby_penalty += 1 score += nearby_penalty * 3 return score def _update_ai_direction(self): if not self._is_ai_enabled() or self.mode != "playing" or self.paused: return candidates = [UP, DOWN, LEFT, RIGHT] best_direction = min(candidates, key=self._score_ai_move) self.next_directions[1] = best_direction def _advance_snakes(self): players = self._players_count() self.prev_snakes = [list(snake) for snake in self.snakes] self.directions = list(self.next_directions) new_heads = [] for i in range(players): new_heads.append(self._apply_move(self.snakes[i][0], self.directions[i])) for i, head in enumerate(new_heads): if not self.wrap_walls: if not (0 <= head[0] < self.grid_w and 0 <= head[1] < self.grid_h): if players == 1: self._end_round("R: Restart Enter: Menu Esc: Quit") else: loser = i + 1 winner = 2 if loser == 1 else 1 self._end_round(f"P{loser} crashed wall. P{winner} wins") return if head in self.obstacles: if players == 1: self._end_round("R: Restart Enter: Menu Esc: Quit") else: loser = i + 1 winner = 2 if loser == 1 else 1 self._end_round(f"P{loser} hit obstacle. P{winner} wins") return if players == 2 and new_heads[0] == new_heads[1]: self._end_round("Head-on collision. Draw") return for i, head in enumerate(new_heads): own_body = self.snakes[i] if head in own_body: if players == 1: self._end_round("R: Restart Enter: Menu Esc: Quit") else: loser = i + 1 winner = 2 if loser == 1 else 1 self._end_round(f"P{loser} self-collided. P{winner} wins") return if players == 2: if new_heads[0] in self.snakes[1]: self._end_round("P1 hit P2. P2 wins") return if new_heads[1] in self.snakes[0]: self._end_round("P2 hit P1. P1 wins") return for i, head in enumerate(new_heads): self.snakes[i].insert(0, head) ate_food = False for i, head in enumerate(new_heads): if head == self.food["pos"]: fruit = self.food["type"] self._spawn_particles(head, fruit["color"]) if fruit.get("is_bad", False): self._apply_bad_food_effect(i) if self.mode != "playing": return else: self.scores[i] += fruit["score"] self.growth_pending[i] += fruit["grow"] ate_food = True self._update_level_progress() if fruit["speed_ticks"] > 0: self.speed_effect_scale = fruit["speed_scale"] self.speed_effect_ticks = fruit["speed_ticks"] self.food = self._random_food() if self.powerup is not None and head == self.powerup["pos"]: powerup_type = self.powerup["type"] self._apply_powerup(i, powerup_type, head) self.powerup = None self.powerup_lifetime_ms = 0 self.powerup_spawn_timer_ms = random.randint(6500, 11500) if ate_food: self._play_sound(self.sfx_eat) for i in range(players): if self.growth_pending[i] > 0: self.growth_pending[i] -= 1 else: self.snakes[i].pop() if self.sudden_death_active: self.sudden_death_counter += 1 if self.sudden_death_counter % 3 == 0: self._add_sudden_death_obstacle() def update_game(self, dt): if self.mode != "playing" or self.paused: return self._update_round_timer(dt) if self.mode != "playing": return self._update_speed_effect() self._spawn_powerup_if_needed(dt) self._update_ai_direction() self.move_timer += dt move_interval = self._move_interval() if self.move_timer < move_interval: return self.move_timer -= move_interval self._advance_snakes() def draw_game(self): if self.mode in {"menu", "settings"}: return if self.mode == "playing" and not self.paused: interp = min(self.move_timer / max(1, self._move_interval()), 1.0) else: interp = 1.0 self.screen.fill(self.bg_dark) self._draw_checkerboard() self._draw_grid_border() self._draw_obstacles() self._draw_food() self._draw_powerup() for i in range(self._players_count()): self._draw_one_snake(i, interp) self._draw_score() self._update_particles() if self.mode == "game_over": self._draw_overlay_text("GAME OVER", self.game_over_text) elif self.paused: self._draw_overlay_text("PAUSED", "P: Resume Enter: Menu") elif self.sudden_death_active: self._draw_overlay_text("SUDDEN DEATH", "Board is filling with obstacles") pygame.display.flip() def _menu_move(self, delta): self.menu_index = (self.menu_index + delta) % len(self.menu_items) def _settings_move(self, delta): if not self.settings_in_category: self.settings_category_index = (self.settings_category_index + delta) % len(self.settings_categories) return items = self.settings_group_items.get(self.settings_categories[self.settings_category_index], []) if not items: self.settings_item_index = 0 return self.settings_item_index = (self.settings_item_index + delta) % len(items) def _apply_setting_reset(self): self.reset_game() self.mode = "settings" def _setting_value_text(self, label): values = { "Difficulty": DIFFICULTY_OPTIONS[self.difficulty_index]["name"], "Game Mode": GAME_MODE_OPTIONS[self.game_mode_index]["name"], "P2 Control": P2_CONTROL_OPTIONS[self.p2_control_index]["name"], "Map Size": MAP_SIZE_OPTIONS[self.map_size_index]["name"], "Obstacles": OBSTACLE_OPTIONS[self.obstacle_index]["name"], "Wrap Walls": "On" if self.wrap_walls else "Off", "Round Timer": TIMER_OPTIONS[self.timer_index]["name"], "Sudden Death": "On" if self.sudden_death_enabled else "Off", "Apple": "On" if self.fruit_enabled["Apple"] else "Off", "Banana": "On" if self.fruit_enabled["Banana"] else "Off", "Watermelon": "On" if self.fruit_enabled["Watermelon"] else "Off", "Grape": "On" if self.fruit_enabled["Grape"] else "Off", "Berry": "On" if self.fruit_enabled["Berry"] else "Off", "Gold": "On" if self.fruit_enabled["Gold"] else "Off", "Ice": "On" if self.fruit_enabled["Ice"] else "Off", "Poison Food": "On" if self.bad_food_enabled else "Off", "Poison Chance": BAD_FOOD_CHANCE_OPTIONS[self.bad_food_chance_index]["name"], "Poison Effect": BAD_FOOD_EFFECT_OPTIONS[self.bad_food_effect_index]["name"], "Sound FX": "On" if self.sound_enabled else "Off", "Music": "On" if self.music_enabled else "Off", "Music Volume": f"{int(self.music_volume * 100)}%", } return values.get(label, "") def _settings_change_value(self, delta): """Handle setting value changes. Saves to file after each change.""" if not self.settings_in_category: return group_items = self.settings_group_items.get(self.settings_categories[self.settings_category_index], []) if not group_items: return active_item = group_items[self.settings_item_index] if active_item == "Difficulty": self.difficulty_index = (self.difficulty_index + delta) % len(DIFFICULTY_OPTIONS) elif active_item == "Game Mode": self.game_mode_index = (self.game_mode_index + delta) % len(GAME_MODE_OPTIONS) self._apply_setting_reset() elif active_item == "P2 Control": self.p2_control_index = (self.p2_control_index + delta) % len(P2_CONTROL_OPTIONS) elif active_item == "Map Size": self.map_size_index = (self.map_size_index + delta) % len(MAP_SIZE_OPTIONS) self._apply_map_settings() self._apply_setting_reset() elif active_item == "Obstacles": self.obstacle_index = (self.obstacle_index + delta) % len(OBSTACLE_OPTIONS) self._apply_setting_reset() elif active_item == "Wrap Walls": self.wrap_walls = not self.wrap_walls elif active_item == "Round Timer": self.timer_index = (self.timer_index + delta) % len(TIMER_OPTIONS) self._apply_setting_reset() elif active_item == "Sudden Death": self.sudden_death_enabled = not self.sudden_death_enabled elif active_item in self.fruit_enabled: enabled_count = sum(1 for is_enabled in self.fruit_enabled.values() if is_enabled) # Prevent disabling all good fruits. if self.fruit_enabled[active_item] and enabled_count <= 1: self.fruit_enabled[active_item] = True else: self.fruit_enabled[active_item] = not self.fruit_enabled[active_item] self._apply_setting_reset() elif active_item == "Poison Food": self.bad_food_enabled = not self.bad_food_enabled self._apply_setting_reset() elif active_item == "Poison Chance": self.bad_food_chance_index = (self.bad_food_chance_index + delta) % len(BAD_FOOD_CHANCE_OPTIONS) self._apply_setting_reset() elif active_item == "Poison Effect": self.bad_food_effect_index = (self.bad_food_effect_index + delta) % len(BAD_FOOD_EFFECT_OPTIONS) elif active_item == "Sound FX": self.sound_enabled = not self.sound_enabled elif active_item == "Music": self.music_enabled = not self.music_enabled self._sync_music() elif active_item == "Music Volume": self.music_volume = min(1.0, max(0.0, self.music_volume + (delta * 0.05))) self._sync_music() self._save_settings() def _menu_click(self, index): """Handle mouse click on menu item.""" self.menu_index = index selection = self.menu_items[self.menu_index] if selection == "Start Game": self.reset_game() self.mode = "playing" elif selection == "Progression": self.progression_tab_index = 0 self.progression_item_index = 0 self.mode = "progression" elif selection == "Settings": self.settings_in_category = False self.settings_category_index = 0 self.settings_item_index = 0 self.mode = "settings" elif selection == "Quit": return False return True def _settings_category_click(self, index): """Handle mouse click on settings category.""" self.settings_category_index = index if self.settings_categories[index] == "Back": self.mode = "menu" else: self.settings_in_category = True self.settings_item_index = 0 def _settings_item_click(self, index): """Handle mouse click on settings item.""" self.settings_item_index = index def _settings_update_index(self, index): """Update current item index after value change.""" self.settings_item_index = index def _handle_mouse_click(self, pos): """Handle mouse click at given position (x, y).""" if self.mode == "menu": for rect, action in self.menu_rects: if rect.collidepoint(pos): return action() elif self.mode == "settings": for rect, action in self.settings_rects: if rect.collidepoint(pos): action() break elif self.mode == "progression": for rect, action in self.progression_rects: if rect.collidepoint(pos): action() break return True def _handle_menu_key(self, key): """Process key input while in menu.""" if key in (pygame.K_UP, pygame.K_w): self._menu_move(-1) elif key in (pygame.K_DOWN, pygame.K_s): self._menu_move(1) elif key in (pygame.K_RETURN, pygame.K_KP_ENTER): selection = self.menu_items[self.menu_index] if selection == "Start Game": self.reset_game() self.mode = "playing" elif selection == "Progression": self.progression_tab_index = 0 self.progression_item_index = 0 self.mode = "progression" elif selection == "Settings": self.settings_in_category = False self.settings_category_index = 0 self.settings_item_index = 0 self.mode = "settings" elif selection == "Quit": return False elif key == pygame.K_ESCAPE: return False return True def _handle_settings_key(self, key): """Process key input while in settings menu.""" if key in (pygame.K_UP, pygame.K_w): self._settings_move(-1) elif key in (pygame.K_DOWN, pygame.K_s): self._settings_move(1) elif key in (pygame.K_LEFT, pygame.K_a): self._settings_change_value(-1) elif key in (pygame.K_RIGHT, pygame.K_d): self._settings_change_value(1) elif key in (pygame.K_RETURN, pygame.K_KP_ENTER): if not self.settings_in_category: if self.settings_categories[self.settings_category_index] == "Back": self.mode = "menu" else: self.settings_in_category = True self.settings_item_index = 0 else: self._settings_change_value(1) elif key == pygame.K_ESCAPE: if self.settings_in_category: self.settings_in_category = False else: self.mode = "menu" return True def _handle_progression_key(self, key): items = self._progression_items_for_active_tab() if key in (pygame.K_LEFT, pygame.K_a): self._set_progression_tab(-1) elif key in (pygame.K_RIGHT, pygame.K_d): self._set_progression_tab(1) elif key in (pygame.K_UP, pygame.K_w): self.progression_item_index = (self.progression_item_index - 1) % max(1, len(items)) elif key in (pygame.K_DOWN, pygame.K_s): self.progression_item_index = (self.progression_item_index + 1) % max(1, len(items)) elif key in (pygame.K_RETURN, pygame.K_KP_ENTER): self._equip_progression_selection() elif key == pygame.K_SPACE: if PROGRESSION_TABS[self.progression_tab_index] == "SFX Packs": self._play_sound(self.sfx_eat) elif PROGRESSION_TABS[self.progression_tab_index] == "Music": self._equip_progression_selection() elif key == pygame.K_ESCAPE: self.mode = "menu" return True def _handle_play_key(self, key): """Process key input during gameplay.""" if key == pygame.K_ESCAPE: return False if self.mode == "game_over": if key == pygame.K_r: self.reset_game() self.mode = "playing" elif key in (pygame.K_RETURN, pygame.K_KP_ENTER): self.mode = "menu" return True if key == pygame.K_p: self.paused = not self.paused return True if self.paused and key in (pygame.K_RETURN, pygame.K_KP_ENTER): self.mode = "menu" return True if self.paused: return True key_map_p1 = { pygame.K_w: UP, pygame.K_s: DOWN, pygame.K_a: LEFT, pygame.K_d: RIGHT, } key_map_p2 = { pygame.K_UP: UP, pygame.K_DOWN: DOWN, pygame.K_LEFT: LEFT, pygame.K_RIGHT: RIGHT, } if self._players_count() == 1: merged_map = dict(key_map_p1) merged_map.update(key_map_p2) new_dir = merged_map.get(key) if new_dir: opposite = (-self.directions[0][0], -self.directions[0][1]) if new_dir != opposite: self.next_directions[0] = new_dir return True p1_dir = key_map_p1.get(key) if p1_dir: opposite = (-self.directions[0][0], -self.directions[0][1]) if p1_dir != opposite: self.next_directions[0] = p1_dir if not self._is_ai_enabled(): p2_dir = key_map_p2.get(key) if p2_dir: opposite = (-self.directions[1][0], -self.directions[1][1]) if p2_dir != opposite: self.next_directions[1] = p2_dir return True def handle_events(self): """Handle all input events including keyboard and mouse.""" for event in pygame.event.get(): if event.type == pygame.QUIT: return False if event.type == pygame.MOUSEBUTTONDOWN: if event.button == 1: # Left mouse button if not self._handle_mouse_click(event.pos): return False continue if event.type != pygame.KEYDOWN: continue if self.mode == "menu": if not self._handle_menu_key(event.key): return False elif self.mode == "settings": if not self._handle_settings_key(event.key): return False elif self.mode == "progression": if not self._handle_progression_key(event.key): return False else: if not self._handle_play_key(event.key): return False return True def run(self): """Main game loop. Runs until quit.""" running = True while running: dt = self.clock.tick(FPS) self.tick += 1 running = self.handle_events() self.update_game(dt) self._sync_music() if self.mode == "menu": self._draw_menu() elif self.mode == "progression": self._draw_progression() elif self.mode == "settings": self._draw_settings() else: self.draw_game() pygame.quit() sys.exit() # ============================================================================ # ENTRY POINT # ============================================================================ if __name__ == "__main__": game = SnakeGame() game.run()