TonyChen06 commited on
Commit
2268683
·
1 Parent(s): 438cb3f

Initial space with replayer

Browse files
Files changed (2) hide show
  1. README.md +37 -7
  2. index.html +1073 -18
README.md CHANGED
@@ -1,12 +1,42 @@
1
  ---
2
- title: CaptchaSolve Demo
3
- emoji: 📊
4
- colorFrom: pink
5
- colorTo: green
6
  sdk: static
7
  pinned: false
8
- license: apache-2.0
9
- short_description: Demo space for replaying and attempting CaptchaSolve30k.
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CapyCap Mouse Movement Dataset Demo
3
+ emoji: 🐹
4
+ colorFrom: yellow
5
+ colorTo: yellow
6
  sdk: static
7
  pinned: false
8
+ license: mit
 
9
  ---
10
 
11
+ # CapyCap Mouse Movement Dataset - Demo & Replay
12
+
13
+ This space lets you:
14
+
15
+ 1. **Replay samples** from the [CaptchaSolve dataset](https://huggingface.co/datasets/Capycap-AI/CaptchaSolve) to visualize mouse movements
16
+ 2. **Play the games** yourself and export your session data in the same format
17
+
18
+ ## Usage
19
+
20
+ ### Replay Mode
21
+ - Paste JSON from the dataset or upload a JSONL file
22
+ - Use playback controls to watch the mouse movement replay
23
+ - Navigate between multiple sessions if you load a JSONL file
24
+
25
+ ### Play Mode
26
+ - Select a game type (Sheep Herding, Thread the Needle, or Polygon Stacking)
27
+ - Click "Start Game" and solve the puzzle using your mouse
28
+ - After completion, copy or download the exported session JSON
29
+
30
+ ## Dataset Format
31
+
32
+ Each session contains:
33
+ - `tickInputs`: Array of `{x, y, isDown, sampleIndex}` at 240 Hz physics rate
34
+ - `inputStream`: Base64-encoded 1000 Hz mouse sampling (for biometric analysis)
35
+ - `gameType`: One of `sheep-herding`, `thread-the-needle`, `polygon-stacking`
36
+ - `duration`: Session length in milliseconds
37
+ - `physicsTickCount`: Total physics ticks
38
+
39
+ ## Links
40
+
41
+ - [Dataset on HuggingFace](https://huggingface.co/datasets/Capycap-AI/CaptchaSolve)
42
+ - [CapyCap Website](https://capycap.com)
index.html CHANGED
@@ -1,19 +1,1074 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CapyCap Mouse Movement Dataset - Demo &amp; Replay</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: #1c1917;
12
+ color: #fafaf9;
13
+ min-height: 100vh;
14
+ padding: 20px;
15
+ }
16
+ .container { max-width: 1200px; margin: 0 auto; }
17
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
18
+ .subtitle { color: #a8a29e; margin-bottom: 1.5rem; }
19
+ .tabs {
20
+ display: flex;
21
+ gap: 8px;
22
+ margin-bottom: 1.5rem;
23
+ }
24
+ .tab {
25
+ padding: 10px 20px;
26
+ border: none;
27
+ background: #292524;
28
+ color: #a8a29e;
29
+ border-radius: 8px;
30
+ cursor: pointer;
31
+ font-size: 0.9rem;
32
+ transition: all 0.2s;
33
+ }
34
+ .tab:hover { background: #44403c; color: #fafaf9; }
35
+ .tab.active { background: #a67c52; color: #fafaf9; }
36
+ .panel { display: none; }
37
+ .panel.active { display: block; }
38
+ .card {
39
+ background: #292524;
40
+ border-radius: 12px;
41
+ padding: 20px;
42
+ margin-bottom: 16px;
43
+ }
44
+ .card h2 {
45
+ font-size: 1.1rem;
46
+ margin-bottom: 12px;
47
+ color: #d6d3d1;
48
+ }
49
+ .row { display: flex; gap: 20px; flex-wrap: wrap; }
50
+ .col { flex: 1; min-width: 300px; }
51
+ canvas {
52
+ display: block;
53
+ border-radius: 8px;
54
+ background: #44403c;
55
+ }
56
+ .controls {
57
+ display: flex;
58
+ gap: 10px;
59
+ flex-wrap: wrap;
60
+ align-items: center;
61
+ margin-bottom: 16px;
62
+ }
63
+ select, input[type="file"], button {
64
+ padding: 8px 16px;
65
+ border-radius: 8px;
66
+ border: 1px solid #44403c;
67
+ background: #1c1917;
68
+ color: #fafaf9;
69
+ font-size: 0.9rem;
70
+ }
71
+ button {
72
+ background: #a67c52;
73
+ border-color: #a67c52;
74
+ cursor: pointer;
75
+ transition: background 0.2s;
76
+ }
77
+ button:hover { background: #8b5e3c; }
78
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
79
+ .btn-secondary { background: #44403c; border-color: #57534e; }
80
+ .btn-secondary:hover { background: #57534e; }
81
+ textarea {
82
+ width: 100%;
83
+ height: 150px;
84
+ padding: 12px;
85
+ border-radius: 8px;
86
+ border: 1px solid #44403c;
87
+ background: #1c1917;
88
+ color: #fafaf9;
89
+ font-family: monospace;
90
+ font-size: 0.8rem;
91
+ resize: vertical;
92
+ }
93
+ .info {
94
+ font-size: 0.85rem;
95
+ color: #a8a29e;
96
+ }
97
+ .info strong { color: #d6d3d1; }
98
+ .status {
99
+ padding: 8px 12px;
100
+ border-radius: 6px;
101
+ font-size: 0.85rem;
102
+ margin-top: 12px;
103
+ }
104
+ .status.success { background: #166534; color: #86efac; }
105
+ .status.error { background: #991b1b; color: #fca5a5; }
106
+ .status.info { background: #1e40af; color: #93c5fd; }
107
+ .playback-controls {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: 12px;
111
+ margin-top: 12px;
112
+ }
113
+ .progress-bar {
114
+ flex: 1;
115
+ height: 8px;
116
+ background: #44403c;
117
+ border-radius: 4px;
118
+ cursor: pointer;
119
+ position: relative;
120
+ }
121
+ .progress-fill {
122
+ height: 100%;
123
+ background: #a67c52;
124
+ border-radius: 4px;
125
+ transition: width 0.1s;
126
+ }
127
+ .speed-select { width: 80px; }
128
+ #loading {
129
+ position: fixed;
130
+ inset: 0;
131
+ background: rgba(0,0,0,0.8);
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ z-index: 1000;
136
+ }
137
+ #loading.hidden { display: none; }
138
+ .game-wrapper {
139
+ display: inline-block;
140
+ border-radius: 8px;
141
+ overflow: hidden;
142
+ }
143
+ .game-wrapper canvas {
144
+ display: block;
145
+ border-radius: 0;
146
+ }
147
+ .game-header {
148
+ padding: 12px 16px;
149
+ }
150
+ .game-header-small { font-size: 0.85rem; opacity: 0.9; }
151
+ .game-header-main { font-size: 1.2rem; font-weight: bold; }
152
+ .game-header.sheep { background: #916d46; }
153
+ .game-header.needle { background: #d97706; }
154
+ .game-header.polygon { background: #78716c; }
155
+ .spinner {
156
+ width: 40px;
157
+ height: 40px;
158
+ border: 3px solid #44403c;
159
+ border-top-color: #a67c52;
160
+ border-radius: 50%;
161
+ animation: spin 1s linear infinite;
162
+ }
163
+ @keyframes spin { to { transform: rotate(360deg); } }
164
+ .session-nav {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 8px;
168
+ margin-bottom: 12px;
169
+ }
170
+ .session-nav span { color: #a8a29e; font-size: 0.9rem; }
171
+ a { color: #a67c52; }
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <div id="loading"><div class="spinner"></div></div>
176
+
177
+ <div class="container">
178
+ <h1>CapyCap Mouse Movement Dataset</h1>
179
+ <p class="subtitle">
180
+ Play captcha games or replay samples from the
181
+ <a href="https://huggingface.co/datasets/Capycap-AI/CaptchaSolve" target="_blank">HuggingFace dataset</a>
182
+ </p>
183
+
184
+ <div class="tabs">
185
+ <button class="tab active" data-tab="replay">Replay Samples</button>
186
+ <button class="tab" data-tab="play">Play Games</button>
187
+ </div>
188
+
189
+ <!-- REPLAY PANEL -->
190
+ <div id="replay-panel" class="panel active">
191
+ <div class="row">
192
+ <div class="col">
193
+ <div class="card">
194
+ <h2>Load Session Data</h2>
195
+ <div class="controls">
196
+ <input type="file" id="file-input" accept=".json,.jsonl">
197
+ <span class="info">or paste JSON below</span>
198
+ </div>
199
+ <textarea id="json-input" placeholder='Paste a session JSON from the dataset here...
200
+ Example: {"index":0,"tickInputs":[...],"gameType":"sheep-herding",...}'></textarea>
201
+ <div class="controls" style="margin-top: 12px;">
202
+ <button id="load-btn">Load &amp; Replay</button>
203
+ <button id="load-random-btn" class="btn-secondary">Load Random from HF</button>
204
+ </div>
205
+ <div id="load-status"></div>
206
+ </div>
207
+
208
+ <div class="card">
209
+ <h2>Session Info</h2>
210
+ <div class="info" id="session-info">
211
+ <p>No session loaded</p>
212
+ </div>
213
+ </div>
214
+ </div>
215
+
216
+ <div class="col">
217
+ <div class="card">
218
+ <h2>Replay Viewer</h2>
219
+ <div class="session-nav" id="session-nav" style="display: none;">
220
+ <button id="prev-session" class="btn-secondary">&larr;</button>
221
+ <span id="session-counter">1 / 1</span>
222
+ <button id="next-session" class="btn-secondary">&rarr;</button>
223
+ </div>
224
+ <canvas id="replay-canvas" width="330" height="330"></canvas>
225
+ <div class="playback-controls">
226
+ <button id="play-pause-btn">Play</button>
227
+ <button id="restart-btn" class="btn-secondary">Restart</button>
228
+ <div class="progress-bar" id="progress-bar">
229
+ <div class="progress-fill" id="progress-fill"></div>
230
+ </div>
231
+ <select id="speed-select" class="speed-select">
232
+ <option value="0.25">0.25x</option>
233
+ <option value="0.5">0.5x</option>
234
+ <option value="1" selected>1x</option>
235
+ <option value="2">2x</option>
236
+ <option value="4">4x</option>
237
+ </select>
238
+ </div>
239
+ <div class="info" style="margin-top: 8px;">
240
+ <span id="tick-display">Tick: 0 / 0</span>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </div>
245
+ </div>
246
+
247
+ <!-- PLAY PANEL -->
248
+ <div id="play-panel" class="panel">
249
+ <div class="row">
250
+ <div class="col">
251
+ <div class="card">
252
+ <h2>Play a Game</h2>
253
+ <div class="controls">
254
+ <select id="game-select">
255
+ <option value="sheep-herding">Sheep Herding</option>
256
+ <option value="thread-the-needle">Thread the Needle</option>
257
+ <option value="polygon-stacking">Polygon Stacking</option>
258
+ </select>
259
+ <button id="start-game-btn">Start Game</button>
260
+ <button id="stop-game-btn" class="btn-secondary" disabled>Stop</button>
261
+ </div>
262
+ <div class="game-wrapper">
263
+ <div id="game-instructions" class="game-header" style="display: none;">
264
+ <div class="game-header-text"></div>
265
+ </div>
266
+ <canvas id="play-canvas" width="330" height="330"></canvas>
267
+ </div>
268
+ <div id="play-status" class="status info" style="display: none;"></div>
269
+ </div>
270
+ </div>
271
+
272
+ <div class="col">
273
+ <div class="card">
274
+ <h2>Export Session (HuggingFace Format)</h2>
275
+ <p class="info" style="margin-bottom: 12px;">
276
+ After completing a game, copy this JSON to use with ML models.
277
+ This matches the format in the HuggingFace dataset.
278
+ </p>
279
+ <textarea id="export-output" readonly placeholder="Complete a game to see the exported session data..."></textarea>
280
+ <div class="controls" style="margin-top: 12px;">
281
+ <button id="copy-export-btn" class="btn-secondary">Copy to Clipboard</button>
282
+ <button id="download-export-btn" class="btn-secondary">Download JSON</button>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- Load WASM game module -->
291
+ <script src="game.js"></script>
292
+ <script>
293
+ // ============================================================================
294
+ // CORE UTILITIES (from GAME_SPEC.ts)
295
+ // ============================================================================
296
+
297
+ const GRID_BASE = 200;
298
+ const PHYSICS_TIMESTEP = 1 / 240; // 240 Hz
299
+ const BASE_RENDER_WIDTH = 330;
300
+ const MIN_RENDER_SCALE = 1.1;
301
+ const MAX_RENDER_SCALE = 1.2;
302
+
303
+ // Mulberry32 PRNG
304
+ function createSeededRNG(seed) {
305
+ let state = seed >>> 0;
306
+ function next() {
307
+ state |= 0;
308
+ state = (state + 0x6D2B79F5) | 0;
309
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
310
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
311
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
312
+ }
313
+ return { next };
314
+ }
315
+
316
+ function generateRenderScale(rng) {
317
+ return MIN_RENDER_SCALE + rng.next() * (MAX_RENDER_SCALE - MIN_RENDER_SCALE);
318
+ }
319
+
320
+ // Game type enum (matches C)
321
+ const GameType = {
322
+ THREAD_NEEDLE: 0,
323
+ SHEEP_HERDING: 1,
324
+ POLYGON_STACKING: 2,
325
+ };
326
+
327
+ function getGameTypeEnum(str) {
328
+ switch (str) {
329
+ case 'thread-the-needle': return GameType.THREAD_NEEDLE;
330
+ case 'sheep-herding': return GameType.SHEEP_HERDING;
331
+ case 'polygon-stacking': return GameType.POLYGON_STACKING;
332
+ default: return GameType.SHEEP_HERDING;
333
+ }
334
+ }
335
+
336
+ // ============================================================================
337
+ // COMMAND BUFFER RENDERER (from command-renderer.ts)
338
+ // ============================================================================
339
+
340
+ const DrawCommandType = {
341
+ CMD_CLEAR: 0, CMD_FILL_RECT: 1, CMD_STROKE_RECT: 2,
342
+ CMD_FILL_CIRCLE: 3, CMD_STROKE_CIRCLE: 4,
343
+ CMD_FILL_ELLIPSE: 5, CMD_STROKE_ELLIPSE: 6,
344
+ CMD_LINE: 7, CMD_FILL_POLYGON: 8, CMD_STROKE_POLYGON: 9,
345
+ CMD_ARC: 10, CMD_BEZIER: 11, CMD_QUADRATIC: 12,
346
+ CMD_SET_LINE_WIDTH: 13, CMD_SET_LINE_CAP: 14, CMD_SET_LINE_DASH: 15,
347
+ CMD_TEXT: 16, CMD_END: 17,
348
+ };
349
+
350
+ function colorToCSS(color) {
351
+ const r = color & 0xff;
352
+ const g = (color >> 8) & 0xff;
353
+ const b = (color >> 16) & 0xff;
354
+ const a = (color >> 24) & 0xff;
355
+ return a === 255 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${(a/255).toFixed(3)})`;
356
+ }
357
+
358
+ class CommandBufferReader {
359
+ constructor(buffer, byteOffset, byteLength) {
360
+ this.data = new Uint8Array(buffer, byteOffset, byteLength);
361
+ this.view = new DataView(buffer, byteOffset, byteLength);
362
+ this.offset = 0;
363
+ }
364
+ readU8() { return this.data[this.offset++]; }
365
+ readF32() { const v = this.view.getFloat32(this.offset, true); this.offset += 4; return v; }
366
+ readU32() { const v = this.view.getUint32(this.offset, true); this.offset += 4; return v; }
367
+ readString(len) {
368
+ let s = '';
369
+ for (let i = 0; i < len; i++) s += String.fromCharCode(this.data[this.offset + i]);
370
+ this.offset += len;
371
+ return s;
372
+ }
373
+ execute(ctx) {
374
+ this.offset = 0;
375
+ while (this.offset < this.data.length) {
376
+ const cmd = this.readU8();
377
+ switch (cmd) {
378
+ case DrawCommandType.CMD_CLEAR: {
379
+ ctx.fillStyle = colorToCSS(this.readU32());
380
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
381
+ break;
382
+ }
383
+ case DrawCommandType.CMD_FILL_RECT: {
384
+ const x = this.readF32(), y = this.readF32(), w = this.readF32(), h = this.readF32();
385
+ ctx.fillStyle = colorToCSS(this.readU32());
386
+ ctx.fillRect(x, y, w, h);
387
+ break;
388
+ }
389
+ case DrawCommandType.CMD_FILL_CIRCLE: {
390
+ const cx = this.readF32(), cy = this.readF32(), r = this.readF32();
391
+ ctx.fillStyle = colorToCSS(this.readU32());
392
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
393
+ break;
394
+ }
395
+ case DrawCommandType.CMD_STROKE_CIRCLE: {
396
+ const cx = this.readF32(), cy = this.readF32(), r = this.readF32();
397
+ ctx.strokeStyle = colorToCSS(this.readU32());
398
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
399
+ break;
400
+ }
401
+ case DrawCommandType.CMD_FILL_ELLIPSE: {
402
+ const cx = this.readF32(), cy = this.readF32(), rx = this.readF32(), ry = this.readF32();
403
+ ctx.fillStyle = colorToCSS(this.readU32());
404
+ ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
405
+ break;
406
+ }
407
+ case DrawCommandType.CMD_LINE: {
408
+ const x1 = this.readF32(), y1 = this.readF32(), x2 = this.readF32(), y2 = this.readF32();
409
+ ctx.strokeStyle = colorToCSS(this.readU32());
410
+ ctx.lineWidth = this.readF32();
411
+ ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
412
+ break;
413
+ }
414
+ case DrawCommandType.CMD_FILL_POLYGON: {
415
+ const n = this.readU8();
416
+ ctx.fillStyle = colorToCSS(this.readU32());
417
+ ctx.beginPath();
418
+ for (let i = 0; i < n; i++) {
419
+ const x = this.readF32(), y = this.readF32();
420
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
421
+ }
422
+ ctx.closePath(); ctx.fill();
423
+ break;
424
+ }
425
+ case DrawCommandType.CMD_STROKE_POLYGON: {
426
+ const n = this.readU8();
427
+ ctx.strokeStyle = colorToCSS(this.readU32());
428
+ ctx.beginPath();
429
+ for (let i = 0; i < n; i++) {
430
+ const x = this.readF32(), y = this.readF32();
431
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
432
+ }
433
+ ctx.closePath(); ctx.stroke();
434
+ break;
435
+ }
436
+ case DrawCommandType.CMD_SET_LINE_WIDTH: ctx.lineWidth = this.readF32(); break;
437
+ case DrawCommandType.CMD_SET_LINE_CAP: {
438
+ const c = this.readU8();
439
+ ctx.lineCap = c === 1 ? 'round' : c === 2 ? 'square' : 'butt';
440
+ break;
441
+ }
442
+ case DrawCommandType.CMD_SET_LINE_DASH: {
443
+ const d = this.readF32(), g = this.readF32();
444
+ ctx.setLineDash(d > 0 && g > 0 ? [d, g] : []);
445
+ break;
446
+ }
447
+ case DrawCommandType.CMD_TEXT: {
448
+ const x = this.readF32(), y = this.readF32();
449
+ ctx.fillStyle = colorToCSS(this.readU32());
450
+ const size = this.readF32(), center = this.readU8() !== 0, len = this.readU8();
451
+ const text = this.readString(len);
452
+ ctx.font = `${size}px sans-serif`;
453
+ ctx.textAlign = center ? 'center' : 'left';
454
+ ctx.textBaseline = 'middle';
455
+ ctx.fillText(text, x, y);
456
+ break;
457
+ }
458
+ case DrawCommandType.CMD_END: return;
459
+ default: return;
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ // ============================================================================
466
+ // WASM GAME WRAPPER
467
+ // ============================================================================
468
+
469
+ let wasmModule = null;
470
+
471
+ async function loadWasm() {
472
+ if (wasmModule) return wasmModule;
473
+
474
+ if (typeof createGameModule === 'undefined') {
475
+ throw new Error('game.js not loaded');
476
+ }
477
+
478
+ wasmModule = await createGameModule({
479
+ locateFile: (path) => path,
480
+ });
481
+
482
+ return wasmModule;
483
+ }
484
+
485
+ class WasmGame {
486
+ constructor(module, gameType, width = 330, height = 330) {
487
+ this.module = module;
488
+ this.gameType = gameType;
489
+ this.width = width;
490
+ this.height = height;
491
+ this.initialized = false;
492
+ this.imageData = null;
493
+ }
494
+
495
+ init(seed) {
496
+ if (this.initialized) this.cleanup();
497
+ const result = this.module._game_init(this.gameType, seed, this.width, this.height);
498
+ this.initialized = result === 0;
499
+ if (this.initialized) {
500
+ this.imageData = new ImageData(this.width, this.height);
501
+ }
502
+ return this.initialized;
503
+ }
504
+
505
+ step(x, y, isDown) {
506
+ if (!this.initialized) return;
507
+ this.module._game_step(x, y, isDown ? 1 : 0);
508
+ }
509
+
510
+ applyInput(x, y, isDown) {
511
+ if (!this.initialized) return;
512
+ this.module._game_apply_input(x, y, isDown ? 1 : 0);
513
+ }
514
+
515
+ recordInput(x, y) {
516
+ if (!this.initialized) return;
517
+ this.module._game_record_input(x, y);
518
+ }
519
+
520
+ renderPvg(ctx) {
521
+ if (!this.initialized || !this.imageData) return;
522
+ this.module._game_render_pvg();
523
+ const pixelPtr = this.module._game_get_pvg_pixels();
524
+ const stride = this.module._game_get_pvg_stride();
525
+ if (pixelPtr === 0 || stride === 0) return;
526
+
527
+ const data = this.imageData.data;
528
+ const wasmMem = new Uint8Array(this.module.wasmMemory.buffer);
529
+ for (let y = 0; y < this.height; y++) {
530
+ const srcRow = pixelPtr + y * stride;
531
+ const dstRow = y * this.width * 4;
532
+ for (let x = 0; x < this.width; x++) {
533
+ const si = srcRow + x * 4, di = dstRow + x * 4;
534
+ const r = wasmMem[si], g = wasmMem[si+1], b = wasmMem[si+2], a = wasmMem[si+3];
535
+ if (a === 0) { data[di] = data[di+1] = data[di+2] = data[di+3] = 0; }
536
+ else if (a === 255) { data[di] = r; data[di+1] = g; data[di+2] = b; data[di+3] = 255; }
537
+ else {
538
+ data[di] = Math.min(255, Math.round((r * 255) / a));
539
+ data[di+1] = Math.min(255, Math.round((g * 255) / a));
540
+ data[di+2] = Math.min(255, Math.round((b * 255) / a));
541
+ data[di+3] = a;
542
+ }
543
+ }
544
+ }
545
+ ctx.putImageData(this.imageData, 0, 0);
546
+ }
547
+
548
+ isSolved() { return this.initialized && this.module._game_is_solved() !== 0; }
549
+ isFailed() { return this.initialized && this.module._game_is_failed() !== 0; }
550
+ getCurrentTick() { return this.initialized ? this.module._game_get_current_tick() : 0; }
551
+
552
+ getTargetInfo() {
553
+ if (!this.initialized) return { shape: '', colorName: '', colorHex: '#000000' };
554
+ const shapePtr = this.module._malloc(64);
555
+ const colorPtr = this.module._malloc(64);
556
+ const colorValuePtr = this.module._malloc(4);
557
+ this.module._game_get_target_info(shapePtr, colorPtr, colorValuePtr);
558
+ const shape = this.module.UTF8ToString(shapePtr);
559
+ const colorName = this.module.UTF8ToString(colorPtr);
560
+ const colorValue = this.module.getValue(colorValuePtr, 'i32');
561
+ const r = colorValue & 0xff;
562
+ const g = (colorValue >> 8) & 0xff;
563
+ const b = (colorValue >> 16) & 0xff;
564
+ const colorHex = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
565
+ this.module._free(shapePtr);
566
+ this.module._free(colorPtr);
567
+ this.module._free(colorValuePtr);
568
+ return { shape, colorName, colorHex };
569
+ }
570
+
571
+ getSessionDataJson() {
572
+ if (!this.initialized) return '{}';
573
+ const ptr = this.module._game_get_session_data();
574
+ return this.module.UTF8ToString(ptr);
575
+ }
576
+
577
+ cleanup() {
578
+ if (this.initialized) {
579
+ this.module._game_cleanup();
580
+ this.initialized = false;
581
+ this.imageData = null;
582
+ }
583
+ }
584
+ }
585
+
586
+ // ============================================================================
587
+ // APP STATE
588
+ // ============================================================================
589
+
590
+ let currentSessions = [];
591
+ let currentSessionIndex = 0;
592
+ let replayGame = null;
593
+ let replayAnimationId = null;
594
+ let isReplaying = false;
595
+ let currentTick = 0;
596
+
597
+ let playGame = null;
598
+ let playAnimationId = null;
599
+ let isPlaying = false;
600
+ let playStartTime = 0;
601
+ let playTickInputs = [];
602
+ let playLastInput = { x: 0, y: 0, isDown: false };
603
+ let playSeed = 0;
604
+
605
+ // ============================================================================
606
+ // REPLAY FUNCTIONS
607
+ // ============================================================================
608
+
609
+ function parseJsonlOrJson(text) {
610
+ text = text.trim();
611
+ // Try single JSON first
612
+ try {
613
+ const obj = JSON.parse(text);
614
+ if (Array.isArray(obj)) return obj;
615
+ return [obj];
616
+ } catch {}
617
+ // Try JSONL
618
+ const lines = text.split('\n').filter(l => l.trim());
619
+ const sessions = [];
620
+ for (const line of lines) {
621
+ try {
622
+ const obj = JSON.parse(line);
623
+ if (obj.tickInputs) sessions.push(obj);
624
+ } catch {}
625
+ }
626
+ return sessions;
627
+ }
628
+
629
+ async function loadAndReplay(sessions, index = 0) {
630
+ if (sessions.length === 0) {
631
+ showStatus('load-status', 'No valid sessions found', 'error');
632
+ return;
633
+ }
634
+
635
+ currentSessions = sessions;
636
+ currentSessionIndex = index;
637
+
638
+ stopReplay();
639
+
640
+ const session = sessions[index];
641
+ const module = await loadWasm();
642
+
643
+ // Create game
644
+ const gameType = getGameTypeEnum(session.gameType);
645
+ replayGame = new WasmGame(module, gameType, 330, 330);
646
+
647
+ // Initialize with puzzleSeed from session
648
+ if (!session.puzzleSeed) {
649
+ showStatus('load-status', 'Warning: No puzzleSeed in session - replay may not match original', 'error');
650
+ }
651
+ const seed = session.puzzleSeed || 0;
652
+ const rng = createSeededRNG(seed);
653
+ generateRenderScale(rng); // Advance RNG (same as original)
654
+ const wasmSeed = Math.floor(rng.next() * 0xffffffff);
655
+ replayGame.init(wasmSeed);
656
+
657
+ currentTick = 0;
658
+ updateSessionInfo(session);
659
+ updateSessionNav();
660
+ renderFrame();
661
+
662
+ showStatus('load-status', `Loaded session ${index + 1} of ${sessions.length}`, 'success');
663
+ }
664
+
665
+ function renderFrame() {
666
+ if (!replayGame) return;
667
+ const canvas = document.getElementById('replay-canvas');
668
+ const ctx = canvas.getContext('2d');
669
+ replayGame.renderPvg(ctx);
670
+
671
+ const session = currentSessions[currentSessionIndex];
672
+ const total = session?.physicsTickCount || session?.tickInputs?.length || 0;
673
+ document.getElementById('tick-display').textContent = `Tick: ${currentTick} / ${total}`;
674
+ document.getElementById('progress-fill').style.width = total > 0 ? `${(currentTick / total) * 100}%` : '0%';
675
+ }
676
+
677
+ function startReplay() {
678
+ if (!replayGame || isReplaying) return;
679
+
680
+ const session = currentSessions[currentSessionIndex];
681
+ if (!session?.tickInputs) return;
682
+
683
+ isReplaying = true;
684
+ document.getElementById('play-pause-btn').textContent = 'Pause';
685
+
686
+ const tickInputs = session.tickInputs;
687
+ const totalTicks = session.physicsTickCount || tickInputs.length;
688
+ const speed = parseFloat(document.getElementById('speed-select').value);
689
+ const TICK_MS = PHYSICS_TIMESTEP * 1000;
690
+
691
+ let lastTime = performance.now();
692
+ let accumulator = 0;
693
+ let lastInput = { x: 0, y: 0, isDown: false };
694
+
695
+ function frame(now) {
696
+ if (!isReplaying) return;
697
+
698
+ const delta = (now - lastTime) * speed;
699
+ lastTime = now;
700
+ accumulator += delta;
701
+
702
+ while (accumulator >= TICK_MS && currentTick < totalTicks) {
703
+ const input = tickInputs[currentTick] || lastInput;
704
+ if (tickInputs[currentTick]) {
705
+ lastInput = { x: input.x, y: input.y, isDown: input.isDown };
706
+ }
707
+ replayGame.step(input.x, input.y, input.isDown);
708
+ currentTick++;
709
+ accumulator -= TICK_MS;
710
+
711
+ if (replayGame.isSolved() || replayGame.isFailed()) break;
712
+ }
713
+
714
+ renderFrame();
715
+
716
+ if (currentTick >= totalTicks || replayGame.isSolved() || replayGame.isFailed()) {
717
+ stopReplay();
718
+ return;
719
+ }
720
+
721
+ replayAnimationId = requestAnimationFrame(frame);
722
+ }
723
+
724
+ replayAnimationId = requestAnimationFrame(frame);
725
+ }
726
+
727
+ function stopReplay() {
728
+ isReplaying = false;
729
+ if (replayAnimationId) {
730
+ cancelAnimationFrame(replayAnimationId);
731
+ replayAnimationId = null;
732
+ }
733
+ document.getElementById('play-pause-btn').textContent = 'Play';
734
+ }
735
+
736
+ function restartReplay() {
737
+ stopReplay();
738
+ if (currentSessions.length > 0) {
739
+ loadAndReplay(currentSessions, currentSessionIndex);
740
+ }
741
+ }
742
+
743
+ function updateSessionInfo(session) {
744
+ const info = document.getElementById('session-info');
745
+ info.innerHTML = `
746
+ <p><strong>Game Type:</strong> ${session.gameType || 'unknown'}</p>
747
+ <p><strong>Duration:</strong> ${session.duration ? (session.duration / 1000).toFixed(2) + 's' : 'N/A'}</p>
748
+ <p><strong>Physics Ticks:</strong> ${session.physicsTickCount || session.tickInputs?.length || 0}</p>
749
+ <p><strong>Input Samples:</strong> ${session.inputSampleCount || 'N/A'}</p>
750
+ <p><strong>Touchscreen:</strong> ${session.touchscreen ? 'Yes' : 'No'}</p>
751
+ `;
752
+ }
753
+
754
+ function updateSessionNav() {
755
+ const nav = document.getElementById('session-nav');
756
+ const counter = document.getElementById('session-counter');
757
+ nav.style.display = currentSessions.length > 1 ? 'flex' : 'none';
758
+ counter.textContent = `${currentSessionIndex + 1} / ${currentSessions.length}`;
759
+ document.getElementById('prev-session').disabled = currentSessionIndex === 0;
760
+ document.getElementById('next-session').disabled = currentSessionIndex >= currentSessions.length - 1;
761
+ }
762
+
763
+ // ============================================================================
764
+ // PLAY FUNCTIONS
765
+ // ============================================================================
766
+
767
+ const GAME_INSTRUCTIONS = {
768
+ 'sheep-herding': {
769
+ class: 'sheep',
770
+ small: 'Drag the dots into the',
771
+ main: '<span style="color:#86efac">Green</span> circle'
772
+ },
773
+ 'thread-the-needle': {
774
+ class: 'needle',
775
+ small: 'Drag the top of the string<br>to guide the carrot into',
776
+ main: 'The Target' // Will be replaced with actual target
777
+ },
778
+ 'polygon-stacking': {
779
+ class: 'polygon',
780
+ small: 'Stack and balance for',
781
+ main: '3 Seconds'
782
+ }
783
+ };
784
+
785
+ function showGameInstructions(gameType, targetInfo = null) {
786
+ const header = document.getElementById('game-instructions');
787
+ const info = GAME_INSTRUCTIONS[gameType];
788
+ if (!info) {
789
+ header.style.display = 'none';
790
+ return;
791
+ }
792
+ header.className = 'game-header ' + info.class;
793
+
794
+ let mainText = info.main;
795
+ if (gameType === 'thread-the-needle' && targetInfo && targetInfo.colorName) {
796
+ const shapeName = targetInfo.shape.charAt(0).toUpperCase() + targetInfo.shape.slice(1);
797
+ mainText = `The <span style="color:${targetInfo.colorHex}">${targetInfo.colorName}</span> ${shapeName}`;
798
+ }
799
+
800
+ header.innerHTML = `
801
+ <div class="game-header-small">${info.small}</div>
802
+ <div class="game-header-main">${mainText}</div>
803
+ `;
804
+ header.style.display = 'block';
805
+ }
806
+
807
+ async function startGame() {
808
+ const gameTypeStr = document.getElementById('game-select').value;
809
+ const module = await loadWasm();
810
+
811
+ if (playGame) playGame.cleanup();
812
+ stopPlayLoop();
813
+
814
+ const gameType = getGameTypeEnum(gameTypeStr);
815
+ playGame = new WasmGame(module, gameType, 330, 330);
816
+
817
+ playSeed = Math.floor(Math.random() * 3333); // 0-3332, same as production
818
+ const rng = createSeededRNG(playSeed);
819
+ generateRenderScale(rng);
820
+ const wasmSeed = Math.floor(rng.next() * 0xffffffff);
821
+ playGame.init(wasmSeed);
822
+
823
+ playTickInputs = [];
824
+ playLastInput = { x: 0, y: 0, isDown: false };
825
+ playStartTime = Date.now();
826
+ isPlaying = true;
827
+
828
+ // Get target info for thread-the-needle
829
+ const targetInfo = playGame.getTargetInfo();
830
+ showGameInstructions(gameTypeStr, targetInfo);
831
+ document.getElementById('start-game-btn').disabled = true;
832
+ document.getElementById('stop-game-btn').disabled = false;
833
+ document.getElementById('play-status').style.display = 'none';
834
+ document.getElementById('export-output').value = '';
835
+
836
+ const canvas = document.getElementById('play-canvas');
837
+ const ctx = canvas.getContext('2d');
838
+
839
+ const TICK_MS = PHYSICS_TIMESTEP * 1000;
840
+ let lastTime = performance.now();
841
+ let accumulator = 0;
842
+ let tick = 0;
843
+
844
+ function frame(now) {
845
+ if (!isPlaying) return;
846
+
847
+ const delta = now - lastTime;
848
+ lastTime = now;
849
+ accumulator += delta;
850
+
851
+ while (accumulator >= TICK_MS) {
852
+ playTickInputs.push({
853
+ x: playLastInput.x,
854
+ y: playLastInput.y,
855
+ isDown: playLastInput.isDown,
856
+ sampleIndex: tick
857
+ });
858
+ playGame.step(playLastInput.x, playLastInput.y, playLastInput.isDown);
859
+ tick++;
860
+ accumulator -= TICK_MS;
861
+
862
+ if (playGame.isSolved() || playGame.isFailed()) {
863
+ finishGame(playGame.isSolved() ? 'solved' : 'failed');
864
+ return;
865
+ }
866
+ }
867
+
868
+ playGame.renderPvg(ctx);
869
+ playAnimationId = requestAnimationFrame(frame);
870
+ }
871
+
872
+ playAnimationId = requestAnimationFrame(frame);
873
+ }
874
+
875
+ function stopPlayLoop() {
876
+ isPlaying = false;
877
+ if (playAnimationId) {
878
+ cancelAnimationFrame(playAnimationId);
879
+ playAnimationId = null;
880
+ }
881
+ }
882
+
883
+ function finishGame(outcome) {
884
+ stopPlayLoop();
885
+ document.getElementById('game-instructions').style.display = 'none';
886
+
887
+ const duration = Date.now() - playStartTime;
888
+ const gameTypeStr = document.getElementById('game-select').value;
889
+
890
+ // Create HuggingFace format output
891
+ const exportData = {
892
+ index: 0,
893
+ tickInputs: playTickInputs,
894
+ duration: duration,
895
+ gameType: gameTypeStr,
896
+ physicsTickCount: playTickInputs.length,
897
+ touchscreen: false,
898
+ puzzleSeed: playSeed,
899
+ };
900
+
901
+ document.getElementById('export-output').value = JSON.stringify(exportData, null, 2);
902
+
903
+ const status = document.getElementById('play-status');
904
+ status.style.display = 'block';
905
+ status.className = 'status ' + (outcome === 'solved' ? 'success' : 'error');
906
+ status.textContent = outcome === 'solved' ? 'Success! Puzzle completed!' : 'Failed. Try again!';
907
+
908
+ document.getElementById('start-game-btn').disabled = false;
909
+ document.getElementById('stop-game-btn').disabled = true;
910
+ }
911
+
912
+ function stopGame() {
913
+ finishGame('abandoned');
914
+ }
915
+
916
+ // ============================================================================
917
+ // EVENT HANDLERS
918
+ // ============================================================================
919
+
920
+ document.addEventListener('DOMContentLoaded', async () => {
921
+ try {
922
+ await loadWasm();
923
+ document.getElementById('loading').classList.add('hidden');
924
+ } catch (err) {
925
+ document.getElementById('loading').innerHTML = `<div style="color:#fca5a5;text-align:center;">
926
+ <p>Failed to load WASM module</p>
927
+ <p style="font-size:0.8rem;margin-top:8px;">${err.message}</p>
928
+ </div>`;
929
+ }
930
+
931
+ // Tab switching
932
+ document.querySelectorAll('.tab').forEach(tab => {
933
+ tab.addEventListener('click', () => {
934
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
935
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
936
+ tab.classList.add('active');
937
+ document.getElementById(`${tab.dataset.tab}-panel`).classList.add('active');
938
+ });
939
+ });
940
+
941
+ // Replay controls
942
+ document.getElementById('load-btn').addEventListener('click', () => {
943
+ const text = document.getElementById('json-input').value;
944
+ const sessions = parseJsonlOrJson(text);
945
+ loadAndReplay(sessions);
946
+ });
947
+
948
+ document.getElementById('file-input').addEventListener('change', (e) => {
949
+ const file = e.target.files[0];
950
+ if (!file) return;
951
+ const reader = new FileReader();
952
+ reader.onload = (ev) => {
953
+ const sessions = parseJsonlOrJson(ev.target.result);
954
+ loadAndReplay(sessions);
955
+ };
956
+ reader.readAsText(file);
957
+ });
958
+
959
+ document.getElementById('load-random-btn').addEventListener('click', async () => {
960
+ showStatus('load-status', 'Loading from HuggingFace...', 'info');
961
+ try {
962
+ // Note: This would need CORS headers from HF or a proxy
963
+ // For now, show instructions
964
+ showStatus('load-status',
965
+ 'Direct HF loading requires CORS. Download the dataset and paste JSON here.',
966
+ 'info');
967
+ } catch (err) {
968
+ showStatus('load-status', err.message, 'error');
969
+ }
970
+ });
971
+
972
+ document.getElementById('play-pause-btn').addEventListener('click', () => {
973
+ if (isReplaying) stopReplay();
974
+ else startReplay();
975
+ });
976
+
977
+ document.getElementById('restart-btn').addEventListener('click', restartReplay);
978
+
979
+ document.getElementById('prev-session').addEventListener('click', () => {
980
+ if (currentSessionIndex > 0) loadAndReplay(currentSessions, currentSessionIndex - 1);
981
+ });
982
+
983
+ document.getElementById('next-session').addEventListener('click', () => {
984
+ if (currentSessionIndex < currentSessions.length - 1) loadAndReplay(currentSessions, currentSessionIndex + 1);
985
+ });
986
+
987
+ document.getElementById('progress-bar').addEventListener('click', (e) => {
988
+ const rect = e.target.getBoundingClientRect();
989
+ const pct = (e.clientX - rect.left) / rect.width;
990
+ const session = currentSessions[currentSessionIndex];
991
+ if (!session) return;
992
+ const total = session.physicsTickCount || session.tickInputs?.length || 0;
993
+ // Can't seek in current implementation - would need to re-run physics
994
+ });
995
+
996
+ // Play controls
997
+ document.getElementById('start-game-btn').addEventListener('click', startGame);
998
+ document.getElementById('stop-game-btn').addEventListener('click', stopGame);
999
+
1000
+ document.getElementById('copy-export-btn').addEventListener('click', () => {
1001
+ const text = document.getElementById('export-output').value;
1002
+ navigator.clipboard.writeText(text);
1003
+ });
1004
+
1005
+ document.getElementById('download-export-btn').addEventListener('click', () => {
1006
+ const text = document.getElementById('export-output').value;
1007
+ if (!text) return;
1008
+ const blob = new Blob([text], { type: 'application/json' });
1009
+ const url = URL.createObjectURL(blob);
1010
+ const a = document.createElement('a');
1011
+ a.href = url;
1012
+ a.download = 'session.json';
1013
+ a.click();
1014
+ URL.revokeObjectURL(url);
1015
+ });
1016
+
1017
+ // Play canvas mouse events
1018
+ const playCanvas = document.getElementById('play-canvas');
1019
+
1020
+ function updatePlayInput(e) {
1021
+ if (!isPlaying) return;
1022
+ const rect = playCanvas.getBoundingClientRect();
1023
+ const scaleX = 330 / rect.width;
1024
+ const scaleY = 330 / rect.height;
1025
+ const screenX = (e.clientX - rect.left) * scaleX;
1026
+ const screenY = (e.clientY - rect.top) * scaleY;
1027
+ // Convert to grid coords (330px canvas maps to 200 grid units)
1028
+ playLastInput.x = (screenX / 330) * 200;
1029
+ playLastInput.y = (screenY / 330) * 200;
1030
+ }
1031
+
1032
+ playCanvas.addEventListener('mousedown', (e) => {
1033
+ playLastInput.isDown = true;
1034
+ updatePlayInput(e);
1035
+ });
1036
+
1037
+ playCanvas.addEventListener('mousemove', updatePlayInput);
1038
+
1039
+ playCanvas.addEventListener('mouseup', () => {
1040
+ playLastInput.isDown = false;
1041
+ });
1042
+
1043
+ playCanvas.addEventListener('mouseleave', () => {
1044
+ playLastInput.isDown = false;
1045
+ });
1046
+
1047
+ // Touch events
1048
+ playCanvas.addEventListener('touchstart', (e) => {
1049
+ e.preventDefault();
1050
+ playLastInput.isDown = true;
1051
+ const touch = e.touches[0];
1052
+ updatePlayInput({ clientX: touch.clientX, clientY: touch.clientY });
1053
+ });
1054
+
1055
+ playCanvas.addEventListener('touchmove', (e) => {
1056
+ e.preventDefault();
1057
+ const touch = e.touches[0];
1058
+ updatePlayInput({ clientX: touch.clientX, clientY: touch.clientY });
1059
+ });
1060
+
1061
+ playCanvas.addEventListener('touchend', () => {
1062
+ playLastInput.isDown = false;
1063
+ });
1064
+ });
1065
+
1066
+ function showStatus(id, message, type) {
1067
+ const el = document.getElementById(id);
1068
+ el.textContent = message;
1069
+ el.className = 'status ' + type;
1070
+ el.style.display = 'block';
1071
+ }
1072
+ </script>
1073
+ </body>
1074
  </html>