← 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"