← 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();
})();