mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): initial implementation
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:
1. Base infrastructure
- cmd/poc-server HTTP+WebSocket server, /health, /stats
- internal/transport wire types (Envelope + all message types)
- internal/catalog, internal/realtime, internal/stats
- internal/storage/postgres with migrations 001-004
- .env, .env.example, docker-compose, Dockerfile, scripts/
2. Swagger documentation
- go get github.com/swaggo/http-swagger
- swag init produces docs/docs.go
- /swagger/index.html UI, /swagger/doc.json spec
- annotations on health/stats/ws and catalog handlers
3. Races API
- internal/races/{store,service,keyset,types}.go
- migration 005_races.sql: finished_races, race_plans, race_queue
- GET /api/races with keyset pagination, status filter
- GET /api/races/upcoming
- POST /api/races/queue/join, GET, DELETE
- POST/GET /api/races/plans, DELETE /{id}
- lobby.RaceMeta simplification (no host_id/host_name)
4. Races seeder
- internal/races/seed with deterministic generator
- --seed-races / --reset CLI flags in main.go
- 30 finished + 5 live + 5 plans + 4 queue entries
5. Drivers and clans
- internal/drivers, internal/clans (CRUD, validation)
- migration 008_drivers_clans.sql
- /api/clans and /api/drivers endpoints
- 3-letter uppercase nickname/tag validation
- lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag
6. Podium
- internal/transport.RacePodiumEntry
- lobby.SetDriverProfile for in-memory metadata sync
- migration 007_podium.sql (podium JSONB column)
- seeder populates top-3 per finished race
7. Live races persistence
- migration 009_live_persistence.sql: live_races,
live_race_drivers, lobby_drivers
- internal/races/live_store.go: LiveStore with write-side
mirror for lobby.Service mutations
- Service.collectLive and Upcoming read from Postgres
- main.go RestoreFromDB rehydrates lobby on startup
8. Unify live + finished into one races table
- migration 010_unify_races.sql: rename finished_races to
races, expand status CHECK, merge live rows
- PgStore now hosts both write paths; LiveStore is a
thin facade implementing lobby.Persistence
- seeder resetAll drops only finished/cancelled rows and
race_plans / race_queue / lobby_drivers
Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# x0gp · PoC web client
|
||||
|
||||
Минимальный vanilla JS клиент для проверки PoC-сервера из этой же папки.
|
||||
Без сборки, без зависимостей — открывается в браузере.
|
||||
|
||||
## Запуск
|
||||
|
||||
Вариант А — открыть файл напрямую:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
start web/index.html
|
||||
|
||||
# Linux
|
||||
xdg-open web/index.html
|
||||
|
||||
# macOS
|
||||
open web/index.html
|
||||
```
|
||||
|
||||
> Некоторые браузеры не разрешают `WebSocket` к чужим origin со страницы,
|
||||
> открытой по `file://`. Если что — поднимите мини-сервер (вариант Б).
|
||||
|
||||
Вариант Б — раздать через тот же poc-server:
|
||||
|
||||
В `cmd/poc-server/main.go` есть TODO — добавить `http.FileServer` для `web/`.
|
||||
После этого клиент будет доступен по `http://localhost:8080/`.
|
||||
|
||||
Вариант В — `python -m http.server`:
|
||||
|
||||
```bash
|
||||
cd server/web
|
||||
python -m http.server 5173
|
||||
# открыть http://localhost:5173/
|
||||
```
|
||||
|
||||
## Подключение к серверу
|
||||
|
||||
1. Запустите `poc-server` (см. корневой `server/README.md`).
|
||||
2. В UI клиента проверьте `WS URL` — по умолчанию `ws://localhost:8080/ws`.
|
||||
3. Укажите `driver_id` (например `driver-1`).
|
||||
4. **Connect** → **Join race**.
|
||||
5. Управление:
|
||||
- `W` / `↑` — газ
|
||||
- `S` / `↓` — тормоз
|
||||
- `A` / `←` — руль влево
|
||||
- `D` / `→` — руль вправо
|
||||
- `Space` — ручник
|
||||
6. На canvas появится ваша машинка (синяя) и машинки других подключённых
|
||||
драйверов (оранжевые).
|
||||
|
||||
## Что смотреть
|
||||
|
||||
- **tick Hz** в верхней панели — частота приходящих `race_snapshot`.
|
||||
Должно держаться ~30 Hz.
|
||||
- **rtt** — round-trip ping/pong, в PoC без сети это 0–2 ms.
|
||||
- **seq** — растёт с каждым отправленным сообщением.
|
||||
- **Телеметрия** — speed/heading/lap/sector вашей машинки.
|
||||
- **Лог** — все входящие/исходящие сообщения (кроме ping/pong).
|
||||
|
||||
## Что PoC делает
|
||||
|
||||
- Открывает WebSocket, шлёт `client_hello`.
|
||||
- По кнопке— `join_race` / `leave_race`.
|
||||
- Каждые 33 ms (≈30 Hz) шлёт `input_state` (throttle/brake/steer/handbrake).
|
||||
- Каждые 1000 ms шлёт `ping` для измерения RTT.
|
||||
- Получает `race_snapshot` и рисует все машинки на канвасе.
|
||||
- Получает `input_ack`, `race_event`, `correction`, `error` и логирует их.
|
||||
|
||||
## Что PoC НЕ делает
|
||||
|
||||
- Нет предиктивного сглаживания (interpolation/extrapolation) — увидите
|
||||
«дёрганье», это нормально для PoC.
|
||||
- Нет клиент-сайд предикции — добавим, когда появится настоящая модель физики.
|
||||
- Нет тач-управления для мобильных — клавиатура только.
|
||||
- Нет авторизации, биллинга, чата — это всё вне PoC.
|
||||
|
||||
## Что дальше
|
||||
|
||||
- Когда `proto/` будет сгенерирован в Go и JS — заменим JSON на protobuf.
|
||||
- Подключим `gRPC-Web` или бинарный WS-фрейминг.
|
||||
- Добавим предиктивный слой и интерполяцию.
|
||||
- Добавим мобильный layout (touch steering + pedals).
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>x0gp · PoC client</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">x0gp</span>
|
||||
<span class="tag">PoC client</span>
|
||||
</div>
|
||||
<div class="conn" id="connState">disconnected</div>
|
||||
<div class="hud-top" id="hudTop">
|
||||
<span>tick: <b id="tickHz">--</b> Hz</span>
|
||||
<span>rtt: <b id="rtt">--</b> ms</span>
|
||||
<span>seq: <b id="seq">0</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<!-- Левая колонка: трасса -->
|
||||
<section class="track-wrap">
|
||||
<canvas id="track" width="640" height="420"></canvas>
|
||||
<div class="legend">
|
||||
<span><i class="dot me"></i> вы</span>
|
||||
<span><i class="dot other"></i> другие</span>
|
||||
<span><i class="dot ghost"></i> correction (если будет)</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Правая колонка: управление и телеметрия -->
|
||||
<aside class="controls">
|
||||
<div class="card">
|
||||
<h3>Подключение</h3>
|
||||
<label>WS URL
|
||||
<input id="wsUrl" type="text" value="ws://localhost:8080/ws" />
|
||||
</label>
|
||||
<label>Ник / driver_id
|
||||
<input id="driverId" type="text" placeholder="driver-1" value="driver-1" />
|
||||
</label>
|
||||
<label>Race ID
|
||||
<input id="raceId" type="text" value="demo-race" />
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button id="btnConnect" class="btn primary">Connect</button>
|
||||
<button id="btnDisconnect" class="btn" disabled>Disconnect</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="btnJoin" class="btn" disabled>Join race</button>
|
||||
<button id="btnLeave" class="btn" disabled>Leave race</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Управление</h3>
|
||||
<p class="hint">
|
||||
W/S — газ/тормоз, A/D — руль, Space — ручник, R — сброс позиции (debug)
|
||||
</p>
|
||||
<div class="pedals">
|
||||
<div class="bar">
|
||||
<span>throttle</span>
|
||||
<div class="bar-track"><div id="barThrottle" class="bar-fill green"></div></div>
|
||||
<b id="lblThrottle">0.00</b>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<span>brake</span>
|
||||
<div class="bar-track"><div id="barBrake" class="bar-fill red"></div></div>
|
||||
<b id="lblBrake">0.00</b>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<span>steer</span>
|
||||
<div class="bar-track"><div id="barSteer" class="bar-fill blue"></div></div>
|
||||
<b id="lblSteer">0.00</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wheel">
|
||||
<div class="wheel-disc" id="wheelDisc"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Телеметрия</h3>
|
||||
<dl class="tel">
|
||||
<dt>speed</dt><dd id="tSpeed">0.00 m/s</dd>
|
||||
<dt>heading</dt><dd id="tHeading">0.0°</dd>
|
||||
<dt>lap</dt><dd id="tLap">0</dd>
|
||||
<dt>sector</dt><dd id="tSector">-</dd>
|
||||
<dt>pos</dt><dd id="tPos">(0.00, 0.00)</dd>
|
||||
<dt>server_time</dt><dd id="tTsm">--</dd>
|
||||
<dt>cars in race</dt><dd id="tCars">0</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card log-card">
|
||||
<h3>Лог</h3>
|
||||
<pre id="log" class="log"></pre>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,468 @@
|
||||
// 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'),
|
||||
};
|
||||
|
||||
// -------- 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 },
|
||||
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;
|
||||
|
||||
// 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)`;
|
||||
}
|
||||
|
||||
// -------- 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);
|
||||
});
|
||||
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);
|
||||
|
||||
// -------- Boot --------
|
||||
setConn('disconnected', '');
|
||||
log('x0gp PoC client ready. Press Connect.', 'meta');
|
||||
})();
|
||||
@@ -0,0 +1,267 @@
|
||||
/* x0gp · PoC web client stylesheet
|
||||
Минималистичный тёмный UI. Без зависимостей, без сборки. */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #0e1117;
|
||||
color: #e6edf3;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 10px 16px;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
.brand .logo {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: #58a6ff;
|
||||
font-size: 18px;
|
||||
}
|
||||
.brand .tag {
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
background: #1f6feb;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.conn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: #6e7681;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.conn.connected { background: #2ea043; }
|
||||
.conn.connecting { background: #d29922; }
|
||||
.conn.error { background: #f85149; }
|
||||
|
||||
.hud-top {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.hud-top b { color: #e6edf3; font-weight: 600; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Track canvas */
|
||||
.track-wrap {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.track-wrap canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
background: #0a0d12;
|
||||
border-radius: 4px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.legend i.dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot.me { background: #58a6ff; }
|
||||
.dot.other { background: #f0883e; }
|
||||
.dot.ghost { background: #d29922; }
|
||||
|
||||
/* Controls aside */
|
||||
.controls { display: flex; flex-direction: column; gap: 12px; }
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.card label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card input[type=text] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
background: #0e1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
color: #e6edf3;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card input[type=text]:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
color: #e6edf3;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #30363d; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn.primary { background: #238636; border-color: #2ea043; }
|
||||
.btn.primary:hover:not(:disabled) { background: #2ea043; }
|
||||
|
||||
.hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* Pedals / bars */
|
||||
.pedals { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bar {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 44px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar-track {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
background: #0e1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
left: 50%;
|
||||
width: 0%;
|
||||
transition: left 60ms linear, width 60ms linear;
|
||||
}
|
||||
.bar-fill.green { background: #2ea043; }
|
||||
.bar-fill.red { background: #f85149; }
|
||||
.bar-fill.blue { background: #58a6ff; }
|
||||
.bar b {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #e6edf3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Steering wheel visual */
|
||||
.wheel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.wheel-disc {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at center, #30363d 0%, #21262d 70%);
|
||||
border: 2px solid #484f58;
|
||||
position: relative;
|
||||
transition: transform 80ms linear;
|
||||
}
|
||||
.wheel-disc::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: #f0883e;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Telemetry */
|
||||
.tel {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 4px 12px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tel dt { color: #8b949e; }
|
||||
.tel dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
/* Log */
|
||||
.log-card .log {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background: #0a0d12;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
height: 160px;
|
||||
overflow-y: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #c9d1d9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.log .l-info { color: #58a6ff; }
|
||||
.log .l-warn { color: #d29922; }
|
||||
.log .l-error { color: #f85149; }
|
||||
.log .l-rx { color: #2ea043; }
|
||||
.log .l-tx { color: #f0883e; }
|
||||
.log .l-meta { color: #8b949e; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user