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:
163
client/client.js
Normal file
163
client/client.js
Normal 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
30
client/index.html
Normal 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
160
client/protocol.js
Normal 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user