/* RSIGame page core — shared by every design version. * * A version page loads this, then calls Evo.boot(init). init(ctx) receives { games, compare, arms } * and mounts whatever sections that version has, using the building blocks below: * Evo.mountGallery(root, opts) filters + grid + load-more (opts.card(g) to restyle cards) * Evo.mountCompare(root, list) before/after pairs * Evo.mountHeroVideo(videoEl) looping hero mosaic * Evo.renderStrip/renderCats/renderTable(el, arms) charts, colored by CSS vars --godot / --phaser * Evo.videoFrame(mediaPath) + Evo.observe(el) a poster+clip that only plays while on screen * Evo.openModal(game, list) the shared game detail dialog (markup injected here, styled per version) * Data comes from static/data (in repo); video/images from MEDIA (Hugging Face, or ./media on localhost). */ (() => { "use strict"; const SCRIPT = document.currentScript.src; const ROOT = new URL("../../", SCRIPT).href; // site root, wherever the version page lives // Clips, posters and the playable builds are not in this repo; by default they stream from the // published site. `?media=local` uses ./media instead (a copy or a symlink of the media folder), // and a deployed copy can pin its own location with window.EVO_MEDIA. const REMOTE = "https://rsigame-rsigame-page.static.hf.space/media"; const qs = new URLSearchParams(location.search); const published = /(^|\.)static\.hf\.space$/.test(location.hostname) || location.hostname === "huggingface.co"; const MEDIA = window.EVO_MEDIA ? new URL(window.EVO_MEDIA, ROOT).href.replace(/\/$/, "") : qs.get("media") === "local" || published ? `${ROOT}media` : REMOTE; const MAX_PLAYING = 12; const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; const CATS = [["M", "Core Mechanics"], ["D", "Content Depth"], ["V", "Functional Visuals"], ["A", "Presentation & Art"]]; const SHORT = { M: "Mechanics", D: "Depth", V: "Visuals", A: "Art" }; const ENGINE_CLASS = { "Godot 4": "godot", Phaser: "phaser" }; const MODEL_CLASS = { "GPT-5.5": "gpt", "Qwen3.8-27B": "qwen" }; const GENRE_NAMES = { visualnovel: "Visual novel", openworld: "Open world", cardgame: "Card game" }; const VERSIONS = [["v1", ""], ["v2", "v2/"], ["v3", "v3/"], ["v4", "v4/"]]; const $ = (s, el = document) => el.querySelector(s); const h = (tag, attrs = {}, ...kids) => { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v == null || v === false) continue; if (k === "class") el.className = v; else if (k === "html") el.innerHTML = v; else if (k.startsWith("on")) el.addEventListener(k.slice(2), v); else el.setAttribute(k, v === true ? "" : v); } for (const kid of kids.flat()) if (kid != null) el.append(kid); return el; }; const media = (p) => `${MEDIA}/${p}`; const data = (p) => `${ROOT}static/data/${p}`; const s100 = (x) => (x * 100).toFixed(1); const i100 = (x) => String(Math.round(x * 100)); const genreName = (g) => GENRE_NAMES[g] || g[0].toUpperCase() + g.slice(1); const demoName = (id) => id.replace(/^\d+[_-]?/, "").replace(/[_-]+/g, " ").replace(/^./, (c) => c.toUpperCase()); const demoNum = (id) => String(parseInt(id, 10) || ""); const reqClass = (x) => (x >= 0.75 ? "s-hi" : x >= 0.4 ? "s-mid" : "s-lo"); const key = (g) => `${g.arm}/${g.task}`; const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; const median = (xs) => { const t = [...xs].sort((a, b) => a - b), m = t.length >> 1; return t.length % 2 ? t[m] : (t[m - 1] + t[m]) / 2; }; let GAMES = []; const byKey = new Map(); // ---------- video manager: only what's on screen plays, never more than MAX_PLAYING videos ---------- const playing = new Set(); function play(video) { playing.delete(video); playing.add(video); if (!video.getAttribute("src")) video.src = video.dataset.src; video.play().catch(() => {}); while (playing.size > MAX_PLAYING) { const oldest = playing.values().next().value; oldest.pause(); playing.delete(oldest); if (oldest._pair) { oldest._pair.pause(); playing.delete(oldest._pair); } } } function pause(video) { video.pause(); playing.delete(video); } function pauseAll() { [...playing].forEach(pause); } const playAll = (el) => el.querySelectorAll("video").forEach(play); const pauseIn = (el) => el.querySelectorAll("video").forEach(pause); // a before/after pair replays the same inputs: keep the "after" clip locked to the "before" clock setInterval(() => { for (const v of playing) { const o = v._follower; if (o && !v.paused && v.readyState > 2 && o.readyState > 2 && Math.abs(v.currentTime - o.currentTime) > 0.2) o.currentTime = v.currentTime; } }, 500); const visibility = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting && !reduceMotion && modalHidden()) playAll(e.target); else pauseIn(e.target); } }, { threshold: 0.55 }); const observe = (el) => { visibility.observe(el); }; const unobserve = (el) => { visibility.unobserve(el); }; function clipVideo(src) { const v = h("video", { muted: true, loop: true, playsinline: true, preload: "none", "data-src": media(src) }); v.muted = true; v.addEventListener("playing", () => v.classList.add("ready")); return v; } function videoFrame(mediaPath, { alt = "", eager = false, cls = "frame", clip = "highlight" } = {}) { const frame = h("div", { class: cls }); if (!mediaPath) { frame.append(h("div", { class: "no-media" }, "no replay — build failed")); return frame; } frame.append(h("img", { src: media(`${mediaPath}/poster.webp`), alt, loading: eager ? "eager" : "lazy", decoding: "async" })); frame.append(clipVideo(clip === "highlight" ? `${mediaPath}/highlight.mp4` : `${mediaPath}/demos/${clip}.mp4`)); return frame; } /** * Before/after wipe: the two replays of the same demo stacked, the "after" one revealed right of a divider. * The divider sweeps on its own; pointer/drag takes over, and it resumes sweeping after the pointer leaves. * pairPath holds p0.mp4/p0.webp and final.mp4/final.webp. */ function wipeFrame(pairPath, { alt = "", eager = false, cls = "frame wipe", before = "Round 0", after = "Final", auto = true } = {}) { const frame = h("div", { class: `${cls}${auto && !reduceMotion ? " auto" : ""}`, style: "--pos:50%" }); const b = clipVideo(`${pairPath}/p0.mp4`), a = clipVideo(`${pairPath}/final.mp4`); b._follower = a; a._pair = b; b._pair = a; frame.append( h("img", { class: "w-img", src: media(`${pairPath}/p0.webp`), alt: `${alt} — ${before}`, loading: eager ? "eager" : "lazy", decoding: "async" }), b, h("div", { class: "w-after" }, h("img", { class: "w-img", src: media(`${pairPath}/final.webp`), alt: `${alt} — ${after}`, loading: eager ? "eager" : "lazy", decoding: "async" }), a), h("span", { class: "w-label w-l" }, before), h("span", { class: "w-label w-r" }, after), h("div", { class: "w-handle", "aria-hidden": "true" }, h("i"))); let resume; const setPos = (e) => { const r = frame.getBoundingClientRect(); frame.style.setProperty("--pos", `${Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100))}%`); }; frame.addEventListener("pointermove", (e) => { if (e.pointerType === "touch" && !frame.dragging) return; clearTimeout(resume); frame.classList.remove("auto"); setPos(e); }); frame.addEventListener("pointerdown", (e) => { if (e.pointerType === "touch") { frame.dragging = true; frame.classList.remove("auto"); setPos(e); } }); const release = () => { frame.dragging = false; if (auto && !reduceMotion) resume = setTimeout(() => frame.classList.add("auto"), 1800); }; frame.addEventListener("pointerup", release); frame.addEventListener("pointerleave", release); return frame; } /** Hover-to-play for an element holding a frame; also plays while on screen. */ function wirePlayback(el) { if (!el.querySelector("video")) return; el.addEventListener("mouseenter", () => modalHidden() && playAll(el)); if (reduceMotion) el.addEventListener("mouseleave", () => pauseIn(el)); observe(el); } function mountHeroVideo(video, { src = "hero_mosaic.mp4", poster = "hero_mosaic.webp" } = {}) { video.poster = media(poster); if (reduceMotion) return; video.muted = true; video.src = media(src); new IntersectionObserver(([e]) => (e.isIntersecting && modalHidden() ? video.play().catch(() => {}) : video.pause())).observe(video); } // ---------- gallery ---------- function defaultCard(g) { const frame = videoFrame(g.media, { alt: `${g.title} (${g.engine})` }); frame.append( h("span", { class: "badge" }, h("i", { class: `dot ${ENGINE_CLASS[g.engine]}` }), g.engine), h("span", { class: "score-chip" }, s100(g.reward))); return h("button", { class: "card", "aria-label": `${g.title}, ${g.engine}, score ${s100(g.reward)}` }, frame, h("div", { class: "card-body" }, h("div", { class: "card-genre" }, `${genreName(g.genre)} · ${g.model}`), h("h3", {}, g.title), mdva(g))); } function mdva(g) { return h("div", { class: "mdva" }, CATS.map(([k, name]) => h("div", { title: `${name}: ${s100(g[k])}` }, h("span", {}, k, h("b", {}, i100(g[k]))), h("div", { class: "meter" }, h("i", { style: `width:${g[k] * 100}%` }))))); } /** * Filters + grid + load more. opts: * card(g) -> element (clicking it opens the modal; any