- 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
164 lines
5.8 KiB
JavaScript
164 lines
5.8 KiB
JavaScript
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);
|
|
|