OutOfMystic Claude Opus 4.6 commited on
Commit
1d19ab5
·
1 Parent(s): e19c278

Add playable Tetris web UI at /play

Browse files

- Custom HTML page with rendered board grid, colors, stats
- Keyboard controls: WASD/arrows, Space=drop, Q/E=rotate, R=reset
- Click buttons for mobile
- WebSocket connection to game engine
- HF Spaces base_path changed to /play

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

README.md CHANGED
@@ -5,7 +5,7 @@ colorFrom: purple
5
  colorTo: blue
6
  sdk: docker
7
  app_port: 8000
8
- base_path: /web
9
  tags:
10
  - openenv
11
  ---
 
5
  colorTo: blue
6
  sdk: docker
7
  app_port: 8000
8
+ base_path: /play
9
  tags:
10
  - openenv
11
  ---
src/tetris_env/server/app.py CHANGED
@@ -3,6 +3,8 @@ FastAPI application for Tetris OpenEnv.
3
  Uses openenv-core create_app for standard routes.
4
  """
5
 
 
 
6
  from openenv.core import create_app
7
  from ..models import TetrisAction, TetrisObservation
8
  from .environment import TetrisEnvironment
@@ -13,3 +15,10 @@ app = create_app(
13
  observation_cls=TetrisObservation,
14
  env_name="tetris-env",
15
  )
 
 
 
 
 
 
 
 
3
  Uses openenv-core create_app for standard routes.
4
  """
5
 
6
+ from pathlib import Path
7
+ from fastapi.responses import HTMLResponse
8
  from openenv.core import create_app
9
  from ..models import TetrisAction, TetrisObservation
10
  from .environment import TetrisEnvironment
 
15
  observation_cls=TetrisObservation,
16
  env_name="tetris-env",
17
  )
18
+
19
+ # Serve custom Tetris UI at /play
20
+ STATIC_DIR = Path(__file__).parent / "static"
21
+
22
+ @app.get("/play", response_class=HTMLResponse)
23
+ async def play():
24
+ return (STATIC_DIR / "play.html").read_text(encoding="utf-8")
src/tetris_env/server/static/play.html ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Tetris OpenEnv</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ background: #1a1a2e;
11
+ color: #e0e0e0;
12
+ font-family: 'Courier New', monospace;
13
+ display: flex;
14
+ justify-content: center;
15
+ align-items: center;
16
+ min-height: 100vh;
17
+ }
18
+ .game-container {
19
+ display: flex;
20
+ gap: 24px;
21
+ align-items: flex-start;
22
+ }
23
+ .board-wrap {
24
+ background: #16213e;
25
+ border: 2px solid #0f3460;
26
+ border-radius: 8px;
27
+ padding: 8px;
28
+ }
29
+ .board {
30
+ display: grid;
31
+ grid-template-columns: repeat(10, 28px);
32
+ grid-template-rows: repeat(20, 28px);
33
+ gap: 1px;
34
+ background: #0a0a1a;
35
+ }
36
+ .cell {
37
+ width: 28px;
38
+ height: 28px;
39
+ border-radius: 3px;
40
+ }
41
+ .cell-empty { background: #16213e; }
42
+ .cell-placed { background: #e94560; border: 1px solid #ff6b81; }
43
+ .cell-current { background: #53d769; border: 1px solid #7bff8e; }
44
+ .sidebar {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 16px;
48
+ min-width: 200px;
49
+ }
50
+ .panel {
51
+ background: #16213e;
52
+ border: 2px solid #0f3460;
53
+ border-radius: 8px;
54
+ padding: 16px;
55
+ }
56
+ .panel h3 {
57
+ color: #e94560;
58
+ margin-bottom: 8px;
59
+ font-size: 14px;
60
+ text-transform: uppercase;
61
+ }
62
+ .stat-row {
63
+ display: flex;
64
+ justify-content: space-between;
65
+ margin: 4px 0;
66
+ font-size: 14px;
67
+ }
68
+ .stat-value { color: #53d769; font-weight: bold; }
69
+ .next-piece {
70
+ display: grid;
71
+ grid-template-columns: repeat(4, 22px);
72
+ gap: 1px;
73
+ margin-top: 8px;
74
+ }
75
+ .next-cell {
76
+ width: 22px;
77
+ height: 22px;
78
+ border-radius: 2px;
79
+ background: #0a0a1a;
80
+ }
81
+ .next-cell.filled { background: #e94560; border: 1px solid #ff6b81; }
82
+ .controls {
83
+ display: grid;
84
+ grid-template-columns: repeat(3, 1fr);
85
+ gap: 6px;
86
+ }
87
+ .controls button {
88
+ background: #0f3460;
89
+ color: #e0e0e0;
90
+ border: 1px solid #e94560;
91
+ border-radius: 6px;
92
+ padding: 10px 4px;
93
+ font-family: 'Courier New', monospace;
94
+ font-size: 11px;
95
+ cursor: pointer;
96
+ transition: background 0.15s;
97
+ }
98
+ .controls button:hover { background: #e94560; }
99
+ .controls button:active { background: #ff6b81; }
100
+ .controls button.wide { grid-column: span 3; }
101
+ .controls button.half { grid-column: span 1; }
102
+ .status {
103
+ font-size: 12px;
104
+ text-align: center;
105
+ padding: 8px;
106
+ border-radius: 6px;
107
+ }
108
+ .status.connected { background: #1b4332; color: #53d769; }
109
+ .status.disconnected { background: #4a1525; color: #e94560; }
110
+ .status.gameover { background: #4a1525; color: #ff6b81; font-size: 16px; font-weight: bold; }
111
+ .reward-display {
112
+ text-align: center;
113
+ font-size: 18px;
114
+ padding: 4px;
115
+ }
116
+ .reward-pos { color: #53d769; }
117
+ .reward-neg { color: #e94560; }
118
+ kbd {
119
+ background: #0a0a1a;
120
+ border: 1px solid #444;
121
+ border-radius: 3px;
122
+ padding: 1px 5px;
123
+ font-size: 11px;
124
+ }
125
+ </style>
126
+ </head>
127
+ <body>
128
+
129
+ <div class="game-container">
130
+ <div class="board-wrap">
131
+ <div class="board" id="board"></div>
132
+ </div>
133
+
134
+ <div class="sidebar">
135
+ <div id="status" class="status disconnected">Connecting...</div>
136
+
137
+ <div class="panel">
138
+ <h3>Score</h3>
139
+ <div class="stat-row"><span>Score</span><span class="stat-value" id="score">0</span></div>
140
+ <div class="stat-row"><span>Lines</span><span class="stat-value" id="lines">0</span></div>
141
+ <div class="stat-row"><span>Height</span><span class="stat-value" id="height">0</span></div>
142
+ <div class="stat-row"><span>Holes</span><span class="stat-value" id="holes">0</span></div>
143
+ <div class="stat-row"><span>Steps</span><span class="stat-value" id="steps">0</span></div>
144
+ </div>
145
+
146
+ <div class="panel">
147
+ <h3>Last Reward</h3>
148
+ <div class="reward-display" id="reward">-</div>
149
+ </div>
150
+
151
+ <div class="panel">
152
+ <h3>Next Piece</h3>
153
+ <div class="next-piece" id="next-piece"></div>
154
+ </div>
155
+
156
+ <div class="panel">
157
+ <h3>Controls</h3>
158
+ <div class="controls">
159
+ <button onclick="send('rotate_ccw')">&#x21BA; CCW<br><kbd>Q</kbd></button>
160
+ <button onclick="send('drop')">DROP<br><kbd>Space</kbd></button>
161
+ <button onclick="send('rotate_cw')">CW &#x21BB;<br><kbd>E</kbd></button>
162
+ <button onclick="send('left')">&#x2190; Left<br><kbd>A</kbd></button>
163
+ <button onclick="send('down')">&#x2193; Down<br><kbd>S</kbd></button>
164
+ <button onclick="send('right')">Right &#x2192;<br><kbd>D</kbd></button>
165
+ <button class="wide" onclick="doReset()" style="background:#4a1525">NEW GAME &nbsp;<kbd>R</kbd></button>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="panel" style="font-size:11px; color:#888;">
170
+ <b>Keyboard:</b> A/D = move, S = down, Space = drop, Q/E = rotate, R = reset
171
+ </div>
172
+ </div>
173
+ </div>
174
+
175
+ <script>
176
+ const boardEl = document.getElementById('board');
177
+ const COLS = 10, ROWS = 20;
178
+ let ws = null;
179
+ let gameOver = false;
180
+
181
+ // Build board cells
182
+ for (let i = 0; i < ROWS * COLS; i++) {
183
+ const cell = document.createElement('div');
184
+ cell.className = 'cell cell-empty';
185
+ boardEl.appendChild(cell);
186
+ }
187
+
188
+ function connect() {
189
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
190
+ const url = proto + '//' + location.host + '/ws';
191
+ ws = new WebSocket(url);
192
+
193
+ ws.onopen = () => {
194
+ setStatus('connected', 'Connected');
195
+ doReset();
196
+ };
197
+
198
+ ws.onmessage = (e) => {
199
+ const msg = JSON.parse(e.data);
200
+ if (msg.data) handleObs(msg.data);
201
+ };
202
+
203
+ ws.onclose = () => {
204
+ setStatus('disconnected', 'Disconnected — reconnecting...');
205
+ setTimeout(connect, 2000);
206
+ };
207
+
208
+ ws.onerror = () => ws.close();
209
+ }
210
+
211
+ function setStatus(cls, text) {
212
+ const el = document.getElementById('status');
213
+ el.className = 'status ' + cls;
214
+ el.textContent = text;
215
+ }
216
+
217
+ function send(action) {
218
+ if (!ws || ws.readyState !== 1) return;
219
+ if (gameOver) return;
220
+ ws.send(JSON.stringify({type: 'step', data: {action: action, metadata: {}}}));
221
+ }
222
+
223
+ function doReset() {
224
+ if (!ws || ws.readyState !== 1) return;
225
+ gameOver = false;
226
+ setStatus('connected', 'Connected');
227
+ ws.send(JSON.stringify({type: 'reset', data: {}}));
228
+ }
229
+
230
+ function handleObs(data) {
231
+ const obs = data.observation || data;
232
+ const reward = data.reward;
233
+ const done = data.done || obs.done;
234
+
235
+ // Parse board text
236
+ if (obs.board) {
237
+ const lines = obs.board.split('\n').filter(l => l.startsWith('|'));
238
+ const cells = boardEl.children;
239
+ for (let r = 0; r < ROWS; r++) {
240
+ const row = lines[r] || '';
241
+ const chars = row.slice(1, 11); // strip | |
242
+ for (let c = 0; c < COLS; c++) {
243
+ const ch = chars[c] || '.';
244
+ const idx = r * COLS + c;
245
+ if (ch === '#') cells[idx].className = 'cell cell-placed';
246
+ else if (ch === '@') cells[idx].className = 'cell cell-current';
247
+ else cells[idx].className = 'cell cell-empty';
248
+ }
249
+ }
250
+ }
251
+
252
+ // Stats
253
+ document.getElementById('score').textContent = obs.score || 0;
254
+ document.getElementById('lines').textContent = obs.total_lines || 0;
255
+ document.getElementById('height').textContent = obs.max_height || 0;
256
+ document.getElementById('holes').textContent = obs.holes || 0;
257
+ document.getElementById('steps').textContent = obs.steps || 0;
258
+
259
+ // Reward
260
+ if (reward !== undefined && reward !== null) {
261
+ const el = document.getElementById('reward');
262
+ el.textContent = reward >= 0 ? '+' + reward : reward;
263
+ el.className = 'reward-display ' + (reward >= 0 ? 'reward-pos' : 'reward-neg');
264
+ }
265
+
266
+ // Next piece
267
+ if (obs.next_piece_shape) {
268
+ renderNextPiece(obs.next_piece_shape);
269
+ }
270
+
271
+ // Game over
272
+ if (done) {
273
+ gameOver = true;
274
+ setStatus('gameover', 'GAME OVER — press R');
275
+ }
276
+ }
277
+
278
+ function renderNextPiece(shape) {
279
+ const el = document.getElementById('next-piece');
280
+ el.innerHTML = '';
281
+ const rows = shape.split('\n');
282
+ const maxCols = 4;
283
+ const maxRows = 4;
284
+ for (let r = 0; r < maxRows; r++) {
285
+ for (let c = 0; c < maxCols; c++) {
286
+ const cell = document.createElement('div');
287
+ const ch = (rows[r] || '')[c] || '.';
288
+ cell.className = 'next-cell' + (ch === '#' ? ' filled' : '');
289
+ el.appendChild(cell);
290
+ }
291
+ }
292
+ }
293
+
294
+ // Keyboard controls
295
+ document.addEventListener('keydown', (e) => {
296
+ const key = e.key.toLowerCase();
297
+ if (key === 'a' || key === 'arrowleft') send('left');
298
+ else if (key === 'd' || key === 'arrowright') send('right');
299
+ else if (key === 's' || key === 'arrowdown') send('down');
300
+ else if (key === ' ') { e.preventDefault(); send('drop'); }
301
+ else if (key === 'q') send('rotate_ccw');
302
+ else if (key === 'e') send('rotate_cw');
303
+ else if (key === 'r') doReset();
304
+ });
305
+
306
+ connect();
307
+ </script>
308
+ </body>
309
+ </html>