Spaces:
Paused
Paused
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>ποΈ TypeRacer β Real-time Multiplayer</title> | |
| <style> | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| RESET & ROOT VARIABLES | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0d0d1a; | |
| --panel: #13132a; | |
| --panel2: #1a1a35; | |
| --accent: #7c3aed; | |
| --accent2: #a855f7; | |
| --green: #22c55e; | |
| --red: #ef4444; | |
| --yellow: #eab308; | |
| --text: #e2e8f0; | |
| --muted: #64748b; | |
| --border: #2d2d5e; | |
| --track-bg: #1e1e3f; | |
| --track-line: #2d2d6e; | |
| --road: #374151; | |
| --road-mark: #4b5563; | |
| --glow: 0 0 20px rgba(124,58,237,0.4); | |
| --font: 'Segoe UI', system-ui, sans-serif; | |
| } | |
| body { | |
| font-family: var(--font); | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| SCROLLBAR | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: var(--bg); } | |
| ::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 3px; } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| HEADER | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| header { | |
| background: linear-gradient(135deg, #1a0533 0%, #0d0d1a 100%); | |
| border-bottom: 2px solid var(--border); | |
| padding: 14px 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| box-shadow: var(--glow); | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| } | |
| .logo { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-size: 1.5rem; | |
| font-weight: 800; | |
| background: linear-gradient(90deg, #a855f7, #7c3aed, #3b82f6); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| letter-spacing: -0.5px; | |
| } | |
| .logo span { font-size: 1.8rem; } | |
| .header-info { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| font-size: 0.85rem; | |
| } | |
| .badge { | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| padding: 5px 14px; | |
| font-weight: 600; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .status-dot { | |
| width: 8px; height: 8px; | |
| border-radius: 50%; | |
| background: var(--muted); | |
| transition: background 0.3s; | |
| } | |
| .status-dot.connected { background: var(--green); box-shadow: 0 0 8px var(--green); } | |
| .status-dot.error { background: var(--red); box-shadow: 0 0 8px var(--red); } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| MAIN LAYOUT | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| main { | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 24px 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| WAITING / COUNTDOWN OVERLAY | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| #overlay { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(10, 10, 26, 0.92); | |
| backdrop-filter: blur(8px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 200; | |
| flex-direction: column; | |
| gap: 20px; | |
| } | |
| .overlay-card { | |
| background: var(--panel); | |
| border: 2px solid var(--border); | |
| border-radius: 24px; | |
| padding: 48px 56px; | |
| text-align: center; | |
| max-width: 480px; | |
| width: 90%; | |
| box-shadow: var(--glow), 0 24px 64px rgba(0,0,0,0.6); | |
| } | |
| .overlay-title { | |
| font-size: 2.2rem; | |
| font-weight: 900; | |
| background: linear-gradient(90deg, #a855f7, #3b82f6); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 8px; | |
| } | |
| .overlay-sub { | |
| color: var(--muted); | |
| font-size: 0.95rem; | |
| margin-bottom: 28px; | |
| } | |
| /* Spinning loader */ | |
| .loader { | |
| width: 56px; height: 56px; | |
| border: 4px solid var(--border); | |
| border-top-color: var(--accent2); | |
| border-radius: 50%; | |
| animation: spin 0.9s linear infinite; | |
| margin: 0 auto 20px; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* Countdown number */ | |
| .countdown-number { | |
| font-size: 6rem; | |
| font-weight: 900; | |
| line-height: 1; | |
| background: linear-gradient(135deg, #f59e0b, #ef4444); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| animation: pulse 1s ease-in-out infinite; | |
| } | |
| @keyframes pulse { | |
| 0%,100% { transform: scale(1); opacity: 1; } | |
| 50% { transform: scale(1.1); opacity: 0.8; } | |
| } | |
| .waiting-players { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| margin-top: 16px; | |
| } | |
| .waiting-player-chip { | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 8px 14px; | |
| font-size: 0.85rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| PLAYERS INFO BAR | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| #players-bar { | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 16px; | |
| padding: 14px 20px; | |
| display: flex; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .player-chip { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| padding: 6px 14px; | |
| font-size: 0.82rem; | |
| font-weight: 600; | |
| transition: border-color 0.2s; | |
| } | |
| .player-chip.me { border-color: var(--accent2); } | |
| .player-chip .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| RACE TRACK | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| #race-track { | |
| background: var(--track-bg); | |
| border: 2px solid var(--border); | |
| border-radius: 20px; | |
| padding: 20px 20px 20px 20px; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0; | |
| box-shadow: inset 0 0 40px rgba(0,0,0,0.4); | |
| } | |
| .lane { | |
| position: relative; | |
| height: 76px; | |
| display: flex; | |
| align-items: center; | |
| border-bottom: 2px dashed var(--track-line); | |
| padding: 0 8px; | |
| } | |
| .lane:last-child { border-bottom: none; } | |
| /* Road texture inside lane */ | |
| .lane::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background: repeating-linear-gradient( | |
| 90deg, | |
| transparent 0px, | |
| transparent 40px, | |
| rgba(255,255,255,0.025) 40px, | |
| rgba(255,255,255,0.025) 41px | |
| ); | |
| pointer-events: none; | |
| } | |
| /* Lane label */ | |
| .lane-label { | |
| position: absolute; | |
| left: 10px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 0.72rem; | |
| color: var(--muted); | |
| font-weight: 700; | |
| letter-spacing: 0.5px; | |
| white-space: nowrap; | |
| z-index: 2; | |
| width: 80px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| /* Progress bar inside lane */ | |
| .lane-progress-bg { | |
| position: absolute; | |
| left: 90px; | |
| right: 10px; | |
| height: 6px; | |
| background: rgba(255,255,255,0.06); | |
| border-radius: 3px; | |
| bottom: 14px; | |
| } | |
| .lane-progress-fill { | |
| height: 100%; | |
| border-radius: 3px; | |
| transition: width 0.3s ease; | |
| width: 0%; | |
| } | |
| /* ββ Car (pure SVG/CSS) ββ */ | |
| .car-wrapper { | |
| position: absolute; | |
| left: 90px; | |
| /* right boundary = track-width - car-width - right-padding */ | |
| /* We move it via JS: transform: translateX(px) */ | |
| top: 50%; | |
| transform: translateY(-50%) translateX(0px); | |
| transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94); | |
| z-index: 5; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 2px; | |
| } | |
| /* WPM badge above car */ | |
| .car-wpm { | |
| font-size: 0.65rem; | |
| font-weight: 800; | |
| color: #fff; | |
| background: rgba(0,0,0,0.6); | |
| border-radius: 6px; | |
| padding: 1px 5px; | |
| white-space: nowrap; | |
| } | |
| /* SVG Car container */ | |
| .car-svg-wrap { line-height: 0; } | |
| /* Finish flag */ | |
| .finish-flag { | |
| position: absolute; | |
| right: 10px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 1.6rem; | |
| z-index: 3; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| TYPING SECTION | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| #typing-section { | |
| background: var(--panel); | |
| border: 2px solid var(--border); | |
| border-radius: 20px; | |
| padding: 28px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| } | |
| /* ββ Stats row ββ */ | |
| .stats-row { | |
| display: flex; | |
| gap: 14px; | |
| flex-wrap: wrap; | |
| } | |
| .stat-box { | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| padding: 12px 20px; | |
| min-width: 90px; | |
| text-align: center; | |
| flex: 1; | |
| } | |
| .stat-value { | |
| font-size: 2rem; | |
| font-weight: 900; | |
| line-height: 1; | |
| background: linear-gradient(135deg, #a855f7, #3b82f6); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .stat-label { | |
| font-size: 0.7rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: var(--muted); | |
| margin-top: 4px; | |
| } | |
| /* ββ Paragraph display ββ */ | |
| #paragraph-display { | |
| font-size: 1.25rem; | |
| line-height: 1.9; | |
| letter-spacing: 0.3px; | |
| padding: 18px 20px; | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| min-height: 90px; | |
| user-select: none; | |
| font-family: 'Courier New', monospace; | |
| } | |
| /* Word states */ | |
| .word { display: inline; } | |
| .word .char { transition: color 0.1s; } | |
| .char.correct { color: var(--green); } | |
| .char.wrong { color: var(--red); background: rgba(239,68,68,0.15); border-radius: 2px; } | |
| .char.current { | |
| color: #fff; | |
| background: var(--accent); | |
| border-radius: 3px; | |
| padding: 0 1px; | |
| box-shadow: 0 0 8px var(--accent2); | |
| } | |
| .char.pending { color: var(--muted); } | |
| /* Cursor blink on active char */ | |
| .char.current { animation: blink-bg 1s step-start infinite; } | |
| @keyframes blink-bg { | |
| 50% { background: var(--accent2); } | |
| } | |
| /* ββ Input ββ */ | |
| #typing-input { | |
| width: 100%; | |
| padding: 16px 20px; | |
| background: var(--panel2); | |
| border: 2px solid var(--border); | |
| border-radius: 14px; | |
| color: var(--text); | |
| font-size: 1.1rem; | |
| font-family: 'Courier New', monospace; | |
| outline: none; | |
| transition: border-color 0.2s, box-shadow 0.2s; | |
| caret-color: var(--accent2); | |
| } | |
| #typing-input:focus { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 3px rgba(124,58,237,0.2); | |
| } | |
| #typing-input.correct-word { border-color: var(--green); } | |
| #typing-input.wrong-word { border-color: var(--red); background: rgba(239,68,68,0.07); } | |
| #typing-input:disabled { opacity: 0.4; cursor: not-allowed; } | |
| /* ββ Progress bar ββ */ | |
| .progress-bar-wrap { | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| height: 10px; | |
| overflow: hidden; | |
| } | |
| #my-progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, var(--accent), var(--accent2)); | |
| border-radius: 10px; | |
| width: 0%; | |
| transition: width 0.3s ease; | |
| box-shadow: 0 0 10px var(--accent); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| RESULTS MODAL | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| #results-modal { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(10, 10, 26, 0.92); | |
| backdrop-filter: blur(8px); | |
| z-index: 300; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| display: none; | |
| } | |
| .results-card { | |
| background: var(--panel); | |
| border: 2px solid var(--border); | |
| border-radius: 24px; | |
| padding: 40px; | |
| max-width: 520px; | |
| width: 90%; | |
| box-shadow: var(--glow), 0 24px 64px rgba(0,0,0,0.6); | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| } | |
| .results-title { | |
| font-size: 1.8rem; | |
| font-weight: 900; | |
| text-align: center; | |
| background: linear-gradient(90deg, #f59e0b, #ef4444, #a855f7); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 28px; | |
| } | |
| .result-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| padding: 14px 16px; | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| margin-bottom: 10px; | |
| transition: border-color 0.2s; | |
| } | |
| .result-row.me { border-color: var(--accent2); box-shadow: 0 0 12px rgba(168,85,247,0.2); } | |
| .result-pos { | |
| font-size: 1.6rem; | |
| font-weight: 900; | |
| min-width: 40px; | |
| text-align: center; | |
| } | |
| .result-pos.first { color: #fbbf24; } | |
| .result-pos.second { color: #94a3b8; } | |
| .result-pos.third { color: #b45309; } | |
| .result-name { flex: 1; font-weight: 700; font-size: 1rem; } | |
| .result-wpm { font-size: 0.9rem; color: var(--accent2); font-weight: 700; } | |
| .result-car-dot { | |
| width: 14px; height: 14px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .play-again-btn { | |
| width: 100%; | |
| margin-top: 24px; | |
| padding: 16px; | |
| background: linear-gradient(135deg, var(--accent), #3b82f6); | |
| color: #fff; | |
| font-size: 1.1rem; | |
| font-weight: 800; | |
| border: none; | |
| border-radius: 14px; | |
| cursor: pointer; | |
| transition: opacity 0.2s, transform 0.1s; | |
| letter-spacing: 0.5px; | |
| } | |
| .play-again-btn:hover { opacity: 0.9; transform: translateY(-1px); } | |
| .play-again-btn:active { transform: translateY(0); } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| NOTIFICATIONS (toast) | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| #toast-container { | |
| position: fixed; | |
| bottom: 24px; | |
| right: 24px; | |
| z-index: 400; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| max-width: 320px; | |
| } | |
| .toast { | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 12px 18px; | |
| font-size: 0.85rem; | |
| animation: slide-in 0.3s ease; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.4); | |
| } | |
| .toast.info { border-left: 4px solid var(--accent2); } | |
| .toast.success { border-left: 4px solid var(--green); } | |
| .toast.warning { border-left: 4px solid var(--yellow); } | |
| .toast.error { border-left: 4px solid var(--red); } | |
| @keyframes slide-in { | |
| from { transform: translateX(100%); opacity: 0; } | |
| to { transform: translateX(0); opacity: 1; } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| CHAT (minimal side strip) | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| #chat-section { | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 16px; | |
| padding: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| #chat-messages { | |
| max-height: 100px; | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .chat-msg { | |
| font-size: 0.8rem; | |
| color: var(--muted); | |
| } | |
| .chat-msg strong { color: var(--accent2); } | |
| .chat-row { | |
| display: flex; | |
| gap: 8px; | |
| } | |
| #chat-input { | |
| flex: 1; | |
| background: var(--panel2); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| color: var(--text); | |
| font-size: 0.85rem; | |
| padding: 8px 12px; | |
| outline: none; | |
| } | |
| #chat-input:focus { border-color: var(--accent); } | |
| #chat-send { | |
| background: var(--accent); | |
| border: none; | |
| border-radius: 10px; | |
| color: #fff; | |
| padding: 8px 14px; | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| font-weight: 700; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββ | |
| RESPONSIVE | |
| βββββββββββββββββββββββββββββββββββββββ */ | |
| @media (max-width: 600px) { | |
| header { flex-direction: column; gap: 8px; } | |
| .overlay-card { padding: 32px 20px; } | |
| .countdown-number { font-size: 4rem; } | |
| #paragraph-display { font-size: 1rem; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- βββββββββββ HEADER βββββββββββ --> | |
| <header> | |
| <div class="logo"><span>ποΈ</span> TypeRacer</div> | |
| <div class="header-info"> | |
| <div class="badge"> | |
| <div class="status-dot" id="conn-dot"></div> | |
| <span id="conn-label">Connectingβ¦</span> | |
| </div> | |
| <div class="badge" id="my-name-badge">β³ Joiningβ¦</div> | |
| <div class="badge" id="room-badge" style="display:none">πͺ Room: β</div> | |
| </div> | |
| </header> | |
| <!-- βββββββββββ WAITING / COUNTDOWN OVERLAY βββββββββββ --> | |
| <div id="overlay"> | |
| <div class="overlay-card"> | |
| <div class="overlay-title">π TypeRacer</div> | |
| <div class="overlay-sub" id="overlay-sub">Connecting to serverβ¦</div> | |
| <div class="loader" id="overlay-loader"></div> | |
| <div class="countdown-number" id="overlay-countdown" style="display:none"></div> | |
| <div class="waiting-players" id="waiting-players-list"></div> | |
| </div> | |
| </div> | |
| <!-- βββββββββββ MAIN βββββββββββ --> | |
| <main> | |
| <!-- Players bar --> | |
| <div id="players-bar"></div> | |
| <!-- Race Track --> | |
| <div id="race-track"></div> | |
| <!-- Typing Section --> | |
| <div id="typing-section"> | |
| <!-- Stats --> | |
| <div class="stats-row"> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="stat-wpm">0</div> | |
| <div class="stat-label">WPM</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="stat-acc">100%</div> | |
| <div class="stat-label">Accuracy</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="stat-time">0s</div> | |
| <div class="stat-label">Time</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="stat-prog">0%</div> | |
| <div class="stat-label">Progress</div> | |
| </div> | |
| </div> | |
| <!-- Progress bar --> | |
| <div class="progress-bar-wrap"> | |
| <div id="my-progress-fill"></div> | |
| </div> | |
| <!-- Paragraph --> | |
| <div id="paragraph-display">Waiting for race to startβ¦</div> | |
| <!-- Input --> | |
| <input | |
| type="text" | |
| id="typing-input" | |
| placeholder="Race starts soon β get ready!" | |
| disabled | |
| autocomplete="off" | |
| autocorrect="off" | |
| autocapitalize="off" | |
| spellcheck="false" | |
| /> | |
| </div> | |
| <!-- Chat --> | |
| <div id="chat-section"> | |
| <div id="chat-messages"></div> | |
| <div class="chat-row"> | |
| <input type="text" id="chat-input" placeholder="Say something⦠(Enter to send)" maxlength="200"/> | |
| <button id="chat-send">Send</button> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- βββββββββββ RESULTS MODAL βββββββββββ --> | |
| <div id="results-modal"> | |
| <div class="results-card"> | |
| <div class="results-title">π Race Results</div> | |
| <div id="results-list"></div> | |
| <button class="play-again-btn" onclick="location.reload()">π Play Again</button> | |
| </div> | |
| </div> | |
| <!-- βββββββββββ TOAST βββββββββββ --> | |
| <div id="toast-container"></div> | |
| <!-- βββββββββββββββββββββββββββββββββββββββ | |
| JAVASCRIPT | |
| βββββββββββββββββββββββββββββββββββββββ --> | |
| <script> | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| UTILITIES | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function toast(msg, type = 'info', duration = 3500) { | |
| const el = document.createElement('div'); | |
| el.className = `toast ${type}`; | |
| el.textContent = msg; | |
| document.getElementById('toast-container').appendChild(el); | |
| setTimeout(() => el.remove(), duration); | |
| } | |
| function $(id) { return document.getElementById(id); } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| CAR SVG FACTORY | |
| Draws a side-view pixel-style car with | |
| the player's assigned color. | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function makeCarSVG(color) { | |
| const body = color.body || '#e74c3c'; | |
| const stripe = color.stripe || '#c0392b'; | |
| const window_ = color.window || '#85c1e9'; | |
| return ` | |
| <svg xmlns="http://www.w3.org/2000/svg" width="72" height="36" viewBox="0 0 72 36"> | |
| <!-- Shadow --> | |
| <ellipse cx="36" cy="34" rx="28" ry="3" fill="rgba(0,0,0,0.35)"/> | |
| <!-- Body --> | |
| <rect x="4" y="16" width="64" height="14" rx="4" fill="${body}"/> | |
| <!-- Hood slope --> | |
| <polygon points="4,16 18,16 24,8 50,8 56,16 68,16" fill="${body}"/> | |
| <!-- Roof --> | |
| <rect x="22" y="6" width="28" height="12" rx="4" fill="${stripe}"/> | |
| <!-- Windshield front --> | |
| <polygon points="50,8 56,16 50,16" fill="${window_}" opacity="0.85"/> | |
| <!-- Windshield rear --> | |
| <polygon points="22,8 26,16 22,16" fill="${window_}" opacity="0.85"/> | |
| <!-- Side windows --> | |
| <rect x="27" y="9" width="10" height="7" rx="2" fill="${window_}" opacity="0.9"/> | |
| <rect x="39" y="9" width="10" height="7" rx="2" fill="${window_}" opacity="0.9"/> | |
| <!-- Stripe --> | |
| <rect x="4" y="20" width="64" height="3" fill="${stripe}" opacity="0.5"/> | |
| <!-- Wheels --> | |
| <circle cx="17" cy="30" r="6" fill="#1a1a2e"/> | |
| <circle cx="17" cy="30" r="3" fill="#4a4a6e"/> | |
| <circle cx="55" cy="30" r="6" fill="#1a1a2e"/> | |
| <circle cx="55" cy="30" r="3" fill="#4a4a6e"/> | |
| <!-- Headlight --> | |
| <rect x="64" y="17" width="5" height="4" rx="1" fill="#fef08a"/> | |
| <!-- Tail light --> | |
| <rect x="3" y="17" width="4" height="4" rx="1" fill="#fca5a5"/> | |
| </svg>`; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| GAME STATE | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| const state = { | |
| myId: null, | |
| myName: '', | |
| myColor: {}, | |
| roomId: null, | |
| raceText: '', | |
| words: [], // array of word strings | |
| wordIndex: 0, // current word index | |
| charIndex: 0, // current char within current word | |
| totalCharsTyped: 0, | |
| errorCount: 0, | |
| startTime: null, | |
| raceActive: false, | |
| raceEnded: false, | |
| players: {}, // id β { name, color, progress, wpm, finished, el:{lane,car,fill,wpm} } | |
| timerInterval: null, | |
| sendInterval: null, | |
| lastWPM: 0, | |
| lastProgress: 0, | |
| }; | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| DOM REFERENCES | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| const overlay = $('overlay'); | |
| const overlaySub = $('overlay-sub'); | |
| const overlayLoader = $('overlay-loader'); | |
| const overlayCD = $('overlay-countdown'); | |
| const waitingList = $('waiting-players-list'); | |
| const raceTrack = $('race-track'); | |
| const playersBar = $('players-bar'); | |
| const paraDisplay = $('paragraph-display'); | |
| const typingInput = $('typing-input'); | |
| const connDot = $('conn-dot'); | |
| const connLabel = $('conn-label'); | |
| const myNameBadge = $('my-name-badge'); | |
| const roomBadge = $('room-badge'); | |
| const statWPM = $('stat-wpm'); | |
| const statAcc = $('stat-acc'); | |
| const statTime = $('stat-time'); | |
| const statProg = $('stat-prog'); | |
| const myProgressFill = $('my-progress-fill'); | |
| const resultsModal = $('results-modal'); | |
| const resultsList = $('results-list'); | |
| const chatMessages = $('chat-messages'); | |
| const chatInput = $('chat-input'); | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| WEBSOCKET | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| let ws = null; | |
| function connectWS() { | |
| const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | |
| const url = `${proto}://${location.host}/ws`; | |
| ws = new WebSocket(url); | |
| ws.onopen = () => { | |
| connDot.className = 'status-dot connected'; | |
| connLabel.textContent = 'Connected'; | |
| }; | |
| ws.onclose = () => { | |
| connDot.className = 'status-dot error'; | |
| connLabel.textContent = 'Disconnected'; | |
| toast('Connection lost. Refresh to reconnect.', 'error', 8000); | |
| }; | |
| ws.onerror = () => { | |
| connDot.className = 'status-dot error'; | |
| connLabel.textContent = 'Error'; | |
| }; | |
| ws.onmessage = (evt) => { | |
| let data; | |
| try { data = JSON.parse(evt.data); } catch { return; } | |
| handleMessage(data); | |
| }; | |
| // Keep-alive ping every 25 s | |
| setInterval(() => { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({ type: 'ping' })); | |
| } | |
| }, 25000); | |
| } | |
| function sendWS(obj) { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify(obj)); | |
| } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| MESSAGE HANDLER | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function handleMessage(data) { | |
| switch (data.type) { | |
| case 'init': | |
| onInit(data); | |
| break; | |
| case 'countdown': | |
| onCountdown(data); | |
| break; | |
| case 'race_start': | |
| onRaceStart(data); | |
| break; | |
| case 'player_joined': | |
| onPlayerJoined(data.player); | |
| break; | |
| case 'player_left': | |
| onPlayerLeft(data); | |
| break; | |
| case 'player_update': | |
| onPlayerUpdate(data.player); | |
| break; | |
| case 'player_finished': | |
| onPlayerFinished(data); | |
| break; | |
| case 'race_end': | |
| onRaceEnd(data); | |
| break; | |
| case 'disqualified': | |
| toast('β οΈ ' + data.message, 'error', 8000); | |
| typingInput.disabled = true; | |
| state.raceActive = false; | |
| break; | |
| case 'chat': | |
| appendChat(data.name, data.message); | |
| break; | |
| case 'pong': | |
| break; // silence | |
| } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| INIT (joined room, told who I am) | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function onInit(data) { | |
| state.myId = data.player_id; | |
| state.myName = data.name; | |
| state.myColor = data.color; | |
| state.roomId = data.room_id; | |
| myNameBadge.textContent = 'π€ ' + state.myName; | |
| roomBadge.textContent = 'πͺ Room: ' + state.room_id; | |
| roomBadge.style.display = 'flex'; | |
| overlaySub.textContent = `You joined as ${state.myName}. Waiting for raceβ¦`; | |
| // Initialise existing players | |
| data.players.forEach(p => ensurePlayer(p)); | |
| updatePlayersBar(); | |
| updateWaitingList(); | |
| if (data.room_state === 'racing') { | |
| // We joined mid-race | |
| if (data.text) onRaceStart({ text: data.text, players: data.players }); | |
| } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| COUNTDOWN | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function onCountdown(data) { | |
| overlayLoader.style.display = 'none'; | |
| overlayCD.style.display = 'block'; | |
| overlaySub.textContent = data.message; | |
| overlayCD.textContent = data.seconds; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| RACE START | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function onRaceStart(data) { | |
| // Update players from payload | |
| if (data.players) data.players.forEach(p => ensurePlayer(p)); | |
| state.raceText = data.text; | |
| state.words = data.text.split(' '); | |
| state.wordIndex = 0; | |
| state.charIndex = 0; | |
| state.totalCharsTyped = 0; | |
| state.errorCount = 0; | |
| state.startTime = null; | |
| state.raceActive = true; | |
| state.raceEnded = false; | |
| // Hide overlay | |
| overlay.style.display = 'none'; | |
| // Build paragraph DOM | |
| buildParagraphDOM(state.words); | |
| // Enable input | |
| typingInput.disabled = false; | |
| typingInput.value = ''; | |
| typingInput.placeholder = 'Start typingβ¦'; | |
| typingInput.focus(); | |
| // Start client timer | |
| state.timerInterval = setInterval(updateTimer, 500); | |
| // Start sending progress every 500ms | |
| state.sendInterval = setInterval(sendProgress, 500); | |
| updatePlayersBar(); | |
| rebuildRaceTrack(); | |
| toast('π Race started! Go go go!', 'success', 2500); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| PARAGRAPH DOM BUILDER | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function buildParagraphDOM(words) { | |
| paraDisplay.innerHTML = ''; | |
| words.forEach((word, wi) => { | |
| const wordSpan = document.createElement('span'); | |
| wordSpan.className = 'word'; | |
| wordSpan.dataset.wi = wi; | |
| word.split('').forEach((ch, ci) => { | |
| const s = document.createElement('span'); | |
| s.className = 'char pending'; | |
| s.dataset.ci = ci; | |
| s.textContent = ch; | |
| wordSpan.appendChild(s); | |
| }); | |
| paraDisplay.appendChild(wordSpan); | |
| // Space between words (except last) | |
| if (wi < words.length - 1) { | |
| const sp = document.createElement('span'); | |
| sp.className = 'char pending space-char'; | |
| sp.dataset.wi = wi; | |
| sp.dataset.ci = word.length; | |
| sp.textContent = ' '; | |
| paraDisplay.appendChild(sp); | |
| } | |
| }); | |
| // Highlight first char | |
| highlightCurrent(); | |
| } | |
| function getCharEl(wi, ci) { | |
| const wEl = paraDisplay.querySelector(`.word[data-wi="${wi}"]`); | |
| if (!wEl) return null; | |
| return wEl.querySelector(`[data-ci="${ci}"]`); | |
| } | |
| function getSpaceEl(wi) { | |
| return paraDisplay.querySelector(`.space-char[data-wi="${wi}"]`); | |
| } | |
| function highlightCurrent() { | |
| // Remove existing current highlights | |
| paraDisplay.querySelectorAll('.char.current').forEach(el => { | |
| el.classList.remove('current'); | |
| }); | |
| if (state.wordIndex >= state.words.length) return; | |
| const word = state.words[state.wordIndex]; | |
| if (state.charIndex < word.length) { | |
| const el = getCharEl(state.wordIndex, state.charIndex); | |
| if (el) el.classList.add('current'); | |
| } else { | |
| // Cursor on space | |
| const sp = getSpaceEl(state.wordIndex); | |
| if (sp) sp.classList.add('current'); | |
| } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| TYPING ENGINE | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| typingInput.addEventListener('keydown', (e) => { | |
| if (!state.raceActive || state.raceEnded) return; | |
| // Start timer on first keystroke | |
| if (!state.startTime) state.startTime = Date.now(); | |
| }); | |
| typingInput.addEventListener('input', () => { | |
| if (!state.raceActive || state.raceEnded) return; | |
| if (!state.startTime) state.startTime = Date.now(); | |
| const typed = typingInput.value; | |
| const word = state.words[state.wordIndex]; | |
| // ββ Space key pressed β advance to next word ββ | |
| if (typed.endsWith(' ')) { | |
| const attempt = typed.trimEnd(); | |
| if (attempt === word) { | |
| // Correct word: mark all chars green + space green | |
| word.split('').forEach((_, ci) => { | |
| const el = getCharEl(state.wordIndex, ci); | |
| if (el) { el.classList.remove('pending','wrong','current'); el.classList.add('correct'); } | |
| }); | |
| const sp = getSpaceEl(state.wordIndex); | |
| if (sp) { sp.classList.remove('pending','current'); sp.classList.add('correct'); } | |
| state.totalCharsTyped += word.length + 1; | |
| state.wordIndex++; | |
| state.charIndex = 0; | |
| typingInput.value = ''; | |
| typingInput.className = ''; | |
| if (state.wordIndex >= state.words.length) { | |
| finishRace(); | |
| return; | |
| } | |
| highlightCurrent(); | |
| updateStats(); | |
| } else { | |
| // Wrong word on space β shake, keep in place | |
| typingInput.classList.add('wrong-word'); | |
| state.errorCount++; | |
| } | |
| return; | |
| } | |
| // ββ Character-level highlight ββ | |
| typingInput.classList.remove('wrong-word'); | |
| const word2 = state.words[state.wordIndex]; | |
| let allCorrect = true; | |
| for (let ci = 0; ci < typed.length; ci++) { | |
| const charEl = getCharEl(state.wordIndex, ci); | |
| if (!charEl) continue; | |
| charEl.classList.remove('pending','correct','wrong','current'); | |
| if (ci < word2.length && typed[ci] === word2[ci]) { | |
| charEl.classList.add('correct'); | |
| } else { | |
| charEl.classList.add('wrong'); | |
| allCorrect = false; | |
| } | |
| } | |
| // Remaining chars in this word β pending | |
| for (let ci = typed.length; ci < word2.length; ci++) { | |
| const charEl = getCharEl(state.wordIndex, ci); | |
| if (charEl) { charEl.classList.remove('correct','wrong','current'); charEl.classList.add('pending'); } | |
| } | |
| state.charIndex = typed.length; | |
| typingInput.className = typed.length > 0 ? (allCorrect ? 'correct-word' : 'wrong-word') : ''; | |
| highlightCurrent(); | |
| updateStats(); | |
| }); | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| STATS UPDATE | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function updateStats() { | |
| const elapsed = state.startTime ? (Date.now() - state.startTime) / 60000 : 0.0001; | |
| const charsTyped = state.totalCharsTyped; | |
| const wpm = elapsed > 0 ? Math.round((charsTyped / 5) / elapsed) : 0; | |
| const totalChars = state.raceText.length; | |
| const progress = Math.min((state.totalCharsTyped / totalChars) * 100, 100); | |
| const totalTyped = charsTyped + state.errorCount; | |
| const accuracy = totalTyped > 0 ? Math.round((charsTyped / totalTyped) * 100) : 100; | |
| state.lastWPM = wpm; | |
| state.lastProgress = progress; | |
| statWPM.textContent = wpm; | |
| statAcc.textContent = accuracy + '%'; | |
| statProg.textContent = Math.round(progress) + '%'; | |
| myProgressFill.style.width = progress + '%'; | |
| // Update my car position | |
| updateCarPosition(state.myId, progress); | |
| } | |
| function updateTimer() { | |
| if (!state.startTime || !state.raceActive) return; | |
| const secs = Math.floor((Date.now() - state.startTime) / 1000); | |
| statTime.textContent = secs + 's'; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| SEND PROGRESS TO SERVER | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function sendProgress() { | |
| if (!state.raceActive) return; | |
| sendWS({ | |
| type: 'progress', | |
| progress: state.lastProgress, | |
| wpm: state.lastWPM, | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| FINISH | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function finishRace() { | |
| state.raceActive = false; | |
| typingInput.disabled = true; | |
| typingInput.placeholder = 'You finished! π'; | |
| clearInterval(state.timerInterval); | |
| clearInterval(state.sendInterval); | |
| // Send final 100% immediately | |
| sendWS({ type: 'progress', progress: 100, wpm: state.lastWPM }); | |
| updateCarPosition(state.myId, 100); | |
| toast('π You finished the race!', 'success', 5000); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| PLAYER MANAGEMENT | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function ensurePlayer(p) { | |
| if (!state.players[p.id]) { | |
| state.players[p.id] = { | |
| name: p.name, | |
| color: p.color, | |
| progress: p.progress || 0, | |
| wpm: p.wpm || 0, | |
| finished: p.finished || false, | |
| finish_pos: p.finish_pos || 0, | |
| el: null, | |
| }; | |
| } else { | |
| // Merge updates | |
| Object.assign(state.players[p.id], { | |
| name: p.name, | |
| color: p.color, | |
| progress: p.progress || state.players[p.id].progress, | |
| wpm: p.wpm || state.players[p.id].wpm, | |
| }); | |
| } | |
| } | |
| function onPlayerJoined(p) { | |
| ensurePlayer(p); | |
| updatePlayersBar(); | |
| updateWaitingList(); | |
| rebuildRaceTrack(); | |
| toast(`π€ ${p.name} joined the lobby`, 'info'); | |
| } | |
| function onPlayerLeft(data) { | |
| const p = state.players[data.player_id]; | |
| if (p) { | |
| // Remove lane if exists | |
| if (p.el && p.el.lane) p.el.lane.remove(); | |
| delete state.players[data.player_id]; | |
| } | |
| updatePlayersBar(); | |
| toast(`β ${data.name} left the race`, 'warning'); | |
| } | |
| function onPlayerUpdate(player) { | |
| if (!state.players[player.id]) return; | |
| state.players[player.id].progress = player.progress; | |
| state.players[player.id].wpm = player.wpm; | |
| state.players[player.id].finished = player.finished; | |
| // Update car on track | |
| updateCarPosition(player.id, player.progress); | |
| // Update WPM badge above car | |
| const p = state.players[player.id]; | |
| if (p.el && p.el.wpmEl) { | |
| p.el.wpmEl.textContent = player.wpm + ' WPM'; | |
| } | |
| // Update players bar chip | |
| const chip = playersBar.querySelector(`[data-pid="${player.id}"]`); | |
| if (chip) { | |
| const wpmEl = chip.querySelector('.chip-wpm'); | |
| if (wpmEl) wpmEl.textContent = player.wpm + ' WPM'; | |
| } | |
| } | |
| function onPlayerFinished(data) { | |
| if (state.players[data.player_id]) { | |
| state.players[data.player_id].finished = true; | |
| state.players[data.player_id].finish_pos = data.finish_pos; | |
| } | |
| const medals = { 1: 'π₯', 2: 'π₯', 3: 'π₯' }; | |
| const medal = medals[data.finish_pos] || `${data.finish_pos}${data.suffix}`; | |
| toast(`${medal} ${data.player_name} finished! (${data.wpm} WPM)`, 'success', 4000); | |
| } | |
| function onRaceEnd(data) { | |
| state.raceEnded = true; | |
| state.raceActive = false; | |
| clearInterval(state.timerInterval); | |
| clearInterval(state.sendInterval); | |
| typingInput.disabled = true; | |
| // Build results | |
| resultsList.innerHTML = ''; | |
| data.results.forEach((p, i) => { | |
| const isMe = p.id === state.myId; | |
| const row = document.createElement('div'); | |
| row.className = `result-row${isMe ? ' me' : ''}`; | |
| let posText = p.finish_pos; | |
| let posCls = ''; | |
| if (p.disqualified) { posText = 'π«'; } | |
| else if (!p.finished) { posText = 'DNF'; } | |
| else if (i === 0) { posCls = 'first'; } | |
| else if (i === 1) { posCls = 'second'; } | |
| else if (i === 2) { posCls = 'third'; } | |
| row.innerHTML = ` | |
| <div class="result-pos ${posCls}">${posText}</div> | |
| <div class="result-car-dot" style="background:${p.color.body}"></div> | |
| <div class="result-name">${p.name}${isMe ? ' <em>(you)</em>' : ''}</div> | |
| <div class="result-wpm">${p.wpm} WPM</div> | |
| `; | |
| resultsList.appendChild(row); | |
| }); | |
| setTimeout(() => { resultsModal.style.display = 'flex'; }, 1200); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| RACE TRACK (build lanes) | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function rebuildRaceTrack() { | |
| raceTrack.innerHTML = ''; | |
| Object.entries(state.players).forEach(([pid, p]) => { | |
| const lane = document.createElement('div'); | |
| lane.className = 'lane'; | |
| lane.dataset.pid = pid; | |
| // Label | |
| const label = document.createElement('div'); | |
| label.className = 'lane-label'; | |
| label.textContent = pid === state.myId ? 'β ' + p.name : p.name; | |
| lane.appendChild(label); | |
| // Finish flag | |
| const flag = document.createElement('div'); | |
| flag.className = 'finish-flag'; | |
| flag.textContent = 'π'; | |
| lane.appendChild(flag); | |
| // Progress background | |
| const progBg = document.createElement('div'); | |
| progBg.className = 'lane-progress-bg'; | |
| const progFill = document.createElement('div'); | |
| progFill.className = 'lane-progress-fill'; | |
| progFill.style.background = `linear-gradient(90deg, ${p.color.body}, ${p.color.stripe})`; | |
| progFill.style.width = (p.progress || 0) + '%'; | |
| progBg.appendChild(progFill); | |
| lane.appendChild(progBg); | |
| // Car wrapper | |
| const carWrap = document.createElement('div'); | |
| carWrap.className = 'car-wrapper'; | |
| const wpmBadge = document.createElement('div'); | |
| wpmBadge.className = 'car-wpm'; | |
| wpmBadge.textContent = (p.wpm || 0) + ' WPM'; | |
| const carSVGWrap = document.createElement('div'); | |
| carSVGWrap.className = 'car-svg-wrap'; | |
| carSVGWrap.innerHTML = makeCarSVG(p.color); | |
| carWrap.appendChild(wpmBadge); | |
| carWrap.appendChild(carSVGWrap); | |
| lane.appendChild(carWrap); | |
| // Store DOM refs | |
| p.el = { lane, fill: progFill, car: carWrap, wpmEl: wpmBadge }; | |
| raceTrack.appendChild(lane); | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| UPDATE CAR POSITION | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function updateCarPosition(pid, progress) { | |
| const p = state.players[pid]; | |
| if (!p || !p.el) return; | |
| // Track available width for car travel | |
| const trackWidth = raceTrack.offsetWidth || 800; | |
| const labelWidth = 90; // left reserved | |
| const rightPad = 52; // flag area | |
| const carWidth = 72; | |
| const travelW = trackWidth - labelWidth - rightPad - carWidth; | |
| const px = (Math.min(progress, 100) / 100) * travelW; | |
| p.el.car.style.transform = `translateY(-50%) translateX(${px}px)`; | |
| p.el.fill.style.width = Math.min(progress, 100) + '%'; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| PLAYERS BAR | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function updatePlayersBar() { | |
| playersBar.innerHTML = ''; | |
| Object.entries(state.players).forEach(([pid, p]) => { | |
| const chip = document.createElement('div'); | |
| chip.className = `player-chip${pid === state.myId ? ' me' : ''}`; | |
| chip.dataset.pid = pid; | |
| const dot = document.createElement('div'); | |
| dot.className = 'dot'; | |
| dot.style.background = p.color.body || '#aaa'; | |
| chip.innerHTML = ` | |
| <div class="dot" style="background:${p.color.body}"></div> | |
| <span>${pid === state.myId ? 'β ' : ''}${p.name}</span> | |
| <span class="chip-wpm" style="color:${p.color.body};font-size:0.75rem">${p.wpm || 0} WPM</span> | |
| `; | |
| playersBar.appendChild(chip); | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| WAITING LIST (overlay) | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function updateWaitingList() { | |
| waitingList.innerHTML = ''; | |
| Object.entries(state.players).forEach(([pid, p]) => { | |
| const chip = document.createElement('div'); | |
| chip.className = 'waiting-player-chip'; | |
| chip.innerHTML = ` | |
| <div style="width:10px;height:10px;border-radius:50%;background:${p.color.body}"></div> | |
| <span>${p.name}${pid === state.myId ? ' (you)' : ''}</span> | |
| `; | |
| waitingList.appendChild(chip); | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| CHAT | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function appendChat(name, msg) { | |
| const el = document.createElement('div'); | |
| el.className = 'chat-msg'; | |
| el.innerHTML = `<strong>${escapeHTML(name)}:</strong> ${escapeHTML(msg)}`; | |
| chatMessages.appendChild(el); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| function escapeHTML(str) { | |
| return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| $('chat-send').addEventListener('click', sendChat); | |
| chatInput.addEventListener('keydown', e => { if (e.key === 'Enter') sendChat(); }); | |
| function sendChat() { | |
| const msg = chatInput.value.trim(); | |
| if (!msg) return; | |
| sendWS({ type: 'chat', message: msg }); | |
| appendChat(state.myName || 'You', msg); | |
| chatInput.value = ''; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| WINDOW RESIZE β reposition cars | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| window.addEventListener('resize', () => { | |
| Object.entries(state.players).forEach(([pid, p]) => { | |
| updateCarPosition(pid, p.progress || 0); | |
| }); | |
| }); | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| START | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| connectWS(); | |
| </script> | |
| </body> | |
| </html> |