Skip to content

app/game.ts

268 lines · 22.6 KB · TypeScript
Raw
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 `<!doctype html>
<html lang="en" data-app-base="${esc(ctx.appBase)}" data-build="${esc(BUILD.buildId)}"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,viewport-fit=cover,user-scalable=no">
<title>Asteroids</title>
${headTags(ctx, 'Asteroids')}
<style>
${baseCss}
*{box-sizing:border-box}html,body{margin:0;height:100%;background:#05070d;color:#e8f1ff;font:600 14px/1 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden;touch-action:none;user-select:none;-webkit-user-select:none}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block}
#hud{position:fixed;top:max(6px,env(safe-area-inset-top));left:8px;right:8px;display:grid;grid-template-columns:repeat(4,1fr);gap:4px;pointer-events:none;font-variant-numeric:tabular-nums;letter-spacing:.06em}
#hud div{white-space:nowrap}#hud span{display:inline-block;min-width:6ch}#hud div:nth-child(n+3){text-align:right}#hud div:nth-child(n+3) span{min-width:3ch;text-align:right}
#msg{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;pointer-events:none;text-align:center;padding:16px}
#msgTitle{font-size:clamp(28px,8vw,56px);letter-spacing:.3em}#msgSub{opacity:.75;min-height:1em}
#help{opacity:.5;font-size:12px}body.touch #help{display:none}
#stickZone{position:fixed;left:0;bottom:0;width:55%;top:calc(max(6px,env(safe-area-inset-top)) + 56px);display:none;touch-action:none}
#fireBtn{position:fixed;right:max(16px,env(safe-area-inset-right));bottom:max(20px,calc(env(safe-area-inset-bottom) + 12px));width:92px;height:92px;border-radius:50%;border:2px solid #e8f1ffaa;background:#ffffff08;color:#e8f1ff;font:inherit;font-size:13px;letter-spacing:.18em;display:none;touch-action:none;box-shadow:0 0 14px #7fb4ff55,inset 0 0 12px #7fb4ff33;text-shadow:0 0 6px #7fb4ff}
#fireBtn.on{background:#7fb4ff33;box-shadow:0 0 22px #7fb4ffaa,inset 0 0 18px #7fb4ff66}
body.touch #stickZone,body.touch #fireBtn{display:block}
.stick{position:fixed;left:0;top:0;pointer-events:none;visibility:hidden;border-radius:50%}
#stickBase{width:120px;height:120px;margin:-60px 0 0 -60px;border:2px solid #e8f1ff88;box-shadow:0 0 14px #7fb4ff66,inset 0 0 14px #7fb4ff44}
#stickKnob{width:50px;height:50px;margin:-25px 0 0 -25px;border:2px solid #e8f1ff;background:#e8f1ff14;box-shadow:0 0 12px #7fb4ffaa}
body.touch #hud{font-size:12px;left:max(8px,env(safe-area-inset-left));right:max(8px,env(safe-area-inset-right))}
body.touch #hud span{min-width:4ch}
body.touch #audio{top:calc(max(6px,env(safe-area-inset-top)) + 20px);right:max(8px,env(safe-area-inset-right));gap:4px}
body.touch #audio .btn{font-size:10px;min-width:0;padding:5px 7px}
@media (orientation:landscape) and (max-height:500px){body.touch #hud{right:auto;width:46%;grid-template-columns:repeat(4,auto);gap:12px}body.touch #audio{top:max(4px,env(safe-area-inset-top))}body.touch #stickZone{top:calc(max(4px,env(safe-area-inset-top)) + 34px)}}
#audio{position:fixed;top:calc(max(6px,env(safe-area-inset-top)) + 22px);right:8px;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}
#audio .btn{pointer-events:auto}
</style></head><body>
<canvas id="c"></canvas>
<div id="hud"><div>SCORE <span id="score">0</span></div><div>HI <span id="hi">0</span></div><div>LIVES <span id="lives">3</span></div><div>LEVEL <span id="level">1</span></div></div>
<div id="audio"><button id="installBtn" class="btn hot" type="button" tabindex="-1" title="Install Asteroids as an app" hidden>INSTALL</button><a id="aboutBtn" class="btn" tabindex="-1" href="${esc(ctx.appBase)}/about" title="Version, updates, controls">ABOUT</a><button id="sndBtn" class="btn" type="button" tabindex="-1" title="Toggle sound effects (S)" aria-pressed="true">SOUND ON</button><button id="musBtn" class="btn" type="button" tabindex="-1" title="Toggle music (M)" aria-pressed="true">MUSIC ON</button></div>
<div id="msg"><div id="msgTitle">ASTEROIDS</div><div id="msgSub">Press Space or tap to start</div><div id="help">←/→ or A/D rotate · ↑ or W thrust · Space fire · S sound · M music</div></div>
<div id="stickZone" aria-label="Joystick: touch and drag to steer and thrust"></div><div id="stickBase" class="stick"></div><div id="stickKnob" class="stick"></div><button id="fireBtn" type="button" aria-label="Fire">FIRE</button>
<div id="toast" role="status" hidden>UPDATE AVAILABLE <button class="btn hot" type="button" data-update>UPDATE</button><button class="btn" type="button" data-later>LATER</button></div>
<script>${gameScript}</script>
<script>${pwaScript}</script>
</body></html>`
}

export const html = renderGame()