Client: minimal browser WebTransport client (HTML/JS)

- index.html with connect UI, canvas renderer, overlay
- protocol.js with header, QUIC varint, JOIN/INPUT builders, STATE_FULL parser
- client.js to connect, send JOIN, read datagrams, render snakes/apples; basic input handling
This commit is contained in:
Vladyslav Doloman
2025-10-07 22:49:51 +03:00
parent 088c36396b
commit 0ceea925cd
3 changed files with 353 additions and 0 deletions

163
client/client.js Normal file
View File

@@ -0,0 +1,163 @@
import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody } from './protocol.js';
const ui = {
url: document.getElementById('url'),
hash: document.getElementById('hash'),
name: document.getElementById('name'),
connect: document.getElementById('connect'),
status: document.getElementById('status'),
overlay: document.getElementById('overlay'),
canvas: document.getElementById('view'),
};
let transport = null;
let writer = null;
let seq = 0;
let lastServerSeq = 0;
let inputSeq = 0;
let playerId = null;
let colorId = null;
let fieldW = 60, fieldH = 40;
let tickRate = 10;
const snakes = new Map(); // id -> {len, head:[x,y], dirs:[]}
let apples = [];
function setStatus(t) { ui.status.textContent = t; }
function nextSeq() { seq = (seq + 1) & 0xffff; return seq; }
function colorForId(id) {
const palette = [
'#e6194B','#3cb44b','#ffe119','#4363d8','#f58231','#911eb4','#46f0f0','#f032e6',
'#bcf60c','#fabebe','#008080','#e6beff','#9A6324','#fffac8','#800000','#aaffc3',
'#808000','#ffd8b1','#000075','#808080','#ffffff','#000000'
];
return palette[id % palette.length];
}
function decodeSnakeCells(s) {
const cells = [];
let x = s.hx, y = s.hy;
cells.push([x, y]);
for (let d of s.dirs) {
if (d === Direction.UP) y -= 1;
else if (d === Direction.RIGHT) x += 1;
else if (d === Direction.DOWN) y += 1;
else if (d === Direction.LEFT) x -= 1;
cells.push([x, y]);
}
return cells;
}
function render() {
const ctx = ui.canvas.getContext('2d');
const W = ui.canvas.width = window.innerWidth;
const H = ui.canvas.height = window.innerHeight;
ctx.fillStyle = '#111'; ctx.fillRect(0,0,W,H);
const cell = Math.floor(Math.min(W / fieldW, H / fieldH));
const ox = Math.floor((W - cell*fieldW)/2);
const oy = Math.floor((H - cell*fieldH)/2);
// grid (optional)
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
for (let x=0;x<=fieldW;x++){ ctx.beginPath(); ctx.moveTo(ox + x*cell, oy); ctx.lineTo(ox + x*cell, oy + fieldH*cell); ctx.stroke(); }
for (let y=0;y<=fieldH;y++){ ctx.beginPath(); ctx.moveTo(ox, oy + y*cell); ctx.lineTo(ox + fieldW*cell, oy + y*cell); ctx.stroke(); }
// apples
ctx.fillStyle = '#f00';
for (const [ax, ay] of apples) {
ctx.fillRect(ox + ax*cell, oy + ay*cell, cell, cell);
}
// snakes
for (const [sid, s] of snakes) {
const cells = decodeSnakeCells({hx:s.hx, hy:s.hy, dirs:s.dirs});
ctx.fillStyle = colorForId(sid);
for (const [x,y] of cells) ctx.fillRect(ox + x*cell, oy + y*cell, cell, cell);
}
requestAnimationFrame(render);
}
async function readLoop() {
try {
for await (const datagram of transport.datagrams.readable) {
const dv = new DataView(datagram.buffer, datagram.byteOffset, datagram.byteLength);
if (dv.byteLength < 5) continue;
const type = dv.getUint8(1);
lastServerSeq = dv.getUint16(3);
const expectTick = (type === PacketType.STATE_FULL || type === PacketType.STATE_DELTA || type === PacketType.PART || type === PacketType.CONFIG_UPDATE);
const [ver, ptype, flags, seq, tick, off0] = parseHeader(dv, 0, expectTick);
let off = off0;
switch (ptype) {
case PacketType.JOIN_ACK: {
const info = parseJoinAck(dv, off);
playerId = info.playerId; colorId = info.colorId; fieldW = info.width; fieldH = info.height; tickRate = info.tickRate;
ui.overlay.textContent = 'connected — press space to join';
break;
}
case PacketType.JOIN_DENY: {
// read reason
const [len, p] = quicVarintDecode(dv, off); off = p;
const bytes = new Uint8Array(dv.buffer, dv.byteOffset + off, len);
const reason = new TextDecoder().decode(bytes);
setStatus('Join denied: ' + reason);
break;
}
case PacketType.STATE_FULL: {
const { snakes: sn, apples: ap } = parseStateFullBody(dv, off);
snakes.clear();
for (const s of sn) snakes.set(s.id, { len: s.len, hx: s.hx, hy: s.hy, dirs: s.dirs });
apples = ap;
ui.overlay.style.display = snakes.size ? 'none' : 'flex';
break;
}
case PacketType.STATE_DELTA: {
// Minimal: ignore detailed deltas for now; rely on periodic full (good enough for a demo)
break;
}
default: break;
}
}
} catch (e) {
setStatus('Read error: ' + e);
}
}
async function connectWT() {
const url = ui.url.value.trim();
const hashHex = ui.hash.value.trim();
setStatus('connecting...');
const opts = {};
if (hashHex) {
const bytes = new Uint8Array(hashHex.match(/.{1,2}/g).map(b => parseInt(b,16)));
opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: bytes }];
}
const wt = new WebTransport(url, opts);
await wt.ready;
transport = wt;
writer = wt.datagrams.writable.getWriter();
setStatus('connected');
readLoop();
requestAnimationFrame(render);
// Send JOIN immediately (spectator → player upon space handled below)
const pkt = buildJoin(1, nextSeq(), ui.name.value.trim());
await writer.write(pkt);
}
function dirFromKey(e) {
if (e.key === 'ArrowUp' || e.key === 'w') return Direction.UP;
if (e.key === 'ArrowRight' || e.key === 'd') return Direction.RIGHT;
if (e.key === 'ArrowDown' || e.key === 's') return Direction.DOWN;
if (e.key === 'ArrowLeft' || e.key === 'a') return Direction.LEFT;
return null;
}
async function onKey(e) {
const d = dirFromKey(e);
if (d == null) return;
if (!writer) return;
const pkt = buildInput(1, nextSeq(), lastServerSeq, (inputSeq = (inputSeq + 1) & 0xffff), 0, [{ rel: 0, dir: d }]);
try { await writer.write(pkt); } catch (err) { /* ignore */ }
}
ui.connect.onclick = () => { connectWT().catch(e => setStatus('connect failed: ' + e)); };
window.addEventListener('keydown', onKey);
window.addEventListener('resize', render);

30
client/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Multiplayer Snake (WebTransport)</title>
<style>
html, body { height: 100%; margin: 0; background: #111; color: #ddd; font-family: system-ui, sans-serif; }
#ui { position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.6); padding: 10px; border-radius: 6px; }
label { display: block; margin: 4px 0; font-size: 12px; }
input { width: 360px; }
#overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; color: rgba(255,255,255,0.85); font-size: 24px; font-weight: 600; }
canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; }
</style>
</head>
<body>
<div id="ui">
<label>Server URL (WebTransport): <input id="url" value="https://localhost:4433/"/></label>
<label>SHA-256 Cert Hash (hex, optional for self-signed): <input id="hash" placeholder="e.g., aabbcc..."/></label>
<label>Name: <input id="name" value="guest"/></label>
<button id="connect">Connect</button>
<span id="status"></span>
</div>
<div id="overlay">press space to join</div>
<canvas id="view"></canvas>
<script src="protocol.js"></script>
<script src="client.js"></script>
</body>
</html>

160
client/protocol.js Normal file
View File

@@ -0,0 +1,160 @@
// Minimal protocol helpers in JS (match server/protocol.py)
export const PacketType = {
JOIN: 0,
JOIN_ACK: 1,
JOIN_DENY: 2,
INPUT: 3,
INPUT_BROADCAST: 4,
STATE_DELTA: 5,
STATE_FULL: 6,
PART: 7,
CONFIG_UPDATE: 8,
PING: 9,
PONG: 10,
ERROR: 11,
};
export const Direction = { UP: 0, RIGHT: 1, DOWN: 2, LEFT: 3 };
export function quicVarintEncode(n) {
if (n <= 0x3f) return new Uint8Array([n & 0x3f]);
if (n <= 0x3fff) {
const v = 0x4000 | n;
return new Uint8Array([(v >> 8) & 0xff, v & 0xff]);
}
if (n <= 0x3fffffff) {
const v = 0x80000000 | n;
return new Uint8Array([(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff]);
}
throw new Error("varint too large for demo");
}
export function quicVarintDecode(view, offset) {
const first = view.getUint8(offset);
const prefix = first >> 6;
if (prefix === 0) return [first & 0x3f, offset + 1];
if (prefix === 1) return [view.getUint16(offset) & 0x3fff, offset + 2];
if (prefix === 2) return [view.getUint32(offset) & 0x3fffffff, offset + 4];
throw new Error("8-byte varint not supported in demo");
}
export function packHeader(ver, type, flags, seq, tick /* or null */) {
const len = tick == null ? 5 : 7;
const buf = new ArrayBuffer(len);
const dv = new DataView(buf);
dv.setUint8(0, ver & 0xff);
dv.setUint8(1, type & 0xff);
dv.setUint8(2, flags & 0xff);
dv.setUint16(3, seq & 0xffff);
if (tick != null) dv.setUint16(5, tick & 0xffff);
return new Uint8Array(buf);
}
export function parseHeader(dv, offset = 0, expectTick = false) {
if (dv.byteLength - offset < 5) throw new Error("small header");
const ver = dv.getUint8(offset);
const type = dv.getUint8(offset + 1);
const flags = dv.getUint8(offset + 2);
const seq = dv.getUint16(offset + 3);
let tick = null;
let off = offset + 5;
if (expectTick) {
tick = dv.getUint16(off);
off += 2;
}
return [ver, type, flags, seq, tick, off];
}
export function buildJoin(ver, seq, name, preferredColor = null) {
const enc = new TextEncoder();
const nb = enc.encode(name);
const nameLen = quicVarintEncode(nb.length);
const header = packHeader(ver, PacketType.JOIN, 0, seq, null);
const extra = preferredColor == null ? new Uint8Array([]) : new Uint8Array([preferredColor & 0xff]);
const out = new Uint8Array(header.length + nameLen.length + nb.length + extra.length);
out.set(header, 0);
out.set(nameLen, header.length);
out.set(nb, header.length + nameLen.length);
out.set(extra, header.length + nameLen.length + nb.length);
return out;
}
export function buildInput(ver, seq, ackSeq, inputSeq, baseTick, events /* [{rel, dir}] */) {
const header = packHeader(ver, PacketType.INPUT, 0, seq, null);
const evParts = [];
evParts.push(...[ackSeq >> 8, ackSeq & 0xff, inputSeq >> 8, inputSeq & 0xff, baseTick >> 8, baseTick & 0xff]);
const evCount = quicVarintEncode(events.length);
const chunks = [header, new Uint8Array(evParts), evCount];
for (const e of events) {
const off = quicVarintEncode(e.rel);
chunks.push(off, new Uint8Array([e.dir & 0x03]));
}
const size = chunks.reduce((s, a) => s + a.length, 0);
const out = new Uint8Array(size);
let p = 0;
for (const c of chunks) { out.set(c, p); p += c.length; }
return out;
}
export function parseJoinAck(dv, off) {
const playerId = dv.getUint8(off); off += 1;
const colorId = dv.getUint8(off); off += 1;
const width = dv.getUint8(off); off += 1;
const height = dv.getUint8(off); off += 1;
const tickRate = dv.getUint8(off); off += 1;
const wrapEdges = dv.getUint8(off) !== 0; off += 1;
const applesPerSnake = dv.getUint8(off); off += 1;
const applesCap = dv.getUint8(off); off += 1;
const compressionMode = dv.getUint8(off); off += 1;
return { playerId, colorId, width, height, tickRate, wrapEdges, applesPerSnake, applesCap, compressionMode, off };
}
export function parseStateFullBody(dv, off) {
const snakes = [];
// snakes count
let [n, p] = quicVarintDecode(dv, off); off = p;
for (let i = 0; i < n; i++) {
const id = dv.getUint8(off); off += 1;
const len = dv.getUint16(off); off += 2;
const hx = dv.getUint8(off); off += 1;
const hy = dv.getUint8(off); off += 1;
// TLV
let [t, p1] = quicVarintDecode(dv, off); off = p1;
let [L, p2] = quicVarintDecode(dv, off); off = p2;
const tStart = off;
const tEnd = off + L;
let dirs = [];
if (t === 0 /* 2-bit */) {
const needed = Math.max(0, (len - 1));
let bits = 0, acc = 0, di = 0;
for (let iB = off; iB < tEnd && di < needed; iB++) {
acc |= dv.getUint8(iB) << bits;
bits += 8;
while (bits >= 2 && di < needed) {
dirs.push(acc & 0x03);
acc >>= 2; bits -= 2; di++;
}
}
} else if (t === 1 /* RLE */) {
let countAccum = 0;
while (off < tEnd && countAccum < (len - 1)) {
const dir = dv.getUint8(off); off += 1;
let c; [c, off] = quicVarintDecode(dv, off);
for (let k = 0; k < c; k++) dirs.push(dir & 0x03);
countAccum += c;
}
} else {
// chunk types not expected in full body for initial join; skip
off = tEnd;
}
off = tEnd;
snakes.push({ id, len, hx, hy, dirs });
}
// apples
let m; [m, off] = quicVarintDecode(dv, off);
const apples = [];
for (let i = 0; i < m; i++) { apples.push([dv.getUint8(off++), dv.getUint8(off++)]); }
return { snakes, apples, off };
}