import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody, parseStateDeltaBody } 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: { const delta = parseStateDeltaBody(dv, off); // Apply changes to local snake state for (const ch of delta.changes) { let snake = snakes.get(ch.snakeId); if (!snake) { // New snake appeared; skip for now (will get it in next full update) continue; } // Update direction (always present) // Note: direction is the current direction, not necessarily the new head direction if (ch.headMoved) { // Snake moved: prepend new head position // Compute direction from old head to new head const oldHx = snake.hx; const oldHy = snake.hy; const newHx = ch.newHeadX; const newHy = ch.newHeadY; // Determine direction of movement let moveDir = ch.direction; if (newHx === oldHx && newHy === oldHy - 1) moveDir = Direction.UP; else if (newHx === oldHx + 1 && newHy === oldHy) moveDir = Direction.RIGHT; else if (newHx === oldHx && newHy === oldHy + 1) moveDir = Direction.DOWN; else if (newHx === oldHx - 1 && newHy === oldHy) moveDir = Direction.LEFT; // Update head position snake.hx = newHx; snake.hy = newHy; // If tail was removed (normal move), keep dirs same length by prepending new dir // If grew (ate apple), prepend without removing tail if (ch.grew) { // Growing: add direction to front, don't remove from back snake.dirs = [moveDir, ...snake.dirs]; snake.len += 1; } else if (ch.tailRemoved) { // Normal move: head advances, tail shrinks // Keep dirs array same size (represents len-1 directions) // Actually, we need to be careful here about the representation // dirs[i] tells us how to get from cell i to cell i+1 // When head moves and tail shrinks, we add new dir at front, remove old from back if (snake.dirs.length > 0) { snake.dirs.pop(); // remove tail direction } snake.dirs = [moveDir, ...snake.dirs]; } else { // Head moved but tail didn't remove (shouldn't happen in normal gameplay) snake.dirs = [moveDir, ...snake.dirs]; snake.len += 1; } } else if (ch.blocked) { // Snake is blocked: head stayed in place, tail shrunk if (ch.tailRemoved && snake.dirs.length > 0) { snake.dirs.pop(); // remove last direction (tail shrinks) snake.len = Math.max(1, snake.len - 1); } } } // Apply apple changes const appleSet = new Set(apples.map(a => `${a[0]},${a[1]}`)); for (const [x, y] of delta.applesAdded) { appleSet.add(`${x},${y}`); } for (const [x, y] of delta.applesRemoved) { appleSet.delete(`${x},${y}`); } apples = Array.from(appleSet).map(s => { const [x, y] = s.split(',').map(Number); return [x, y]; }); break; } default: break; } } } catch (e) { setStatus('Read error: ' + e); } } async function connectWT() { // Check if WebTransport is available if (typeof WebTransport === 'undefined') { setStatus('ERROR: WebTransport not supported in this browser. Use Chrome/Edge 97+ or Firefox with flag enabled.'); return; } const url = ui.url.value.trim(); const hashHex = ui.hash.value.trim(); if (!url) { setStatus('ERROR: Please enter a server URL'); return; } setStatus('connecting...'); console.log('Connecting to:', url); const opts = {}; if (hashHex) { try { const bytes = new Uint8Array(hashHex.match(/.{1,2}/g).map(b => parseInt(b,16))); opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: bytes }]; console.log('Using certificate hash:', hashHex); } catch (err) { setStatus('ERROR: Invalid certificate hash format'); return; } } else { console.warn('No certificate hash provided - connection may fail for self-signed certs'); } try { const wt = new WebTransport(url, opts); await wt.ready; transport = wt; writer = wt.datagrams.writable.getWriter(); setStatus('connected to server'); console.log('WebTransport connected successfully'); 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); } catch (err) { console.error('WebTransport connection failed:', err); if (err.message.includes('certificate')) { setStatus('ERROR: Certificate validation failed. Check cert hash or use valid CA cert.'); } else if (err.message.includes('net::')) { setStatus('ERROR: Network error - is server running on ' + url + '?'); } else { setStatus('ERROR: ' + err.message); } } } 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 */ } } // Auto-configure on page load async function autoConfigureFromServer() { try { // Try to fetch cert hash from the static server const response = await fetch('/cert-hash.json'); if (!response.ok) { setStatus('Auto-config unavailable, please enter manually'); return; } const config = await response.json(); // Auto-populate fields if (config.wtUrl) { // Use the hostname from browser but with the WT port from server const hostname = window.location.hostname || '127.0.0.1'; const wtPort = config.wtPort || 4433; ui.url.value = `https://${hostname}:${wtPort}/`; } if (config.sha256) { ui.hash.value = config.sha256; } setStatus('Auto-configured from server'); console.log('Auto-configured:', config); } catch (err) { console.warn('Auto-config failed:', err); // Fallback: just populate URL from browser location const hostname = window.location.hostname || '127.0.0.1'; ui.url.value = `https://${hostname}:4433/`; setStatus('Manual configuration required'); } } ui.connect.onclick = () => { connectWT().catch(e => setStatus('connect failed: ' + e)); }; window.addEventListener('keydown', onKey); window.addEventListener('resize', render); // Auto-configure when page loads if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', autoConfigureFromServer); } else { autoConfigureFromServer(); }