Created
January 4, 2026 13:37
-
-
Save zxhfighter/bed91b9a2e673b77f63437364df0fc44 to your computer and use it in GitHub Desktop.
awesome fireworks in a html file
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>Interactive Fireworks Display</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| overflow: hidden; | |
| background: #000000; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| } | |
| #canvas-container { | |
| position: relative; | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| canvas { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| } | |
| #main-canvas { | |
| z-index: 1; | |
| } | |
| #glow-canvas { | |
| z-index: 2; | |
| mix-blend-mode: screen; | |
| pointer-events: none; | |
| } | |
| #controls { | |
| position: fixed; | |
| top: 20px; | |
| left: 20px; | |
| z-index: 100; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .control-group { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .btn { | |
| padding: 10px 16px; | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 8px; | |
| background: rgba(255, 255, 255, 0.1); | |
| color: white; | |
| font-size: 14px; | |
| cursor: pointer; | |
| backdrop-filter: blur(10px); | |
| transition: all 0.3s ease; | |
| } | |
| .btn:hover { | |
| background: rgba(255, 255, 255, 0.2); | |
| } | |
| .btn.active { | |
| background: rgba(255, 200, 100, 0.3); | |
| border-color: rgba(255, 200, 100, 0.8); | |
| } | |
| .btn.paused { | |
| background: rgba(255, 50, 50, 0.4); | |
| border-color: rgba(255, 50, 50, 0.8); | |
| } | |
| #top-controls { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| z-index: 100; | |
| display: flex; | |
| gap: 10px; | |
| } | |
| #sound-btn { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| } | |
| #pause-btn { | |
| position: fixed; | |
| top: 20px; | |
| right: 80px; | |
| } | |
| #top-controls .btn { | |
| padding: 12px 20px; | |
| font-size: 16px; | |
| } | |
| @media (max-width: 768px) { | |
| #controls { | |
| top: 70px; | |
| } | |
| .btn { | |
| padding: 8px 12px; | |
| font-size: 12px; | |
| } | |
| #sound-btn { | |
| right: 10px; | |
| } | |
| #pause-btn { | |
| right: 60px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="canvas-container"> | |
| <canvas id="main-canvas"></canvas> | |
| <canvas id="glow-canvas"></canvas> | |
| </div> | |
| <button id="sound-btn" class="btn">🔊</button> | |
| <button id="pause-btn" class="btn">⏸️</button> | |
| <div id="controls"> | |
| <div class="control-group"> | |
| <button class="btn mode-btn active" data-mode="random">Random Shapes</button> | |
| <button class="btn mode-btn" data-mode="rapid">Rapid Fire</button> | |
| <button class="btn mode-btn" data-mode="finale">Grand Finale</button> | |
| <button class="btn mode-btn" data-mode="auto">Auto Show</button> | |
| </div> | |
| <div class="control-group"> | |
| <button class="btn shape-btn" data-shape="heart">Heart</button> | |
| <button class="btn shape-btn" data-shape="star">Star</button> | |
| <button class="btn shape-btn" data-shape="ring">Ring</button> | |
| <button class="btn shape-btn" data-shape="spiral">Spiral</button> | |
| <button class="btn shape-btn" data-shape="flower">Flower</button> | |
| <button class="btn shape-btn" data-shape="smiley">Smiley</button> | |
| <button class="btn shape-btn" data-shape="frog">Frog</button> | |
| </div> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script> | |
| <script> | |
| // Configuration | |
| const CONFIG = { | |
| GRAVITY: 0.03, | |
| FRICTION: 0.99, | |
| CHARACTER_SIZE: 90, | |
| CHARACTER_DURATION: 120, | |
| PARTICLES_PER_EXPLOSION: 500, | |
| CHARACTER_PARTICLES: 150, | |
| COLORS: [ | |
| '#ff1744', '#ff4081', '#e040fb', '#7c4dff', | |
| '#536dfe', '#448aff', '#00b0ff', '#00e5ff', | |
| '#1de9b6', '#00e676', '#76ff03', '#c6ff00', | |
| '#ffea00', '#ffc400', '#ff9100', '#ff3d00' | |
| ] | |
| }; | |
| // State | |
| let canvas, ctx, glowCanvas, glowCtx; | |
| let width, height; | |
| let particles = []; | |
| let rockets = []; | |
| let stars = []; | |
| let isPaused = false; | |
| let soundEnabled = true; | |
| let currentMode = 'random'; | |
| let lockedShape = null; | |
| let autoShowInterval = null; | |
| let audioContext = null; | |
| let isMouseDown = false; | |
| let lastLaunchTime = 0; | |
| // Shape functions - return [[x1, y1], [x2, y2], ...] centered at (0,0) | |
| function getHeartPoints(numPoints) { | |
| const points = []; | |
| for (let i = 0; i < numPoints; i++) { | |
| const t = (i / numPoints) * Math.PI * 2; | |
| const x = 16 * Math.pow(Math.sin(t), 3); | |
| const y = -(13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t)); | |
| points.push([x * 2.5, y * 2.5]); | |
| } | |
| return points; | |
| } | |
| function getStarPoints(numPoints) { | |
| const points = []; | |
| const outerRadius = 50; | |
| const innerRadius = 20; | |
| const numVertices = 10; // 5 outer + 5 inner vertices | |
| // Generate the 10 vertices of a 5-pointed star | |
| const vertices = []; | |
| for (let i = 0; i < numVertices; i++) { | |
| const angle = (i / numVertices) * Math.PI * 2 - Math.PI / 2; // Rotate so star points up | |
| const radius = (i % 2 === 0) ? outerRadius : innerRadius; | |
| vertices.push([ | |
| Math.cos(angle) * radius, | |
| Math.sin(angle) * radius | |
| ]); | |
| } | |
| // Interpolate between vertices to get numPoints | |
| const pointsPerEdge = Math.floor(numPoints / numVertices); | |
| for (let i = 0; i < numVertices; i++) { | |
| const currentVertex = vertices[i]; | |
| const nextVertex = vertices[(i + 1) % numVertices]; | |
| for (let j = 0; j < pointsPerEdge; j++) { | |
| const t = j / pointsPerEdge; | |
| const x = currentVertex[0] + (nextVertex[0] - currentVertex[0]) * t; | |
| const y = currentVertex[1] + (nextVertex[1] - currentVertex[1]) * t; | |
| points.push([x, y]); | |
| } | |
| } | |
| return points; | |
| } | |
| function getRingPoints(numPoints) { | |
| const points = []; | |
| const radius = 50; | |
| for (let i = 0; i < numPoints; i++) { | |
| const angle = (i / numPoints) * Math.PI * 2; | |
| points.push([ | |
| Math.cos(angle) * radius, | |
| Math.sin(angle) * radius | |
| ]); | |
| } | |
| return points; | |
| } | |
| function getSpiralPoints(numPoints) { | |
| const points = []; | |
| for (let i = 0; i < numPoints; i++) { | |
| const angle = (i / numPoints) * Math.PI * 6; | |
| const radius = (i / numPoints) * 50; | |
| points.push([ | |
| Math.cos(angle) * radius, | |
| Math.sin(angle) * radius | |
| ]); | |
| } | |
| return points; | |
| } | |
| function getDoubleRingPoints(numPoints) { | |
| const points = []; | |
| const halfPoints = Math.floor(numPoints / 2); | |
| // Outer ring | |
| for (let i = 0; i < halfPoints; i++) { | |
| const angle = (i / halfPoints) * Math.PI * 2; | |
| points.push([ | |
| Math.cos(angle) * 50, | |
| Math.sin(angle) * 50 | |
| ]); | |
| } | |
| // Inner ring | |
| for (let i = 0; i < halfPoints; i++) { | |
| const angle = (i / halfPoints) * Math.PI * 2; | |
| points.push([ | |
| Math.cos(angle) * 25, | |
| Math.sin(angle) * 25 | |
| ]); | |
| } | |
| return points; | |
| } | |
| function getDiamondPoints(numPoints) { | |
| const points = []; | |
| const pointsPerSide = numPoints / 4; | |
| // Top to right | |
| for (let i = 0; i < pointsPerSide; i++) { | |
| const t = i / pointsPerSide; | |
| points.push([t * 50, -(1 - t) * 50]); | |
| } | |
| // Right to bottom | |
| for (let i = 0; i < pointsPerSide; i++) { | |
| const t = i / pointsPerSide; | |
| points.push([(1 - t) * 50, t * 50]); | |
| } | |
| // Bottom to left | |
| for (let i = 0; i < pointsPerSide; i++) { | |
| const t = i / pointsPerSide; | |
| points.push([-t * 50, (1 - t) * 50]); | |
| } | |
| // Left to top | |
| for (let i = 0; i < pointsPerSide; i++) { | |
| const t = i / pointsPerSide; | |
| points.push([-(1 - t) * 50, -t * 50]); | |
| } | |
| return points; | |
| } | |
| function getFlowerPoints(numPoints) { | |
| const points = []; | |
| for (let i = 0; i < numPoints; i++) { | |
| const angle = (i / numPoints) * Math.PI * 2; | |
| const r = 30 + 20 * Math.cos(6 * angle); | |
| points.push([ | |
| Math.cos(angle) * r, | |
| Math.sin(angle) * r | |
| ]); | |
| } | |
| return points; | |
| } | |
| function getSmileyPoints(numPoints) { | |
| const points = []; | |
| // Face circle (50% of points) | |
| const facePoints = Math.floor(numPoints * 0.45); | |
| const faceRadius = 50; | |
| for (let i = 0; i < facePoints; i++) { | |
| const angle = (i / facePoints) * Math.PI * 2; | |
| points.push([ | |
| Math.cos(angle) * faceRadius, | |
| -Math.sin(angle) * faceRadius | |
| ]); | |
| } | |
| // Left eye (15% of points) | |
| const eyePoints = Math.floor(numPoints * 0.15); | |
| const eyeRadius = 8; | |
| const leftEyeX = -18; | |
| const leftEyeY = -10; | |
| for (let i = 0; i < eyePoints; i++) { | |
| const angle = (i / eyePoints) * Math.PI * 2; | |
| points.push([ | |
| leftEyeX + Math.cos(angle) * eyeRadius, | |
| leftEyeY - Math.sin(angle) * eyeRadius | |
| ]); | |
| } | |
| // Right eye (15% of points) | |
| const rightEyeX = 18; | |
| const rightEyeY = -10; | |
| for (let i = 0; i < eyePoints; i++) { | |
| const angle = (i / eyePoints) * Math.PI * 2; | |
| points.push([ | |
| rightEyeX + Math.cos(angle) * eyeRadius, | |
| rightEyeY - Math.sin(angle) * eyeRadius | |
| ]); | |
| } | |
| // Smile mouth (25% of points) - arc from 0 to PI | |
| const mouthPoints = numPoints - facePoints - eyePoints * 2; | |
| const mouthRadius = 25; | |
| const mouthY = 10; | |
| for (let i = 0; i < mouthPoints; i++) { | |
| const angle = (i / (mouthPoints - 1)) * Math.PI; | |
| points.push([ | |
| Math.cos(angle) * mouthRadius, | |
| mouthY + Math.sin(angle) * mouthRadius | |
| ]); | |
| } | |
| return points; | |
| } | |
| function getFrogPoints(numPoints) { | |
| const points = []; | |
| // Head ellipse (40% of points) | |
| const headPoints = Math.floor(numPoints * 0.40); | |
| const headRadiusX = 50; | |
| const headRadiusY = 40; | |
| for (let i = 0; i < headPoints; i++) { | |
| const angle = (i / headPoints) * Math.PI * 2; | |
| points.push([ | |
| Math.cos(angle) * headRadiusX, | |
| -Math.sin(angle) * headRadiusY | |
| ]); | |
| } | |
| // Left eye (20% of points) - prominent and sticking out | |
| const leftEyePoints = Math.floor(numPoints * 0.20); | |
| const leftEyeRadius = 18; | |
| const leftEyeX = -28; | |
| const leftEyeY = -45; | |
| for (let i = 0; i < leftEyePoints; i++) { | |
| const angle = (i / leftEyePoints) * Math.PI * 2; | |
| points.push([ | |
| leftEyeX + Math.cos(angle) * leftEyeRadius, | |
| leftEyeY - Math.sin(angle) * leftEyeRadius | |
| ]); | |
| } | |
| // Right eye (20% of points) - prominent and sticking out | |
| const rightEyePoints = Math.floor(numPoints * 0.20); | |
| const rightEyeRadius = 18; | |
| const rightEyeX = 28; | |
| const rightEyeY = -45; | |
| for (let i = 0; i < rightEyePoints; i++) { | |
| const angle = (i / rightEyePoints) * Math.PI * 2; | |
| points.push([ | |
| rightEyeX + Math.cos(angle) * rightEyeRadius, | |
| rightEyeY - Math.sin(angle) * rightEyeRadius | |
| ]); | |
| } | |
| // Smile mouth (20% of points) - wide smile arc inside head | |
| const mouthPoints = numPoints - headPoints - leftEyePoints - rightEyePoints; | |
| const mouthRadius = 25; | |
| const mouthY = -10; | |
| for (let i = 0; i < mouthPoints; i++) { | |
| const angle = Math.PI * 0.15 + (i / (mouthPoints - 1)) * Math.PI * 0.7; | |
| points.push([ | |
| Math.cos(angle) * mouthRadius, | |
| mouthY + Math.sin(angle) * mouthRadius | |
| ]); | |
| } | |
| return points; | |
| } | |
| function getShapePoints(shapeType, numPoints) { | |
| switch (shapeType) { | |
| case 'heart': return getHeartPoints(numPoints); | |
| case 'star': return getStarPoints(numPoints); | |
| case 'ring': return getRingPoints(numPoints); | |
| case 'spiral': return getSpiralPoints(numPoints); | |
| case 'doubleRing': return getDoubleRingPoints(numPoints); | |
| case 'diamond': return getDiamondPoints(numPoints); | |
| case 'flower': return getFlowerPoints(numPoints); | |
| case 'smiley': return getSmileyPoints(numPoints); | |
| case 'frog': return getFrogPoints(numPoints); | |
| default: return null; | |
| } | |
| } | |
| // Audio functions | |
| function initAudio() { | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| } | |
| function playLaunchSound() { | |
| if (!soundEnabled || !audioContext) return; | |
| const oscillator = audioContext.createOscillator(); | |
| const gainNode = audioContext.createGain(); | |
| oscillator.connect(gainNode); | |
| gainNode.connect(audioContext.destination); | |
| oscillator.type = 'sine'; | |
| oscillator.frequency.setValueAtTime(150, audioContext.currentTime); | |
| oscillator.frequency.exponentialRampToValueAtTime(300, audioContext.currentTime + 0.5); | |
| gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5); | |
| oscillator.start(audioContext.currentTime); | |
| oscillator.stop(audioContext.currentTime + 0.5); | |
| } | |
| function playExplosionSound() { | |
| if (!soundEnabled || !audioContext) return; | |
| const bufferSize = audioContext.sampleRate * 0.5; | |
| const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) { | |
| data[i] = (Math.random() * 2 - 1) * 0.5; | |
| } | |
| const source = audioContext.createBufferSource(); | |
| source.buffer = buffer; | |
| const filter = audioContext.createBiquadFilter(); | |
| filter.type = 'lowpass'; | |
| filter.frequency.setValueAtTime(1000, audioContext.currentTime); | |
| filter.frequency.exponentialRampToValueAtTime(100, audioContext.currentTime + 0.5); | |
| const gainNode = audioContext.createGain(); | |
| gainNode.gain.setValueAtTime(0.15, audioContext.currentTime); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5); | |
| source.connect(filter); | |
| filter.connect(gainNode); | |
| gainNode.connect(audioContext.destination); | |
| source.start(); | |
| source.stop(audioContext.currentTime + 0.5); | |
| } | |
| // Particle class | |
| class Particle { | |
| constructor(x, y, color, options = {}) { | |
| this.x = x; | |
| this.y = y; | |
| this.vx = options.vx || 0; | |
| this.vy = options.vy || 0; | |
| this.color = color; | |
| this.alpha = 1; | |
| this.size = 2.5; | |
| this.isShapeParticle = options.isShapeParticle || false; | |
| this.shapeFrame = 0; | |
| this.targetVx = options.targetVx || (Math.random() - 0.5) * 10; | |
| this.targetVy = options.targetVy || (Math.random() - 0.5) * 10; | |
| this.trail = []; | |
| this.maxTrail = 8; | |
| this.sparkle = options.sparkle || (Math.random() < 0.3); | |
| this.sparklePhase = Math.random() * Math.PI * 2; | |
| } | |
| update() { | |
| // Store trail position | |
| this.trail.push({ x: this.x, y: this.y, alpha: this.alpha }); | |
| if (this.trail.length > this.maxTrail) { | |
| this.trail.shift(); | |
| } | |
| if (this.isShapeParticle) { | |
| this.shapeFrame++; | |
| // Shimmer effect during formation | |
| if (this.shapeFrame < CONFIG.CHARACTER_DURATION) { | |
| this.x += (Math.random() - 0.5) * 0.2; | |
| this.y += (Math.random() - 0.5) * 0.2; | |
| // Gentle gravity during formation (0.1x normal) | |
| this.vy = CONFIG.GRAVITY * 0.1; | |
| this.y += this.vy; | |
| // After 70% of formation, start transitioning | |
| if (this.shapeFrame > CONFIG.CHARACTER_DURATION * 0.7) { | |
| const transitionProgress = (this.shapeFrame - CONFIG.CHARACTER_DURATION * 0.7) / (CONFIG.CHARACTER_DURATION * 0.3); | |
| this.vx = this.targetVx * transitionProgress * 0.3; | |
| this.vy = this.targetVy * transitionProgress * 0.3; | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| } | |
| this.alpha -= 0.004; | |
| } else { | |
| // Formation ended, switch to normal physics | |
| this.vx = this.targetVx; | |
| this.vy = this.targetVy; | |
| this.isShapeParticle = false; | |
| } | |
| } else { | |
| // Normal physics | |
| this.vy += CONFIG.GRAVITY; | |
| this.vx *= CONFIG.FRICTION; | |
| this.vy *= CONFIG.FRICTION; | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| this.alpha -= 0.008; | |
| } | |
| // Update sparkle phase | |
| this.sparklePhase += 0.1; | |
| } | |
| draw(ctx) { | |
| // Draw trail | |
| if (this.trail.length > 1) { | |
| ctx.beginPath(); | |
| ctx.moveTo(this.trail[0].x, this.trail[0].y); | |
| for (let i = 1; i < this.trail.length; i++) { | |
| ctx.lineTo(this.trail[i].x, this.trail[i].y); | |
| } | |
| ctx.strokeStyle = this.color; | |
| ctx.lineWidth = this.size * 0.5; | |
| ctx.globalAlpha = this.alpha * 0.3; | |
| ctx.stroke(); | |
| } | |
| // Draw particle | |
| let sparkleIntensity = 1; | |
| if (this.sparkle) { | |
| sparkleIntensity = 0.5 + 0.5 * Math.sin(this.sparklePhase); | |
| } | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, this.size * sparkleIntensity, 0, Math.PI * 2); | |
| ctx.fillStyle = this.color; | |
| ctx.globalAlpha = this.alpha; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| // Rocket class | |
| class Rocket { | |
| constructor(x, y, targetX, targetY) { | |
| this.x = x; | |
| this.y = y; | |
| this.targetX = targetX; | |
| this.targetY = targetY; | |
| const dx = targetX - x; | |
| const dy = targetY - y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| const speed = Math.min(distance / 40, 15); | |
| const angle = Math.atan2(dy, dx); | |
| this.vx = Math.cos(angle) * speed; | |
| this.vy = Math.sin(angle) * speed; | |
| this.trail = []; | |
| this.maxTrail = 15; | |
| this.color = '#ffffff'; | |
| this.exploded = false; | |
| playLaunchSound(); | |
| } | |
| update() { | |
| this.trail.push({ x: this.x, y: this.y }); | |
| if (this.trail.length > this.maxTrail) { | |
| this.trail.shift(); | |
| } | |
| // Calculate distance to target | |
| const dx = this.targetX - this.x; | |
| const dy = this.targetY - this.y; | |
| const distanceToTarget = Math.sqrt(dx * dx + dy * dy); | |
| this.vy += CONFIG.GRAVITY; | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| // Explode when: | |
| // 1. Close enough to target (within 30px), OR | |
| // 2. Moving away from target (reached peak and passed it) | |
| const newDx = this.targetX - this.x; | |
| const newDy = this.targetY - this.y; | |
| const newDistanceToTarget = Math.sqrt(newDx * newDx + newDy * newDy); | |
| if (distanceToTarget <= 30 || (this.vy >= 0 && newDistanceToTarget > distanceToTarget)) { | |
| this.explode(); | |
| return false; | |
| } | |
| return true; | |
| } | |
| explode() { | |
| this.exploded = true; | |
| playExplosionSound(); | |
| // Screen shake | |
| const container = document.getElementById('canvas-container'); | |
| gsap.to(container, { | |
| x: (Math.random() - 0.5) * 10, | |
| y: (Math.random() - 0.5) * 10, | |
| duration: 0.05, | |
| repeat: 3, | |
| yoyo: true, | |
| onComplete: () => { | |
| gsap.set(container, { x: 0, y: 0 }); | |
| } | |
| }); | |
| // Determine shape type | |
| let shapeType = lockedShape; | |
| if (!shapeType || currentMode === 'random') { | |
| const shapes = ['heart', 'star', 'ring', 'spiral', 'doubleRing', 'diamond', 'flower', 'smiley', 'frog']; | |
| shapeType = shapes[Math.floor(Math.random() * shapes.length)]; | |
| } | |
| // Create shape particles (white, form shape) | |
| const shapePoints = getShapePoints(shapeType, CONFIG.CHARACTER_PARTICLES); | |
| if (shapePoints) { | |
| for (const point of shapePoints) { | |
| const px = this.x + point[0] * (CONFIG.CHARACTER_SIZE / 50); | |
| const py = this.y + point[1] * (CONFIG.CHARACTER_SIZE / 50); | |
| particles.push(new Particle(px, py, '#ffffff', { | |
| isShapeParticle: true, | |
| targetVx: (Math.random() - 0.5) * 10, | |
| targetVy: (Math.random() - 0.5) * 10, | |
| sparkle: Math.random() < 0.3 | |
| })); | |
| } | |
| } | |
| // Create burst particles (colored, explode immediately) | |
| const burstCount = CONFIG.PARTICLES_PER_EXPLOSION; | |
| // Random scale for this explosion (0.3 to 1) | |
| const explosionScale = 0.3 + Math.random() * 0.7; | |
| for (let i = 0; i < burstCount; i++) { | |
| const angle = (i / burstCount) * Math.PI * 2; | |
| const speed = (Math.random() * 8 + 2) * explosionScale; | |
| const color = CONFIG.COLORS[Math.floor(Math.random() * CONFIG.COLORS.length)]; | |
| particles.push(new Particle(this.x, this.y, color, { | |
| vx: Math.cos(angle) * speed, | |
| vy: Math.sin(angle) * speed, | |
| sparkle: Math.random() < 0.3 | |
| })); | |
| } | |
| } | |
| draw(ctx) { | |
| // Draw trail | |
| if (this.trail.length > 1) { | |
| ctx.beginPath(); | |
| ctx.moveTo(this.trail[0].x, this.trail[0].y); | |
| for (let i = 1; i < this.trail.length; i++) { | |
| ctx.lineTo(this.trail[i].x, this.trail[i].y); | |
| } | |
| ctx.strokeStyle = this.color; | |
| ctx.lineWidth = 2; | |
| ctx.globalAlpha = 0.6; | |
| ctx.stroke(); | |
| ctx.globalAlpha = 1; | |
| } | |
| // Draw rocket | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, 3, 0, Math.PI * 2); | |
| ctx.fillStyle = this.color; | |
| ctx.fill(); | |
| } | |
| } | |
| // Initialize stars | |
| function initStars() { | |
| stars = []; | |
| for (let i = 0; i < 80; i++) { | |
| stars.push({ | |
| x: Math.random() * width, | |
| y: Math.random() * height, | |
| size: Math.random() * 2, | |
| twinkleOffset: Math.random() * Math.PI * 2 | |
| }); | |
| } | |
| } | |
| // Draw stars | |
| function drawStars(ctx, time) { | |
| for (const star of stars) { | |
| const twinkle = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(time * 0.002 + star.twinkleOffset)); | |
| ctx.beginPath(); | |
| ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2); | |
| ctx.fillStyle = `rgba(255, 255, 255, ${twinkle})`; | |
| ctx.fill(); | |
| } | |
| } | |
| // Launch firework | |
| function launchFirework(targetX, targetY) { | |
| const startX = width / 2; | |
| const startY = height; | |
| rockets.push(new Rocket(startX, startY, targetX, targetY)); | |
| } | |
| // Grand finale | |
| function triggerGrandFinale() { | |
| for (let i = 0; i < 20; i++) { | |
| setTimeout(() => { | |
| if (isPaused) return; | |
| const x = Math.random() * width * 0.6 + width * 0.2; | |
| const y = Math.random() * height * 0.4 + height * 0.1; | |
| launchFirework(x, y); | |
| }, i * 100); | |
| } | |
| } | |
| // Auto show | |
| function startAutoShow() { | |
| stopAutoShow(); | |
| autoShowInterval = setInterval(() => { | |
| if (isPaused) return; | |
| const x = Math.random() * width * 0.6 + width * 0.2; | |
| const y = Math.random() * height * 0.4 + height * 0.1; | |
| launchFirework(x, y); | |
| }, 800); | |
| } | |
| function stopAutoShow() { | |
| if (autoShowInterval) { | |
| clearInterval(autoShowInterval); | |
| autoShowInterval = null; | |
| } | |
| } | |
| // Resize handler | |
| function resize() { | |
| width = window.innerWidth; | |
| height = window.innerHeight; | |
| canvas.width = width; | |
| canvas.height = height; | |
| glowCanvas.width = width; | |
| glowCanvas.height = height; | |
| initStars(); | |
| } | |
| // Animation loop | |
| let lastTime = 0; | |
| function animate(time) { | |
| requestAnimationFrame(animate); | |
| if (isPaused) return; | |
| const deltaTime = time - lastTime; | |
| lastTime = time; | |
| // Clear canvases | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; | |
| ctx.fillRect(0, 0, width, height); | |
| glowCtx.clearRect(0, 0, width, height); | |
| // Draw stars | |
| drawStars(glowCtx, time); | |
| // Update and draw rockets | |
| rockets = rockets.filter(rocket => { | |
| const alive = rocket.update(); | |
| rocket.draw(ctx); | |
| rocket.draw(glowCtx); | |
| return alive; | |
| }); | |
| // Update and draw particles | |
| particles = particles.filter(particle => { | |
| particle.update(); | |
| if (particle.alpha > 0) { | |
| particle.draw(ctx); | |
| particle.draw(glowCtx); | |
| return true; | |
| } | |
| return false; | |
| }); | |
| } | |
| // Setup controls | |
| function setupControls() { | |
| // Pause button | |
| const pauseBtn = document.getElementById('pause-btn'); | |
| pauseBtn.addEventListener('click', () => { | |
| isPaused = !isPaused; | |
| pauseBtn.textContent = isPaused ? '▶️' : '⏸️'; | |
| pauseBtn.classList.toggle('paused', isPaused); | |
| if (isPaused) { | |
| stopAutoShow(); | |
| } else if (currentMode === 'auto') { | |
| startAutoShow(); | |
| } | |
| }); | |
| // Sound button | |
| const soundBtn = document.getElementById('sound-btn'); | |
| soundBtn.addEventListener('click', () => { | |
| soundEnabled = !soundEnabled; | |
| soundBtn.textContent = soundEnabled ? '🔊' : '🔇'; | |
| if (soundEnabled) { | |
| initAudio(); | |
| } | |
| }); | |
| // Mode buttons | |
| const modeBtns = document.querySelectorAll('.mode-btn'); | |
| modeBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| stopAutoShow(); | |
| modeBtns.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| currentMode = btn.dataset.mode; | |
| if (currentMode === 'auto') { | |
| startAutoShow(); | |
| } | |
| }); | |
| }); | |
| // Shape buttons | |
| const shapeBtns = document.querySelectorAll('.shape-btn'); | |
| shapeBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const shape = btn.dataset.shape; | |
| if (lockedShape === shape) { | |
| // Unlock | |
| lockedShape = null; | |
| btn.classList.remove('active'); | |
| } else { | |
| // Lock new shape | |
| lockedShape = shape; | |
| shapeBtns.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| } | |
| }); | |
| }); | |
| // Canvas click/touch | |
| canvas.addEventListener('mousedown', (e) => { | |
| isMouseDown = true; | |
| handleLaunch(e); | |
| }); | |
| canvas.addEventListener('mousemove', (e) => { | |
| if (isMouseDown && currentMode === 'rapid') { | |
| const now = Date.now(); | |
| if (now - lastLaunchTime > 50) { | |
| handleLaunch(e); | |
| lastLaunchTime = now; | |
| } | |
| } | |
| }); | |
| canvas.addEventListener('mouseup', () => { | |
| isMouseDown = false; | |
| }); | |
| canvas.addEventListener('mouseleave', () => { | |
| isMouseDown = false; | |
| }); | |
| // Touch events | |
| canvas.addEventListener('touchstart', (e) => { | |
| e.preventDefault(); | |
| isMouseDown = true; | |
| handleLaunch(e.touches[0]); | |
| }); | |
| canvas.addEventListener('touchmove', (e) => { | |
| e.preventDefault(); | |
| if (isMouseDown && currentMode === 'rapid') { | |
| const now = Date.now(); | |
| if (now - lastLaunchTime > 50) { | |
| handleLaunch(e.touches[0]); | |
| lastLaunchTime = now; | |
| } | |
| } | |
| }); | |
| canvas.addEventListener('touchend', () => { | |
| isMouseDown = false; | |
| }); | |
| } | |
| function handleLaunch(e) { | |
| if (isPaused) return; | |
| initAudio(); | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| if (currentMode === 'finale') { | |
| triggerGrandFinale(); | |
| } else { | |
| launchFirework(x, y); | |
| } | |
| } | |
| // Initialize | |
| function init() { | |
| canvas = document.getElementById('main-canvas'); | |
| ctx = canvas.getContext('2d'); | |
| glowCanvas = document.getElementById('glow-canvas'); | |
| glowCtx = glowCanvas.getContext('2d'); | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| setupControls(); | |
| // Initial firework - only ONE on page load | |
| setTimeout(() => { | |
| initAudio(); | |
| launchFirework(width / 2, height * 0.35); | |
| }, 500); | |
| animate(0); | |
| } | |
| // Start when page loads | |
| window.addEventListener('load', init); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment