terainia/web/main.js
Maximus Gorog c0589d0dfc Tick/toc instrumentation across build + test + mesh phases
run.sh:
  - phase() wrapper logs elapsed seconds per build step.
  - Tracks total build+startup at the end.
  - Output is "==> phase / [Ns] phase" so the slow steps are obvious.

test/run.py:
  - Per-step time.perf_counter() around each scenario step.
  - "slowest steps" summary printed at the end so the worst
    offenders are immediately visible.
  - Total wall-clock time at scenario end.

src/render/mod.rs:
  - browser_now() helper: web_sys::performance().now() on wasm,
    Instant-based on native. Monotonic ms timestamps for tick/toc.
  - Renderer::rebuild_chunk wraps build_chunk_mesh in a t0/t1
    measurement and logs anything over 5ms with vertex/index counts.
    Surfaces sky_visibility cost in the browser console.

web/main.js:
  - Exposes window.voxel_game = wasm after init so the test
    harness can drive scenarios declaratively (set_scene_time,
    teleport, look_at, get_position, etc.).

src/shader.wgsl:
  - Fix duplicate `let to_eye` declaration introduced in Round D
    (specular's normalized to_eye conflicted with fog's raw version).
    Renamed fog's local to_eye_raw. The test harness caught this
    immediately — first WGSL compile error, first scenario run.

Findings from running scenarios/lighting-times-of-day.yaml:
  - 289 chunks × ~100ms avg = ~29s mesh-build on main thread.
  - Page-ready latency dominated by this. window.voxel_game appears
    almost immediately (init resolves before chunks build), but
    the world is invisible until meshes are uploaded.
  - sky_visibility (8 cosine rays × HashMap voxel lookups) is the
    hot path inside build_chunk_mesh.

Next: make chunk-mesh build progressive (one or two chunks per tick
instead of all up-front), so the world becomes visible immediately
and pops in over a few seconds.
2026-05-24 11:49:08 -06:00

678 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(() => {
// Expose all wasm-bindgen exports on window.voxel_game so the
// Playwright test harness (test/run.py + scenarios) can drive the
// game declaratively from JS: set_scene_time, teleport, look_at,
// get_position, etc. Dev-affordance only; production users never
// touch this surface.
window.voxel_game = wasm;
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);
}