waypoint-1-5 / index.html
multimodalart's picture
multimodalart HF Staff
Create index.html
a309856 verified
<!DOCTYPE html>
<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 &nbsp;<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>&nbsp;
<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">&times;</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>