<!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">__DOTZUKI_STR_LOADING__</div>
<canvas id="screen" width="320" height="240" hidden></canvas>
<div id="hint">__DOTZUKI_STR_HINT__</div>
<script type="module">
const SAVE_KEY = __DOTZUKI_SAVE_KEY__
const STR = {
loadingRuntime: __DOTZUKI_STR_LOADING_RUNTIME__,
downloading: __DOTZUKI_STR_DOWNLOADING__,
starting: __DOTZUKI_STR_STARTING__,
failedPrefix: __DOTZUKI_STR_FAILED_PREFIX__,
}
const statusEl = document.getElementById('status')
const canvas = document.getElementById('screen')
const say = (t) => { statusEl.textContent = t }
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() } })
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
}
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 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)
}
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(STR.loadingRuntime)
const mod = await import('./wasm/dotzuki_runner_web.js')
await mod.default()
say(STR.downloading)
const res = await fetch('./game.dzpk')
if (!res.ok) throw new Error('HTTP ' + res.status)
const pack = await res.arrayBuffer()
say(STR.starting)
const runner = mod.WasmRunner.fromPack(new Uint8Array(pack), 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(STR.failedPrefix + ((e && e.message) || String(e)))
}
</script>
</body>
</html>