Snake-Pygame-/snake.py
2026-04-11 16:02:56 +02:00

2411 lines
93 KiB
Python

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