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);