Surn commited on
Commit
f055004
·
1 Parent(s): d57c213

Update to add shiny-border part 1

Browse files
gradio_app.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wrdler - Gradio Application Entry Point
3
+
4
+ A vocabulary puzzle game with an 8x6 grid and 6 hidden words.
5
+ This is the Gradio version, running alongside the Streamlit app.
6
+
7
+ Usage:
8
+ python gradio_app.py
9
+ # or
10
+ gradio gradio_app.py
11
+ """
12
+
13
+ import gradio as gr
14
+ from wrdler.gradio_ui import create_app
15
+
16
+ # Create the Gradio app
17
+ demo = create_app()
18
+
19
+ if __name__ == "__main__":
20
+ demo.launch(
21
+ server_name="0.0.0.0",
22
+ server_port=7860,
23
+ share=False,
24
+ show_error=True
25
+ )
requirements.txt CHANGED
@@ -12,4 +12,5 @@ mypy
12
  requests
13
  huggingface_hub
14
  python-dotenv
15
- google-api-core
 
 
12
  requests
13
  huggingface_hub
14
  python-dotenv
15
+ google-api-core
16
+ gradio>=4.0.0
style_wrdler.css ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Wrdler - Custom CSS for Gradio App */
2
+ /* Based on Surn/beeuty theme patterns */
3
+
4
+ .interface-wrapper {
5
+ max-width: 1200px;
6
+ margin: 0 auto;
7
+ }
8
+
9
+ .centered {
10
+ margin: 0 auto;
11
+ display: block;
12
+ text-align: center;
13
+ }
14
+
15
+ /* Grid Container - for native Gradio buttons with shiny-border */
16
+ .wrdler-grid-container {
17
+ position: relative;
18
+ padding: 4px;
19
+ background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);
20
+ border-radius: 1.25rem;
21
+ overflow: hidden;
22
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
23
+ max-width: 520px;
24
+ margin: 0 auto 16px auto;
25
+ font-weight: 600;
26
+ }
27
+
28
+ /* Inner container with original grid colors */
29
+ .wrdler-grid-container > div {
30
+ background: linear-gradient(145deg, #1a1a2e, #16213e);
31
+ border-radius: 1rem;
32
+ padding: 12px;
33
+ }
34
+
35
+ /* Shiny hover effect for grid */
36
+ .wrdler-grid-container::before {
37
+ content: '';
38
+ position: absolute;
39
+ top: 0;
40
+ left: -100%;
41
+ width: 100%;
42
+ height: 100%;
43
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
44
+ transition: left 0.5s;
45
+ z-index: 1;
46
+ pointer-events: none;
47
+ }
48
+
49
+ .wrdler-grid-container:hover::before {
50
+ left: 100%;
51
+ }
52
+ .prose table, .wrdler-score-panel-container tr.hidden td {
53
+ border: none;
54
+ color: #ffffff;
55
+ font-weight: 600;
56
+ }
57
+ .wrdler-grid-container .shiny-border {
58
+ position: relative;
59
+ padding: 12px;
60
+ background: #333;
61
+ color: #ffffff !important;
62
+ border-radius: 1.25rem;
63
+ overflow: hidden;
64
+ /*background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);*/
65
+ border-width: 12px !important;
66
+ border-image: linear-gradient(-45deg, #a1a1a1, #fff, #a1a1a1, #666) !important;
67
+ }
68
+ .metal-border { position: relative; padding: 20px; background: #333; color: white; border: 4px solid; border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; border-radius: 8px; }
69
+ .shiny-border { position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
70
+ .shiny-border::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: left 0.5s; }
71
+ .bw-score-panel-container { height: 100%; overflow: hidden; text-align:center;}
72
+ .bw-score-panel-container table tbody tr h3 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}
73
+ .shiny-border:hover::before { left: 100%; }
74
+
75
+ .wrdler-grid-row {
76
+ gap: 4px 8px !important;
77
+ justify-content: center !important;
78
+ flex-wrap: nowrap !important;
79
+ margin-bottom: 6px !important;
80
+ }
81
+
82
+ .wrdler-grid-row:last-child {
83
+ margin-bottom: 0 !important;
84
+ }
85
+
86
+ .wrdler-grid-row > div {
87
+ flex: 0 0 auto !important;
88
+ min-width: 50px !important;
89
+ }
90
+
91
+ /* Grid cell buttons - unrevealed (clickable) */
92
+ .grid-cell-unrevealed button {
93
+ min-width: 50px !important;
94
+ height: 48px !important;
95
+ font-size: 1.3rem !important;
96
+ font-weight: 700 !important;
97
+ border-radius: 6px !important;
98
+ padding: 0 !important;
99
+ background: linear-gradient(145deg, #3a7bd5, #00d2ff) !important;
100
+ border: 2px solid #00d2ff !important;
101
+ color: #ffffff !important;
102
+ cursor: pointer !important;
103
+ box-shadow: 0 2px 8px rgba(0, 210, 255, 0.3) !important;
104
+ transition: all 0.2s ease !important;
105
+ }
106
+
107
+ .grid-cell-unrevealed button:hover {
108
+ background: linear-gradient(145deg, #00d2ff, #3a7bd5) !important;
109
+ transform: translateY(-2px);
110
+ box-shadow: 0 4px 12px rgba(0, 210, 255, 0.5) !important;
111
+ }
112
+
113
+ /* Grid cell buttons - revealed */
114
+ .grid-cell-revealed button {
115
+ min-width: 50px !important;
116
+ height: 48px !important;
117
+ font-size: 1.3rem !important;
118
+ font-weight: 700 !important;
119
+ border-radius: 6px !important;
120
+ padding: 0 !important;
121
+ background: linear-gradient(145deg, #d4f1f9, #a8e6cf) !important;
122
+ border: 2px solid #00bfa5 !important;
123
+ color: #1a1a2e !important;
124
+ cursor: default !important;
125
+ }
126
+
127
+ /* Empty revealed cells (middle dot) */
128
+ .grid-cell-revealed button:has(span:empty),
129
+ .grid-cell-revealed button[value="·"] {
130
+ background: #2d2d44 !important;
131
+ border: 2px solid #3d3d5c !important;
132
+ color: #5d5d7c !important;
133
+ }
134
+
135
+ /* Legacy CSS for HTML grid (kept for compatibility) */
136
+ .wrdler-grid {
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: 4px 8px;
140
+ padding: 12px;
141
+ background: linear-gradient(145deg, #1a1a2e, #16213e);
142
+ border-radius: 12px;
143
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
144
+ max-width: 480px;
145
+ margin: 0 auto;
146
+ font-weight: 700;
147
+
148
+ }
149
+
150
+ .wrdler-row {
151
+ display: flex;
152
+ gap: 4px 8px;
153
+ justify-content: center;
154
+ }
155
+
156
+ /* Grid Cells (HTML version) */
157
+ .wrdler-cell {
158
+ width: 56px;
159
+ height: 52px;
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ font-size: 1.4rem;
164
+ font-weight: 700;
165
+ border-radius: 6px;
166
+ transition: all 0.2s ease;
167
+ user-select: none;
168
+ }
169
+
170
+ /* Letter buttons row */
171
+ .letter-buttons-row {
172
+ gap: 4px !important;
173
+ flex-wrap: wrap !important;
174
+ justify-content: center !important;
175
+ margin-bottom: 12px !important;
176
+ }
177
+
178
+ .letter-buttons-row > div {
179
+ flex: 0 0 auto !important;
180
+ }
181
+
182
+ /* Letter button - available */
183
+ .letter-btn-available button {
184
+ min-width: 36px !important;
185
+ height: 36px !important;
186
+ font-size: 1rem !important;
187
+ font-weight: 700 !important;
188
+ border-radius: 50% !important;
189
+ padding: 0 !important;
190
+ background: linear-gradient(145deg, #00bfa5, #00d2ff) !important;
191
+ border: 2px solid #00bfa5 !important;
192
+ color: #ffffff !important;
193
+ cursor: pointer !important;
194
+ box-shadow: 0 2px 8px rgba(0, 191, 165, 0.3) !important;
195
+ transition: all 0.2s ease !important;
196
+ }
197
+
198
+ .letter-btn-available button:hover {
199
+ background: linear-gradient(145deg, #00d2ff, #00bfa5) !important;
200
+ transform: translateY(-2px) scale(1.1);
201
+ box-shadow: 0 4px 12px rgba(0, 191, 165, 0.5) !important;
202
+ }
203
+
204
+ /* Letter button - used */
205
+ .letter-btn-used button {
206
+ min-width: 36px !important;
207
+ height: 36px !important;
208
+ font-size: 1rem !important;
209
+ font-weight: 700 !important;
210
+ border-radius: 50% !important;
211
+ padding: 0 !important;
212
+ background: #3d3d5c !important;
213
+ border: 2px solid #5d5d7c !important;
214
+ color: #7d7d9c !important;
215
+ cursor: not-allowed !important;
216
+ opacity: 0.7 !important;
217
+ }
218
+
219
+ /* Free Letter Selection (legacy) */
220
+ .wrdler-free-letters {
221
+ background: linear-gradient(145deg, rgba(26, 26, 46, 0.9), rgba(22, 33, 62, 0.9));
222
+ border-radius: 12px;
223
+ padding: 16px;
224
+ margin-bottom: 16px;
225
+ border: 2px solid rgba(0, 210, 255, 0.3);
226
+ font-weight:bolder;
227
+ }
228
+
229
+ .free-letter-header {
230
+ text-align: center;
231
+ font-size: 1.1rem;
232
+ font-weight: 600;
233
+ color: #00d2ff;
234
+ margin-bottom: 12px;
235
+ }
236
+
237
+ .free-letter-buttons {
238
+ display: flex;
239
+ flex-wrap: wrap;
240
+ gap: 8px;
241
+ justify-content: center;
242
+ }
243
+
244
+ .wrdler-free-btn {
245
+ width: 40px;
246
+ height: 40px;
247
+ border-radius: 50%;
248
+ border: 2px solid #00bfa5;
249
+ background: linear-gradient(145deg, #00bfa5, #00d2ff);
250
+ color: #ffffff;
251
+ font-weight: 700;
252
+ font-size: 1rem;
253
+ cursor: pointer;
254
+ transition: all 0.2s ease;
255
+ box-shadow: 0 2px 8px rgba(0, 191, 165, 0.3);
256
+ }
257
+
258
+ .wrdler-free-btn:hover {
259
+ background: linear-gradient(145deg, #00d2ff, #00bfa5);
260
+ transform: translateY(-2px) scale(1.1);
261
+ box-shadow: 0 4px 12px rgba(0, 191, 165, 0.5);
262
+ }
263
+
264
+ .wrdler-free-btn.used {
265
+ background: #3d3d5c;
266
+ border-color: #5d5d7c;
267
+ color: #7d7d9c;
268
+ cursor: not-allowed;
269
+ opacity: 0.6;
270
+ }
271
+
272
+ .wrdler-free-letters-done {
273
+ text-align: center;
274
+ padding: 12px;
275
+ background: rgba(0, 191, 165, 0.2);
276
+ border-radius: 8px;
277
+ color: #00bfa5;
278
+ font-weight: 600;
279
+ margin-bottom: 16px;
280
+ }
281
+
282
+ /* Score Panel */
283
+ .wrdler-score-panel {
284
+ background: linear-gradient(145deg, #1a1a2e, #16213e);
285
+ border-radius: 12px;
286
+ padding: 16px;
287
+ border: 2px solid rgba(0, 210, 255, 0.3);
288
+ }
289
+
290
+ .score-header {
291
+ text-align: center;
292
+ font-size: 1.5rem;
293
+ font-weight: 700;
294
+ color: #ffffff;
295
+ margin-bottom: 12px;
296
+ }
297
+
298
+ .score-value {
299
+ color: #00d2ff;
300
+ font-size: 2rem;
301
+ }
302
+
303
+ .timer {
304
+ text-align: center;
305
+ font-size: 1.1rem;
306
+ color: #a8e6cf;
307
+ margin-bottom: 16px;
308
+ }
309
+
310
+ .time-value {
311
+ font-family: monospace;
312
+ font-weight: 700;
313
+ }
314
+
315
+ .words-table {
316
+ margin-top: 16px;
317
+ }
318
+
319
+ .words-table table {
320
+ width: 100%;
321
+ border-collapse: collapse;
322
+ }
323
+
324
+ .words-table th,
325
+ .words-table td {
326
+ padding: 8px 12px;
327
+ text-align: center;
328
+ border-bottom: 1px solid rgba(0, 210, 255, 0.2);
329
+ }
330
+
331
+ .words-table th {
332
+ color: #00d2ff;
333
+ font-weight: 600;
334
+ }
335
+
336
+ .words-table tr.found td {
337
+ color: #00bfa5;
338
+ font-weight: 600;
339
+ }
340
+
341
+ .words-table tr.hidden td {
342
+ color: #7d7d9c;
343
+ font-family: monospace;
344
+ }
345
+
346
+ /* Game Over Display */
347
+ .wrdler-game-over {
348
+ background: linear-gradient(145deg, rgba(0, 191, 165, 0.1), rgba(0, 210, 255, 0.1));
349
+ border: 2px solid #00bfa5;
350
+ border-radius: 12px;
351
+ padding: 24px;
352
+ margin-top: 16px;
353
+ text-align: center;
354
+ }
355
+
356
+ .wrdler-game-over h2 {
357
+ color: #00d2ff;
358
+ font-size: 1.8rem;
359
+ margin-bottom: 16px;
360
+ }
361
+
362
+ .final-score {
363
+ font-size: 2rem;
364
+ font-weight: 700;
365
+ color: #00bfa5;
366
+ margin-bottom: 8px;
367
+ }
368
+
369
+ .tier {
370
+ font-size: 1.3rem;
371
+ color: #ffd700;
372
+ font-weight: 600;
373
+ margin-bottom: 8px;
374
+ }
375
+
376
+ .final-time {
377
+ font-size: 1.1rem;
378
+ color: #a8e6cf;
379
+ margin-bottom: 16px;
380
+ }
381
+
382
+ .breakdown {
383
+ margin-top: 16px;
384
+ }
385
+
386
+ .breakdown h3 {
387
+ color: #00d2ff;
388
+ margin-bottom: 12px;
389
+ }
390
+
391
+ .breakdown table {
392
+ width: 100%;
393
+ border-collapse: collapse;
394
+ margin: 0 auto;
395
+ max-width: 400px;
396
+ }
397
+
398
+ .breakdown th,
399
+ .breakdown td {
400
+ padding: 6px 10px;
401
+ text-align: center;
402
+ border-bottom: 1px solid rgba(0, 210, 255, 0.2);
403
+ }
404
+
405
+ .breakdown th {
406
+ color: #00d2ff;
407
+ font-size: 0.9rem;
408
+ }
409
+
410
+ .breakdown td {
411
+ color: #a8e6cf;
412
+ }
413
+
414
+ /* Guess Input Styling */
415
+ #guess-input {
416
+ font-size: 1.2rem;
417
+ }
418
+
419
+ #guess-btn {
420
+ min-width: 100px;
421
+ }
422
+
423
+ /* Status Message */
424
+ .status-msg {
425
+ text-align: center;
426
+ padding: 12px;
427
+ background: rgba(0, 210, 255, 0.1);
428
+ border-radius: 8px;
429
+ margin-top: 12px;
430
+ }
431
+
432
+ /* Responsive Design */
433
+ @media (max-width: 768px) {
434
+ .wrdler-grid {
435
+ max-width: 100%;
436
+ padding: 8px;
437
+ }
438
+
439
+ .wrdler-cell {
440
+ width: 42px;
441
+ height: 38px;
442
+ font-size: 1.1rem;
443
+ }
444
+
445
+ .wrdler-free-btn {
446
+ width: 32px;
447
+ height: 32px;
448
+ font-size: 0.9rem;
449
+ }
450
+
451
+ .score-header {
452
+ font-size: 1.2rem;
453
+ }
454
+
455
+ .score-value {
456
+ font-size: 1.5rem;
457
+ }
458
+ }
459
+
460
+ @media (max-width: 480px) {
461
+ .wrdler-cell {
462
+ width: 36px;
463
+ height: 32px;
464
+ font-size: 1rem;
465
+ }
466
+
467
+ .wrdler-row {
468
+ gap: 2px;
469
+ }
470
+
471
+ .wrdler-grid {
472
+ gap: 2px;
473
+ }
474
+ }
475
+
476
+ /* Dark mode adjustments (Beeuty theme compatibility) */
477
+ .dark .wrdler-grid {
478
+ background: linear-gradient(145deg, #0d0d1a, #1a1a2e);
479
+ }
480
+
481
+ .dark .wrdler-score-panel {
482
+ background: linear-gradient(145deg, #0d0d1a, #1a1a2e);
483
+ }
484
+
485
+ /* Toast notifications */
486
+ .toast-body.info {
487
+ background-color: rgba(0, 210, 255, 0.75);
488
+ }
489
+
490
+ .dark .toast-body.info {
491
+ background-color: rgba(0, 191, 165, 0.75);
492
+ }
493
+
494
+ /* Accordion styling */
495
+ .accordion {
496
+ border-radius: 8px;
497
+ }
498
+
499
+ /* Hide bridge elements - keep functional but visually hidden */
500
+ /* Using clip-rect to hide visually while maintaining DOM accessibility */
501
+ .hidden-bridge {
502
+ position: absolute !important;
503
+ width: 1px !important;
504
+ height: 1px !important;
505
+ padding: 0 !important;
506
+ margin: -1px !important;
507
+ overflow: hidden !important;
508
+ clip: rect(0, 0, 0, 0) !important;
509
+ white-space: nowrap !important;
510
+ border: 0 !important;
511
+ /* Keep opacity at 1 and pointer-events enabled for JS interaction */
512
+ opacity: 1 !important;
513
+ pointer-events: auto !important;
514
+ }
515
+
516
+ /* Ensure the actual input/button inside bridge can receive events */
517
+ .hidden-bridge input,
518
+ .hidden-bridge textarea,
519
+ .hidden-bridge button {
520
+ pointer-events: auto !important;
521
+ }
522
+
523
+ /* Challenge Mode Leaderboard */
524
+ .wrdler-challenge-banner {
525
+ background: linear-gradient(145deg, rgba(0, 191, 165, 0.15), rgba(0, 210, 255, 0.15));
526
+ border: 2px solid #00bfa5;
527
+ border-radius: 12px;
528
+ padding: 16px;
529
+ margin-bottom: 16px;
530
+ text-align: center;
531
+ }
532
+
533
+ .wrdler-challenge-banner h3 {
534
+ color: #00d2ff;
535
+ margin: 0 0 8px 0;
536
+ font-size: 1.3rem;
537
+ }
538
+
539
+ .challenge-id {
540
+ color: #a8e6cf;
541
+ font-family: monospace;
542
+ font-size: 0.9rem;
543
+ margin-bottom: 12px;
544
+ }
545
+
546
+ .wrdler-challenge-banner .leaderboard table {
547
+ width: 100%;
548
+ max-width: 400px;
549
+ margin: 0 auto;
550
+ border-collapse: collapse;
551
+ }
552
+
553
+ .wrdler-challenge-banner .leaderboard th,
554
+ .wrdler-challenge-banner .leaderboard td {
555
+ padding: 6px 10px;
556
+ text-align: center;
557
+ border-bottom: 1px solid rgba(0, 210, 255, 0.2);
558
+ }
559
+
560
+ .wrdler-challenge-banner .leaderboard th {
561
+ color: #00d2ff;
562
+ font-size: 0.85rem;
563
+ }
564
+
565
+ .wrdler-challenge-banner .leaderboard td {
566
+ color: #ffffff;
567
+ }
568
+
569
+ /* Share Challenge Section */
570
+ .share-challenge {
571
+ margin-top: 16px;
572
+ padding: 12px;
573
+ background: rgba(0, 191, 165, 0.1);
574
+ border-radius: 8px;
575
+ }
576
+
577
+ .share-challenge input {
578
+ width: 100%;
579
+ padding: 8px;
580
+ border: 1px solid #00bfa5;
581
+ border-radius: 4px;
582
+ background: rgba(26, 26, 46, 0.9);
583
+ color: #ffffff;
584
+ font-family: monospace;
585
+ }
586
+
587
+ .share-challenge-btn {
588
+ margin-top: 8px;
589
+ padding: 8px 16px;
590
+ background: linear-gradient(145deg, #00bfa5, #00d2ff);
591
+ border: none;
592
+ border-radius: 6px;
593
+ color: #ffffff;
594
+ font-weight: 600;
595
+ cursor: pointer;
596
+ }
597
+
598
+ .share-challenge-btn:hover {
599
+ background: linear-gradient(145deg, #00d2ff, #00bfa5);
600
+ }
601
+
602
+ /* Tab Styling */
603
+ .tabs {
604
+ background: transparent !important;
605
+ }
606
+
607
+ .tab-nav {
608
+ background: linear-gradient(145deg, #1a1a2e, #16213e) !important;
609
+ border-radius: 12px 12px 0 0 !important;
610
+ padding: 8px 8px 0 8px !important;
611
+ border: none !important;
612
+ }
613
+
614
+ .tab-nav button {
615
+ background: transparent !important;
616
+ color: #a8e6cf !important;
617
+ border: none !important;
618
+ padding: 12px 24px !important;
619
+ font-weight: 600 !important;
620
+ border-radius: 8px 8px 0 0 !important;
621
+ transition: all 0.2s ease !important;
622
+ }
623
+
624
+ .tab-nav button:hover {
625
+ background: rgba(0, 210, 255, 0.1) !important;
626
+ color: #00d2ff !important;
627
+ }
628
+
629
+ .tab-nav button.selected {
630
+ background: linear-gradient(145deg, #00bfa5, #00d2ff) !important;
631
+ color: #ffffff !important;
632
+ }
633
+
634
+ .tabitem {
635
+ background: linear-gradient(145deg, rgba(26, 26, 46, 0.5), rgba(22, 33, 62, 0.5)) !important;
636
+ border-radius: 0 0 12px 12px !important;
637
+ padding: 16px !important;
638
+ }
639
+
640
+ /* Settings Tab Specific */
641
+ .settings-section h3,
642
+ .tabitem h3 {
643
+ color: #00d2ff !important;
644
+ margin-bottom: 16px !important;
645
+ font-size: 1.2rem !important;
646
+ }
647
+
648
+ .tabitem .gr-button-lg {
649
+ min-width: 200px !important;
650
+ margin-top: 16px !important;
651
+ }
wrdler/assets/audio/effects/congratulations.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:232ca809d3940e7d3491f29ac97230fe0691f21e46993d6d3f42f905c9d225bf
3
+ size 1811619
wrdler/gradio_ui.py ADDED
@@ -0,0 +1,1154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wrdler - Gradio UI Module
3
+
4
+ Main Gradio interface for the Wrdler vocabulary puzzle game.
5
+ Implements the same gameplay as the Streamlit version with Gradio components.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import json
12
+ import copy
13
+ from datetime import datetime
14
+ from typing import Dict, List, Optional, Set, Tuple, Any
15
+ from functools import lru_cache, partial
16
+ import base64
17
+ import mimetypes
18
+
19
+ import gradio as gr
20
+
21
+ from .models import Coord, Word, Puzzle, GameState
22
+ from .logic import (
23
+ build_letter_map,
24
+ reveal_cell,
25
+ reveal_free_letter,
26
+ guess_word,
27
+ is_game_over,
28
+ auto_mark_completed_words,
29
+ compute_tier,
30
+ )
31
+ from .generator import generate_puzzle
32
+
33
+ # Version info
34
+ from . import __version__
35
+
36
+ # Constants
37
+ GRID_ROWS = 6
38
+ GRID_COLS = 8
39
+ MAX_FREE_LETTERS = 2
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Word Loading (Gradio-compatible, no Streamlit dependencies)
44
+ # ---------------------------------------------------------------------------
45
+
46
+ FALLBACK_WORDS: Dict[int, List[str]] = {
47
+ 4: ["TREE", "BOAT", "WIND", "FROG", "LION", "MOON", "FORK", "GLOW", "GAME", "CODE",
48
+ "DATA", "BLUE", "GOLD", "ROAD", "STAR", "FISH", "BIRD", "RAIN", "SNOW", "LEAF",
49
+ "ROCK", "WAVE", "FIRE", "SAND", "LAKE"],
50
+ 5: ["APPLE", "RIVER", "STONE", "PLANT", "MOUSE", "BOARD", "CHAIR", "SCALE", "SMILE",
51
+ "CLOUD", "DRONE", "LEVEL", "ZEBRA", "BRAVE", "CROWN", "GRAPE", "LEMON", "PEARL",
52
+ "STORM", "TIGER", "WHALE", "BEACH", "DREAM", "FLAME", "OCEAN"],
53
+ 6: ["ORANGE", "PYTHON", "STREAM", "MARKET", "FOREST", "THRIVE", "LOGGER", "BREATH",
54
+ "DOMAIN", "GALAXY", "BUTTON", "JUNGLE", "PLANET", "PUZZLE", "QUARTZ", "ROCKET",
55
+ "SALMON", "TEMPLE", "VALLEY", "WINTER", "BRANCH", "CASTLE", "FLOWER", "ISLAND",
56
+ "SUNSET"],
57
+ }
58
+
59
+ MIN_REQUIRED = 25
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Audio Utilities (Gradio-compatible)
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def _get_music_dir() -> str:
67
+ return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
68
+
69
+
70
+ def _get_effects_dir() -> str:
71
+ return os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
72
+
73
+
74
+ def get_audio_tracks() -> List[Tuple[str, str]]:
75
+ """Return list of (label, path) for music files."""
76
+ audio_dir = _get_music_dir()
77
+ if not os.path.isdir(audio_dir):
78
+ return []
79
+ tracks = []
80
+ for fname in os.listdir(audio_dir):
81
+ if fname.lower().endswith('.mp3'):
82
+ path = os.path.join(audio_dir, fname)
83
+ name = os.path.splitext(fname)[0]
84
+ tracks.append((name, path))
85
+ return sorted(tracks)
86
+
87
+
88
+ def get_sound_effect_files() -> Dict[str, str]:
89
+ """Return dict of effect_name -> path."""
90
+ audio_dir = _get_effects_dir()
91
+ if not os.path.isdir(audio_dir):
92
+ return {}
93
+
94
+ effect_names = ["correct_guess", "incorrect_guess", "hit", "miss", "congratulations"]
95
+ result = {}
96
+ for name in effect_names:
97
+ for ext in (".mp3", ".wav"):
98
+ path = os.path.join(audio_dir, f"{name}{ext}")
99
+ if os.path.exists(path):
100
+ result[name] = path
101
+ break
102
+ return result
103
+
104
+
105
+ @lru_cache(maxsize=32)
106
+ def load_audio_data_url(path: str) -> str:
107
+ """Convert audio file to data URL for browser playback."""
108
+ mime, _ = mimetypes.guess_type(path)
109
+ if not mime:
110
+ mime = "audio/mpeg"
111
+ try:
112
+ with open(path, "rb") as fp:
113
+ encoded = base64.b64encode(fp.read()).decode("ascii")
114
+ return f"data:{mime};base64,{encoded}"
115
+ except Exception:
116
+ return ""
117
+
118
+
119
+ def render_audio_player_html(
120
+ effect_name: Optional[str] = None,
121
+ volume: float = 0.5,
122
+ enabled: bool = True
123
+ ) -> str:
124
+ """Generate HTML to play a sound effect."""
125
+ if not enabled or not effect_name:
126
+ return ""
127
+
128
+ sound_files = get_sound_effect_files()
129
+ if effect_name not in sound_files:
130
+ return ""
131
+
132
+ data_url = load_audio_data_url(sound_files[effect_name])
133
+ if not data_url:
134
+ return ""
135
+
136
+ vol = max(0.0, min(1.0, float(volume)))
137
+
138
+ return f'''
139
+ <script>
140
+ (function(){{
141
+ const audio = new Audio("{data_url}");
142
+ audio.volume = {vol:.3f};
143
+ audio.play().catch(e => console.log('Audio play blocked'));
144
+ }})();
145
+ </script>
146
+ '''
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Word Loading (Gradio-compatible, no Streamlit dependencies)
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def get_wordlist_files() -> List[str]:
154
+ """Get available word list files."""
155
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
156
+ if not os.path.isdir(words_dir):
157
+ return []
158
+ files = [f for f in os.listdir(words_dir) if f.lower().endswith(".txt")]
159
+ return sorted(files)
160
+
161
+
162
+ @lru_cache(maxsize=32)
163
+ def load_word_list_gradio(selected_file: Optional[str] = None) -> Dict[int, List[str]]:
164
+ """Load word list without Streamlit dependencies."""
165
+ import re
166
+
167
+ words_by_len: Dict[int, List[str]] = {4: [], 5: [], 6: []}
168
+
169
+ try:
170
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
171
+
172
+ if selected_file:
173
+ path = os.path.join(words_dir, selected_file)
174
+ else:
175
+ path = os.path.join(words_dir, "wordlist.txt")
176
+
177
+ with open(path, "r", encoding="utf-8") as f:
178
+ text = f.read()
179
+
180
+ seen = {4: set(), 5: set(), 6: set()}
181
+ for raw in text.splitlines():
182
+ line = raw.strip()
183
+ if not line or line.startswith("#"):
184
+ continue
185
+ if "#" in line:
186
+ line = line.split("#", 1)[0].strip()
187
+ word = line.upper()
188
+ if not re.fullmatch(r"[A-Z]+", word):
189
+ continue
190
+ L = len(word)
191
+ if L in (4, 5, 6) and word not in seen[L]:
192
+ words_by_len[L].append(word)
193
+ seen[L].add(word)
194
+
195
+ # Check minimum requirements
196
+ counts = {k: len(v) for k, v in words_by_len.items()}
197
+ if all(counts[k] >= MIN_REQUIRED for k in (4, 5, 6)):
198
+ return words_by_len
199
+
200
+ # Fallback for insufficient words
201
+ return {
202
+ 4: words_by_len[4] if counts[4] >= MIN_REQUIRED else FALLBACK_WORDS[4],
203
+ 5: words_by_len[5] if counts[5] >= MIN_REQUIRED else FALLBACK_WORDS[5],
204
+ 6: words_by_len[6] if counts[6] >= MIN_REQUIRED else FALLBACK_WORDS[6],
205
+ }
206
+
207
+ except Exception:
208
+ return FALLBACK_WORDS.copy()
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Game State Management
213
+ # ---------------------------------------------------------------------------
214
+
215
+ def create_new_game_state(
216
+ wordlist: str = "classic.txt",
217
+ game_mode: str = "classic",
218
+ seed: Optional[int] = None
219
+ ) -> Dict[str, Any]:
220
+ """Create a new game state dictionary for gr.State."""
221
+ words_by_len = load_word_list_gradio(wordlist)
222
+ puzzle = generate_puzzle(
223
+ grid_rows=GRID_ROWS,
224
+ grid_cols=GRID_COLS,
225
+ words_by_len=words_by_len,
226
+ seed=seed
227
+ )
228
+ letter_map = build_letter_map(puzzle)
229
+
230
+ # Convert Coord keys to string for JSON serialization
231
+ letter_map_serializable = {f"{c.x},{c.y}": v for c, v in letter_map.items()}
232
+
233
+ # Get unique letters in the puzzle (sorted for consistent button order)
234
+ puzzle_letters = sorted(set(letter for word in puzzle.words for letter in word.text.upper()))
235
+
236
+ return {
237
+ "puzzle_uid": puzzle.uid,
238
+ "puzzle_words": [(w.text, w.start.x, w.start.y, w.direction) for w in puzzle.words],
239
+ "puzzle_letters": puzzle_letters, # Letters available for free letter selection
240
+ "grid_rows": GRID_ROWS,
241
+ "grid_cols": GRID_COLS,
242
+ "revealed": [], # List of "x,y" strings
243
+ "guessed": [], # List of guessed words
244
+ "score": 0,
245
+ "last_action": "Welcome! Choose your 2 free letters to start.",
246
+ "can_guess": False,
247
+ "game_mode": game_mode,
248
+ "points_by_word": {},
249
+ "start_time": datetime.now().isoformat(),
250
+ "end_time": None,
251
+ "free_letters": [],
252
+ "free_letters_used": 0,
253
+ "letter_map": letter_map_serializable,
254
+ "wordlist": wordlist,
255
+ "incorrect_guesses": [],
256
+ "game_over": False,
257
+ # Audio settings
258
+ "sound_effects_enabled": True,
259
+ "sound_effects_volume": 50,
260
+ "music_enabled": False,
261
+ "music_volume": 30,
262
+ "pending_sound": None, # Sound effect to play on next render
263
+ # Challenge mode
264
+ "challenge_mode": False,
265
+ "challenge_sid": None,
266
+ "challenge_settings": None,
267
+ "challenge_leaderboard": [],
268
+ }
269
+
270
+
271
+ def state_to_gamestate(state: Dict[str, Any]) -> Tuple[GameState, Dict[Coord, str]]:
272
+ """Convert state dict to GameState object and letter_map."""
273
+ # Reconstruct puzzle
274
+ words = []
275
+ for text, x, y, direction in state["puzzle_words"]:
276
+ words.append(Word(text=text, start=Coord(x, y), direction=direction))
277
+
278
+ puzzle = Puzzle(
279
+ words=words,
280
+ uid=state["puzzle_uid"],
281
+ grid_rows=state["grid_rows"],
282
+ grid_cols=state["grid_cols"]
283
+ )
284
+
285
+ # Reconstruct letter map
286
+ letter_map = {}
287
+ for coord_str, letter in state["letter_map"].items():
288
+ x, y = map(int, coord_str.split(","))
289
+ letter_map[Coord(x, y)] = letter
290
+
291
+ # Reconstruct revealed set
292
+ revealed = set()
293
+ for coord_str in state["revealed"]:
294
+ x, y = map(int, coord_str.split(","))
295
+ revealed.add(Coord(x, y))
296
+
297
+ # Parse times
298
+ start_time = datetime.fromisoformat(state["start_time"]) if state["start_time"] else None
299
+ end_time = datetime.fromisoformat(state["end_time"]) if state["end_time"] else None
300
+
301
+ game_state = GameState(
302
+ grid_rows=state["grid_rows"],
303
+ grid_cols=state["grid_cols"],
304
+ puzzle=puzzle,
305
+ revealed=revealed,
306
+ guessed=set(state["guessed"]),
307
+ score=state["score"],
308
+ last_action=state["last_action"],
309
+ can_guess=state["can_guess"],
310
+ game_mode=state["game_mode"],
311
+ points_by_word=state["points_by_word"].copy(),
312
+ start_time=start_time,
313
+ end_time=end_time,
314
+ free_letters=set(state["free_letters"]),
315
+ free_letters_used=state["free_letters_used"]
316
+ )
317
+
318
+ return game_state, letter_map
319
+
320
+
321
+ def gamestate_to_dict(gs: GameState, state: Dict[str, Any]) -> Dict[str, Any]:
322
+ """Update state dict from GameState object."""
323
+ state["revealed"] = [f"{c.x},{c.y}" for c in gs.revealed]
324
+ state["guessed"] = list(gs.guessed)
325
+ state["score"] = gs.score
326
+ state["last_action"] = gs.last_action
327
+ state["can_guess"] = gs.can_guess
328
+ state["points_by_word"] = gs.points_by_word.copy()
329
+ state["free_letters"] = list(gs.free_letters)
330
+ state["free_letters_used"] = gs.free_letters_used
331
+ state["game_over"] = is_game_over(gs)
332
+ if state["game_over"] and gs.end_time is None:
333
+ state["end_time"] = datetime.now().isoformat()
334
+ return state
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Grid Button Labels
339
+ # ---------------------------------------------------------------------------
340
+
341
+ def get_cell_label(state: Dict[str, Any], row: int, col: int) -> str:
342
+ """Get the display label for a grid cell button."""
343
+ coord_str = f"{row},{col}"
344
+ revealed_set = set(state.get("revealed", []))
345
+ letter_map = state.get("letter_map", {})
346
+
347
+ if coord_str in revealed_set:
348
+ letter = letter_map.get(coord_str, "")
349
+ return letter if letter else "·" # Middle dot for empty revealed cells
350
+ else:
351
+ return "?"
352
+
353
+
354
+ def get_cell_variant(state: Dict[str, Any], row: int, col: int) -> str:
355
+ """Get the button variant for a grid cell."""
356
+ coord_str = f"{row},{col}"
357
+ revealed_set = set(state.get("revealed", []))
358
+ guessed_words = set(state.get("guessed", []))
359
+ letter_map = state.get("letter_map", {})
360
+
361
+ # Check if cell is part of a completed word
362
+ completed_cells = set()
363
+ for text, x, y, direction in state.get("puzzle_words", []):
364
+ if text in guessed_words:
365
+ for i in range(len(text)):
366
+ if direction == "H":
367
+ completed_cells.add(f"{x},{y+i}")
368
+ else:
369
+ completed_cells.add(f"{x+i},{y}")
370
+
371
+ if coord_str in revealed_set:
372
+ if coord_str in completed_cells:
373
+ return "secondary" # Completed word
374
+ elif letter_map.get(coord_str, ""):
375
+ return "secondary" # Revealed letter
376
+ else:
377
+ return "secondary" # Empty cell
378
+ else:
379
+ return "primary" # Unrevealed - clickable
380
+
381
+
382
+ def get_all_button_updates(state: Dict[str, Any]) -> List[gr.Button]:
383
+ """Generate button updates for all 48 grid cells."""
384
+ updates = []
385
+ for row in range(GRID_ROWS):
386
+ for col in range(GRID_COLS):
387
+ coord_str = f"{row},{col}"
388
+ revealed_set = set(state.get("revealed", []))
389
+ is_revealed = coord_str in revealed_set
390
+
391
+ label = get_cell_label(state, row, col)
392
+ variant = get_cell_variant(state, row, col)
393
+
394
+ updates.append(gr.Button(
395
+ value=label,
396
+ variant=variant,
397
+ interactive=not is_revealed,
398
+ elem_classes=["grid-cell-revealed"] if is_revealed else ["grid-cell-unrevealed"]
399
+ ))
400
+ return updates
401
+
402
+
403
+ def get_puzzle_letters(state: Dict[str, Any]) -> Set[str]:
404
+ """Get set of unique letters that exist in the puzzle words."""
405
+ letters = set()
406
+ for text, x, y, direction in state.get("puzzle_words", []):
407
+ letters.update(text.upper())
408
+ return letters
409
+
410
+
411
+ def get_available_letters(state: Dict[str, Any]) -> List[str]:
412
+ """Get list of available letters for free letter selection (only letters in puzzle)."""
413
+ used_letters = set(state.get("free_letters", []))
414
+ letters_used = state.get("free_letters_used", 0)
415
+
416
+ if letters_used >= MAX_FREE_LETTERS:
417
+ return []
418
+
419
+ # Only show letters that exist in the puzzle and haven't been used
420
+ puzzle_letters = get_puzzle_letters(state)
421
+ return sorted([letter for letter in puzzle_letters if letter not in used_letters])
422
+
423
+
424
+ def get_all_letter_button_updates(state: Dict[str, Any], all_puzzle_letters: List[str]) -> List[gr.Button]:
425
+ """Generate button updates for all letter buttons."""
426
+ used_letters = set(state.get("free_letters", []))
427
+ letters_used = state.get("free_letters_used", 0)
428
+ available = letters_used < MAX_FREE_LETTERS
429
+
430
+ updates = []
431
+ for letter in all_puzzle_letters:
432
+ is_used = letter in used_letters
433
+ updates.append(gr.Button(
434
+ value=letter,
435
+ variant="secondary" if is_used else "primary",
436
+ interactive=available and not is_used,
437
+ elem_classes=["letter-btn-used"] if is_used else ["letter-btn-available"]
438
+ ))
439
+ return updates
440
+
441
+
442
+ def render_free_letters_status(state: Dict[str, Any]) -> str:
443
+ """Generate status message for free letter selection."""
444
+ letters_used = state.get("free_letters_used", 0)
445
+
446
+ if letters_used >= MAX_FREE_LETTERS:
447
+ return "Free letters used! Click grid cells to reveal more."
448
+
449
+ remaining = MAX_FREE_LETTERS - letters_used
450
+ return f"Choose {remaining} free letter{'s' if remaining > 1 else ''}:"
451
+
452
+
453
+ def render_score_panel_html(state: Dict[str, Any]) -> str:
454
+ """Generate HTML for the score panel with shiny-border styling."""
455
+ score = state["score"]
456
+ guessed = set(state["guessed"])
457
+ points_by_word = state["points_by_word"]
458
+ start_time = state["start_time"]
459
+ end_time = state["end_time"]
460
+ game_over = state["game_over"]
461
+
462
+ # Calculate elapsed time
463
+ if end_time:
464
+ start = datetime.fromisoformat(start_time)
465
+ end = datetime.fromisoformat(end_time)
466
+ elapsed = (end - start).total_seconds()
467
+ else:
468
+ elapsed = 0 # Will be updated by JS
469
+
470
+ minutes = int(elapsed // 60)
471
+ seconds = int(elapsed % 60)
472
+ time_str = f"{minutes:02d}:{seconds:02d}"
473
+
474
+ # Build table rows
475
+ table_rows = []
476
+ table_rows.append('<tr><th>Word</th><th>Len</th><th>Bonus</th></tr>')
477
+
478
+ for text, x, y, direction in state["puzzle_words"]:
479
+ if text in guessed:
480
+ total_points = points_by_word.get(text, len(text))
481
+ base_points = len(text)
482
+ bonus_points = total_points - base_points
483
+ table_rows.append(
484
+ f'<tr class="found">'
485
+ f'<td>{text}</td>'
486
+ f'<td>{base_points}</td>'
487
+ f'<td>+{bonus_points}</td>'
488
+ f'</tr>'
489
+ )
490
+ else:
491
+ hidden = "?" * len(text)
492
+ base_points = len(text)
493
+ table_rows.append(
494
+ f'<tr class="hidden">'
495
+ f'<td>{hidden}</td>'
496
+ f'<td>{base_points}</td>'
497
+ f'<td>-</td>'
498
+ f'</tr>'
499
+ )
500
+
501
+ table_inner = "\n".join(table_rows)
502
+
503
+ # Timer HTML
504
+ if not game_over:
505
+ timer_html = f'''
506
+ <div class="timer" id="wrdler-timer" data-start="{start_time}">
507
+ Time: <span class="time-value">{time_str}</span>
508
+ </div>
509
+ <script>
510
+ (function() {{
511
+ const timerEl = document.getElementById('wrdler-timer');
512
+ if (!timerEl) return;
513
+ const startTime = new Date(timerEl.dataset.start);
514
+ function updateTimer() {{
515
+ const now = new Date();
516
+ const elapsed = Math.floor((now - startTime) / 1000);
517
+ const mins = Math.floor(elapsed / 60);
518
+ const secs = elapsed % 60;
519
+ const timeSpan = timerEl.querySelector('.time-value');
520
+ if (timeSpan) {{
521
+ timeSpan.textContent = String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
522
+ }}
523
+ }}
524
+ setInterval(updateTimer, 1000);
525
+ updateTimer();
526
+ }})();
527
+ </script>
528
+ '''
529
+ else:
530
+ timer_html = f'<div class="timer">Time: <span class="time-value">{time_str}</span></div>'
531
+
532
+ html = f'''
533
+ <div class='wrdler-score-panel-container'>
534
+ <style>
535
+ .bold-text {{ font-weight: 700; }}
536
+ .metal-border {{ position: relative; padding: 20px; background: #333; color: white; border: 4px solid; border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; border-radius: 8px; }}
537
+ .shiny-border {{ position: relative; padding: 12px; color: white; border-radius: 1.25rem; overflow: hidden; background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); transition: left 0.5s;}}
538
+ .shiny-border::before {{ content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: left 0.5s; }}
539
+ .wrdler-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
540
+ .wrdler-score-panel-container table tbody tr h3 {{display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}}
541
+ .shiny-border:hover::before {{ left: 100%; }}
542
+ .wrdler-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
543
+ .wrdler-score-panel-container table {{ width: 100%; margin: 0 auto; border-collapse: separate; border-spacing: 0; }}
544
+ .wrdler-score-panel-container th, .wrdler-score-panel-container td {{ padding: 6px 8px; }}
545
+ .wrdler-score-panel-container .score-header {{ font-size: 1.5rem; font-weight: 700; color: #1a1a2e; margin-bottom: 8px; }}
546
+ .wrdler-score-panel-container .score-value {{ color: #00bfa5; font-size: 2rem; }}
547
+ .wrdler-score-panel-container .timer {{ font-size: 1.1rem; color: #1a1a2e; margin-bottom: 12px; }}
548
+ .wrdler-score-panel-container .time-value {{ font-family: monospace; font-weight: 700; }}
549
+ .wrdler-score-panel-container th {{ color: #1a1a2e; font-weight: 600; border-bottom: 2px solid rgba(0, 0, 0, 0.2); background: #000;}}
550
+ .wrdler-score-panel-container tr {{ border-bottom: 1px solid rgba(0, 0, 0, 0.1); background: #000;}}
551
+ .wrdler-score-panel-container tr.found td {{ font-weight: 600; background: #000;}}
552
+ .wrdler-score-panel-container tr.hidden td {{ color: #333; }}
553
+ </style>
554
+ <div class="score-header">Score: <span class="score-value">{score}</span></div>
555
+ {timer_html}
556
+ <table class='shiny-border' style=\"border-radius:0.75rem; overflow:hidden; width:100%; margin:0 auto; border-collapse:separate; border-spacing: 0;\">
557
+ {table_inner}
558
+ </table>
559
+ </div>
560
+ '''
561
+
562
+ return html
563
+
564
+
565
+ def render_pending_audio_html(state: Dict[str, Any]) -> str:
566
+ """Render audio HTML for any pending sound effect."""
567
+ pending = state.get("pending_sound")
568
+ if not pending:
569
+ return ""
570
+
571
+ enabled = state.get("sound_effects_enabled", True)
572
+ volume = state.get("sound_effects_volume", 50) / 100.0
573
+
574
+ # Clear pending sound
575
+ state["pending_sound"] = None
576
+
577
+ return render_audio_player_html(pending, volume, enabled)
578
+
579
+
580
+ def render_challenge_leaderboard_html(state: Dict[str, Any]) -> str:
581
+ """Generate HTML for challenge mode leaderboard."""
582
+ if not state.get("challenge_mode") or not state.get("challenge_leaderboard"):
583
+ return ""
584
+
585
+ leaderboard = state["challenge_leaderboard"]
586
+ sid = state.get("challenge_sid", "")
587
+
588
+ html = [
589
+ '<div class="wrdler-challenge-banner">',
590
+ '<h3>Challenge Mode</h3>',
591
+ f'<div class="challenge-id">Challenge: {sid}</div>',
592
+ '<div class="leaderboard">',
593
+ '<table>',
594
+ '<tr><th>#</th><th>Player</th><th>Score</th><th>Time</th></tr>'
595
+ ]
596
+
597
+ # Sort by score desc, then time asc
598
+ sorted_lb = sorted(leaderboard, key=lambda x: (-x.get("score", 0), x.get("time", 9999)))
599
+
600
+ medals = ["🥇", "🥈", "🥉"]
601
+ for i, entry in enumerate(sorted_lb[:5]): # Top 5
602
+ medal = medals[i] if i < 3 else str(i + 1)
603
+ name = entry.get("username", "Anonymous")
604
+ score = entry.get("score", 0)
605
+ time_sec = entry.get("time", 0)
606
+ mins = time_sec // 60
607
+ secs = time_sec % 60
608
+ time_str = f"{mins}:{secs:02d}"
609
+ html.append(f'<tr><td>{medal}</td><td>{name}</td><td>{score}</td><td>{time_str}</td></tr>')
610
+
611
+ html.append('</table></div></div>')
612
+ return "\n".join(html)
613
+
614
+
615
+ def render_game_over_html(state: Dict[str, Any]) -> str:
616
+ """Generate HTML for game over display."""
617
+ if not state["game_over"]:
618
+ return ""
619
+
620
+ score = state["score"]
621
+ tier = compute_tier(score)
622
+ points_by_word = state["points_by_word"]
623
+ start_time = state["start_time"]
624
+ end_time = state["end_time"]
625
+
626
+ # Calculate final time
627
+ if end_time and start_time:
628
+ start = datetime.fromisoformat(start_time)
629
+ end = datetime.fromisoformat(end_time)
630
+ elapsed = (end - start).total_seconds()
631
+ minutes = int(elapsed // 60)
632
+ seconds = int(elapsed % 60)
633
+ time_str = f"{minutes:02d}:{seconds:02d}"
634
+ else:
635
+ time_str = "--:--"
636
+
637
+ html = [
638
+ '<div class="wrdler-game-over">',
639
+ '<h2>Game Over!</h2>',
640
+ f'<div class="final-score">Score: {score}</div>',
641
+ f'<div class="tier">{tier}</div>',
642
+ f'<div class="final-time">Time: {time_str}</div>',
643
+ '<div class="breakdown"><h3>Score Breakdown</h3><table>',
644
+ '<tr><th>Word</th><th>Length</th><th>Bonus</th><th>Total</th></tr>'
645
+ ]
646
+
647
+ for text, x, y, direction in state["puzzle_words"]:
648
+ points = points_by_word.get(text, len(text))
649
+ base = len(text)
650
+ bonus = points - base
651
+ html.append(f'<tr><td>{text}</td><td>{base}</td><td>+{bonus}</td><td>{points}</td></tr>')
652
+
653
+ html.append('</table></div>')
654
+ html.append('</div>')
655
+
656
+ return "\n".join(html)
657
+
658
+
659
+ # ---------------------------------------------------------------------------
660
+ # Event Handlers
661
+ # ---------------------------------------------------------------------------
662
+
663
+ # All 26 letters for consistent button count
664
+ ALL_LETTERS = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
665
+
666
+
667
+ def get_letter_button_updates(state: Dict[str, Any]) -> List[gr.Button]:
668
+ """Generate button updates for all 26 letter buttons."""
669
+ puzzle_letters = set(state.get("puzzle_letters", []))
670
+ used_letters = set(state.get("free_letters", []))
671
+ letters_used = state.get("free_letters_used", 0)
672
+ can_select = letters_used < MAX_FREE_LETTERS
673
+
674
+ updates = []
675
+ for letter in ALL_LETTERS:
676
+ in_puzzle = letter in puzzle_letters
677
+ is_used = letter in used_letters
678
+
679
+ if not in_puzzle:
680
+ # Letter not in puzzle - hide it
681
+ updates.append(gr.Button(
682
+ value=letter,
683
+ visible=False,
684
+ interactive=False
685
+ ))
686
+ elif is_used:
687
+ # Letter already used
688
+ updates.append(gr.Button(
689
+ value=letter,
690
+ visible=True,
691
+ variant="secondary",
692
+ interactive=False,
693
+ elem_classes=["letter-btn-used"]
694
+ ))
695
+ else:
696
+ # Letter available
697
+ updates.append(gr.Button(
698
+ value=letter,
699
+ visible=True,
700
+ variant="primary",
701
+ interactive=can_select,
702
+ elem_classes=["letter-btn-available"]
703
+ ))
704
+ return updates
705
+
706
+
707
+ def build_ui_outputs(state: Dict[str, Any], audio_html: str = "") -> tuple:
708
+ """Build the complete UI output tuple for all handlers.
709
+
710
+ Returns: (48 grid buttons, 26 letter buttons, score_panel, status_msg, audio, game_over, free_letter_status, state)
711
+ """
712
+ grid_button_updates = get_all_button_updates(state)
713
+ letter_button_updates = get_letter_button_updates(state)
714
+
715
+ return (
716
+ *grid_button_updates, # 48 grid button updates
717
+ *letter_button_updates, # 26 letter button updates
718
+ render_score_panel_html(state),
719
+ state.get("last_action", ""),
720
+ audio_html,
721
+ render_game_over_html(state),
722
+ render_free_letters_status(state),
723
+ state
724
+ )
725
+
726
+
727
+ def ensure_state(state: Dict[str, Any]) -> Dict[str, Any]:
728
+ """Ensure state is a dict, not the initializer function."""
729
+ if callable(state):
730
+ return state()
731
+ return state
732
+
733
+
734
+ def handle_cell_click(row: int, col: int, state: Dict[str, Any]) -> tuple:
735
+ """Handle grid cell click."""
736
+ state = ensure_state(state)
737
+ if state.get("game_over"):
738
+ return build_ui_outputs(state)
739
+
740
+ # Check if free letters required first
741
+ if state["free_letters_used"] < MAX_FREE_LETTERS:
742
+ state["last_action"] = f"Please choose {MAX_FREE_LETTERS - state['free_letters_used']} more free letter(s) first."
743
+ return build_ui_outputs(state)
744
+
745
+ coord = Coord(row, col)
746
+
747
+ # Convert to GameState for logic operations
748
+ gs, letter_map = state_to_gamestate(state)
749
+
750
+ # Check if cell has a letter (for sound effect)
751
+ has_letter = coord in letter_map
752
+
753
+ # Reveal the cell
754
+ reveal_cell(gs, letter_map, coord)
755
+ auto_mark_completed_words(gs)
756
+
757
+ # Update state dict
758
+ state = gamestate_to_dict(gs, state)
759
+
760
+ # Set sound effect
761
+ state["pending_sound"] = "hit" if has_letter else "miss"
762
+ audio_html = render_pending_audio_html(state)
763
+
764
+ return build_ui_outputs(state, audio_html)
765
+
766
+
767
+ def handle_free_letter(letter: str, state: Dict[str, Any]) -> tuple:
768
+ """Handle free letter selection from dropdown."""
769
+ state = ensure_state(state)
770
+ if not letter or state.get("game_over"):
771
+ return build_ui_outputs(state)
772
+
773
+ letter = letter.upper().strip()
774
+ if len(letter) != 1 or not letter.isalpha():
775
+ state["last_action"] = "Invalid letter selection"
776
+ return build_ui_outputs(state)
777
+
778
+ # Check if already at max free letters
779
+ if state.get("free_letters_used", 0) >= MAX_FREE_LETTERS:
780
+ state["last_action"] = "Already used both free letters. Click cells to reveal more."
781
+ return build_ui_outputs(state)
782
+
783
+ # Convert to GameState for logic operations
784
+ gs, letter_map = state_to_gamestate(state)
785
+
786
+ # Count how many letters will be revealed
787
+ count_before = len(gs.revealed)
788
+
789
+ # Reveal free letter
790
+ reveal_free_letter(gs, letter_map, letter)
791
+ auto_mark_completed_words(gs)
792
+
793
+ # Check if any letters were revealed
794
+ count_after = len(gs.revealed)
795
+ revealed_count = count_after - count_before
796
+
797
+ # Update state dict
798
+ state = gamestate_to_dict(gs, state)
799
+
800
+ # Set sound effect
801
+ state["pending_sound"] = "hit" if revealed_count > 0 else "miss"
802
+ audio_html = render_pending_audio_html(state)
803
+
804
+ return build_ui_outputs(state, audio_html)
805
+
806
+
807
+ def build_guess_outputs(state: Dict[str, Any], audio_html: str = "", clear_input: bool = False) -> tuple:
808
+ """Build UI outputs for guess handler (includes guess input clear)."""
809
+ grid_button_updates = get_all_button_updates(state)
810
+ letter_button_updates = get_letter_button_updates(state)
811
+
812
+ return (
813
+ *grid_button_updates, # 48 grid button updates
814
+ *letter_button_updates, # 26 letter button updates
815
+ render_score_panel_html(state),
816
+ state.get("last_action", ""),
817
+ "" if clear_input else gr.update(), # guess input - clear or no change
818
+ audio_html,
819
+ render_game_over_html(state),
820
+ render_free_letters_status(state),
821
+ state
822
+ )
823
+
824
+
825
+ def handle_guess(guess_text: str, state: Dict[str, Any]) -> tuple:
826
+ """Handle word guess submission."""
827
+ state = ensure_state(state)
828
+ if not guess_text or state.get("game_over"):
829
+ return build_guess_outputs(state, "", True)
830
+
831
+ guess_text = guess_text.strip().upper()
832
+
833
+ # Convert to GameState for logic operations
834
+ gs, letter_map = state_to_gamestate(state)
835
+
836
+ # Try to guess
837
+ success, points = guess_word(gs, guess_text)
838
+
839
+ if not success:
840
+ # Track incorrect guess
841
+ if guess_text not in state["incorrect_guesses"]:
842
+ state["incorrect_guesses"].append(guess_text)
843
+ if len(state["incorrect_guesses"]) > 10:
844
+ state["incorrect_guesses"] = state["incorrect_guesses"][-10:]
845
+
846
+ auto_mark_completed_words(gs)
847
+
848
+ # Update state dict
849
+ state = gamestate_to_dict(gs, state)
850
+
851
+ # Set sound effect
852
+ if success:
853
+ state["pending_sound"] = "correct_guess"
854
+ else:
855
+ state["pending_sound"] = "incorrect_guess"
856
+
857
+ # Check for game over - play congratulations
858
+ if state["game_over"]:
859
+ state["pending_sound"] = "congratulations"
860
+
861
+ audio_html = render_pending_audio_html(state)
862
+
863
+ return build_guess_outputs(state, audio_html, True)
864
+
865
+
866
+ def handle_new_game(
867
+ wordlist: str,
868
+ game_mode: str,
869
+ sfx_enabled: bool,
870
+ sfx_volume: int,
871
+ music_enabled: bool,
872
+ music_volume: int,
873
+ state: Dict[str, Any]
874
+ ) -> tuple:
875
+ """Handle new game creation."""
876
+ state = ensure_state(state)
877
+ new_state = create_new_game_state(wordlist=wordlist, game_mode=game_mode)
878
+
879
+ # Preserve audio settings from UI components
880
+ new_state["sound_effects_enabled"] = sfx_enabled
881
+ new_state["sound_effects_volume"] = sfx_volume
882
+ new_state["music_enabled"] = music_enabled
883
+ new_state["music_volume"] = music_volume
884
+
885
+ return build_ui_outputs(new_state)
886
+
887
+
888
+ def handle_audio_settings_change(
889
+ sfx_enabled: bool,
890
+ sfx_volume: int,
891
+ music_enabled: bool,
892
+ music_volume: int,
893
+ state: Dict[str, Any]
894
+ ) -> Dict[str, Any]:
895
+ """Handle audio settings changes."""
896
+ state = ensure_state(state)
897
+
898
+ # Create a deep copy to ensure Gradio detects the change
899
+ new_state = copy.deepcopy(state)
900
+ new_state["sound_effects_enabled"] = sfx_enabled
901
+ new_state["sound_effects_volume"] = sfx_volume
902
+ new_state["music_enabled"] = music_enabled
903
+ new_state["music_volume"] = music_volume
904
+ return new_state
905
+
906
+
907
+ # ---------------------------------------------------------------------------
908
+ # Main App Creation
909
+ # ---------------------------------------------------------------------------
910
+
911
+ def create_app() -> gr.Blocks:
912
+ """Create the Gradio Blocks application."""
913
+
914
+ # Get available wordlists
915
+ wordlist_files = get_wordlist_files()
916
+ if not wordlist_files:
917
+ wordlist_files = ["classic.txt"]
918
+
919
+ # CSS file path
920
+ css_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "style_wrdler.css")
921
+
922
+ with gr.Blocks(
923
+ css=css_path if os.path.exists(css_path) else None,
924
+ theme="Surn/beeuty",
925
+ title=f"Wrdler v{__version__}"
926
+ ) as demo:
927
+
928
+ # Game state
929
+ game_state = gr.State(value=create_new_game_state)
930
+
931
+ # Header
932
+ gr.Markdown(f"# Wrdler v{__version__}")
933
+ gr.Markdown("Find all 6 hidden words in the 8×6 grid!")
934
+
935
+ # Tab layout
936
+ with gr.Tabs():
937
+ # Game Tab
938
+ with gr.TabItem("Game"):
939
+ with gr.Row():
940
+ # Left column - Game area
941
+ with gr.Column(scale=5):
942
+ # Challenge mode leaderboard (if in challenge mode)
943
+ challenge_leaderboard_html = gr.HTML(
944
+ value="",
945
+ elem_id="challenge-leaderboard-container"
946
+ )
947
+
948
+ # Free letter selection using button grid
949
+ free_letter_status = gr.Markdown(
950
+ value="Choose 2 free letters:",
951
+ elem_id="free-letter-status"
952
+ )
953
+ # Create 26 letter buttons (only puzzle letters will be visible)
954
+ letter_buttons: List[gr.Button] = []
955
+ with gr.Row(elem_classes=["letter-buttons-row"]):
956
+ for letter in ALL_LETTERS:
957
+ btn = gr.Button(
958
+ value=letter,
959
+ variant="primary",
960
+ size="sm",
961
+ min_width=36,
962
+ visible=False, # Will be shown for letters in puzzle
963
+ elem_classes=["letter-btn-available"],
964
+ elem_id=f"letter-{letter}"
965
+ )
966
+ letter_buttons.append(btn)
967
+
968
+ # Game grid using native Gradio buttons
969
+ # Create 48 buttons (6 rows x 8 cols) in a CSS grid
970
+ grid_buttons: List[gr.Button] = []
971
+ with gr.Group(elem_classes=["wrdler-grid-container"]):
972
+ for row in range(GRID_ROWS):
973
+ with gr.Row(elem_classes=["wrdler-grid-row"]):
974
+ for col in range(GRID_COLS):
975
+ btn = gr.Button(
976
+ value="?",
977
+ variant="primary",
978
+ size="sm",
979
+ min_width=50,
980
+ elem_classes=["grid-cell-unrevealed"],
981
+ elem_id=f"cell-{row}-{col}"
982
+ )
983
+ grid_buttons.append(btn)
984
+
985
+ # Guess form
986
+ with gr.Row():
987
+ guess_input = gr.Textbox(
988
+ label="Your Guess",
989
+ placeholder="Enter a word...",
990
+ max_lines=1,
991
+ elem_id="guess-input"
992
+ )
993
+ guess_btn = gr.Button("Guess", variant="primary", elem_id="guess-btn")
994
+
995
+ # Status message
996
+ status_msg = gr.Markdown(value="Welcome! Choose your 2 free letters to start.")
997
+
998
+ # Game over display
999
+ game_over_html = gr.HTML(value="", elem_id="game-over-container")
1000
+
1001
+ # Right column - Score panel and New Game
1002
+ with gr.Column(scale=3):
1003
+ # Score panel
1004
+ score_panel_html = gr.HTML(
1005
+ value=lambda: render_score_panel_html(create_new_game_state()),
1006
+ elem_id="score-panel-container"
1007
+ )
1008
+ # New Game button
1009
+ new_game_btn = gr.Button("New Game", variant="primary", size="lg")
1010
+
1011
+ # Settings Tab
1012
+ with gr.TabItem("Settings"):
1013
+ with gr.Row():
1014
+ with gr.Column():
1015
+ gr.Markdown("### Game Settings")
1016
+ wordlist_dropdown = gr.Dropdown(
1017
+ choices=wordlist_files,
1018
+ value=wordlist_files[0] if wordlist_files else "classic.txt",
1019
+ label="Word List"
1020
+ )
1021
+ game_mode_dropdown = gr.Dropdown(
1022
+ choices=["classic", "easy", "too easy"],
1023
+ value="classic",
1024
+ label="Game Mode"
1025
+ )
1026
+
1027
+ with gr.Column():
1028
+ gr.Markdown("### Audio Settings")
1029
+ sfx_enabled = gr.Checkbox(
1030
+ value=True,
1031
+ label="Enable Sound Effects"
1032
+ )
1033
+ sfx_volume = gr.Slider(
1034
+ minimum=0,
1035
+ maximum=100,
1036
+ value=50,
1037
+ step=1,
1038
+ label="Sound Effects Volume"
1039
+ )
1040
+ music_enabled = gr.Checkbox(
1041
+ value=False,
1042
+ label="Enable Background Music"
1043
+ )
1044
+ music_volume = gr.Slider(
1045
+ minimum=0,
1046
+ maximum=100,
1047
+ value=30,
1048
+ step=1,
1049
+ label="Music Volume"
1050
+ )
1051
+
1052
+ # Hidden audio player for sound effects
1053
+ audio_player_html = gr.HTML(value="", elem_id="audio-player", visible=False)
1054
+
1055
+ # Define common outputs for handlers
1056
+ # Order: 48 grid buttons, 26 letter buttons, score_panel, status_msg, audio, game_over, free_letter_status, state
1057
+ common_outputs = [
1058
+ *grid_buttons,
1059
+ *letter_buttons,
1060
+ score_panel_html,
1061
+ status_msg,
1062
+ audio_player_html,
1063
+ game_over_html,
1064
+ free_letter_status,
1065
+ game_state
1066
+ ]
1067
+
1068
+ # Outputs for guess handler (includes guess_input clear)
1069
+ guess_outputs = [
1070
+ *grid_buttons,
1071
+ *letter_buttons,
1072
+ score_panel_html,
1073
+ status_msg,
1074
+ guess_input, # Will be cleared
1075
+ audio_player_html,
1076
+ game_over_html,
1077
+ free_letter_status,
1078
+ game_state
1079
+ ]
1080
+
1081
+ # Wire up each grid button to handle_cell_click
1082
+ for idx, btn in enumerate(grid_buttons):
1083
+ row = idx // GRID_COLS
1084
+ col = idx % GRID_COLS
1085
+ btn.click(
1086
+ fn=partial(handle_cell_click, row, col),
1087
+ inputs=[game_state],
1088
+ outputs=common_outputs
1089
+ )
1090
+
1091
+ # Wire up each letter button to handle_free_letter
1092
+ for idx, btn in enumerate(letter_buttons):
1093
+ letter = ALL_LETTERS[idx]
1094
+ btn.click(
1095
+ fn=partial(handle_free_letter, letter),
1096
+ inputs=[game_state],
1097
+ outputs=common_outputs
1098
+ )
1099
+
1100
+ # Guess button and enter key
1101
+ guess_btn.click(
1102
+ fn=handle_guess,
1103
+ inputs=[guess_input, game_state],
1104
+ outputs=guess_outputs
1105
+ )
1106
+
1107
+ guess_input.submit(
1108
+ fn=handle_guess,
1109
+ inputs=[guess_input, game_state],
1110
+ outputs=guess_outputs
1111
+ )
1112
+
1113
+ # New game button
1114
+ new_game_btn.click(
1115
+ fn=handle_new_game,
1116
+ inputs=[wordlist_dropdown, game_mode_dropdown, sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
1117
+ outputs=common_outputs
1118
+ )
1119
+
1120
+ # Audio settings change handlers
1121
+ sfx_enabled.change(
1122
+ fn=handle_audio_settings_change,
1123
+ inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
1124
+ outputs=[game_state]
1125
+ )
1126
+ sfx_volume.change(
1127
+ fn=handle_audio_settings_change,
1128
+ inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
1129
+ outputs=[game_state]
1130
+ )
1131
+ music_enabled.change(
1132
+ fn=handle_audio_settings_change,
1133
+ inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
1134
+ outputs=[game_state]
1135
+ )
1136
+ music_volume.change(
1137
+ fn=handle_audio_settings_change,
1138
+ inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
1139
+ outputs=[game_state]
1140
+ )
1141
+
1142
+ # Initialize UI on app load
1143
+ def initialize_ui(state):
1144
+ """Initialize UI components on app load."""
1145
+ state = ensure_state(state)
1146
+ return build_ui_outputs(state)
1147
+
1148
+ demo.load(
1149
+ fn=initialize_ui,
1150
+ inputs=[game_state],
1151
+ outputs=common_outputs
1152
+ )
1153
+
1154
+ return demo