import { BUILD } from './build-info.ts' import { pwaScript } from './pwa.ts' import { baseCss, esc, headTags, type PageCtx } from './ui.ts' export const gameScript = String.raw` (() => { const cv = document.getElementById('c'), ctx = cv.getContext('2d'); const $ = (id) => document.getElementById(id); let W = 0, H = 0, DPR = 1; function resize() { DPR = Math.min(2, window.devicePixelRatio || 1); W = innerWidth; H = innerHeight; cv.width = W * DPR; cv.height = H * DPR; ctx.setTransform(DPR,0,0,DPR,0,0); } addEventListener('resize', resize); resize(); const TAU = Math.PI * 2, SIZES = { 3: 52, 2: 28, 1: 14 }, PTS = { 3: 20, 2: 50, 1: 100 }; const keys = {}, touch = { fire: false }; // Virtual analog stick (touch): floating base where the left thumb lands. dx/dy in -1..1. const STICK_R = 56, STEER_DEAD = 0.18, THRUST_DEAD = 0.35; const stick = { id: null, ox: 0, oy: 0, dx: 0, dy: 0, mag: 0 }; // ---- Audio: everything synthesized with Web Audio, no files or requests ---- const SND_KEY = 'kody-asteroids-sound', MUS_KEY = 'kody-asteroids-music'; const readPref = (k) => { try { return localStorage.getItem(k) !== 'off' } catch { return true } }; const writePref = (k, on) => { try { localStorage.setItem(k, on ? 'on' : 'off') } catch {} }; const audio = { ctx: null, master: null, sfx: null, music: null, noiseBuf: null, thrustSrc: null, thrustGain: null, soundOn: readPref(SND_KEY), musicOn: readPref(MUS_KEY), step: 0, nextT: 0, timer: 0 }; function audioInit() { if (audio.ctx) { if (audio.ctx.state === 'suspended') audio.ctx.resume(); return; } const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; try { audio.ctx = new AC(); } catch { return; } const a = audio.ctx; audio.master = a.createGain(); audio.master.gain.value = 0.5; audio.master.connect(a.destination); audio.sfx = a.createGain(); audio.sfx.gain.value = audio.soundOn ? 0.55 : 0; audio.sfx.connect(audio.master); audio.music = a.createGain(); audio.music.gain.value = audio.musicOn ? 0.16 : 0; audio.music.connect(audio.master); const len = a.sampleRate; audio.noiseBuf = a.createBuffer(1, len, a.sampleRate); const d = audio.noiseBuf.getChannelData(0); for (let i = 0; i < len; i++) d[i] = Math.random()*2 - 1; // Thrust: looping filtered noise, gated by a gain. audio.thrustGain = a.createGain(); audio.thrustGain.gain.value = 0; const f = a.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 420; audio.thrustSrc = a.createBufferSource(); audio.thrustSrc.buffer = audio.noiseBuf; audio.thrustSrc.loop = true; audio.thrustSrc.connect(f); f.connect(audio.thrustGain); audio.thrustGain.connect(audio.sfx); audio.thrustSrc.start(); audio.nextT = a.currentTime + 0.1; audio.timer = setInterval(scheduleMusic, 25); } function tone(freq, dur, type, vol, slideTo, when) { const a = audio.ctx; if (!a || !audio.soundOn) return; const t = when || a.currentTime, o = a.createOscillator(), g = a.createGain(); o.type = type; o.frequency.setValueAtTime(freq, t); if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t + dur); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.connect(g); g.connect(audio.sfx); o.start(t); o.stop(t + dur + 0.02); } function noise(dur, vol, cutoff, endCutoff) { const a = audio.ctx; if (!a || !audio.soundOn) return; const t = a.currentTime, src = a.createBufferSource(), f = a.createBiquadFilter(), g = a.createGain(); src.buffer = audio.noiseBuf; f.type = 'lowpass'; f.frequency.setValueAtTime(cutoff, t); f.frequency.exponentialRampToValueAtTime(endCutoff || 60, t + dur); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); src.connect(f); f.connect(g); g.connect(audio.sfx); src.start(t, Math.random()*0.5); src.stop(t + dur + 0.02); } const sfx = { shoot() { tone(880, 0.09, 'square', 0.18, 220); }, explode(size) { noise(0.25 + size*0.18, 0.3 + size*0.12, 900 + size*900, 50); tone(140 / size, 0.2 + size*0.12, 'triangle', 0.25, 30); }, death() { noise(1.1, 0.6, 2400, 40); tone(320, 0.9, 'sawtooth', 0.22, 40); }, levelUp() { const a = audio.ctx; if (!a) return; [523, 659, 784, 1047].forEach((f, i) => tone(f, 0.14, 'square', 0.16, 0, a.currentTime + i*0.09)); }, extraLife() { const a = audio.ctx; if (!a) return; [784, 988, 1175, 1568, 1175, 1568].forEach((f, i) => tone(f, 0.1, 'triangle', 0.22, 0, a.currentTime + i*0.07)); }, thrust(on) { if (!audio.thrustGain) return; const t = audio.ctx.currentTime; audio.thrustGain.gain.setTargetAtTime(on && audio.soundOn ? 0.35 : 0, t, 0.04); }, }; // Chiptune loop: 140 BPM, 16th-note steps, bass + arpeggio lead over Am F C G. const BASS = [45, 45, 57, 45, 41, 41, 53, 41, 48, 48, 60, 48, 43, 43, 55, 43]; const CHORDS = [[69, 72, 76], [65, 69, 72], [67, 72, 76], [67, 71, 74]]; const LEAD = [0, 1, 2, 1, 2, 1, 0, 2]; const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12); function musicNote(freq, t, dur, type, vol) { const a = audio.ctx, o = a.createOscillator(), g = a.createGain(); o.type = type; o.frequency.value = freq; g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.connect(g); g.connect(audio.music); o.start(t); o.stop(t + dur + 0.02); } function scheduleMusic() { const a = audio.ctx; if (!a) return; const stepDur = 60 / 140 / 4; if (audio.nextT < a.currentTime - 0.2) audio.nextT = a.currentTime + 0.05; while (audio.nextT < a.currentTime + 0.12) { if (audio.musicOn) { const s = audio.step % 64, bar = Math.floor(s / 16), i = s % 16, t = audio.nextT; if (i % 2 === 0) musicNote(mtof(BASS[bar*4 + (i/4|0)] - 12), t, stepDur*1.8, 'triangle', 0.9); const chord = CHORDS[bar]; musicNote(mtof(chord[LEAD[i % 8]] + (i >= 8 ? 12 : 0)), t, stepDur*0.9, 'square', 0.22); if (i % 4 === 2) { const src = a.createBufferSource(), g = a.createGain(), f = a.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 6000; src.buffer = audio.noiseBuf; g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.05); src.connect(f); f.connect(g); g.connect(audio.music); src.start(t); src.stop(t + 0.06); } } audio.step++; audio.nextT += stepDur; } } function syncAudioButtons() { const sb = $('sndBtn'), mb = $('musBtn'); sb.textContent = 'SOUND ' + (audio.soundOn ? 'ON' : 'OFF'); sb.setAttribute('aria-pressed', String(audio.soundOn)); mb.textContent = 'MUSIC ' + (audio.musicOn ? 'ON' : 'OFF'); mb.setAttribute('aria-pressed', String(audio.musicOn)); } function setSound(on) { audio.soundOn = on; writePref(SND_KEY, on); if (audio.sfx) audio.sfx.gain.setTargetAtTime(on ? 0.55 : 0, audio.ctx.currentTime, 0.02); if (!on) sfx.thrust(false); syncAudioButtons(); } function setMusic(on) { audio.musicOn = on; writePref(MUS_KEY, on); if (audio.music) audio.music.gain.setTargetAtTime(on ? 0.16 : 0, audio.ctx.currentTime, 0.05); syncAudioButtons(); } function bindAudioButton(id, toggle) { const el = $(id); el.addEventListener('pointerdown', (e) => e.stopPropagation()); el.addEventListener('click', (e) => { e.preventDefault(); audioInit(); toggle(); el.blur(); }); } bindAudioButton('sndBtn', () => setSound(!audio.soundOn)); bindAudioButton('musBtn', () => setMusic(!audio.musicOn)); syncAudioButtons(); addEventListener('pointerdown', audioInit, { capture: true }); addEventListener('keydown', audioInit, { capture: true }); let hi = 0; try { hi = Number(localStorage.getItem('kody-asteroids-hi')) || 0 } catch {} let ship, rocks, bullets, parts, score, lives, level, state = 'title', fireCd = 0, respawnT = 0, nextLife = 10000; const EXTRA_LIFE_EVERY = 10000; const wrap = (o) => { if (o.x < 0) o.x += W; if (o.x > W) o.x -= W; if (o.y < 0) o.y += H; if (o.y > H) o.y -= H; }; function newShip() { return { x: W/2, y: H/2, vx: 0, vy: 0, a: -Math.PI/2, r: 12, inv: 2.5, thrust: false }; } function rock(x, y, size, speedMul) { const n = 9 + (Math.random()*4|0), shape = []; for (let i = 0; i < n; i++) shape.push(0.72 + Math.random()*0.38); const a = Math.random()*TAU, s = (20 + Math.random()*40) * (4 - size) * 0.5 * speedMul + 15; return { x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s, size, r: SIZES[size], shape, rot: 0, vr: (Math.random()-.5)*1.5 }; } function startLevel() { rocks = []; const n = 3 + level, mul = 1 + (level-1)*0.15; for (let i = 0; i < n; i++) { let x, y; do { x = Math.random()*W; y = Math.random()*H; } while (Math.hypot(x-ship.x, y-ship.y) < 160); rocks.push(rock(x, y, 3, mul)); } ship.inv = Math.max(ship.inv, 1.5); } function newGame() { score = 0; lives = 3; level = 1; nextLife = EXTRA_LIFE_EVERY; bullets = []; parts = []; ship = newShip(); startLevel(); state = 'play'; hud(); } function hud() { $('score').textContent = String(score); $('hi').textContent = String(hi); $('lives').textContent = String(lives); $('level').textContent = String(level); const m = $('msg'); m.style.visibility = state === 'play' ? 'hidden' : 'visible'; $('msgTitle').textContent = state === 'over' ? 'GAME OVER' : 'ASTEROIDS'; $('msgSub').textContent = state === 'over' ? 'Score ' + score + ' — press Space or tap to restart' : 'Press Space or tap to start'; } function boom(x, y, n) { for (let i = 0; i < n; i++) { const a = Math.random()*TAU, s = 40 + Math.random()*160; parts.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s, t: 0.4 + Math.random()*0.5 }); } } function fire() { if (fireCd > 0 || bullets.length >= 8) return; fireCd = 0.18; sfx.shoot(); bullets.push({ x: ship.x + Math.cos(ship.a)*ship.r, y: ship.y + Math.sin(ship.a)*ship.r, vx: ship.vx + Math.cos(ship.a)*520, vy: ship.vy + Math.sin(ship.a)*520, t: 0.9 }); } function saveHi() { if (score > hi) { hi = score; try { localStorage.setItem('kody-asteroids-hi', String(hi)) } catch {} } } function hitShip() { boom(ship.x, ship.y, 30); sfx.death(); sfx.thrust(false); lives--; saveHi(); if (lives <= 0) { state = 'over'; ship = null; } else { ship = null; respawnT = 1.2; } hud(); } function update(dt) { for (const p of parts) { p.x += p.vx*dt; p.y += p.vy*dt; p.t -= dt; } parts = parts.filter(p => p.t > 0); if (state !== 'play') { if (ship && ship.thrust) { ship.thrust = false; sfx.thrust(false); } for (const r of rocks || []) { r.x += r.vx*dt; r.y += r.vy*dt; r.rot += r.vr*dt; wrap(r); } return; } if (!ship) { respawnT -= dt; if (respawnT <= 0) { ship = newShip(); ship.inv = 3; } } const L = keys.ArrowLeft || keys.KeyA, R = keys.ArrowRight || keys.KeyD; const T = keys.ArrowUp || keys.KeyW, F = keys.Space || touch.fire; fireCd -= dt; if (ship) { if (L) ship.a -= 4.2*dt; if (R) ship.a += 4.2*dt; // Stick: turn toward its direction at the normal turn rate; thrust scales with deflection past the deadzone. let power = T ? 1 : 0; if (stick.id !== null && stick.mag > STEER_DEAD) { let diff = Math.atan2(stick.dy, stick.dx) - ship.a; diff = Math.atan2(Math.sin(diff), Math.cos(diff)); const step = 4.2*dt; ship.a += Math.abs(diff) <= step ? diff : Math.sign(diff)*step; if (stick.mag > THRUST_DEAD) power = Math.max(power, Math.min(1, (stick.mag - THRUST_DEAD) / (1 - THRUST_DEAD))); } const thr = power > 0; if (ship.thrust !== thr) sfx.thrust(thr); ship.thrust = thr; if (thr) { ship.vx += Math.cos(ship.a)*260*power*dt; ship.vy += Math.sin(ship.a)*260*power*dt; } const drag = Math.pow(0.55, dt); ship.vx *= drag; ship.vy *= drag; const sp = Math.hypot(ship.vx, ship.vy); if (sp > 420) { ship.vx *= 420/sp; ship.vy *= 420/sp; } ship.x += ship.vx*dt; ship.y += ship.vy*dt; wrap(ship); ship.inv -= dt; if (F) fire(); } for (const b of bullets) { b.x += b.vx*dt; b.y += b.vy*dt; b.t -= dt; wrap(b); } bullets = bullets.filter(b => b.t > 0); for (const r of rocks) { r.x += r.vx*dt; r.y += r.vy*dt; r.rot += r.vr*dt; wrap(r); } const born = [], mul = 1 + (level-1)*0.15; for (const r of rocks) { for (const b of bullets) if (b.t > 0 && Math.hypot(b.x-r.x, b.y-r.y) < r.r) { b.t = 0; r.dead = true; score += PTS[r.size]; boom(r.x, r.y, 8 + r.size*4); sfx.explode(r.size); if (r.size > 1) { born.push(rock(r.x, r.y, r.size-1, mul), rock(r.x, r.y, r.size-1, mul)); } break; } if (!r.dead && ship && ship.inv <= 0 && Math.hypot(ship.x-r.x, ship.y-r.y) < r.r + ship.r*0.8) { r.dead = true; sfx.explode(r.size); if (r.size > 1) born.push(rock(r.x, r.y, r.size-1, mul), rock(r.x, r.y, r.size-1, mul)); hitShip(); } } bullets = bullets.filter(b => b.t > 0); rocks = rocks.filter(r => !r.dead).concat(born); if (score >= nextLife) { lives++; nextLife += EXTRA_LIFE_EVERY; sfx.extraLife(); } if (state === 'play' && rocks.length === 0) { level++; sfx.levelUp(); if (!ship) { ship = newShip(); } startLevel(); } hud(); } function draw() { ctx.fillStyle = '#05070d'; ctx.fillRect(0,0,W,H); ctx.strokeStyle = '#e8f1ff'; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.shadowColor = '#7fb4ff'; ctx.shadowBlur = 6; for (const r of rocks || []) { ctx.beginPath(); r.shape.forEach((k, i) => { const a = r.rot + i/r.shape.length*TAU, x = r.x + Math.cos(a)*r.r*k, y = r.y + Math.sin(a)*r.r*k; i ? ctx.lineTo(x,y) : ctx.moveTo(x,y); }); ctx.closePath(); ctx.stroke(); } ctx.fillStyle = '#e8f1ff'; for (const b of bullets || []) { ctx.beginPath(); ctx.arc(b.x, b.y, 2, 0, TAU); ctx.fill(); } for (const p of parts || []) { ctx.globalAlpha = Math.min(1, p.t*2); ctx.fillRect(p.x, p.y, 2, 2); } ctx.globalAlpha = 1; if (ship && state === 'play' && !(ship.inv > 0 && Math.floor(ship.inv*10) % 2)) { ctx.save(); ctx.translate(ship.x, ship.y); ctx.rotate(ship.a); ctx.beginPath(); ctx.moveTo(16,0); ctx.lineTo(-11,-10); ctx.lineTo(-6,0); ctx.lineTo(-11,10); ctx.closePath(); ctx.stroke(); if (ship.thrust && Math.random() > 0.2) { ctx.beginPath(); ctx.moveTo(-8,-5); ctx.lineTo(-18 - Math.random()*6, 0); ctx.lineTo(-8,5); ctx.stroke(); } ctx.restore(); } ctx.shadowBlur = 0; } let last = performance.now(); function loop(now) { const dt = Math.min(0.05, (now - last)/1000); last = now; update(dt); draw(); requestAnimationFrame(loop); } function startOrRestart() { if (state !== 'play') newGame(); } addEventListener('keydown', (e) => { if (!e.repeat && !e.metaKey && !e.ctrlKey && !e.altKey) { if (e.code === 'KeyM') { setMusic(!audio.musicOn); return; } if (e.code === 'KeyS') { setSound(!audio.soundOn); return; } } if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault(); if (e.code === 'Space' || e.code === 'Enter') { if (state !== 'play') { startOrRestart(); return; } } keys[e.code] = true; }); addEventListener('keyup', (e) => { keys[e.code] = false; }); addEventListener('blur', () => { for (const k in keys) keys[k] = false; sfx.thrust(false); if (ship) ship.thrust = false; }); // Touch controls: each finger is tracked by pointerId, so steering and FIRE work at the same time. const zone = $('stickZone'), base = $('stickBase'), knob = $('stickKnob'), fireBtn = $('fireBtn'); function stickShow(on) { base.style.visibility = knob.style.visibility = on ? 'visible' : 'hidden'; } function stickMove(x, y) { let dx = x - stick.ox, dy = y - stick.oy; const d = Math.hypot(dx, dy); if (d > STICK_R) { dx *= STICK_R/d; dy *= STICK_R/d; } stick.dx = dx / STICK_R; stick.dy = dy / STICK_R; stick.mag = Math.min(1, d / STICK_R); knob.style.transform = 'translate(' + (stick.ox + dx) + 'px,' + (stick.oy + dy) + 'px)'; } function stickEnd(e) { if (e.pointerId !== stick.id) return; stick.id = null; stick.dx = stick.dy = stick.mag = 0; stickShow(false); } zone.addEventListener('pointerdown', (e) => { e.preventDefault(); if (state !== 'play') { startOrRestart(); } if (stick.id !== null) return; stick.id = e.pointerId; try { zone.setPointerCapture(e.pointerId) } catch {} stick.ox = e.clientX; stick.oy = e.clientY; base.style.transform = 'translate(' + e.clientX + 'px,' + e.clientY + 'px)'; stickMove(e.clientX, e.clientY); stickShow(true); }); zone.addEventListener('pointermove', (e) => { if (e.pointerId === stick.id) { e.preventDefault(); stickMove(e.clientX, e.clientY); } }); zone.addEventListener('pointerup', stickEnd); zone.addEventListener('pointercancel', stickEnd); zone.addEventListener('lostpointercapture', stickEnd); let fireId = null; fireBtn.addEventListener('pointerdown', (e) => { e.preventDefault(); if (state !== 'play') { startOrRestart(); return; } fireId = e.pointerId; try { fireBtn.setPointerCapture(e.pointerId) } catch {} touch.fire = true; fireBtn.classList.add('on'); }); const fireEnd = (e) => { if (e.pointerId !== fireId) return; fireId = null; touch.fire = false; fireBtn.classList.remove('on'); }; fireBtn.addEventListener('pointerup', fireEnd); fireBtn.addEventListener('pointercancel', fireEnd); fireBtn.addEventListener('lostpointercapture', fireEnd); // No pinch/double-tap zoom or page scroll (iOS ignores user-scalable=no). ['gesturestart', 'gesturechange', 'dblclick'].forEach((t) => document.addEventListener(t, (e) => e.preventDefault(), { passive: false })); document.addEventListener('touchmove', (e) => { if (e.touches.length > 1 || e.target.closest('#stickZone,#fireBtn,canvas')) e.preventDefault(); }, { passive: false }); cv.addEventListener('pointerdown', () => startOrRestart()); const coarse = matchMedia('(pointer: coarse)'); const setTouch = () => document.body.classList.toggle('touch', coarse.matches || navigator.maxTouchPoints > 0 && !matchMedia('(hover: hover)').matches); setTouch(); coarse.addEventListener && coarse.addEventListener('change', setTouch); addEventListener('pointerdown', (e) => { if (e.pointerType === 'touch' && !document.body.classList.contains('touch')) document.body.classList.add('touch'); }, { capture: true }); ship = newShip(); level = 1; score = 0; lives = 3; bullets = []; parts = []; startLevel(); hud(); window.__asteroids = { get state() { return state }, get rocks() { return rocks.length }, newGame, get stick() { return { active: stick.id !== null, dx: stick.dx, dy: stick.dy, mag: stick.mag } }, get fire() { return touch.fire }, get ship() { return ship ? { a: ship.a, vx: ship.vx, vy: ship.vy, thrust: ship.thrust } : null }, get bullets() { return bullets.length }, get audio() { return { ctx: audio.ctx ? audio.ctx.state : null, soundOn: audio.soundOn, musicOn: audio.musicOn, step: audio.step } } }; requestAnimationFrame(loop); })(); ` export function renderGame(ctx: PageCtx = { origin: 'https://asteroids.kentcdodds.com', appBase: '', assetBase: '/a' }) { return ` Asteroids ${headTags(ctx, 'Asteroids')}
SCORE 0
HI 0
LIVES 3
LEVEL 1
ABOUT
ASTEROIDS
Press Space or tap to start
←/→ or A/D rotate · ↑ or W thrust · Space fire · S sound · M music
` } export const html = renderGame()