triflix commited on
Commit
3f5cc95
Β·
verified Β·
1 Parent(s): d2527dc

Create templates/index.html

Browse files
Files changed (1) hide show
  1. templates/index.html +1529 -0
templates/index.html ADDED
@@ -0,0 +1,1529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>🏎️ TypeRacer – Real-time Multiplayer</title>
7
+ <style>
8
+ /* ═══════════════════════════════════════
9
+ RESET & ROOT VARIABLES
10
+ ═══════════════════════════════════════ */
11
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
+
13
+ :root {
14
+ --bg: #0d0d1a;
15
+ --panel: #13132a;
16
+ --panel2: #1a1a35;
17
+ --accent: #7c3aed;
18
+ --accent2: #a855f7;
19
+ --green: #22c55e;
20
+ --red: #ef4444;
21
+ --yellow: #eab308;
22
+ --text: #e2e8f0;
23
+ --muted: #64748b;
24
+ --border: #2d2d5e;
25
+ --track-bg: #1e1e3f;
26
+ --track-line: #2d2d6e;
27
+ --road: #374151;
28
+ --road-mark: #4b5563;
29
+ --glow: 0 0 20px rgba(124,58,237,0.4);
30
+ --font: 'Segoe UI', system-ui, sans-serif;
31
+ }
32
+
33
+ body {
34
+ font-family: var(--font);
35
+ background: var(--bg);
36
+ color: var(--text);
37
+ min-height: 100vh;
38
+ overflow-x: hidden;
39
+ }
40
+
41
+ /* ═══════════════════════════════════════
42
+ SCROLLBAR
43
+ ═══════════════════════════════════════ */
44
+ ::-webkit-scrollbar { width: 6px; }
45
+ ::-webkit-scrollbar-track { background: var(--bg); }
46
+ ::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 3px; }
47
+
48
+ /* ═══════════════════════════════════════
49
+ HEADER
50
+ ═══════════════════════════════════════ */
51
+ header {
52
+ background: linear-gradient(135deg, #1a0533 0%, #0d0d1a 100%);
53
+ border-bottom: 2px solid var(--border);
54
+ padding: 14px 24px;
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: space-between;
58
+ box-shadow: var(--glow);
59
+ position: sticky;
60
+ top: 0;
61
+ z-index: 100;
62
+ }
63
+
64
+ .logo {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 10px;
68
+ font-size: 1.5rem;
69
+ font-weight: 800;
70
+ background: linear-gradient(90deg, #a855f7, #7c3aed, #3b82f6);
71
+ -webkit-background-clip: text;
72
+ -webkit-text-fill-color: transparent;
73
+ letter-spacing: -0.5px;
74
+ }
75
+
76
+ .logo span { font-size: 1.8rem; }
77
+
78
+ .header-info {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 16px;
82
+ font-size: 0.85rem;
83
+ }
84
+
85
+ .badge {
86
+ background: var(--panel2);
87
+ border: 1px solid var(--border);
88
+ border-radius: 20px;
89
+ padding: 5px 14px;
90
+ font-weight: 600;
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 6px;
94
+ }
95
+
96
+ .status-dot {
97
+ width: 8px; height: 8px;
98
+ border-radius: 50%;
99
+ background: var(--muted);
100
+ transition: background 0.3s;
101
+ }
102
+
103
+ .status-dot.connected { background: var(--green); box-shadow: 0 0 8px var(--green); }
104
+ .status-dot.error { background: var(--red); box-shadow: 0 0 8px var(--red); }
105
+
106
+ /* ═══════════════════════════════════════
107
+ MAIN LAYOUT
108
+ ═══════════════════════════════════════ */
109
+ main {
110
+ max-width: 1100px;
111
+ margin: 0 auto;
112
+ padding: 24px 16px;
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: 20px;
116
+ }
117
+
118
+ /* ═══════════════════════════════════════
119
+ WAITING / COUNTDOWN OVERLAY
120
+ ═══════════════════════════════════════ */
121
+ #overlay {
122
+ position: fixed;
123
+ inset: 0;
124
+ background: rgba(10, 10, 26, 0.92);
125
+ backdrop-filter: blur(8px);
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ z-index: 200;
130
+ flex-direction: column;
131
+ gap: 20px;
132
+ }
133
+
134
+ .overlay-card {
135
+ background: var(--panel);
136
+ border: 2px solid var(--border);
137
+ border-radius: 24px;
138
+ padding: 48px 56px;
139
+ text-align: center;
140
+ max-width: 480px;
141
+ width: 90%;
142
+ box-shadow: var(--glow), 0 24px 64px rgba(0,0,0,0.6);
143
+ }
144
+
145
+ .overlay-title {
146
+ font-size: 2.2rem;
147
+ font-weight: 900;
148
+ background: linear-gradient(90deg, #a855f7, #3b82f6);
149
+ -webkit-background-clip: text;
150
+ -webkit-text-fill-color: transparent;
151
+ margin-bottom: 8px;
152
+ }
153
+
154
+ .overlay-sub {
155
+ color: var(--muted);
156
+ font-size: 0.95rem;
157
+ margin-bottom: 28px;
158
+ }
159
+
160
+ /* Spinning loader */
161
+ .loader {
162
+ width: 56px; height: 56px;
163
+ border: 4px solid var(--border);
164
+ border-top-color: var(--accent2);
165
+ border-radius: 50%;
166
+ animation: spin 0.9s linear infinite;
167
+ margin: 0 auto 20px;
168
+ }
169
+
170
+ @keyframes spin { to { transform: rotate(360deg); } }
171
+
172
+ /* Countdown number */
173
+ .countdown-number {
174
+ font-size: 6rem;
175
+ font-weight: 900;
176
+ line-height: 1;
177
+ background: linear-gradient(135deg, #f59e0b, #ef4444);
178
+ -webkit-background-clip: text;
179
+ -webkit-text-fill-color: transparent;
180
+ animation: pulse 1s ease-in-out infinite;
181
+ }
182
+
183
+ @keyframes pulse {
184
+ 0%,100% { transform: scale(1); opacity: 1; }
185
+ 50% { transform: scale(1.1); opacity: 0.8; }
186
+ }
187
+
188
+ .waiting-players {
189
+ display: flex;
190
+ flex-direction: column;
191
+ gap: 8px;
192
+ max-height: 200px;
193
+ overflow-y: auto;
194
+ margin-top: 16px;
195
+ }
196
+
197
+ .waiting-player-chip {
198
+ background: var(--panel2);
199
+ border: 1px solid var(--border);
200
+ border-radius: 10px;
201
+ padding: 8px 14px;
202
+ font-size: 0.85rem;
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 10px;
206
+ }
207
+
208
+ /* ═══════════════════════════════════════
209
+ PLAYERS INFO BAR
210
+ ═══════════════════════════════════════ */
211
+ #players-bar {
212
+ background: var(--panel);
213
+ border: 1px solid var(--border);
214
+ border-radius: 16px;
215
+ padding: 14px 20px;
216
+ display: flex;
217
+ gap: 12px;
218
+ flex-wrap: wrap;
219
+ align-items: center;
220
+ }
221
+
222
+ .player-chip {
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 8px;
226
+ background: var(--panel2);
227
+ border: 1px solid var(--border);
228
+ border-radius: 20px;
229
+ padding: 6px 14px;
230
+ font-size: 0.82rem;
231
+ font-weight: 600;
232
+ transition: border-color 0.2s;
233
+ }
234
+
235
+ .player-chip.me { border-color: var(--accent2); }
236
+ .player-chip .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
237
+
238
+ /* ═══════════════════════════════════════
239
+ RACE TRACK
240
+ ═══════════════════════════════════════ */
241
+ #race-track {
242
+ background: var(--track-bg);
243
+ border: 2px solid var(--border);
244
+ border-radius: 20px;
245
+ padding: 20px 20px 20px 20px;
246
+ overflow: hidden;
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 0;
250
+ box-shadow: inset 0 0 40px rgba(0,0,0,0.4);
251
+ }
252
+
253
+ .lane {
254
+ position: relative;
255
+ height: 76px;
256
+ display: flex;
257
+ align-items: center;
258
+ border-bottom: 2px dashed var(--track-line);
259
+ padding: 0 8px;
260
+ }
261
+
262
+ .lane:last-child { border-bottom: none; }
263
+
264
+ /* Road texture inside lane */
265
+ .lane::before {
266
+ content: '';
267
+ position: absolute;
268
+ inset: 0;
269
+ background: repeating-linear-gradient(
270
+ 90deg,
271
+ transparent 0px,
272
+ transparent 40px,
273
+ rgba(255,255,255,0.025) 40px,
274
+ rgba(255,255,255,0.025) 41px
275
+ );
276
+ pointer-events: none;
277
+ }
278
+
279
+ /* Lane label */
280
+ .lane-label {
281
+ position: absolute;
282
+ left: 10px;
283
+ top: 50%;
284
+ transform: translateY(-50%);
285
+ font-size: 0.72rem;
286
+ color: var(--muted);
287
+ font-weight: 700;
288
+ letter-spacing: 0.5px;
289
+ white-space: nowrap;
290
+ z-index: 2;
291
+ width: 80px;
292
+ overflow: hidden;
293
+ text-overflow: ellipsis;
294
+ }
295
+
296
+ /* Progress bar inside lane */
297
+ .lane-progress-bg {
298
+ position: absolute;
299
+ left: 90px;
300
+ right: 10px;
301
+ height: 6px;
302
+ background: rgba(255,255,255,0.06);
303
+ border-radius: 3px;
304
+ bottom: 14px;
305
+ }
306
+
307
+ .lane-progress-fill {
308
+ height: 100%;
309
+ border-radius: 3px;
310
+ transition: width 0.3s ease;
311
+ width: 0%;
312
+ }
313
+
314
+ /* ── Car (pure SVG/CSS) ── */
315
+ .car-wrapper {
316
+ position: absolute;
317
+ left: 90px;
318
+ /* right boundary = track-width - car-width - right-padding */
319
+ /* We move it via JS: transform: translateX(px) */
320
+ top: 50%;
321
+ transform: translateY(-50%) translateX(0px);
322
+ transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
323
+ z-index: 5;
324
+ display: flex;
325
+ flex-direction: column;
326
+ align-items: center;
327
+ gap: 2px;
328
+ }
329
+
330
+ /* WPM badge above car */
331
+ .car-wpm {
332
+ font-size: 0.65rem;
333
+ font-weight: 800;
334
+ color: #fff;
335
+ background: rgba(0,0,0,0.6);
336
+ border-radius: 6px;
337
+ padding: 1px 5px;
338
+ white-space: nowrap;
339
+ }
340
+
341
+ /* SVG Car container */
342
+ .car-svg-wrap { line-height: 0; }
343
+
344
+ /* Finish flag */
345
+ .finish-flag {
346
+ position: absolute;
347
+ right: 10px;
348
+ top: 50%;
349
+ transform: translateY(-50%);
350
+ font-size: 1.6rem;
351
+ z-index: 3;
352
+ }
353
+
354
+ /* ═══════════════════════════════════════
355
+ TYPING SECTION
356
+ ═══════════════════════════════════════ */
357
+ #typing-section {
358
+ background: var(--panel);
359
+ border: 2px solid var(--border);
360
+ border-radius: 20px;
361
+ padding: 28px;
362
+ display: flex;
363
+ flex-direction: column;
364
+ gap: 20px;
365
+ }
366
+
367
+ /* ── Stats row ── */
368
+ .stats-row {
369
+ display: flex;
370
+ gap: 14px;
371
+ flex-wrap: wrap;
372
+ }
373
+
374
+ .stat-box {
375
+ background: var(--panel2);
376
+ border: 1px solid var(--border);
377
+ border-radius: 14px;
378
+ padding: 12px 20px;
379
+ min-width: 90px;
380
+ text-align: center;
381
+ flex: 1;
382
+ }
383
+
384
+ .stat-value {
385
+ font-size: 2rem;
386
+ font-weight: 900;
387
+ line-height: 1;
388
+ background: linear-gradient(135deg, #a855f7, #3b82f6);
389
+ -webkit-background-clip: text;
390
+ -webkit-text-fill-color: transparent;
391
+ }
392
+
393
+ .stat-label {
394
+ font-size: 0.7rem;
395
+ text-transform: uppercase;
396
+ letter-spacing: 1px;
397
+ color: var(--muted);
398
+ margin-top: 4px;
399
+ }
400
+
401
+ /* ── Paragraph display ── */
402
+ #paragraph-display {
403
+ font-size: 1.25rem;
404
+ line-height: 1.9;
405
+ letter-spacing: 0.3px;
406
+ padding: 18px 20px;
407
+ background: var(--panel2);
408
+ border: 1px solid var(--border);
409
+ border-radius: 14px;
410
+ min-height: 90px;
411
+ user-select: none;
412
+ font-family: 'Courier New', monospace;
413
+ }
414
+
415
+ /* Word states */
416
+ .word { display: inline; }
417
+ .word .char { transition: color 0.1s; }
418
+
419
+ .char.correct { color: var(--green); }
420
+ .char.wrong { color: var(--red); background: rgba(239,68,68,0.15); border-radius: 2px; }
421
+ .char.current {
422
+ color: #fff;
423
+ background: var(--accent);
424
+ border-radius: 3px;
425
+ padding: 0 1px;
426
+ box-shadow: 0 0 8px var(--accent2);
427
+ }
428
+ .char.pending { color: var(--muted); }
429
+
430
+ /* Cursor blink on active char */
431
+ .char.current { animation: blink-bg 1s step-start infinite; }
432
+ @keyframes blink-bg {
433
+ 50% { background: var(--accent2); }
434
+ }
435
+
436
+ /* ── Input ── */
437
+ #typing-input {
438
+ width: 100%;
439
+ padding: 16px 20px;
440
+ background: var(--panel2);
441
+ border: 2px solid var(--border);
442
+ border-radius: 14px;
443
+ color: var(--text);
444
+ font-size: 1.1rem;
445
+ font-family: 'Courier New', monospace;
446
+ outline: none;
447
+ transition: border-color 0.2s, box-shadow 0.2s;
448
+ caret-color: var(--accent2);
449
+ }
450
+
451
+ #typing-input:focus {
452
+ border-color: var(--accent);
453
+ box-shadow: 0 0 0 3px rgba(124,58,237,0.2);
454
+ }
455
+
456
+ #typing-input.correct-word { border-color: var(--green); }
457
+ #typing-input.wrong-word { border-color: var(--red); background: rgba(239,68,68,0.07); }
458
+ #typing-input:disabled { opacity: 0.4; cursor: not-allowed; }
459
+
460
+ /* ── Progress bar ── */
461
+ .progress-bar-wrap {
462
+ background: var(--panel2);
463
+ border: 1px solid var(--border);
464
+ border-radius: 10px;
465
+ height: 10px;
466
+ overflow: hidden;
467
+ }
468
+
469
+ #my-progress-fill {
470
+ height: 100%;
471
+ background: linear-gradient(90deg, var(--accent), var(--accent2));
472
+ border-radius: 10px;
473
+ width: 0%;
474
+ transition: width 0.3s ease;
475
+ box-shadow: 0 0 10px var(--accent);
476
+ }
477
+
478
+ /* ═══════════════════════════════════════
479
+ RESULTS MODAL
480
+ ═══════════════════════════════════════ */
481
+ #results-modal {
482
+ position: fixed;
483
+ inset: 0;
484
+ background: rgba(10, 10, 26, 0.92);
485
+ backdrop-filter: blur(8px);
486
+ z-index: 300;
487
+ display: flex;
488
+ align-items: center;
489
+ justify-content: center;
490
+ display: none;
491
+ }
492
+
493
+ .results-card {
494
+ background: var(--panel);
495
+ border: 2px solid var(--border);
496
+ border-radius: 24px;
497
+ padding: 40px;
498
+ max-width: 520px;
499
+ width: 90%;
500
+ box-shadow: var(--glow), 0 24px 64px rgba(0,0,0,0.6);
501
+ max-height: 90vh;
502
+ overflow-y: auto;
503
+ }
504
+
505
+ .results-title {
506
+ font-size: 1.8rem;
507
+ font-weight: 900;
508
+ text-align: center;
509
+ background: linear-gradient(90deg, #f59e0b, #ef4444, #a855f7);
510
+ -webkit-background-clip: text;
511
+ -webkit-text-fill-color: transparent;
512
+ margin-bottom: 28px;
513
+ }
514
+
515
+ .result-row {
516
+ display: flex;
517
+ align-items: center;
518
+ gap: 14px;
519
+ padding: 14px 16px;
520
+ background: var(--panel2);
521
+ border: 1px solid var(--border);
522
+ border-radius: 14px;
523
+ margin-bottom: 10px;
524
+ transition: border-color 0.2s;
525
+ }
526
+
527
+ .result-row.me { border-color: var(--accent2); box-shadow: 0 0 12px rgba(168,85,247,0.2); }
528
+
529
+ .result-pos {
530
+ font-size: 1.6rem;
531
+ font-weight: 900;
532
+ min-width: 40px;
533
+ text-align: center;
534
+ }
535
+
536
+ .result-pos.first { color: #fbbf24; }
537
+ .result-pos.second { color: #94a3b8; }
538
+ .result-pos.third { color: #b45309; }
539
+
540
+ .result-name { flex: 1; font-weight: 700; font-size: 1rem; }
541
+ .result-wpm { font-size: 0.9rem; color: var(--accent2); font-weight: 700; }
542
+
543
+ .result-car-dot {
544
+ width: 14px; height: 14px;
545
+ border-radius: 50%;
546
+ flex-shrink: 0;
547
+ }
548
+
549
+ .play-again-btn {
550
+ width: 100%;
551
+ margin-top: 24px;
552
+ padding: 16px;
553
+ background: linear-gradient(135deg, var(--accent), #3b82f6);
554
+ color: #fff;
555
+ font-size: 1.1rem;
556
+ font-weight: 800;
557
+ border: none;
558
+ border-radius: 14px;
559
+ cursor: pointer;
560
+ transition: opacity 0.2s, transform 0.1s;
561
+ letter-spacing: 0.5px;
562
+ }
563
+
564
+ .play-again-btn:hover { opacity: 0.9; transform: translateY(-1px); }
565
+ .play-again-btn:active { transform: translateY(0); }
566
+
567
+ /* ═══════════════════════════════════════
568
+ NOTIFICATIONS (toast)
569
+ ═══════════════════════════════════════ */
570
+ #toast-container {
571
+ position: fixed;
572
+ bottom: 24px;
573
+ right: 24px;
574
+ z-index: 400;
575
+ display: flex;
576
+ flex-direction: column;
577
+ gap: 8px;
578
+ max-width: 320px;
579
+ }
580
+
581
+ .toast {
582
+ background: var(--panel);
583
+ border: 1px solid var(--border);
584
+ border-radius: 12px;
585
+ padding: 12px 18px;
586
+ font-size: 0.85rem;
587
+ animation: slide-in 0.3s ease;
588
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
589
+ }
590
+
591
+ .toast.info { border-left: 4px solid var(--accent2); }
592
+ .toast.success { border-left: 4px solid var(--green); }
593
+ .toast.warning { border-left: 4px solid var(--yellow); }
594
+ .toast.error { border-left: 4px solid var(--red); }
595
+
596
+ @keyframes slide-in {
597
+ from { transform: translateX(100%); opacity: 0; }
598
+ to { transform: translateX(0); opacity: 1; }
599
+ }
600
+
601
+ /* ═══════════════════════════════════════
602
+ CHAT (minimal side strip)
603
+ ═══════════════════════════════════════ */
604
+ #chat-section {
605
+ background: var(--panel);
606
+ border: 1px solid var(--border);
607
+ border-radius: 16px;
608
+ padding: 16px;
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 10px;
612
+ }
613
+
614
+ #chat-messages {
615
+ max-height: 100px;
616
+ overflow-y: auto;
617
+ display: flex;
618
+ flex-direction: column;
619
+ gap: 4px;
620
+ }
621
+
622
+ .chat-msg {
623
+ font-size: 0.8rem;
624
+ color: var(--muted);
625
+ }
626
+
627
+ .chat-msg strong { color: var(--accent2); }
628
+
629
+ .chat-row {
630
+ display: flex;
631
+ gap: 8px;
632
+ }
633
+
634
+ #chat-input {
635
+ flex: 1;
636
+ background: var(--panel2);
637
+ border: 1px solid var(--border);
638
+ border-radius: 10px;
639
+ color: var(--text);
640
+ font-size: 0.85rem;
641
+ padding: 8px 12px;
642
+ outline: none;
643
+ }
644
+
645
+ #chat-input:focus { border-color: var(--accent); }
646
+
647
+ #chat-send {
648
+ background: var(--accent);
649
+ border: none;
650
+ border-radius: 10px;
651
+ color: #fff;
652
+ padding: 8px 14px;
653
+ cursor: pointer;
654
+ font-size: 0.85rem;
655
+ font-weight: 700;
656
+ }
657
+
658
+ /* ═══════════════════════════════════════
659
+ RESPONSIVE
660
+ ═══════════════════════════════════════ */
661
+ @media (max-width: 600px) {
662
+ header { flex-direction: column; gap: 8px; }
663
+ .overlay-card { padding: 32px 20px; }
664
+ .countdown-number { font-size: 4rem; }
665
+ #paragraph-display { font-size: 1rem; }
666
+ }
667
+ </style>
668
+ </head>
669
+ <body>
670
+
671
+ <!-- ═══════════ HEADER ═══════════ -->
672
+ <header>
673
+ <div class="logo"><span>🏎️</span> TypeRacer</div>
674
+ <div class="header-info">
675
+ <div class="badge">
676
+ <div class="status-dot" id="conn-dot"></div>
677
+ <span id="conn-label">Connecting…</span>
678
+ </div>
679
+ <div class="badge" id="my-name-badge">⏳ Joining…</div>
680
+ <div class="badge" id="room-badge" style="display:none">πŸšͺ Room: β€”</div>
681
+ </div>
682
+ </header>
683
+
684
+ <!-- ═══════════ WAITING / COUNTDOWN OVERLAY ═══════════ -->
685
+ <div id="overlay">
686
+ <div class="overlay-card">
687
+ <div class="overlay-title">🏁 TypeRacer</div>
688
+ <div class="overlay-sub" id="overlay-sub">Connecting to server…</div>
689
+ <div class="loader" id="overlay-loader"></div>
690
+ <div class="countdown-number" id="overlay-countdown" style="display:none"></div>
691
+ <div class="waiting-players" id="waiting-players-list"></div>
692
+ </div>
693
+ </div>
694
+
695
+ <!-- ═══════════ MAIN ═══════════ -->
696
+ <main>
697
+
698
+ <!-- Players bar -->
699
+ <div id="players-bar"></div>
700
+
701
+ <!-- Race Track -->
702
+ <div id="race-track"></div>
703
+
704
+ <!-- Typing Section -->
705
+ <div id="typing-section">
706
+ <!-- Stats -->
707
+ <div class="stats-row">
708
+ <div class="stat-box">
709
+ <div class="stat-value" id="stat-wpm">0</div>
710
+ <div class="stat-label">WPM</div>
711
+ </div>
712
+ <div class="stat-box">
713
+ <div class="stat-value" id="stat-acc">100%</div>
714
+ <div class="stat-label">Accuracy</div>
715
+ </div>
716
+ <div class="stat-box">
717
+ <div class="stat-value" id="stat-time">0s</div>
718
+ <div class="stat-label">Time</div>
719
+ </div>
720
+ <div class="stat-box">
721
+ <div class="stat-value" id="stat-prog">0%</div>
722
+ <div class="stat-label">Progress</div>
723
+ </div>
724
+ </div>
725
+
726
+ <!-- Progress bar -->
727
+ <div class="progress-bar-wrap">
728
+ <div id="my-progress-fill"></div>
729
+ </div>
730
+
731
+ <!-- Paragraph -->
732
+ <div id="paragraph-display">Waiting for race to start…</div>
733
+
734
+ <!-- Input -->
735
+ <input
736
+ type="text"
737
+ id="typing-input"
738
+ placeholder="Race starts soon β€” get ready!"
739
+ disabled
740
+ autocomplete="off"
741
+ autocorrect="off"
742
+ autocapitalize="off"
743
+ spellcheck="false"
744
+ />
745
+ </div>
746
+
747
+ <!-- Chat -->
748
+ <div id="chat-section">
749
+ <div id="chat-messages"></div>
750
+ <div class="chat-row">
751
+ <input type="text" id="chat-input" placeholder="Say something… (Enter to send)" maxlength="200"/>
752
+ <button id="chat-send">Send</button>
753
+ </div>
754
+ </div>
755
+
756
+ </main>
757
+
758
+ <!-- ═══════════ RESULTS MODAL ═══════════ -->
759
+ <div id="results-modal">
760
+ <div class="results-card">
761
+ <div class="results-title">πŸ† Race Results</div>
762
+ <div id="results-list"></div>
763
+ <button class="play-again-btn" onclick="location.reload()">πŸ”„ Play Again</button>
764
+ </div>
765
+ </div>
766
+
767
+ <!-- ═══════════ TOAST ═══════════ -->
768
+ <div id="toast-container"></div>
769
+
770
+ <!-- ════════════════════���══════════════════
771
+ JAVASCRIPT
772
+ ═══════════════════════════════════════ -->
773
+ <script>
774
+ /* ─────────────────────────────────────────
775
+ UTILITIES
776
+ ───────────────────────────────────────── */
777
+ function toast(msg, type = 'info', duration = 3500) {
778
+ const el = document.createElement('div');
779
+ el.className = `toast ${type}`;
780
+ el.textContent = msg;
781
+ document.getElementById('toast-container').appendChild(el);
782
+ setTimeout(() => el.remove(), duration);
783
+ }
784
+
785
+ function $(id) { return document.getElementById(id); }
786
+
787
+ /* ─────────────────────────────────────────
788
+ CAR SVG FACTORY
789
+ Draws a side-view pixel-style car with
790
+ the player's assigned color.
791
+ ───────────────────────────────────────── */
792
+ function makeCarSVG(color) {
793
+ const body = color.body || '#e74c3c';
794
+ const stripe = color.stripe || '#c0392b';
795
+ const window_ = color.window || '#85c1e9';
796
+
797
+ return `
798
+ <svg xmlns="http://www.w3.org/2000/svg" width="72" height="36" viewBox="0 0 72 36">
799
+ <!-- Shadow -->
800
+ <ellipse cx="36" cy="34" rx="28" ry="3" fill="rgba(0,0,0,0.35)"/>
801
+ <!-- Body -->
802
+ <rect x="4" y="16" width="64" height="14" rx="4" fill="${body}"/>
803
+ <!-- Hood slope -->
804
+ <polygon points="4,16 18,16 24,8 50,8 56,16 68,16" fill="${body}"/>
805
+ <!-- Roof -->
806
+ <rect x="22" y="6" width="28" height="12" rx="4" fill="${stripe}"/>
807
+ <!-- Windshield front -->
808
+ <polygon points="50,8 56,16 50,16" fill="${window_}" opacity="0.85"/>
809
+ <!-- Windshield rear -->
810
+ <polygon points="22,8 26,16 22,16" fill="${window_}" opacity="0.85"/>
811
+ <!-- Side windows -->
812
+ <rect x="27" y="9" width="10" height="7" rx="2" fill="${window_}" opacity="0.9"/>
813
+ <rect x="39" y="9" width="10" height="7" rx="2" fill="${window_}" opacity="0.9"/>
814
+ <!-- Stripe -->
815
+ <rect x="4" y="20" width="64" height="3" fill="${stripe}" opacity="0.5"/>
816
+ <!-- Wheels -->
817
+ <circle cx="17" cy="30" r="6" fill="#1a1a2e"/>
818
+ <circle cx="17" cy="30" r="3" fill="#4a4a6e"/>
819
+ <circle cx="55" cy="30" r="6" fill="#1a1a2e"/>
820
+ <circle cx="55" cy="30" r="3" fill="#4a4a6e"/>
821
+ <!-- Headlight -->
822
+ <rect x="64" y="17" width="5" height="4" rx="1" fill="#fef08a"/>
823
+ <!-- Tail light -->
824
+ <rect x="3" y="17" width="4" height="4" rx="1" fill="#fca5a5"/>
825
+ </svg>`;
826
+ }
827
+
828
+ /* ─────────────────────────────────────────
829
+ GAME STATE
830
+ ───────────────────────────────────────── */
831
+ const state = {
832
+ myId: null,
833
+ myName: '',
834
+ myColor: {},
835
+ roomId: null,
836
+ raceText: '',
837
+ words: [], // array of word strings
838
+ wordIndex: 0, // current word index
839
+ charIndex: 0, // current char within current word
840
+ totalCharsTyped: 0,
841
+ errorCount: 0,
842
+ startTime: null,
843
+ raceActive: false,
844
+ raceEnded: false,
845
+ players: {}, // id β†’ { name, color, progress, wpm, finished, el:{lane,car,fill,wpm} }
846
+ timerInterval: null,
847
+ sendInterval: null,
848
+ lastWPM: 0,
849
+ lastProgress: 0,
850
+ };
851
+
852
+ /* ─────────────────────────────────────────
853
+ DOM REFERENCES
854
+ ───────────────────────────────────────── */
855
+ const overlay = $('overlay');
856
+ const overlaySub = $('overlay-sub');
857
+ const overlayLoader = $('overlay-loader');
858
+ const overlayCD = $('overlay-countdown');
859
+ const waitingList = $('waiting-players-list');
860
+ const raceTrack = $('race-track');
861
+ const playersBar = $('players-bar');
862
+ const paraDisplay = $('paragraph-display');
863
+ const typingInput = $('typing-input');
864
+ const connDot = $('conn-dot');
865
+ const connLabel = $('conn-label');
866
+ const myNameBadge = $('my-name-badge');
867
+ const roomBadge = $('room-badge');
868
+ const statWPM = $('stat-wpm');
869
+ const statAcc = $('stat-acc');
870
+ const statTime = $('stat-time');
871
+ const statProg = $('stat-prog');
872
+ const myProgressFill = $('my-progress-fill');
873
+ const resultsModal = $('results-modal');
874
+ const resultsList = $('results-list');
875
+ const chatMessages = $('chat-messages');
876
+ const chatInput = $('chat-input');
877
+
878
+ /* ─────────────────────────────────────────
879
+ WEBSOCKET
880
+ ───────────────────────────────────────── */
881
+ let ws = null;
882
+
883
+ function connectWS() {
884
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
885
+ const url = `${proto}://${location.host}/ws`;
886
+ ws = new WebSocket(url);
887
+
888
+ ws.onopen = () => {
889
+ connDot.className = 'status-dot connected';
890
+ connLabel.textContent = 'Connected';
891
+ };
892
+
893
+ ws.onclose = () => {
894
+ connDot.className = 'status-dot error';
895
+ connLabel.textContent = 'Disconnected';
896
+ toast('Connection lost. Refresh to reconnect.', 'error', 8000);
897
+ };
898
+
899
+ ws.onerror = () => {
900
+ connDot.className = 'status-dot error';
901
+ connLabel.textContent = 'Error';
902
+ };
903
+
904
+ ws.onmessage = (evt) => {
905
+ let data;
906
+ try { data = JSON.parse(evt.data); } catch { return; }
907
+ handleMessage(data);
908
+ };
909
+
910
+ // Keep-alive ping every 25 s
911
+ setInterval(() => {
912
+ if (ws && ws.readyState === WebSocket.OPEN) {
913
+ ws.send(JSON.stringify({ type: 'ping' }));
914
+ }
915
+ }, 25000);
916
+ }
917
+
918
+ function sendWS(obj) {
919
+ if (ws && ws.readyState === WebSocket.OPEN) {
920
+ ws.send(JSON.stringify(obj));
921
+ }
922
+ }
923
+
924
+ /* ─────────────────────────────────────────
925
+ MESSAGE HANDLER
926
+ ───────────────────────────────────────── */
927
+ function handleMessage(data) {
928
+ switch (data.type) {
929
+
930
+ case 'init':
931
+ onInit(data);
932
+ break;
933
+
934
+ case 'countdown':
935
+ onCountdown(data);
936
+ break;
937
+
938
+ case 'race_start':
939
+ onRaceStart(data);
940
+ break;
941
+
942
+ case 'player_joined':
943
+ onPlayerJoined(data.player);
944
+ break;
945
+
946
+ case 'player_left':
947
+ onPlayerLeft(data);
948
+ break;
949
+
950
+ case 'player_update':
951
+ onPlayerUpdate(data.player);
952
+ break;
953
+
954
+ case 'player_finished':
955
+ onPlayerFinished(data);
956
+ break;
957
+
958
+ case 'race_end':
959
+ onRaceEnd(data);
960
+ break;
961
+
962
+ case 'disqualified':
963
+ toast('⚠️ ' + data.message, 'error', 8000);
964
+ typingInput.disabled = true;
965
+ state.raceActive = false;
966
+ break;
967
+
968
+ case 'chat':
969
+ appendChat(data.name, data.message);
970
+ break;
971
+
972
+ case 'pong':
973
+ break; // silence
974
+ }
975
+ }
976
+
977
+ /* ─────────────────────────────────────────
978
+ INIT (joined room, told who I am)
979
+ ───────────────────────────────────────── */
980
+ function onInit(data) {
981
+ state.myId = data.player_id;
982
+ state.myName = data.name;
983
+ state.myColor = data.color;
984
+ state.roomId = data.room_id;
985
+
986
+ myNameBadge.textContent = 'πŸ‘€ ' + state.myName;
987
+ roomBadge.textContent = 'πŸšͺ Room: ' + state.room_id;
988
+ roomBadge.style.display = 'flex';
989
+
990
+ overlaySub.textContent = `You joined as ${state.myName}. Waiting for race…`;
991
+
992
+ // Initialise existing players
993
+ data.players.forEach(p => ensurePlayer(p));
994
+
995
+ updatePlayersBar();
996
+ updateWaitingList();
997
+
998
+ if (data.room_state === 'racing') {
999
+ // We joined mid-race
1000
+ if (data.text) onRaceStart({ text: data.text, players: data.players });
1001
+ }
1002
+ }
1003
+
1004
+ /* ─────────────────────────────────────────
1005
+ COUNTDOWN
1006
+ ───────────────────────────────────────── */
1007
+ function onCountdown(data) {
1008
+ overlayLoader.style.display = 'none';
1009
+ overlayCD.style.display = 'block';
1010
+ overlaySub.textContent = data.message;
1011
+ overlayCD.textContent = data.seconds;
1012
+ }
1013
+
1014
+ /* ─────────────────────────────────────────
1015
+ RACE START
1016
+ ───────────────────────────────────────── */
1017
+ function onRaceStart(data) {
1018
+ // Update players from payload
1019
+ if (data.players) data.players.forEach(p => ensurePlayer(p));
1020
+
1021
+ state.raceText = data.text;
1022
+ state.words = data.text.split(' ');
1023
+ state.wordIndex = 0;
1024
+ state.charIndex = 0;
1025
+ state.totalCharsTyped = 0;
1026
+ state.errorCount = 0;
1027
+ state.startTime = null;
1028
+ state.raceActive = true;
1029
+ state.raceEnded = false;
1030
+
1031
+ // Hide overlay
1032
+ overlay.style.display = 'none';
1033
+
1034
+ // Build paragraph DOM
1035
+ buildParagraphDOM(state.words);
1036
+
1037
+ // Enable input
1038
+ typingInput.disabled = false;
1039
+ typingInput.value = '';
1040
+ typingInput.placeholder = 'Start typing…';
1041
+ typingInput.focus();
1042
+
1043
+ // Start client timer
1044
+ state.timerInterval = setInterval(updateTimer, 500);
1045
+
1046
+ // Start sending progress every 500ms
1047
+ state.sendInterval = setInterval(sendProgress, 500);
1048
+
1049
+ updatePlayersBar();
1050
+ rebuildRaceTrack();
1051
+
1052
+ toast('🏁 Race started! Go go go!', 'success', 2500);
1053
+ }
1054
+
1055
+ /* ─────────────────────────────────────────
1056
+ PARAGRAPH DOM BUILDER
1057
+ ��──────────────────────────────────────── */
1058
+ function buildParagraphDOM(words) {
1059
+ paraDisplay.innerHTML = '';
1060
+ words.forEach((word, wi) => {
1061
+ const wordSpan = document.createElement('span');
1062
+ wordSpan.className = 'word';
1063
+ wordSpan.dataset.wi = wi;
1064
+
1065
+ word.split('').forEach((ch, ci) => {
1066
+ const s = document.createElement('span');
1067
+ s.className = 'char pending';
1068
+ s.dataset.ci = ci;
1069
+ s.textContent = ch;
1070
+ wordSpan.appendChild(s);
1071
+ });
1072
+
1073
+ paraDisplay.appendChild(wordSpan);
1074
+
1075
+ // Space between words (except last)
1076
+ if (wi < words.length - 1) {
1077
+ const sp = document.createElement('span');
1078
+ sp.className = 'char pending space-char';
1079
+ sp.dataset.wi = wi;
1080
+ sp.dataset.ci = word.length;
1081
+ sp.textContent = ' ';
1082
+ paraDisplay.appendChild(sp);
1083
+ }
1084
+ });
1085
+
1086
+ // Highlight first char
1087
+ highlightCurrent();
1088
+ }
1089
+
1090
+ function getCharEl(wi, ci) {
1091
+ const wEl = paraDisplay.querySelector(`.word[data-wi="${wi}"]`);
1092
+ if (!wEl) return null;
1093
+ return wEl.querySelector(`[data-ci="${ci}"]`);
1094
+ }
1095
+
1096
+ function getSpaceEl(wi) {
1097
+ return paraDisplay.querySelector(`.space-char[data-wi="${wi}"]`);
1098
+ }
1099
+
1100
+ function highlightCurrent() {
1101
+ // Remove existing current highlights
1102
+ paraDisplay.querySelectorAll('.char.current').forEach(el => {
1103
+ el.classList.remove('current');
1104
+ });
1105
+
1106
+ if (state.wordIndex >= state.words.length) return;
1107
+
1108
+ const word = state.words[state.wordIndex];
1109
+ if (state.charIndex < word.length) {
1110
+ const el = getCharEl(state.wordIndex, state.charIndex);
1111
+ if (el) el.classList.add('current');
1112
+ } else {
1113
+ // Cursor on space
1114
+ const sp = getSpaceEl(state.wordIndex);
1115
+ if (sp) sp.classList.add('current');
1116
+ }
1117
+ }
1118
+
1119
+ /* ─────────────────────────────────────────
1120
+ TYPING ENGINE
1121
+ ───────────────────────────────────────── */
1122
+ typingInput.addEventListener('keydown', (e) => {
1123
+ if (!state.raceActive || state.raceEnded) return;
1124
+
1125
+ // Start timer on first keystroke
1126
+ if (!state.startTime) state.startTime = Date.now();
1127
+ });
1128
+
1129
+ typingInput.addEventListener('input', () => {
1130
+ if (!state.raceActive || state.raceEnded) return;
1131
+ if (!state.startTime) state.startTime = Date.now();
1132
+
1133
+ const typed = typingInput.value;
1134
+ const word = state.words[state.wordIndex];
1135
+
1136
+ // ── Space key pressed β†’ advance to next word ──
1137
+ if (typed.endsWith(' ')) {
1138
+ const attempt = typed.trimEnd();
1139
+
1140
+ if (attempt === word) {
1141
+ // Correct word: mark all chars green + space green
1142
+ word.split('').forEach((_, ci) => {
1143
+ const el = getCharEl(state.wordIndex, ci);
1144
+ if (el) { el.classList.remove('pending','wrong','current'); el.classList.add('correct'); }
1145
+ });
1146
+ const sp = getSpaceEl(state.wordIndex);
1147
+ if (sp) { sp.classList.remove('pending','current'); sp.classList.add('correct'); }
1148
+
1149
+ state.totalCharsTyped += word.length + 1;
1150
+ state.wordIndex++;
1151
+ state.charIndex = 0;
1152
+ typingInput.value = '';
1153
+ typingInput.className = '';
1154
+
1155
+ if (state.wordIndex >= state.words.length) {
1156
+ finishRace();
1157
+ return;
1158
+ }
1159
+
1160
+ highlightCurrent();
1161
+ updateStats();
1162
+ } else {
1163
+ // Wrong word on space β†’ shake, keep in place
1164
+ typingInput.classList.add('wrong-word');
1165
+ state.errorCount++;
1166
+ }
1167
+ return;
1168
+ }
1169
+
1170
+ // ── Character-level highlight ──
1171
+ typingInput.classList.remove('wrong-word');
1172
+ const word2 = state.words[state.wordIndex];
1173
+
1174
+ let allCorrect = true;
1175
+ for (let ci = 0; ci < typed.length; ci++) {
1176
+ const charEl = getCharEl(state.wordIndex, ci);
1177
+ if (!charEl) continue;
1178
+ charEl.classList.remove('pending','correct','wrong','current');
1179
+ if (ci < word2.length && typed[ci] === word2[ci]) {
1180
+ charEl.classList.add('correct');
1181
+ } else {
1182
+ charEl.classList.add('wrong');
1183
+ allCorrect = false;
1184
+ }
1185
+ }
1186
+
1187
+ // Remaining chars in this word β†’ pending
1188
+ for (let ci = typed.length; ci < word2.length; ci++) {
1189
+ const charEl = getCharEl(state.wordIndex, ci);
1190
+ if (charEl) { charEl.classList.remove('correct','wrong','current'); charEl.classList.add('pending'); }
1191
+ }
1192
+
1193
+ state.charIndex = typed.length;
1194
+ typingInput.className = typed.length > 0 ? (allCorrect ? 'correct-word' : 'wrong-word') : '';
1195
+
1196
+ highlightCurrent();
1197
+ updateStats();
1198
+ });
1199
+
1200
+ /* ─────────────────────────────────────────
1201
+ STATS UPDATE
1202
+ ───────────────────────────────────────── */
1203
+ function updateStats() {
1204
+ const elapsed = state.startTime ? (Date.now() - state.startTime) / 60000 : 0.0001;
1205
+ const charsTyped = state.totalCharsTyped;
1206
+ const wpm = elapsed > 0 ? Math.round((charsTyped / 5) / elapsed) : 0;
1207
+ const totalChars = state.raceText.length;
1208
+ const progress = Math.min((state.totalCharsTyped / totalChars) * 100, 100);
1209
+ const totalTyped = charsTyped + state.errorCount;
1210
+ const accuracy = totalTyped > 0 ? Math.round((charsTyped / totalTyped) * 100) : 100;
1211
+
1212
+ state.lastWPM = wpm;
1213
+ state.lastProgress = progress;
1214
+
1215
+ statWPM.textContent = wpm;
1216
+ statAcc.textContent = accuracy + '%';
1217
+ statProg.textContent = Math.round(progress) + '%';
1218
+
1219
+ myProgressFill.style.width = progress + '%';
1220
+
1221
+ // Update my car position
1222
+ updateCarPosition(state.myId, progress);
1223
+ }
1224
+
1225
+ function updateTimer() {
1226
+ if (!state.startTime || !state.raceActive) return;
1227
+ const secs = Math.floor((Date.now() - state.startTime) / 1000);
1228
+ statTime.textContent = secs + 's';
1229
+ }
1230
+
1231
+ /* ─────────────────────────────────────────
1232
+ SEND PROGRESS TO SERVER
1233
+ ───────────────────────────────────────── */
1234
+ function sendProgress() {
1235
+ if (!state.raceActive) return;
1236
+ sendWS({
1237
+ type: 'progress',
1238
+ progress: state.lastProgress,
1239
+ wpm: state.lastWPM,
1240
+ });
1241
+ }
1242
+
1243
+ /* ─────────────────────────────────────────
1244
+ FINISH
1245
+ ───────────────────────────────────────── */
1246
+ function finishRace() {
1247
+ state.raceActive = false;
1248
+ typingInput.disabled = true;
1249
+ typingInput.placeholder = 'You finished! 🏁';
1250
+
1251
+ clearInterval(state.timerInterval);
1252
+ clearInterval(state.sendInterval);
1253
+
1254
+ // Send final 100% immediately
1255
+ sendWS({ type: 'progress', progress: 100, wpm: state.lastWPM });
1256
+
1257
+ updateCarPosition(state.myId, 100);
1258
+ toast('πŸŽ‰ You finished the race!', 'success', 5000);
1259
+ }
1260
+
1261
+ /* ─────────────────────────────────────────
1262
+ PLAYER MANAGEMENT
1263
+ ───────────────────────────────────────── */
1264
+ function ensurePlayer(p) {
1265
+ if (!state.players[p.id]) {
1266
+ state.players[p.id] = {
1267
+ name: p.name,
1268
+ color: p.color,
1269
+ progress: p.progress || 0,
1270
+ wpm: p.wpm || 0,
1271
+ finished: p.finished || false,
1272
+ finish_pos: p.finish_pos || 0,
1273
+ el: null,
1274
+ };
1275
+ } else {
1276
+ // Merge updates
1277
+ Object.assign(state.players[p.id], {
1278
+ name: p.name,
1279
+ color: p.color,
1280
+ progress: p.progress || state.players[p.id].progress,
1281
+ wpm: p.wpm || state.players[p.id].wpm,
1282
+ });
1283
+ }
1284
+ }
1285
+
1286
+ function onPlayerJoined(p) {
1287
+ ensurePlayer(p);
1288
+ updatePlayersBar();
1289
+ updateWaitingList();
1290
+ rebuildRaceTrack();
1291
+ toast(`πŸ‘€ ${p.name} joined the lobby`, 'info');
1292
+ }
1293
+
1294
+ function onPlayerLeft(data) {
1295
+ const p = state.players[data.player_id];
1296
+ if (p) {
1297
+ // Remove lane if exists
1298
+ if (p.el && p.el.lane) p.el.lane.remove();
1299
+ delete state.players[data.player_id];
1300
+ }
1301
+ updatePlayersBar();
1302
+ toast(`❌ ${data.name} left the race`, 'warning');
1303
+ }
1304
+
1305
+ function onPlayerUpdate(player) {
1306
+ if (!state.players[player.id]) return;
1307
+ state.players[player.id].progress = player.progress;
1308
+ state.players[player.id].wpm = player.wpm;
1309
+ state.players[player.id].finished = player.finished;
1310
+
1311
+ // Update car on track
1312
+ updateCarPosition(player.id, player.progress);
1313
+
1314
+ // Update WPM badge above car
1315
+ const p = state.players[player.id];
1316
+ if (p.el && p.el.wpmEl) {
1317
+ p.el.wpmEl.textContent = player.wpm + ' WPM';
1318
+ }
1319
+
1320
+ // Update players bar chip
1321
+ const chip = playersBar.querySelector(`[data-pid="${player.id}"]`);
1322
+ if (chip) {
1323
+ const wpmEl = chip.querySelector('.chip-wpm');
1324
+ if (wpmEl) wpmEl.textContent = player.wpm + ' WPM';
1325
+ }
1326
+ }
1327
+
1328
+ function onPlayerFinished(data) {
1329
+ if (state.players[data.player_id]) {
1330
+ state.players[data.player_id].finished = true;
1331
+ state.players[data.player_id].finish_pos = data.finish_pos;
1332
+ }
1333
+
1334
+ const medals = { 1: 'πŸ₯‡', 2: 'πŸ₯ˆ', 3: 'πŸ₯‰' };
1335
+ const medal = medals[data.finish_pos] || `${data.finish_pos}${data.suffix}`;
1336
+ toast(`${medal} ${data.player_name} finished! (${data.wpm} WPM)`, 'success', 4000);
1337
+ }
1338
+
1339
+ function onRaceEnd(data) {
1340
+ state.raceEnded = true;
1341
+ state.raceActive = false;
1342
+ clearInterval(state.timerInterval);
1343
+ clearInterval(state.sendInterval);
1344
+
1345
+ typingInput.disabled = true;
1346
+
1347
+ // Build results
1348
+ resultsList.innerHTML = '';
1349
+ data.results.forEach((p, i) => {
1350
+ const isMe = p.id === state.myId;
1351
+ const row = document.createElement('div');
1352
+ row.className = `result-row${isMe ? ' me' : ''}`;
1353
+
1354
+ let posText = p.finish_pos;
1355
+ let posCls = '';
1356
+ if (p.disqualified) { posText = '🚫'; }
1357
+ else if (!p.finished) { posText = 'DNF'; }
1358
+ else if (i === 0) { posCls = 'first'; }
1359
+ else if (i === 1) { posCls = 'second'; }
1360
+ else if (i === 2) { posCls = 'third'; }
1361
+
1362
+ row.innerHTML = `
1363
+ <div class="result-pos ${posCls}">${posText}</div>
1364
+ <div class="result-car-dot" style="background:${p.color.body}"></div>
1365
+ <div class="result-name">${p.name}${isMe ? ' <em>(you)</em>' : ''}</div>
1366
+ <div class="result-wpm">${p.wpm} WPM</div>
1367
+ `;
1368
+ resultsList.appendChild(row);
1369
+ });
1370
+
1371
+ setTimeout(() => { resultsModal.style.display = 'flex'; }, 1200);
1372
+ }
1373
+
1374
+ /* ─────────────────────────────────────────
1375
+ RACE TRACK (build lanes)
1376
+ ───────────────────────────────────────── */
1377
+ function rebuildRaceTrack() {
1378
+ raceTrack.innerHTML = '';
1379
+
1380
+ Object.entries(state.players).forEach(([pid, p]) => {
1381
+ const lane = document.createElement('div');
1382
+ lane.className = 'lane';
1383
+ lane.dataset.pid = pid;
1384
+
1385
+ // Label
1386
+ const label = document.createElement('div');
1387
+ label.className = 'lane-label';
1388
+ label.textContent = pid === state.myId ? '⭐ ' + p.name : p.name;
1389
+ lane.appendChild(label);
1390
+
1391
+ // Finish flag
1392
+ const flag = document.createElement('div');
1393
+ flag.className = 'finish-flag';
1394
+ flag.textContent = '🏁';
1395
+ lane.appendChild(flag);
1396
+
1397
+ // Progress background
1398
+ const progBg = document.createElement('div');
1399
+ progBg.className = 'lane-progress-bg';
1400
+ const progFill = document.createElement('div');
1401
+ progFill.className = 'lane-progress-fill';
1402
+ progFill.style.background = `linear-gradient(90deg, ${p.color.body}, ${p.color.stripe})`;
1403
+ progFill.style.width = (p.progress || 0) + '%';
1404
+ progBg.appendChild(progFill);
1405
+ lane.appendChild(progBg);
1406
+
1407
+ // Car wrapper
1408
+ const carWrap = document.createElement('div');
1409
+ carWrap.className = 'car-wrapper';
1410
+
1411
+ const wpmBadge = document.createElement('div');
1412
+ wpmBadge.className = 'car-wpm';
1413
+ wpmBadge.textContent = (p.wpm || 0) + ' WPM';
1414
+
1415
+ const carSVGWrap = document.createElement('div');
1416
+ carSVGWrap.className = 'car-svg-wrap';
1417
+ carSVGWrap.innerHTML = makeCarSVG(p.color);
1418
+
1419
+ carWrap.appendChild(wpmBadge);
1420
+ carWrap.appendChild(carSVGWrap);
1421
+ lane.appendChild(carWrap);
1422
+
1423
+ // Store DOM refs
1424
+ p.el = { lane, fill: progFill, car: carWrap, wpmEl: wpmBadge };
1425
+
1426
+ raceTrack.appendChild(lane);
1427
+ });
1428
+ }
1429
+
1430
+ /* ─────────────────────────────────────────
1431
+ UPDATE CAR POSITION
1432
+ ───────────────────────────────────────── */
1433
+ function updateCarPosition(pid, progress) {
1434
+ const p = state.players[pid];
1435
+ if (!p || !p.el) return;
1436
+
1437
+ // Track available width for car travel
1438
+ const trackWidth = raceTrack.offsetWidth || 800;
1439
+ const labelWidth = 90; // left reserved
1440
+ const rightPad = 52; // flag area
1441
+ const carWidth = 72;
1442
+ const travelW = trackWidth - labelWidth - rightPad - carWidth;
1443
+ const px = (Math.min(progress, 100) / 100) * travelW;
1444
+
1445
+ p.el.car.style.transform = `translateY(-50%) translateX(${px}px)`;
1446
+ p.el.fill.style.width = Math.min(progress, 100) + '%';
1447
+ }
1448
+
1449
+ /* ─────────────────────────────────────────
1450
+ PLAYERS BAR
1451
+ ───────────────────────────────────────── */
1452
+ function updatePlayersBar() {
1453
+ playersBar.innerHTML = '';
1454
+ Object.entries(state.players).forEach(([pid, p]) => {
1455
+ const chip = document.createElement('div');
1456
+ chip.className = `player-chip${pid === state.myId ? ' me' : ''}`;
1457
+ chip.dataset.pid = pid;
1458
+
1459
+ const dot = document.createElement('div');
1460
+ dot.className = 'dot';
1461
+ dot.style.background = p.color.body || '#aaa';
1462
+
1463
+ chip.innerHTML = `
1464
+ <div class="dot" style="background:${p.color.body}"></div>
1465
+ <span>${pid === state.myId ? '⭐ ' : ''}${p.name}</span>
1466
+ <span class="chip-wpm" style="color:${p.color.body};font-size:0.75rem">${p.wpm || 0} WPM</span>
1467
+ `;
1468
+ playersBar.appendChild(chip);
1469
+ });
1470
+ }
1471
+
1472
+ /* ─────────────────────────────────────────
1473
+ WAITING LIST (overlay)
1474
+ ───────────────────────────────────────── */
1475
+ function updateWaitingList() {
1476
+ waitingList.innerHTML = '';
1477
+ Object.entries(state.players).forEach(([pid, p]) => {
1478
+ const chip = document.createElement('div');
1479
+ chip.className = 'waiting-player-chip';
1480
+ chip.innerHTML = `
1481
+ <div style="width:10px;height:10px;border-radius:50%;background:${p.color.body}"></div>
1482
+ <span>${p.name}${pid === state.myId ? ' (you)' : ''}</span>
1483
+ `;
1484
+ waitingList.appendChild(chip);
1485
+ });
1486
+ }
1487
+
1488
+ /* ─────────────────────��───────────────────
1489
+ CHAT
1490
+ ───────────────────────────────────────── */
1491
+ function appendChat(name, msg) {
1492
+ const el = document.createElement('div');
1493
+ el.className = 'chat-msg';
1494
+ el.innerHTML = `<strong>${escapeHTML(name)}:</strong> ${escapeHTML(msg)}`;
1495
+ chatMessages.appendChild(el);
1496
+ chatMessages.scrollTop = chatMessages.scrollHeight;
1497
+ }
1498
+
1499
+ function escapeHTML(str) {
1500
+ return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1501
+ }
1502
+
1503
+ $('chat-send').addEventListener('click', sendChat);
1504
+ chatInput.addEventListener('keydown', e => { if (e.key === 'Enter') sendChat(); });
1505
+
1506
+ function sendChat() {
1507
+ const msg = chatInput.value.trim();
1508
+ if (!msg) return;
1509
+ sendWS({ type: 'chat', message: msg });
1510
+ appendChat(state.myName || 'You', msg);
1511
+ chatInput.value = '';
1512
+ }
1513
+
1514
+ /* ─────────────────────────────────────────
1515
+ WINDOW RESIZE β†’ reposition cars
1516
+ ───────────────────────────────────────── */
1517
+ window.addEventListener('resize', () => {
1518
+ Object.entries(state.players).forEach(([pid, p]) => {
1519
+ updateCarPosition(pid, p.progress || 0);
1520
+ });
1521
+ });
1522
+
1523
+ /* ─────────────────────────────────────────
1524
+ START
1525
+ ───────────────────────────────────────── */
1526
+ connectWS();
1527
+ </script>
1528
+ </body>
1529
+ </html>