terainia/web/main.js
Maximus Gorog b52c1927cf Render + UI polish since pre-alpha-0.0.1
- Greedy meshing now bakes per-vertex AO with 4-corner sampling and an
  anisotropic-diagonal split when corner AO disagrees.
- WGSL: extracted sky_dome() for hemisphere ambient sampling so vertical
  faces match the sun-side sky tint at day; ambient_strength mixed by
  day strength instead of a flat constant.
- Step-1 post pipeline: render scene into an offscreen color texture,
  pass-through to the surface. Foundation for FXAA/shafts that will
  follow.
- Input bug: merge_held() now recomputes per tick from sticky keyboard +
  live touch bridge, so releasing the joystick actually stops the
  player (previous OR-into-self bug ate playtests).
- Touch UI hit-zones reordered (menu/hotbar above the joystick z-index);
  hotbar widened to 10 slots with tap-to-select on mobile.
- find_safe_spawn anchors on natural_surface_y so spawn is deterministic
  from noise — towers built at spawn no longer climb the spawn point.
- move_axis is sub-stepped (0.45-block max) so high-velocity falls can't
  teleport the player inside terrain.
2026-05-23 18:44:56 -06:00

672 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import init, * as wasm from "./voxel_game.js";
const detectedTouch =
(("ontouchstart" in window) || navigator.maxTouchPoints > 0)
&& !window.matchMedia("(pointer:fine)").matches;
let inputMode = localStorage.getItem("voxel-input-mode")
|| (detectedTouch ? "mobile" : "pc");
function applyInputMode(mode) {
inputMode = mode;
localStorage.setItem("voxel-input-mode", mode);
document.body.classList.toggle("touch", mode === "mobile");
wasm.set_touch_mode(mode === "mobile");
wasm.reset_input();
const pcBtn = document.getElementById("mode-pc");
const mbBtn = document.getElementById("mode-mobile");
if (pcBtn && mbBtn) {
pcBtn.classList.toggle("active", mode === "pc");
mbBtn.classList.toggle("active", mode === "mobile");
}
}
init().then(() => {
wasm.reset_input();
setupTouch();
setupGamepad();
setupHotbar();
setupDeathScreen();
setupStatusLoop();
setupNetwork();
setupMenu();
applyInputMode(inputMode);
document.addEventListener("visibilitychange", () => {
if (document.hidden) wasm.reset_input();
});
window.addEventListener("blur", () => wasm.reset_input());
}).catch(err => {
console.error(err);
document.body.insertAdjacentHTML("beforeend",
`<pre style="position:fixed;bottom:10px;left:10px;color:#f88;background:#000;padding:10px;max-width:90vw;white-space:pre-wrap">${err}</pre>`);
});
function setupHotbar() {
const slots = document.querySelectorAll("#hotbar .slot");
slots.forEach((s, idx) => {
s.addEventListener("pointerdown", (e) => {
e.preventDefault();
const b = parseInt(s.dataset.b, 10);
wasm.select_block(b);
slots.forEach(x => x.classList.remove("active"));
s.classList.add("active");
_selectedSlot = idx;
});
});
window.addEventListener("keydown", (e) => {
const map = {
"Digit1": 0, "Digit2": 1, "Digit3": 2, "Digit4": 3, "Digit5": 4,
"Digit6": 5, "Digit7": 6, "Digit8": 7, "Digit9": 8, "Digit0": 9,
};
if (e.code in map) {
const idx = map[e.code];
slots.forEach((x, i) => x.classList.toggle("active", i === idx));
_selectedSlot = idx;
}
});
// Mouse-wheel cycles the hotbar like Minecraft does.
window.addEventListener("wheel", (e) => {
if (document.body.classList.contains("menu-open")) return;
if (document.body.classList.contains("dead")) return;
if (e.deltaY === 0) return;
e.preventDefault();
cycleHotbar(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
}
function setupDeathScreen() {
document.getElementById("respawn-btn").addEventListener("click", (e) => {
e.preventDefault();
wasm.respawn();
});
}
function setupMenu() {
const canvas = document.getElementById("game-canvas");
const sens = document.getElementById("set-sens");
const sensVal = document.getElementById("set-sens-val");
const fov = document.getElementById("set-fov");
const fovVal = document.getElementById("set-fov-val");
const dist = document.getElementById("set-dist");
const distVal = document.getElementById("set-dist-val");
const tscale = document.getElementById("set-tscale");
const tscaleVal = document.getElementById("set-tscale-val");
const name = document.getElementById("set-name");
const saved = JSON.parse(localStorage.getItem("voxel-settings") || "{}");
sens.value = saved.sens ?? 0.005;
fov.value = saved.fov ?? 70;
dist.value = saved.dist ?? 240;
tscale.value = saved.tscale ?? 1.0;
name.value = localStorage.getItem("voxel-name") || "";
const topName = document.getElementById("player-name");
if (topName) topName.value = name.value;
const apply = () => {
const sv = parseFloat(sens.value);
const fv = parseFloat(fov.value);
const dv = parseFloat(dist.value);
const tv = parseFloat(tscale.value);
wasm.set_mouse_sens(sv);
wasm.set_fov(fv);
wasm.set_render_distance(dv);
wasm.set_time_scale(tv);
sensVal.textContent = sv.toFixed(4);
fovVal.textContent = fv + "°";
distVal.textContent = dv + " bl";
tscaleVal.textContent = tv === 0 ? "frozen" : (tv.toFixed(2) + "×");
localStorage.setItem("voxel-settings", JSON.stringify({ sens: sv, fov: fv, dist: dv, tscale: tv }));
};
sens.addEventListener("input", apply);
fov.addEventListener("input", apply);
dist.addEventListener("input", apply);
tscale.addEventListener("input", apply);
apply();
const pushName = () => {
localStorage.setItem("voxel-name", name.value);
wasm.set_player_name(name.value || "");
if (topName) topName.value = name.value;
};
name.addEventListener("change", pushName);
let everLocked = false;
const openMenu = () => {
document.body.classList.add("menu-open");
wasm.set_paused(true);
};
const closeMenu = () => {
document.body.classList.remove("menu-open");
wasm.set_paused(false);
};
document.addEventListener("pointerlockchange", () => {
const locked = document.pointerLockElement === canvas;
if (locked) {
everLocked = true;
closeMenu();
} else if (everLocked && inputMode !== "mobile") {
openMenu();
}
});
document.getElementById("menu-resume").addEventListener("click", (e) => {
e.preventDefault();
if (inputMode === "mobile") {
closeMenu();
} else {
canvas.requestPointerLock();
}
});
document.getElementById("menu-respawn").addEventListener("click", (e) => {
e.preventDefault();
wasm.respawn();
});
document.getElementById("mode-pc").addEventListener("click", (e) => {
e.preventDefault();
applyInputMode("pc");
});
document.getElementById("mode-mobile").addEventListener("click", (e) => {
e.preventDefault();
applyInputMode("mobile");
});
document.getElementById("menu-btn").addEventListener("click", (e) => {
e.preventDefault();
if (document.body.classList.contains("menu-open")) {
closeMenu();
} else {
openMenu();
}
});
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && inputMode === "mobile") {
if (document.body.classList.contains("menu-open")) closeMenu();
else openMenu();
}
});
}
function setupStatusLoop() {
const hpFill = document.getElementById("hp-fill");
const hpLabel = document.getElementById("hp-label");
setInterval(() => {
const hp = wasm.get_hp();
const alive = wasm.is_alive();
hpFill.style.width = (hp / 20 * 100) + "%";
hpLabel.textContent = `${hp} / 20`;
document.body.classList.toggle("dead", !alive);
}, 100);
}
function setupNetwork() {
const nameInput = document.getElementById("player-name");
const savedName = localStorage.getItem("voxel-name") || "";
nameInput.value = savedName;
const sendName = () => {
localStorage.setItem("voxel-name", nameInput.value);
wasm.set_player_name(nameInput.value || "");
};
nameInput.addEventListener("change", sendName);
sendName();
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${location.host}/ws`;
let ws = null;
let pumpTimer = null;
const setStatus = (text, connected) => {
document.getElementById("net-text").textContent = text;
document.getElementById("net-status").classList.toggle("connected", connected);
};
const pump = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const items = wasm.drain_outbox();
for (const s of items) {
try { ws.send(s); } catch (e) { break; }
}
};
const connect = () => {
setStatus("connecting…", false);
try {
ws = new WebSocket(url);
} catch (e) {
setStatus("server unreachable", false);
setTimeout(connect, 2500);
return;
}
ws.onopen = () => {
setStatus("connected", true);
wasm.on_ws_open();
pumpTimer = setInterval(pump, 50);
};
ws.onmessage = (ev) => {
wasm.on_ws_message(ev.data);
};
ws.onclose = () => {
setStatus("disconnected · retrying", false);
wasm.on_ws_close();
if (pumpTimer) { clearInterval(pumpTimer); pumpTimer = null; }
setTimeout(connect, 2000);
};
ws.onerror = () => {};
};
connect();
}
function setupTouch() {
wasm.touch_move(false, false, false, false);
wasm.touch_jump(false);
wasm.touch_sprint(false);
const stick = document.getElementById("stick");
const knob = document.getElementById("stick-knob");
let stickRect = null;
let stickPointerId = null;
const stickReset = () => {
knob.style.left = "50%";
knob.style.top = "50%";
stick.classList.remove("engaged");
wasm.touch_move(false, false, false, false);
};
const stickApply = (e) => {
if (!stickRect) return;
const cx = stickRect.left + stickRect.width / 2;
const cy = stickRect.top + stickRect.height / 2;
let dx = (e.clientX - cx) / (stickRect.width / 2);
let dy = (e.clientY - cy) / (stickRect.height / 2);
const m = Math.hypot(dx, dy);
if (m > 1.0) { dx /= m; dy /= m; }
knob.style.left = (50 + dx * 38) + "%";
knob.style.top = (50 + dy * 38) + "%";
const dz = 0.30;
wasm.touch_move(dy < -dz, dy > dz, dx < -dz, dx > dz);
};
stick.addEventListener("pointerdown", (e) => {
if (stickPointerId !== null) return;
e.preventDefault();
stickPointerId = e.pointerId;
stickRect = stick.getBoundingClientRect();
stick.setPointerCapture(e.pointerId);
stick.classList.add("engaged");
stickApply(e);
});
stick.addEventListener("pointermove", (e) => {
if (e.pointerId !== stickPointerId) return;
stickApply(e);
});
const stickEnd = (e) => {
if (e.pointerId !== stickPointerId) return;
stickPointerId = null;
stickRect = null;
stickReset();
};
stick.addEventListener("pointerup", stickEnd);
stick.addEventListener("pointercancel", stickEnd);
const look = document.getElementById("look-pad");
const lookers = new Map();
look.addEventListener("pointerdown", (e) => {
if (e.pointerType === "mouse") return;
e.preventDefault();
look.setPointerCapture(e.pointerId);
lookers.set(e.pointerId, { x: e.clientX, y: e.clientY });
});
look.addEventListener("pointermove", (e) => {
const prev = lookers.get(e.pointerId);
if (!prev) return;
const dx = e.clientX - prev.x;
const dy = e.clientY - prev.y;
lookers.set(e.pointerId, { x: e.clientX, y: e.clientY });
wasm.touch_look(dx * 0.55, dy * 0.55);
});
const lookEnd = (e) => lookers.delete(e.pointerId);
look.addEventListener("pointerup", lookEnd);
look.addEventListener("pointercancel", lookEnd);
look.addEventListener("pointerleave", lookEnd);
const hold = (id, setter) => {
const el = document.getElementById(id);
let activeId = null;
el.addEventListener("pointerdown", (e) => {
if (activeId !== null) return;
e.preventDefault();
activeId = e.pointerId;
el.setPointerCapture(e.pointerId);
el.classList.add("pressed");
setter(true);
});
const off = (e) => {
if (e.pointerId !== activeId) return;
activeId = null;
el.classList.remove("pressed");
setter(false);
};
el.addEventListener("pointerup", off);
el.addEventListener("pointercancel", off);
el.addEventListener("pointerleave", off);
};
const tap = (id, fn) => {
const el = document.getElementById(id);
el.addEventListener("pointerdown", (e) => {
e.preventDefault();
el.classList.add("pressed");
fn();
setTimeout(() => el.classList.remove("pressed"), 120);
});
};
hold("btn-jump", wasm.touch_jump);
hold("btn-sprint", wasm.touch_sprint);
tap("btn-break", wasm.touch_break);
tap("btn-place", wasm.touch_place);
}
function setupGamepad() {
let prev = [];
let lastSeenId = null;
// ---- Stick axis mapping, calibrated by the user via the test overlay ----
// Defaults match the W3C "standard" gamepad mapping (sticks on axes 0..3).
// The Steam Deck and other devices sometimes expose them elsewhere
// (trackpads-as-sticks, gyro, etc), so we let the user record which axis
// their physical stick actually drives.
const defaultMap = { lx: 0, ly: 1, rx: 2, ry: 3 };
let stickMap = { ...defaultMap, ...(JSON.parse(localStorage.getItem("voxel-gp-map") || "{}")) };
const saveMap = () => localStorage.setItem("voxel-gp-map", JSON.stringify(stickMap));
const updateMapText = () => {
const el = document.getElementById("gp-mapping");
if (el) {
el.textContent =
`axes: L=(${stickMap.lx},${stickMap.ly}) R=(${stickMap.rx},${stickMap.ry})`;
}
};
const gpIndicator = document.getElementById("gp-indicator");
const testCard = document.getElementById("gptest");
const testInfo = document.getElementById("gptest-info");
const testAxes = document.getElementById("gptest-axes");
const testButtons = document.getElementById("gptest-buttons");
document.getElementById("menu-gptest").addEventListener("click", (e) => {
e.preventDefault();
// Close the settings menu so the test overlay isn't hidden behind it
// and the engine is unpaused so the "walk fwd 1s" verification actually
// makes the player move.
document.body.classList.remove("menu-open");
wasm.set_paused(false);
testCard.style.display = "block";
updateMapText();
});
document.getElementById("gptest-close").addEventListener("click", (e) => {
e.preventDefault();
testCard.style.display = "none";
});
// Calibration: pick the most-displaced axis right now and assign it.
const captureAxis = (slot) => {
const gp = firstConnectedGamepad();
if (!gp) {
alert("No controller detected. Press any button on your controller so the browser exposes the gamepad, then try again.");
return;
}
// Dump the full axis snapshot to console for diagnosis regardless of
// whether we end up capturing — invaluable when nothing seems to move.
const snapshot = [];
for (let i = 0; i < gp.axes.length; i++) snapshot.push(`${i}=${safeAxis(gp, i).toFixed(3)}`);
console.log(`[gamepad] axes at calibration: ${snapshot.join(" ")}`);
let bestI = -1;
let bestV = 0.10; // be permissive — Steam Input often scales sticks down
for (let i = 0; i < gp.axes.length; i++) {
const v = Math.abs(safeAxis(gp, i));
if (v > bestV) { bestV = v; bestI = i; }
}
if (bestI < 0) {
alert(
"No axis moved past 0.10 right now. This usually means Steam Input is intercepting your stick (mapping it to mouse or keyboard) and the browser never sees it as an axis at all.\n\n" +
"Check console: I just logged the full axis state. If everything is 0.000, your stick is not reaching the browser.\n\n" +
"Fix path: Steam → Settings → Controller → Desktop Layout → set the joystick behavior to \"Joystick\" (not Mouse / WASD)."
);
return;
}
stickMap[slot] = bestI;
saveMap();
updateMapText();
console.log(`[gamepad] mapped ${slot} → axis ${bestI} (full map: ${JSON.stringify(stickMap)})`);
};
document.getElementById("gp-cal-lx").addEventListener("click", (e) => { e.preventDefault(); captureAxis("lx"); });
document.getElementById("gp-cal-ly").addEventListener("click", (e) => { e.preventDefault(); captureAxis("ly"); });
document.getElementById("gp-cal-rx").addEventListener("click", (e) => { e.preventDefault(); captureAxis("rx"); });
document.getElementById("gp-cal-ry").addEventListener("click", (e) => { e.preventDefault(); captureAxis("ry"); });
document.getElementById("gp-cal-reset").addEventListener("click", (e) => {
e.preventDefault();
stickMap = { ...defaultMap };
saveMap();
updateMapText();
});
// Path-verification: send input straight to the engine, bypassing any
// controller mystery. If walk-forward works here, the wasm side is fine
// and the problem is the input source.
document.getElementById("gp-test-fwd").addEventListener("click", (e) => {
e.preventDefault();
console.log("[gamepad-test] firing touch_move(forward) for 1s");
wasm.touch_move(true, false, false, false);
setTimeout(() => wasm.touch_move(false, false, false, false), 1000);
});
document.getElementById("gp-test-jump").addEventListener("click", (e) => {
e.preventDefault();
console.log("[gamepad-test] firing touch_jump press/release");
wasm.touch_jump(true);
setTimeout(() => wasm.touch_jump(false), 250);
});
// Live keyboard echo so we can tell whether Steam Input is translating
// sticks into WASD presses that Chrome actually receives.
const kbdEl = document.getElementById("gp-kbd-state");
const pressed = new Set();
window.addEventListener("keydown", (e) => {
if (!kbdEl) return;
pressed.add(e.code);
kbdEl.textContent = "kbd: " + [...pressed].join(" ");
});
window.addEventListener("keyup", (e) => {
if (!kbdEl) return;
pressed.delete(e.code);
kbdEl.textContent = "kbd: " + ([...pressed].join(" ") || "(none)");
});
window.addEventListener("gamepadconnected", (e) => {
console.log(`[gamepad] connected: ${e.gamepad.id} | mapping=${e.gamepad.mapping || "(none)"} | axes=${e.gamepad.axes.length} | buttons=${e.gamepad.buttons.length}`);
});
window.addEventListener("gamepaddisconnected", (e) => {
console.log(`[gamepad] disconnected: ${e.gamepad.id}`);
prev = [];
wasm.reset_input();
});
// Smooth deadzone — rescales (deadzone..1) into (0..1) so just past the
// deadzone you get a true 0, not a sudden jump to 0.15.
const applyDeadzone = (v, t = 0.15) => {
if (Math.abs(v) < t) return 0;
return (v - Math.sign(v) * t) / (1 - t);
};
const firstConnectedGamepad = () => {
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (const p of pads) {
// Some browsers leave nulls in the array even after disconnect.
// `connected` defaults to true if not set explicitly.
if (p && (p.connected !== false)) return p;
}
return null;
};
const safeAxis = (gp, i) => {
const v = gp.axes && gp.axes[i];
return typeof v === "number" ? v : 0;
};
const buttonValue = (b) => {
if (!b) return 0;
if (typeof b === "object") return b.value || (b.pressed ? 1 : 0);
return typeof b === "number" ? b : 0;
};
const isDown = (gp, i) => buttonValue(gp.buttons && gp.buttons[i]) > 0.5;
const stickL = document.getElementById("gp-stick-l");
const stickR = document.getElementById("gp-stick-r");
const moveStick = (el, x, y) => {
if (!el) return;
const r = 24;
const cx = Math.max(-1, Math.min(1, x)) * r;
const cy = Math.max(-1, Math.min(1, y)) * r;
el.style.transform = `translate(calc(-50% + ${cx}px), calc(-50% + ${cy}px))`;
el.style.background = (Math.abs(x) + Math.abs(y)) > 0.05 ? "#6c6" : "#ddd";
};
const renderTestOverlay = (gp) => {
if (!testCard || testCard.style.display === "none") return;
if (!gp) {
// Always show what the API itself is reporting so we can tell
// "browser sees no gamepad at all" from "browser sees one but we
// can't read it". This is the single most useful diagnostic.
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
const slots = (pads.length ? pads : [null]).map((p, i) =>
p ? `slot ${i}: ${p.id}` : `slot ${i}: (empty)`
).join("\n");
testInfo.style.whiteSpace = "pre-wrap";
testInfo.textContent =
`No controller detected. navigator.getGamepads():\n${slots}\n\n` +
`Press a button on your controller — most browsers don't expose ` +
`gamepads until they receive at least one input event.\n\n` +
`Steam Deck note: if Chrome was installed via Discover (Flatpak), ` +
`it may be sandboxed from /dev/input. Grant input access via Flatseal ` +
`or run "flatpak override --user --device=input com.google.Chrome".`;
testAxes.textContent = "";
testButtons.textContent = "";
moveStick(stickL, 0, 0);
moveStick(stickR, 0, 0);
return;
}
testInfo.style.whiteSpace = "normal";
testInfo.textContent = `${gp.id} · mapping: ${gp.mapping || "(none)"} · ${gp.axes.length} axes / ${gp.buttons.length} buttons`;
moveStick(stickL, safeAxis(gp, stickMap.lx), safeAxis(gp, stickMap.ly));
moveStick(stickR, safeAxis(gp, stickMap.rx), safeAxis(gp, stickMap.ry));
// Show all axes numerically (Steam Deck sometimes exposes >4 axes —
// trackpads, gyro — and this is how we'll spot if sticks are landing on
// an unexpected index).
const axisLines = [];
for (let i = 0; i < gp.axes.length; i++) {
const v = safeAxis(gp, i);
const bar = Math.abs(v) > 0.05 ? ` ${v >= 0 ? "+" : ""}${"█".repeat(Math.min(8, Math.floor(Math.abs(v) * 8)))}` : "";
axisLines.push(`${String(i).padStart(2)}: ${v.toFixed(2).padStart(6)}${bar}`);
}
testAxes.textContent = axisLines.join("\n");
testButtons.innerHTML = "";
for (let i = 0; i < gp.buttons.length; i++) {
const b = document.createElement("div");
b.className = "gp-btn" + (isDown(gp, i) ? " on" : "");
b.textContent = i;
testButtons.appendChild(b);
}
};
const tick = () => {
const gp = firstConnectedGamepad();
if (gp && gp.id !== lastSeenId) {
lastSeenId = gp.id;
console.log(`[gamepad] active: ${gp.id} | mapping=${gp.mapping || "(none)"} | axes=${gp.axes.length} | buttons=${gp.buttons.length}`);
}
if (gpIndicator) gpIndicator.classList.toggle("active", !!gp);
if (gp) {
// Lower deadzone — Deck sticks are Hall effect with very low natural
// drift, and Steam Input passes them through nearly raw. The previous
// 0.15 + 0.2 = 0.35 effective threshold meant gentle stick deflections
// didn't move the player at all.
const lx = applyDeadzone(safeAxis(gp, stickMap.lx), 0.10);
const ly = applyDeadzone(safeAxis(gp, stickMap.ly), 0.10);
const moveT = 0.18;
wasm.touch_move(ly < -moveT, ly > moveT, lx < -moveT, lx > moveT);
const rx = applyDeadzone(safeAxis(gp, stickMap.rx), 0.10);
const ry = applyDeadzone(safeAxis(gp, stickMap.ry), 0.10);
if (rx !== 0 || ry !== 0) {
wasm.touch_look(rx * 9.0, ry * 9.0);
}
// ---- Complete standard XInput button mapping ----
// 0 = A / Cross → jump (held)
// 1 = B / Circle → (unused — would be sneak)
// 2 = X / Square → break (one-shot)
// 3 = Y / Triangle → place (one-shot, alternate to RT)
// 4 = LB → previous hotbar slot
// 5 = RB → next hotbar slot
// 6 = LT → break (alternate, held → continuous? no, one-shot)
// 7 = RT → place (one-shot)
// 8 = Back / Select → toggle menu
// 9 = Start → toggle menu
// 10 = L3 (left stick) → sprint (held)
// 11 = R3 (right stick) → respawn (long-press feel)
// 12 = D-pad up → previous hotbar slot
// 13 = D-pad down → next hotbar slot
// 14 = D-pad left → previous hotbar slot
// 15 = D-pad right → next hotbar slot
// 16 = Home / Guide → (reserved by OS in most browsers)
const justPressed = (i) => isDown(gp, i) && !prev[i];
wasm.touch_jump(isDown(gp, 0));
wasm.touch_sprint(isDown(gp, 10));
if (justPressed(2)) wasm.touch_break();
if (justPressed(6)) wasm.touch_break();
if (justPressed(7)) wasm.touch_place();
if (justPressed(3)) wasm.touch_place();
if (justPressed(4) || justPressed(14)) cycleHotbar(-1);
if (justPressed(5) || justPressed(15)) cycleHotbar(+1);
if (justPressed(12)) cycleHotbar(-1);
if (justPressed(13)) cycleHotbar(+1);
if (justPressed(8) || justPressed(9)) {
document.getElementById("menu-btn").click();
}
if (justPressed(11)) wasm.respawn();
prev = [];
const len = gp.buttons ? gp.buttons.length : 0;
for (let i = 0; i < len; i++) prev[i] = isDown(gp, i);
} else {
if (lastSeenId !== null) {
lastSeenId = null;
prev = [];
}
}
renderTestOverlay(gp);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
let _selectedSlot = 2;
function cycleHotbar(delta) {
const slots = document.querySelectorAll("#hotbar .slot");
if (!slots.length) return;
_selectedSlot = (_selectedSlot + delta + slots.length) % slots.length;
slots.forEach((s, i) => s.classList.toggle("active", i === _selectedSlot));
const b = parseInt(slots[_selectedSlot].dataset.b, 10);
wasm.select_block(b);
}