/** * 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. * * This file is the wiring: the socket, the keys and the pointer, the HUD. * The drawing lives beside it — `scene.js` for the first-person view, * `topdown.js` for the map, `gun.js` for the crosshair, `prompt.js` for the * line of advice and `raycast.js` for the maths all of it stands on. */ "use strict"; import { makeBeeper } from "./beep.js"; import { arrive, bodyAt, feed, makeTimeline, renderTime } from "./interp.js"; import { BODY_R, moveSpeed } from "../src/glide.ts"; import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; import { choosePrompt, REACH } from "./prompt.js"; import { roster, rosterKey } from "./roster.js"; import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; import { moveVector } from "./steer.js"; import { drawTopdown } from "./topdown.js"; import { drawWatching, watching, watchLines } from "./watch.js"; import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js"; 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 COARSE = matchMedia("(pointer: coarse)").matches; const LOOK = 0.0022; // radians per pixel of mouse travel const HELP_SEEN = "val.help.seen"; let map = null; let solid = () => true; let world = null; // what predict.js slides against: the grid, from map.json let snap = null; let socket = null; let agents = null; // Everybody else, kept as the last few things the server said about them and // drawn between those — see interp.js. `drawAt` is the moment this frame is // drawing, a snapshot interval behind the present. const timeline = makeTimeline(); let drawAt = 0; const keys = new Set(); // Where this person is looking: the browser's between snapshots, and the // server's word taken only when it is saying something we did not. See // yaw.js — a snapshot that stomps this is what made looking around choppy. const head = makeYaw(); let locked = false; let firing = false; let mode = COARSE ? "top" : "fp"; // a raycaster with touch look is miserable let mapOpen = false; let pointer = null; // aim target in canvas pixels, top-down only let stick = null; // { id, ox, oy, mx, my } let aimTouch = null; let helpShown = false; let last = performance.now(); let snapAt = last; // when the last snapshot landed: how stale the answer is const walk = { x: 0, y: 0 }; // What this person is doing right now, read once a frame for the drawing and // once every 50ms for the server. The same object either way: no allocation // in a loop that runs sixty times a second. const intent = { mx: 0, my: 0, angle: 0, fire: false, use: false, reload: false }; // Your own body, run forward from your own keys between snapshots, and the // object the drawing is handed for it. const local = makeLocal(); const drawnMe = { id: "", x: 0, y: 0, angle: 0 }; const scene = makeScene(); const gun = makeGun(); const beeper = makeBeeper(); // ------------------------------------------------------------------ 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") return; snap = data; snapAt = performance.now(); // No seat, no `me`, no players. Everything below reads a body out of the // snapshot, so a watching connection takes the other path entirely. if (watching(data)) return void paintWatch(data); // Timed samples for every body in it, ours included: the drawing walks // between them rather than chasing the newest one. arrive(timeline, snapAt / 1000); feed(timeline, data.me.id, data.me, snapAt / 1000); for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000); // The server's angle is taken only when it is a correction, never as the // routine echo of the angle we sent it a moment ago. settle(head, data.me, snapAt / 1000); gunSnapshot(gun, data); paintHud(data); maybeHelp(data); }; } const send = (message) => { if (socket && socket.readyState === 1) socket.send(JSON.stringify(message)); }; // ------------------------------------------------------------------- input /** * Swap the view. The two share one facing — whichever you leave, you keep * looking the way you were looking — so nothing here touches the angle. */ function setMode(next) { mode = next; $("stage").dataset.mode = next; if (next !== "fp" && document.pointerLockElement === view) document.exitPointerLock(); } addEventListener("keydown", (event) => { if (event.target instanceof HTMLInputElement) return; if (event.code === "Tab") { mapOpen = true; event.preventDefault(); return; } if (event.code === "KeyH" || event.key === "?") { toggleHelp(); event.preventDefault(); return; } if (event.code === "KeyV") { setMode(mode === "fp" ? "top" : "fp"); event.preventDefault(); 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", "ArrowLeft", "ArrowRight"].includes(event.code)) event.preventDefault(); }); addEventListener("keyup", (event) => { if (event.code === "Tab") mapOpen = false; keys.delete(event.code); }); addEventListener("blur", () => { keys.clear(); mapOpen = false; firing = false; }); // Aiming is pointer lock: click the canvas to capture the mouse, move to // look, Escape gives it back. The angle sent is the same angle as ever. view.addEventListener("click", () => { beeper.wake(); if (watching(snap)) return; // nothing to aim: capturing the mouse would be a lie if (mode !== "fp" || locked) return; if (!view.requestPointerLock) return setMode("top"); // no lock, no first person // Asked for too soon after the last release, a browser rejects this; that // is a shrug, not an error, and the hint stays up until it takes. const asked = view.requestPointerLock(); if (asked && asked.catch) asked.catch(() => {}); }); document.addEventListener("pointerlockchange", () => { locked = document.pointerLockElement === view; if (!locked) firing = false; $("stage").dataset.locked = locked ? "1" : ""; }); addEventListener("mousemove", (event) => { if (locked) turn(head, event.movementX * LOOK); }); addEventListener("mousedown", (event) => { if (locked && event.button === 0) firing = true; }); addEventListener("mouseup", (event) => { if (locked && event.button === 0) firing = false; }); // The top-down view keeps the pointer aim it always had: it is the phone // control scheme, and the fallback when the first-person view is not wanted. const canvasPoint = (event) => { const box = view.getBoundingClientRect(); return { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height, }; }; view.addEventListener("pointermove", (event) => { if (mode !== "top" || event.pointerType === "touch") return; pointer = canvasPoint(event); }); view.addEventListener("pointerdown", (event) => { beeper.wake(); if (mode !== "top") return; view.setPointerCapture(event.pointerId); const box = view.getBoundingClientRect(); if (event.pointerType === "touch") { if (event.clientX - box.left < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 }; else { aimTouch = { id: event.pointerId, ...canvasPoint(event) }; firing = true; } event.preventDefault(); return; } pointer = canvasPoint(event); 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) Object.assign(aimTouch, canvasPoint(event)); }); 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()); // What this person is doing, in the world's terms. In the first-person view // the keys are read against the way you are facing; in the top-down view they // are the compass, the way they always were. function readIntent() { let ax = 0; let ay = 0; if (keys.has("KeyW")) ay -= 1; if (keys.has("KeyS")) ay += 1; if (keys.has("KeyA")) ax -= 1; if (keys.has("KeyD")) ax += 1; if (stick) { ax = stick.mx; ay = stick.my; } intent.mx = ax; intent.my = ay; if (mode === "fp") { intent.angle = head.angle; moveVector(ax, ay, head.angle, walk); intent.mx = walk.x; intent.my = walk.y; } else { const aim = aimTouch || pointer; const me = worldToScreen(drawnMe.id ? drawnMe : snap.me); // The pointer is this view's aim, and it is ours the same way the mouse // is: with no pointer yet, keep the facing rather than the stale answer. if (aim) aimAt(head, Math.atan2(aim.y - me.y, aim.x - me.x)); intent.angle = head.angle; } intent.fire = firing || keys.has("Space"); intent.use = keys.has("KeyF"); intent.reload = keys.has("KeyR"); return intent; } // Twenty times a second the server hears it. Intent only — a direction, an // angle, a held key — never a position: the guess below is for drawing. setInterval(() => { if (!snap || !map || watching(snap)) return; // nothing to drive, nothing to send const now = readIntent(); send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload }); // What we told it, so its echo is recognisable as ours when it comes back. told(head, now.angle, performance.now() / 1000); }, 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() }); /** * Everybody else is drawn at `drawAt`: between the two samples that bracket * it, so they move at the server's own speed and arrive where it put them. * You are not: your own body is the one this browser can run forward * honestly, and drawing it late is what dragged the camera backwards. */ const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : bodyAt(timeline, p, drawAt)); /** * Your own movement is only guessed at while the round is live and you are * alive. The buy phase pens everybody in their own spawn — a rule of the * match, not of the map — and a guess that does not know it would spend the * phase being pulled back through the door. */ const predicting = () => snap !== null && !watching(snap) && world !== null && snap.phase !== "buy" && snap.me.alive; /** The server's answer, caught up to now, then this frame of your own keys. */ function predict(dt, now) { if (!predicting()) { local.ready = false; drawnMe.id = ""; return; } const held = readIntent(); const speed = moveSpeed({ haste: snap.me.haste > 0, slow: snap.me.slow > 0, stopped: snap.me.planting > 0 || snap.me.defusing, heavy: snap.me.weapon === "operator", }); // The snapshot is a moment old and the keys held since have not reached // it; carry it that far forward and lean the drawn body onto it. const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world); reconcile(local, target, dt); advance(local, held.mx, held.my, speed, dt, world); holdBack(local, target, held.mx, held.my); // A lean across a corner could put the drawn body in a wall; the server's // answer is never wrong, so take it rather than draw from inside one. if (insideWall(local, world)) anchor(local, snap.me); drawnMe.id = snap.me.id; drawnMe.x = local.x; drawnMe.y = local.y; // Either view: the facing is the one this browser is holding, whether the // mouse moved it or the pointer aim did. The server hears it a moment later. drawnMe.angle = head.angle; } function frame(now) { requestAnimationFrame(frame); const dt = Math.min(0.1, (now - last) / 1000); last = now; drawAt = renderTime(timeline, now / 1000); // A watching connection draws the watching view and stops there: it has no // map to stand on and no body to put on it, and must invent neither. if (watching(snap)) return void drawWatch(); if (!map) return; gunTick(gun, dt); ctx.clearRect(0, 0, view.width, view.height); if (!snap) { drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height }); return; } // Turning with the arrow keys belongs to the frame, at a rate a second, // rather than to whatever the input timer happens to do. if (mode === "fp" && keys.has("ArrowLeft")) turn(head, -0.9 * dt); if (mode === "fp" && keys.has("ArrowRight")) turn(head, 0.9 * dt); predict(dt, now); beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000); const eye = look(snap.me); const facing = mode === "fp" ? head.angle : eye.angle; if (mode === "fp" && !mapOpen) { drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, { width: view.width, height: view.height, look, }); drawGun(ctx, gun, view.width, view.height, snap); drawMinimap(); } else { drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height }); } 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"; $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none"; } /** The same top-down drawing, small, in the corner, with you as an arrow. */ function drawMinimap() { const w = Math.min(view.width * 0.3, 260 * (view.width / 900)); const h = (w * map.rows.length) / map.rows[0].length; const pad = 8; ctx.save(); ctx.globalAlpha = 0.85; ctx.fillStyle = "#0b0d11"; ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4); drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false }); ctx.restore(); ctx.strokeStyle = "rgba(255,255,255,0.18)"; ctx.lineWidth = 1; ctx.strokeRect(pad - 2, pad - 2, w + 4, h + 4); } 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, sans-serif"; ctx.textAlign = "center"; ctx.fillText(label, view.width / 2, y + 12); ctx.textAlign = "left"; } // --------------------------------------------------------------------- hud // 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); $("viewtoggle").addEventListener("click", () => setMode(mode === "fp" ? "top" : "fp")); $("helptoggle").addEventListener("click", () => toggleHelp()); $("helpclose").addEventListener("click", () => toggleHelp(false)); 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 })); function toggleHelp(force) { const open = force === undefined ? $("help").style.display !== "grid" : force; $("help").style.display = open ? "grid" : "none"; if (!open) { helpShown = true; try { localStorage.setItem(HELP_SEEN, "1"); } catch { // A browser that refuses storage still gets the card, every visit. } } } /** The first-run card, once per browser, and never over a round in progress. */ function maybeHelp(s) { if (helpShown) return; try { if (localStorage.getItem(HELP_SEEN) === "1") { helpShown = true; return; } } catch { /* no storage: show it and move on */ } if (s.phase === "live" && s.me.alive) return; helpShown = true; $("help").style.display = "grid"; } 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 text = choosePrompt(s, { onSite: onSite(s.me), nearSpike: s.spike.x !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH, }); if ($("prompt").textContent !== text) $("prompt").textContent = text; paintKit(s); paintBuy(s); paintRoster(s); paintFeed(s); paintLog(s); 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.replaceChildren(); 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); } } } /** The last few things that happened, in words. Rebuilt only when they change. */ function paintLog(s) { const log = $("log"); const lines = (s.events ?? []).slice(0, 3); if (log.dataset.text === lines.join("\n")) return; log.dataset.text = lines.join("\n"); log.replaceChildren(); for (const line of lines) { const div = document.createElement("div"); div.textContent = line; log.append(div); } } /** * The HUD for a connection with no seat. Everything a seat's HUD says about * you — health, gun, credits, kit, your five — has no answer here, so it says * so rather than leaving the page's first paint standing there going stale. */ function paintWatch(s) { const lines = watchLines(s); $("clock").textContent = Math.ceil(Math.max(0, Number(s.clock) || 0)); document.querySelector("#scoreline .red").textContent = "red " + (s.score?.red ?? 0); document.querySelector("#scoreline .blue").textContent = (s.score?.blue ?? 0) + " blue"; $("phase").textContent = lines.phase; $("hp").textContent = "watching"; $("gun").textContent = "no seat"; $("creds").textContent = "—"; $("ultmeter").textContent = "—"; $("buy").classList.toggle("on", false); if ($("prompt").textContent !== lines.why) $("prompt").textContent = lines.why; paintFeed(s); paintLog(s); blank($("kit")); blank($("roster")); blank($("atkboard")); blank($("defboard")); if (KITS.shape !== "watching") KITS.shape = "watching"; } /** Empty a panel whose answer this connection is not sent, once. */ function blank(panel) { if (panel.dataset.key === "watching") return; panel.dataset.key = "watching"; panel.dataset.text = "watching"; panel.replaceChildren(); } /** The canvas, watching: no map, no bodies, and the overlays a seat owns off. */ function drawWatch() { ctx.clearRect(0, 0, view.width, view.height); drawWatching(ctx, snap, view.width, view.height); $("flash").style.opacity = 0; $("dead").style.display = "none"; $("aimhint").style.display = "none"; } /** Is this player standing where the spike arms? The grid says so. */ function onSite(me) { if (!map) return false; const row = map.rows[Math.floor(me.y / map.tile)]; const cell = row ? row[Math.floor(me.x / map.tile)] : ""; return cell === "A" || cell === "B"; } /** * What just happened to whom: newest first, and gone a few seconds later. * The server sends names and a gun and nothing else, so there is nothing * here to read a position out of. */ function paintFeed(s) { const box = $("feed"); const lines = (s.feed ?? []).slice().reverse(); // The age is bucketed into the key: an entry fades once on its way out // rather than rebuilding the panel every frame. const key = lines.map((l) => `${l.k}>${l.v}:${l.w}:${l.h}:${l.t > 6 ? "old" : "new"}`).join("|"); if (box.dataset.key === key) return; box.dataset.key = key; box.replaceChildren(); for (const line of lines) { const row = document.createElement("div"); row.style.opacity = line.t > 6 ? "0.5" : "1"; const killer = document.createElement("span"); killer.className = "k"; killer.textContent = line.k ?? "—"; const victim = document.createElement("span"); victim.className = "v"; victim.textContent = line.v; const how = document.createElement("span"); how.className = "w"; how.textContent = `${line.w ? " " + line.w : " ability"}${line.h ? " ✧" : ""}`; const arrow = document.createElement("span"); arrow.textContent = " ▸ "; row.append(killer, arrow, victim, how); box.append(row); } } /** * Your team, always on screen: five slots, health, and struck through when * somebody is dead. The one place both questions — who is with me, who is * still up — are answered whether anybody is in sight or not. */ function paintRoster(s) { const rows = roster(s); const panel = $("roster"); const key = rosterKey(rows); if (panel.dataset.key === key) return; panel.dataset.key = key; panel.replaceChildren(); for (const row of rows) { const slot = document.createElement("div"); slot.className = `slot${row.mine ? " mine" : ""}${row.alive ? "" : " down"}`; slot.style.borderLeftColor = AGENT_COLOUR[row.agent] || "#4a5162"; const who = document.createElement("span"); who.className = "who"; who.textContent = row.name; const agent = document.createElement("span"); agent.className = "agent"; agent.textContent = row.alive ? row.agent : `${row.agent} · dead`; if (row.spike) { const spike = document.createElement("span"); spike.className = "spike"; spike.textContent = " spike"; agent.append(spike); } const bar = document.createElement("span"); bar.className = "hp"; const fill = document.createElement("i"); fill.style.width = `${row.hp}%`; fill.style.background = row.hp > 40 ? "#6fcf6f" : "#e0a040"; bar.append(fill); slot.append(who, agent, bar); panel.append(slot); } } const kitOf = (id) => (agents ? agents.find((a) => a.id === id) : null); /** The four ability buttons, each saying what it does and what it has left. */ function paintKit(s) { const kit = $("kit"); const agent = kitOf(s.me.agent); 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 + (agent ? "k" : ""); if (KITS.shape === shape) return; KITS.shape = shape; kit.replaceChildren(); for (const slot of ["c", "q", "e", "x"]) { const ability = agent ? agent.abilities[slot] : null; 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; button.type = "button"; 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]; const name = document.createElement("span"); name.textContent = `${slot.toUpperCase()}${ability ? " " + ability.name : ""}`; const tally = document.createElement("b"); tally.textContent = String(count); button.append(name, tally); if (ability) button.title = `${ability.name} — ${ability.text}`; button.addEventListener("click", () => send({ t: "i", c: slot })); kit.append(button); } } /** Prices on every button, greyed when you cannot afford it. */ function paintBuy(s) { const agent = kitOf(s.me.agent); for (const button of document.querySelectorAll("[data-buy]")) { const what = button.dataset.buy; let cost = Number(button.dataset.cost ?? 0); if ((what === "c" || what === "q") && agent) { const ability = agent.abilities[what]; cost = ability.cost; button.replaceChildren(); const name = document.createElement("span"); name.textContent = `${what.toUpperCase()} ${ability.name}`; const price = document.createElement("b"); price.textContent = `${ability.cost} · ${s.me.charges[what]}/${ability.charges}`; button.append(name, price); button.title = `${ability.text} (${ability.charges} charge${ability.charges === 1 ? "" : "s"} a round)`; } button.disabled = cost > s.me.credits; } for (const button of document.querySelectorAll("[data-agent]")) { const picked = button.dataset.agent === s.me.agent; button.style.outline = picked ? "2px solid #6fcf9f" : ""; } } // ------------------------------------------------------------------- start Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").then((r) => r.json())]) .then(([mapData, kit]) => { map = mapData; agents = kit.agents; const rows = map.rows; const cols = rows[0].length; solid = (col, row) => col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#"; // The same grid the server collides against, so a guess about your own // movement lands where the server's answer is about to. world = { solid, tile: map.tile, r: BODY_R }; $("stage").dataset.mode = mode; fillHelp(); resize(); connect(); }) .catch(() => ($("conn").textContent = "offline")); /** The card says the round, the sides, the keys and the kit, in that order. */ function fillHelp() { const list = $("helpagents"); if (!list || !agents) return; list.replaceChildren(); for (const agent of agents) { const li = document.createElement("li"); li.textContent = `${agent.name} (${agent.role}) — ${agent.blurb}`; list.append(li); } } requestAnimationFrame(frame);