dotzuki-cli 0.5.6

dotzuki CLI: scaffold (dotzuki new), compile-check (dotzuki check) and play (dotzuki run) zero-Rust JRPG projects
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__DOTZUKI_TITLE__</title>
<style>
  html,body{margin:0;height:100%;background:#14161a;color:#e8e8e8;font-family:system-ui,-apple-system,sans-serif}
  body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px}
  h1{font-size:18px;font-weight:600;margin:0}
  #status{color:#9aa0a6;font-size:14px}
  #hint{color:#5f6368;font-size:12px}
  canvas{image-rendering:pixelated;width:960px;max-width:96vw;background:#000;
         box-shadow:0 8px 40px rgba(0,0,0,.6);border-radius:4px}
</style>
</head>
<body>
<h1>__DOTZUKI_TITLE__</h1>
<div id="status">Loading…</div>
<canvas id="screen" width="320" height="240" hidden></canvas>
<div id="hint">Arrows / WASD — move &nbsp;·&nbsp; Z — A &nbsp;·&nbsp; X — B &nbsp;·&nbsp; Enter — Start &nbsp;·&nbsp; Backspace — Select &nbsp;·&nbsp; M — mute</div>
<script type="module">
const SAVE_KEY = __DOTZUKI_SAVE_KEY__
const statusEl = document.getElementById('status')
const canvas = document.getElementById('screen')
const say = (t) => { statusEl.textContent = t }
// One bit per button, matching dotzuki-runner-web's tick() contract:
// bit0=A bit1=B bit2=Select bit3=Start bit4=Right bit5=Left bit6=Up bit7=Down
const KEY_BITS = { ArrowUp: 64, ArrowDown: 128, ArrowLeft: 32, ArrowRight: 16,
  Enter: 8, Backspace: 4, KeyW: 64, KeyS: 128, KeyA: 32, KeyD: 16,
  KeyZ: 1, KeyX: 2, Space: 8, ShiftRight: 4 }
let input = 0
addEventListener('keydown', (e) => {
  if (e.code === 'KeyM') { muted = !muted; return }
  const b = KEY_BITS[e.key] ?? KEY_BITS[e.code]
  if (b) { input |= b; e.preventDefault() }
})
addEventListener('keyup', (e) => { const b = KEY_BITS[e.key] ?? KEY_BITS[e.code]; if (b) { input &= ~b; e.preventDefault() } })

// ── Audio: WasmRunner.take_audio() yields interleaved stereo f32 @ 44.1 kHz ──
// FIFO queue (~0.5s cap, oldest chunks dropped) between the rAF clock (push)
// and the audio clock (drain). ScriptProcessorNode rather than AudioWorklet:
// a plain static host sends no COOP/COEP headers, so SharedArrayBuffer is
// unavailable. The context resumes on the first user gesture (autoplay policy).
const AUDIO_RATE = 44100
const MAX_QUEUE_FRAMES = 22050
let chunks = [], queueFrames = 0, muted = false

function pushAudio(samples) {
  if (!samples.length) return
  chunks.push(samples)
  queueFrames += samples.length / 2
  while (queueFrames > MAX_QUEUE_FRAMES && chunks.length > 1) {
    queueFrames -= chunks[0].length / 2
    chunks.shift()
  }
}

function drainAudio(frames) {
  const out = new Float32Array(frames * 2)
  let written = 0
  while (written < out.length && chunks.length) {
    const first = chunks[0]
    const n = Math.min(first.length, out.length - written)
    out.set(first.subarray(0, n), written)
    written += n
    if (n === first.length) chunks.shift()
    else chunks[0] = first.subarray(n)
  }
  queueFrames -= written / 2
  return out
}

// Linear interpolation; output length preserves the rate ratio. Good enough
// for game audio and dependency-free.
function resampleLinear(input, inFrames, outFrames) {
  const out = new Float32Array(outFrames * 2)
  if (!inFrames || !outFrames) return out
  if (inFrames < 2) {
    for (let j = 0; j < outFrames; j++) { out[j * 2] = input[0]; out[j * 2 + 1] = input[1] }
    return out
  }
  const step = (inFrames - 1) / (outFrames - 1)
  for (let j = 0; j < outFrames; j++) {
    const p = j * step
    const i = Math.min(Math.floor(p), inFrames - 2)
    const f = p - i
    out[j * 2] = input[i * 2] + (input[(i + 1) * 2] - input[i * 2]) * f
    out[j * 2 + 1] = input[i * 2 + 1] + (input[(i + 1) * 2 + 1] - input[i * 2 + 1]) * f
  }
  return out
}

function initAudio() {
  const actx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: AUDIO_RATE })
  const outRate = actx.sampleRate // browsers may ignore the requested rate
  let inDebt = 0
  const node = actx.createScriptProcessor(4096, 0, 2)
  node.onaudioprocess = (e) => {
    const outL = e.outputBuffer.getChannelData(0)
    const outR = e.outputBuffer.getChannelData(1)
    const outFrames = outL.length
    let data
    if (outRate === AUDIO_RATE) {
      data = drainAudio(outFrames)
    } else {
      inDebt += (outFrames * AUDIO_RATE) / outRate
      const inFrames = Math.floor(inDebt)
      inDebt -= inFrames
      data = resampleLinear(drainAudio(inFrames), inFrames, outFrames)
    }
    // Muted: write silence but keep draining, so a long mute builds no backlog.
    if (muted) { outL.fill(0); outR.fill(0); return }
    for (let i = 0; i < outFrames; i++) { outL[i] = data[i * 2]; outR[i] = data[i * 2 + 1] }
  }
  node.connect(actx.destination)
  const onGesture = () => { if (actx.state === 'suspended') actx.resume() }
  addEventListener('pointerdown', onGesture, { capture: true })
  addEventListener('keydown', onGesture, { capture: true })
}

try {
  say('Loading runtime…')
  const mod = await import('./wasm/dotzuki_runner_web.js')
  await mod.default()
  say('Downloading game…')
  const res = await fetch('./game.bundle.json')
  if (!res.ok) throw new Error('HTTP ' + res.status)
  const bundle = await res.json()
  say('Starting…')
  const runner = new mod.WasmRunner(JSON.stringify(bundle.files), localStorage.getItem(SAVE_KEY))
  const ctx = canvas.getContext('2d')
  const image = ctx.createImageData(runner.width(), runner.height())
  canvas.hidden = false
  statusEl.hidden = true
  initAudio()
  const persist = () => { const s = runner.export_save(); if (s) { try { localStorage.setItem(SAVE_KEY, s) } catch {} } }
  addEventListener('pagehide', persist)
  let lastSave = 0
  const frame = (now) => {
    image.data.set(runner.tick(input))
    ctx.putImageData(image, 0, 0)
    pushAudio(runner.take_audio())
    if (now - lastSave > 5000) { lastSave = now; persist() }
    requestAnimationFrame(frame)
  }
  requestAnimationFrame(frame)
} catch (e) {
  say('Failed to load: ' + ((e && e.message) || String(e)))
}
</script>
</body>
</html>