/** * val, in a browser. It draws what the server sends and sends what the * person does — nothing else. There is no position here the server did not * hand over, and no enemy in a snapshot the server decided you cannot see, * so a patched client learns nothing a plain one does not. */ "use strict"; const $ = (id) => document.getElementById(id); const view = $("view"); const ctx = view.getContext("2d"); const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" }; const AGENT_COLOUR = { ember: "#f08a4b", veil: "#8f7fe8", sable: "#4fd6a0", mender: "#e0c060", rook: "#d96a8f" }; let map = null; let snap = null; let socket = null; const shown = new Map(); // smoothed positions, so 15 frames a second look like 60 const keys = new Set(); let pointer = null; // aim target in canvas pixels let firing = false; let stick = null; // { id, ox, oy, mx, my } let aimTouch = null; // ------------------------------------------------------------------ socket function connect() { const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws"; socket = new WebSocket(url); socket.onopen = () => ($("conn").textContent = "live"); socket.onclose = () => { $("conn").textContent = "reconnecting…"; setTimeout(connect, 1500); }; socket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.t === "s") { snap = data; paintHud(data); } }; } const send = (message) => { if (socket && socket.readyState === 1) socket.send(JSON.stringify(message)); }; // ------------------------------------------------------------------- input addEventListener("keydown", (event) => { if (event.target instanceof HTMLInputElement) return; if (event.code === "KeyB") { toggleBuy(); event.preventDefault(); return; } if (KEY_SLOT[event.code]) { send({ t: "i", c: KEY_SLOT[event.code] }); event.preventDefault(); return; } keys.add(event.code); if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR"].includes(event.code)) event.preventDefault(); }); addEventListener("keyup", (event) => keys.delete(event.code)); addEventListener("blur", () => keys.clear()); view.addEventListener("pointermove", (event) => { if (event.pointerType === "touch") return; const box = view.getBoundingClientRect(); pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; }); view.addEventListener("pointerdown", (event) => { view.setPointerCapture(event.pointerId); const box = view.getBoundingClientRect(); const x = event.clientX - box.left; if (event.pointerType === "touch") { if (x < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 }; else { aimTouch = { id: event.pointerId, x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; firing = true; } event.preventDefault(); return; } pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; firing = true; }); view.addEventListener("pointermove", (event) => { if (stick && event.pointerId === stick.id) { const dx = event.clientX - stick.ox; const dy = event.clientY - stick.oy; const len = Math.hypot(dx, dy) || 1; const reach = Math.min(len, 48) / 48; stick.mx = (dx / len) * reach; stick.my = (dy / len) * reach; } if (aimTouch && event.pointerId === aimTouch.id) { const box = view.getBoundingClientRect(); aimTouch.x = ((event.clientX - box.left) / box.width) * view.width; aimTouch.y = ((event.clientY - box.top) / box.height) * view.height; } }); const endPointer = (event) => { if (stick && event.pointerId === stick.id) stick = null; else if (aimTouch && event.pointerId === aimTouch.id) { aimTouch = null; firing = false; } else firing = false; }; view.addEventListener("pointerup", endPointer); view.addEventListener("pointercancel", endPointer); view.addEventListener("contextmenu", (event) => event.preventDefault()); // The buy opens itself every buy phase; closing it is remembered until the // next one, so nobody has to fight the panel to watch the round start. function toggleBuy() { const open = $("buy").classList.toggle("on"); $("buy").dataset.closed = open ? "" : "1"; } $("buytoggle").addEventListener("click", toggleBuy); for (const button of document.querySelectorAll("[data-buy]")) button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy })); for (const button of document.querySelectorAll("[data-agent]")) button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent })); // Twenty times a second the server hears what this person is doing. setInterval(() => { if (!snap || !map) return; let mx = 0; let my = 0; if (keys.has("KeyW")) my -= 1; if (keys.has("KeyS")) my += 1; if (keys.has("KeyA")) mx -= 1; if (keys.has("KeyD")) mx += 1; if (stick) { mx = stick.mx; my = stick.my; } const aim = aimTouch || pointer; const me = worldToScreen(snap.me); const angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; send({ t: "i", mx, my, a: angle, f: firing || keys.has("Space"), u: keys.has("KeyF"), r: keys.has("KeyR"), }); }, 50); // ------------------------------------------------------------------ drawing function resize() { const box = view.getBoundingClientRect(); const dpr = Math.min(devicePixelRatio || 1, 2); view.width = Math.round(box.width * dpr); view.height = Math.round(box.height * dpr); } addEventListener("resize", resize); const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1); const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() }); function smooth(id, x, y, angle) { const now = shown.get(id); if (!now) { shown.set(id, { x, y, angle }); return { x, y, angle }; } now.x += (x - now.x) * 0.35; now.y += (y - now.y) * 0.35; let turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; now.angle += turn * 0.4; return now; } function drawMap(s) { const t = map.tile * s; for (let row = 0; row < map.rows.length; row++) for (let col = 0; col < map.rows[row].length; col++) { const cell = map.rows[row][col]; ctx.fillStyle = cell === "#" ? "#20242d" : cell === "A" || cell === "B" ? "#2c2a22" : cell === "a" ? "#2a2024" : cell === "d" ? "#1e2630" : "#171a21"; ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); if (cell === "A" || cell === "B") { ctx.fillStyle = "#6b5f3a"; ctx.font = `${Math.round(t * 0.5)}px system-ui`; ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); } } } function drawZone(z, s) { const x = z.x * s; const y = z.y * s; const r = z.r * s; ctx.save(); if (z.k === "smoke") { ctx.fillStyle = "rgba(190,195,205,0.72)"; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill(); } else if (z.k === "molly") { ctx.fillStyle = "rgba(230,110,50,0.45)"; ctx.strokeStyle = "#f08a4b"; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill(); ctx.stroke(); } else if (z.k === "wall") { ctx.fillStyle = "rgba(120,200,255,0.55)"; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill(); } else if (z.k === "flash") { ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)"; ctx.beginPath(); ctx.arc(x, y, z.f > 0 ? 5 * s : 30 * s, 0, 7); ctx.fill(); } else if (z.k === "dart") { ctx.strokeStyle = "rgba(90,220,170,0.7)"; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.stroke(); } else if (z.k === "trip") { ctx.strokeStyle = "rgba(230,200,90,0.8)"; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.stroke(); } else if (z.k === "heal") { ctx.fillStyle = "rgba(120,230,140,0.5)"; ctx.beginPath(); ctx.arc(x, y, 14 * s, 0, 7); ctx.fill(); } ctx.restore(); } function drawPlayer(p, s, me) { const pos = smooth(p.id, p.x, p.y, p.angle); const x = pos.x * s; const y = pos.y * s; const r = 16 * s; const mine = p.id === me.id; ctx.fillStyle = p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; if (!p.alive) ctx.fillStyle = "#555a63"; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill(); ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; ctx.lineWidth = Math.max(1, 3 * s); ctx.stroke(); if (p.alive) { ctx.strokeStyle = mine ? "#fff" : ctx.fillStyle; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); ctx.stroke(); } if (p.spike) { ctx.fillStyle = "#f0d060"; ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); } if (p.blind) { ctx.fillStyle = "rgba(255,255,255,0.8)"; ctx.beginPath(); ctx.arc(x, y - r * 1.6, r * 0.35, 0, 7); ctx.fill(); } if (!mine) { ctx.fillStyle = "rgba(235,235,235,0.75)"; ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui`; ctx.textAlign = "center"; ctx.fillText(p.name, x, y - r * 1.9); ctx.textAlign = "left"; } if (p.alive && p.hp < 100) { ctx.fillStyle = "#0008"; ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); ctx.fillStyle = "#6fcf6f"; ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); } } function frame() { requestAnimationFrame(frame); if (!map) return; const s = scale(); ctx.clearRect(0, 0, view.width, view.height); drawMap(s); if (!snap) return; for (const orb of snap.orbs) { ctx.fillStyle = "#c9a0ff"; ctx.beginPath(); ctx.arc(orb.x * s, orb.y * s, 9 * s, 0, 7); ctx.fill(); } for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(zone, s); if (snap.spike.x !== null) { const x = snap.spike.x * s; const y = snap.spike.y * s; ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c"; ctx.fillRect(x - 7 * s, y - 7 * s, 14 * s, 14 * s); if (snap.spike.planted && snap.spike.defuse > 0) { ctx.fillStyle = "#4fd6a0"; ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 4); } } for (const shot of snap.shots) { ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.55)"; ctx.lineWidth = Math.max(1, 2 * s); ctx.beginPath(); ctx.moveTo(shot.x1 * s, shot.y1 * s); ctx.lineTo(shot.x2 * s, shot.y2 * s); ctx.stroke(); } for (const player of snap.players) drawPlayer(player, s, snap.me); // Smoke goes on last: it hides what is behind it on screen too. for (const zone of snap.zones) if (zone.k === "smoke") drawZone(zone, s); if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting"); else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") bar(snap.spike.defuse / 7, "#4fd6a0", "defusing"); $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8); $("dead").style.display = snap.me.alive ? "none" : "grid"; } function bar(fraction, colour, label) { const w = view.width * 0.4; const x = view.width / 2 - w / 2; const y = view.height * 0.86; ctx.fillStyle = "#0009"; ctx.fillRect(x, y, w, 16); ctx.fillStyle = colour; ctx.fillRect(x, y, w * Math.min(1, fraction), 16); ctx.fillStyle = "#fff"; ctx.font = "12px system-ui"; ctx.textAlign = "center"; ctx.fillText(label, view.width / 2, y + 12); ctx.textAlign = "left"; } // --------------------------------------------------------------------- hud const KITS = {}; function paintHud(s) { $("clock").textContent = Math.ceil(s.phase === "live" && s.spike.planted ? s.spike.fuse : s.clock); document.querySelector("#scoreline .red").textContent = "red " + s.score.red; document.querySelector("#scoreline .blue").textContent = s.score.blue + " blue"; $("phase").textContent = `round ${s.round} · ${s.phase === "buy" ? "buy phase" : s.phase === "live" ? (s.spike.planted ? "spike down" : "live") : s.phase === "end" ? "round over" : "match over"} · ${s.attackers} attacking · you are ${s.me.side === "atk" ? "attacking" : "defending"}${s.result ? " · " + s.result : ""}`; $("hp").textContent = `${s.me.hp} hp${s.me.armour ? " +" + s.me.armour : ""}`; $("gun").textContent = `${s.me.weapon}${s.me.reloading ? " reloading" : " " + s.me.ammo}`; $("creds").textContent = `${s.me.credits} cr`; $("ultmeter").textContent = `ult ${s.me.ult}/${s.me.ultNeeds}`; $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1"); if (s.phase !== "buy") $("buy").dataset.closed = ""; const kit = $("kit"); const shape = ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") + s.me.agent + s.me.ult; if (KITS.shape !== shape) { KITS.shape = shape; kit.innerHTML = ""; for (const slot of ["c", "q", "e", "x"]) { const button = document.createElement("button"); const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0; button.disabled = !ready; const count = slot === "x" ? `${s.me.ult}/${s.me.ultNeeds}` : s.me.cooldown[slot] > 0 ? `${Math.ceil(s.me.cooldown[slot])}s` : s.me.charges[slot]; button.innerHTML = `${slot.toUpperCase()} ${count}`; button.type = "button"; button.addEventListener("click", () => send({ t: "i", c: slot })); kit.append(button); } } const log = $("log"); const text = s.events.slice(0, 3).join("\n"); if (log.dataset.text !== text) { log.dataset.text = text; log.innerHTML = ""; for (const line of s.events.slice(0, 3)) { const div = document.createElement("div"); div.textContent = line; log.append(div); } } for (const side of ["atk", "def"]) { const body = $(side + "board"); const rows = s.board.filter((p) => p.side === side).sort((a, b) => b.kills - a.kills); const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join(); if (body.dataset.key === key) continue; body.dataset.key = key; body.innerHTML = ""; for (const player of rows) { const tr = document.createElement("tr"); const cells = [ player.name + (player.bot ? " (bot)" : "") + (player.alive ? "" : " ✕"), player.agent + (player.credits !== null ? ` · ${player.credits}cr` : ""), String(player.kills), String(player.deaths), ]; for (const value of cells) { const td = document.createElement("td"); td.textContent = value; tr.append(td); } if (player.id === s.me.id) tr.style.fontWeight = "700"; body.append(tr); } } } fetch("/map.json") .then((r) => r.json()) .then((data) => { map = data; resize(); connect(); }); requestAnimationFrame(frame);