multimodalart's picture
multimodalart HF Staff
Revert look/pan to keyboard IJKL (replacing mouse-look), keep WASD movement
304b499 verified
Raw
History Blame Contribute Delete
28 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>ABot-World · Interactive World Rollout</title>
<style>
:root{
--bg:#0b0e14; --panel:#121722; --panel-2:#171d2b; --edge:#232c3d;
--text:#e6ecf5; --muted:#8a97ad; --accent:#5b8cff; --accent-2:#7c5cff;
--good:#3ecf8e; --warn:#ffb454; --bad:#ff5c6c; --radius:16px;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
font-family:'Inter',system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
background:radial-gradient(1200px 700px at 80% -10%, #1a2138 0%, var(--bg) 60%) fixed;
color:var(--text); -webkit-font-smoothing:antialiased; min-height:100%;
}
.wrap{max-width:1240px; margin:0 auto; padding:28px 20px 48px}
header{display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; margin-bottom:22px}
.brand{display:flex; align-items:center; gap:12px}
.logo{width:42px;height:42px;border-radius:12px;
background:linear-gradient(135deg,var(--accent),var(--accent-2));
display:grid;place-items:center;font-size:22px;box-shadow:0 6px 24px rgba(91,140,255,.35)}
.brand h1{font-size:19px;margin:0;letter-spacing:.2px}
.brand p{margin:2px 0 0;font-size:12.5px;color:var(--muted)}
.links{display:flex;gap:8px;flex-wrap:wrap}
.links a{font-size:12.5px;color:var(--muted);text-decoration:none;border:1px solid var(--edge);
padding:6px 11px;border-radius:999px;transition:.15s}
.links a:hover{color:var(--text);border-color:var(--accent);background:rgba(91,140,255,.08)}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:20px}
@media (max-width:960px){.layout{grid-template-columns:1fr}}
.card{background:linear-gradient(180deg,var(--panel),var(--panel-2));
border:1px solid var(--edge);border-radius:var(--radius);padding:16px}
/* Stage */
.stage{position:relative;aspect-ratio:1280/704;background:#05070c;border-radius:12px;
overflow:hidden;border:1px solid var(--edge);display:grid;place-items:center}
.stage canvas{width:100%;height:100%;object-fit:cover;display:none;image-rendering:auto}
.stage.playing canvas{display:block}
.overlay{position:absolute;inset:0;display:grid;place-items:center;text-align:center;
padding:24px;background:rgba(5,7,12,.55);backdrop-filter:blur(2px)}
.overlay.hidden{display:none}
.spinner{width:34px;height:34px;border-radius:50%;border:3px solid rgba(255,255,255,.15);
border-top-color:var(--accent);animation:spin 1s linear infinite;margin:0 auto 14px}
@keyframes spin{to{transform:rotate(360deg)}}
.overlay h3{margin:0 0 6px;font-size:16px}
.overlay p{margin:0;color:var(--muted);font-size:13px;max-width:420px}
.hud{position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;
pointer-events:none;font-size:12px}
.chip{background:rgba(9,12,20,.7);border:1px solid var(--edge);border-radius:999px;
padding:5px 11px;display:flex;align-items:center;gap:7px;backdrop-filter:blur(4px)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
.dot.playing{background:var(--good);box-shadow:0 0 8px var(--good)}
.dot.loading{background:var(--warn);box-shadow:0 0 8px var(--warn);animation:pulse 1s infinite}
.dot.error,.dot.ended{background:var(--bad)}
@keyframes pulse{50%{opacity:.35}}
.capture-hint{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
background:rgba(9,12,20,.72);border:1px solid var(--edge);border-radius:999px;
padding:6px 14px;font-size:12px;color:var(--muted);pointer-events:none;transition:.2s;opacity:0}
.stage.playing .capture-hint{opacity:1}
/* Uploader */
.drop{border:1.5px dashed var(--edge);border-radius:12px;padding:16px;text-align:center;
cursor:pointer;transition:.15s;background:rgba(255,255,255,.015)}
.drop:hover,.drop.drag{border-color:var(--accent);background:rgba(91,140,255,.07)}
.drop .icon{font-size:26px}
.drop .t{font-size:13.5px;margin-top:6px}
.drop .s{font-size:11.5px;color:var(--muted);margin-top:2px}
.thumb{display:none;margin-top:10px;border-radius:10px;overflow:hidden;border:1px solid var(--edge)}
.thumb img{width:100%;display:block;max-height:170px;object-fit:cover}
.thumb.show{display:block}
/* Preset starting-world thumbnails */
.presets{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:10px}
.preset{position:relative;border:1px solid var(--edge);border-radius:9px;overflow:hidden;
cursor:pointer;background:#0d1220;aspect-ratio:16/9;transition:.15s}
.preset:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(91,140,255,.25)}
.preset.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(91,140,255,.45)}
.preset img{width:100%;height:100%;object-fit:cover;display:block}
.preset .pl{position:absolute;left:0;right:0;bottom:0;font-size:10px;color:#fff;
padding:3px 5px;background:linear-gradient(0deg,rgba(5,7,12,.85),transparent);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.or-sep{font-size:11px;color:var(--muted);text-align:center;margin:12px 0 8px;
text-transform:uppercase;letter-spacing:.7px}
.field{margin-top:14px}
.label{font-size:11.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);margin-bottom:6px}
textarea,input[type=number]{width:100%;background:#0d1220;color:var(--text);border:1px solid var(--edge);
border-radius:10px;padding:10px 12px;font-size:13.5px;font-family:inherit;resize:vertical}
textarea:focus,input:focus{outline:none;border-color:var(--accent)}
.row{display:flex;gap:10px}
.row>*{flex:1}
.btns{display:flex;gap:10px;margin-top:16px}
button{border:none;border-radius:10px;padding:11px 14px;font-size:13.5px;font-weight:600;
cursor:pointer;font-family:inherit;transition:.15s;flex:1}
.primary{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#fff;
box-shadow:0 6px 20px rgba(91,140,255,.32)}
.primary:hover{filter:brightness(1.07)}
.ghost{background:transparent;color:var(--muted);border:1px solid var(--edge)}
.ghost:hover{color:var(--text);border-color:var(--accent)}
button:disabled{opacity:.45;cursor:not-allowed;filter:none}
/* Controls section (below the stage) */
.controls-section{margin-top:20px}
.controls-grid{display:grid;grid-template-columns:auto 1fr;gap:28px;align-items:center;margin-top:12px}
@media (max-width:640px){.controls-grid{grid-template-columns:1fr;gap:20px}}
/* Visual on-screen WASD keyboard */
.keyboard{display:grid;grid-template-columns:repeat(3,56px);grid-template-rows:repeat(2,56px);
gap:8px;justify-content:start}
.keycap{display:grid;place-items:center;border-radius:12px;background:#0d1220;
border:1px solid var(--edge);font-size:18px;color:var(--muted);font-weight:700;
transition:.08s;user-select:none}
.keycap.spacer{background:transparent;border:none}
.keycap.active{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#fff;
border-color:transparent;box-shadow:0 4px 16px rgba(91,140,255,.45);transform:translateY(1px)}
/* WASD cross placement inside the 3x2 grid */
.keycap[data-k="W"]{grid-column:2;grid-row:1}
.keycap[data-k="A"]{grid-column:1;grid-row:2}
.keycap[data-k="S"]{grid-column:2;grid-row:2}
.keycap[data-k="D"]{grid-column:3;grid-row:2}
/* IJKL look cross placement inside its own 3x2 grid */
.keycap[data-k="I"]{grid-column:2;grid-row:1}
.keycap[data-k="J"]{grid-column:1;grid-row:2}
.keycap[data-k="K"]{grid-column:2;grid-row:2}
.keycap[data-k="L"]{grid-column:3;grid-row:2}
.look-info{font-size:12.5px;color:var(--muted);line-height:1.6}
.look-info b{color:var(--text)}
.look-badge{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--good);
border:1px solid var(--edge);border-radius:999px;padding:4px 10px;margin-top:8px}
.look-badge .d{width:7px;height:7px;border-radius:50%;background:var(--muted)}
.look-badge.active .d{background:var(--good);box-shadow:0 0 8px var(--good)}
.hint-line{font-size:11.5px;color:var(--muted);margin-top:14px;line-height:1.55}
.active-tags{font-size:12px;color:var(--good);min-height:16px;margin-top:8px}
.footnote{font-size:11px;color:var(--muted);margin-top:12px;line-height:1.5}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<div class="logo">🌍</div>
<div>
<h1>ABot-World — Interactive World Rollout</h1>
<p>Upload a starting frame, then steer a live action-conditioned world with WASD + IJKL.</p>
</div>
</div>
<div class="links">
<a href="https://huggingface.co/acvlab/ABot-World-0-5B-LF" target="_blank">Model</a>
<a href="https://amap-cvlab.github.io/ABot-World/" target="_blank">Project</a>
<a href="https://github.com/amap-cvlab/ABot-World" target="_blank">Code</a>
</div>
</header>
<div class="layout">
<!-- Stage -->
<div class="card">
<div class="stage" id="stage" tabindex="0">
<canvas id="view" width="1280" height="704"></canvas>
<div class="hud">
<div class="chip"><span class="dot" id="status-dot"></span><span id="status-label">Idle</span></div>
<div class="chip"><span id="fps-value">0</span> fps · <span id="frame-value">0</span> blk</div>
</div>
<div class="capture-hint" id="capture-hint">Click to capture · WASD to move · IJKL to look · Esc to release</div>
<div class="overlay" id="overlay">
<div>
<div class="spinner" id="spinner" style="display:none"></div>
<h3 id="overlay-title">Upload an image to begin</h3>
<p id="overlay-sub">Pick a starting frame on the right, add a scene prompt, and press <b>Start world</b>.</p>
</div>
</div>
</div>
<!-- Controls (directly below the video output) -->
<div class="controls-section">
<div class="controls-grid">
<div>
<div class="label" style="margin-bottom:8px">Movement</div>
<div class="keyboard" id="keys">
<span class="keycap" data-k="W">W</span>
<span class="keycap" data-k="A">A</span>
<span class="keycap" data-k="S">S</span>
<span class="keycap" data-k="D">D</span>
</div>
</div>
<div>
<div class="label" style="margin-bottom:8px">Look / pan</div>
<div class="keyboard" id="look-keys">
<span class="keycap" data-k="I">I</span>
<span class="keycap" data-k="J">J</span>
<span class="keycap" data-k="K">K</span>
<span class="keycap" data-k="L">L</span>
</div>
<div class="look-badge" id="look-badge"><span class="d"></span><span id="look-badge-text">Look idle</span></div>
</div>
</div>
<div class="hint-line">
<b>W A S D</b> — move &amp; turn (hold to steer the next block) ·
<b>I J K L</b> — look / pan. Click the video to capture input.
</div>
<div class="active-tags" id="active-tags"></div>
</div>
</div>
<!-- Controls -->
<div class="card">
<div class="label">Starting image (i2v seed)</div>
<div class="presets" id="presets"></div>
<div class="or-sep">or upload your own</div>
<div class="drop" id="drop">
<div class="icon">🖼️</div>
<div class="t">Drop an image or click to upload</div>
<div class="s">PNG / JPG · becomes the world's first frame</div>
</div>
<input type="file" id="file-input" accept="image/*" style="display:none" />
<div class="thumb" id="thumb"><img id="thumb-img" alt="seed preview" /></div>
<div class="field">
<div class="label">Scene prompt</div>
<textarea id="prompt" rows="3">A realistic outdoor world scene with a navigable path, natural lighting, detailed ground texture, and stable forward motion.</textarea>
</div>
<div class="field row">
<div>
<div class="label">Seed</div>
<input type="number" id="seed" value="42" />
</div>
</div>
<div class="btns">
<button class="primary" id="start-btn" disabled>Start world</button>
<button class="ghost" id="stop-btn" disabled>Stop</button>
</div>
</div>
</div>
</div>
<script type="module">
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
// One session id per tab keeps every server-side queue isolated per user.
const SESSION_ID = (crypto.randomUUID && crypto.randomUUID()) ||
(`s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`);
// Movement keys are driven by the keyboard (WASD); look/pan is driven by the
// keyboard too (I J K L) — both are handled as held-key controls.
const MOVE_KEYS = new Set(["W","A","S","D"]);
const LOOK_KEYS = new Set(["I","J","K","L"]);
const KEY_FROM_CODE = {
KeyW:"W", KeyA:"A", KeyS:"S", KeyD:"D",
KeyI:"I", KeyJ:"J", KeyK:"K", KeyL:"L",
};
const state = {
sessionId: SESSION_ID,
client: null,
ws: null,
playing: false,
capturing: false,
pressed: new Set(),
seedPath: "",
prompt: "",
};
// --- DOM ---
const $ = (id) => document.getElementById(id);
const stage = $("stage"), view = $("view"), overlay = $("overlay");
const viewCtx = view.getContext("2d", { alpha: false, desynchronized: true });
const spinner = $("spinner"), overlayTitle = $("overlay-title"), overlaySub = $("overlay-sub");
const statusDot = $("status-dot"), statusLabel = $("status-label");
const fpsValue = $("fps-value"), frameValue = $("frame-value");
const drop = $("drop"), fileInput = $("file-input"), thumb = $("thumb"), thumbImg = $("thumb-img");
const presetsEl = $("presets");
const promptEl = $("prompt"), seedEl = $("seed");
const startBtn = $("start-btn"), stopBtn = $("stop-btn");
const activeTags = $("active-tags");
const keycaps = Array.from(document.querySelectorAll(".keycap[data-k]"));
const lookBadge = $("look-badge"), lookBadgeText = $("look-badge-text");
// --- UI helpers ---
function setStatus(kind, label){
statusDot.className = "dot " + kind;
statusLabel.textContent = label;
}
function showOverlay(title, sub, loading){
overlay.classList.remove("hidden");
overlayTitle.textContent = title;
overlaySub.textContent = sub || "";
spinner.style.display = loading ? "block" : "none";
}
function hideOverlay(){ overlay.classList.add("hidden"); }
function setPlaying(on){
state.playing = on;
stage.classList.toggle("playing", on);
stopBtn.disabled = !on;
}
function refreshStartEnabled(){
startBtn.disabled = !state.seedPath || state.playing;
}
// --- Upload ---
async function uploadFile(file){
if(!file || !file.type.startsWith("image/")){ return; }
clearPresetSelection();
// Local preview
const reader = new FileReader();
reader.onload = (e)=>{ thumbImg.src = e.target.result; thumb.classList.add("show"); };
reader.readAsDataURL(file);
drop.querySelector(".t").textContent = "Uploading…";
const fd = new FormData();
fd.append("file", file, file.name || "seed.png");
try{
const res = await fetch(`${window.location.origin}/upload_seed`, { method:"POST", body:fd });
const data = await res.json();
if(data.seed_path){
state.seedPath = data.seed_path;
drop.querySelector(".t").textContent = "Image ready ✓ — click to replace";
showOverlay("Ready to explore", "Press Start world to begin the rollout.", false);
}else{
drop.querySelector(".t").textContent = "Upload failed — try another image";
}
}catch(err){
console.error(err);
drop.querySelector(".t").textContent = "Upload failed — try again";
}
refreshStartEnabled();
}
drop.addEventListener("click", ()=>fileInput.click());
fileInput.addEventListener("change", (e)=>uploadFile(e.target.files[0]));
["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,(e)=>{e.preventDefault();drop.classList.add("drag");}));
["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,(e)=>{e.preventDefault();drop.classList.remove("drag");}));
drop.addEventListener("drop",(e)=>{ if(e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); });
// --- Preset starting-world thumbnails (seed rollout directly, no upload) ---
function clearPresetSelection(){
presetsEl.querySelectorAll(".preset").forEach(p=>p.classList.remove("selected"));
}
async function pickPreset(name, el){
clearPresetSelection();
el.classList.add("selected");
// Show the chosen preset in the seed preview too.
thumbImg.src = `${window.location.origin}/example_thumb?name=${encodeURIComponent(name)}`;
thumb.classList.add("show");
drop.querySelector(".t").textContent = "Loading preset…";
try{
const res = await fetch(`${window.location.origin}/example_seed?name=${encodeURIComponent(name)}`);
const data = await res.json();
if(data.seed_path){
state.seedPath = data.seed_path;
drop.querySelector(".t").textContent = "Preset ready ✓ — or upload to replace";
showOverlay("Ready to explore", "Press Start world to begin the rollout.", false);
}else{
drop.querySelector(".t").textContent = "Could not load preset — try another";
el.classList.remove("selected");
}
}catch(err){
console.error(err);
drop.querySelector(".t").textContent = "Could not load preset — try again";
el.classList.remove("selected");
}
refreshStartEnabled();
}
async function loadPresets(){
try{
const res = await fetch(`${window.location.origin}/example_seeds`);
const data = await res.json();
(data.examples || []).forEach(ex=>{
const div = document.createElement("div");
div.className = "preset";
div.title = ex.label || ex.name;
div.innerHTML =
`<img loading="lazy" alt="${ex.label || ex.name}" ` +
`src="${window.location.origin}/example_thumb?name=${encodeURIComponent(ex.name)}" />` +
`<span class="pl">${ex.label || ex.name}</span>`;
div.addEventListener("click", ()=>pickPreset(ex.name, div));
presetsEl.appendChild(div);
});
}catch(err){ console.error("presets load failed", err); }
}
// --- Gradio client (auto-forwards the HF iframe x-api-token for quota) ---
async function initClient(){
if(!state.client) state.client = await Client.connect(window.location.origin);
}
// --- Frame decode + jitter buffer + steady render clock ---
// Rendering is DECOUPLED from network arrival. WebSocket messages only decode
// frames into a small jitter buffer; a steady render clock (requestAnimationFrame)
// then pulls the freshest decoded frame at a smoothed target interval and paints
// it. This absorbs network jitter and the server's block-boundary bursts so the
// visible cadence stays even instead of "burst of N frames, then a gap".
//
// The buffer is intentionally shallow (JITTER_MAX): if frames arrive faster than
// we render, older ones are dropped (coalesced) rather than queued, so we never
// accumulate buffering delay that would later flush as a burst of stale frames.
const JITTER_MAX = 4; // max decoded frames held before we drop-oldest
const JITTER_MIN = 2; // frames to buffer before the render clock starts
// (a 2-frame lead absorbs network gaps so the
// render clock rarely starves between bursts)
let jitterBuf = []; // [{bitmap, blk, fps}] decoded, awaiting display
let renderPrimed = false; // becomes true once JITTER_MIN frames buffered
let renderInterval = 1000 / 16; // ms between paints; adapted to server fps (EMA)
let lastPaint = 0; // performance.now() of last painted frame
let lastArrival = 0; // performance.now() of last WS frame (for interval est.)
let arrivalEma = 0; // EMA of inter-arrival gap (ms)
async function ingestFrame(bytes, blk, fps){
// Estimate the true incoming cadence from arrival timing and adapt the render
// interval toward it (bounded), so the steady clock matches the real fps.
const now = performance.now();
if(lastArrival){
const gap = now - lastArrival;
arrivalEma = arrivalEma ? (0.2 * gap + 0.8 * arrivalEma) : gap;
const tgt = Math.min(200, Math.max(20, arrivalEma)); // clamp 5–50 fps
renderInterval = 0.2 * tgt + 0.8 * renderInterval;
}
lastArrival = now;
let bitmap = null;
try{
bitmap = await createImageBitmap(new Blob([bytes], {type:"image/jpeg"}));
}catch(err){
// Fallback path for browsers without createImageBitmap JPEG support:
// paint straight to canvas (bypasses buffer but keeps the stream alive).
await paintViaImage(bytes, blk, fps);
return;
}
jitterBuf.push({ bitmap, blk, fps });
// Drop-oldest to keep the buffer shallow (avoid stale-frame burst on flush).
while(jitterBuf.length > JITTER_MAX){
const stale = jitterBuf.shift();
if(stale.bitmap && stale.bitmap.close) stale.bitmap.close();
}
if(!renderPrimed && jitterBuf.length >= JITTER_MIN){
renderPrimed = true;
lastPaint = performance.now() - renderInterval; // paint first frame promptly
}
}
function paintBitmap(entry){
viewCtx.drawImage(entry.bitmap, 0, 0, view.width, view.height);
if(entry.bitmap && entry.bitmap.close) entry.bitmap.close();
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
frameValue.textContent = entry.blk;
fpsValue.textContent = entry.fps.toFixed(1);
}
// Steady render clock: runs continuously via rAF while playing; pulls at most one
// buffered frame per render-interval so display timing is even, not arrival-tied.
function renderClock(){
requestAnimationFrame(renderClock);
if(!renderPrimed || jitterBuf.length === 0) return;
const now = performance.now();
if(now - lastPaint < renderInterval) return; // not yet this frame's slot
const entry = jitterBuf.shift(); // freshest frames kept; oldest painted in order
lastPaint = now;
paintBitmap(entry);
}
requestAnimationFrame(renderClock);
function resetJitterBuffer(){
for(const e of jitterBuf){ if(e.bitmap && e.bitmap.close) e.bitmap.close(); }
jitterBuf = [];
renderPrimed = false;
lastArrival = 0;
arrivalEma = 0;
}
function paintViaImage(bytes, blk, fps){
return new Promise((resolve)=>{
const url = URL.createObjectURL(new Blob([bytes], {type:"image/jpeg"}));
const im = new Image();
im.onload = ()=>{
viewCtx.drawImage(im, 0, 0, view.width, view.height);
URL.revokeObjectURL(url);
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
if(typeof blk !== "undefined") frameValue.textContent = blk;
if(typeof fps !== "undefined") fpsValue.textContent = fps.toFixed(1);
resolve(true);
};
im.onerror = ()=>{ URL.revokeObjectURL(url); resolve(false); };
im.src = url;
});
}
// --- WebSocket frame stream ---
function connectWS(){
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws?session_id=${encodeURIComponent(state.sessionId)}`);
ws.binaryType = "arraybuffer";
ws.onmessage = (e)=>{
if(e.data instanceof ArrayBuffer){
const dv = new DataView(e.data);
const blk = dv.getUint32(0);
const fps = dv.getUint32(4)/10;
// Decode into the jitter buffer; the steady render clock paints on its own
// cadence, so display timing is decoupled from network arrival jitter.
ingestFrame(e.data.slice(8), blk, fps);
}else{
try{
const msg = JSON.parse(e.data);
if(msg.type === "status"){ overlayTitle.textContent = msg.message; }
else if(msg.type === "session_ended"){
setStatus("ended","Session ended"); setPlaying(false);
showOverlay("Session ended","GPU time limit reached — press Start world to continue.",false);
}else if(msg.type === "error"){
setStatus("error","Error"); setPlaying(false);
showOverlay("Something went wrong", msg.message || "Unknown error", false);
}
}catch{}
}
};
ws.onclose = ()=>{ if(state.playing) setTimeout(connectWS, 1000); };
state.ws = ws;
}
// --- Control loop: send held keys ~10x/s ---
let ctrlTimer = null;
function startCtrlLoop(){
if(ctrlTimer) return;
ctrlTimer = setInterval(()=>{
if(state.ws?.readyState === WebSocket.OPEN && state.playing){
state.ws.send(JSON.stringify({
type:"control",
buttons: Array.from(state.pressed),
prompt: state.prompt,
}));
}
}, 100);
}
function stopCtrlLoop(){ if(ctrlTimer){ clearInterval(ctrlTimer); ctrlTimer = null; } }
function renderKeys(){
// Highlight the on-screen WASD + IJKL keys that are currently held.
keycaps.forEach(c=>c.classList.toggle("active", state.pressed.has(c.dataset.k)));
// Look/pan status badge reflects the held look keys (I J K L).
const look = Array.from(state.pressed).filter(k=>LOOK_KEYS.has(k));
const looking = look.length > 0;
lookBadge.classList.toggle("active", looking);
const LOOK_LABEL = { I:"up", K:"down", J:"left", L:"right" };
lookBadgeText.textContent = looking
? "Looking " + look.map(k=>LOOK_LABEL[k]).join(" + ")
: (state.capturing ? "Look ready" : "Look idle");
const held = Array.from(state.pressed);
activeTags.textContent = held.length ? "Active: " + held.join(" + ") : "";
}
// --- Keyboard capture: WASD (move) + IJKL (look/pan) ---
// Clicking the stage captures input; held keys are sent ~10x/s to the server.
function requestCapture(){
state.capturing = true;
stage.focus();
renderKeys();
}
stage.addEventListener("click", requestCapture);
document.addEventListener("keydown",(e)=>{
if(!state.capturing) return;
if(e.code === "Escape"){
state.capturing=false; state.pressed.clear();
renderKeys(); return;
}
const k = KEY_FROM_CODE[e.code];
if(k && (MOVE_KEYS.has(k) || LOOK_KEYS.has(k))){ e.preventDefault(); state.pressed.add(k); renderKeys(); }
});
document.addEventListener("keyup",(e)=>{
const k = KEY_FROM_CODE[e.code];
if(k){ state.pressed.delete(k); renderKeys(); }
});
promptEl.addEventListener("input", ()=>{ state.prompt = promptEl.value; });
// --- Start / Stop ---
async function startWorld(){
if(!state.seedPath) return;
state.prompt = promptEl.value;
await initClient();
setStatus("loading","Starting…");
showOverlay("Initializing GPU & world…","Requesting a ZeroGPU slot — first run can take a moment.", true);
startBtn.disabled = true;
connectWS();
startCtrlLoop();
try{
await state.client.predict("/start_game", {
session_id: state.sessionId,
seed_path: state.seedPath,
prompt: state.prompt || "",
seed: parseInt(seedEl.value || "42", 10),
});
// Auto-capture keyboard so the user can steer immediately.
state.capturing = true; stage.focus();
}catch(err){
console.error("start failed", err);
setStatus("error","Failed to start");
showOverlay("Failed to start", String(err?.message || err), false);
startBtn.disabled = false;
}
}
async function stopWorld(){
try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{}
if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; }
resetJitterBuffer();
stopCtrlLoop();
setPlaying(false);
state.pressed.clear(); renderKeys();
setStatus("idle","Idle");
showOverlay("Stopped","Press Start world to roll out again.", false);
refreshStartEnabled();
}
startBtn.addEventListener("click", startWorld);
stopBtn.addEventListener("click", stopWorld);
// --- Init ---
setStatus("idle","Idle");
state.prompt = promptEl.value;
loadPresets();
initClient().catch(console.error);
</script>
</body>
</html>