// x0gp · PoC web client (vanilla JS) // Протокол совпадает с internal/transport/codec.go (envelope + payload). // В PoC сервер говорит по JSON — потом заменим на binary protobuf. (function () { 'use strict'; // -------- DOM -------- const $ = (id) => document.getElementById(id); const ui = { wsUrl: $('wsUrl'), driverId: $('driverId'), raceId: $('raceId'), btnConnect: $('btnConnect'), btnDisconnect: $('btnDisconnect'), btnJoin: $('btnJoin'), btnLeave: $('btnLeave'), connState: $('connState'), tickHz: $('tickHz'), rtt: $('rtt'), seq: $('seq'), canvas: $('track'), log: $('log'), barThrottle: $('barThrottle'), barBrake: $('barBrake'), barSteer: $('barSteer'), lblThrottle: $('lblThrottle'), lblBrake: $('lblBrake'), lblSteer: $('lblSteer'), wheelDisc: $('wheelDisc'), tSpeed: $('tSpeed'), tHeading: $('tHeading'), tLap: $('tLap'), tSector: $('tSector'), tPos: $('tPos'), tTsm: $('tTsm'), tCars: $('tCars'), lblGear: $('lblGear'), lblDrs: $('lblDrs'), lblPit: $('lblPit'), btnDrs: $('btnDrs'), btnPit: $('btnPit'), btnGearUp: $('btnGearUp'), btnGearDown: $('btnGearDown'), ctrlJson: $('ctrlJson'), }; // -------- State -------- const state = { ws: null, connected: false, joined: false, mySessionId: null, myCarId: null, inputSeq: 0, inputSendAt: 0, lastPingAt: 0, lastPongRtt: 0, lastSnapshot: null, lastSnapshotAt: 0, snapshotCount: 0, snapshotWindow: [], keys: new Set(), input: { throttle: 0, brake: 0, steer: 0, handbrake: false, buttons: 0, gear: 0, drs: false, pitLimiter: false }, pingTimer: null, inputTimer: null, slotHint: 0, // сервер выдаёт слот; 0 по умолчанию }; const ctx = ui.canvas.getContext('2d'); const CANVAS_W = ui.canvas.width; const CANVAS_H = ui.canvas.height; // Простая кольцевая трасса для PoC (центр в (CANVAS_W/2, CANVAS_H/2)) const TRACK = { cx: CANVAS_W / 2, cy: CANVAS_H / 2, rx: 260, // внешний радиус по X ry: 150, // внешний радиус по Y lane: 40, // ширина трассы в пикселях }; // Коэффициент пересчёта: 1 метр = ~ 20 пикселей (для PoC) const M2PX = 20; // -------- Logging -------- function log(msg, kind) { const t = new Date().toLocaleTimeString(); const line = document.createElement('div'); line.className = 'l-' + (kind || 'info'); line.textContent = `[${t}] ${msg}`; ui.log.appendChild(line); ui.log.scrollTop = ui.log.scrollHeight; // обрезаем лог до 500 строк while (ui.log.childNodes.length > 500) ui.log.removeChild(ui.log.firstChild); } // -------- Helpers -------- function setConn(text, cls) { ui.connState.textContent = text; ui.connState.className = 'conn ' + (cls || ''); } function clamp(v, a, b) { return v < a ? a : v > b ? b : v; } function deadzone(v, d) { return Math.abs(v) < d ? 0 : v; } // -------- Input handling (keyboard) -------- // W/S — газ/тормоз; A/D — руль; Space — ручник; R — reset (debug) function pollInput() { const k = state.keys; // Throttle & brake — дискретные «клавиши», но мы шлём 1.0 при нажатой. // Для плавности можно отдавать ramp-rate — в PoC держим 0/1. const throttle = (k.has('w') || k.has('arrowup')) ? 1.0 : 0.0; const brake = (k.has('s') || k.has('arrowdown')) ? 1.0 : 0.0; // Steer — это «удержание» крайнего положения. 0 если ничего не нажато. let steer = 0.0; if (k.has('a') || k.has('arrowleft')) steer -= 1.0; if (k.has('d') || k.has('arrowright')) steer += 1.0; steer = clamp(steer, -1, 1); const handbrake = k.has(' '); state.input.throttle = throttle; state.input.brake = brake; state.input.steer = steer; state.input.handbrake = handbrake; // Calculate buttons bitmask: bit 0 (DRS), bit 1 (Pit Limiter) let buttons = 0; if (state.input.drs) buttons |= 1; if (state.input.pitLimiter) buttons |= 2; state.input.buttons = buttons; // Update UI bars ui.lblThrottle.textContent = throttle.toFixed(2); ui.lblBrake.textContent = brake.toFixed(2); ui.lblSteer.textContent = steer.toFixed(2); // bars: позитивная шкала; для руля — симметричная вокруг центра ui.barThrottle.style.left = '0%'; ui.barThrottle.style.width = (throttle * 100).toFixed(1) + '%'; ui.barBrake.style.left = '0%'; ui.barBrake.style.width = (brake * 100).toFixed(1) + '%'; // руль: центр = 50% const steerPct = (steer + 1) * 50; ui.barSteer.style.left = (steerPct - 1) + '%'; ui.barSteer.style.width = '2%'; ui.wheelDisc.style.transform = `rotate(${steer * 90}deg)`; // Update Gear, DRS, Pit Limiter UI text & class if elements exist if (ui.lblGear) { let gName = 'N'; if (state.input.gear === -1) gName = 'R'; if (state.input.gear === 1) gName = 'D'; ui.lblGear.textContent = gName; } if (ui.lblDrs) { ui.lblDrs.textContent = state.input.drs ? 'ON' : 'OFF'; ui.lblDrs.className = state.input.drs ? 'val active' : 'val'; } if (ui.lblPit) { ui.lblPit.textContent = state.input.pitLimiter ? 'ON' : 'OFF'; ui.lblPit.className = state.input.pitLimiter ? 'val active' : 'val'; } // Format control packet as JSON and show in UI if (ui.ctrlJson) { const steerByte = Math.round((steer + 1.0) * 100); const throttleByte = Math.round(throttle * 100); const brakeByte = Math.round(brake * 100); const gearByte = state.input.gear === -1 ? 0 : (state.input.gear === 1 ? 2 : 1); const jsonStr = JSON.stringify({ sent_json: { steering: steer, throttle: throttle, brake: brake, gear: state.input.gear, buttons: buttons }, binary_packet_bytes: { byte0_steer: `0x${steerByte.toString(16).toUpperCase().padStart(2, '0')} (${steerByte})`, byte1_throttle: `0x${throttleByte.toString(16).toUpperCase().padStart(2, '0')} (${throttleByte}%)`, byte2_brake: `0x${brakeByte.toString(16).toUpperCase().padStart(2, '0')} (${brakeByte}%)`, byte3_gear: `0x${gearByte.toString(16).toUpperCase().padStart(2, '0')} (${gearByte === 0 ? 'R' : (gearByte === 2 ? 'D' : 'N')})`, byte4_flags: `0x${buttons.toString(16).toUpperCase().padStart(2, '0')} (DRS=${state.input.drs ? 1 : 0}, Pit=${state.input.pitLimiter ? 1 : 0})` } }, null, 2); ui.ctrlJson.textContent = jsonStr; } } // -------- Send helpers -------- function sendEnvelope(env) { if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return; const data = JSON.stringify(env); try { state.ws.send(data); if (env.type !== 'ping') log('→ ' + env.type, 'tx'); } catch (e) { log('send error: ' + e.message, 'error'); } } function sendClientHello() { sendEnvelope({ type: 'client_hello', seq: ++state.inputSeq, tsMs: Date.now(), payload: { version: 'x0gp/0.1', token: '', locale: (navigator.language || 'en').slice(0, 8), client_id: '', }, }); } function sendJoinRace() { const name = ui.driverId.value.trim() || 'driver-anon'; sendEnvelope({ type: 'join_race', seq: ++state.inputSeq, tsMs: Date.now(), payload: { race_id: ui.raceId.value.trim() || 'demo-race', car_slot: state.slotHint, car_name: name, }, }); } function sendLeaveRace() { sendEnvelope({ type: 'leave_race', seq: ++state.inputSeq, tsMs: Date.now(), payload: {}, }); } function sendInputState() { if (!state.joined) return; state.inputSendAt = performance.now(); sendEnvelope({ type: 'input_state', seq: ++state.inputSeq, tsMs: Date.now(), payload: { steering: state.input.steer, throttle: state.input.throttle, brake: state.input.brake, gear: 1, buttons: state.input.buttons, }, }); } function sendPing() { state.lastPingAt = performance.now(); sendEnvelope({ type: 'ping', seq: ++state.inputSeq, tsMs: Date.now(), payload: { client_ts_ms: Date.now(), }, }); } // -------- Receive -------- function handleServerEnvelope(env) { switch (env.type) { case 'server_hello': { const p = env.payload || {}; state.mySessionId = p.session_id || p.sessionId; const cfg = p.config || {}; log('server hello: session=' + state.mySessionId + ' tick=' + (cfg.tick_rate_hz || '?') + 'Hz' + ' snapshot=' + (cfg.snapshot_rate_hz || '?') + 'Hz', 'meta'); break; } case 'race_snapshot': { handleSnapshot(env.payload); break; } case 'input_ack': { // сервер подтверждает вход по seq; можно использовать для RTT-оценки const seq = env.seq; log('input_ack seq=' + seq, 'rx'); break; } case 'pong': { const now = performance.now(); state.lastPongRtt = Math.round(now - state.lastPingAt); ui.rtt.textContent = String(state.lastPongRtt); break; } case 'race_event': { const p = env.payload || {}; log('event: ' + (p.eventType || '?') + ' ' + JSON.stringify(p), 'warn'); break; } case 'error': { log('server error: ' + JSON.stringify(env.payload), 'error'); break; } case 'correction': { log('correction: ' + JSON.stringify(env.payload), 'warn'); break; } default: log('unknown type: ' + env.type, 'warn'); } } function handleSnapshot(payload) { if (!payload) return; state.lastSnapshot = payload; state.lastSnapshotAt = performance.now(); state.snapshotCount++; const now = performance.now(); state.snapshotWindow.push(now); while (state.snapshotWindow.length && now - state.snapshotWindow[0] > 1000) { state.snapshotWindow.shift(); } ui.tickHz.textContent = String(state.snapshotWindow.length); ui.seq.textContent = String(env_seq_current()); ui.tTsm.textContent = String(payload.ts_ms || '--'); ui.tCars.textContent = String((payload.cars || []).length); // Найти свою машинку по driver_name (== driverId из формы) const myName = ui.driverId.value.trim(); const me = (payload.cars || []).find((c) => c.driver_name === myName); if (me) { state.myCarId = me.id; ui.tSpeed.textContent = (me.speed || 0).toFixed(2) + ' m/s'; ui.tHeading.textContent = ((me.heading || 0) * 180 / Math.PI).toFixed(1) + '°'; ui.tLap.textContent = String(me.lap || 0); ui.tSector.textContent = String(me.sector != null ? me.sector : '-'); ui.tPos.textContent = `(${(me.x || 0).toFixed(2)}, ${(me.y || 0).toFixed(2)})`; } drawSnapshot(payload); } function env_seq_current() { return state.inputSeq; } // -------- Rendering -------- function drawSnapshot(snap) { ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); // фон-сетка ctx.strokeStyle = '#1f2630'; ctx.lineWidth = 1; for (let x = 0; x < CANVAS_W; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CANVAS_H); ctx.stroke(); } for (let y = 0; y < CANVAS_H; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CANVAS_W, y); ctx.stroke(); } // Кольцевая трасса (овал). Внешний радиус = rx, ry; внутренний = rx-lane, ry-lane. ctx.lineWidth = TRACK.lane; ctx.strokeStyle = '#2d333b'; ctx.beginPath(); ctx.ellipse(TRACK.cx, TRACK.cy, TRACK.rx, TRACK.ry, 0, 0, Math.PI * 2); ctx.stroke(); // Центральная разметка ctx.lineWidth = 2; ctx.strokeStyle = '#484f58'; ctx.setLineDash([8, 8]); ctx.beginPath(); ctx.ellipse(TRACK.cx, TRACK.cy, TRACK.rx - TRACK.lane / 2, TRACK.ry - TRACK.lane / 2, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); // Стартовая линия ctx.strokeStyle = '#f0883e'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(TRACK.cx, TRACK.cy - TRACK.ry); ctx.lineTo(TRACK.cx, TRACK.cy - TRACK.ry + TRACK.lane); ctx.stroke(); // Машинки. Мировые координаты (метры) → пиксели: // центр трассы в (0,0), X — вправо, Y — вниз (как на канвасе). // В PoC считаем, что 1 метр = M2PX пикселей, начало трассы в (0,0). // TODO: после появления настоящей карты — масштабировать под неё. const myDriver = ui.driverId.value.trim(); for (const car of (snap.cars || [])) { const isMe = car.driver_name === myDriver; const px = TRACK.cx + (car.x || 0) * M2PX; const py = TRACK.cy + (car.y || 0) * M2PX; ctx.save(); ctx.translate(px, py); ctx.rotate(car.heading || 0); ctx.fillStyle = isMe ? '#58a6ff' : '#f0883e'; ctx.strokeStyle = '#0e1117'; ctx.lineWidth = 1; // корпус машинки ctx.fillRect(-10, -5, 20, 10); ctx.strokeRect(-10, -5, 20, 10); // направляющая полоска ctx.fillStyle = isMe ? '#a5c6ff' : '#ffc685'; ctx.fillRect(8, -1, 4, 2); ctx.restore(); // метка driver_name ctx.fillStyle = '#e6edf3'; ctx.font = '11px ui-monospace, Menlo, Consolas, monospace'; ctx.textAlign = 'center'; ctx.fillText((car.driver_name || '?') + (isMe ? ' ★' : ''), px, py - 14); } } // -------- Connection -------- function connect() { const url = ui.wsUrl.value.trim(); log('connecting to ' + url, 'info'); setConn('connecting…', 'connecting'); const ws = new WebSocket(url); state.ws = ws; ws.addEventListener('open', () => { state.connected = true; setConn('connected', 'connected'); log('ws open', 'info'); ui.btnConnect.disabled = true; ui.btnDisconnect.disabled = false; ui.btnJoin.disabled = false; sendClientHello(); // Timers: input 30Hz, ping 1Hz if (state.inputTimer) clearInterval(state.inputTimer); state.inputTimer = setInterval(() => { pollInput(); sendInputState(); }, 33); // ~30 Hz (snapshots — 30 Hz, чтобы не заливать канал) if (state.pingTimer) clearInterval(state.pingTimer); state.pingTimer = setInterval(sendPing, 1000); }); ws.addEventListener('message', (ev) => { let env; try { env = JSON.parse(ev.data); } catch (e) { log('bad json: ' + e.message, 'error'); return; } if (env.type !== 'pong') log('← ' + env.type, 'rx'); handleServerEnvelope(env); }); ws.addEventListener('close', (ev) => { state.connected = false; state.joined = false; setConn('disconnected', ''); log('ws close code=' + ev.code + ' reason=' + (ev.reason || ''), 'warn'); ui.btnConnect.disabled = false; ui.btnDisconnect.disabled = true; ui.btnJoin.disabled = true; ui.btnLeave.disabled = true; if (state.inputTimer) { clearInterval(state.inputTimer); state.inputTimer = null; } if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; } }); ws.addEventListener('error', (ev) => { log('ws error', 'error'); setConn('error', 'error'); }); } function disconnect() { if (state.ws) state.ws.close(1000, 'client-bye'); } function joinRace() { if (!state.connected) return; state.joined = true; ui.btnJoin.disabled = true; ui.btnLeave.disabled = false; sendJoinRace(); } function leaveRace() { if (!state.connected) return; state.joined = false; ui.btnJoin.disabled = false; ui.btnLeave.disabled = true; sendLeaveRace(); } // -------- Keyboard -------- function keyName(e) { if (e.key === ' ') return ' '; if (e.key === 'ArrowUp') return 'arrowup'; if (e.key === 'ArrowDown') return 'arrowdown'; if (e.key === 'ArrowLeft') return 'arrowleft'; if (e.key === 'ArrowRight') return 'arrowright'; return e.key.toLowerCase(); } window.addEventListener('keydown', (e) => { const k = keyName(e); // Не даём странице скроллить на стрелках/пробеле, если фокус на инпутах — пропускаем. const tag = (e.target && e.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault(); state.keys.add(k); // Toggle DRS, Pit Limiter, and shift gears if (k === 'q') { state.input.drs = !state.input.drs; pollInput(); } if (k === 'e') { state.input.pitLimiter = !state.input.pitLimiter; pollInput(); } if (k === 'z') { state.input.gear = Math.max(-1, state.input.gear - 1); pollInput(); } if (k === 'x') { state.input.gear = Math.min(1, state.input.gear + 1); pollInput(); } }); window.addEventListener('keyup', (e) => { state.keys.delete(keyName(e)); }); window.addEventListener('blur', () => state.keys.clear()); // -------- Buttons -------- ui.btnConnect.addEventListener('click', connect); ui.btnDisconnect.addEventListener('click', disconnect); ui.btnJoin.addEventListener('click', joinRace); ui.btnLeave.addEventListener('click', leaveRace); if (ui.btnDrs) { ui.btnDrs.addEventListener('click', () => { state.input.drs = !state.input.drs; pollInput(); }); } if (ui.btnPit) { ui.btnPit.addEventListener('click', () => { state.input.pitLimiter = !state.input.pitLimiter; pollInput(); }); } if (ui.btnGearUp) { ui.btnGearUp.addEventListener('click', () => { state.input.gear = Math.min(1, state.input.gear + 1); pollInput(); }); } if (ui.btnGearDown) { ui.btnGearDown.addEventListener('click', () => { state.input.gear = Math.max(-1, state.input.gear - 1); pollInput(); }); } // -------- Boot -------- setConn('disconnected', ''); log('x0gp PoC client ready. Press Connect.', 'meta'); })();