minetest-wasm/static/launcher.js

758 lines
24 KiB
JavaScript

'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 = `
<div id="header">
<div class="emscripten">
<span id="controls">
<span>
<select id="resolution" onchange="fixGeometry()">
<option value="high">High Res</option>
<option value="medium">Medium</option>
<option value="low">Low Res</option>
</select>
</span>
<span>
<select id="aspectRatio" onchange="fixGeometry()">
<option value="any">Fit Screen</option>
<option value="4:3">4:3</option>
<option value="16:9">16:9</option>
<option value="5:4">5:4</option>
<option value="21:9">21:9</option>
<option value="32:9">32:9</option>
<option value="1:1">1:1</option>
</select>
</span>
<span><input id="console_button" type="button" value="Show Console" onclick="consoleToggle()"></span>
<span>(full screen: try F11 or Command+Shift+F)</span>
</span>
<div id="progressbar_div" style="display: none">
<progress id="progressbar" value="0" max="100">0%</progress>
</div>
</div>
</div>
<div class="emscripten" id="canvas_container">
</div>
<div id="footer">
<textarea id="console_output" class="console" rows="8" style="display: none; height: 200px"></textarea>
</div>
`;
// 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 <args>
//
// 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");
}
}