Created
March 4, 2025 17:33
-
-
Save Jeremiah-England/d7fe559c0e3f834a410bab8dd4b01e68 to your computer and use it in GitHub Desktop.
Snowflake Generator - Canvas
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
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Snowflake Generator - JavaScript</title> | |
<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; | |
} | |
#game-canvas { | |
width: 100%; | |
flex: 1; | |
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> | |
<canvas id="game-canvas" width="1600" height="900"></canvas> | |
<!-- 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 This Application</h3> | |
<p>This Snowflake Generator uses JavaScript and HTML5 Canvas to create procedurally generated snowflakes using L-systems. It allows interactive panning and zooming, with realistic physics for the snowflakes.</p> | |
</section> | |
<section> | |
<h3>Credits</h3> | |
<p>Converted to JavaScript based on the original PyGame implementation.</p> | |
</section> | |
<button id="modal-close">Close</button> | |
</div> | |
</div> | |
<script> | |
// Get DOM elements | |
const canvas = document.getElementById("game-canvas"); | |
const ctx = canvas.getContext("2d"); | |
const infoDiv = document.getElementById("info"); | |
const pauseBtn = document.getElementById("pause-btn"); | |
const zoomInBtn = document.getElementById("zoom-in-btn"); | |
const zoomOutBtn = document.getElementById("zoom-out-btn"); | |
const infoButton = document.getElementById("info-button"); | |
const modalOverlay = document.getElementById("modal-overlay"); | |
const modalClose = document.getElementById("modal-close"); | |
// Constants | |
const SCREEN_WIDTH = 1600; | |
const SCREEN_HEIGHT = 900; | |
const NUM_SNOWFLAKES = 150; | |
const NUM_DESIGNS = 25; | |
const MIN_SCALE = 0.05; | |
// Variables | |
let paused = false; | |
let zoom = 1.0; | |
let cameraX = 0; | |
let cameraY = 0; | |
let dragging = false; | |
let lastMousePos = null; | |
let keysPressed = new Set(); | |
let snowflakeDesigns = []; | |
let snowflakes = []; | |
// L-system rules | |
const axiom = "F"; | |
const rules = { | |
"F": ["F[+F]F[-F]F", "F[+F]F", "F[-F]F", "F[+F][-F]"] | |
}; | |
// Generate L-system string | |
function generateLSystem(iterations) { | |
let current = axiom; | |
for (let i = 0; i < iterations; i++) { | |
let next = ""; | |
for (const char of current) { | |
if (char in rules) { | |
next += rules[char][Math.floor(Math.random() * rules[char].length)]; | |
} else { | |
next += char; | |
} | |
} | |
current = next; | |
} | |
return current; | |
} | |
// Draw L-system on canvas | |
function drawLSystem(ctx, string, length, angleStep, scaleFactor) { | |
let angle = 0; | |
let x = 0; | |
let y = 0; | |
let depth = 0; | |
const stack = []; | |
ctx.beginPath(); | |
ctx.moveTo(x, y); | |
for (const char of string) { | |
if (char === "F") { | |
const currentLength = length * (scaleFactor ** depth); | |
const newX = x + currentLength * Math.cos(angle * Math.PI / 180); | |
const newY = y + currentLength * Math.sin(angle * Math.PI / 180); | |
ctx.lineTo(newX, newY); | |
x = newX; | |
y = newY; | |
} else if (char === "+") { | |
angle += angleStep; | |
} else if (char === "-") { | |
angle -= angleStep; | |
} else if (char === "[") { | |
stack.push({x, y, angle, depth}); | |
depth += 1; | |
} else if (char === "]") { | |
({x, y, angle, depth} = stack.pop()); | |
ctx.moveTo(x, y); | |
} | |
} | |
ctx.stroke(); | |
} | |
// Snowflake class | |
class Snowflake { | |
constructor(designIndex, x, y, dx, dy, rotationSpeed, scale) { | |
this.designIndex = designIndex; | |
this.x = x; | |
this.y = y; | |
this.dx = dx; | |
this.dy = dy; | |
this.rotation = 0; | |
this.rotationSpeed = rotationSpeed; | |
this.scale = scale; | |
} | |
update() { | |
if (paused) return; | |
this.x += this.dx; | |
this.y += this.dy; | |
this.rotation += this.rotationSpeed; | |
// Calculate visible area | |
const visibleWidth = SCREEN_WIDTH / zoom; | |
const visibleHeight = SCREEN_HEIGHT / zoom; | |
const worldLeft = cameraX - visibleWidth / 2; | |
const worldTop = cameraY - visibleHeight / 2; | |
const worldRight = cameraX + visibleWidth / 2; | |
const worldBottom = cameraY + visibleHeight / 2; | |
const buffer = 100 / zoom; | |
// Respawn if off-screen | |
if (this.y > worldBottom + buffer || this.y < worldTop - buffer || | |
this.x > worldRight + buffer || this.x < worldLeft - buffer) { | |
const edge = Math.floor(Math.random() * 4); | |
if (edge === 0) { // Top | |
this.y = worldTop - buffer; | |
this.x = Math.random() * (worldRight - worldLeft) + worldLeft; | |
} else if (edge === 1) { // Right | |
this.x = worldRight + buffer; | |
this.y = Math.random() * (worldBottom - worldTop) + worldTop; | |
} else if (edge === 2) { // Bottom | |
this.y = worldBottom + buffer; | |
this.x = Math.random() * (worldRight - worldLeft) + worldLeft; | |
} else { // Left | |
this.x = worldLeft - buffer; | |
this.y = Math.random() * (worldBottom - worldTop) + worldTop; | |
} | |
} | |
} | |
draw(ctx) { | |
const design = snowflakeDesigns[this.designIndex]; | |
const totalScale = this.scale * zoom; | |
if (totalScale <= MIN_SCALE) return; | |
// Calculate screen position | |
const screenX = (this.x - cameraX) * zoom + SCREEN_WIDTH / 2; | |
const screenY = (this.y - cameraY) * zoom + SCREEN_HEIGHT / 2; | |
if (screenX < -100 || screenX > SCREEN_WIDTH + 100 || | |
screenY < -100 || screenY > SCREEN_HEIGHT + 100) return; | |
ctx.save(); | |
ctx.translate(screenX, screenY); | |
ctx.rotate(this.rotation * Math.PI / 180); | |
ctx.scale(totalScale, totalScale); | |
ctx.strokeStyle = "white"; | |
for (let i = 0; i < 6; i++) { | |
ctx.save(); | |
ctx.rotate(i * 60 * Math.PI / 180); | |
drawLSystem(ctx, design, 10, 60, 0.7); | |
ctx.restore(); | |
} | |
ctx.restore(); | |
} | |
} | |
// Generate snowflake designs | |
function generateSnowflakeDesigns() { | |
for (let i = 0; i < NUM_DESIGNS; i++) { | |
snowflakeDesigns.push(generateLSystem(2)); | |
} | |
} | |
// Create snowflakes | |
function createSnowflakes() { | |
for (let i = 0; i < NUM_SNOWFLAKES; i++) { | |
const designIndex = Math.floor(Math.random() * NUM_DESIGNS); | |
const x = Math.random() * SCREEN_WIDTH * 2 - SCREEN_WIDTH; | |
const y = Math.random() * SCREEN_HEIGHT * 2 - SCREEN_HEIGHT; | |
const dx = Math.random() * 1 - 0.5; | |
const dy = Math.random() * 2 + 1; | |
const rotationSpeed = Math.random() * 2 - 1; | |
const scale = Math.random() * 1 + 0.5; | |
snowflakes.push(new Snowflake(designIndex, x, y, dx, dy, rotationSpeed, scale)); | |
} | |
} | |
// Animation loop | |
let lastTime = performance.now(); | |
let frameCount = 0; | |
let fps = 0; | |
function animate(currentTime) { | |
// Calculate FPS | |
frameCount++; | |
const deltaTime = currentTime - lastTime; | |
if (deltaTime >= 1000) { | |
fps = frameCount / (deltaTime / 1000); | |
frameCount = 0; | |
lastTime = currentTime; | |
} | |
// Update snowflakes | |
snowflakes.forEach(snowflake => snowflake.update()); | |
// Handle keyboard panning | |
const panSpeed = 5 / zoom; | |
if (keysPressed.has("ArrowLeft") || keysPressed.has("KeyA")) cameraX -= panSpeed; | |
if (keysPressed.has("ArrowRight") || keysPressed.has("KeyD")) cameraX += panSpeed; | |
if (keysPressed.has("ArrowUp") || keysPressed.has("KeyW")) cameraY -= panSpeed; | |
if (keysPressed.has("ArrowDown") || keysPressed.has("KeyS")) cameraY += panSpeed; | |
if (keysPressed.has("Equal") || keysPressed.has("NumpadAdd")) zoom = Math.min(5.0, zoom * 1.1); | |
if (keysPressed.has("Minus") || keysPressed.has("NumpadSubtract")) zoom = Math.max(0.1, zoom / 1.1); | |
// Clear canvas | |
ctx.fillStyle = "black"; | |
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); | |
// Draw snowflakes | |
snowflakes.forEach(snowflake => snowflake.draw(ctx)); | |
// Update info | |
infoDiv.textContent = `Zoom: ${zoom.toFixed(1)}x | FPS: ${Math.round(fps)}`; | |
requestAnimationFrame(animate); | |
} | |
// Event listeners | |
pauseBtn.addEventListener("click", () => { | |
paused = !paused; | |
pauseBtn.textContent = paused ? "Resume" : "Pause"; | |
}); | |
zoomInBtn.addEventListener("click", () => { | |
zoom = Math.min(5.0, zoom * 1.1); | |
}); | |
zoomOutBtn.addEventListener("click", () => { | |
zoom = Math.max(0.1, zoom / 1.1); | |
}); | |
infoButton.addEventListener("click", () => { | |
modalOverlay.style.display = "flex"; | |
}); | |
modalClose.addEventListener("click", () => { | |
modalOverlay.style.display = "none"; | |
}); | |
modalOverlay.addEventListener("click", (e) => { | |
if (e.target === modalOverlay) { | |
modalOverlay.style.display = "none"; | |
} | |
}); | |
// Mouse events for panning | |
canvas.addEventListener("mousedown", (e) => { | |
dragging = true; | |
lastMousePos = {x: e.clientX, y: e.clientY}; | |
}); | |
document.addEventListener("mouseup", () => { | |
dragging = false; | |
}); | |
document.addEventListener("mousemove", (e) => { | |
if (dragging) { | |
const dx = e.clientX - lastMousePos.x; | |
const dy = e.clientY - lastMousePos.y; | |
cameraX -= dx / zoom; | |
cameraY -= dy / zoom; | |
lastMousePos = {x: e.clientX, y: e.clientY}; | |
} | |
}); | |
// Mouse wheel for zooming | |
canvas.addEventListener("wheel", (e) => { | |
e.preventDefault(); | |
const delta = e.deltaY > 0 ? 0.9 : 1.1; | |
const mouseX = e.offsetX; | |
const mouseY = e.offsetY; | |
const worldXBefore = (mouseX - SCREEN_WIDTH / 2) / zoom + cameraX; | |
const worldYBefore = (mouseY - SCREEN_HEIGHT / 2) / zoom + cameraY; | |
zoom = Math.max(0.1, Math.min(5.0, zoom * delta)); | |
const worldXAfter = (mouseX - SCREEN_WIDTH / 2) / zoom + cameraX; | |
const worldYAfter = (mouseY - SCREEN_HEIGHT / 2) / zoom + cameraY; | |
cameraX += worldXBefore - worldXAfter; | |
cameraY += worldYBefore - worldYAfter; | |
}); | |
// Keyboard events | |
document.addEventListener("keydown", (e) => { | |
keysPressed.add(e.code); | |
if (e.code === "Space") { | |
e.preventDefault(); | |
paused = !paused; | |
pauseBtn.textContent = paused ? "Resume" : "Pause"; | |
} | |
}); | |
document.addEventListener("keyup", (e) => { | |
keysPressed.delete(e.code); | |
}); | |
// Initialize | |
generateSnowflakeDesigns(); | |
createSnowflakes(); | |
requestAnimationFrame(animate); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is a rewrite of https://gist.github.com/Jeremiah-England/7ee1a484fb99796f5c3ba84492e6a56b into plain javascript by Grok3 which is much faster and looks nicer.