Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kody/doomPublic

src/app/client.ts

2 lines · 17.1 KB · TypeScript
/** Browser client bundle for KODOOM. */
export const appClientJs = "(() => {\n  const BOOT = Object.assign({\n    engineBase: '',\n    freedoomZipUrl: '',\n    sharewareWadUrl: '',\n    cfgUrl: '',\n    fflateUrl: ''\n  }, window.__KODOOM__ || {});\n  const base = (BOOT.appBasePath || \"\").replace(/\\/$/, \"\");\n  const asset = (p) => base + (p.startsWith(\"/\") ? p : \"/\" + p);\n  // Origin-absolute URL so relative resolution never walks up from /packages/doom (no trailing slash).\n  const absoluteAsset = (p) => new URL(asset(p), window.location.origin).href;\n  // Trailing-slash soft-nav: /packages/doom -> /packages/doom/ so relative assets stay under the app.\n  try {\n    var pn = location.pathname;\n    if (pn.length > 1 && !pn.endsWith('/') && /\\/packages\\/[^/]+$/.test(pn)) {\n      history.replaceState(null, '', pn + '/' + location.search + location.hash);\n    }\n  } catch (e) {}\n  const WASM_URL = absoluteAsset('/websockets-doom.wasm');\n  const ENGINE_JS_URL = BOOT.engineBase + '/websockets-doom.js';\n  // asset URLs come from window.__KODOOM__ (BOOT)\n  const state = { phase: \"idle\", iwad: \"freedoom\", iwadFile: \"freedoom1.wad\", progress: \"\", error: null, started: false, paused: false, module: null, webmcp: \"unknown\", engineJsLoading: null, engineJsLoaded: false };\n  const els = {\n    canvas: document.getElementById(\"canvas\"),\n    overlay: document.getElementById(\"overlay\"),\n    overlayTitle: document.getElementById(\"overlay-title\"),\n    overlayBody: document.getElementById(\"overlay-body\"),\n    status: document.getElementById(\"status\"),\n    badge: document.getElementById(\"webmcp-badge\"),\n    offline: document.getElementById(\"offline-note\"),\n    helpDialog: document.getElementById(\"help-dialog\"),\n    iwadSelect: document.getElementById(\"iwad-select\"),\n  };\n\n  const ACTION_KEYS = { up:38, down:40, left:37, right:39, fire:17, use:32, strafe_left:44, strafe_right:46, run:16, escape:27, enter:13, y:89, n:78, tab:9 };\n  function setStatus(extra) {\n    const lines = [\"phase: \"+state.phase, \"iwad: \"+state.iwad+\" (\"+state.iwadFile+\")\", \"webmcp: \"+state.webmcp];\n    if (state.progress) lines.push(\"progress: \"+state.progress);\n    if (state.error) lines.push(\"error: \"+state.error);\n    if (extra) lines.push(extra);\n    els.status.textContent = lines.join(\"\\n\");\n  }\n  function showOverlay(title, body, show) {\n    if (show === undefined) show = true;\n    els.overlay.classList.toggle(\"hidden\", !show);\n    if (title != null) els.overlayTitle.textContent = title;\n    if (body != null) els.overlayBody.textContent = body;\n  }\n  function updateOnlineUI() {\n    const offline = !navigator.onLine;\n    els.offline.classList.toggle(\"show\", offline);\n    if (offline && state.phase !== \"running\") {\n      showOverlay(\"Offline shell\", \"KODOOM UI is cached. Engine/IWAD need network the first time (or until cached). Reconnect, then press Start.\", true);\n    }\n  }\n  function loadExternal(src) {\n    return new Promise(function(resolve, reject) {\n      var el = document.createElement(String.fromCharCode(115,99,114,105,112,116));\n      el.src = src; el.async = true; el.onload = resolve;\n      el.onerror = function() { reject(new Error('Failed to load ' + src)); };\n      document.head.appendChild(el);\n    });\n  }\n  // Upstream websockets-doom.js declares class HandleAllocator at top level — loading it twice throws.\n  function loadEngineJsOnce() {\n    if (state.engineJsLoaded) {\n      return Promise.reject(new Error('Engine JS already loaded. Reload the page to retry Start.'));\n    }\n    if (state.engineJsLoading) return state.engineJsLoading;\n    state.engineJsLoading = loadExternal(ENGINE_JS_URL).then(function() {\n      state.engineJsLoaded = true;\n      state.engineJsLoading = null;\n    }, function(err) {\n      state.engineJsLoading = null;\n      throw err;\n    });\n    return state.engineJsLoading;\n  }\n  async function ensureFflate() {\n    if (window.fflate) return window.fflate;\n    await loadExternal(BOOT.fflateUrl);\n    return window.fflate;\n  }\n  async function fetchBytes(url, label) {\n    state.progress = 'fetching ' + label; setStatus();\n    var res;\n    try {\n      res = await fetch(url);\n    } catch (err) {\n      throw new Error('Network error fetching ' + label + ' from ' + url + ': ' + ((err && err.message) || String(err)));\n    }\n    if (!res.ok) throw new Error('HTTP ' + res.status + ' fetching ' + label + ' from ' + url);\n    var buf = new Uint8Array(await res.arrayBuffer());\n    state.progress = 'fetched ' + label + ' (' + buf.byteLength + ' bytes)'; setStatus();\n    return buf;\n  }\n  async function loadIwadBytes() {\n    if (state.iwad === 'shareware') {\n      state.iwadFile = 'doom1.wad';\n      return fetchBytes(BOOT.sharewareWadUrl, 'doom1.wad');\n    }\n    state.iwadFile = 'freedoom1.wad';\n    var zipBytes = await fetchBytes(BOOT.freedoomZipUrl, 'Freedoom zip');\n    var zz = await ensureFflate();\n    state.progress = 'extracting freedoom1.wad'; setStatus();\n    var files = zz.unzipSync(zipBytes);\n    var key = Object.keys(files).find(function(k) { return k.toLowerCase().endsWith('freedoom1.wad'); });\n    if (!key) throw new Error('freedoom1.wad missing');\n    return files[key];\n  }\n  function dispatchKey(code, type) {\n    var ev = new KeyboardEvent(type || 'keydown', { keyCode: code, which: code, bubbles: true, cancelable: true, view: window });\n    els.canvas.dispatchEvent(ev); document.dispatchEvent(ev);\n  }\n  function pulseKey(code, ms) {\n    dispatchKey(code, 'keydown');\n    setTimeout(function() { dispatchKey(code, 'keyup'); }, ms || 80);\n  }\n  function getControls() {\n    return { move: 'Arrow keys', fire: 'Ctrl', use: 'Space', strafe: ', and .', run: 'Shift', menu: 'Esc', map: 'Tab', note: 'Click canvas for focus' };\n  }\n  function getStatus() {\n    return { phase: state.phase, iwad: state.iwad, iwadFile: state.iwadFile, started: state.started, paused: state.paused, webmcp: state.webmcp, progress: state.progress, error: state.error, online: navigator.onLine, controls: getControls(), wasmUrl: WASM_URL };\n  }\n  function pauseOrResume() {\n    if (!state.started) return { ok: false, error: 'Game not started' };\n    pulseKey(ACTION_KEYS.escape);\n    state.paused = !state.paused;\n    state.phase = state.paused ? 'paused' : 'running';\n    setStatus(); return { ok: true, paused: state.paused };\n  }\n  async function toggleFullscreen() {\n    var root = document.getElementById('stage') || els.canvas;\n    if (!document.fullscreenElement) { await root.requestFullscreen(); return { ok: true, fullscreen: true }; }\n    await document.exitFullscreen(); return { ok: true, fullscreen: false };\n  }\n  function sendInput(input) {\n    input = input || {};\n    if (!state.started) return { ok: false, error: 'Game not started' };\n    els.canvas.focus();\n    var code = null;\n    if (input.action && ACTION_KEYS[input.action] != null) code = ACTION_KEYS[input.action];\n    if (input.key != null && input.key !== '') code = Number(input.key);\n    if (code == null || isNaN(code)) return { ok: false, error: 'Provide action or key' };\n    pulseKey(code, input.durationMs ? Number(input.durationMs) : 80);\n    return { ok: true, keyCode: code, action: input.action || null };\n  }\n  function openHelp() {\n    if (els.helpDialog && els.helpDialog.showModal) els.helpDialog.showModal();\n    else alert('Help');\n    return { ok: true };\n  }\n  async function startGame(opts) {\n    opts = opts || {};\n    if (state.started && state.phase === 'running') return { ok: true, message: 'already running' };\n    if (state.engineJsLoaded && state.phase === 'error') {\n      var msg = 'Engine JS already injected. Reload the page to retry Start (avoids HandleAllocator redeclare).';\n      state.error = msg;\n      showOverlay('Reload required', msg);\n      setStatus();\n      return { ok: false, error: msg };\n    }\n    if (opts.iwad === 'shareware' || opts.iwad === 'freedoom') {\n      state.iwad = opts.iwad;\n      if (els.iwadSelect) els.iwadSelect.value = state.iwad;\n    }\n    state.phase = 'loading'; state.error = null;\n    showOverlay('Loading hellgate', 'Fetching engine + IWAD. First Freedoom run downloads a zip.');\n    setStatus();\n    try {\n      var iwadBytes = await loadIwadBytes();\n      var cfgBytes = await fetchBytes(BOOT.cfgUrl, 'default.cfg');\n      // ALWAYS prefetch absolute same-origin WASM BEFORE engine JS.\n      // Upstream findWasmBinary() returns bare \"websockets-doom.wasm\" and does NOT call locateFile\n      // (locateFile call is commented out). With page at /packages/doom (no trailing slash),\n      // relative resolution becomes /packages/websockets-doom.wasm (404). Prefetch + instantiateWasm\n      // prevent that relative fetch entirely.\n      var wasmBytes = await fetchBytes(WASM_URL, 'websockets-doom.wasm');\n      var commonArgs = ['-iwad', state.iwadFile, '-window', '-nogui', '-nomusic', '-config', 'default.cfg', '-servername', 'kodoom'];\n      if (opts.episode && opts.map) commonArgs.push('-warp', String(opts.episode), String(opts.map));\n      await new Promise(function(resolve, reject) {\n        var Module = {\n          // Let Emscripten run() call local callMain(args) — glue never assigns Module.callMain.\n          arguments: commonArgs,\n          canvas: els.canvas,\n          wasmBinary: wasmBytes,\n          locateFile: function(path) {\n            var p = String(path);\n            if (p.endsWith('.wasm') || p === 'websockets-doom.wasm' || p.endsWith('/websockets-doom.wasm')) {\n              return WASM_URL;\n            }\n            return BOOT.engineBase + '/' + path;\n          },\n          // Hard bypass: never let instantiateAsync fetch(binaryFile) with the bare relative name.\n          // Async shape per Emscripten docs: call successCallback then return {}.\n          instantiateWasm: function(imports, successCallback) {\n            WebAssembly.instantiate(wasmBytes, imports).then(function(output) {\n              successCallback(output.instance, output.module);\n            });\n            return {}; // async\n          },\n          preRun: [function() {\n            var fs = Module.FS || (typeof FS !== 'undefined' ? FS : null);\n            if (!fs) throw new Error('Emscripten FS not available in preRun');\n            fs.writeFile(state.iwadFile, iwadBytes);\n            fs.writeFile('default.cfg', cfgBytes);\n          }],\n          onRuntimeInitialized: function() {\n            // callMain already invoked by run() via Module.arguments — do not call Module.callMain.\n            state.module = Module; state.started = true; state.phase = 'running'; state.paused = false;\n            showOverlay('', '', false); els.canvas.focus(); setStatus('engine ready'); resolve();\n          },\n          print: function(t) { console.log(t); },\n          printErr: function(t) { console.error(t); },\n          setStatus: function(t) { if (t) { state.progress = String(t); setStatus(); } }\n        };\n        window.Module = Module;\n        loadEngineJsOnce().catch(reject);\n      });\n      return { ok: true, iwad: state.iwad, file: state.iwadFile, wasmUrl: WASM_URL };\n    } catch (e) {\n      state.phase = 'error'; state.error = (e && e.message) || String(e);\n      showOverlay('Failed to start', state.error); setStatus();\n      return { ok: false, error: state.error };\n    }\n  }\n  function selectEpisodeOrMap(input) {\n    input = input || {};\n    var episode = Number(input.episode || 1); var map = Number(input.map || 1);\n    if (state.started) return { ok: false, error: 'Warp before start only. Reload page then start with episode/map.' };\n    return startGame({ iwad: state.iwad, episode: episode, map: map });\n  }\n  async function invokeTool(name, input) {\n    input = input || {};\n    if (name === 'get_status') return getStatus();\n    if (name === 'start_game') return startGame({ iwad: input.iwad || state.iwad, episode: input.episode ? Number(input.episode) : undefined, map: input.map ? Number(input.map) : undefined });\n    if (name === 'pause_or_resume') return pauseOrResume();\n    if (name === 'toggle_fullscreen') return toggleFullscreen();\n    if (name === 'send_input') return sendInput(input);\n    if (name === 'select_episode_or_map') return selectEpisodeOrMap(input);\n    if (name === 'get_controls') return getControls();\n    if (name === 'open_help') return openHelp();\n    return { ok: false, error: 'Unknown tool ' + name };\n  }\n  document.getElementById('btn-start') && document.getElementById('btn-start').addEventListener('click', function() {\n    state.iwad = (els.iwadSelect && els.iwadSelect.value) || 'freedoom';\n    startGame({ iwad: state.iwad });\n  });\n  document.getElementById('btn-pause') && document.getElementById('btn-pause').addEventListener('click', function() { pauseOrResume(); });\n  document.getElementById('btn-fullscreen') && document.getElementById('btn-fullscreen').addEventListener('click', function() { toggleFullscreen(); });\n  document.getElementById('btn-help') && document.getElementById('btn-help').addEventListener('click', function() { openHelp(); });\n  document.querySelectorAll('[data-action]').forEach(function(btn) {\n    btn.addEventListener('click', function() { sendInput({ action: btn.getAttribute('data-action') }); });\n  });\n  document.getElementById('btn-warp') && document.getElementById('btn-warp').addEventListener('click', function() {\n    selectEpisodeOrMap({ episode: Number(document.getElementById('warp-ep').value || 1), map: Number(document.getElementById('warp-map').value || 1) });\n  });\n  document.querySelectorAll('form[toolname]').forEach(function(form) {\n    form.addEventListener('submit', async function(ev) {\n      ev.preventDefault();\n      var name = form.getAttribute('toolname');\n      var fd = new FormData(form);\n      var input = Object.fromEntries(fd.entries());\n      var result = await invokeTool(name, input);\n      setStatus('last tool ' + name + ': ' + JSON.stringify(result));\n    });\n  });\n  async function registerWebmcp() {\n    var ctx = document.modelContext || navigator.modelContext;\n    if (!ctx || typeof ctx.registerTool !== 'function') {\n      state.webmcp = 'unavailable';\n      els.badge.textContent = 'WebMCP unavailable (enable chrome://flags/#enable-webmcp-testing)';\n      els.badge.className = 'badge missing';\n      setStatus(); return;\n    }\n    state.webmcp = 'ready';\n    els.badge.textContent = 'WebMCP ready';\n    els.badge.className = 'badge ready';\n    var actionEnum = Object.keys(ACTION_KEYS);\n    var tools = [\n      { name: 'get_status', description: 'Get KODOOM status, IWAD, and WebMCP availability.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true }, execute: async function() { return getStatus(); } },\n      { name: 'start_game', description: 'Start Freedoom or shareware on Chocolate Doom WASM (same as Start).', inputSchema: { type: 'object', properties: { iwad: { type: 'string', enum: ['freedoom', 'shareware'] }, episode: { type: 'number' }, map: { type: 'number' } } }, execute: async function(input) { return startGame(input || {}); } },\n      { name: 'pause_or_resume', description: 'Toggle menu/pause via Escape (same as Pause).', inputSchema: { type: 'object', properties: {} }, execute: async function() { return pauseOrResume(); } },\n      { name: 'toggle_fullscreen', description: 'Toggle fullscreen for the game stage.', inputSchema: { type: 'object', properties: {} }, execute: async function() { return toggleFullscreen(); } },\n      { name: 'send_input', description: 'Send a short key press to the live game canvas.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: actionEnum }, key: { type: 'number' }, durationMs: { type: 'number' } } }, execute: async function(input) { return sendInput(input || {}); } },\n      { name: 'select_episode_or_map', description: 'Start with -warp episode/map before the engine is running.', inputSchema: { type: 'object', properties: { episode: { type: 'number' }, map: { type: 'number' } }, required: ['episode', 'map'] }, execute: async function(input) { return selectEpisodeOrMap(input || {}); } },\n      { name: 'get_controls', description: 'List keyboard controls.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true }, execute: async function() { return getControls(); } },\n      { name: 'open_help', description: 'Open the in-page help dialog.', inputSchema: { type: 'object', properties: {} }, execute: async function() { return openHelp(); } }\n    ];\n    for (var i = 0; i < tools.length; i++) {\n      try { await ctx.registerTool(tools[i]); } catch (e) { console.warn('registerTool failed', tools[i].name, e); }\n    }\n    setStatus();\n  }\n  window.KODOOM = { getStatus: getStatus, startGame: startGame, pauseOrResume: pauseOrResume, toggleFullscreen: toggleFullscreen, sendInput: sendInput, selectEpisodeOrMap: selectEpisodeOrMap, getControls: getControls, openHelp: openHelp, invokeTool: invokeTool };\n  if ('serviceWorker' in navigator) {\n    navigator.serviceWorker.register(asset('/sw.js')).catch(function(e) { console.warn('SW', e); });\n  }\n  window.addEventListener('online', updateOnlineUI);\n  window.addEventListener('offline', updateOnlineUI);\n  updateOnlineUI(); setStatus(); registerWebmcp();\n})();\n"