Tyre Launch
Home
Snippets
Tyre Launch
HTML
CSS
JS
<div id="wrap"> <canvas id="c"></canvas> <div id="ui"> <button id="startBtn">▶ START</button> <span id="phase">READY - PRESS START</span> <button id="resetBtn">↺ RESET</button> </div> </div>
* { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; background: #0a0a0a; overflow: hidden; font-family: 'Courier New', monospace; } #wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; } canvas { border-radius: 12px; box-shadow: 0 0 60px rgba(255,160,30,0.15); display: block; } #ui { margin-top: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: center; } #ui button { padding: 8px 22px; border-radius: 6px; border: 1.5px solid #f90; background: transparent; color: #f90; font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; cursor: pointer; letter-spacing: 1px; transition: all 0.2s; } #ui button:hover { background: #f90; color: #000; } #ui button:disabled { opacity: 0.35; cursor: default; } #phase { color: #aaa; font-size: 11px; letter-spacing: 2px; text-align: center; }
const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); const phaseEl = document.getElementById('phase'); const startBtn = document.getElementById('startBtn'); const resetBtn = document.getElementById('resetBtn'); let CW, CH, GY; let GEN_CX, GEN_Y; let RAMP_LX, RAMP_LY, RAMP_TX, RAMP_TY, RAMP_BY; let TR; function computeLayout() { const uiEl = document.getElementById('ui'); const uiH = uiEl.offsetHeight + 28; CW = window.innerWidth; CH = window.innerHeight - uiH; canvas.width = CW; canvas.height = CH; GY = CH - Math.round(CH * 0.13); GEN_CX = Math.round(CW * 0.20); GEN_Y = GY - Math.round(CH * 0.09); RAMP_LX = Math.round(CW * 0.55); RAMP_LY = GY; RAMP_TX = Math.round(CW * 0.67); RAMP_TY = GY - Math.round(CH * 0.20); RAMP_BY = GY; TR = Math.max(14, Math.round(Math.min(CW, CH) * 0.048)); } let raf, frame = 0, running = false; let phase = 'idle'; let tyre = {}; let releaseFx = 0; let dustP = [], sparkP = [], debrisP = [], smokeP = []; let bounces = 0, rpmDisplay = 0; function initTyre() { tyre = { x: GEN_CX, y: GEN_Y - 4, vx: 0, vy: 0, angle: 0, spin: 0, spinSpeed: 0 }; } function resetState() { frame = 0; phase = 'idle'; bounces = 0; releaseFx = 0; dustP = []; sparkP = []; debrisP = []; smokeP = []; rpmDisplay = 0; initTyre(); phaseEl.textContent = 'READY - PRESS START'; startBtn.disabled = false; startBtn.textContent = 'START'; } function spawnDust(x, y, n) { for (let i = 0; i < n; i++) { const a = Math.PI + Math.random() * Math.PI; dustP.push({ x, y, vx: Math.cos(a)*(1+Math.random()*3), vy: -(1+Math.random()*2), life: 1, r: 3+Math.random()*4 }); } } function spawnSparks(x, y, n) { for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; sparkP.push({ x, y, vx: Math.cos(a)*(2+Math.random()*5), vy: Math.sin(a)*(2+Math.random()*5)-3, life: 1 }); } } function spawnDebris() { for (let i = 0; i < 5; i++) debrisP.push({ x: tyre.x, y: tyre.y, vx: (Math.random()-0.5)*4, vy: -(2+Math.random()*3), life: 1, r: 2+Math.random()*3 }); } function addSmoke() { smokeP.push({ x: GEN_CX + CW*0.02, y: GEN_Y - CH*0.13, r: 6, life: 1, vx: (Math.random()-0.5)*0.5, vy: -0.7 }); } function update() { frame++; const spd = CW / 130; if (phase === 'spinning') { tyre.spinSpeed = Math.min(tyre.spinSpeed + 0.045, 2.2); tyre.spin += tyre.spinSpeed; rpmDisplay = Math.round(tyre.spinSpeed * 550); if (frame % 4 === 0) addSmoke(); if (frame % 6 === 0) spawnSparks(tyre.x, tyre.y, 3); if (frame > 220) { phase = 'release'; releaseFx = 1; spawnSparks(tyre.x, tyre.y, 20); tyre.vx = spd * 0.94; tyre.vy = -0.5; tyre.y = GY - TR; phaseEl.textContent = 'ROD RELEASED - TYRE ROLLING'; } } if (phase === 'release') { releaseFx = Math.max(0, releaseFx - 0.06); tyre.spin += tyre.spinSpeed * 0.85; tyre.x += tyre.vx; tyre.angle += tyre.vx / TR; if (frame % 3 === 0) spawnDust(tyre.x, GY, 2); if (tyre.x > RAMP_LX - 10 && tyre.x <= RAMP_TX + 10) { const t = Math.min(1, (tyre.x - RAMP_LX) / (RAMP_TX - RAMP_LX)); const ry = RAMP_LY + t * (RAMP_TY - RAMP_LY); if (tyre.y >= ry - TR) tyre.y = ry - TR; if (tyre.x >= RAMP_TX - 5) { phase = 'flying'; tyre.vx = spd * 1.1; tyre.vy = -(CH * 0.028); phaseEl.textContent = 'LAUNCHED!'; spawnSparks(tyre.x, tyre.y, 25); spawnDebris(); } } } if (phase === 'flying' || phase === 'falling') { tyre.vy += CH * 0.0007; tyre.x += tyre.vx; tyre.y += tyre.vy; tyre.spin += 0.22; tyre.angle += 0.09; tyre.vx *= 0.995; if (frame % 5 === 0) spawnDebris(); if (tyre.vy > 0) { phase = 'falling'; phaseEl.textContent = 'FALLING...'; } if (tyre.y + TR >= GY) { tyre.y = GY - TR; bounces++; const s = Math.abs(tyre.vy); tyre.vy = -tyre.vy * 0.42; tyre.vx *= 0.6; spawnDust(tyre.x, GY, 16); spawnSparks(tyre.x, GY, 8); if (s < 2.5 || bounces >= 4) { phase = 'done'; tyre.vx = 0; tyre.vy = 0; phaseEl.textContent = 'LANDED'; startBtn.textContent = 'DONE'; startBtn.disabled = true; } else { phase = 'falling'; } } } dustP.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.15; p.life -= 0.025; }); dustP = dustP.filter(p => p.life > 0); sparkP.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.3; p.life -= 0.04; }); sparkP = sparkP.filter(p => p.life > 0); debrisP.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.2; p.life -= 0.018; }); debrisP = debrisP.filter(p => p.life > 0); smokeP.forEach(p => { p.x += p.vx; p.y += p.vy; p.r += 0.4; p.life -= 0.012; }); smokeP = smokeP.filter(p => p.life > 0); } function drawCloud(cx, cy, sc) { ctx.fillStyle = 'rgba(255,255,255,0.88)'; [[0,0,36],[30,6,28],[-28,8,24],[55,10,20],[-52,12,18],[20,-10,20]].forEach(([dx,dy,r]) => { ctx.beginPath(); ctx.arc(cx+dx*sc, cy+dy*sc, r*sc, 0, Math.PI*2); ctx.fill(); }); } function drawBackground() { const sky = ctx.createLinearGradient(0, 0, 0, GY); sky.addColorStop(0, '#1a3a6e'); sky.addColorStop(0.4, '#3d78c0'); sky.addColorStop(1, '#a0c8f0'); ctx.fillStyle = sky; ctx.fillRect(0, 0, CW, GY); const sx = CW*0.82, sy = CH*0.12, sunR = Math.max(18, CH*0.1); const sunG = ctx.createRadialGradient(sx, sy, 2, sx, sy, sunR); sunG.addColorStop(0, 'rgba(255,240,180,1)'); sunG.addColorStop(0.4, 'rgba(255,200,60,0.7)'); sunG.addColorStop(1, 'rgba(255,160,0,0)'); ctx.fillStyle = sunG; ctx.beginPath(); ctx.arc(sx, sy, sunR, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#FDEEA0'; ctx.beginPath(); ctx.arc(sx, sy, sunR*0.4, 0, Math.PI*2); ctx.fill(); const cs = Math.max(0.4, CW/800); drawCloud(CW*0.1, CH*0.08, 1.2*cs); drawCloud(CW*0.38, CH*0.05, 0.9*cs); drawCloud(CW*0.65, CH*0.12, 1.0*cs); ctx.fillStyle = '#4a7c4e'; ctx.beginPath(); ctx.moveTo(0, GY); ctx.bezierCurveTo(CW*0.1, GY-CH*0.12, CW*0.2, GY-CH*0.17, CW*0.3, GY-CH*0.09); ctx.bezierCurveTo(CW*0.4, GY-CH*0.04, CW*0.5, GY-CH*0.13, CW*0.65, GY-CH*0.08); ctx.bezierCurveTo(CW*0.75, GY-CH*0.04, CW*0.9, GY-CH*0.1, CW, GY-CH*0.06); ctx.lineTo(CW, GY); ctx.lineTo(0, GY); ctx.closePath(); ctx.fill(); const gG = ctx.createLinearGradient(0, GY, 0, CH); gG.addColorStop(0, '#7a9e3a'); gG.addColorStop(0.15, '#5c7a28'); gG.addColorStop(1, '#3d5218'); ctx.fillStyle = gG; ctx.fillRect(0, GY, CW, CH-GY); ctx.strokeStyle = '#6aa030'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(0, GY); ctx.lineTo(CW, GY); ctx.stroke(); ctx.fillStyle = 'rgba(100,70,30,0.35)'; ctx.fillRect(GEN_CX-10, GY+2, RAMP_TX-GEN_CX+20, 5); const tufts = Math.max(8, Math.round(CW/55)); for (let i = 0; i < tufts; i++) { const gx = 30 + i*(CW/tufts); ctx.strokeStyle = '#8bc34a'; ctx.lineWidth = 1.5; for (let j = -2; j <= 2; j++) { ctx.beginPath(); ctx.moveTo(gx+j*4, GY); ctx.quadraticCurveTo(gx+j*4+(j%2===0?3:-3), GY-10, gx+j*4+j*2, GY-14); ctx.stroke(); } } } function drawSmoke() { smokeP.forEach(p => { ctx.fillStyle = `rgba(190,190,190,${p.life*0.35})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); ctx.fill(); }); } function drawGenerator() { const gx = GEN_CX, gy = GEN_Y; const sc = Math.max(0.45, Math.min(CW, CH) / 600); ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(gx-10, GY+4, 52*sc, 10*sc, 0, 0, Math.PI*2); ctx.fill(); [-32*sc, 22*sc].forEach(dx => { ctx.fillStyle = '#222'; ctx.beginPath(); ctx.arc(gx+dx, GY, 10*sc, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#555'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(gx+dx, GY, 6*sc, 0, Math.PI*2); ctx.stroke(); }); ctx.fillStyle = '#2c2c2c'; ctx.beginPath(); ctx.roundRect(gx-52*sc, gy-50*sc, 90*sc, 55*sc, 6); ctx.fill(); ctx.fillStyle = '#444'; ctx.beginPath(); ctx.roundRect(gx-46*sc, gy-44*sc, 52*sc, 36*sc, 4); ctx.fill(); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5; for (let i = 0; i < 5; i++) { ctx.beginPath(); ctx.moveTo(gx + (-46+i*10)*sc, gy-44*sc); ctx.lineTo(gx + (-46+i*10)*sc, gy-8*sc); ctx.stroke(); } ctx.fillStyle = '#d44'; ctx.beginPath(); ctx.roundRect(gx+10*sc, gy-42*sc, 22*sc, 32*sc, 4); ctx.fill(); ctx.fillStyle = '#e66'; const fSize = Math.max(7, Math.round(7*sc)); ctx.font = `bold ${fSize}px monospace`; ctx.textAlign = 'center'; ctx.fillText('FUEL', gx+21*sc, gy-23*sc); ctx.strokeStyle = '#555'; ctx.lineWidth = 7*sc; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(gx-10*sc, gy-44*sc); ctx.lineTo(gx-10*sc, gy-72*sc); ctx.stroke(); ctx.strokeStyle = '#888'; ctx.lineWidth = 5*sc; ctx.beginPath(); ctx.moveTo(gx-5*sc, gy-8*sc); ctx.lineTo(gx+38*sc, gy-8*sc); ctx.stroke(); ctx.save(); ctx.translate(gx+20*sc, gy-8*sc); ctx.rotate(tyre.spin * 2); ctx.fillStyle = '#666'; ctx.beginPath(); ctx.arc(0, 0, 12*sc, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#FFD700'; ctx.lineWidth = 2; for (let i = 0; i < 4; i++) { const a = (i/4)*Math.PI*2; ctx.beginPath(); ctx.moveTo(Math.cos(a)*3*sc, Math.sin(a)*3*sc); ctx.lineTo(Math.cos(a)*11*sc, Math.sin(a)*11*sc); ctx.stroke(); } ctx.fillStyle = '#aaa'; ctx.beginPath(); ctx.arc(0, 0, 3*sc, 0, Math.PI*2); ctx.fill(); ctx.restore(); if (phase === 'spinning' || phase === 'release') { ctx.fillStyle = 'rgba(0,0,0,0.65)'; ctx.beginPath(); ctx.roundRect(gx-52*sc, gy-70*sc, 44*sc, 22*sc, 4); ctx.fill(); ctx.fillStyle = '#f90'; const rSize = Math.max(8, Math.round(9*sc)); ctx.font = `bold ${rSize}px monospace`; ctx.textAlign = 'center'; ctx.fillText(rpmDisplay+' RPM', gx-30*sc, gy-55*sc); } ctx.fillStyle = '#FFD700'; const gSize = Math.max(8, Math.round(9*sc)); ctx.font = `bold ${gSize}px monospace`; ctx.textAlign = 'center'; ctx.fillText('GENERATOR', gx-7*sc, gy-56*sc); } function drawRod() { if (phase !== 'idle' && phase !== 'spinning') return; const sc = Math.max(0.45, Math.min(CW, CH) / 600); const px = GEN_CX + 60*sc, py = GY; ctx.fillStyle = '#FDBCB4'; ctx.beginPath(); ctx.arc(px, py-50*sc, 10*sc, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#8B5E3C'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(px, py-50*sc, 10*sc, 0, Math.PI*2); ctx.stroke(); ctx.fillStyle = '#2d2d2d'; ctx.fillRect(px-13*sc, py-62*sc, 26*sc, 5*sc); ctx.fillRect(px-9*sc, py-74*sc, 18*sc, 14*sc); ctx.strokeStyle = '#5a3a1a'; ctx.lineWidth = 2.5*sc; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(px, py-40*sc); ctx.lineTo(px, py-16*sc); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px, py-16*sc); ctx.lineTo(px-9*sc, py); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px, py-16*sc); ctx.lineTo(px+9*sc, py); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px, py-34*sc); ctx.lineTo(px-15*sc, py-48*sc); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px, py-34*sc); ctx.lineTo(px-18*sc, py-22*sc); ctx.stroke(); ctx.strokeStyle = '#aaa'; ctx.lineWidth = Math.max(2, 4*sc); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(px-16*sc, py-34*sc); ctx.lineTo(tyre.x, tyre.y); ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = Math.max(1, 1.5*sc); ctx.beginPath(); ctx.moveTo(px-16*sc, py-35*sc); ctx.lineTo(tyre.x, tyre.y-1); ctx.stroke(); } function drawRamp() { ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.ellipse((RAMP_LX+RAMP_TX)/2, GY+5, (RAMP_TX-RAMP_LX)/2+10, 9, 0, 0, Math.PI*2); ctx.fill(); ctx.save(); ctx.beginPath(); ctx.moveTo(RAMP_LX, RAMP_LY); ctx.lineTo(RAMP_TX, RAMP_TY); ctx.lineTo(RAMP_TX, RAMP_BY); ctx.closePath(); ctx.fillStyle = '#8B6914'; ctx.fill(); ctx.strokeStyle = '#6B5010'; ctx.lineWidth = 1; for (let i = 1; i < 5; i++) { const t = i/5; const sx = RAMP_LX + t*(RAMP_TX-RAMP_LX), sy = RAMP_LY + t*(RAMP_TY-RAMP_LY); ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(RAMP_TX, sy); ctx.stroke(); } ctx.restore(); ctx.strokeStyle = '#D4A820'; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(RAMP_LX, RAMP_LY); ctx.lineTo(RAMP_TX, RAMP_TY); ctx.stroke(); ctx.strokeStyle = '#5C3D08'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(RAMP_LX, RAMP_LY); ctx.lineTo(RAMP_TX, RAMP_TY); ctx.lineTo(RAMP_TX, RAMP_BY); ctx.closePath(); ctx.stroke(); [[RAMP_TX-8, RAMP_BY-12],[RAMP_TX-8, RAMP_BY-35],[RAMP_TX-8, RAMP_BY-58]].forEach(([bx,by]) => { if (by > RAMP_TY) { ctx.fillStyle = '#aaa'; ctx.beginPath(); ctx.arc(bx, by, 3, 0, Math.PI*2); ctx.fill(); } }); const rSize = Math.max(9, Math.round(CW*0.013)); ctx.fillStyle = '#FFF8DC'; ctx.font = `bold ${rSize}px monospace`; ctx.textAlign = 'center'; ctx.fillText('RAMP', RAMP_LX+(RAMP_TX-RAMP_LX)*0.55, RAMP_BY-18); } function drawTyre(x, y, angle, spin) { ctx.save(); ctx.translate(x, y); ctx.rotate(angle); const R = TR; if (y > GY - 80) { const ss = 1 - Math.max(0, (GY-y)/80)*0.5; ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.ellipse(0, GY-y+6, R*ss, 5*ss, 0, 0, Math.PI*2); ctx.fill(); } ctx.strokeStyle = '#111'; ctx.lineWidth = Math.max(6, R*0.35); ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI*2); ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(0, 0, R, Math.PI*1.1, Math.PI*1.7); ctx.stroke(); ctx.strokeStyle = '#333'; ctx.lineWidth = 2; for (let i = 0; i < 12; i++) { const a = spin+(i/12)*Math.PI*2; ctx.beginPath(); ctx.moveTo(Math.cos(a)*(R-5), Math.sin(a)*(R-5)); ctx.lineTo(Math.cos(a)*(R+1), Math.sin(a)*(R+1)); ctx.stroke(); } ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(0, 0, R-9, 0, Math.PI*2); ctx.stroke(); const rimG = ctx.createRadialGradient(-4,-4,2,0,0,R-10); rimG.addColorStop(0,'#e0e0e0'); rimG.addColorStop(1,'#888'); ctx.fillStyle = rimG; ctx.beginPath(); ctx.arc(0,0,R-10,0,Math.PI*2); ctx.fill(); ctx.strokeStyle = '#aaa'; ctx.lineWidth = 3; for (let i = 0; i < 5; i++) { const a = spin+(i/5)*Math.PI*2; ctx.beginPath(); ctx.moveTo(Math.cos(a)*4, Math.sin(a)*4); ctx.lineTo(Math.cos(a)*(R-11), Math.sin(a)*(R-11)); ctx.stroke(); } const hubG = ctx.createRadialGradient(-2,-2,1,0,0,7); hubG.addColorStop(0,'#fff'); hubG.addColorStop(1,'#aaa'); ctx.fillStyle = hubG; ctx.beginPath(); ctx.arc(0,0,7,0,Math.PI*2); ctx.fill(); ctx.strokeStyle = '#888'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0,0,7,0,Math.PI*2); ctx.stroke(); ctx.restore(); } function drawParticles() { dustP.forEach(p => { ctx.fillStyle = `rgba(140,110,70,${p.life*0.6})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.r*p.life, 0, Math.PI*2); ctx.fill(); }); sparkP.forEach(p => { ctx.strokeStyle = `rgba(255,${180+Math.round(75*p.life)},0,${p.life})`; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x-p.vx*3, p.y-p.vy*3); ctx.stroke(); }); debrisP.forEach(p => { ctx.fillStyle = `rgba(80,60,40,${p.life*0.5})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); ctx.fill(); }); } function drawSpeedLines() { if (phase !== 'spinning') return; ctx.strokeStyle = `rgba(255,200,50,${0.15+tyre.spinSpeed*0.1})`; ctx.lineWidth = 1; for (let i = 0; i < 8; i++) { const a = tyre.spin*1.5+(i/8)*Math.PI*2; ctx.beginPath(); ctx.moveTo(tyre.x+Math.cos(a)*(TR+4), tyre.y+Math.sin(a)*(TR+4)); ctx.lineTo(tyre.x+Math.cos(a)*(TR+14+tyre.spinSpeed*4), tyre.y+Math.sin(a)*(TR+14+tyre.spinSpeed*4)); ctx.stroke(); } } function drawTrail() { if (phase !== 'flying' && phase !== 'falling') return; ctx.strokeStyle = 'rgba(80,80,80,0.15)'; ctx.lineWidth = TR*1.5; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(tyre.x+tyre.vx*3, tyre.y+tyre.vy*3); ctx.lineTo(tyre.x, tyre.y); ctx.stroke(); } function drawFlash() { if (releaseFx > 0) { ctx.fillStyle = `rgba(255,200,50,${releaseFx*0.25})`; ctx.fillRect(0, 0, CW, CH); } } function draw() { ctx.clearRect(0, 0, CW, CH); drawBackground(); drawSmoke(); drawRamp(); drawGenerator(); drawRod(); drawTrail(); drawSpeedLines(); drawParticles(); drawTyre(tyre.x, tyre.y, tyre.angle, tyre.spin); drawFlash(); } function loop() { if (!running) return; update(); draw(); raf = requestAnimationFrame(loop); } let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const wasRunning = running; running = false; cancelAnimationFrame(raf); computeLayout(); resetState(); draw(); }, 120); }); startBtn.addEventListener('click', () => { if (phase === 'idle') { phase = 'spinning'; phaseEl.textContent = 'SPINNING UP...'; startBtn.disabled = true; running = true; loop(); } }); resetBtn.addEventListener('click', () => { running = false; cancelAnimationFrame(raf); computeLayout(); resetState(); draw(); }); computeLayout(); resetState(); draw();
Ad #1
Ad #2
Scroll to Top