// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave, // HTTP requests (savegame service), audio output. (function () { "use strict"; var pending = null; // value handed over by dc_storage_get var file = null; // bytes of a file chosen via the file picker var cache = {}; // key -> string, persisted to localStorage when the page is hidden/closed var http = {}; // request id -> { done, status, body } var httpNext = 1; var token = ""; // login session of the savegame service (sent with every request) var fetched = { state: 0 }; // download of a file from the server: 0 idle, 1 running, 2 done, 3 failed var ws = {}; // socket id -> { state, queue, sock } var wsNext = 1; var gpPad = null; // the gamepad handed out by dc_gamepad_slot (reused for its buttons/axes) var gpSlot = -1; // browsers offer the Gamepad API only in a secure context (https or localhost) function gamepads() { try { return navigator.getGamepads ? navigator.getGamepads() : []; } catch (e) { return []; } } function bytes(ptr, len) { return new Uint8Array(wasm_memory.buffer, ptr, len); } function str(ptr, len) { return new TextDecoder().decode(bytes(ptr, len)); } function flushCache() { for (var k in cache) { try { window.localStorage.setItem(k, cache[k]); } catch (e) { } } } window.addEventListener("pagehide", flushCache); document.addEventListener("visibilitychange", function () { if (document.visibilityState === "hidden") flushCache(); }); // hidden for loading savegames var input = document.createElement("input"); input.type = "file"; input.accept = ".dcsave,.json,application/json"; input.style.display = "none"; input.addEventListener("change", function () { var f = input.files && input.files[0]; if (!f) return; f.arrayBuffer().then(function (buf) { file = new Uint8Array(buf); }); input.value = ""; }); document.addEventListener("DOMContentLoaded", function () { document.body.appendChild(input); }); // Asynchronous HTTP request; the result is polled by the game. // status 0 = network error (e.g. no service, or the page was opened as a local file) function httpStart(method, url, body) { var id = httpNext++; var req = { done: false, status: 0, body: new Uint8Array(0) }; http[id] = req; var ctrl = typeof AbortController !== "undefined" ? new AbortController() : null; var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 15000); try { var opts = { method: method, cache: "no-store", headers: {} }; if (token) opts.headers["Authorization"] = "Bearer " + token; if (ctrl) opts.signal = ctrl.signal; if (body !== null) { opts.body = body; opts.headers["Content-Type"] = "application/json"; } fetch(url, opts).then(function (r) { req.status = r.status; return r.arrayBuffer(); }).then(function (buf) { req.body = new Uint8Array(buf); req.done = true; clearTimeout(timer); }).catch(function () { req.status = 0; req.done = true; clearTimeout(timer); }); } catch (e) { req.done = true; } return id; } function register(importObject) { var env = importObject.env; env.dc_set_token = function (tp, tl) { token = tl > 0 ? str(tp, tl) : ""; }; // fetches a file (with the login) and offers it as a download, without copying it into the game env.dc_fetch_download = function (up, ul, np, nl) { var url = str(up, ul), name = str(np, nl); fetched.state = 1; var opts = { cache: "no-store", headers: {} }; if (token) opts.headers["Authorization"] = "Bearer " + token; fetch(url, opts).then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.blob(); }).then(function (blob) { var a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 2000); fetched.state = 2; }).catch(function () { fetched.state = 3; }); }; env.dc_fetch_download_state = function () { var s = fetched.state; if (s >= 2) fetched.state = 0; return s; }; env.dc_http = function (mp, ml, up, ul, bp, bl) { var body = bl > 0 ? bytes(bp, bl).slice() : null; return httpStart(str(mp, ml), str(up, ul), body); }; // -2 = still running, otherwise the HTTP status (0 = network error) env.dc_http_poll = function (id) { var r = http[id]; if (!r) return 0; return r.done ? r.status : -2; }; env.dc_http_len = function (id) { var r = http[id]; return r ? r.body.length : 0; }; // copies the response body and forgets the request env.dc_http_take = function (id, ptr) { var r = http[id]; if (!r) return; bytes(ptr, r.body.length).set(r.body); delete http[id]; }; // WebSocket for multiplayer matches. state: 0 connecting, 1 open, 2 closed, 3 failed, 4 gone env.dc_ws_connect = function (up, ul) { var url = str(up, ul); if (url.charAt(0) === "/") { var proto = window.location.protocol === "https:" ? "wss://" : "ws://"; url = proto + window.location.host + url; } var id = wsNext++; var s = { state: 0, queue: [], sock: null }; ws[id] = s; try { s.sock = new WebSocket(url); s.sock.onopen = function () { s.state = 1; }; s.sock.onmessage = function (e) { if (typeof e.data === "string" && s.queue.length < 2000) s.queue.push(e.data); }; s.sock.onclose = function () { s.state = 2; }; s.sock.onerror = function () { s.state = 3; }; } catch (e) { s.state = 3; } return id; }; env.dc_ws_state = function (id) { var s = ws[id]; return s ? s.state : 4; }; env.dc_ws_send = function (id, pp, pl) { var s = ws[id]; if (!s || s.state !== 1 || !s.sock) return 0; try { s.sock.send(str(pp, pl)); return 1; } catch (e) { return 0; } }; env.dc_ws_take = function (id) { var s = ws[id]; if (!s || !s.queue.length) return -1; pending = new TextEncoder().encode(s.queue.shift()); return pending.length; }; env.dc_ws_close = function (id) { var s = ws[id]; if (!s) return; try { if (s.sock) s.sock.close(); } catch (e) { } delete ws[id]; }; env.dc_storage_get = function (kp, kl) { var v = null; try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { } if (v === null) { pending = null; return -1; } pending = new TextEncoder().encode(v); return pending.length; }; env.dc_storage_take = function (ptr) { if (pending === null) return; bytes(ptr, pending.length).set(pending); pending = null; }; // browser text dialog (touch devices have no keyboard in the canvas); result via dc_storage_take env.dc_prompt = function (tp, tl, dp, dl) { var v = window.prompt(str(tp, tl), str(dp, dl)); if (v === null || v === undefined) { pending = null; return -1; } pending = new TextEncoder().encode(v); return pending.length; }; // opens a web page in a new tab (repository link) env.dc_open_url = function (up, ul) { try { window.open(str(up, ul), "_blank", "noopener"); } catch (e) { } }; // the device's main pointer is a finger (phones, tablets) env.dc_coarse_pointer = function () { try { return window.matchMedia("(pointer: coarse)").matches ? 1 : 0; } catch (e) { return 0; } }; // ---- gamepads: the game polls these functions once per frame (see src/pad.rs). // The Gamepad API needs a secure context (https or localhost) and shows a pad only after // its first button press. slot = the index in navigator.getGamepads(), -1 = no gamepad. env.dc_gamepad_slot = function () { gpPad = null; gpSlot = -1; var pads = gamepads(); for (var i = 0; i < pads.length; i++) { if (pads[i] && pads[i].connected && pads[i].mapping === "standard") { gpPad = pads[i]; gpSlot = i; return i; } } // a pad without the standard mapping (old DirectInput devices): use it anyway for (var j = 0; j < pads.length; j++) { if (pads[j] && pads[j].connected) { gpPad = pads[j]; gpSlot = j; return j; } } return -1; }; function gamepad(slot) { if (gpPad && slot === gpSlot) return gpPad; var pads = gamepads(); return pads[slot] && pads[slot].connected ? pads[slot] : null; } // bit i set: standard button i is down (triggers count from 40 % on) env.dc_gamepad_buttons = function (slot) { var p = gamepad(slot); if (!p) return 0; var m = 0; for (var i = 0; i < p.buttons.length && i < 16; i++) { var b = p.buttons[i]; if (b && (b.pressed || b.value > 0.4)) m |= 1 << i; } return m; }; // axis 0/1 = left stick x/y, 2/3 = right stick x/y (all -1..1) env.dc_gamepad_axis = function (slot, axis) { var p = gamepad(slot); if (!p || !p.axes || axis < 0 || axis >= p.axes.length) return 0; return p.axes[axis]; }; // 0 unknown, 1 Xbox, 2 PlayStation, 3 Nintendo (from the pad's id) // (the Xbox check comes first: its id contains "Wireless Controller" too) env.dc_gamepad_kind = function (slot) { var p = gamepad(slot); if (!p) return 0; var id = (p.id || "").toLowerCase(); if (id.indexOf("xbox") >= 0 || id.indexOf("xinput") >= 0 || id.indexOf("microsoft") >= 0) return 1; if (id.indexOf("nintendo") >= 0 || id.indexOf("switch") >= 0 || id.indexOf("joy-con") >= 0 || id.indexOf("joycon") >= 0 || id.indexOf("pro controller") >= 0) return 3; if (id.indexOf("playstation") >= 0 || id.indexOf("dualshock") >= 0 || id.indexOf("dualsense") >= 0 || id.indexOf("sony") >= 0 || id.indexOf("wireless controller") >= 0 || id.indexOf("054c") >= 0 || /(^|[^0-9])ps[345]([^0-9]|$)/.test(id)) return 2; return 0; }; env.dc_storage_set = function (kp, kl, vp, vl) { try { window.localStorage.setItem(str(kp, kl), str(vp, vl)); } catch (e) { } }; env.dc_cache_set = function (kp, kl, vp, vl) { cache[str(kp, kl)] = str(vp, vl); }; env.dc_download = function (np, nl, dp, dl) { var blob = new Blob([bytes(dp, dl).slice()], { type: "application/json" }); var a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = str(np, nl); document.body.appendChild(a); a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000); }; env.dc_open_file = function () { file = null; input.click(); }; env.dc_file_poll = function () { return file === null ? -1 : file.length; }; env.dc_file_take = function (ptr) { if (file === null) return; bytes(ptr, file.length).set(file); file = null; }; } // ---- audio: the Rust mixer renders on demand (dc_audio_render), started by the first user input var actx = null; var stats = { frames: 0, peak: 0 }; // diagnostics, e.g. for automated tests: state of the audio output window.dc_audio_stats = function () { return { state: actx ? actx.state : "not started", frames: stats.frames, peak: stats.peak }; }; function startAudio() { if (typeof wasm_exports === "undefined" || !wasm_exports.dc_audio_render) return; if (actx) { if (actx.state !== "running") actx.resume(); return; } var AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; actx = new AC(); wasm_exports.dc_audio_init(actx.sampleRate); var node = actx.createScriptProcessor(2048, 0, 2); node.onaudioprocess = function (e) { var n = e.outputBuffer.length; var l = e.outputBuffer.getChannelData(0); var r = e.outputBuffer.getChannelData(1); var ptr = wasm_exports.dc_audio_render(n); if (!ptr) { l.fill(0); r.fill(0); return; } var buf = new Float32Array(wasm_memory.buffer, ptr, n * 2); var peak = 0; for (var i = 0; i < n; i++) { l[i] = buf[2 * i]; r[i] = buf[2 * i + 1]; var a = Math.abs(l[i]); if (a > peak) peak = a; } stats.frames += n; stats.peak = Math.max(stats.peak * 0.9, peak); }; node.connect(actx.destination); } ["mousedown", "keydown", "touchstart"].forEach(function (ev) { window.addEventListener(ev, startAudio, true); }); function gfxInfo() { try { var canvas = document.getElementById("glcanvas"); if (!canvas) { var cs = document.getElementsByTagName("canvas"); canvas = cs.length ? cs[0] : null; } if (!canvas) return ""; var gl = canvas.getContext("webgl2"); var version = "WebGL 2"; if (!gl) { gl = canvas.getContext("webgl"); version = "WebGL 1"; } if (!gl) return ""; var versionName = gl.getParameter(gl.VERSION) || version; var dbg = gl.getExtension("WEBGL_debug_renderer_info"); var gpu = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) || "" : ""; var vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) || "" : ""; var api = "WebGL"; var all = versionName + " " + gpu + " " + vendor; if (all.indexOf("Vulkan") >= 0) api = "Vulkan"; else if (all.indexOf("Metal") >= 0) api = "Metal"; else if (all.indexOf("Direct3D") >= 0 || all.indexOf("D3D") >= 0) api = "D3D"; else if (all.indexOf("OpenGL") >= 0) api = "OpenGL"; return versionName + " | " + api + " | " + (gpu || vendor || api); } catch (e) { return ""; } } function registerGfx(importObject) { importObject.env.dc_renderer = function () { pending = new TextEncoder().encode(gfxInfo()); return pending.length; }; } var oldRegister = register; register = function (importObject) { oldRegister(importObject); registerGfx(importObject); }; miniquad_add_plugin({ register_plugin: register, version: 1, name: "dungeoncraft" }); })();