Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>CapyCap Mouse Movement Dataset - Demo & Replay</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: #1c1917; | |
| color: #fafaf9; | |
| min-height: 100vh; | |
| padding: 20px; | |
| } | |
| .container { max-width: 1200px; margin: 0 auto; } | |
| h1 { font-size: 1.5rem; margin-bottom: 0.5rem; } | |
| .subtitle { color: #a8a29e; margin-bottom: 1.5rem; } | |
| .tabs { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 1.5rem; | |
| } | |
| .tab { | |
| padding: 10px 20px; | |
| border: none; | |
| background: #292524; | |
| color: #a8a29e; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| transition: all 0.2s; | |
| } | |
| .tab:hover { background: #44403c; color: #fafaf9; } | |
| .tab.active { background: #a67c52; color: #fafaf9; } | |
| .panel { display: none; } | |
| .panel.active { display: block; } | |
| .card { | |
| background: #292524; | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin-bottom: 16px; | |
| } | |
| .card h2 { | |
| font-size: 1.1rem; | |
| margin-bottom: 12px; | |
| color: #d6d3d1; | |
| } | |
| .row { display: flex; gap: 20px; flex-wrap: wrap; } | |
| .col { flex: 1; min-width: 300px; } | |
| canvas { | |
| display: block; | |
| border-radius: 8px; | |
| background: #44403c; | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| margin-bottom: 16px; | |
| } | |
| select, input[type="file"], button { | |
| padding: 8px 16px; | |
| border-radius: 8px; | |
| border: 1px solid #44403c; | |
| background: #1c1917; | |
| color: #fafaf9; | |
| font-size: 0.9rem; | |
| } | |
| button { | |
| background: #a67c52; | |
| border-color: #a67c52; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| } | |
| button:hover { background: #8b5e3c; } | |
| button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .btn-secondary { background: #44403c; border-color: #57534e; } | |
| .btn-secondary:hover { background: #57534e; } | |
| textarea { | |
| width: 100%; | |
| height: 150px; | |
| padding: 12px; | |
| border-radius: 8px; | |
| border: 1px solid #44403c; | |
| background: #1c1917; | |
| color: #fafaf9; | |
| font-family: monospace; | |
| font-size: 0.8rem; | |
| resize: vertical; | |
| } | |
| .info { | |
| font-size: 0.85rem; | |
| color: #a8a29e; | |
| } | |
| .info strong { color: #d6d3d1; } | |
| .status { | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| margin-top: 12px; | |
| } | |
| .status.success { background: #166534; color: #86efac; } | |
| .status.error { background: #991b1b; color: #fca5a5; } | |
| .status.info { background: #1e40af; color: #93c5fd; } | |
| .playback-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| margin-top: 12px; | |
| } | |
| .progress-bar { | |
| flex: 1; | |
| height: 8px; | |
| background: #44403c; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: #a67c52; | |
| border-radius: 4px; | |
| transition: width 0.1s; | |
| } | |
| .speed-select { width: 80px; } | |
| #loading { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0,0,0,0.8); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| } | |
| #loading.hidden { display: none; } | |
| .game-wrapper { | |
| display: inline-block; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| } | |
| .game-wrapper canvas { | |
| display: block; | |
| border-radius: 0; | |
| } | |
| .game-header { | |
| padding: 12px 16px; | |
| } | |
| .game-header-small { font-size: 0.85rem; opacity: 0.9; } | |
| .game-header-main { font-size: 1.2rem; font-weight: bold; } | |
| .game-header.sheep { background: #916d46; } | |
| .game-header.needle { background: #d97706; } | |
| .game-header.polygon { background: #78716c; } | |
| .spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 3px solid #44403c; | |
| border-top-color: #a67c52; | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .session-nav { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| } | |
| .session-nav span { color: #a8a29e; font-size: 0.9rem; } | |
| a { color: #a67c52; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="loading"><div class="spinner"></div></div> | |
| <div class="container"> | |
| <h1>CapyCap Mouse Movement Dataset</h1> | |
| <p class="subtitle"> | |
| Play captcha games or replay samples from the | |
| <a href="https://huggingface.co/datasets/Capycap-AI/CaptchaSolve30k" target="_blank">HuggingFace dataset</a> | |
| </p> | |
| <div class="tabs"> | |
| <button class="tab active" data-tab="replay">Replay Samples</button> | |
| <button class="tab" data-tab="play">Play Games</button> | |
| </div> | |
| <!-- REPLAY PANEL --> | |
| <div id="replay-panel" class="panel active"> | |
| <div class="row"> | |
| <div class="col"> | |
| <div class="card"> | |
| <h2>Load Session Data</h2> | |
| <div class="controls"> | |
| <input type="file" id="file-input" accept=".json,.jsonl"> | |
| <span class="info">or paste JSON below</span> | |
| </div> | |
| <textarea id="json-input" placeholder='Paste a session JSON from the dataset here... | |
| Example: {"index":0,"tickInputs":[...],"gameType":"sheep-herding",...}'></textarea> | |
| <div class="controls" style="margin-top: 12px;"> | |
| <button id="load-btn">Load & Replay</button> | |
| <button id="load-random-btn" class="btn-secondary">Load Sample from HF</button> | |
| </div> | |
| <div id="load-status"></div> | |
| </div> | |
| <div class="card"> | |
| <h2>Session Info</h2> | |
| <div class="info" id="session-info"> | |
| <p>No session loaded</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col"> | |
| <div class="card"> | |
| <h2>Replay Viewer</h2> | |
| <div class="session-nav" id="session-nav" style="display: none;"> | |
| <button id="prev-session" class="btn-secondary">←</button> | |
| <span id="session-counter">1 / 1</span> | |
| <button id="next-session" class="btn-secondary">→</button> | |
| </div> | |
| <canvas id="replay-canvas" width="330" height="330"></canvas> | |
| <div class="playback-controls"> | |
| <button id="play-pause-btn">Play</button> | |
| <button id="restart-btn" class="btn-secondary">Restart</button> | |
| <div class="progress-bar" id="progress-bar"> | |
| <div class="progress-fill" id="progress-fill"></div> | |
| </div> | |
| <select id="speed-select" class="speed-select"> | |
| <option value="0.25">0.25x</option> | |
| <option value="0.5">0.5x</option> | |
| <option value="1" selected>1x</option> | |
| <option value="2">2x</option> | |
| <option value="4">4x</option> | |
| </select> | |
| </div> | |
| <div class="info" style="margin-top: 8px;"> | |
| <span id="tick-display">Tick: 0 / 0</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- PLAY PANEL --> | |
| <div id="play-panel" class="panel"> | |
| <div class="row"> | |
| <div class="col"> | |
| <div class="card"> | |
| <h2>Play a Game</h2> | |
| <div class="controls"> | |
| <select id="game-select"> | |
| <option value="sheep-herding">Sheep Herding</option> | |
| <option value="thread-the-needle">Thread the Needle</option> | |
| <option value="polygon-stacking">Polygon Stacking</option> | |
| </select> | |
| <button id="start-game-btn">Start Game</button> | |
| <button id="stop-game-btn" class="btn-secondary" disabled>Stop</button> | |
| </div> | |
| <div class="game-wrapper"> | |
| <div id="game-instructions" class="game-header" style="display: none;"> | |
| <div class="game-header-text"></div> | |
| </div> | |
| <canvas id="play-canvas" width="330" height="330"></canvas> | |
| </div> | |
| <div id="play-status" class="status info" style="display: none;"></div> | |
| </div> | |
| </div> | |
| <div class="col"> | |
| <div class="card"> | |
| <h2>Export Session (HuggingFace Format)</h2> | |
| <p class="info" style="margin-bottom: 12px;"> | |
| After completing a game, copy this JSON to use with ML models. | |
| This matches the format in the HuggingFace dataset. | |
| </p> | |
| <textarea id="export-output" readonly placeholder="Complete a game to see the exported session data..."></textarea> | |
| <div class="controls" style="margin-top: 12px;"> | |
| <button id="copy-export-btn" class="btn-secondary">Copy to Clipboard</button> | |
| <button id="download-export-btn" class="btn-secondary">Download JSON</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Load WASM game module --> | |
| <script src="game.js"></script> | |
| <script> | |
| // ============================================================================ | |
| // CORE UTILITIES (from GAME_SPEC.ts) | |
| // ============================================================================ | |
| const GRID_BASE = 200; | |
| const PHYSICS_TIMESTEP = 1 / 240; // 240 Hz | |
| const BASE_RENDER_WIDTH = 330; | |
| const MIN_RENDER_SCALE = 1.1; | |
| const MAX_RENDER_SCALE = 1.2; | |
| // Mulberry32 PRNG | |
| function createSeededRNG(seed) { | |
| let state = seed >>> 0; | |
| function next() { | |
| state |= 0; | |
| state = (state + 0x6D2B79F5) | 0; | |
| let t = Math.imul(state ^ (state >>> 15), 1 | state); | |
| t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| } | |
| return { next }; | |
| } | |
| function generateRenderScale(rng) { | |
| return MIN_RENDER_SCALE + rng.next() * (MAX_RENDER_SCALE - MIN_RENDER_SCALE); | |
| } | |
| // Game type enum (matches C) | |
| const GameType = { | |
| THREAD_NEEDLE: 0, | |
| SHEEP_HERDING: 1, | |
| POLYGON_STACKING: 2, | |
| }; | |
| function getGameTypeEnum(str) { | |
| switch (str) { | |
| case 'thread-the-needle': return GameType.THREAD_NEEDLE; | |
| case 'sheep-herding': return GameType.SHEEP_HERDING; | |
| case 'polygon-stacking': return GameType.POLYGON_STACKING; | |
| default: return GameType.SHEEP_HERDING; | |
| } | |
| } | |
| // ============================================================================ | |
| // COMMAND BUFFER RENDERER (from command-renderer.ts) | |
| // ============================================================================ | |
| const DrawCommandType = { | |
| CMD_CLEAR: 0, CMD_FILL_RECT: 1, CMD_STROKE_RECT: 2, | |
| CMD_FILL_CIRCLE: 3, CMD_STROKE_CIRCLE: 4, | |
| CMD_FILL_ELLIPSE: 5, CMD_STROKE_ELLIPSE: 6, | |
| CMD_LINE: 7, CMD_FILL_POLYGON: 8, CMD_STROKE_POLYGON: 9, | |
| CMD_ARC: 10, CMD_BEZIER: 11, CMD_QUADRATIC: 12, | |
| CMD_SET_LINE_WIDTH: 13, CMD_SET_LINE_CAP: 14, CMD_SET_LINE_DASH: 15, | |
| CMD_TEXT: 16, CMD_END: 17, | |
| }; | |
| function colorToCSS(color) { | |
| const r = color & 0xff; | |
| const g = (color >> 8) & 0xff; | |
| const b = (color >> 16) & 0xff; | |
| const a = (color >> 24) & 0xff; | |
| return a === 255 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${(a/255).toFixed(3)})`; | |
| } | |
| class CommandBufferReader { | |
| constructor(buffer, byteOffset, byteLength) { | |
| this.data = new Uint8Array(buffer, byteOffset, byteLength); | |
| this.view = new DataView(buffer, byteOffset, byteLength); | |
| this.offset = 0; | |
| } | |
| readU8() { return this.data[this.offset++]; } | |
| readF32() { const v = this.view.getFloat32(this.offset, true); this.offset += 4; return v; } | |
| readU32() { const v = this.view.getUint32(this.offset, true); this.offset += 4; return v; } | |
| readString(len) { | |
| let s = ''; | |
| for (let i = 0; i < len; i++) s += String.fromCharCode(this.data[this.offset + i]); | |
| this.offset += len; | |
| return s; | |
| } | |
| execute(ctx) { | |
| this.offset = 0; | |
| while (this.offset < this.data.length) { | |
| const cmd = this.readU8(); | |
| switch (cmd) { | |
| case DrawCommandType.CMD_CLEAR: { | |
| ctx.fillStyle = colorToCSS(this.readU32()); | |
| ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); | |
| break; | |
| } | |
| case DrawCommandType.CMD_FILL_RECT: { | |
| const x = this.readF32(), y = this.readF32(), w = this.readF32(), h = this.readF32(); | |
| ctx.fillStyle = colorToCSS(this.readU32()); | |
| ctx.fillRect(x, y, w, h); | |
| break; | |
| } | |
| case DrawCommandType.CMD_FILL_CIRCLE: { | |
| const cx = this.readF32(), cy = this.readF32(), r = this.readF32(); | |
| ctx.fillStyle = colorToCSS(this.readU32()); | |
| ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); | |
| break; | |
| } | |
| case DrawCommandType.CMD_STROKE_CIRCLE: { | |
| const cx = this.readF32(), cy = this.readF32(), r = this.readF32(); | |
| ctx.strokeStyle = colorToCSS(this.readU32()); | |
| ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); | |
| break; | |
| } | |
| case DrawCommandType.CMD_FILL_ELLIPSE: { | |
| const cx = this.readF32(), cy = this.readF32(), rx = this.readF32(), ry = this.readF32(); | |
| ctx.fillStyle = colorToCSS(this.readU32()); | |
| ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill(); | |
| break; | |
| } | |
| case DrawCommandType.CMD_LINE: { | |
| const x1 = this.readF32(), y1 = this.readF32(), x2 = this.readF32(), y2 = this.readF32(); | |
| ctx.strokeStyle = colorToCSS(this.readU32()); | |
| ctx.lineWidth = this.readF32(); | |
| ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); | |
| break; | |
| } | |
| case DrawCommandType.CMD_FILL_POLYGON: { | |
| const n = this.readU8(); | |
| ctx.fillStyle = colorToCSS(this.readU32()); | |
| ctx.beginPath(); | |
| for (let i = 0; i < n; i++) { | |
| const x = this.readF32(), y = this.readF32(); | |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); | |
| } | |
| ctx.closePath(); ctx.fill(); | |
| break; | |
| } | |
| case DrawCommandType.CMD_STROKE_POLYGON: { | |
| const n = this.readU8(); | |
| ctx.strokeStyle = colorToCSS(this.readU32()); | |
| ctx.beginPath(); | |
| for (let i = 0; i < n; i++) { | |
| const x = this.readF32(), y = this.readF32(); | |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); | |
| } | |
| ctx.closePath(); ctx.stroke(); | |
| break; | |
| } | |
| case DrawCommandType.CMD_SET_LINE_WIDTH: ctx.lineWidth = this.readF32(); break; | |
| case DrawCommandType.CMD_SET_LINE_CAP: { | |
| const c = this.readU8(); | |
| ctx.lineCap = c === 1 ? 'round' : c === 2 ? 'square' : 'butt'; | |
| break; | |
| } | |
| case DrawCommandType.CMD_SET_LINE_DASH: { | |
| const d = this.readF32(), g = this.readF32(); | |
| ctx.setLineDash(d > 0 && g > 0 ? [d, g] : []); | |
| break; | |
| } | |
| case DrawCommandType.CMD_TEXT: { | |
| const x = this.readF32(), y = this.readF32(); | |
| ctx.fillStyle = colorToCSS(this.readU32()); | |
| const size = this.readF32(), center = this.readU8() !== 0, len = this.readU8(); | |
| const text = this.readString(len); | |
| ctx.font = `${size}px sans-serif`; | |
| ctx.textAlign = center ? 'center' : 'left'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(text, x, y); | |
| break; | |
| } | |
| case DrawCommandType.CMD_END: return; | |
| default: return; | |
| } | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // WASM GAME WRAPPER | |
| // ============================================================================ | |
| let wasmModule = null; | |
| async function loadWasm() { | |
| if (wasmModule) return wasmModule; | |
| if (typeof createGameModule === 'undefined') { | |
| throw new Error('game.js not loaded'); | |
| } | |
| wasmModule = await createGameModule({ | |
| locateFile: (path) => path, | |
| }); | |
| return wasmModule; | |
| } | |
| class WasmGame { | |
| constructor(module, gameType, width = 330, height = 330) { | |
| this.module = module; | |
| this.gameType = gameType; | |
| this.width = width; | |
| this.height = height; | |
| this.initialized = false; | |
| this.imageData = null; | |
| } | |
| init(seed) { | |
| if (this.initialized) this.cleanup(); | |
| const result = this.module._game_init(this.gameType, seed, this.width, this.height); | |
| this.initialized = result === 0; | |
| if (this.initialized) { | |
| this.imageData = new ImageData(this.width, this.height); | |
| } | |
| return this.initialized; | |
| } | |
| step(x, y, isDown) { | |
| if (!this.initialized) return; | |
| this.module._game_step(x, y, isDown ? 1 : 0); | |
| } | |
| applyInput(x, y, isDown) { | |
| if (!this.initialized) return; | |
| this.module._game_apply_input(x, y, isDown ? 1 : 0); | |
| } | |
| recordInput(x, y) { | |
| if (!this.initialized) return; | |
| this.module._game_record_input(x, y); | |
| } | |
| renderPvg(ctx) { | |
| if (!this.initialized || !this.imageData) return; | |
| this.module._game_render_pvg(); | |
| const pixelPtr = this.module._game_get_pvg_pixels(); | |
| const stride = this.module._game_get_pvg_stride(); | |
| if (pixelPtr === 0 || stride === 0) return; | |
| const data = this.imageData.data; | |
| const wasmMem = new Uint8Array(this.module.wasmMemory.buffer); | |
| for (let y = 0; y < this.height; y++) { | |
| const srcRow = pixelPtr + y * stride; | |
| const dstRow = y * this.width * 4; | |
| for (let x = 0; x < this.width; x++) { | |
| const si = srcRow + x * 4, di = dstRow + x * 4; | |
| const r = wasmMem[si], g = wasmMem[si+1], b = wasmMem[si+2], a = wasmMem[si+3]; | |
| if (a === 0) { data[di] = data[di+1] = data[di+2] = data[di+3] = 0; } | |
| else if (a === 255) { data[di] = r; data[di+1] = g; data[di+2] = b; data[di+3] = 255; } | |
| else { | |
| data[di] = Math.min(255, Math.round((r * 255) / a)); | |
| data[di+1] = Math.min(255, Math.round((g * 255) / a)); | |
| data[di+2] = Math.min(255, Math.round((b * 255) / a)); | |
| data[di+3] = a; | |
| } | |
| } | |
| } | |
| ctx.putImageData(this.imageData, 0, 0); | |
| } | |
| isSolved() { return this.initialized && this.module._game_is_solved() !== 0; } | |
| isFailed() { return this.initialized && this.module._game_is_failed() !== 0; } | |
| getCurrentTick() { return this.initialized ? this.module._game_get_current_tick() : 0; } | |
| getTargetInfo() { | |
| if (!this.initialized) return { shape: '', colorName: '', colorHex: '#000000' }; | |
| const shapePtr = this.module._malloc(64); | |
| const colorPtr = this.module._malloc(64); | |
| const colorValuePtr = this.module._malloc(4); | |
| this.module._game_get_target_info(shapePtr, colorPtr, colorValuePtr); | |
| const shape = this.module.UTF8ToString(shapePtr); | |
| const colorName = this.module.UTF8ToString(colorPtr); | |
| const colorValue = this.module.getValue(colorValuePtr, 'i32'); | |
| const r = colorValue & 0xff; | |
| const g = (colorValue >> 8) & 0xff; | |
| const b = (colorValue >> 16) & 0xff; | |
| const colorHex = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`; | |
| this.module._free(shapePtr); | |
| this.module._free(colorPtr); | |
| this.module._free(colorValuePtr); | |
| return { shape, colorName, colorHex }; | |
| } | |
| getSessionDataJson() { | |
| if (!this.initialized) return '{}'; | |
| const ptr = this.module._game_get_session_data(); | |
| return this.module.UTF8ToString(ptr); | |
| } | |
| cleanup() { | |
| if (this.initialized) { | |
| this.module._game_cleanup(); | |
| this.initialized = false; | |
| this.imageData = null; | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // APP STATE | |
| // ============================================================================ | |
| let currentSessions = []; | |
| let currentSessionIndex = 0; | |
| let replayGame = null; | |
| let replayAnimationId = null; | |
| let isReplaying = false; | |
| let currentTick = 0; | |
| let playGame = null; | |
| let playAnimationId = null; | |
| let isPlaying = false; | |
| let playStartTime = 0; | |
| let playTickInputs = []; | |
| let playLastInput = { x: 0, y: 0, isDown: false }; | |
| let playSeed = 0; | |
| // ============================================================================ | |
| // REPLAY FUNCTIONS | |
| // ============================================================================ | |
| function parseJsonlOrJson(text) { | |
| text = text.trim(); | |
| // Try single JSON first | |
| try { | |
| const obj = JSON.parse(text); | |
| if (Array.isArray(obj)) return obj; | |
| return [obj]; | |
| } catch {} | |
| // Try JSONL | |
| const lines = text.split('\n').filter(l => l.trim()); | |
| const sessions = []; | |
| for (const line of lines) { | |
| try { | |
| const obj = JSON.parse(line); | |
| if (obj.tickInputs) sessions.push(obj); | |
| } catch {} | |
| } | |
| return sessions; | |
| } | |
| async function loadAndReplay(sessions, index = 0) { | |
| if (sessions.length === 0) { | |
| showStatus('load-status', 'No valid sessions found', 'error'); | |
| return; | |
| } | |
| currentSessions = sessions; | |
| currentSessionIndex = index; | |
| stopReplay(); | |
| const session = sessions[index]; | |
| const module = await loadWasm(); | |
| // Create game | |
| const gameType = getGameTypeEnum(session.gameType); | |
| replayGame = new WasmGame(module, gameType, 330, 330); | |
| // Initialize with puzzleSeed from session | |
| if (!session.puzzleSeed) { | |
| showStatus('load-status', 'Warning: No puzzleSeed in session - replay may not match original', 'error'); | |
| } | |
| const seed = session.puzzleSeed || 0; | |
| const rng = createSeededRNG(seed); | |
| generateRenderScale(rng); // Advance RNG (same as original) | |
| const wasmSeed = Math.floor(rng.next() * 0xffffffff); | |
| replayGame.init(wasmSeed); | |
| currentTick = 0; | |
| updateSessionInfo(session); | |
| updateSessionNav(); | |
| renderFrame(); | |
| showStatus('load-status', `Loaded session ${index + 1} of ${sessions.length}`, 'success'); | |
| } | |
| function renderFrame() { | |
| if (!replayGame) return; | |
| const canvas = document.getElementById('replay-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| replayGame.renderPvg(ctx); | |
| const session = currentSessions[currentSessionIndex]; | |
| const total = session?.physicsTickCount || session?.tickInputs?.length || 0; | |
| document.getElementById('tick-display').textContent = `Tick: ${currentTick} / ${total}`; | |
| document.getElementById('progress-fill').style.width = total > 0 ? `${(currentTick / total) * 100}%` : '0%'; | |
| } | |
| function startReplay() { | |
| if (!replayGame || isReplaying) return; | |
| const session = currentSessions[currentSessionIndex]; | |
| if (!session?.tickInputs) return; | |
| isReplaying = true; | |
| document.getElementById('play-pause-btn').textContent = 'Pause'; | |
| const tickInputs = session.tickInputs; | |
| const totalTicks = session.physicsTickCount || tickInputs.length; | |
| const speed = parseFloat(document.getElementById('speed-select').value); | |
| const TICK_MS = PHYSICS_TIMESTEP * 1000; | |
| let lastTime = performance.now(); | |
| let accumulator = 0; | |
| let lastInput = { x: 0, y: 0, isDown: false }; | |
| function frame(now) { | |
| if (!isReplaying) return; | |
| const delta = (now - lastTime) * speed; | |
| lastTime = now; | |
| accumulator += delta; | |
| while (accumulator >= TICK_MS && currentTick < totalTicks) { | |
| const input = tickInputs[currentTick] || lastInput; | |
| if (tickInputs[currentTick]) { | |
| lastInput = { x: input.x, y: input.y, isDown: input.isDown }; | |
| } | |
| replayGame.step(input.x, input.y, input.isDown); | |
| currentTick++; | |
| accumulator -= TICK_MS; | |
| if (replayGame.isSolved() || replayGame.isFailed()) break; | |
| } | |
| renderFrame(); | |
| if (currentTick >= totalTicks || replayGame.isSolved() || replayGame.isFailed()) { | |
| stopReplay(); | |
| return; | |
| } | |
| replayAnimationId = requestAnimationFrame(frame); | |
| } | |
| replayAnimationId = requestAnimationFrame(frame); | |
| } | |
| function stopReplay() { | |
| isReplaying = false; | |
| if (replayAnimationId) { | |
| cancelAnimationFrame(replayAnimationId); | |
| replayAnimationId = null; | |
| } | |
| document.getElementById('play-pause-btn').textContent = 'Play'; | |
| } | |
| function restartReplay() { | |
| stopReplay(); | |
| if (currentSessions.length > 0) { | |
| loadAndReplay(currentSessions, currentSessionIndex); | |
| } | |
| } | |
| function updateSessionInfo(session) { | |
| const info = document.getElementById('session-info'); | |
| info.innerHTML = ` | |
| <p><strong>Game Type:</strong> ${session.gameType || 'unknown'}</p> | |
| <p><strong>Duration:</strong> ${session.duration ? (session.duration / 1000).toFixed(2) + 's' : 'N/A'}</p> | |
| <p><strong>Physics Ticks:</strong> ${session.physicsTickCount || session.tickInputs?.length || 0}</p> | |
| <p><strong>Input Samples:</strong> ${session.inputSampleCount || 'N/A'}</p> | |
| <p><strong>Touchscreen:</strong> ${session.touchscreen ? 'Yes' : 'No'}</p> | |
| `; | |
| } | |
| function updateSessionNav() { | |
| const nav = document.getElementById('session-nav'); | |
| const counter = document.getElementById('session-counter'); | |
| nav.style.display = currentSessions.length > 1 ? 'flex' : 'none'; | |
| counter.textContent = `${currentSessionIndex + 1} / ${currentSessions.length}`; | |
| document.getElementById('prev-session').disabled = currentSessionIndex === 0; | |
| document.getElementById('next-session').disabled = currentSessionIndex >= currentSessions.length - 1; | |
| } | |
| // ============================================================================ | |
| // PLAY FUNCTIONS | |
| // ============================================================================ | |
| const GAME_INSTRUCTIONS = { | |
| 'sheep-herding': { | |
| class: 'sheep', | |
| small: 'Drag the dots into the', | |
| main: '<span style="color:#86efac">Green</span> circle' | |
| }, | |
| 'thread-the-needle': { | |
| class: 'needle', | |
| small: 'Drag the top of the string<br>to guide the carrot into', | |
| main: 'The Target' // Will be replaced with actual target | |
| }, | |
| 'polygon-stacking': { | |
| class: 'polygon', | |
| small: 'Stack and balance for', | |
| main: '3 Seconds' | |
| } | |
| }; | |
| function showGameInstructions(gameType, targetInfo = null) { | |
| const header = document.getElementById('game-instructions'); | |
| const info = GAME_INSTRUCTIONS[gameType]; | |
| if (!info) { | |
| header.style.display = 'none'; | |
| return; | |
| } | |
| header.className = 'game-header ' + info.class; | |
| let mainText = info.main; | |
| if (gameType === 'thread-the-needle' && targetInfo && targetInfo.colorName) { | |
| const shapeName = targetInfo.shape.charAt(0).toUpperCase() + targetInfo.shape.slice(1); | |
| mainText = `The <span style="color:${targetInfo.colorHex}">${targetInfo.colorName}</span> ${shapeName}`; | |
| } | |
| header.innerHTML = ` | |
| <div class="game-header-small">${info.small}</div> | |
| <div class="game-header-main">${mainText}</div> | |
| `; | |
| header.style.display = 'block'; | |
| } | |
| async function startGame() { | |
| const gameTypeStr = document.getElementById('game-select').value; | |
| const module = await loadWasm(); | |
| if (playGame) playGame.cleanup(); | |
| stopPlayLoop(); | |
| const gameType = getGameTypeEnum(gameTypeStr); | |
| playGame = new WasmGame(module, gameType, 330, 330); | |
| playSeed = Math.floor(Math.random() * 3333); // 0-3332, same as production | |
| const rng = createSeededRNG(playSeed); | |
| generateRenderScale(rng); | |
| const wasmSeed = Math.floor(rng.next() * 0xffffffff); | |
| playGame.init(wasmSeed); | |
| playTickInputs = []; | |
| playLastInput = { x: 0, y: 0, isDown: false }; | |
| playStartTime = Date.now(); | |
| isPlaying = true; | |
| // Get target info for thread-the-needle | |
| const targetInfo = playGame.getTargetInfo(); | |
| showGameInstructions(gameTypeStr, targetInfo); | |
| document.getElementById('start-game-btn').disabled = true; | |
| document.getElementById('stop-game-btn').disabled = false; | |
| document.getElementById('play-status').style.display = 'none'; | |
| document.getElementById('export-output').value = ''; | |
| const canvas = document.getElementById('play-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const TICK_MS = PHYSICS_TIMESTEP * 1000; | |
| let lastTime = performance.now(); | |
| let accumulator = 0; | |
| let tick = 0; | |
| function frame(now) { | |
| if (!isPlaying) return; | |
| const delta = now - lastTime; | |
| lastTime = now; | |
| accumulator += delta; | |
| while (accumulator >= TICK_MS) { | |
| playTickInputs.push({ | |
| x: playLastInput.x, | |
| y: playLastInput.y, | |
| isDown: playLastInput.isDown, | |
| sampleIndex: tick | |
| }); | |
| playGame.step(playLastInput.x, playLastInput.y, playLastInput.isDown); | |
| tick++; | |
| accumulator -= TICK_MS; | |
| if (playGame.isSolved() || playGame.isFailed()) { | |
| finishGame(playGame.isSolved() ? 'solved' : 'failed'); | |
| return; | |
| } | |
| } | |
| playGame.renderPvg(ctx); | |
| playAnimationId = requestAnimationFrame(frame); | |
| } | |
| playAnimationId = requestAnimationFrame(frame); | |
| } | |
| function stopPlayLoop() { | |
| isPlaying = false; | |
| if (playAnimationId) { | |
| cancelAnimationFrame(playAnimationId); | |
| playAnimationId = null; | |
| } | |
| } | |
| function finishGame(outcome) { | |
| stopPlayLoop(); | |
| document.getElementById('game-instructions').style.display = 'none'; | |
| const duration = Date.now() - playStartTime; | |
| const gameTypeStr = document.getElementById('game-select').value; | |
| // Create HuggingFace format output | |
| const exportData = { | |
| index: 0, | |
| tickInputs: playTickInputs, | |
| duration: duration, | |
| gameType: gameTypeStr, | |
| physicsTickCount: playTickInputs.length, | |
| touchscreen: false, | |
| puzzleSeed: playSeed, | |
| }; | |
| document.getElementById('export-output').value = JSON.stringify(exportData, null, 2); | |
| const status = document.getElementById('play-status'); | |
| status.style.display = 'block'; | |
| status.className = 'status ' + (outcome === 'solved' ? 'success' : 'error'); | |
| status.textContent = outcome === 'solved' ? 'Success! Puzzle completed!' : 'Failed. Try again!'; | |
| document.getElementById('start-game-btn').disabled = false; | |
| document.getElementById('stop-game-btn').disabled = true; | |
| } | |
| function stopGame() { | |
| finishGame('abandoned'); | |
| } | |
| // ============================================================================ | |
| // EVENT HANDLERS | |
| // ============================================================================ | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| try { | |
| await loadWasm(); | |
| document.getElementById('loading').classList.add('hidden'); | |
| } catch (err) { | |
| document.getElementById('loading').innerHTML = `<div style="color:#fca5a5;text-align:center;"> | |
| <p>Failed to load WASM module</p> | |
| <p style="font-size:0.8rem;margin-top:8px;">${err.message}</p> | |
| </div>`; | |
| } | |
| // Tab switching | |
| document.querySelectorAll('.tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); | |
| tab.classList.add('active'); | |
| document.getElementById(`${tab.dataset.tab}-panel`).classList.add('active'); | |
| }); | |
| }); | |
| // Replay controls | |
| document.getElementById('load-btn').addEventListener('click', () => { | |
| const text = document.getElementById('json-input').value; | |
| const sessions = parseJsonlOrJson(text); | |
| loadAndReplay(sessions); | |
| }); | |
| document.getElementById('file-input').addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (ev) => { | |
| const sessions = parseJsonlOrJson(ev.target.result); | |
| loadAndReplay(sessions); | |
| }; | |
| reader.readAsText(file); | |
| }); | |
| document.getElementById('load-random-btn').addEventListener('click', async () => { | |
| showStatus('load-status', 'Loading sample...', 'info'); | |
| try { | |
| const resp = await fetch('samples.json'); | |
| if (!resp.ok) throw new Error('Failed to load samples.json'); | |
| const samples = await resp.json(); | |
| const randomSample = samples[Math.floor(Math.random() * samples.length)]; | |
| loadAndReplay([randomSample]); | |
| showStatus('load-status', `Loaded ${randomSample.gameType} sample`, 'success'); | |
| } catch (err) { | |
| showStatus('load-status', err.message, 'error'); | |
| } | |
| }); | |
| document.getElementById('play-pause-btn').addEventListener('click', () => { | |
| if (isReplaying) stopReplay(); | |
| else startReplay(); | |
| }); | |
| document.getElementById('restart-btn').addEventListener('click', restartReplay); | |
| document.getElementById('prev-session').addEventListener('click', () => { | |
| if (currentSessionIndex > 0) loadAndReplay(currentSessions, currentSessionIndex - 1); | |
| }); | |
| document.getElementById('next-session').addEventListener('click', () => { | |
| if (currentSessionIndex < currentSessions.length - 1) loadAndReplay(currentSessions, currentSessionIndex + 1); | |
| }); | |
| document.getElementById('progress-bar').addEventListener('click', (e) => { | |
| const rect = e.target.getBoundingClientRect(); | |
| const pct = (e.clientX - rect.left) / rect.width; | |
| const session = currentSessions[currentSessionIndex]; | |
| if (!session) return; | |
| const total = session.physicsTickCount || session.tickInputs?.length || 0; | |
| // Can't seek in current implementation - would need to re-run physics | |
| }); | |
| // Play controls | |
| document.getElementById('start-game-btn').addEventListener('click', startGame); | |
| document.getElementById('stop-game-btn').addEventListener('click', stopGame); | |
| document.getElementById('copy-export-btn').addEventListener('click', () => { | |
| const text = document.getElementById('export-output').value; | |
| navigator.clipboard.writeText(text); | |
| }); | |
| document.getElementById('download-export-btn').addEventListener('click', () => { | |
| const text = document.getElementById('export-output').value; | |
| if (!text) return; | |
| const blob = new Blob([text], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'session.json'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| // Play canvas mouse events | |
| const playCanvas = document.getElementById('play-canvas'); | |
| function updatePlayInput(e) { | |
| if (!isPlaying) return; | |
| const rect = playCanvas.getBoundingClientRect(); | |
| const scaleX = 330 / rect.width; | |
| const scaleY = 330 / rect.height; | |
| const screenX = (e.clientX - rect.left) * scaleX; | |
| const screenY = (e.clientY - rect.top) * scaleY; | |
| // Convert to grid coords (330px canvas maps to 200 grid units) | |
| playLastInput.x = (screenX / 330) * 200; | |
| playLastInput.y = (screenY / 330) * 200; | |
| } | |
| playCanvas.addEventListener('mousedown', (e) => { | |
| playLastInput.isDown = true; | |
| updatePlayInput(e); | |
| }); | |
| playCanvas.addEventListener('mousemove', updatePlayInput); | |
| playCanvas.addEventListener('mouseup', () => { | |
| playLastInput.isDown = false; | |
| }); | |
| playCanvas.addEventListener('mouseleave', () => { | |
| playLastInput.isDown = false; | |
| }); | |
| // Touch events | |
| playCanvas.addEventListener('touchstart', (e) => { | |
| e.preventDefault(); | |
| playLastInput.isDown = true; | |
| const touch = e.touches[0]; | |
| updatePlayInput({ clientX: touch.clientX, clientY: touch.clientY }); | |
| }); | |
| playCanvas.addEventListener('touchmove', (e) => { | |
| e.preventDefault(); | |
| const touch = e.touches[0]; | |
| updatePlayInput({ clientX: touch.clientX, clientY: touch.clientY }); | |
| }); | |
| playCanvas.addEventListener('touchend', () => { | |
| playLastInput.isDown = false; | |
| }); | |
| }); | |
| function showStatus(id, message, type) { | |
| const el = document.getElementById(id); | |
| el.textContent = message; | |
| el.className = 'status ' + type; | |
| el.style.display = 'block'; | |
| } | |
| </script> | |
| </body> | |
| </html> | |