diff --git a/client/client.js b/client/client.js new file mode 100644 index 0000000..7069279 --- /dev/null +++ b/client/client.js @@ -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); + diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..26c48bb --- /dev/null +++ b/client/index.html @@ -0,0 +1,30 @@ + + + + + + Multiplayer Snake (WebTransport) + + + +
+ + + + + +
+
press space to join
+ + + + + + diff --git a/client/protocol.js b/client/protocol.js new file mode 100644 index 0000000..727bad1 --- /dev/null +++ b/client/protocol.js @@ -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 }; +} +