mirror of
https://github.com/pepaskopec-blip/Snake-Pygame-.git
synced 2026-10-27 08:14:32 +00:00
2411 lines
93 KiB
Python
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()
|