Created
August 18, 2025 06:58
-
-
Save kujirahand/e312341b1ed9cd43a1544ed21862f328 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
| <!DOCTYPE html> | |
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| <title>Full Screen Fireworks</title> | |
| <style> | |
| html, body { height: 100%; margin: 0; } | |
| body { background: radial-gradient(1200px 800px at 50% 80%, #06102a 0%, #020712 60%, #000 100%); | |
| overflow: hidden; } | |
| canvas { display: block; width: 100vw; height: 100vh; } | |
| .hint { position: fixed; left: 12px; bottom: 10px; color: #cbd5e1; font-family: system-ui, -apple-system, Segoe UI, Roboto, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif; font-size: 12px; opacity: .7; user-select: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="fw"></canvas> | |
| <div class="hint">Full Screen Fireworks — 自動で連続&同時打ち上げ</div> | |
| <script> | |
| (() => { | |
| 'use strict'; | |
| // ====== Config ====== | |
| const CFG = { | |
| dprMax: 2, | |
| gravity: 0.15, // 重力(大きいほど垂れる) | |
| airFriction: 0.995, // 空気抵抗 | |
| trailLen: 6, // パーティクルの軌跡の長さ | |
| fadeAlpha: 0.18, // 尾を残すためのフェード | |
| rocketMinVy: -12.5, // 打ち上げ初速(最小) | |
| rocketMaxVy: -17.5, // 打ち上げ初速(最大) | |
| rocketSpread: 0.9, // ロケットの左右ブレ | |
| explodeAltitudeMin: 0.25, // 画面高に対する爆発の最低比率 | |
| explodeAltitudeMax: 0.55, // 爆発の最高比率 | |
| particlesPerBurstMin: 160, | |
| particlesPerBurstMax: 320, | |
| megaEvery: 6, // 何回に1回は巨大花火 | |
| megaMultiplier: 2.2, // 巨大花火の粒子倍率 | |
| launchEveryMs: 600, // 定常打ち上げの間隔 | |
| stormEveryMs: 3200, // 同時多発(連発)間隔 | |
| stormCountMin: 3, | |
| stormCountMax: 6, | |
| maxLiveParticles: 16000, // 安全上限(端末保護) | |
| }; | |
| // ====== Canvas & sizing ====== | |
| const canvas = document.getElementById('fw'); | |
| const ctx = canvas.getContext('2d'); | |
| let W = 0, H = 0, DPR = Math.min(window.devicePixelRatio || 1, CFG.dprMax); | |
| function resize() { | |
| DPR = Math.min(window.devicePixelRatio || 1, CFG.dprMax); | |
| W = Math.max(1, Math.floor(innerWidth * DPR)); | |
| H = Math.max(1, Math.floor(innerHeight * DPR)); | |
| canvas.width = W; canvas.height = H; | |
| } | |
| addEventListener('resize', resize, { passive: true }); | |
| resize(); | |
| // ====== Utils ====== | |
| const rand = (a, b) => a + Math.random() * (b - a); | |
| const randi = (a, b) => (Math.random() * (b - a + 1) + a) | 0; | |
| const clamp = (x, a, b) => x < a ? a : (x > b ? b : x); | |
| const TAU = Math.PI * 2; | |
| function hsv2rgb(h, s, v) { // 0..360, 0..1, 0..1 | |
| let c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c; | |
| let r=0,g=0,b=0; | |
| if (h < 60) { r=c; g=x; b=0; } | |
| else if (h < 120) { r=x; g=c; b=0; } | |
| else if (h < 180) { r=0; g=c; b=x; } | |
| else if (h < 240) { r=0; g=x; b=c; } | |
| else if (h < 300) { r=x; g=0; b=c; } | |
| else { r=c; g=0; b=x; } | |
| return [(r+m)*255, (g+m)*255, (b+m)*255]; | |
| } | |
| function rgba(r,g,b,a){ return `rgba(${r|0},${g|0},${b|0},${a})`; } | |
| // ====== Objects ====== | |
| class Rocket { | |
| constructor(x, vy, hueBase) { | |
| this.x = x; | |
| this.y = H + rand(20, 120); | |
| this.vx = rand(-CFG.rocketSpread, CFG.rocketSpread); | |
| this.vy = vy; | |
| this.hue = hueBase ?? randi(0, 360); | |
| const minY = H * CFG.explodeAltitudeMin; | |
| const maxY = H * CFG.explodeAltitudeMax; | |
| this.targetY = rand(minY, maxY); | |
| this.dead = false; | |
| } | |
| update(dt) { | |
| this.x += this.vx * dt; | |
| this.y += this.vy * dt; | |
| this.vy += CFG.gravity * 0.3 * dt; // 少しだけ重力 | |
| if (this.vy > 0 || this.y <= this.targetY) { | |
| this.dead = true; | |
| explode(this.x, this.y, this.hue); | |
| } | |
| } | |
| draw() { | |
| // 上昇中の光跡 | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, 2.5 * DPR, 0, TAU); | |
| const [r,g,b] = hsv2rgb(this.hue, 0.8, 1.0); | |
| ctx.fillStyle = rgba(r,g,b, 0.95); | |
| ctx.fill(); | |
| } | |
| } | |
| class Particle { | |
| constructor(x, y, spd, ang, hue, sparkle=false, life=1.0) { | |
| this.x = x; this.y = y; | |
| this.vx = Math.cos(ang) * spd; | |
| this.vy = Math.sin(ang) * spd; | |
| this.hue = hue; | |
| this.life = life; // 1..0 | |
| this.sparkle = sparkle; | |
| this.trail = []; | |
| } | |
| update(dt) { | |
| // 保存する軌跡点 | |
| this.trail.push([this.x, this.y]); | |
| if (this.trail.length > CFG.trailLen) this.trail.shift(); | |
| this.x += this.vx * dt; | |
| this.y += this.vy * dt; | |
| this.vx *= CFG.airFriction; | |
| this.vy = this.vy * CFG.airFriction + CFG.gravity * dt; | |
| this.life -= 0.009 * dt; // 寿命減衰 | |
| if (this.life <= 0) this.life = 0; | |
| } | |
| draw() { | |
| const [r,g,b] = hsv2rgb(this.hue, 0.9, 1.0); | |
| // 軌跡 | |
| ctx.beginPath(); | |
| for (let i=0;i<this.trail.length;i++) { | |
| const p = this.trail[i]; | |
| if (i===0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); | |
| } | |
| ctx.strokeStyle = rgba(r,g,b, 0.35 * this.life); | |
| ctx.lineWidth = 1.4 * DPR; | |
| ctx.stroke(); | |
| // 本体 | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, 1.6*DPR, 0, TAU); | |
| ctx.fillStyle = rgba(r,g,b, 0.8 * this.life); | |
| ctx.fill(); | |
| // キラキラ | |
| if (this.sparkle && Math.random() < 0.2) { | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, 0.7*DPR, 0, TAU); | |
| ctx.fillStyle = rgba(255,255,255, 0.4 * this.life); | |
| ctx.fill(); | |
| } | |
| } | |
| } | |
| // ====== Simulation state ====== | |
| const rockets = []; | |
| const particles = []; | |
| let launchCount = 0; | |
| // ====== Explosion patterns ====== | |
| function explode(x, y, hueSeed) { | |
| let count = randi(CFG.particlesPerBurstMin, CFG.particlesPerBurstMax); | |
| const isMega = (++launchCount % CFG.megaEvery === 0); | |
| if (isMega) count = (count * CFG.megaMultiplier) | 0; | |
| const mode = randi(0, 5); // 6種類 | |
| const baseHue = (hueSeed + randi(-20, 20) + 360) % 360; | |
| const V = isMega ? 1.0 : 0.95; | |
| const angleStep = TAU / count; | |
| for (let i = 0; i < count; i++) { | |
| const a = angleStep * i + Math.random()*0.03; | |
| let spd, ang = a, hue = (baseHue + randi(-10, 10) + 360) % 360; | |
| let sparkle = false, life = 1.0; | |
| switch (mode) { | |
| case 0: // 球(菊) | |
| spd = rand(3.6, 6.2) * DPR * (isMega ? 1.3 : 1); | |
| break; | |
| case 1: // リング | |
| spd = 5.4 * DPR * (isMega ? 1.2 : 1); | |
| break; | |
| case 2: // ウィロー(柳) | |
| spd = rand(2.8, 4.2) * DPR; life = 1.6; hue = baseHue; sparkle = true; | |
| break; | |
| case 3: // スパイラル | |
| spd = rand(3.4, 5.4) * DPR; ang += i * 0.02; | |
| break; | |
| case 4: // パーム(中心から強め) | |
| spd = rand(4.8, 7.0) * DPR; if (Math.random()<0.15) spd *= 1.4; | |
| break; | |
| case 5: // ハート | |
| // パラメトリック心形: r = (1 - sinθ) | |
| const r = (1 - Math.sin(a)) * 3.4 * DPR * (isMega ? 1.35 : 1); | |
| spd = r; ang = a; sparkle = true; life = 1.2; hue = baseHue; | |
| break; | |
| } | |
| // 安全上限(パフォーマンス保護) | |
| if (particles.length > CFG.maxLiveParticles) break; | |
| particles.push(new Particle(x, y, spd, ang, hue, sparkle, life)); | |
| } | |
| } | |
| // ====== Launch scheduling ====== | |
| function scheduleLaunch(amount = 1) { | |
| for (let i = 0; i < amount; i++) { | |
| const x = rand(W*0.08, W*0.92); | |
| const vy = rand(CFG.rocketMaxVy, CFG.rocketMinVy); | |
| // 同時発火感を出すため、微小遅延 | |
| const delay = i === 0 ? 0 : rand(20, 120); | |
| const hue = randi(0, 360); | |
| setTimeout(() => rockets.push(new Rocket(x, vy, hue)), delay); | |
| } | |
| } | |
| // 定常打ち上げ | |
| setInterval(() => { | |
| // 1〜2発を連続 | |
| scheduleLaunch(randi(1, 2)); | |
| }, CFG.launchEveryMs); | |
| // 同時多発の嵐 | |
| setInterval(() => { | |
| scheduleLaunch(randi(CFG.stormCountMin, CFG.stormCountMax)); | |
| }, CFG.stormEveryMs); | |
| // ====== Main loop ====== | |
| let last = performance.now(); | |
| function loop(now) { | |
| requestAnimationFrame(loop); | |
| const dtMs = now - last; last = now; | |
| // 可変フレームレート対応(60fps基準で正規化) | |
| const dt = clamp(dtMs / (1000/60), 0.5, 2.5); | |
| // フェード(残像) | |
| ctx.globalCompositeOperation = 'source-over'; | |
| ctx.fillStyle = `rgba(0,0,0,${CFG.fadeAlpha})`; | |
| ctx.fillRect(0, 0, W, H); | |
| // オブジェクト更新 | |
| for (let i = rockets.length - 1; i >= 0; i--) { | |
| const r = rockets[i]; | |
| r.update(dt); | |
| if (r.dead) rockets.splice(i, 1); | |
| } | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const p = particles[i]; | |
| p.update(dt); | |
| if (p.life <= 0) particles.splice(i, 1); | |
| } | |
| // 描画 | |
| ctx.globalCompositeOperation = 'lighter'; | |
| for (let i = 0; i < rockets.length; i++) rockets[i].draw(); | |
| for (let i = 0; i < particles.length; i++) particles[i].draw(); | |
| } | |
| // 初期の景気づけ(いきなり大花火) | |
| scheduleLaunch(4); | |
| requestAnimationFrame(loop); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Claude sonnet 4で作成したプログラム