| <!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 · Modular Diffusers</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{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} |
| |
| |
| .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} |
| |
| |
| .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{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}} |
| |
| |
| .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)} |
| |
| .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} |
| |
| .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 on Modular Diffusers</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/YiYiXu/ABot-World-0-5B-LF-Diffusers" target="_blank">Model</a> |
| <a href="https://github.com/huggingface/diffusers/pull/14159" target="_blank">Modular streaming API</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"> |
| |
| <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> |
|
|
| |
| <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 & 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> |
|
|
| |
| <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"; |
| |
| |
| const SESSION_ID = (crypto.randomUUID && crypto.randomUUID()) || |
| (`s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`); |
| |
| |
| |
| 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: "", |
| }; |
| |
| |
| 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"); |
| |
| |
| 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; |
| } |
| |
| |
| async function uploadFile(file){ |
| if(!file || !file.type.startsWith("image/")){ return; } |
| clearPresetSelection(); |
| |
| 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]); }); |
| |
| |
| function clearPresetSelection(){ |
| presetsEl.querySelectorAll(".preset").forEach(p=>p.classList.remove("selected")); |
| } |
| async function pickPreset(name, el){ |
| clearPresetSelection(); |
| el.classList.add("selected"); |
| |
| 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); } |
| } |
| |
| |
| async function initClient(){ |
| if(!state.client) state.client = await Client.connect(window.location.origin); |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const JITTER_MAX = 4; |
| const JITTER_MIN = 2; |
| |
| |
| let jitterBuf = []; |
| let renderPrimed = false; |
| let renderInterval = 1000 / 16; |
| let lastPaint = 0; |
| let lastArrival = 0; |
| let arrivalEma = 0; |
| |
| async function ingestFrame(bytes, blk, 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)); |
| renderInterval = 0.2 * tgt + 0.8 * renderInterval; |
| } |
| lastArrival = now; |
| |
| let bitmap = null; |
| try{ |
| bitmap = await createImageBitmap(new Blob([bytes], {type:"image/jpeg"})); |
| }catch(err){ |
| |
| |
| await paintViaImage(bytes, blk, fps); |
| return; |
| } |
| jitterBuf.push({ bitmap, blk, fps }); |
| |
| 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; |
| } |
| } |
| |
| 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); |
| } |
| |
| |
| |
| function renderClock(){ |
| requestAnimationFrame(renderClock); |
| if(!renderPrimed || jitterBuf.length === 0) return; |
| const now = performance.now(); |
| if(now - lastPaint < renderInterval) return; |
| const entry = jitterBuf.shift(); |
| 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; |
| }); |
| } |
| |
| |
| 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; |
| |
| |
| 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; |
| } |
| |
| |
| 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(){ |
| |
| keycaps.forEach(c=>c.classList.toggle("active", state.pressed.has(c.dataset.k))); |
| |
| 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(" + ") : ""; |
| } |
| |
| |
| |
| 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; }); |
| |
| |
| 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), |
| }); |
| |
| 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); |
| |
| |
| setStatus("idle","Idle"); |
| state.prompt = promptEl.value; |
| loadPresets(); |
| initClient().catch(console.error); |
| </script> |
| </body> |
| </html> |
|
|