Skip to content

Instantly share code, notes, and snippets.

@Jeremiah-England
Last active March 4, 2025 06:16
Show Gist options
  • Save Jeremiah-England/7ee1a484fb99796f5c3ba84492e6a56b to your computer and use it in GitHub Desktop.
Save Jeremiah-England/7ee1a484fb99796f5c3ba84492e6a56b to your computer and use it in GitHub Desktop.
Snowflakes Generator with Pygame and Pyscript
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Snowflake Generator - PyScript</title>
<link rel="stylesheet" href="https://pyscript.net/releases/2025.2.4/core.css">
<script type="module" src="https://pyscript.net/releases/2025.2.4/core.js"></script>
<style>
body {
background-color: black;
color: white;
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
}
#header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 20px;
background-color: rgba(20, 20, 20, 0.8);
flex-shrink: 0;
}
h1 {
margin: 0;
flex: 1;
text-align: center;
}
#info {
color: #aaa;
font-size: 16px;
margin-right: 20px;
min-width: 150px;
}
#controls {
display: flex;
gap: 10px;
}
#snowflakes-container {
width: 100%;
flex: 1; /* Take up all available space */
margin: 0 auto;
display: flex;
justify-content: center;
align-items: center;
}
button {
padding: 6px 12px;
background-color: #444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #555;
}
/* Info Modal Styles */
#info-button {
margin-left: 10px;
font-size: 16px;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #555;
}
#modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
#info-modal {
background-color: #222;
border-radius: 8px;
padding: 20px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
color: #ddd;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
#info-modal h2 {
margin-top: 0;
color: #fff;
border-bottom: 1px solid #444;
padding-bottom: 10px;
}
#info-modal section {
margin-bottom: 20px;
}
#info-modal h3 {
color: #aaa;
margin-bottom: 8px;
}
#modal-close {
background-color: #444;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
margin-top: 10px;
}
#modal-close:hover {
background-color: #555;
}
</style>
</head>
<body>
<div id="header">
<div id="info"></div>
<h1>Snowflake Generator</h1>
<div id="controls">
<button id="pause-btn">Pause</button>
<button id="zoom-in-btn">Zoom In</button>
<button id="zoom-out-btn">Zoom Out</button>
<button id="info-button">ⓘ</button>
</div>
</div>
<div id="snowflakes-container"></div>
<!-- Information Modal -->
<div id="modal-overlay">
<div id="info-modal">
<h2>About Snowflake Generator</h2>
<section>
<h3>Controls</h3>
<ul>
<li><strong>Drag mouse:</strong> Pan camera</li>
<li><strong>Mouse wheel:</strong> Zoom in/out</li>
<li><strong>Arrow keys / WASD:</strong> Pan camera</li>
<li><strong>Space:</strong> Pause/Resume</li>
<li><strong>+/- keys:</strong> Zoom in/out</li>
</ul>
</section>
<section>
<h3>About PyGame</h3>
<p>PyGame is a set of Python modules designed for writing video games. It provides functionality for graphics, sound, input handling, and more, making it easy to create interactive applications.</p>
</section>
<section>
<h3>About PyScript</h3>
<p>PyScript allows Python to run in web browsers, bringing Python's capabilities to the web platform. It enables developers to create Python applications that can be run directly in a browser without server-side processing.</p>
</section>
<section>
<h3>Credits</h3>
<p>This Snowflake Generator was initially created with Grok 3, with refinements and enhancements by Claude 3 Sonnet.</p>
<p>The application uses fractal L-systems to generate unique, procedurally-created snowflakes with realistic physics and interactive controls.</p>
</section>
<button id="modal-close">Close</button>
</div>
</div>
<py-config>
packages = ["pygame-ce"]
</py-config>
<script type="py">
import js
import asyncio
import math
import random
import base64
from pyscript import document
# Define keyboard shortcuts
keys_pressed = set()
def key_down(event):
global keys_pressed
keys_pressed.add(event.code)
print(f"Key down: {event.code}")
def key_up(event):
global keys_pressed
if event.code in keys_pressed:
keys_pressed.remove(event.code)
# Handle single-press keys on key up to prevent repeats
if event.code == "Space":
toggle_pause(None) # Call the pause function
print(f"Key up: {event.code}")
# Add keyboard handlers
try:
document.onkeydown = key_down
document.onkeyup = key_up
print("Keyboard handlers attached")
except Exception as e:
print(f"Error setting up keyboard handlers: {e}")
# Process keyboard input during animation
def process_keyboard_input():
global camera_x, camera_y, zoom
pan_speed = 5
# Handle continuous-press keys
if "ArrowLeft" in keys_pressed or "KeyA" in keys_pressed:
camera_x += pan_speed
if "ArrowRight" in keys_pressed or "KeyD" in keys_pressed:
camera_x -= pan_speed
if "ArrowUp" in keys_pressed or "KeyW" in keys_pressed:
camera_y += pan_speed
if "ArrowDown" in keys_pressed or "KeyS" in keys_pressed:
camera_y -= pan_speed
if "Equal" in keys_pressed or "NumpadAdd" in keys_pressed: # + key
old_zoom = zoom
zoom = min(5.0, zoom * 1.02)
adjust_camera_for_zoom(old_zoom, zoom)
if "Minus" in keys_pressed or "NumpadSubtract" in keys_pressed: # - key
old_zoom = zoom
zoom = max(0.1, zoom / 1.02)
adjust_camera_for_zoom(old_zoom, zoom)
# Prepare the output area
container = document.getElementById("snowflakes-container")
info_div = document.getElementById("info")
# Get button elements
pause_btn = document.getElementById("pause-btn")
zoom_in_btn = document.getElementById("zoom-in-btn")
zoom_out_btn = document.getElementById("zoom-out-btn")
# Set a fixed width to the pause button to prevent layout shifts
pause_btn.style.minWidth = "80px"
# Define callback functions for buttons
def toggle_pause(event):
global paused
print("Toggle pause button clicked")
paused = not paused
# Set a fixed width to the button to prevent layout shifts when text changes
pause_btn.style.minWidth = "80px"
pause_btn.textContent = "Resume" if paused else "Pause"
# Remove focus from the button to prevent repeat spacebar interactions
pause_btn.blur()
def zoom_in(event):
global zoom
print("Zoom in button clicked")
old_zoom = zoom
zoom = min(5.0, zoom * 1.1)
# Adjust camera to zoom toward center
adjust_camera_for_zoom(old_zoom, zoom)
# Remove focus from the button to prevent spacebar interactions
zoom_in_btn.blur()
def zoom_out(event):
global zoom
print("Zoom out button clicked")
old_zoom = zoom
zoom = max(0.1, zoom / 1.1)
# Adjust camera to zoom toward center
adjust_camera_for_zoom(old_zoom, zoom)
# Remove focus from the button to prevent spacebar interactions
zoom_out_btn.blur()
# Attach event listeners to buttons using onclick property
try:
print("Setting up button event handlers")
pause_btn.onclick = toggle_pause
zoom_in_btn.onclick = zoom_in
zoom_out_btn.onclick = zoom_out
print("Button event handlers attached")
except Exception as e:
print(f"Error setting up button events: {e}")
# Setup mouse events for panning
dragging = False
last_mouse_pos = None
def mouse_down(event):
global dragging, last_mouse_pos
# Prevent default browser behavior of dragging
try:
event.preventDefault()
except:
pass
dragging = True
last_mouse_pos = (event.clientX, event.clientY)
print("Mouse down:", last_mouse_pos)
def mouse_up(event):
global dragging
print("Mouse up")
dragging = False
def mouse_move(event):
global last_mouse_pos, camera_x, camera_y
if dragging and last_mouse_pos:
dx = event.clientX - last_mouse_pos[0]
dy = event.clientY - last_mouse_pos[1]
print(f"Mouse drag: dx={dx}, dy={dy}")
camera_x += dx
camera_y += dy
last_mouse_pos = (event.clientX, event.clientY)
# Make sure we have the container element before adding listeners
try:
print("Setting up mouse event listeners on container:", container)
# Add event listeners using on* properties instead of addEventListener
container.onmousedown = mouse_down
document.onmouseup = mouse_up
document.onmousemove = mouse_move
print("Mouse event listeners attached")
except Exception as e:
print(f"Error setting up mouse events: {e}")
# Function to adjust camera for zoom changes
def adjust_camera_for_zoom(old_zoom, new_zoom):
global camera_x, camera_y
# Get the center of the screen
center_x = screen_width / 2
center_y = screen_height / 2
# Calculate how the center point's position changes with zoom
camera_x = camera_x * (new_zoom / old_zoom)
camera_y = camera_y * (new_zoom / old_zoom)
# Try to import pygame
try:
import pygame
# Initialize Pygame
pygame.init()
# Setup the display - making it larger
screen_width, screen_height = 1600, 900
surface = pygame.Surface((screen_width, screen_height))
# Colors
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
GRAY = (150, 150, 150)
# Constants
MIN_SCALE = 0.05
MIN_DIMENSION = 2
EDGE_TOP = 0
EDGE_RIGHT = 1
EDGE_BOTTOM = 2
EDGE_LEFT = 3
# Stochastic L-system generation
def generate_l_system(axiom, rules, iterations):
current = axiom
for _ in range(iterations):
next_str = ""
for char in current:
if char in rules:
next_str += random.choice(rules[char])
else:
next_str += char
current = next_str
return current
# Drawing function with tapering
def draw_l_system(surface, string, pos, angle, length, depth=0, scale_factor=0.7):
x, y = pos
stack = []
for char in string:
if char == "F":
current_length = length * (scale_factor ** depth)
new_x = x + current_length * math.cos(math.radians(angle))
new_y = y + current_length * math.sin(math.radians(angle))
pygame.draw.line(surface, WHITE, (x, y), (new_x, new_y))
x, y = new_x, new_y
elif char == "+":
angle += 60
elif char == "-":
angle -= 60
elif char == "[":
stack.append((x, y, angle, depth))
depth += 1
elif char == "]":
x, y, angle, depth = stack.pop()
# Create a snowflake surface
def create_snowflake_surface(size, iterations):
surface = pygame.Surface((size, size), pygame.SRCALPHA)
# Rules for snowflake
rules = {
"F": ["F[+F]F[-F]F", "F[+F]F", "F[-F]F", "F[+F][-F]"]
}
# Generate l-system string
string = generate_l_system("F", rules, iterations)
# Draw 6 branches
for i in range(6):
draw_l_system(surface, string, (size/2, size/2), i * 60, size/10, depth=0, scale_factor=0.7)
return surface
class Snowflake:
def __init__(self, original_surface, x, y, dx, dy, rotation_speed, scale):
self.original_surface = original_surface
self.x = x
self.y = y
self.dx = dx
self.dy = dy
self.rotation = 0
self.rotation_speed = rotation_speed
self.scale = scale
self.cached_surface = None
self.cached_rect = None
self._cache_key = None
def update(self, screen_width, screen_height, zoom, camera_x, camera_y):
# Update position
self.x += self.dx
self.y += self.dy
self.rotation += self.rotation_speed
# Calculate visible world area based on zoom level and camera position
visible_width = screen_width / zoom
visible_height = screen_height / zoom
center_x = screen_width / 2
center_y = screen_height / 2
# Visible area in world coordinates
world_left = (0 - center_x) / zoom - camera_x / zoom + center_x
world_top = (0 - center_y) / zoom - camera_y / zoom + center_y
world_right = world_left + visible_width
world_bottom = world_top + visible_height
# Buffer to avoid sudden appearances/disappearances
buffer = 100 / zoom # Scale buffer with zoom
# Reset if off-screen
if (self.y > world_bottom + buffer or self.y < world_top - buffer or
self.x > world_right + buffer or self.x < world_left - buffer):
# Only respawn with a probability inversely proportional to zoom
respawn_chance = min(1.0, 1.0 / zoom)
if random.random() > respawn_chance:
# Don't respawn this snowflake at higher zoom levels
self.x = world_left - buffer * 10
self.y = world_top - buffer * 10
return
# Choose a random edge to spawn from
edge = random.randint(0, 3)
if edge == EDGE_TOP:
self.y = world_top - buffer
self.x = random.uniform(world_left - buffer, world_right + buffer)
elif edge == EDGE_RIGHT:
self.x = world_right + buffer
self.y = random.uniform(world_top - buffer, world_bottom + buffer)
elif edge == EDGE_BOTTOM:
self.y = world_bottom + buffer
self.x = random.uniform(world_left - buffer, world_right + buffer)
else: # EDGE_LEFT
self.x = world_left - buffer
self.y = random.uniform(world_top - buffer, world_bottom + buffer)
def draw(self, surface, camera_x, camera_y, zoom, screen_width, screen_height):
# Calculate screen position with camera and zoom
center_x = screen_width / 2
center_y = screen_height / 2
# Position relative to center, scaled by zoom, with camera offset
screen_x = center_x + (self.x - center_x) * zoom + camera_x
screen_y = center_y + (self.y - center_y) * zoom + camera_y
# Skip if off-screen
margin = 100
if (screen_x < -margin or screen_x > screen_width + margin or
screen_y < -margin or screen_y > screen_height + margin):
return
# Calculate scale (affected by both snowflake's own scale and zoom level)
total_scale = self.scale * zoom
# Skip if too small
if total_scale <= MIN_SCALE:
return
# Cache key combines scale and rotation to know when to regenerate
cache_key = (total_scale, self.rotation)
# Use cached surface if available and parameters haven't changed
if not self.cached_surface or self._cache_key != cache_key:
width = max(1, int(self.original_surface.get_width() * total_scale))
height = max(1, int(self.original_surface.get_height() * total_scale))
# Skip if dimensions are too small
if width < MIN_DIMENSION or height < MIN_DIMENSION:
return
try:
# Scale original surface
scaled_surface = pygame.transform.scale(self.original_surface, (width, height))
# Rotate surface
self.cached_surface = pygame.transform.rotate(scaled_surface, self.rotation)
self._cache_key = cache_key
except (pygame.error, ValueError, OverflowError):
return
# Position the surface
rect = self.cached_surface.get_rect(center=(screen_x, screen_y))
# Draw to screen
try:
surface.blit(self.cached_surface, rect)
except (pygame.error, ValueError, OverflowError):
self.cached_surface = None # Clear cache if error occurs
# Pre-generate snowflake designs
snowflake_surfaces = [create_snowflake_surface(50, 2) for _ in range(25)]
# Create snowflakes
snowflakes = []
num_snowflakes = 150 # Reduced for better web performance
def create_snowflake(x_range=None, y_range=None):
surface = random.choice(snowflake_surfaces)
x = random.randint(-screen_width, screen_width * 2) if x_range is None else random.uniform(*x_range)
y = random.randint(-screen_height, screen_height * 2) if y_range is None else random.uniform(*y_range)
dx = random.uniform(-0.5, 0.5) # Wind effect
dy = random.uniform(1, 3) # Falling speed
rotation_speed = random.uniform(-1, 1)
scale = random.uniform(0.5, 1.5) # Size variation
return Snowflake(surface, x, y, dx, dy, rotation_speed, scale)
# Create initial snowflakes
for _ in range(num_snowflakes):
x = random.uniform(-screen_width, screen_width * 2)
y = random.uniform(-screen_height, screen_height * 2)
snowflakes.append(create_snowflake([x, x], [y, y]))
# Initialize variables
paused = False
zoom = 1.0
camera_x, camera_y = 0, 0
clock = pygame.time.Clock()
# Try to use default font instead of system font
try:
font = pygame.font.Font(None, 24) # Default font
print("Using default font")
except Exception as e:
print(f"Font error: {e}")
# Create a very basic font alternative
font = None
# Function to display the current frame
def update_display():
try:
# Save surface to a BytesIO object as PNG
import io
buffer = io.BytesIO()
pygame.image.save(surface, buffer, "PNG")
buffer.seek(0)
# Convert buffer to base64
encoded = base64.b64encode(buffer.read()).decode('utf-8')
# Create an <img> element with the data URL, set to 100% width to fill container
img_html = f'<img width="100%" src="data:image/png;base64,{encoded}" alt="Snowflakes" style="border:1px solid #333;" />'
container.innerHTML = img_html
# Update info text
info_div.textContent = f"Zoom: {zoom:.1f}x | FPS: {int(clock.get_fps())}"
except Exception as e:
print(f"Display update error: {e}")
container.innerHTML += f"<div style='color:red'>Error in update_display: {str(e)}</div>"
# Handle mouse wheel events for zooming
def on_wheel(event):
global zoom, camera_x, camera_y
try:
# Try to prevent default scroll behavior
try:
event.preventDefault()
except:
pass # If not supported, continue anyway
# Get wheel delta (can be different across browsers)
delta = 0
if hasattr(event, 'wheelDelta'):
delta = event.wheelDelta # Older browsers
elif hasattr(event, 'deltaY'):
delta = -event.deltaY # Negative because wheel down should zoom out
if delta == 0:
return # No scroll detected
# Get mouse position
mouse_x, mouse_y = 0, 0
try:
# Try to get mouse position relative to container
rect = container.getBoundingClientRect()
mouse_x = event.clientX - rect.left
mouse_y = event.clientY - rect.top
except:
# Fallback to center of container
mouse_x = screen_width / 2
mouse_y = screen_height / 2
# Store old zoom for camera adjustment
old_zoom = zoom
# Adjust zoom level
if delta > 0:
zoom = min(5.0, zoom * 1.1) # Zoom in
else:
zoom = max(0.1, zoom / 1.1) # Zoom out
# Calculate the world position under the mouse before zooming
world_x = (mouse_x - screen_width / 2) / old_zoom - camera_x / old_zoom
world_y = (mouse_y - screen_height / 2) / old_zoom - camera_y / old_zoom
# Calculate what the screen position would be after the zoom
new_screen_x = world_x * zoom + camera_x
new_screen_y = world_y * zoom + camera_y
# Adjust the camera to keep the world position under the mouse
camera_x -= new_screen_x - (mouse_x - screen_width / 2)
camera_y -= new_screen_y - (mouse_y - screen_height / 2)
print(f"Zoomed to {zoom:.2f}x")
except Exception as e:
print(f"Wheel event error: {e}")
# Handle modal functionality
def show_modal(event):
modal = document.getElementById("modal-overlay")
modal.style.display = "flex"
print("Modal opened")
# Remove focus from the button
info_button.blur()
def hide_modal(event):
modal = document.getElementById("modal-overlay")
modal.style.display = "none"
print("Modal closed")
# Add event listeners for modal buttons
try:
info_button = document.getElementById("info-button")
info_button.onclick = show_modal
close_button = document.getElementById("modal-close")
close_button.onclick = hide_modal
# Also close by clicking overlay
modal_overlay = document.getElementById("modal-overlay")
def overlay_click(event):
if event.target == modal_overlay:
hide_modal(event)
modal_overlay.onclick = overlay_click
print("Modal handlers attached")
except Exception as e:
print(f"Error setting up modal handlers: {e}")
# Try to add wheel event listener using different methods
try:
# First try modern event listener
document.onwheel = on_wheel
print("Mouse wheel handler attached (onwheel)")
except Exception as e:
print(f"Error setting up wheel handler: {e}")
try:
# Fallback to mousewheel event for older browsers
document.onmousewheel = on_wheel
print("Mouse wheel handler attached (onmousewheel)")
except Exception as e2:
print(f"Error setting up mousewheel handler: {e2}")
# Main animation loop
async def animation_loop():
global paused, zoom, camera_x, camera_y
try:
print("Animation loop starting")
frame_count = 0
while True:
try:
# Process keyboard input
process_keyboard_input()
# Clear the surface
surface.fill(BLACK)
# Update snowflakes
if not paused:
for snowflake in snowflakes:
snowflake.update(screen_width, screen_height, zoom, camera_x, camera_y)
# Draw snowflakes
for snowflake in snowflakes:
snowflake.draw(surface, camera_x, camera_y, zoom, screen_width, screen_height)
# Update display
update_display()
frame_count += 1
if frame_count % 100 == 0:
print(f"Rendered {frame_count} frames")
# Cap at 30 FPS for better web performance
clock.tick(30)
# Yield to browser to keep it responsive
await asyncio.sleep(0.01)
except Exception as e:
print(f"Error in animation frame: {e}")
container.innerHTML = f"<div style='color:red; padding:10px;'>Error in animation frame: {str(e)}</div>"
await asyncio.sleep(1) # Wait a bit before continuing
except Exception as e:
print(f"Animation loop error: {e}")
container.innerHTML = f"<div style='color:red; padding:10px;'>Animation loop error: {str(e)}</div>"
# Start the animation
asyncio.ensure_future(animation_loop())
except Exception as e:
# Display any errors
container.innerHTML = f"<div style='color:red; background:#300; padding:20px; border-radius:5px;'>Error: {str(e)}</div>"
print(f"Error: {str(e)}")
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment