'use strict'; // These are relative paths const RELEASE_DIR = '%__RELEASE_UUID__%'; // set by build_www.sh const DEFAULT_PACKS_DIR = RELEASE_DIR + '/packs'; const rtCSS = ` body { font-family: arial; margin: 0; padding: none; background-color: black; } .emscripten { color: #aaaaaa; padding-right: 0; margin-left: auto; margin-right: auto; display: block; } div.emscripten { text-align: center; width: 100%; } /* the canvas *must not* have any border or padding, or mouse coords will be wrong */ canvas.emscripten { border: 0px none; background-color: black; } #controls { display: inline-block; vertical-align: top; height: 25px; } .console { width: 100%; margin: 0 auto; margin-top: 0px; border-left: 0px; border-right: 0px; padding-left: 0px; padding-right: 0px; display: block; background-color: black; color: white; font-family: 'Lucida Console', Monaco, monospace; outline: none; } `; const rtHTML = `
`; // The canvas needs to be created before the wasm module is loaded. // It is not attached to the document until activateBody() const mtCanvas = document.createElement('canvas'); mtCanvas.className = "emscripten"; mtCanvas.id = "canvas"; mtCanvas.oncontextmenu = (event) => { event.preventDefault(); }; mtCanvas.tabIndex = "-1"; mtCanvas.width = 1024; mtCanvas.height = 600; var consoleButton; var consoleOutput; var progressBar; var progressBarDiv; function activateBody() { const extraCSS = document.createElement("style"); extraCSS.innerText = rtCSS; document.head.appendChild(extraCSS); // Replace the entire body document.body.style = ''; document.body.className = ''; document.body.innerHTML = ''; const mtContainer = document.createElement('div'); mtContainer.innerHTML = rtHTML; document.body.appendChild(mtContainer); const canvasContainer = document.getElementById('canvas_container'); canvasContainer.appendChild(mtCanvas); setupResizeHandlers(); consoleButton = document.getElementById('console_button'); consoleOutput = document.getElementById('console_output'); // Triggers the first and all future updates consoleUpdate(); progressBar = document.getElementById('progressbar'); progressBarDiv = document.getElementById('progressbar_div'); updateProgressBar(0, 0); } var PB_bytes_downloaded = 0; var PB_bytes_needed = 0; function updateProgressBar(doneBytes, neededBytes) { PB_bytes_downloaded += doneBytes; PB_bytes_needed += neededBytes; if (progressBar) { progressBarDiv.style.display = (PB_bytes_downloaded == PB_bytes_needed) ? "none" : "block"; const pct = PB_bytes_needed ? Math.round(100 * PB_bytes_downloaded / PB_bytes_needed) : 0; progressBar.value = `${pct}`; progressBar.innerText = `${pct}%`; } } // Singleton var mtLauncher = null; class LaunchScheduler { constructor() { this.conditions = new Map(); window.requestAnimationFrame(this.invokeCallbacks.bind(this)); } isSet(name) { return this.conditions.get(name)[0]; } addCondition(name, startCallback = null, deps = []) { this.conditions.set(name, [false, new Set(), startCallback]); for (const depname of deps) { this.addDep(name, depname); } } addDep(name, depname) { if (!this.isSet(depname)) { this.conditions.get(name)[1].add(depname); } } setCondition(name) { if (this.isSet(name)) { throw new Error('Scheduler condition set twice'); } this.conditions.get(name)[0] = true; this.conditions.forEach(v => { v[1].delete(name); }); window.requestAnimationFrame(this.invokeCallbacks.bind(this)); } clearCondition(name, newCallback = null, deps = []) { if (!this.isSet(name)) { throw new Error('clearCondition called on unset condition'); } const arr = this.conditions.get(name); arr[0] = false; arr[1] = new Set(deps); arr[2] = newCallback; } invokeCallbacks() { const callbacks = []; this.conditions.forEach(v => { if (!v[0] && v[1].size == 0 && v[2] !== null) { callbacks.push(v[2]); v[2] = null; } }); callbacks.forEach(cb => cb()); } } const mtScheduler = new LaunchScheduler(); function loadWasm() { // Start loading the wasm module // The module will call emloop_ready when it is loaded // and waiting for main() arguments. const mtModuleScript = document.createElement("script"); mtModuleScript.type = "text/javascript"; mtModuleScript.src = RELEASE_DIR + "/minetest.js"; mtModuleScript.async = true; document.head.appendChild(mtModuleScript); } function callMain() { const fullargs = [ './minetest', ...mtLauncher.args.toArray() ]; const [argc, argv] = makeArgv(fullargs); emloop_invoke_main(argc, argv); // Pausing and unpausing here gives the browser time to redraw the DOM // before Minetest freezes the main thread generating the world. If this // is not done, the page will stay frozen for several seconds emloop_request_animation_frame(); mtScheduler.setCondition("main_called"); } var emloop_pause; var emloop_unpause; var emloop_init_sound; var emloop_invoke_main; var emloop_install_pack; var emloop_set_minetest_conf; var irrlicht_want_pointerlock; var irrlicht_force_pointerlock; var irrlicht_resize; var emsocket_init; var emsocket_set_proxy; var emsocket_set_vpn; // Called when the wasm module is ready function emloop_ready() { emloop_pause = cwrap("emloop_pause", null, []); emloop_unpause = cwrap("emloop_unpause", null, []); emloop_init_sound = cwrap("emloop_init_sound", null, []); emloop_invoke_main = cwrap("emloop_invoke_main", null, ["number", "number"]); emloop_install_pack = cwrap("emloop_install_pack", null, ["number", "number", "number"]); emloop_set_minetest_conf = cwrap("emloop_set_minetest_conf", null, ["number"]); irrlicht_want_pointerlock = cwrap("irrlicht_want_pointerlock", "number"); irrlicht_force_pointerlock = cwrap("irrlicht_force_pointerlock", null); irrlicht_resize = cwrap("irrlicht_resize", null, ["number", "number"]); emsocket_init = cwrap("emsocket_init", null, []); emsocket_set_proxy = cwrap("emsocket_set_proxy", null, ["number"]); emsocket_set_vpn = cwrap("emsocket_set_vpn", null, ["number"]); mtScheduler.setCondition("wasmReady"); } // Called when the wasm module wants to force redraw before next frame function emloop_request_animation_frame() { emloop_pause(); window.requestAnimationFrame(() => { emloop_unpause(); }); } function makeArgv(args) { // Assuming 4-byte pointers const argv = _malloc((args.length + 1) * 4); let i; for (i = 0; i < args.length; i++) { HEAPU32[(argv >>> 2) + i] = allocateUTF8(args[i]); } HEAPU32[(argv >>> 2) + i] = 0; // argv[argc] == NULL return [i, argv]; } var consoleText = []; var consoleLengthMax = 1000; var consoleTextLast = 0; var consoleDirty = false; function consoleUpdate() { if (consoleDirty) { if (consoleText.length > consoleLengthMax) { consoleText = consoleText.slice(-consoleLengthMax); } consoleOutput.value = consoleText.join(''); consoleOutput.scrollTop = consoleOutput.scrollHeight; // focus on bottom consoleDirty = false; } window.requestAnimationFrame(consoleUpdate); } function consoleToggle() { consoleOutput.style.display = (consoleOutput.style.display == 'block') ? 'none' : 'block'; consoleButton.value = (consoleOutput.style.display == 'none') ? 'Show Console' : 'Hide Console'; fixGeometry(); } var enableTracing = false; function consolePrint(text) { if (enableTracing) { console.trace(text); } consoleText.push(text + "\n"); consoleDirty = true; if (mtLauncher && mtLauncher.onprint) { mtLauncher.onprint(text); } } var Module = { preRun: [], postRun: [], print: consolePrint, canvas: (function() { // As a default initial behavior, pop up an alert when webgl context is lost. To make your // application robust, you may want to override this behavior before shipping! // See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2 mtCanvas.addEventListener("webglcontextlost", function(e) { alert('WebGL context lost. You will need to reload the page.'); e.preventDefault(); }, false); return mtCanvas; })(), setStatus: function(text) { if (text) Module.print('[wasm module status] ' + text); }, totalDependencies: 0, monitorRunDependencies: function(left) { this.totalDependencies = Math.max(this.totalDependencies, left); if (!mtLauncher || !mtLauncher.onprogress) return; mtLauncher.onprogress('wasm_module', (this.totalDependencies-left) / this.totalDependencies); } }; Module['printErr'] = Module['print']; // This is injected into workers so that out/err are sent to the main thread. // This probably should be the default behavior, but doesn't seem to be for WasmFS. const workerInject = ` Module['print'] = (text) => { postMessage({cmd: 'print', text: text, threadId: Module['_pthread_self']()}); }; Module['printErr'] = (text) => { postMessage({cmd: 'printErr', text: text, threadId: Module['_pthread_self']()}); }; importScripts('minetest.js'); `; Module['mainScriptUrlOrBlob'] = new Blob([workerInject], { type: "text/javascript" }); Module['onFullScreen'] = () => { fixGeometry(); }; window.onerror = function(event) { consolePrint('Exception thrown, see JavaScript console'); }; function resizeCanvas(width, height) { const canvas = mtCanvas; if (canvas.width != width || canvas.height != height) { canvas.width = width; canvas.height = height; canvas.widthNative = width; canvas.heightNative = height; } // Trigger SDL window resize. // This should happen automatically, not sure why it doesn't. irrlicht_resize(width, height); } function now() { return (new Date()).getTime(); } // Only allow fixGeometry to be called every 250ms // Firefox calls this way too often, causing flicker. var fixGeometryPause = 0; function fixGeometry(override) { if (!override && now() < fixGeometryPause) { return; } const resolutionSelect = document.getElementById('resolution'); const aspectRatioSelect = document.getElementById('aspectRatio'); var canvas = mtCanvas; var resolution = resolutionSelect.value; var aspectRatio = aspectRatioSelect.value; var screenX; var screenY; // Prevent the controls from getting focus canvas.focus(); var isFullScreen = document.fullscreenElement ? true : false; if (isFullScreen) { screenX = screen.width; screenY = screen.height; } else { // F11-style full screen var controls = document.getElementById('controls'); var maximized = !window.screenTop && !window.screenY; controls.style = maximized ? 'display: none' : ''; var headerHeight = document.getElementById('header').offsetHeight; var footerHeight = document.getElementById('footer').offsetHeight; screenX = document.documentElement.clientWidth - 6; screenY = document.documentElement.clientHeight - headerHeight - footerHeight - 6; } // Size of the viewport (after scaling) var realX; var realY; if (aspectRatio == 'any') { realX = screenX; realY = screenY; } else { var ar = aspectRatio.split(':'); var innerRatio = parseInt(ar[0]) / parseInt(ar[1]); var outerRatio = screenX / screenY; if (innerRatio <= outerRatio) { realX = Math.floor(innerRatio * screenY); realY = screenY; } else { realX = screenX; realY = Math.floor(screenX / innerRatio); } } // Native canvas resolution var resX; var resY; var scale = false; if (resolution == 'high') { resX = realX; resY = realY; } else if (resolution == 'medium') { resX = Math.floor(realX / 1.5); resY = Math.floor(realY / 1.5); scale = true; } else { resX = Math.floor(realX / 2.0); resY = Math.floor(realY / 2.0); scale = true; } resizeCanvas(resX, resY); if (scale) { var styleWidth = realX + "px"; var styleHeight = realY + "px"; canvas.style.setProperty("width", styleWidth, "important"); canvas.style.setProperty("height", styleHeight, "important"); } else { canvas.style.removeProperty("width"); canvas.style.removeProperty("height"); } } function setupResizeHandlers() { window.addEventListener('resize', () => { fixGeometry(); }); // Needed to prevent special keys from triggering browser actions, like // F5 causing page reload. document.addEventListener('keydown', (e) => { // Allow F11 to go full screen if (e.code == "F11") { // On Firefox, F11 is animated. The window smoothly grows to // full screen over several seconds. During this transition, the 'resize' // event is triggered hundreds of times. To prevent flickering, have // fixGeometry ignore repeated calls, and instead resize every 500ms // for 2.5 seconds. By then it should be finished. fixGeometryPause = now() + 2000; for (var delay = 100; delay <= 2600; delay += 500) { setTimeout(() => { fixGeometry(true); }, delay); } } }); } class MinetestArgs { constructor() { this.go = false; this.server = false; this.name = ''; this.password = ''; this.gameid = ''; this.address = ''; this.port = ''; this.packs = []; this.extra = []; } toArray() { const args = []; if (this.go) args.push('--go'); if (this.server) args.push('--server'); if (this.name) args.push('--name', this.name); if (this.password) args.push('--password', this.password); if (this.gameid) args.push('--gameid', this.gameid); if (this.address) args.push('--address', this.address); if (this.port) args.push('--port', this.port.toString()); args.push(...this.extra); return args; } toQueryString() { const params = new URLSearchParams(); if (this.go) params.append('go', ''); if (this.server) params.append('server', ''); if (this.name) params.append('name', this.name); if (this.password) params.append('password', this.password); if (this.gameid) params.append('gameid', this.gameid); if (this.address) params.append('address', this.address); if (this.port) params.append('port', this.port.toString()); const extra_packs = []; this.packs.forEach(v => { if (v != 'base' && v != 'minetest_game' && v != 'devtest' && v != this.gameid) { extra_packs.push(v); } }); if (extra_packs.length) { params.append('packs', extra_packs.join(',')); } if (this.extra.length) { params.append('extra', this.extra.join(',')); } return params.toString(); } static fromQueryString(qs) { const r = new MinetestArgs(); const params = new URLSearchParams(qs); if (params.has('go')) r.go = true; if (params.has('server')) r.server = true; if (params.has('name')) r.name = params.get('name'); if (params.has('password')) r.password = params.get('password'); if (params.has('gameid')) r.gameid = params.get('gameid'); if (params.has('address')) r.address = params.get('address'); if (params.has('port')) r.port = parseInt(params.get('port')); if (r.gameid && r.gameid != 'minetest_game' && r.gameid != 'devtest' && r.gameid != 'base') { r.packs.push(r.gameid); } if (params.has('packs')) { params.get('packs').split(',').forEach(p => { if (!r.packs.includes(p)) { r.packs.push(p); } }); } if (params.has('extra')) { r.extra = params.get('extra').split(','); } return r; } } class MinetestLauncher { constructor() { if (mtLauncher !== null) { throw new Error("There can be only one launcher"); } mtLauncher = this; this.args = null; this.onprogress = null; // function(name, percent done) this.onready = null; // function() this.onerror = null; // function(message) this.onprint = null; // function(text) this.addedPacks = new Set(); this.vpn = null; this.serverCode = null; this.clientCode = null; this.proxyUrl = "wss://minetest.dustlabs.io/proxy"; this.packsDir = DEFAULT_PACKS_DIR; this.packsDirIsCors = false; this.minetestConf = null; mtScheduler.addCondition("wasmReady", loadWasm); mtScheduler.addCondition("launch_called"); mtScheduler.addCondition("ready", this.#notifyReady.bind(this), ['wasmReady']); mtScheduler.addCondition("main_called", callMain, ['ready', 'launch_called']); this.addPack('base'); } setProxy(url) { this.proxyUrl = url; } /* * Set the url for the pack files directory * This can be relative or absolute. */ setPacksDir(url, is_cors) { this.packsDir = url; this.packsDirIsCors = is_cors; } #notifyReady() { mtScheduler.setCondition("ready"); if (this.onready) this.onready(); } isReady() { return mtScheduler.isSet("ready"); } // Must be set before launch() setVPN(serverCode, clientCode) { this.serverCode = serverCode; this.clientCode = clientCode; this.vpn = serverCode ? serverCode : clientCode; } setMinetestConf(contents) { this.minetestConf = contents; } // Returns pack status: // 0 - pack has not been added // 1 - pack is downloading // 2 - pack has been installed checkPack(name) { if (!this.addedPacks.has(name)) { return 0; } if (mtScheduler.isSet("installed:" + name)) { return 2; } return 1; } addPacks(packs) { for (const pack of packs) { this.addPack(pack); } } async addPack(name) { if (mtScheduler.isSet("launch_called")) { throw new Error("Cannot add packs after launch"); } if (name == 'minetest_game' || name == 'devtest' || this.addedPacks.has(name)) return; this.addedPacks.add(name); const fetchedCond = "fetched:" + name; const installedCond = "installed:" + name; let chunks = []; let received = 0; // This is done here instead of at the bottom, because it needs to // be delayed until after the 'wasmReady' condition. // TODO: Add the ability to `await` a condition instead. const installPack = () => { // Install const data = _malloc(received); let offset = 0; for (const arr of chunks) { HEAPU8.set(arr, data + offset); offset += arr.byteLength; } emloop_install_pack(allocateUTF8(name), data, received); _free(data); mtScheduler.setCondition(installedCond); if (this.onprogress) { this.onprogress(`download:${name}`, 1.0); this.onprogress(`install:${name}`, 1.0); } }; mtScheduler.addCondition(fetchedCond, null); mtScheduler.addCondition(installedCond, installPack, ["wasmReady", fetchedCond]); mtScheduler.addDep("main_called", installedCond); const packUrl = this.packsDir + '/' + name + '.pack'; let resp; try { resp = await fetch(packUrl, this.packsDirIsCors ? { credentials: 'omit' } : {}); } catch (err) { if (this.onerror) { this.onerror(`${err}`); } else { alert(`Error while loading ${packUrl}. Please refresh page`); } throw new Error(`${err}`); } // This could be null if the header is missing var contentLength = resp.headers.get('Content-Length'); if (contentLength) { contentLength = parseInt(contentLength); updateProgressBar(0, contentLength); } let reader = resp.body.getReader(); while (true) { const {done, value} = await reader.read(); if (done) { break; } chunks.push(value); received += value.byteLength; if (contentLength) { updateProgressBar(value.byteLength, 0); if (this.onprogress) { this.onprogress(`download:${name}`, received / contentLength); } } } mtScheduler.setCondition(fetchedCond); } // Launch minetest.exe // // This must be called from a keyboard or mouse event handler, // after the 'onready' event has fired. (For this reason, it cannot // be called from the `onready` handler) launch(args) { if (!this.isReady()) { throw new Error("launch called before onready"); } if (!(args instanceof MinetestArgs)) { throw new Error("launch called without MinetestArgs"); } if (mtScheduler.isSet("launch_called")) { throw new Error("launch called twice"); } this.args = args; if (this.args.gameid) { this.addPack(this.args.gameid); } this.addPacks(this.args.packs); activateBody(); fixGeometry(); if (this.minetestConf) { const confBuf = allocateUTF8(this.minetestConf) emloop_set_minetest_conf(confBuf); _free(confBuf); } emloop_init_sound(); // Setup emsocket // TODO: emsocket should export the helpers for this emsocket_init(); const proxyBuf = allocateUTF8(this.proxyUrl); emsocket_set_proxy(proxyBuf); _free(proxyBuf); if (this.vpn) { const vpnBuf = allocateUTF8(this.vpn); emsocket_set_vpn(vpnBuf); _free(vpnBuf); } if (args.go) { irrlicht_force_pointerlock(); } mtScheduler.setCondition("launch_called"); } }