laya-snake / src /web /js /app.js
deepondas's picture
feat(web): add view-only mode and a Hugging Face Space deployment
634fb2d
Raw History Blame Contribute Delete
6.25 kB
import { game, applyState, subscribe } from "./gameState.js";
import { trackMotion } from "./snakeMotion.js";
import { renderDashboard, drawCharts, setText } from "./dashboard.js";
import { CanvasRenderer } from "./renderer2d.js";
const $ = (id) => document.getElementById(id);
const VIEWS = ["2d", "3d", "cinematic"];
/* ---------- viewer preferences (this browser only) ---------- */
const prefs = { view: "3d", tod: "golden", grid: true, follow: false };
try { Object.assign(prefs, JSON.parse(localStorage.getItem("laya-snake-view") || "{}")); } catch (_) {}
if (!VIEWS.includes(prefs.view)) prefs.view = "3d";
const debug = new URLSearchParams(location.search).has("debug");
// Saves the viewer's scene preferences and applies them.
function savePrefs() {
try { localStorage.setItem("laya-snake-view", JSON.stringify(prefs)); } catch (_) {}
showPrefs();
selectRenderer();
}
// Reflects the local scene preferences in the settings panel.
function showPrefs() {
[...$("view-seg").children].forEach((b) => b.classList.toggle("on", b.dataset.v === prefs.view));
$("tod-sel").value = prefs.tod;
$("sw-grid").classList.toggle("on", prefs.grid);
$("sw-follow").classList.toggle("on", prefs.follow);
setText("hdr-view", { "2d": "2D view", "3d": "3D view", cinematic: "Realistic view" }[prefs.view]);
}
/* ---------- commands (unchanged server API) ---------- */
// Sends one control command to the game server and applies the state it returns.
async function cmd(name) {
if (game.state && game.state.view_only) return;
try {
const r = await fetch("/api/cmd/" + name, { method: "POST" });
if (r.ok) applyState(await r.json());
} catch (_) {}
}
$("btn-pause").onclick = () => cmd("pause");
$("btn-reset").onclick = () => cmd("reset");
$("sw-shield").onclick = () => cmd("shield");
$("sw-auto").onclick = () => cmd("auto");
$("sw-obst").onclick = () => cmd("obstacles");
$("food-dec").onclick = () => game.state && cmd("foods/" + (game.state.rules.foods - 1));
$("food-inc").onclick = () => game.state && cmd("foods/" + (game.state.rules.foods + 1));
$("speed").oninput = (e) => cmd("speed/" + e.target.value);
$("size-sel").onchange = (e) => cmd("size/" + e.target.value);
$("tod-sel").onchange = (e) => { prefs.tod = e.target.value; savePrefs(); };
$("sw-grid").onclick = () => { prefs.grid = !prefs.grid; savePrefs(); };
$("sw-follow").onclick = () => { prefs.follow = !prefs.follow; savePrefs(); };
[...$("view-seg").children].forEach((b) => b.onclick = () => { prefs.view = b.dataset.v; savePrefs(); });
document.addEventListener("keydown", (e) => {
if (e.metaKey || e.ctrlKey || e.altKey || /SELECT|INPUT/.test(e.target.tagName)) return;
const k = e.key.toLowerCase();
const local = {
v: () => { prefs.view = VIEWS[(VIEWS.indexOf(prefs.view) + 1) % VIEWS.length]; },
g: () => { prefs.grid = !prefs.grid; },
f: () => { prefs.follow = !prefs.follow; },
};
if (local[k]) { e.preventDefault(); local[k](); savePrefs(); return; }
const map = { " ": "pause", r: "reset", s: "shield", a: "auto", o: "obstacles",
arrowup: "faster", arrowdown: "slower", "+": "grow", "=": "grow", "-": "shrink", "_": "shrink" };
if (map[k]) { e.preventDefault(); cmd(map[k]); }
});
const navLinks = [...document.querySelectorAll(".nav a")];
const spy = new IntersectionObserver((entries) => {
entries.forEach((en) => {
if (en.isIntersecting) navLinks.forEach((a) => a.classList.toggle("on", a.getAttribute("href") === "#" + en.target.id));
});
}, { rootMargin: "-30% 0px -60% 0px" });
["game", "stats", "model", "charts", "history"].forEach((id) => spy.observe($(id)));
/* ---------- renderers ---------- */
const canvas2d = new CanvasRenderer($("stage"), prefs);
let three = null, threeState = "idle";
let current = null;
// Loads the Three.js world the first time a 3D view is chosen; afterwards it is reused as is.
function ensureThree() {
if (threeState !== "idle") return;
threeState = "loading";
$("load3d").classList.add("show");
import("./renderer3d.js")
.then(async (m) => {
three = new m.ThreeRenderer($("stage3d"), prefs);
if (debug) window.__laya3d = three;
if (game.state) three.update(game.state);
await three.init();
if (debug) { $("debug3d").classList.add("show"); await three.enableDebug($("debug3d")); }
threeState = "ready";
})
.catch((err) => {
console.error("[3d] could not start the 3D view:", err);
threeState = "failed";
$("load3d").textContent = "3D view unavailable (WebGL). Showing 2D.";
setTimeout(() => $("load3d").classList.remove("show"), 4000);
})
.finally(() => { if (threeState === "ready") $("load3d").classList.remove("show"); selectRenderer(); });
}
// Picks the renderer for the current view; the game itself is never touched by a switch.
function selectRenderer() {
const wants3d = prefs.view !== "2d";
if (wants3d) ensureThree();
const next = wants3d && threeState === "ready" ? three : canvas2d;
if (next === three) three.setMode(prefs.view);
$("stage-box").dataset.mode = next === three ? prefs.view : "2d";
if (next !== current) {
if (current) current.deactivate();
current = next;
current.resize();
}
}
new ResizeObserver(() => { canvas2d.resize(); if (three) three.resize(); }).observe($("stage-box"));
window.addEventListener("resize", () => { canvas2d.resize(); drawCharts(); });
/* ---------- state flow: server update -> state -> dashboard + both renderers ---------- */
subscribe((s, prev) => {
trackMotion(prev, s);
canvas2d.update(s);
if (three) three.update(s);
renderDashboard(s);
});
// One animation loop for the whole page; only the visible renderer draws.
function loop(now) {
requestAnimationFrame(loop);
if (game.state && current) current.render(now);
}
// Subscribes to the server's state stream (Server-Sent Events, unchanged).
function connect() {
const es = new EventSource("/api/events");
es.onmessage = (e) => applyState(JSON.parse(e.data));
es.onerror = () => {
$("status").className = "pill down";
setText("status-text", "DISCONNECTED");
};
}
showPrefs();
selectRenderer();
fetch("/api/state").then((r) => r.json()).then(applyState).catch(() => {});
connect();
requestAnimationFrame(loop);