Files
Richard Zinkanddeepseek-v4.1-flash (opencode-go) via CodeCraft 8d51d6ec6a Add browser gamepad support with pad button pictograms
Co-Authored-By: deepseek-v4.1-flash (opencode-go) via CodeCraft
2026-11-11 14:43:39 +01:00

380 lines
16 KiB
JavaScript

// 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 <input type=file> 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" });
})();