Skip to content

Kody is live

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

← Public packages

@kody/doomPublic

static/app.js

303 lines · 16.7 KB · JavaScript
(() => {
  const BOOT = Object.assign({
    engineBase: '',
    freedoomZipUrl: '',
    sharewareWadUrl: '',
    cfgUrl: '',
    fflateUrl: ''
  }, window.__KODOOM__ || {});
  const base = (BOOT.appBasePath || "").replace(/\/$/, "");
  const asset = (p) => base + (p.startsWith("/") ? p : "/" + p);
  // Origin-absolute URL so relative resolution never walks up from /packages/doom (no trailing slash).
  const absoluteAsset = (p) => new URL(asset(p), window.location.origin).href;
  // Trailing-slash soft-nav: /packages/doom -> /packages/doom/ so relative assets stay under the app.
  try {
    var pn = location.pathname;
    if (pn.length > 1 && !pn.endsWith('/') && /\/packages\/[^/]+$/.test(pn)) {
      history.replaceState(null, '', pn + '/' + location.search + location.hash);
    }
  } catch (e) {}
  const WASM_URL = absoluteAsset('/websockets-doom.wasm');
  const ENGINE_JS_URL = BOOT.engineBase + '/websockets-doom.js';
  // asset URLs come from window.__KODOOM__ (BOOT)
  const state = { phase: "idle", iwad: "freedoom", iwadFile: "freedoom1.wad", progress: "", error: null, started: false, paused: false, module: null, webmcp: "unknown", engineJsLoading: null, engineJsLoaded: false };
  const els = {
    canvas: document.getElementById("canvas"),
    overlay: document.getElementById("overlay"),
    overlayTitle: document.getElementById("overlay-title"),
    overlayBody: document.getElementById("overlay-body"),
    status: document.getElementById("status"),
    badge: document.getElementById("webmcp-badge"),
    offline: document.getElementById("offline-note"),
    helpDialog: document.getElementById("help-dialog"),
    iwadSelect: document.getElementById("iwad-select"),
  };

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