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 @@ + + +
+ + +