terainia/web/main.js
Maximus Gorog a01d5c1fa9 Quiet the debug-noise that was lagging the browser
You're right — the telemetry path itself was contributing to perceived
browser lag, not just the GPU work. Two specific sources removed:

render/mod.rs::rebuild_chunk:
  - Per-chunk log::info!("rebuild_chunk {coord}: {ms}ms ...") fired
    ~289 times during world init, plus on every edit. Chrome's console
    buffer accumulates these and DevTools recalcs on each new line.
    Removed entirely. The bench toggles + FPS HUD already give us the
    measurement signal; per-chunk timing was noise once verified.

web/main.js setupFpsHud:
  - Was a 5 Hz setInterval that did `el.textContent = ...` and two
    `classList.toggle` calls each tick (15 DOM writes/sec). Now:
      * Polls at 1 Hz (the EMA already smooths)
      * Only writes textContent when the displayed string actually
        changes (skip is the common case once stabilized)
      * Only writes class when the tier (green/warn/bad) crosses
        a threshold
    Same readout, ~15× fewer DOM writes.

web/main.js setupStatusLoop (HP bar):
  - Same dedupe pattern. 10 Hz polling, but DOM writes only when
    hp or alive changes from the last sample. Was firing 3 style
    writes per tick unconditionally; now zero in steady state.

No functionality changes — every telemetry getter, every bench
toggle, every test-harness export still works (just verified via
the 18-check functional contract suite).

Wasm release built; tests 63/63 pass.
2026-05-24 17:22:30 -06:00

720 lines
26 KiB
JavaScript
Raw Permalink 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;
setupFpsHud();
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");
let lastHp = -1;
let lastAlive = null;
setInterval(() => {
const hp = wasm.get_hp();
const alive = wasm.is_alive();
// Dedupe: only touch the DOM when something actually changed.
// Pre-change the loop ran 10× per second and fired three style
// writes each time — every frame paying a recalc cost even when
// HP held steady. Worth-only-noticeable when the value changes.
if (hp !== lastHp) {
hpFill.style.width = (hp / 20 * 100) + "%";
hpLabel.textContent = `${hp} / 20`;
lastHp = hp;
}
if (alive !== lastAlive) {
document.body.classList.toggle("dead", !alive);
lastAlive = 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);
}
// FPS HUD — reads the EMA-smoothed dt from the game and writes it
// once a second. The previous version polled at 5 Hz with DOM writes
// every poll, which on weak hardware contributed to perceived browser
// lag. 1 Hz is more than enough to read the number; the EMA already
// smooths anything faster anyway. Also skips DOM writes when the
// reported value hasn't changed enough to display differently.
function setupFpsHud() {
const el = document.getElementById("fps");
if (!el) return;
let lastText = "";
let lastTier = "";
setInterval(() => {
const dt = wasm.get_frame_dt_ms ? wasm.get_frame_dt_ms() : 0;
if (!dt || dt <= 0) {
if (lastText !== "— fps") { el.textContent = "— fps"; lastText = "— fps"; }
return;
}
const fps = 1000 / dt;
const next = `${fps.toFixed(0)} fps (${dt.toFixed(1)}ms)`;
if (next !== lastText) { el.textContent = next; lastText = next; }
const tier = dt > 33 ? "bad" : dt > 18 ? "warn" : "";
if (tier !== lastTier) {
el.classList.toggle("warn", tier === "warn");
el.classList.toggle("bad", tier === "bad");
lastTier = tier;
}
}, 1000);
}