-
-
Save DanielHabib/2702472b3bdcb38073ffe973fa99df20 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Voxel Snake Game Animation | |
| Creates an aesthetically pleasing 3D snake that moves through a grid at constant speed | |
| The snake follows classic game mechanics - moving in straight lines and turning at right angles | |
| """ | |
| import math | |
| import numpy as np | |
| import spatialstudio as splv | |
| from tqdm import tqdm | |
| import random | |
| import wave | |
| import struct | |
| import subprocess | |
| import os | |
| # Text rendering system (adapted from text_experiments/text.py) | |
| FONT_5x5 = { | |
| 'A': [ | |
| " X ", | |
| " X X ", | |
| " XXX ", | |
| " X X ", | |
| " X X ", | |
| ], | |
| 'B': [ | |
| " XX ", | |
| " X X ", | |
| " XX ", | |
| " X X ", | |
| " XX ", | |
| ], | |
| 'C': [ | |
| " XX ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| " XX ", | |
| ], | |
| 'D': [ | |
| " XX ", | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| " XX ", | |
| ], | |
| 'E': [ | |
| " XXX ", | |
| " X ", | |
| " XX ", | |
| " X ", | |
| " XXX ", | |
| ], | |
| 'F': [ | |
| " XXX ", | |
| " X ", | |
| " XX ", | |
| " X ", | |
| " X ", | |
| ], | |
| 'G': [ | |
| " XX ", | |
| " X ", | |
| " X X ", | |
| " X X ", | |
| " XX ", | |
| ], | |
| 'H': [ | |
| " X X ", | |
| " X X ", | |
| " XXX ", | |
| " X X ", | |
| " X X ", | |
| ], | |
| 'I': [ | |
| " XXX ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| " XXX ", | |
| ], | |
| 'J': [ | |
| " XX ", | |
| " X ", | |
| " X ", | |
| " X X ", | |
| " X ", | |
| ], | |
| 'K': [ | |
| " X X ", | |
| " X X ", | |
| " XX ", | |
| " X X ", | |
| " X X ", | |
| ], | |
| 'L': [ | |
| " X ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| " XXX ", | |
| ], | |
| 'M': [ | |
| " X X ", | |
| " XXX ", | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| ], | |
| 'N': [ | |
| " X X", | |
| " XX X", | |
| " X XX", | |
| " X X", | |
| " X X", | |
| ], | |
| 'O': [ | |
| " XXX ", | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| " XXX ", | |
| ], | |
| 'P': [ | |
| " XX ", | |
| " X X ", | |
| " XX ", | |
| " X ", | |
| " X ", | |
| ], | |
| 'Q': [ | |
| " XXX ", | |
| " X X ", | |
| " X X ", | |
| " XXX ", | |
| " X ", | |
| ], | |
| 'R': [ | |
| " XX ", | |
| " X X ", | |
| " XX ", | |
| " X X ", | |
| " X X ", | |
| ], | |
| 'S': [ | |
| " XX ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| " XX ", | |
| ], | |
| 'T': [ | |
| " XXX ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| ], | |
| 'U': [ | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| " XXX ", | |
| ], | |
| 'V': [ | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| " X ", | |
| ], | |
| 'W': [ | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| " XXX ", | |
| " X X ", | |
| ], | |
| 'X': [ | |
| " X X ", | |
| " X X ", | |
| " X ", | |
| " X X ", | |
| " X X ", | |
| ], | |
| 'Y': [ | |
| " X X ", | |
| " X X ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| ], | |
| 'Z': [ | |
| " XXX ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| " XXX ", | |
| ], | |
| '0': [ | |
| " XXX ", | |
| " X X ", | |
| " X X ", | |
| " X X ", | |
| " XXX ", | |
| ], | |
| '1': [ | |
| " X ", | |
| " XX ", | |
| " X ", | |
| " X ", | |
| " XXX ", | |
| ], | |
| '2': [ | |
| " XXX ", | |
| " X ", | |
| " XXX ", | |
| " X ", | |
| " XXX ", | |
| ], | |
| '3': [ | |
| " XXX ", | |
| " X ", | |
| " XX ", | |
| " X ", | |
| " XXX ", | |
| ], | |
| '4': [ | |
| " X X ", | |
| " X X ", | |
| " XXX ", | |
| " X ", | |
| " X ", | |
| ], | |
| '5': [ | |
| " XXX ", | |
| " X ", | |
| " XXX ", | |
| " X ", | |
| " XXX ", | |
| ], | |
| '6': [ | |
| " XX ", | |
| " X ", | |
| " XXX ", | |
| " X X ", | |
| " XXX ", | |
| ], | |
| '7': [ | |
| " XXX ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| " X ", | |
| ], | |
| '8': [ | |
| " XXX ", | |
| " X X ", | |
| " XXX ", | |
| " X X ", | |
| " XXX ", | |
| ], | |
| '9': [ | |
| " XXX ", | |
| " X X ", | |
| " XXX ", | |
| " X ", | |
| " XX ", | |
| ], | |
| ' ': [ | |
| " ", | |
| " ", | |
| " ", | |
| " ", | |
| " ", | |
| ], | |
| ':': [ | |
| " ", | |
| " X ", | |
| " ", | |
| " X ", | |
| " ", | |
| ], | |
| } | |
| def render_char(frame, ch, x0, y0, z0, color=(255,255,255), axis='z', scale=1): | |
| ch = ch.upper() | |
| bitmap = FONT_5x5.get(ch, FONT_5x5[' ']) | |
| height = len(bitmap) | |
| for y, row in enumerate(bitmap): | |
| for x, c in enumerate(row): | |
| if c == 'X': | |
| # Flip Y by subtracting from height | |
| flipped_y = height - 1 - y | |
| # Render scaled pixels with bounds checking | |
| for sy in range(int(scale)): | |
| for sx in range(int(scale)): | |
| if axis == 'z': | |
| voxel_x = x0 + x * int(scale) + sx | |
| voxel_y = y0 + flipped_y * int(scale) + sy | |
| if 0 <= voxel_x < SIZE and 0 <= voxel_y < SIZE and 0 <= z0 < SIZE: | |
| add_voxel_safe(frame, voxel_x, voxel_y, z0, color) | |
| def render_string(frame, text, x0, y0, z0, spacing=1, color=(255,255,255), axis='z', scale=1): | |
| step = (5 * scale) + spacing | |
| for i, ch in enumerate(text): | |
| if axis == 'z': | |
| render_char(frame, ch, x0 + int(i * step), y0, z0, color, axis, scale) | |
| # Animation parameters | |
| SIZE = 128 # 128x128x128 voxel grid | |
| FPS = 30 # frames per second | |
| SECONDS = 60 # duration in seconds (1 minute) | |
| FRAMES = FPS * SECONDS | |
| OUTPUT_PATH = "snake_game.splv" | |
| # Snake game parameters | |
| GRID_SIZE = 8 # Snake moves on an 8x8x8 logical grid within the voxel space | |
| SNAKE_SPEED = 4.0 # grid cells per second (increased from 2.0) | |
| SNAKE_LENGTH = 3 # initial length in segments (smaller snake) | |
| SEGMENT_SIZE = 2 # voxel thickness of each segment (thinner segments) | |
| # Collectible parameters | |
| MAX_COLLECTIBLES = 3 # maximum number of collectibles on screen at once | |
| COLLECTIBLE_SIZE = 3 # voxel size of collectibles | |
| COLLECTIBLE_SPAWN_RATE = 1.5 # probability of spawning a collectible per second | |
| # Calculate voxel positions from grid coordinates | |
| CELL_SIZE = SIZE // GRID_SIZE # Each grid cell is 16x16x16 voxels | |
| def grid_to_voxel(grid_pos): | |
| """Convert grid coordinates to voxel coordinates (centered in cell)""" | |
| return [int((pos + 0.5) * CELL_SIZE) for pos in grid_pos] | |
| def add_voxel_safe(frame, x, y, z, color): | |
| """Safely add a voxel with bounds checking""" | |
| if 0 <= x < SIZE and 0 <= y < SIZE and 0 <= z < SIZE: | |
| frame.set_voxel(x, y, z, color) | |
| def generate_pickup_sound(filename="collectible_pickup.wav", duration=0.3, sample_rate=44100): | |
| """Generate a pleasant pickup sound effect""" | |
| frames = [] | |
| # Generate a pleasant ascending chime sound | |
| for i in range(int(duration * sample_rate)): | |
| t = float(i) / sample_rate | |
| # Create a pleasant chime with multiple harmonics | |
| # Main frequency sweeps up from 440Hz to 880Hz | |
| freq = 440 + (440 * t / duration) | |
| # Add harmonics for richness | |
| wave1 = math.sin(freq * 2.0 * math.pi * t) * 0.5 | |
| wave2 = math.sin(freq * 3.0 * 2.0 * math.pi * t) * 0.3 | |
| wave3 = math.sin(freq * 5.0 * 2.0 * math.pi * t) * 0.2 | |
| # Envelope: quick attack, gradual decay | |
| envelope = math.exp(-t * 5.0) | |
| # Combine waves with envelope | |
| sample = (wave1 + wave2 + wave3) * envelope | |
| # Convert to 16-bit PCM | |
| sample_int = int(sample * 32767) | |
| frames.append(struct.pack('<h', sample_int)) | |
| # Write WAV file | |
| with wave.open(filename, 'wb') as wav_file: | |
| wav_file.setnchannels(1) # Mono | |
| wav_file.setsampwidth(2) # 16-bit | |
| wav_file.setframerate(sample_rate) | |
| wav_file.writeframes(b''.join(frames)) | |
| return filename | |
| def play_audio_file(filename): | |
| """Play an audio file using the system's default audio player""" | |
| try: | |
| if os.path.exists(filename): | |
| # Try different audio players based on the system | |
| if os.name == 'posix': # macOS/Linux | |
| if subprocess.run(['which', 'afplay'], capture_output=True).returncode == 0: | |
| subprocess.run(['afplay', filename], check=True) | |
| return True | |
| elif subprocess.run(['which', 'aplay'], capture_output=True).returncode == 0: | |
| subprocess.run(['aplay', filename], check=True) | |
| return True | |
| elif os.name == 'nt': # Windows | |
| subprocess.run(['start', filename], shell=True, check=True) | |
| return True | |
| except Exception as e: | |
| print(f"Could not play audio file: {e}") | |
| return False | |
| class Particle: | |
| def __init__(self, pos, velocity, color, lifetime=1.0): | |
| self.pos = list(pos) # [x, y, z] in voxel coordinates | |
| self.velocity = list(velocity) # [vx, vy, vz] in voxels per second | |
| self.color = color | |
| self.lifetime = lifetime | |
| self.age = 0.0 | |
| self.active = True | |
| def update(self, dt): | |
| """Update particle position and age""" | |
| if not self.active: | |
| return | |
| self.age += dt | |
| if self.age >= self.lifetime: | |
| self.active = False | |
| return | |
| # Update position | |
| for i in range(3): | |
| self.pos[i] += self.velocity[i] * dt | |
| # Add gravity effect | |
| self.velocity[1] -= 50 * dt # gravity pulls down | |
| def render(self, frame): | |
| """Render the particle""" | |
| if not self.active: | |
| return | |
| # Fade out over lifetime | |
| alpha = 1.0 - (self.age / self.lifetime) | |
| faded_color = tuple(int(c * alpha) for c in self.color) | |
| x, y, z = [int(p) for p in self.pos] | |
| add_voxel_safe(frame, x, y, z, faded_color) | |
| class Collectible: | |
| def __init__(self, grid_pos): | |
| self.grid_pos = list(grid_pos) | |
| self.collected = False | |
| self.animation_time = 0.0 | |
| def update(self, dt): | |
| """Update collectible animation""" | |
| self.animation_time += dt | |
| def render(self, frame): | |
| """Render the collectible with pulsing animation""" | |
| if self.collected: | |
| return | |
| # Convert to voxel coordinates | |
| voxel_pos = [self.grid_pos[j] * CELL_SIZE + CELL_SIZE//2 for j in range(3)] | |
| # Pulsing animation - golden color with brightness variation | |
| pulse = 0.7 + 0.3 * math.sin(self.animation_time * 8) # pulse between 0.7 and 1.0 | |
| base_color = (255, 215, 0) # Gold color | |
| color = tuple(int(c * pulse) for c in base_color) | |
| # Render as a diamond/star shape | |
| half_size = COLLECTIBLE_SIZE // 2 | |
| for dx in range(-half_size, half_size + 1): | |
| for dy in range(-half_size, half_size + 1): | |
| for dz in range(-half_size, half_size + 1): | |
| # Create a diamond shape (manhattan distance) | |
| distance = abs(dx) + abs(dy) + abs(dz) | |
| if distance <= COLLECTIBLE_SIZE: | |
| x = int(voxel_pos[0]) + dx | |
| y = int(voxel_pos[1]) + dy | |
| z = int(voxel_pos[2]) + dz | |
| add_voxel_safe(frame, x, y, z, color) | |
| def create_pickup_particles(self): | |
| """Create particles for pickup effect""" | |
| particles = [] | |
| voxel_pos = [self.grid_pos[j] * CELL_SIZE + CELL_SIZE//2 for j in range(3)] | |
| # Create burst of particles | |
| for _ in range(12): | |
| # Random velocity in all directions | |
| velocity = [ | |
| random.uniform(-30, 30), | |
| random.uniform(10, 40), # Upward bias | |
| random.uniform(-30, 30) | |
| ] | |
| # Start at collectible position with small random offset | |
| start_pos = [ | |
| voxel_pos[0] + random.uniform(-2, 2), | |
| voxel_pos[1] + random.uniform(-2, 2), | |
| voxel_pos[2] + random.uniform(-2, 2) | |
| ] | |
| # Bright sparkle colors | |
| colors = [ | |
| (255, 255, 100), # Bright yellow | |
| (255, 200, 100), # Orange-yellow | |
| (255, 255, 200), # Light yellow | |
| (255, 150, 50), # Orange | |
| ] | |
| color = random.choice(colors) | |
| particles.append(Particle(start_pos, velocity, color, lifetime=0.8)) | |
| return particles | |
| class SnakeGame: | |
| def __init__(self): | |
| # Snake starts in center, moving right | |
| center = GRID_SIZE // 2 | |
| self.head_pos = [center, center, center] | |
| self.direction = [1, 0, 0] # moving right initially | |
| # Snake body segments (positions in grid coordinates) | |
| self.segments = [] | |
| for i in range(SNAKE_LENGTH): | |
| self.segments.append([center - i - 1, center, center]) | |
| # Path planning - create a interesting route through 3D space | |
| self.path_points = self.generate_path() | |
| self.current_target = 0 | |
| # Animation timing | |
| self.steps_per_cell = FPS / SNAKE_SPEED # frames needed to move one grid cell | |
| self.step_counter = 0 | |
| # Collectibles system | |
| self.collectibles = [] | |
| self.snake_length = SNAKE_LENGTH # current snake length (can grow) | |
| self.spawn_timer = 0.0 | |
| self.score = 0 | |
| # Sound system - track when sounds should be played | |
| self.sound_events = [] # List of (frame_number, sound_type) tuples | |
| self.current_frame = 0 | |
| # Particle system | |
| self.particles = [] | |
| # Game state | |
| self.game_over = False | |
| self.game_over_frame = None | |
| self.collision_avoidance_disabled = False # Will be enabled near end | |
| # Audio progression system | |
| self.consecutive_collections = 0 | |
| self.last_collection_frame = 0 | |
| # Spawn initial collectibles strategically | |
| print(f"π Snake starting at {self.head_pos} with {len(self.segments)} segments") | |
| self.spawn_collectible() | |
| self.spawn_collectible() | |
| self.spawn_collectible() # Spawn one more for better gameplay | |
| print(f"π Initial setup: {len(self.collectibles)} collectibles spawned") | |
| def generate_path(self): | |
| """Generate an interesting path through 3D space for the snake to follow""" | |
| points = [] | |
| # Create a path that moves through different levels and directions | |
| # Level 1: Horizontal figure-8 pattern | |
| center = GRID_SIZE // 2 | |
| for angle in np.linspace(0, 4*math.pi, 16): | |
| x = center + int(2.5 * math.cos(angle)) | |
| z = center + int(1.5 * math.sin(2*angle)) | |
| y = 2 | |
| points.append([max(1, min(GRID_SIZE-2, x)), y, max(1, min(GRID_SIZE-2, z))]) | |
| # Level 2: Rising spiral | |
| for i in range(12): | |
| angle = i * 0.8 | |
| x = center + int(2 * math.cos(angle)) | |
| z = center + int(2 * math.sin(angle)) | |
| y = 2 + i // 3 | |
| points.append([max(1, min(GRID_SIZE-2, x)), min(GRID_SIZE-2, y), max(1, min(GRID_SIZE-2, z))]) | |
| # Level 3: Top level box pattern | |
| top_y = GRID_SIZE - 2 | |
| box_points = [ | |
| [2, top_y, 2], [6, top_y, 2], [6, top_y, 6], [2, top_y, 6], [2, top_y, 2], | |
| [3, top_y, 3], [5, top_y, 3], [5, top_y, 5], [3, top_y, 5], [3, top_y, 3] | |
| ] | |
| points.extend(box_points) | |
| return points | |
| def spawn_collectible(self): | |
| """Spawn a new collectible at a strategic position""" | |
| if len(self.collectibles) >= MAX_COLLECTIBLES: | |
| return | |
| # Try to spawn in accessible locations for better gameplay | |
| attempts = 0 | |
| while attempts < 100: # More attempts to find good positions | |
| if attempts < 40 and len(self.path_points) > 0: | |
| # First try spawning along the path points | |
| path_idx = random.randint(0, len(self.path_points) - 1) | |
| base_pos = self.path_points[path_idx] | |
| # Add some randomness around the path point | |
| pos = [ | |
| max(1, min(GRID_SIZE - 2, base_pos[0] + random.randint(-1, 1))), | |
| max(1, min(GRID_SIZE - 2, base_pos[1] + random.randint(-1, 1))), | |
| max(1, min(GRID_SIZE - 2, base_pos[2] + random.randint(-1, 1))) | |
| ] | |
| elif attempts < 70: | |
| # Try spawning in open areas (corners and edges) | |
| edge_positions = [ | |
| [1, 1, 1], [1, 1, GRID_SIZE-2], [1, GRID_SIZE-2, 1], [1, GRID_SIZE-2, GRID_SIZE-2], | |
| [GRID_SIZE-2, 1, 1], [GRID_SIZE-2, 1, GRID_SIZE-2], [GRID_SIZE-2, GRID_SIZE-2, 1], [GRID_SIZE-2, GRID_SIZE-2, GRID_SIZE-2], | |
| [GRID_SIZE//2, 1, GRID_SIZE//2], [GRID_SIZE//2, GRID_SIZE-2, GRID_SIZE//2], | |
| [1, GRID_SIZE//2, GRID_SIZE//2], [GRID_SIZE-2, GRID_SIZE//2, GRID_SIZE//2] | |
| ] | |
| pos = random.choice(edge_positions) | |
| else: | |
| # Random position as final fallback | |
| pos = [ | |
| random.randint(1, GRID_SIZE - 2), | |
| random.randint(1, GRID_SIZE - 2), | |
| random.randint(1, GRID_SIZE - 2) | |
| ] | |
| # Check if position is occupied by snake | |
| occupied = False | |
| if pos == self.head_pos: | |
| occupied = True | |
| for segment in self.segments: | |
| if pos == segment: | |
| occupied = True | |
| break | |
| # Check if position is occupied by existing collectible | |
| for collectible in self.collectibles: | |
| if pos == collectible.grid_pos: | |
| occupied = True | |
| break | |
| if not occupied: | |
| self.collectibles.append(Collectible(pos)) | |
| print(f"π Collectible spawned at {pos}") | |
| break | |
| attempts += 1 | |
| def check_collectible_collision(self): | |
| """Check if snake head collides with any collectibles""" | |
| # Get the current head position (accounting for interpolation) | |
| current_positions = self.get_interpolated_positions() | |
| if not current_positions: | |
| return False | |
| # Use the interpolated head position | |
| head_pos = current_positions[0] | |
| # Convert to grid coordinates for comparison | |
| head_grid_pos = [int(round(head_pos[i])) for i in range(3)] | |
| for collectible in self.collectibles: | |
| if not collectible.collected: | |
| # Check if head is exactly at the collectible position (direct hit) | |
| if head_grid_pos == collectible.grid_pos: | |
| # Collectible collected! | |
| collectible.collected = True | |
| self.score += 1 | |
| self.snake_length += 1 # Grow the snake | |
| # Update consecutive collection tracking | |
| frame_gap = self.current_frame - self.last_collection_frame | |
| if frame_gap <= 90: # Within 3 seconds (90 frames at 30fps) | |
| self.consecutive_collections += 1 | |
| else: | |
| self.consecutive_collections = 1 # Reset chain | |
| self.last_collection_frame = self.current_frame | |
| # Record sound event with pitch information | |
| pitch_level = min(self.consecutive_collections - 1, 6) # Cap at 6 levels | |
| self.sound_events.append((self.current_frame, "pickup", pitch_level)) | |
| # Create particle effect | |
| pickup_particles = collectible.create_pickup_particles() | |
| self.particles.extend(pickup_particles) | |
| print(f"π Collectible collected at {collectible.grid_pos}! Score: {self.score}, Snake length: {self.snake_length}") | |
| print(f" Head was at {head_grid_pos} (exact match!) Consecutive: {self.consecutive_collections}, Pitch: {pitch_level}") | |
| return True | |
| return False | |
| def update_collectibles(self, dt): | |
| """Update all collectibles and handle spawning""" | |
| # Update existing collectibles | |
| for collectible in self.collectibles: | |
| collectible.update(dt) | |
| # Remove collected collectibles after a short delay | |
| self.collectibles = [c for c in self.collectibles if not c.collected] | |
| # Handle spawning - spawn more aggressively to ensure collection opportunities | |
| self.spawn_timer += dt | |
| spawn_probability = COLLECTIBLE_SPAWN_RATE * dt | |
| # Increase spawn rate if there are fewer collectibles | |
| if len(self.collectibles) < 2: | |
| spawn_probability *= 2.0 # Double spawn rate when low on collectibles | |
| if random.random() < spawn_probability: | |
| self.spawn_collectible() | |
| def would_collide_with_self(self, new_head): | |
| """Check if moving to new_head would cause self-collision""" | |
| # Check against current segments (body) | |
| for segment in self.segments: | |
| if new_head == segment: | |
| return True | |
| return False | |
| def find_safe_direction(self, preferred_direction): | |
| """Find a safe direction that doesn't cause self-collision""" | |
| # If collision avoidance is disabled (for game over), allow collision | |
| if self.collision_avoidance_disabled: | |
| return preferred_direction | |
| # All possible directions: right, left, up, down, forward, back | |
| directions = [ | |
| [1, 0, 0], [-1, 0, 0], # x-axis | |
| [0, 1, 0], [0, -1, 0], # y-axis | |
| [0, 0, 1], [0, 0, -1] # z-axis | |
| ] | |
| # Try preferred direction first if safe | |
| if preferred_direction != [0, 0, 0]: | |
| new_head = [self.head_pos[i] + preferred_direction[i] for i in range(3)] | |
| # Check bounds and self-collision | |
| in_bounds = all(1 <= new_head[i] <= GRID_SIZE-2 for i in range(3)) | |
| if in_bounds and not self.would_collide_with_self(new_head): | |
| return preferred_direction | |
| # If preferred direction is unsafe, try other directions | |
| for direction in directions: | |
| # Don't reverse direction (move backwards into body) | |
| if direction == [-d for d in self.direction]: | |
| continue | |
| new_head = [self.head_pos[i] + direction[i] for i in range(3)] | |
| # Check bounds and self-collision | |
| in_bounds = all(1 <= new_head[i] <= GRID_SIZE-2 for i in range(3)) | |
| if in_bounds and not self.would_collide_with_self(new_head): | |
| return direction | |
| # If no safe direction found, keep current direction (emergency) | |
| return self.direction | |
| def find_nearest_collectible(self): | |
| """Find the nearest uncollected collectible""" | |
| nearest = None | |
| min_distance = float('inf') | |
| for collectible in self.collectibles: | |
| if not collectible.collected: | |
| # Calculate Manhattan distance | |
| distance = sum(abs(self.head_pos[i] - collectible.grid_pos[i]) for i in range(3)) | |
| if distance < min_distance: | |
| min_distance = distance | |
| nearest = collectible | |
| return nearest | |
| def update_direction(self): | |
| """Update snake direction to move toward collectibles or follow path""" | |
| # Priority 1: Move toward nearest collectible | |
| nearest_collectible = self.find_nearest_collectible() | |
| if nearest_collectible: | |
| target = nearest_collectible.grid_pos | |
| else: | |
| # Priority 2: Follow the predetermined path | |
| if self.current_target >= len(self.path_points): | |
| self.current_target = 0 # Loop back to start | |
| target = self.path_points[self.current_target] | |
| # Calculate direction to target | |
| diff = [target[i] - self.head_pos[i] for i in range(3)] | |
| # Choose the axis with the largest difference | |
| max_diff = max(abs(d) for d in diff) if any(d != 0 for d in diff) else 0 | |
| if max_diff == 0: | |
| # Reached target, move to next path point if following path | |
| if not nearest_collectible: | |
| self.current_target += 1 | |
| return | |
| # Set preferred direction to move toward target (one axis at a time) | |
| preferred_direction = [0, 0, 0] | |
| for i in range(3): | |
| if abs(diff[i]) == max_diff: | |
| preferred_direction[i] = 1 if diff[i] > 0 else -1 | |
| break | |
| # Find safe direction (avoiding self-collision) | |
| self.direction = self.find_safe_direction(preferred_direction) | |
| def update(self, frame_number): | |
| """Update snake position based on constant speed movement""" | |
| self.current_frame = frame_number | |
| dt = 1.0 / FPS # delta time for this frame | |
| # Check if we should trigger game over (near end of animation) | |
| if not self.game_over and frame_number > FRAMES - 150: # Last 5 seconds | |
| # Disable collision avoidance to allow "accidental" collision | |
| self.collision_avoidance_disabled = True | |
| # If game is over, don't update movement | |
| if self.game_over: | |
| # Still update particles for visual effects | |
| for particle in self.particles: | |
| particle.update(dt) | |
| self.particles = [p for p in self.particles if p.active] | |
| return | |
| # Update collectibles | |
| self.update_collectibles(dt) | |
| # Update particles | |
| for particle in self.particles: | |
| particle.update(dt) | |
| # Remove inactive particles | |
| self.particles = [p for p in self.particles if p.active] | |
| # Check for collisions every frame (not just when moving to new grid cell) | |
| self.check_collectible_collision() | |
| self.step_counter += 1 | |
| # Move to next grid cell when enough time has passed | |
| if self.step_counter >= self.steps_per_cell: | |
| self.step_counter = 0 | |
| # Update direction toward next target | |
| self.update_direction() | |
| # Move head | |
| new_head = [self.head_pos[i] + self.direction[i] for i in range(3)] | |
| # Keep snake within bounds | |
| for i in range(3): | |
| new_head[i] = max(1, min(GRID_SIZE-2, new_head[i])) | |
| # Check for self-collision BEFORE moving | |
| if self.would_collide_with_self(new_head): | |
| self.game_over = True | |
| self.game_over_frame = frame_number | |
| self.sound_events.append((frame_number, "game_over")) | |
| print(f"π GAME OVER! Snake collided with itself at frame {frame_number}") | |
| print(f"π Final score: {self.score} collectibles collected!") | |
| print(f"π Final snake length: {self.snake_length} segments") | |
| return | |
| # Check if we reached the target | |
| target = self.path_points[self.current_target] if self.current_target < len(self.path_points) else self.path_points[0] | |
| if new_head == target: | |
| self.current_target += 1 | |
| # Update snake body | |
| self.segments.insert(0, list(self.head_pos)) # Add old head to body | |
| # Update head position | |
| self.head_pos = new_head | |
| # Maintain snake length (grow if collectibles were eaten) | |
| if len(self.segments) > self.snake_length: | |
| self.segments.pop() # Remove tail | |
| def get_interpolated_positions(self): | |
| """Get smoothly interpolated positions for rendering""" | |
| # Interpolation factor for smooth movement between grid cells | |
| t = self.step_counter / self.steps_per_cell | |
| positions = [] | |
| # Interpolated head position | |
| if self.segments: | |
| old_head = self.segments[0] | |
| interp_head = [old_head[i] + t * self.direction[i] for i in range(3)] | |
| positions.append(interp_head) | |
| else: | |
| positions.append(list(self.head_pos)) | |
| # Body segments (no interpolation needed, they follow discretely) | |
| positions.extend(self.segments) | |
| return positions | |
| def render_contiguous_segment(self, frame, start_pos, end_pos, color, size): | |
| """Render a contiguous segment between two positions""" | |
| # Convert grid positions to voxel coordinates | |
| start_voxel = [start_pos[j] * CELL_SIZE + CELL_SIZE//2 for j in range(3)] | |
| end_voxel = [end_pos[j] * CELL_SIZE + CELL_SIZE//2 for j in range(3)] | |
| # Calculate the direction vector | |
| direction = [end_voxel[i] - start_voxel[i] for i in range(3)] | |
| # Find the maximum distance along any axis | |
| max_distance = max(abs(d) for d in direction) | |
| if max_distance == 0: | |
| # Same position, just render a single segment | |
| self.render_single_segment(frame, start_voxel, color, size) | |
| return | |
| # Interpolate along the path to fill gaps | |
| steps = max(int(max_distance), 1) | |
| for step in range(steps + 1): | |
| t = step / steps if steps > 0 else 0 | |
| interp_pos = [ | |
| start_voxel[i] + t * direction[i] for i in range(3) | |
| ] | |
| self.render_single_segment(frame, interp_pos, color, size) | |
| def render_single_segment(self, frame, voxel_pos, color, size): | |
| """Render a single segment at the given voxel position""" | |
| half_size = size // 2 | |
| for dx in range(-half_size, half_size + 1): | |
| for dy in range(-half_size, half_size + 1): | |
| for dz in range(-half_size, half_size + 1): | |
| # Add some shape variation - not perfectly cubic | |
| distance = abs(dx) + abs(dy) + abs(dz) | |
| if distance <= size: | |
| x = int(voxel_pos[0]) + dx | |
| y = int(voxel_pos[1]) + dy | |
| z = int(voxel_pos[2]) + dz | |
| add_voxel_safe(frame, x, y, z, color) | |
| def render(self, frame): | |
| """Render the snake, collectibles, particles, and UI to the frame""" | |
| # Render collectibles first (so they appear behind snake if overlapping) | |
| for collectible in self.collectibles: | |
| collectible.render(frame) | |
| # Render particles | |
| for particle in self.particles: | |
| particle.render(frame) | |
| # Render snake with contiguous segments | |
| positions = self.get_interpolated_positions() | |
| # Color scheme - vibrant gaming colors | |
| head_color = (255, 100, 100) # Bright red head | |
| body_colors = [ | |
| (100, 255, 100), # Bright green | |
| (120, 255, 120), # Light green | |
| (80, 200, 80), # Medium green | |
| (60, 180, 60), # Darker green | |
| ] | |
| if len(positions) == 0: | |
| return | |
| # Render head | |
| head_voxel_pos = [positions[0][j] * CELL_SIZE + CELL_SIZE//2 for j in range(3)] | |
| self.render_single_segment(frame, head_voxel_pos, head_color, SEGMENT_SIZE + 1) | |
| # Render body segments with connections | |
| for i in range(1, len(positions)): | |
| # Choose color for this segment | |
| color_idx = (i - 1) % len(body_colors) | |
| color = body_colors[color_idx] | |
| # Render the segment itself | |
| segment_voxel_pos = [positions[i][j] * CELL_SIZE + CELL_SIZE//2 for j in range(3)] | |
| self.render_single_segment(frame, segment_voxel_pos, color, SEGMENT_SIZE) | |
| # Render connection between this segment and the previous one | |
| prev_pos = positions[i-1] | |
| curr_pos = positions[i] | |
| self.render_contiguous_segment(frame, prev_pos, curr_pos, color, SEGMENT_SIZE) | |
| # Render score in top left corner | |
| score_text = f"SCORE: {self.score}" | |
| render_string(frame, score_text, 2, SIZE-8, 2, spacing=1, color=(255, 255, 255), axis='z', scale=1) | |
| # Render game over text if game is over | |
| if self.game_over: | |
| # Render "GAME" and "OVER" on separate lines | |
| game_text = "GAME" | |
| over_text = "OVER" | |
| # Calculate positioning for centered text | |
| game_width = len(game_text) * 6 * 2 # scale=2 | |
| over_width = len(over_text) * 6 * 2 # scale=2 | |
| game_center_x = (SIZE - game_width) // 2 | |
| over_center_x = (SIZE - over_width) // 2 | |
| center_y = SIZE // 2 | |
| # Render "GAME" on first line | |
| render_string(frame, game_text, game_center_x, center_y + 6, SIZE//2, | |
| spacing=2, color=(255, 0, 0), axis='z', scale=2) | |
| # Render "OVER" on second line (below "GAME") | |
| render_string(frame, over_text, over_center_x, center_y - 6, SIZE//2, | |
| spacing=2, color=(255, 0, 0), axis='z', scale=2) | |
| def generate_audio_for_splv(self, sample_rate=44100): | |
| """Generate audio buffer for direct encoding into .splv file""" | |
| if not self.sound_events: | |
| print("No sound events recorded") | |
| return None, None | |
| # Calculate total duration | |
| total_duration = SECONDS | |
| total_samples = int(total_duration * sample_rate) | |
| # Initialize audio buffer | |
| audio_buffer = np.zeros(total_samples, dtype=np.float32) | |
| # Generate pickup sounds with different pitches | |
| pickup_duration = 0.3 | |
| pickup_samples = int(pickup_duration * sample_rate) | |
| pickup_sounds = {} # Dictionary to store different pitch levels | |
| # Create 7 different pitch levels (0-6) | |
| for pitch_level in range(7): | |
| pickup_sound = np.zeros(pickup_samples, dtype=np.float32) | |
| # Base frequency increases with pitch level | |
| base_freq = 440 + (pitch_level * 80) # 440, 520, 600, 680, 760, 840, 920 Hz | |
| for i in range(pickup_samples): | |
| t = float(i) / sample_rate | |
| freq = base_freq + (base_freq * 0.5 * t / pickup_duration) # Slight upward sweep | |
| wave1 = math.sin(freq * 2.0 * math.pi * t) * 0.5 | |
| wave2 = math.sin(freq * 3.0 * 2.0 * math.pi * t) * 0.3 | |
| wave3 = math.sin(freq * 5.0 * 2.0 * math.pi * t) * 0.2 | |
| envelope = math.exp(-t * 5.0) | |
| pickup_sound[i] = (wave1 + wave2 + wave3) * envelope * 0.3 # Lower volume | |
| pickup_sounds[pitch_level] = pickup_sound | |
| # Generate game over sound (dramatic descending tone) | |
| game_over_duration = 1.5 | |
| game_over_samples = int(game_over_duration * sample_rate) | |
| game_over_sound = np.zeros(game_over_samples, dtype=np.float32) | |
| for i in range(game_over_samples): | |
| t = float(i) / sample_rate | |
| # Descending frequency from 330Hz to 110Hz | |
| freq = 330 - (220 * t / game_over_duration) | |
| # Multiple harmonics for dramatic effect | |
| wave1 = math.sin(freq * 2.0 * math.pi * t) * 0.6 | |
| wave2 = math.sin(freq * 0.5 * 2.0 * math.pi * t) * 0.4 | |
| wave3 = math.sin(freq * 1.5 * 2.0 * math.pi * t) * 0.3 | |
| # Gradual decay envelope | |
| envelope = math.exp(-t * 1.5) | |
| game_over_sound[i] = (wave1 + wave2 + wave3) * envelope * 0.5 | |
| # Place sound effects at the correct times | |
| for sound_event in self.sound_events: | |
| if len(sound_event) == 2: | |
| # Old format: (frame_number, sound_type) | |
| frame_number, sound_type = sound_event | |
| pitch_level = 0 # Default pitch | |
| else: | |
| # New format: (frame_number, sound_type, pitch_level) | |
| frame_number, sound_type, pitch_level = sound_event | |
| time_seconds = frame_number / FPS | |
| sample_start = int(time_seconds * sample_rate) | |
| if sound_type == "pickup": | |
| # Use the appropriate pitch level | |
| pickup_sound = pickup_sounds.get(pitch_level, pickup_sounds[0]) | |
| # Add pickup sound to buffer | |
| for i in range(pickup_samples): | |
| if sample_start + i < total_samples: | |
| audio_buffer[sample_start + i] += pickup_sound[i] | |
| elif sound_type == "game_over": | |
| # Add game over sound to buffer | |
| for i in range(game_over_samples): | |
| if sample_start + i < total_samples: | |
| audio_buffer[sample_start + i] += game_over_sound[i] | |
| # Normalize audio to prevent clipping | |
| if np.max(np.abs(audio_buffer)) > 0: | |
| audio_buffer = audio_buffer / np.max(np.abs(audio_buffer)) * 0.8 | |
| # Convert to uint8 format for SPLV encoding (as per spatialstudio docs) | |
| # First convert to 16-bit range, then to uint8 | |
| audio_16bit = (audio_buffer * 32767).astype(np.int16) | |
| audio_uint8 = ((audio_16bit.astype(np.float32) + 32768) / 65536 * 255).astype(np.uint8) | |
| # Audio parameters: (channels, sampleRate, bytesPerSample) | |
| audio_params = (1, sample_rate, 1) # Mono, 44.1kHz, 1 byte per sample | |
| return audio_uint8, audio_params | |
| def main(): | |
| """Generate the snake game animation""" | |
| print(f"Creating snake game animation: {SIZE}Β³ voxels, {SECONDS}s @ {FPS}fps") | |
| print(f"Snake moves on {GRID_SIZE}Β³ logical grid at {SNAKE_SPEED} cells/second") | |
| # Initialize snake game | |
| snake = SnakeGame() | |
| # Initialize encoder with audio support (we'll generate audio after simulation) | |
| # Use placeholder audio params for now | |
| audio_params = (1, 44100, 1) # Mono, 44.1kHz, 1 byte per sample | |
| encoder = splv.splv.Encoder( | |
| width=SIZE, | |
| height=SIZE, | |
| depth=SIZE, | |
| framerate=FPS, | |
| audioParams=audio_params, # Enable audio encoding | |
| outputPath=OUTPUT_PATH | |
| ) | |
| # Generate frames | |
| for frame_idx in tqdm(range(FRAMES), desc="Generating frames"): | |
| # Update snake position | |
| snake.update(frame_idx) | |
| # Create frame | |
| frame = splv.splv.Frame(SIZE, SIZE, SIZE) | |
| # Render snake | |
| snake.render(frame) | |
| # Encode frame | |
| encoder.encode(frame) | |
| # Now generate audio data based on collected sound events | |
| audio_data, _ = snake.generate_audio_for_splv() | |
| # Encode audio data into the .splv file | |
| if audio_data is not None and len(snake.sound_events) > 0: | |
| print(f"π΅ Encoding {len(audio_data)} audio samples into .splv file...") | |
| encoder.encode_audio(audio_data) | |
| print(f"πΆ {len(snake.sound_events)} sound effects included") | |
| encoder.finish() | |
| print(f"β¨ Snake game animation with audio saved to {OUTPUT_PATH}") | |
| print(f"π Final score: {snake.score} collectibles collected!") | |
| print(f"π Final snake length: {snake.snake_length} segments") | |
| if snake.sound_events: | |
| print("π Audio has been encoded directly into the .splv file!") | |
| else: | |
| print("π No collectibles were collected, no audio track generated") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment