Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Waypoint v1.5</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0a0a0f; | |
| --surface: rgba(15, 17, 23, 0.95); | |
| --panel: rgba(0, 0, 0, 0.3); | |
| --border: rgba(88, 166, 255, 0.15); | |
| --border-active: #58a6ff; | |
| --accent: #58a6ff; | |
| --accent-dim: rgba(88, 166, 255, 0.1); | |
| --accent-mid: rgba(88, 166, 255, 0.2); | |
| --accent-bright: rgba(88, 166, 255, 0.4); | |
| --text: #e6edf3; | |
| --text-dim: #8b949e; | |
| --text-muted: #484f58; | |
| --green: #3fb950; | |
| --red: #ff6b6b; | |
| --yellow: #d29922; | |
| --font: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace; | |
| --sidebar-w: 300px; | |
| } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: var(--font); | |
| overflow: hidden; | |
| height: 100dvh; | |
| display: flex; | |
| } | |
| /* --- Layout --- */ | |
| #main-area { | |
| flex: 1; | |
| min-width: 0; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100dvh; | |
| } | |
| #sidebar { | |
| width: var(--sidebar-w); | |
| flex-shrink: 0; | |
| background: var(--surface); | |
| border-left: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| height: 100dvh; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| scrollbar-width: thin; | |
| scrollbar-color: rgba(88,166,255,0.2) transparent; | |
| } | |
| .sidebar-section { | |
| padding: 12px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .sidebar-section:last-child { border-bottom: none; } | |
| .section-label { | |
| font-size: 10px; | |
| color: var(--text-dim); | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| margin-bottom: 8px; | |
| } | |
| /* --- Game View --- */ | |
| #game-container { | |
| position: relative; | |
| width: 100%; | |
| flex: 1; | |
| min-height: 0; | |
| background: #000; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| } | |
| #game-container.capturing { cursor: none; } | |
| #game-view { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| image-rendering: auto; | |
| display: block; | |
| } | |
| /* --- HUD Overlay --- */ | |
| #hud { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; | |
| padding: 10px 14px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| .hud-group { display: flex; gap: 8px; align-items: center; } | |
| .hud-badge { | |
| background: rgba(0,0,0,0.7); | |
| backdrop-filter: blur(8px); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 4px 10px; | |
| font-size: 11px; | |
| color: var(--text-dim); | |
| letter-spacing: 0.5px; | |
| white-space: nowrap; | |
| } | |
| .hud-badge strong { color: var(--text); font-weight: 600; } | |
| .hud-badge .dot { | |
| display: inline-block; | |
| width: 7px; height: 7px; | |
| border-radius: 50%; | |
| margin-right: 5px; | |
| vertical-align: middle; | |
| } | |
| .dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); } | |
| .dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); } | |
| .dot-yellow { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); } | |
| /* --- Loading Overlay --- */ | |
| #loading-overlay { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(0,0,0,0.85); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 20; | |
| gap: 16px; | |
| } | |
| #loading-overlay.hidden { display: none; } | |
| .spinner { | |
| width: 40px; height: 40px; | |
| border: 3px solid var(--border); | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| #loading-text { font-size: 14px; color: var(--text-dim); text-align: center; } | |
| #loading-subtext { font-size: 11px; color: rgba(139,148,158,0.6); } | |
| /* --- Session Ended Overlay --- */ | |
| #ended-overlay { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(0,0,0,0.6); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 20; | |
| gap: 12px; | |
| backdrop-filter: blur(2px); | |
| } | |
| #ended-overlay.hidden { display: none; } | |
| #ended-overlay .ended-title { | |
| font-size: 18px; | |
| font-weight: 600; | |
| color: var(--text); | |
| } | |
| #ended-overlay .ended-sub { | |
| font-size: 12px; | |
| color: var(--text-dim); | |
| margin-bottom: 4px; | |
| } | |
| #ended-overlay .btn-restart { | |
| padding: 10px 28px; | |
| border: 1px solid var(--accent); | |
| border-radius: 8px; | |
| background: var(--accent); | |
| color: #000; | |
| font-family: var(--font); | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| } | |
| #ended-overlay .btn-restart:hover { background: #79b8ff; } | |
| /* --- Controls Help Overlay (desktop) --- */ | |
| #controls-help { | |
| position: absolute; | |
| bottom: 16px; left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0,0,0,0.8); | |
| backdrop-filter: blur(8px); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 12px 20px; | |
| z-index: 15; | |
| pointer-events: none; | |
| text-align: center; | |
| transition: opacity 0.3s; | |
| } | |
| #controls-help.hidden { opacity: 0; pointer-events: none; } | |
| .controls-row { display: flex; gap: 16px; justify-content: center; margin-top: 6px; } | |
| .control-hint { font-size: 10px; color: var(--text-dim); } | |
| .control-hint kbd { | |
| display: inline-block; | |
| background: var(--accent-dim); | |
| border: 1px solid rgba(88,166,255,0.3); | |
| border-radius: 3px; | |
| padding: 1px 5px; | |
| color: var(--accent); | |
| font-size: 10px; | |
| font-family: var(--font); | |
| } | |
| /* --- Bottom Bar --- */ | |
| #bottom-bar { | |
| background: var(--surface); | |
| border-top: 1px solid var(--border); | |
| padding: 8px 12px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex-shrink: 0; | |
| z-index: 5; | |
| } | |
| /* --- Buttons --- */ | |
| .btn { | |
| padding: 6px 16px; | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| background: var(--accent-dim); | |
| color: var(--accent); | |
| font-family: var(--font); | |
| font-size: 12px; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| } | |
| .btn:hover { background: var(--accent-mid); } | |
| .btn:active { transform: scale(0.97); } | |
| .btn.primary { background: var(--accent); color: #000; font-weight: 600; } | |
| .btn.primary:hover { background: #79b8ff; } | |
| .btn.danger { border-color: rgba(255,107,107,0.3); color: var(--red); background: rgba(255,107,107,0.1); } | |
| .btn.danger:hover { background: rgba(255,107,107,0.2); } | |
| .btn:disabled { opacity: 0.3; cursor: not-allowed; } | |
| .btn.full { width: 100%; text-align: center; } | |
| /* --- Key Visualizer --- */ | |
| .key-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 4px; | |
| max-width: 108px; | |
| margin: 0 auto; | |
| } | |
| .key-btn { | |
| aspect-ratio: 1; | |
| min-height: 32px; | |
| background: var(--accent-dim); | |
| border: 1px solid rgba(88,166,255,0.3); | |
| border-radius: 6px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 12px; | |
| color: var(--accent); | |
| transition: all 0.1s; | |
| cursor: pointer; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| touch-action: manipulation; | |
| } | |
| .key-btn.pressed { | |
| background: var(--accent-bright); | |
| border-color: var(--accent); | |
| box-shadow: 0 0 8px rgba(88,166,255,0.3); | |
| } | |
| .key-btn.empty { background: none; border: none; cursor: default; } | |
| .extra-keys { | |
| display: flex; | |
| gap: 4px; | |
| margin-top: 8px; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| } | |
| .key-wide { | |
| padding: 6px 10px; | |
| min-height: 28px; | |
| background: var(--accent-dim); | |
| border: 1px solid rgba(88,166,255,0.3); | |
| border-radius: 4px; | |
| font-size: 10px; | |
| color: var(--accent); | |
| cursor: pointer; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| touch-action: manipulation; | |
| display: flex; | |
| align-items: center; | |
| transition: all 0.1s; | |
| } | |
| .key-wide.pressed { | |
| background: var(--accent-bright); | |
| border-color: var(--accent); | |
| } | |
| /* --- Mouse / Joystick --- */ | |
| .joystick-container { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| } | |
| .joystick-ring { | |
| width: 80px; height: 80px; | |
| min-width: 80px; | |
| background: rgba(88,166,255,0.05); | |
| border: 2px solid rgba(88,166,255,0.3); | |
| border-radius: 50%; | |
| position: relative; | |
| cursor: pointer; | |
| touch-action: none; | |
| } | |
| .joystick-dot { | |
| width: 20px; height: 20px; | |
| background: var(--accent); | |
| border-radius: 50%; | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| transform: translate(-50%, -50%); | |
| box-shadow: 0 0 12px rgba(88,166,255,0.6); | |
| pointer-events: none; | |
| transition: opacity 0.1s; | |
| } | |
| .mouse-values { | |
| text-align: left; | |
| font-size: 10px; | |
| } | |
| .mouse-values .label { color: var(--text-dim); margin-bottom: 2px; } | |
| .mouse-values .val { color: var(--accent); } | |
| /* --- Active Buttons Display --- */ | |
| .active-bar { | |
| background: var(--panel); | |
| border-radius: 8px; | |
| padding: 6px 10px; | |
| border: 1px solid var(--border); | |
| margin-top: 8px; | |
| } | |
| .active-bar .row { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .active-bar .label { | |
| font-size: 10px; | |
| color: var(--text-dim); | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .active-tags { | |
| display: flex; | |
| gap: 4px; | |
| flex-wrap: wrap; | |
| min-height: 18px; | |
| } | |
| .active-tag { | |
| font-size: 10px; | |
| background: var(--accent-mid); | |
| color: var(--accent); | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| } | |
| /* --- World Selector --- */ | |
| .world-grid { | |
| display: flex; | |
| gap: 6px; | |
| flex-wrap: wrap; | |
| } | |
| .world-thumb { | |
| width: 56px; height: 28px; | |
| border-radius: 4px; | |
| border: 2px solid transparent; | |
| cursor: pointer; | |
| object-fit: cover; | |
| transition: border-color 0.15s, transform 0.15s; | |
| } | |
| .world-thumb:hover { border-color: var(--accent); transform: scale(1.05); } | |
| .world-thumb.selected { border-color: var(--accent); box-shadow: 0 0 8px rgba(88,166,255,0.4); } | |
| /* --- Custom Image Upload --- */ | |
| .upload-area { | |
| border: 2px dashed rgba(88,166,255,0.25); | |
| border-radius: 8px; | |
| padding: 12px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: border-color 0.15s, background 0.15s; | |
| position: relative; | |
| min-height: 60px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| } | |
| .upload-area:hover { border-color: var(--accent); background: rgba(88,166,255,0.05); } | |
| .upload-area.has-image { border-style: solid; padding: 4px; } | |
| .upload-area img { | |
| max-width: 100%; | |
| max-height: 80px; | |
| object-fit: contain; | |
| border-radius: 4px; | |
| } | |
| .upload-area .placeholder { font-size: 11px; color: var(--text-dim); } | |
| .upload-area .icon { font-size: 20px; opacity: 0.5; } | |
| .upload-clear { | |
| position: absolute; | |
| top: 4px; right: 4px; | |
| background: rgba(255,107,107,0.8); | |
| border: none; | |
| color: #fff; | |
| width: 18px; height: 18px; | |
| border-radius: 50%; | |
| font-size: 11px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| line-height: 1; | |
| } | |
| /* --- Prompt --- */ | |
| #prompt-input { | |
| width: 100%; | |
| background: rgba(0,0,0,0.3); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| font-family: var(--font); | |
| font-size: 11px; | |
| padding: 6px 10px; | |
| outline: none; | |
| resize: vertical; | |
| min-height: 32px; | |
| } | |
| #prompt-input:focus { border-color: var(--accent); } | |
| /* --- Status Indicator in Sidebar --- */ | |
| .status-pill { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: rgba(0,0,0,0.4); | |
| padding: 6px 12px; | |
| border-radius: 20px; | |
| border: 1px solid var(--border); | |
| margin-bottom: 10px; | |
| } | |
| .status-pill .dot { | |
| width: 8px; height: 8px; | |
| border-radius: 50%; | |
| } | |
| .status-pill .label { | |
| font-size: 11px; | |
| color: var(--text-dim); | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| /* --- Mobile --- */ | |
| @media (max-width: 768px) { | |
| body { flex-direction: column; } | |
| #main-area { | |
| height: auto; | |
| flex: 1; | |
| min-height: 0; | |
| } | |
| #game-container { | |
| /* Take ~50% of viewport on mobile */ | |
| min-height: 35dvh; | |
| flex: 1; | |
| } | |
| #sidebar { | |
| width: 100%; | |
| height: auto; | |
| max-height: 55dvh; | |
| border-left: none; | |
| border-top: 1px solid var(--border); | |
| flex-shrink: 0; | |
| } | |
| #controls-help { display: none; } | |
| .key-grid { max-width: 120px; } | |
| .key-btn { min-height: 38px; font-size: 13px; } | |
| .key-wide { min-height: 34px; padding: 6px 14px; font-size: 11px; } | |
| .joystick-ring { width: 90px; height: 90px; min-width: 90px; } | |
| .world-thumb { width: 44px; height: 22px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Main game area --> | |
| <div id="main-area"> | |
| <div id="game-container"> | |
| <img id="game-view" alt=""> | |
| <!-- HUD --> | |
| <div id="hud"> | |
| <div class="hud-group"> | |
| <div class="hud-badge"> | |
| <span class="dot dot-red" id="status-dot"></span> | |
| <span id="status-label">Idle</span> | |
| </div> | |
| </div> | |
| <div class="hud-group"> | |
| <div class="hud-badge"><strong id="fps-value">0</strong> FPS</div> | |
| <div class="hud-badge">Frame <strong id="frame-value">0</strong></div> | |
| </div> | |
| </div> | |
| <!-- Loading --> | |
| <div id="loading-overlay" class="hidden"> | |
| <div class="spinner"></div> | |
| <div id="loading-text">Initializing GPU...</div> | |
| <div id="loading-subtext"></div> | |
| </div> | |
| <!-- Session Ended --> | |
| <div id="ended-overlay" class="hidden"> | |
| <div class="ended-title" id="ended-title">Session Ended</div> | |
| <div class="ended-sub" id="ended-sub">GPU time limit reached (120s)</div> | |
| <button class="btn-restart" id="restart-btn">Start Over</button> | |
| </div> | |
| <!-- Controls Help (desktop only) --> | |
| <div id="controls-help" class="hidden"> | |
| <div style="font-size: 12px; color: var(--text); margin-bottom: 4px;">Click to capture controls</div> | |
| <div class="controls-row"> | |
| <span class="control-hint"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> Move</span> | |
| <span class="control-hint"><kbd>Mouse</kbd> Look</span> | |
| <span class="control-hint"><kbd>Space</kbd> Up <kbd>Shift</kbd> Down</span> | |
| <span class="control-hint"><kbd>ESC</kbd> Release</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Bottom Bar: Start/Stop --> | |
| <div id="bottom-bar"> | |
| <button class="btn primary" id="start-btn">Start Game</button> | |
| <button class="btn danger" id="stop-btn" disabled>Stop</button> | |
| <button class="btn" id="reset-btn" disabled>Reset World</button> | |
| </div> | |
| </div> | |
| <!-- Sidebar --> | |
| <div id="sidebar"> | |
| <!-- Controls Visualizer --> | |
| <div class="sidebar-section"> | |
| <div class="status-pill"> | |
| <div class="dot dot-red" id="ctrl-status-dot"></div> | |
| <span class="label" id="ctrl-status-text">Click game to capture</span> | |
| </div> | |
| <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;"> | |
| <!-- Movement Keys --> | |
| <div style="background: var(--panel); border-radius: 10px; padding: 10px; border: 1px solid var(--border);"> | |
| <div class="section-label">Movement</div> | |
| <div class="key-grid"> | |
| <div class="key-btn empty"></div> | |
| <div class="key-btn" data-key="KeyW">W</div> | |
| <div class="key-btn empty"></div> | |
| <div class="key-btn" data-key="KeyA">A</div> | |
| <div class="key-btn" data-key="KeyS">S</div> | |
| <div class="key-btn" data-key="KeyD">D</div> | |
| </div> | |
| <div class="extra-keys"> | |
| <div class="key-wide" data-key="ShiftLeft">SHIFT</div> | |
| <div class="key-wide" data-key="Space">SPACE</div> | |
| <div class="key-wide" data-key="KeyE">E</div> | |
| </div> | |
| </div> | |
| <!-- Look --> | |
| <div style="background: var(--panel); border-radius: 10px; padding: 10px; border: 1px solid var(--border);"> | |
| <div class="section-label">Look</div> | |
| <div class="joystick-container"> | |
| <div class="joystick-ring" id="look-joystick"> | |
| <div class="joystick-dot" id="joystick-dot"></div> | |
| </div> | |
| </div> | |
| <div class="mouse-values" style="text-align: center; margin-top: 6px;"> | |
| <span class="val">X: <span id="mouse-x-val">0.0</span></span> | |
| <span class="val">Y: <span id="mouse-y-val">0.0</span></span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Active buttons --> | |
| <div class="active-bar"> | |
| <div class="row"> | |
| <span class="label">Active:</span> | |
| <div class="active-tags" id="active-tags"> | |
| <span style="font-size: 11px; color: var(--text-muted);">None</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- World Selection --> | |
| <div class="sidebar-section"> | |
| <div class="section-label">Worlds</div> | |
| <div class="world-grid" id="world-selector"></div> | |
| </div> | |
| <!-- Custom Image Upload --> | |
| <div class="sidebar-section"> | |
| <div class="section-label">Custom Start Image</div> | |
| <div class="upload-area" id="upload-area"> | |
| <div class="icon">+</div> | |
| <div class="placeholder">Click or drop image</div> | |
| </div> | |
| <input type="file" id="file-input" accept="image/*" style="display:none"> | |
| </div> | |
| <!-- Prompt --> | |
| <div class="sidebar-section"> | |
| <div class="section-label">World Prompt</div> | |
| <textarea id="prompt-input" rows="2">An explorable world</textarea> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| // --- State --- | |
| const state = { | |
| playing: false, | |
| capturing: false, | |
| pressedKeys: new Set(), | |
| pressedButtons: new Set(), | |
| mouseVelocity: { x: 0, y: 0 }, | |
| lastMouseMove: 0, | |
| selectedSeedUrl: "", | |
| useCustomImage: false, | |
| prompt: "An explorable world", | |
| ws: null, | |
| client: null, | |
| frameCount: 0, | |
| fps: 0, | |
| }; | |
| const BUTTON_MAP = { | |
| KeyW: 87, KeyA: 65, KeyS: 83, KeyD: 68, | |
| KeyQ: 81, KeyE: 69, KeyR: 82, KeyF: 70, | |
| Space: 32, ShiftLeft: 16, ShiftRight: 16, | |
| }; | |
| const KEY_DISPLAY = { | |
| KeyW: 'W', KeyA: 'A', KeyS: 'S', KeyD: 'D', | |
| ShiftLeft: 'Shift', Space: 'Space', KeyE: 'E', | |
| }; | |
| // --- DOM --- | |
| const gameContainer = document.getElementById("game-container"); | |
| const gameView = document.getElementById("game-view"); | |
| const statusDot = document.getElementById("status-dot"); | |
| const statusLabel = document.getElementById("status-label"); | |
| const fpsValue = document.getElementById("fps-value"); | |
| const frameValue = document.getElementById("frame-value"); | |
| const loadingOverlay = document.getElementById("loading-overlay"); | |
| const loadingText = document.getElementById("loading-text"); | |
| const loadingSubtext = document.getElementById("loading-subtext"); | |
| const controlsHelp = document.getElementById("controls-help"); | |
| const startBtn = document.getElementById("start-btn"); | |
| const stopBtn = document.getElementById("stop-btn"); | |
| const resetBtn = document.getElementById("reset-btn"); | |
| const promptInput = document.getElementById("prompt-input"); | |
| const worldSelector = document.getElementById("world-selector"); | |
| const ctrlStatusDot = document.getElementById("ctrl-status-dot"); | |
| const ctrlStatusText = document.getElementById("ctrl-status-text"); | |
| const activeTags = document.getElementById("active-tags"); | |
| const mouseXVal = document.getElementById("mouse-x-val"); | |
| const mouseYVal = document.getElementById("mouse-y-val"); | |
| const uploadArea = document.getElementById("upload-area"); | |
| const fileInput = document.getElementById("file-input"); | |
| const endedOverlay = document.getElementById("ended-overlay"); | |
| const restartBtn = document.getElementById("restart-btn"); | |
| const isMobile = ("ontouchstart" in window) || navigator.maxTouchPoints > 0 || window.innerWidth <= 768; | |
| // --- Gradio Client --- | |
| async function initClient() { | |
| state.client = await Client.connect(window.location.origin); | |
| } | |
| // --- WebSocket --- | |
| function connectWS() { | |
| const proto = location.protocol === "https:" ? "wss:" : "ws:"; | |
| const ws = new WebSocket(`${proto}//${location.host}/ws`); | |
| ws.binaryType = "arraybuffer"; | |
| ws.onmessage = (e) => { | |
| if (e.data instanceof ArrayBuffer) { | |
| const view = new DataView(e.data); | |
| const frameCount = view.getUint32(0); | |
| const fps = view.getUint32(4) / 10; | |
| const jpegBlob = new Blob([e.data.slice(8)], { type: "image/jpeg" }); | |
| const url = URL.createObjectURL(jpegBlob); | |
| gameView.onload = () => URL.revokeObjectURL(url); | |
| gameView.src = url; | |
| // First frame arrived — transition from loading to playing | |
| if (!state.playing) { | |
| hideLoading(); | |
| setPlaying(true); | |
| setStatus("playing", "Click to capture"); | |
| } | |
| state.frameCount = frameCount; | |
| state.fps = fps; | |
| fpsValue.textContent = fps.toFixed(1); | |
| frameValue.textContent = frameCount; | |
| } else { | |
| try { | |
| const msg = JSON.parse(e.data); | |
| if (msg.type === "session_ended") { | |
| setStatus("ended", "Session ended"); | |
| setPlaying(false); | |
| endedOverlay.classList.remove("hidden"); | |
| } else if (msg.type === "error") { | |
| hideLoading(); | |
| setStatus("error", "GPU error"); | |
| setPlaying(false); | |
| document.getElementById("ended-title").textContent = "Something went wrong"; | |
| document.getElementById("ended-sub").textContent = msg.message; | |
| endedOverlay.classList.remove("hidden"); | |
| } else if (msg.type === "status") { | |
| loadingText.textContent = msg.message; | |
| } | |
| } catch {} | |
| } | |
| }; | |
| ws.onclose = () => { | |
| if (state.playing) setTimeout(connectWS, 1000); | |
| }; | |
| ws.onerror = () => {}; | |
| state.ws = ws; | |
| } | |
| // Send controls over WebSocket at ~30fps | |
| let controlInterval = null; | |
| function startControlLoop() { | |
| if (controlInterval) return; | |
| controlInterval = setInterval(() => { | |
| if (state.ws?.readyState === WebSocket.OPEN && state.playing) { | |
| state.ws.send(JSON.stringify({ | |
| type: "control", | |
| buttons: Array.from(state.pressedButtons), | |
| mouse_x: state.mouseVelocity.x, | |
| mouse_y: state.mouseVelocity.y, | |
| prompt: state.prompt, | |
| })); | |
| } | |
| }, 33); | |
| } | |
| function stopControlLoop() { | |
| if (controlInterval) { clearInterval(controlInterval); controlInterval = null; } | |
| } | |
| // --- UI Helpers --- | |
| function setStatus(type, text) { | |
| statusDot.className = "dot " + ( | |
| type === "playing" ? "dot-green" : | |
| type === "loading" ? "dot-yellow" : "dot-red" | |
| ); | |
| statusLabel.textContent = text; | |
| } | |
| let loadingTimer = null; | |
| let loadingStartTime = 0; | |
| function showLoading(text, subtext) { | |
| loadingText.textContent = text; | |
| loadingSubtext.textContent = subtext || ""; | |
| loadingOverlay.classList.remove("hidden"); | |
| loadingStartTime = Date.now(); | |
| if (loadingTimer) clearInterval(loadingTimer); | |
| loadingTimer = setInterval(() => { | |
| const elapsed = ((Date.now() - loadingStartTime) / 1000).toFixed(0); | |
| loadingSubtext.textContent = `${elapsed}s elapsed`; | |
| }, 500); | |
| } | |
| function hideLoading() { | |
| loadingOverlay.classList.add("hidden"); | |
| if (loadingTimer) { clearInterval(loadingTimer); loadingTimer = null; } | |
| } | |
| function setPlaying(playing) { | |
| state.playing = playing; | |
| startBtn.disabled = playing; | |
| stopBtn.disabled = !playing; | |
| resetBtn.disabled = !playing; | |
| if (playing) { | |
| startControlLoop(); | |
| controlsHelp.classList.remove("hidden"); | |
| } else { | |
| stopControlLoop(); | |
| setCapturing(false); | |
| controlsHelp.classList.add("hidden"); | |
| } | |
| } | |
| // --- Key Visualizer --- | |
| function updateKeyVisual(code, pressed) { | |
| const el = document.querySelector(`.key-btn[data-key="${code}"], .key-wide[data-key="${code}"]`); | |
| if (el) el.classList.toggle("pressed", pressed); | |
| } | |
| function updateActiveDisplay() { | |
| if (state.pressedKeys.size === 0) { | |
| activeTags.innerHTML = '<span style="font-size: 11px; color: var(--text-muted);">None</span>'; | |
| } else { | |
| activeTags.innerHTML = Array.from(state.pressedKeys) | |
| .map(code => KEY_DISPLAY[code] || code.replace("Key", "")) | |
| .map(name => `<span class="active-tag">${name}</span>`) | |
| .join(""); | |
| } | |
| } | |
| function updateMouseDisplay() { | |
| const displayX = Math.max(-1, Math.min(1, state.mouseVelocity.x / 10)); | |
| const displayY = Math.max(-1, Math.min(1, state.mouseVelocity.y / 10)); | |
| const dot = document.getElementById("joystick-dot"); | |
| dot.style.left = (50 + displayX * 40) + "%"; | |
| dot.style.top = (50 + displayY * 40) + "%"; | |
| mouseXVal.textContent = state.mouseVelocity.x.toFixed(1); | |
| mouseYVal.textContent = state.mouseVelocity.y.toFixed(1); | |
| } | |
| // --- Pointer Lock / Capturing --- | |
| function setCapturing(val) { | |
| state.capturing = val; | |
| gameContainer.classList.toggle("capturing", val); | |
| ctrlStatusDot.style.background = val ? "var(--green)" : "var(--red)"; | |
| ctrlStatusDot.style.boxShadow = val ? "0 0 8px var(--green)" : "0 0 8px var(--red)"; | |
| if (val) { | |
| controlsHelp.classList.add("hidden"); | |
| setStatus("playing", "Playing"); | |
| ctrlStatusText.textContent = isMobile ? "Controls active" : "Capturing - ESC to release"; | |
| } else { | |
| state.pressedKeys.clear(); | |
| state.pressedButtons.clear(); | |
| state.mouseVelocity = { x: 0, y: 0 }; | |
| // Clear all key visuals | |
| document.querySelectorAll(".key-btn.pressed, .key-wide.pressed").forEach(el => el.classList.remove("pressed")); | |
| updateActiveDisplay(); | |
| updateMouseDisplay(); | |
| if (state.playing) { | |
| controlsHelp.classList.remove("hidden"); | |
| setStatus("playing", "Click to capture"); | |
| } | |
| ctrlStatusText.textContent = isMobile ? "Tap to enable" : "Click game to capture"; | |
| } | |
| } | |
| gameContainer.addEventListener("click", () => { | |
| if (!state.playing) return; | |
| if (isMobile) { | |
| setCapturing(true); | |
| } else { | |
| gameContainer.requestPointerLock(); | |
| } | |
| }); | |
| document.addEventListener("pointerlockchange", () => { | |
| if (!isMobile) setCapturing(document.pointerLockElement === gameContainer); | |
| }); | |
| // --- Keyboard --- | |
| document.addEventListener("keydown", (e) => { | |
| if (!state.capturing) return; | |
| if (e.code === "Escape") { | |
| if (isMobile) setCapturing(false); | |
| else document.exitPointerLock(); | |
| return; | |
| } | |
| const btn = BUTTON_MAP[e.code]; | |
| if (btn !== undefined && !state.pressedKeys.has(e.code)) { | |
| state.pressedKeys.add(e.code); | |
| state.pressedButtons.add(btn); | |
| updateKeyVisual(e.code, true); | |
| updateActiveDisplay(); | |
| e.preventDefault(); | |
| } | |
| }); | |
| document.addEventListener("keyup", (e) => { | |
| const btn = BUTTON_MAP[e.code]; | |
| if (btn !== undefined) { | |
| state.pressedKeys.delete(e.code); | |
| state.pressedButtons.delete(btn); | |
| updateKeyVisual(e.code, false); | |
| updateActiveDisplay(); | |
| } | |
| }); | |
| // --- Mouse --- | |
| const SMOOTHING = 0.35; | |
| document.addEventListener("mousemove", (e) => { | |
| if (!state.capturing || isMobile) return; | |
| const rawX = e.movementX * 1.5; | |
| const rawY = e.movementY * 1.5; | |
| state.mouseVelocity.x += (rawX - state.mouseVelocity.x) * SMOOTHING; | |
| state.mouseVelocity.y += (rawY - state.mouseVelocity.y) * SMOOTHING; | |
| updateMouseDisplay(); | |
| state.lastMouseMove = Date.now(); | |
| }); | |
| // Mouse decay | |
| setInterval(() => { | |
| if (state.capturing && Date.now() - state.lastMouseMove > 50) { | |
| state.mouseVelocity.x *= 0.7; | |
| state.mouseVelocity.y *= 0.7; | |
| if (Math.abs(state.mouseVelocity.x) < 0.01) state.mouseVelocity.x = 0; | |
| if (Math.abs(state.mouseVelocity.y) < 0.01) state.mouseVelocity.y = 0; | |
| updateMouseDisplay(); | |
| } | |
| }, 33); | |
| // --- Touch/Click: Key Buttons in Sidebar --- | |
| document.querySelectorAll("[data-key]").forEach((el) => { | |
| const keyCode = el.dataset.key; | |
| const btn = BUTTON_MAP[keyCode]; | |
| function press(e) { | |
| e.preventDefault(); | |
| if (!state.capturing) setCapturing(true); | |
| state.pressedKeys.add(keyCode); | |
| if (btn !== undefined) state.pressedButtons.add(btn); | |
| updateKeyVisual(keyCode, true); | |
| updateActiveDisplay(); | |
| } | |
| function release(e) { | |
| e.preventDefault(); | |
| state.pressedKeys.delete(keyCode); | |
| if (btn !== undefined) state.pressedButtons.delete(btn); | |
| updateKeyVisual(keyCode, false); | |
| updateActiveDisplay(); | |
| } | |
| el.addEventListener("touchstart", press, { passive: false }); | |
| el.addEventListener("touchend", release, { passive: false }); | |
| el.addEventListener("touchcancel", release, { passive: false }); | |
| el.addEventListener("mousedown", press); | |
| el.addEventListener("mouseup", release); | |
| el.addEventListener("mouseleave", (e) => { | |
| if (state.pressedKeys.has(keyCode)) release(e); | |
| }); | |
| }); | |
| // --- Look Joystick --- | |
| const joystick = document.getElementById("look-joystick"); | |
| let joystickActive = false; | |
| function handleJoystick(clientX, clientY) { | |
| const rect = joystick.getBoundingClientRect(); | |
| const cx = rect.left + rect.width / 2; | |
| const cy = rect.top + rect.height / 2; | |
| const maxDist = rect.width / 2; | |
| let dx = clientX - cx, dy = clientY - cy; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist > maxDist) { dx = (dx / dist) * maxDist; dy = (dy / dist) * maxDist; } | |
| state.mouseVelocity.x = (dx / maxDist) * 10; | |
| state.mouseVelocity.y = (dy / maxDist) * 10; | |
| state.lastMouseMove = Date.now(); | |
| updateMouseDisplay(); | |
| } | |
| function resetJoystick() { | |
| joystickActive = false; | |
| state.mouseVelocity = { x: 0, y: 0 }; | |
| updateMouseDisplay(); | |
| } | |
| joystick.addEventListener("mousedown", (e) => { e.preventDefault(); joystickActive = true; handleJoystick(e.clientX, e.clientY); }); | |
| document.addEventListener("mousemove", (e) => { if (joystickActive && !document.pointerLockElement) handleJoystick(e.clientX, e.clientY); }); | |
| document.addEventListener("mouseup", () => { if (joystickActive) resetJoystick(); }); | |
| joystick.addEventListener("touchstart", (e) => { | |
| e.preventDefault(); | |
| joystickActive = true; | |
| if (!state.capturing) setCapturing(true); | |
| handleJoystick(e.touches[0].clientX, e.touches[0].clientY); | |
| }, { passive: false }); | |
| joystick.addEventListener("touchmove", (e) => { | |
| e.preventDefault(); | |
| if (joystickActive) handleJoystick(e.touches[0].clientX, e.touches[0].clientY); | |
| }, { passive: false }); | |
| joystick.addEventListener("touchend", (e) => { e.preventDefault(); resetJoystick(); }, { passive: false }); | |
| joystick.addEventListener("touchcancel", (e) => { e.preventDefault(); resetJoystick(); }, { passive: false }); | |
| // --- World Selector --- | |
| const SEED_URLS = [ | |
| "https://huggingface.co/spaces/Overworld/waypoint-1-small/resolve/main/starter_18.png", | |
| "https://huggingface.co/spaces/Overworld/waypoint-1-small/resolve/main/starter_9.png", | |
| "https://huggingface.co/spaces/Overworld/waypoint-1-small/resolve/main/starter_22.png", | |
| "https://huggingface.co/spaces/Overworld/waypoint-1-small/resolve/main/starter_14.png", | |
| "https://huggingface.co/spaces/Overworld/waypoint-1-small/resolve/main/starter_21.png", | |
| ]; | |
| SEED_URLS.forEach((url, i) => { | |
| const img = document.createElement("img"); | |
| img.src = url; | |
| img.className = "world-thumb"; | |
| img.title = `World ${i + 1}`; | |
| img.addEventListener("click", () => { | |
| document.querySelectorAll(".world-thumb").forEach(t => t.classList.remove("selected")); | |
| img.classList.add("selected"); | |
| state.selectedSeedUrl = url; | |
| state.useCustomImage = false; | |
| // If playing, reset to this world | |
| if (state.playing && state.ws?.readyState === WebSocket.OPEN) { | |
| state.ws.send(JSON.stringify({ type: "reset", seed_url: url, prompt: state.prompt })); | |
| } | |
| }); | |
| worldSelector.appendChild(img); | |
| }); | |
| // --- Custom Image Upload --- | |
| uploadArea.addEventListener("click", () => fileInput.click()); | |
| uploadArea.addEventListener("dragover", (e) => { e.preventDefault(); uploadArea.style.borderColor = "var(--accent)"; }); | |
| uploadArea.addEventListener("dragleave", () => { uploadArea.style.borderColor = ""; }); | |
| uploadArea.addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| uploadArea.style.borderColor = ""; | |
| const file = e.dataTransfer?.files?.[0]; | |
| if (file && file.type.startsWith("image/")) handleImageUpload(file); | |
| }); | |
| fileInput.addEventListener("change", () => { | |
| if (fileInput.files?.[0]) handleImageUpload(fileInput.files[0]); | |
| }); | |
| async function handleImageUpload(file) { | |
| // Show preview | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| uploadArea.innerHTML = `<img src="${e.target.result}" alt="Custom seed"><button class="upload-clear" id="clear-upload">×</button>`; | |
| uploadArea.classList.add("has-image"); | |
| document.getElementById("clear-upload").addEventListener("click", (ev) => { | |
| ev.stopPropagation(); | |
| clearUpload(); | |
| }); | |
| }; | |
| reader.readAsDataURL(file); | |
| // Upload to server | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| try { | |
| await fetch("/upload_seed_image", { method: "POST", body: formData }); | |
| state.useCustomImage = true; | |
| // Deselect world thumbs | |
| document.querySelectorAll(".world-thumb").forEach(t => t.classList.remove("selected")); | |
| state.selectedSeedUrl = ""; | |
| } catch (err) { | |
| console.error("Upload failed:", err); | |
| } | |
| } | |
| async function clearUpload() { | |
| uploadArea.innerHTML = '<div class="icon">+</div><div class="placeholder">Click or drop image</div>'; | |
| uploadArea.classList.remove("has-image"); | |
| state.useCustomImage = false; | |
| fileInput.value = ""; | |
| try { await fetch("/clear_seed_image", { method: "POST" }); } catch {} | |
| } | |
| // --- Prompt --- | |
| promptInput.addEventListener("input", () => { | |
| state.prompt = promptInput.value || "An explorable world"; | |
| }); | |
| // --- Start / Stop / Reset --- | |
| async function startGame() { | |
| if (!state.client) await initClient(); | |
| endedOverlay.classList.add("hidden"); | |
| document.getElementById("ended-title").textContent = "Session Ended"; | |
| document.getElementById("ended-sub").textContent = "GPU time limit reached (120s)"; | |
| setStatus("loading", "Starting..."); | |
| showLoading("Initializing GPU & generating world...", "This may take a few seconds on first run"); | |
| startBtn.disabled = true; | |
| connectWS(); | |
| try { | |
| await state.client.predict("/start_game", { | |
| seed_url: state.selectedSeedUrl || "", | |
| prompt: state.prompt, | |
| }); | |
| } catch (err) { | |
| hideLoading(); | |
| setStatus("error", "Failed to start"); | |
| startBtn.disabled = false; | |
| console.error("Start failed:", err); | |
| } | |
| } | |
| startBtn.addEventListener("click", startGame); | |
| restartBtn.addEventListener("click", startGame); | |
| stopBtn.addEventListener("click", async () => { | |
| if (!state.client) return; | |
| try { await state.client.predict("/stop_game"); } catch {} | |
| setPlaying(false); | |
| endedOverlay.classList.add("hidden"); | |
| setStatus("idle", "Idle"); | |
| if (state.ws) { state.ws.close(); state.ws = null; } | |
| gameView.src = ""; | |
| fpsValue.textContent = "0"; | |
| frameValue.textContent = "0"; | |
| }); | |
| resetBtn.addEventListener("click", () => { | |
| if (state.ws?.readyState === WebSocket.OPEN) { | |
| state.ws.send(JSON.stringify({ | |
| type: "reset", | |
| seed_url: state.selectedSeedUrl || "", | |
| use_custom_image: state.useCustomImage, | |
| prompt: state.prompt, | |
| })); | |
| } | |
| }); | |
| // --- Init --- | |
| initClient().catch(console.error); | |
| </script> | |
| </body> | |
| </html> | |