const playerId = document.body.dataset.playerId;
const gameId = document.body.dataset.gameId;
const moveUrl = document.body.dataset.moveUrl;
const homeHref = document.body.dataset.homeHref;
const newHref = document.body.dataset.newHref;
const pingUrl = document.body.dataset.pingUrl;
const mountPrefix = document.body.dataset.mountPrefix || "";
const scope = (() => {
if (document.body.dataset.scope) return document.body.dataset.scope;
let p = location.pathname;
if (mountPrefix && p.startsWith(mountPrefix)) p = p.slice(mountPrefix.length);
return p.split("/").filter(Boolean)[0] || "splash";
})();
let movePending = null;
const flashRed = () => {
document.body.classList.remove("flash-red");
void document.body.offsetWidth; document.body.classList.add("flash-red");
};
const rttEl = () => document.querySelector("#rtt");
const tickRtt = () => {
if (movePending == null) return;
rttEl()?.replaceChildren(`${Math.round(performance.now() - movePending.t)}ms`);
requestAnimationFrame(tickRtt);
};
const setPressed = (intent) => {
document.querySelectorAll("[data-intent].is-pressed")
.forEach((el) => el.classList.remove("is-pressed"));
if (intent) document.querySelector(`[data-intent="${intent}"]`)?.classList.add("is-pressed");
};
const move = (intent) => {
const reqId = crypto.randomUUID();
movePending = { id: reqId, t: performance.now() };
setPressed(intent);
document.body.classList.add("move-pending");
if ("hjkl".includes(intent)) {
document.querySelector("#board-wrap")?.setAttribute("data-pending", intent);
}
requestAnimationFrame(tickRtt);
return fetch(moveUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ playerId, gameId, intent, reqId }),
}).then((r) => {
if (!r.ok) {
movePending = null;
document.querySelector("#board-wrap")?.removeAttribute("data-pending");
document.body.classList.remove("move-pending");
setPressed(null);
flashRed();
}
}).catch(() => {
movePending = null;
document.querySelector("#board-wrap")?.removeAttribute("data-pending");
document.body.classList.remove("move-pending");
setPressed(null);
flashRed();
});
};
const PING_INTERVAL_MS = 3000;
const PING_TIMEOUT_MS = 4000;
const setConn = (v) => {
if (document.body.dataset.conn === v) return;
document.body.dataset.conn = v;
};
const TAB_ID_KEY = "nu2048.tabId";
let tabId = sessionStorage.getItem(TAB_ID_KEY);
if (!tabId) { tabId = crypto.randomUUID(); sessionStorage.setItem(TAB_ID_KEY, tabId); }
const presencePing = async () => {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), PING_TIMEOUT_MS);
try {
const r = await fetch(pingUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tabId, scope, gameId }),
signal: ctrl.signal,
});
setConn(r.status === 204 ? "ok" : "down");
} catch {
setConn("down");
} finally {
clearTimeout(t);
}
};
presencePing();
setInterval(presencePing, PING_INTERVAL_MS);
addEventListener("keydown", (e) => {
if (e.key === "Escape") {
location.href = homeHref;
e.preventDefault();
return;
}
if (e.key === "n" && !e.ctrlKey && !e.metaKey && !e.altKey) {
location.href = newHref;
e.preventDefault();
}
});
if (moveUrl) {
window.onAck = (reqId) => {
if (movePending && reqId === movePending.id) {
const rtt = Math.round(performance.now() - movePending.t);
movePending = null;
document.querySelector("#board-wrap")?.removeAttribute("data-pending");
document.body.classList.remove("move-pending");
setPressed(null);
rttEl()?.replaceChildren(`${rtt}ms`);
}
};
const keymap = {
h: "h", ArrowLeft: "h",
j: "j", ArrowDown: "j",
k: "k", ArrowUp: "k",
l: "l", ArrowRight: "l",
};
if (document.body.classList.contains("play")) {
addEventListener("keydown", (e) => {
if (document.body.dataset.conn === "down") return;
const dir = keymap[e.key] || keymap[(e.key + "").toLowerCase()];
const intent = dir || (e.key === "u" ? "undo" : "");
if (intent) {
move(intent);
e.preventDefault();
}
});
}
document.addEventListener("click", (e) => {
const intent = e.target.closest("button[data-intent]");
if (intent) move(intent.dataset.intent);
});
const SWIPE_THRESHOLD = 30;
let swipeStart = null;
addEventListener("pointerdown", (e) => {
swipeStart = e.target.closest(".board") ? [e.clientX, e.clientY] : null;
});
addEventListener("pointerup", (e) => {
if (!swipeStart) return;
const dx = e.clientX - swipeStart[0];
const dy = e.clientY - swipeStart[1];
swipeStart = null;
if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_THRESHOLD) return;
const dir = Math.abs(dx) > Math.abs(dy)
? (dx > 0 ? "l" : "h")
: (dy > 0 ? "j" : "k");
move(dir);
});
}
const audioToggle = document.querySelector(".audio-toggle");
const splashAudio = document.querySelector("#splash-audio");
if (audioToggle && splashAudio) {
let seeded = false;
const toggleAudio = (e) => {
if (e) e.preventDefault();
if (splashAudio.paused) {
if (!seeded) {
splashAudio.currentTime = 48;
seeded = true;
}
splashAudio.play();
} else {
splashAudio.pause();
}
};
audioToggle.addEventListener("click", toggleAudio);
const sync = () => {
const playing = !splashAudio.paused;
audioToggle.setAttribute("aria-pressed", playing ? "true" : "false");
audioToggle.setAttribute("aria-label", playing ? "pause audio" : "play audio");
};
sync();
splashAudio.addEventListener("play", sync);
splashAudio.addEventListener("pause", sync);
splashAudio.addEventListener("ended", sync);
document.addEventListener("keydown", (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === "p" || e.key === "P") {
e.preventDefault();
toggleAudio();
}
});
document.querySelector("#splash-slider")?.addEventListener("scrub-end", () => {
if (splashAudio.paused) return;
if (Number.isFinite(splashAudio.duration) && splashAudio.duration > 0) {
splashAudio.currentTime = Math.random() * splashAudio.duration;
}
});
}