diff --git a/snake.py b/snake.py new file mode 100644 index 0000000..4d86afe --- /dev/null +++ b/snake.py @@ -0,0 +1,2411 @@ +"""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() diff --git a/snake_high_score.txt b/snake_high_score.txt new file mode 100644 index 0000000..978b4e8 --- /dev/null +++ b/snake_high_score.txt @@ -0,0 +1 @@ +26 \ No newline at end of file diff --git a/snake_settings.json b/snake_settings.json new file mode 100644 index 0000000..3f93cd1 --- /dev/null +++ b/snake_settings.json @@ -0,0 +1,31 @@ +{ + "difficulty_index": 1, + "game_mode_index": 0, + "obstacle_index": 0, + "wrap_walls": false, + "timer_index": 2, + "sudden_death_enabled": true, + "p2_control_index": 0, + "sound_enabled": true, + "music_enabled": false, + "music_volume": 1.0, + "music_style_index": 0, + "sfx_pack_index": 0, + "fruit_enabled": { + "Apple": true, + "Banana": true, + "Watermelon": true, + "Grape": true, + "Berry": true, + "Gold": true, + "Ice": true + }, + "bad_food_enabled": false, + "bad_food_chance_index": 0, + "bad_food_effect_index": 1, + "snake_skin_index": 0, + "trail_style_index": 0, + "effect_style_index": 0, + "map_size_index": 1, + "levels_beaten_total": 22 +} \ No newline at end of file