- 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
161 lines
5.3 KiB
JavaScript
161 lines
5.3 KiB
JavaScript
// 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 };
|
|
}
|
|
|