fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Merge pull request #4 from wren/3-put-the-player-inside-the-map-a-first-pe
Put the player inside the map: a first-person view, and instructions somebody can follow
27 files
changed +2974
−247
README.md +30 −12
| @@ -2,11 +2,12 @@ | ||
| 2 | 2 | |
| 3 | 3 | Five attackers with the spike, five defenders, abilities on cooldown. First to 13. |
| 4 | 4 | |
| 5 | A top-down 5v5 played in a browser. One match is always running on the | |
| 6 | server: open the page and you are given a seat in the round that is | |
| 7 | happening, taken from a bot where it stands. When a match ends it shows the | |
| 8 | result for a few seconds and starts a new one, so nobody ever arrives at a | |
| 9 | frozen board. | |
| 5 | A 5v5 played in a browser, from inside the player's head — the map is a tile | |
| 6 | grid and the view is a raycaster over it, so there is no 3D library and no | |
| 7 | dependency at all. One match is always running on the server: open the page | |
| 8 | and you are given a seat in the round that is happening, taken from a bot | |
| 9 | where it stands. When a match ends it shows the result for a few seconds and | |
| 10 | starts a new one, so nobody ever arrives at a frozen board. | |
| 10 | 11 | |
| 11 | 12 | ## What is real |
| 12 | 13 | |
| @@ -31,6 +32,10 @@ server: | ||
| 31 | 32 | - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a |
| 32 | 33 | plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is |
| 33 | 34 | refused server-side. |
| 35 | - **Who is alive.** The roster under the view lists your five with their | |
| 36 | health from the snapshot's own team data, and the kill feed is derived in | |
| 37 | `src/arena.ts` by comparing the players either side of a tick — it carries | |
| 38 | names, the gun and an age, and never a position. | |
| 34 | 39 | - **Bots** fill every empty seat and use their kit: smokes on the way in, |
| 35 | 40 | flashes before the entry, a recon dart, a molly on the planted spike. |
| 36 | 41 | |
| @@ -42,20 +47,30 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 42 | 47 | |
| 43 | 48 | ## What is not |
| 44 | 49 | |
| 50 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is | |
| 51 | no looking up or down, and everything that is not a wall — bodies, the | |
| 52 | spike, ability zones — is a flat billboard scaled by distance and clipped | |
| 53 | against the walls. | |
| 45 | 54 | - One map, one match at a time, ten seats. There is no matchmaking, no |
| 46 | 55 | ranking and no account: your seat belongs to a session cookie. |
| 47 | 56 | - No overtime — first to 13 ends it at 12–12 too. |
| 48 | - Sight is a full circle rather than a view cone; top-down has no vertical, | |
| 49 | so the hit location comes from how precisely the shot passes the target. | |
| 57 | - Sight is a full circle rather than a view cone — you are drawn what is in | |
| 58 | front of you, but the server reveals anything you have line of sight to, | |
| 59 | so a team mate behind you still appears on the minimap. The world has no | |
| 60 | vertical, so the hit location comes from how precisely the shot passes | |
| 61 | the target. | |
| 50 | 62 | - Assists are counted as a field but never awarded. |
| 51 | 63 | - The live match lives in memory and is gone if the process restarts; only |
| 52 | 64 | finished match results are written to SQLite under `FORGE_APP_DATA`. |
| 53 | 65 | |
| 54 | 66 | ## Playing |
| 55 | 67 | |
| 56 | WASD move, the mouse aims, click fires, **R** reloads, **F** plants and | |
| 57 | defuses (hold it), **C / Q / E / X** cast, **B** opens the buy. On a phone | |
| 58 | the left thumb walks and the right thumb aims and fires. | |
| 68 | Click the view to capture the mouse, then WASD move and the mouse looks; | |
| 69 | **Esc** gives the pointer back. Click fires, **R** reloads, **F** plants and | |
| 70 | defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab** | |
| 71 | holds the big map open, **V** swaps to the top-down view and **H** or **?** | |
| 72 | brings back the how-to-play card. On a phone it stays top-down: the left | |
| 73 | thumb walks and the right thumb aims and fires. | |
| 59 | 74 | |
| 60 | 75 | sh |
| 61 | 76 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| @@ -77,6 +92,9 @@ says how. | ||
| 77 | 92 | `src/economy.ts` credits · `src/abilities.ts` the agent kits · |
| 78 | 93 | `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes |
| 79 | 94 | no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one |
| 80 | live match and its sockets · `src/server.ts` the door · `static/val.js` the | |
| 81 | browser. The rules are pure functions and the tests play whole matches | |
| 95 | live match and its sockets · `src/server.ts` the door · `static/` the browser client — | |
| 96 | `val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray | |
| 97 | maths, `scene.js` the first-person view, `topdown.js` the minimap, | |
| 98 | `gun.js`, `beep.js`, `steer.js`, `roster.js` and `prompt.js` the rest; the server bundles | |
| 99 | them into one content-named file at start-up. The rules are pure functions and the tests play whole matches | |
| 82 | 100 | headlessly. |
src/arena.ts +60 −3
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | import { agentById, sightBlocked } from "./abilities.ts"; |
| 14 | 14 | import { runBots } from "./bots.ts"; |
| 15 | 15 | import { recordResult } from "./db.ts"; |
| 16 | import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; | |
| 16 | 17 | import type { Point } from "./map.ts"; |
| 17 | 18 | import { |
| 18 | 19 | buyAbility, |
| @@ -25,7 +26,7 @@ import { | ||
| 25 | 26 | visible, |
| 26 | 27 | } from "./match.ts"; |
| 27 | 28 | import type { MatchState, Player } from "./state.ts"; |
| 28 | import { isWeaponId } from "./weapons.ts"; | |
| 29 | import { isWeaponId, WEAPONS } from "./weapons.ts"; | |
| 29 | 30 | |
| 30 | 31 | export const TICK = 1 / 30; |
| 31 | 32 | const BROADCAST_EVERY = 2; |
| @@ -36,17 +37,40 @@ let state: MatchState = newMatch(); | ||
| 36 | 37 | const seats = new Map<string, Seat>(); |
| 37 | 38 | let ticks = 0; |
| 38 | 39 | let lastResult: string | null = null; |
| 40 | // The kill feed is read off the match a tick at a time; the rules keep no | |
| 41 | // list of their own, and this is a view, not a rule. | |
| 42 | let marks: Mark[] = marksOf(state.players); | |
| 43 | let feed: Fall[] = []; | |
| 39 | 44 | |
| 40 | 45 | export const match = (): MatchState => state; |
| 41 | 46 | |
| 42 | 47 | export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id); |
| 43 | 48 | |
| 49 | /** | |
| 50 | * A name nobody else in this match is using. | |
| 51 | * | |
| 52 | * The kill feed, the round log and the scoreboard all address people by | |
| 53 | * name: two players called Kit make a line that reads as a team kill this | |
| 54 | * game cannot have. The bots are dealt distinct names; a person who picks | |
| 55 | * one that is taken gets a number after it, inside the sixteen characters a | |
| 56 | * name is allowed. | |
| 57 | */ | |
| 58 | function freeName(want: string, exceptId: string): string { | |
| 59 | const taken = new Set(state.players.filter((p) => p.id !== exceptId).map((p) => p.name)); | |
| 60 | if (!taken.has(want)) return want; | |
| 61 | for (let n = 2; n < 100; n++) { | |
| 62 | const tried = `${want.slice(0, 13)} ${n}`; | |
| 63 | if (!taken.has(tried)) return tried; | |
| 64 | } | |
| 65 | return want; | |
| 66 | } | |
| 67 | ||
| 44 | 68 | /** Sit somebody down in the emptiest team, replacing a bot where it stands. */ |
| 45 | 69 | export function takeSeat(session: string, name: string): Player | null { |
| 46 | 70 | const existing = seats.get(session); |
| 47 | 71 | const held = existing ? findPlayer(existing.playerId) : undefined; |
| 48 | 72 | if (held) { |
| 49 | held.name = name; | |
| 73 | held.name = freeName(name, held.id); | |
| 50 | 74 | return held; |
| 51 | 75 | } |
| 52 | 76 | const humans = { red: 0, blue: 0 }; |
| @@ -56,7 +80,7 @@ export function takeSeat(session: string, name: string): Player | null { | ||
| 56 | 80 | state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); |
| 57 | 81 | if (!seat) return null; |
| 58 | 82 | seat.bot = false; |
| 59 | seat.name = name; | |
| 83 | seat.name = freeName(name, seat.id); | |
| 60 | 84 | seat.think = 0; |
| 61 | 85 | seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; |
| 62 | 86 | return seat; |
| @@ -211,14 +235,47 @@ export function snapshot(viewerId: string): string { | ||
| 211 | 235 | ultNeeds: agentById(p.agent).abilities.x.points, |
| 212 | 236 | })), |
| 213 | 237 | events: state.events.slice(0, 5).map((e) => e.text), |
| 238 | // Names and a gun, never a position: the feed says what happened, not | |
| 239 | // where, so it can never be read as a call-out. | |
| 240 | feed: feed.map((f) => ({ | |
| 241 | k: f.killer, | |
| 242 | v: f.victim, | |
| 243 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 244 | h: f.headshot, | |
| 245 | t: round2(Math.max(0, state.now - f.at)), | |
| 246 | })), | |
| 214 | 247 | }); |
| 215 | 248 | } |
| 216 | 249 | |
| 250 | /** | |
| 251 | * Who fell on this tick. A shot that landed this tick and started where the | |
| 252 | * killer stands is what tells a gun kill from an ability kill — the position | |
| 253 | * is used here and goes no further. | |
| 254 | */ | |
| 255 | function readFalls(): void { | |
| 256 | const after = marksOf(state.players); | |
| 257 | const shooters = new Set<string>(); | |
| 258 | for (const shot of state.shots) { | |
| 259 | if (!shot.hit || shot.age > 0) continue; | |
| 260 | for (const player of state.players) | |
| 261 | if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); | |
| 262 | } | |
| 263 | const falls = fallsBetween(marks, after, { | |
| 264 | at: state.now, | |
| 265 | events: state.events.slice(0, 6).map((e) => e.text), | |
| 266 | shooters, | |
| 267 | }); | |
| 268 | marks = after; | |
| 269 | if (falls.length) feed = [...feed, ...falls]; | |
| 270 | feed = trimFeed(feed, state.now); | |
| 271 | } | |
| 272 | ||
| 217 | 273 | // ----------------------------------------------------------------- the loop |
| 218 | 274 | |
| 219 | 275 | function step(): void { |
| 220 | 276 | runBots(state, TICK); |
| 221 | 277 | tick(state, TICK); |
| 278 | readFalls(); | |
| 222 | 279 | if (state.result && state.result !== lastResult) { |
| 223 | 280 | lastResult = state.result; |
| 224 | 281 | recordResult(state.result, state.score.red, state.score.blue, state.round); |
src/beep.test.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { beepInterval, beepPitch, makeBeeper } from "../static/beep.js"; | |
| 3 | ||
| 4 | test("the spike beeps faster as the fuse burns down", () => { | |
| 5 | expect(beepInterval(45)).toBeGreaterThan(beepInterval(20)); | |
| 6 | expect(beepInterval(20)).toBeGreaterThan(beepInterval(5)); | |
| 7 | expect(beepInterval(1)).toBeGreaterThanOrEqual(0.12); | |
| 8 | expect(beepInterval(45)).toBeLessThanOrEqual(1); | |
| 9 | }); | |
| 10 | ||
| 11 | test("a spent fuse does not beep at all", () => { | |
| 12 | expect(beepInterval(0)).toBe(0); | |
| 13 | }); | |
| 14 | ||
| 15 | test("and it rises in pitch on the way", () => { | |
| 16 | expect(beepPitch(2)).toBeGreaterThan(beepPitch(40)); | |
| 17 | }); | |
| 18 | ||
| 19 | test("nothing is played before the browser has had a gesture", () => { | |
| 20 | const beeper = makeBeeper(); | |
| 21 | // No AudioContext in this runtime: update must be a no-op, not a throw. | |
| 22 | expect(() => beeper.update(true, 30, 1)).not.toThrow(); | |
| 23 | expect(() => beeper.wake()).not.toThrow(); | |
| 24 | }); |
src/client.test.ts added +259
| @@ -0,0 +1,259 @@ | ||
| 1 | /** | |
| 2 | * The client, played. | |
| 3 | * | |
| 4 | * The bundle the browser is served is loaded here against a stub document | |
| 5 | * and a canvas that only writes down what it was told to draw, and pointed | |
| 6 | * at the running server over a real socket. It is not a browser, but it is | |
| 7 | * the whole path a person takes: connect, take a snapshot, paint the HUD, | |
| 8 | * draw a frame, press a key, send an input back. | |
| 9 | */ | |
| 10 | ||
| 11 | import { expect, test } from "bun:test"; | |
| 12 | import { mkdtempSync } from "node:fs"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | ||
| 16 | process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); | |
| 17 | process.env.PORT ??= "0"; | |
| 18 | const { server } = await import("./server.ts"); | |
| 19 | const base = `http://${server.hostname}:${server.port}`; | |
| 20 | ||
| 21 | // ------------------------------------------------------------ the stub DOM | |
| 22 | ||
| 23 | const drawn: string[] = []; | |
| 24 | const listeners = new Map<string, ((event: unknown) => void)[]>(); | |
| 25 | const frames: ((now: number) => void)[] = []; | |
| 26 | ||
| 27 | const noop = () => {}; | |
| 28 | const context = () => | |
| 29 | new Proxy( | |
| 30 | { | |
| 31 | measureText: (text: string) => ({ width: text.length * 6 }), | |
| 32 | createLinearGradient: () => ({ addColorStop: noop }), | |
| 33 | } as Record<string, unknown>, | |
| 34 | { | |
| 35 | get(target, prop: string) { | |
| 36 | if (prop in target) return target[prop]; | |
| 37 | if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return ""; | |
| 38 | if (prop === "globalAlpha" || prop === "lineWidth") return 1; | |
| 39 | return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`); | |
| 40 | }, | |
| 41 | set: () => true, | |
| 42 | }, | |
| 43 | ); | |
| 44 | ||
| 45 | function element(id: string, attrs: Record<string, string> = {}) { | |
| 46 | const node: Record<string, unknown> = { | |
| 47 | id, | |
| 48 | dataset: { ...attrs }, | |
| 49 | style: {}, | |
| 50 | textContent: "", | |
| 51 | title: "", | |
| 52 | disabled: false, | |
| 53 | type: "", | |
| 54 | children: [] as unknown[], | |
| 55 | classList: { | |
| 56 | on: new Set<string>(), | |
| 57 | add(name: string) { | |
| 58 | (this.on as Set<string>).add(name); | |
| 59 | }, | |
| 60 | remove(name: string) { | |
| 61 | (this.on as Set<string>).delete(name); | |
| 62 | }, | |
| 63 | toggle(name: string, force?: boolean) { | |
| 64 | const on = force ?? !(this.on as Set<string>).has(name); | |
| 65 | if (on) (this.on as Set<string>).add(name); | |
| 66 | else (this.on as Set<string>).delete(name); | |
| 67 | return on; | |
| 68 | }, | |
| 69 | contains(name: string) { | |
| 70 | return (this.on as Set<string>).has(name); | |
| 71 | }, | |
| 72 | }, | |
| 73 | events: new Map<string, ((event: unknown) => void)[]>(), | |
| 74 | addEventListener(kind: string, fn: (event: unknown) => void) { | |
| 75 | const map = node.events as Map<string, ((event: unknown) => void)[]>; | |
| 76 | map.set(kind, [...(map.get(kind) ?? []), fn]); | |
| 77 | }, | |
| 78 | append(...kids: unknown[]) { | |
| 79 | (node.children as unknown[]).push(...kids); | |
| 80 | }, | |
| 81 | replaceChildren(...kids: unknown[]) { | |
| 82 | node.children = kids; | |
| 83 | }, | |
| 84 | getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }), | |
| 85 | getContext: () => context(), | |
| 86 | requestPointerLock: noop, | |
| 87 | setPointerCapture: noop, | |
| 88 | width: 900, | |
| 89 | height: 570, | |
| 90 | }; | |
| 91 | return node; | |
| 92 | } | |
| 93 | ||
| 94 | const click = (node: Record<string, unknown>) => { | |
| 95 | for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({}); | |
| 96 | }; | |
| 97 | const press = (code: string, key = "") => { | |
| 98 | for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop }); | |
| 99 | }; | |
| 100 | ||
| 101 | const nodes = new Map<string, Record<string, unknown>>(); | |
| 102 | for (const id of [ | |
| 103 | "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle", | |
| 104 | "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage", | |
| 105 | "log", "atkboard", "defboard", "helpagents", "roster", "feed", | |
| 106 | ]) | |
| 107 | nodes.set(id, element(id)); | |
| 108 | ||
| 109 | const buys = [ | |
| 110 | element("b1", { buy: "vandal", cost: "2900" }), | |
| 111 | element("b2", { buy: "c", cost: "0" }), | |
| 112 | element("b3", { buy: "q", cost: "0" }), | |
| 113 | ]; | |
| 114 | const agentButtons = [element("a1", { agent: "veil" })]; | |
| 115 | const scoreRed = element("red"); | |
| 116 | const scoreBlue = element("blue"); | |
| 117 | ||
| 118 | Object.assign(globalThis, { | |
| 119 | document: { | |
| 120 | getElementById: (id: string) => nodes.get(id) ?? null, | |
| 121 | querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue), | |
| 122 | querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons), | |
| 123 | createElement: (tag: string) => element(tag), | |
| 124 | addEventListener: noop, | |
| 125 | pointerLockElement: null, | |
| 126 | exitPointerLock: noop, | |
| 127 | }, | |
| 128 | HTMLInputElement: class {}, | |
| 129 | matchMedia: () => ({ matches: false }), | |
| 130 | devicePixelRatio: 1, | |
| 131 | localStorage: { | |
| 132 | store: new Map<string, string>(), | |
| 133 | getItem(key: string) { | |
| 134 | return this.store.get(key) ?? null; | |
| 135 | }, | |
| 136 | setItem(key: string, value: string) { | |
| 137 | this.store.set(key, value); | |
| 138 | }, | |
| 139 | }, | |
| 140 | requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn), | |
| 141 | addEventListener: (kind: string, fn: (event: unknown) => void) => { | |
| 142 | listeners.set(kind, [...(listeners.get(kind) ?? []), fn]); | |
| 143 | }, | |
| 144 | location: new URL(base), | |
| 145 | }); | |
| 146 | ||
| 147 | // Relative fetches, the way a page makes them. | |
| 148 | const realFetch = globalThis.fetch; | |
| 149 | globalThis.fetch = ((input: string, init?: RequestInit) => | |
| 150 | realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch; | |
| 151 | ||
| 152 | const runFrame = () => { | |
| 153 | const pending = frames.splice(0, frames.length); | |
| 154 | for (const fn of pending) fn(performance.now()); | |
| 155 | }; | |
| 156 | const until = async (what: () => boolean, ms = 4000) => { | |
| 157 | const stop = Date.now() + ms; | |
| 158 | while (Date.now() < stop) { | |
| 159 | runFrame(); | |
| 160 | if (what()) return true; | |
| 161 | await Bun.sleep(20); | |
| 162 | } | |
| 163 | return false; | |
| 164 | }; | |
| 165 | ||
| 166 | // ------------------------------------------------------------------ the run | |
| 167 | ||
| 168 | test("the bundled client connects, paints the HUD and draws a frame", async () => { | |
| 169 | const page = await (await fetch(base + "/")).text(); | |
| 170 | const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!; | |
| 171 | const script = await (await fetch(base + src)).text(); | |
| 172 | const path = join(process.env.FORGE_APP_DATA!, "client.mjs"); | |
| 173 | await Bun.write(path, script); | |
| 174 | ||
| 175 | await import(path); | |
| 176 | expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true); | |
| 177 | expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true); | |
| 178 | ||
| 179 | // The HUD says what the match says. | |
| 180 | expect(String(nodes.get("phase")!.textContent)).toContain("round"); | |
| 181 | expect(String(nodes.get("creds")!.textContent)).toContain("cr"); | |
| 182 | // The kit is four buttons, each named after the ability it casts. | |
| 183 | expect((nodes.get("kit")!.children as unknown[]).length).toBe(4); | |
| 184 | // One prompt, in words, about the round that is running. | |
| 185 | expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); | |
| 186 | // And the buy panel prices itself off the agent's real kit. | |
| 187 | expect(String(buys[1]!.title)).toContain("charge"); | |
| 188 | ||
| 189 | // The team is on screen whether anybody is in sight or not: five slots. | |
| 190 | const slots = nodes.get("roster")!.children as { className: string }[]; | |
| 191 | expect(slots.length).toBe(5); | |
| 192 | expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1); | |
| 193 | ||
| 194 | // Run the match on a few seconds: bots trade, and the feed fills with | |
| 195 | // names — and with nothing that says where anybody is. | |
| 196 | const arena = await import("./arena.ts"); | |
| 197 | for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) { | |
| 198 | arena.advance(5); | |
| 199 | await Bun.sleep(5); | |
| 200 | } | |
| 201 | const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[]; | |
| 202 | expect(feedRows.length).toBeGreaterThan(0); | |
| 203 | // killer ▸ victim, and the gun or the ability it was: four spans, no numbers. | |
| 204 | const row = feedRows[0]!.children.map((span) => span.textContent).join(""); | |
| 205 | expect(row).toContain(" ▸ "); | |
| 206 | expect(row).not.toMatch(/\d/); | |
| 207 | ||
| 208 | // A frame of the first-person view actually draws walls: a slice a column, | |
| 209 | // far more rectangles than the map has tiles. | |
| 210 | drawn.length = 0; | |
| 211 | runFrame(); | |
| 212 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); | |
| 213 | ||
| 214 | // Tab swaps that frame for the whole map — many fewer rectangles. | |
| 215 | for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); | |
| 216 | drawn.length = 0; | |
| 217 | runFrame(); | |
| 218 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800); | |
| 219 | for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); | |
| 220 | drawn.length = 0; | |
| 221 | runFrame(); | |
| 222 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); | |
| 223 | ||
| 224 | // Holding W reaches the server as a direction, and it is the way we face. | |
| 225 | const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; | |
| 226 | press("KeyW"); | |
| 227 | await Bun.sleep(160); | |
| 228 | expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9); | |
| 229 | expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4); | |
| 230 | ||
| 231 | // V falls back to the top-down view, and it draws too. | |
| 232 | press("KeyV"); | |
| 233 | drawn.length = 0; | |
| 234 | runFrame(); | |
| 235 | expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true); | |
| 236 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" }); | |
| 237 | press("KeyV"); | |
| 238 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" }); | |
| 239 | ||
| 240 | // H toggles the card either way, and the button closes it for good. | |
| 241 | const help = nodes.get("help")!.style as Record<string, string>; | |
| 242 | const before = help.display; | |
| 243 | press("KeyH"); | |
| 244 | expect(help.display).not.toBe(before); | |
| 245 | if (help.display !== "grid") press("KeyH"); | |
| 246 | expect(help.display).toBe("grid"); | |
| 247 | click(nodes.get("helpclose")!); | |
| 248 | expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none"); | |
| 249 | expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1"); | |
| 250 | ||
| 251 | // B works the buy panel either way — it opens itself in the buy phase. | |
| 252 | const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean }; | |
| 253 | const wasOpen = panel.contains("on"); | |
| 254 | press("KeyB"); | |
| 255 | expect(panel.contains("on")).toBe(!wasOpen); | |
| 256 | ||
| 257 | // And the socket really took a seat in the running match. | |
| 258 | expect(seat.bot).toBe(false); | |
| 259 | }, 15000); |
src/gun.test.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { fade, firedNow, gunSnapshot, gunTick, hitMarked, makeGun } from "../static/gun.js"; | |
| 3 | ||
| 4 | const snap = (over: Record<string, unknown> = {}, shots: unknown[] = []) => ({ | |
| 5 | me: { x: 100, y: 100, ammo: 25, reloading: false, alive: true, ...over }, | |
| 6 | shots, | |
| 7 | }); | |
| 8 | ||
| 9 | test("the magazine going down is a shot; a reload is not", () => { | |
| 10 | expect(firedNow(25, 24, false)).toBe(true); | |
| 11 | expect(firedNow(25, 25, false)).toBe(false); | |
| 12 | expect(firedNow(1, 25, true)).toBe(false); | |
| 13 | expect(firedNow(null, 25, false)).toBe(false); | |
| 14 | }); | |
| 15 | ||
| 16 | test("a hit marker comes from a shot of mine the server says landed", () => { | |
| 17 | const mine = [{ x1: 102, y1: 99, x2: 400, y2: 100, hit: true }]; | |
| 18 | expect(hitMarked(mine, { x: 100, y: 100 })).toBe(true); | |
| 19 | const missed = [{ x1: 102, y1: 99, x2: 400, y2: 100, hit: false }]; | |
| 20 | expect(hitMarked(missed, { x: 100, y: 100 })).toBe(false); | |
| 21 | const somebody_else = [{ x1: 900, y1: 900, x2: 400, y2: 100, hit: true }]; | |
| 22 | expect(hitMarked(somebody_else, { x: 100, y: 100 })).toBe(false); | |
| 23 | }); | |
| 24 | ||
| 25 | test("a snapshot with a shot in it kicks the gun, and the kick fades", () => { | |
| 26 | const gun = makeGun(); | |
| 27 | gunSnapshot(gun, snap()); | |
| 28 | expect(gun.kick).toBe(0); | |
| 29 | gunSnapshot(gun, snap({ ammo: 24 })); | |
| 30 | expect(gun.kick).toBe(1); | |
| 31 | gunTick(gun, 0.06); | |
| 32 | expect(gun.kick).toBeLessThan(1); | |
| 33 | gunTick(gun, 1); | |
| 34 | expect(gun.kick).toBe(0); | |
| 35 | }); | |
| 36 | ||
| 37 | test("a landed shot marks, and the mark fades on its own clock", () => { | |
| 38 | const gun = makeGun(); | |
| 39 | gunSnapshot(gun, snap({ ammo: 24 }, [{ x1: 100, y1: 100, x2: 300, y2: 100, hit: true }])); | |
| 40 | expect(gun.mark).toBe(1); | |
| 41 | gunTick(gun, 0.2); | |
| 42 | expect(gun.mark).toBeGreaterThan(0); | |
| 43 | gunTick(gun, 0.2); | |
| 44 | expect(gun.mark).toBe(0); | |
| 45 | }); | |
| 46 | ||
| 47 | test("fade never goes below nothing", () => { | |
| 48 | expect(fade(0.1, 10, 0.2)).toBe(0); | |
| 49 | }); |
src/killfeed.test.ts added +153
| @@ -0,0 +1,153 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { fallsBetween, marksOf, trimFeed, type Mark } from "./killfeed.ts"; | |
| 6 | import { newMatch } from "./match.ts"; | |
| 7 | ||
| 8 | process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); | |
| 9 | ||
| 10 | const mark = (id: string, over: Partial<Mark> = {}): Mark => ({ | |
| 11 | id, | |
| 12 | name: id, | |
| 13 | side: "atk", | |
| 14 | alive: true, | |
| 15 | kills: 0, | |
| 16 | weapon: "vandal", | |
| 17 | ...over, | |
| 18 | }); | |
| 19 | ||
| 20 | const context = (events: string[] = [], shooters: string[] = [], at = 10) => ({ | |
| 21 | at, | |
| 22 | events, | |
| 23 | shooters: new Set(shooters), | |
| 24 | }); | |
| 25 | ||
| 26 | describe("reading falls out of two instants", () => { | |
| 27 | test("a shot kill names the killer, the victim and the gun", () => { | |
| 28 | const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; | |
| 29 | const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })]; | |
| 30 | const falls = fallsBetween(before, after, context(["A killed B"], ["A"])); | |
| 31 | expect(falls).toEqual([{ killer: "A", victim: "B", weapon: "vandal", headshot: false, at: 10 }]); | |
| 32 | }); | |
| 33 | ||
| 34 | test("a head shot is marked as one", () => { | |
| 35 | const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; | |
| 36 | const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })]; | |
| 37 | expect(fallsBetween(before, after, context(["A headshot B"], ["A"]))[0]!.headshot).toBe(true); | |
| 38 | }); | |
| 39 | ||
| 40 | test("a kill with no shot behind it names no gun — a molly is not a Vandal", () => { | |
| 41 | const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; | |
| 42 | const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })]; | |
| 43 | expect(fallsBetween(before, after, context(["A killed B"]))[0]!.weapon).toBe(null); | |
| 44 | }); | |
| 45 | ||
| 46 | test("dying to nobody — a molly of your own, a fall — has no killer", () => { | |
| 47 | const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; | |
| 48 | const after = [mark("A", { side: "atk" }), mark("B", { side: "def", alive: false })]; | |
| 49 | const falls = fallsBetween(before, after, context(["B died"])); | |
| 50 | expect(falls.length).toBe(1); | |
| 51 | expect(falls[0]!.killer).toBe(null); | |
| 52 | expect(falls[0]!.weapon).toBe(null); | |
| 53 | }); | |
| 54 | ||
| 55 | test("two kills in one tick go to the right killers, by what the loop said", () => { | |
| 56 | const before = [ | |
| 57 | mark("A", { side: "atk" }), | |
| 58 | mark("C", { side: "atk" }), | |
| 59 | mark("B", { side: "def" }), | |
| 60 | mark("D", { side: "def" }), | |
| 61 | ]; | |
| 62 | const after = [ | |
| 63 | mark("A", { side: "atk", kills: 1 }), | |
| 64 | mark("C", { side: "atk", kills: 1 }), | |
| 65 | mark("B", { side: "def", alive: false }), | |
| 66 | mark("D", { side: "def", alive: false }), | |
| 67 | ]; | |
| 68 | const falls = fallsBetween(before, after, context(["C killed B", "A killed D"], ["A", "C"])); | |
| 69 | expect(falls.find((f) => f.victim === "B")!.killer).toBe("C"); | |
| 70 | expect(falls.find((f) => f.victim === "D")!.killer).toBe("A"); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a killer is never somebody on the victim's own side", () => { | |
| 74 | const before = [mark("A", { side: "atk" }), mark("B", { side: "atk" }), mark("E", { side: "def" })]; | |
| 75 | const after = [ | |
| 76 | mark("A", { side: "atk", kills: 1 }), | |
| 77 | mark("B", { side: "atk", alive: false }), | |
| 78 | mark("E", { side: "def" }), | |
| 79 | ]; | |
| 80 | expect(fallsBetween(before, after, context([]))[0]!.killer).toBe(null); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a kill with two victims and one killer only credits the one kill", () => { | |
| 84 | const before = [mark("A", { side: "atk" }), mark("B", { side: "def" }), mark("C", { side: "def" })]; | |
| 85 | const after = [ | |
| 86 | mark("A", { side: "atk", kills: 1 }), | |
| 87 | mark("B", { side: "def", alive: false }), | |
| 88 | mark("C", { side: "def", alive: false }), | |
| 89 | ]; | |
| 90 | const falls = fallsBetween(before, after, context(["A killed B"], ["A"])); | |
| 91 | expect(falls.filter((f) => f.killer === "A").length).toBe(1); | |
| 92 | expect(falls.filter((f) => f.killer === null).length).toBe(1); | |
| 93 | }); | |
| 94 | ||
| 95 | test("nothing happening is no falls", () => { | |
| 96 | const players = [mark("A"), mark("B", { side: "def" })]; | |
| 97 | expect(fallsBetween(players, players, context())).toEqual([]); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a body that was already dead does not fall twice", () => { | |
| 101 | const before = [mark("B", { side: "def", alive: false })]; | |
| 102 | expect(fallsBetween(before, before, context())).toEqual([]); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a fall never carries a position", () => { | |
| 106 | const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; | |
| 107 | const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })]; | |
| 108 | const fall = fallsBetween(before, after, context(["A killed B"], ["A"]))[0]!; | |
| 109 | expect(Object.keys(fall).sort()).toEqual(["at", "headshot", "killer", "victim", "weapon"]); | |
| 110 | }); | |
| 111 | ||
| 112 | test("marks come off the real players of a real match", () => { | |
| 113 | const marks = marksOf(newMatch().players); | |
| 114 | expect(marks.length).toBe(10); | |
| 115 | expect(marks.every((m) => m.alive && m.kills === 0 && typeof m.name === "string")).toBe(true); | |
| 116 | expect(JSON.stringify(marks)).not.toContain('"x"'); | |
| 117 | }); | |
| 118 | }); | |
| 119 | ||
| 120 | describe("the feed as it is shown", () => { | |
| 121 | const fall = (victim: string, at: number) => ({ killer: "A", victim, weapon: null, headshot: false, at }); | |
| 122 | ||
| 123 | test("keeps the last few and drops the stale", () => { | |
| 124 | const feed = [fall("a", 1), fall("b", 2), fall("c", 9), fall("d", 10)]; | |
| 125 | expect(trimFeed(feed, 10, 5, 8).map((f) => f.victim)).toEqual(["b", "c", "d"]); | |
| 126 | }); | |
| 127 | ||
| 128 | test("never grows past its slots", () => { | |
| 129 | const feed = [1, 2, 3, 4, 5, 6, 7].map((n) => fall(String(n), 10)); | |
| 130 | expect(trimFeed(feed, 10, 5, 8).map((f) => f.victim)).toEqual(["3", "4", "5", "6", "7"]); | |
| 131 | }); | |
| 132 | }); | |
| 133 | ||
| 134 | describe("the feed the arena sends", () => { | |
| 135 | test("bots killing each other reach the snapshot as names, with no positions", async () => { | |
| 136 | const { advance, match, snapshot } = await import("./arena.ts"); | |
| 137 | const viewer = match().players[0]!.id; | |
| 138 | let feed: { k: string | null; v: string; w: string | null; h: boolean; t: number }[] = []; | |
| 139 | for (let seconds = 0; seconds < 400 && feed.length === 0; seconds += 10) { | |
| 140 | advance(10); | |
| 141 | feed = JSON.parse(snapshot(viewer)).feed; | |
| 142 | } | |
| 143 | expect(feed.length).toBeGreaterThan(0); | |
| 144 | const names = match().players.map((p) => p.name); | |
| 145 | for (const line of feed) { | |
| 146 | expect(names).toContain(line.v); | |
| 147 | if (line.k !== null) expect(names).toContain(line.k); | |
| 148 | expect(line.t).toBeLessThanOrEqual(8); | |
| 149 | // Names, a gun, an age. Nothing that says where anybody is. | |
| 150 | expect(Object.keys(line).sort()).toEqual(["h", "k", "t", "v", "w"]); | |
| 151 | } | |
| 152 | }, 30000); | |
| 153 | }); |
src/killfeed.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * Who killed whom, read off the match rather than written into it. | |
| 3 | * | |
| 4 | * The round loop counts kills and deaths on the players and says a line | |
| 5 | * about it; it keeps no list. Rather than change the rules to carry one, | |
| 6 | * the arena compares the players before and after a tick: somebody who was | |
| 7 | * alive and is not is a victim, somebody whose kill count went up is a | |
| 8 | * killer, and the line the loop said settles which of them did it when two | |
| 9 | * fall in the same thirtieth of a second. | |
| 10 | * | |
| 11 | * Pure functions over plain marks. Nothing here knows a position, and a fall | |
| 12 | * never carries one — the feed must not be a way to learn where anybody is. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Player, Side } from "./state.ts"; | |
| 16 | import type { WeaponId } from "./weapons.ts"; | |
| 17 | ||
| 18 | /** Everything about a player this module needs, taken at one instant. */ | |
| 19 | export type Mark = { | |
| 20 | id: string; | |
| 21 | name: string; | |
| 22 | side: Side; | |
| 23 | alive: boolean; | |
| 24 | kills: number; | |
| 25 | weapon: WeaponId; | |
| 26 | }; | |
| 27 | ||
| 28 | export type Fall = { | |
| 29 | killer: string | null; | |
| 30 | victim: string; | |
| 31 | /** The killer's gun when the kill came from a shot; null when it did not. */ | |
| 32 | weapon: WeaponId | null; | |
| 33 | headshot: boolean; | |
| 34 | /** The match clock when it happened. */ | |
| 35 | at: number; | |
| 36 | }; | |
| 37 | ||
| 38 | export const marksOf = (players: Player[]): Mark[] => | |
| 39 | players.map((p) => ({ id: p.id, name: p.name, side: p.side, alive: p.alive, kills: p.kills, weapon: p.weapon })); | |
| 40 | ||
| 41 | export type Context = { | |
| 42 | at: number; | |
| 43 | /** The lines the round loop said this tick, or the most recent few. */ | |
| 44 | events: string[]; | |
| 45 | /** Players whose shot landed on this tick, so a molly is not called a gun. */ | |
| 46 | shooters: Set<string>; | |
| 47 | }; | |
| 48 | ||
| 49 | /** | |
| 50 | * The falls between two instants. Killers are matched to victims by the line | |
| 51 | * the loop said, and by side and a spare kill when there is no line. | |
| 52 | */ | |
| 53 | export function fallsBetween(before: Mark[], after: Mark[], context: Context): Fall[] { | |
| 54 | const was = new Map(before.map((m) => [m.id, m])); | |
| 55 | const falls: Fall[] = []; | |
| 56 | const credit = new Map<string, number>(); | |
| 57 | for (const mark of after) { | |
| 58 | const old = was.get(mark.id); | |
| 59 | if (old && mark.kills > old.kills) credit.set(mark.id, mark.kills - old.kills); | |
| 60 | } | |
| 61 | const named = (killer: Mark, victim: Mark): boolean => | |
| 62 | context.events.includes(`${killer.name} killed ${victim.name}`) || | |
| 63 | context.events.includes(`${killer.name} headshot ${victim.name}`); | |
| 64 | ||
| 65 | for (const mark of after) { | |
| 66 | const old = was.get(mark.id); | |
| 67 | if (!old || !old.alive || mark.alive) continue; | |
| 68 | const able = after.filter((m) => (credit.get(m.id) ?? 0) > 0 && m.side !== mark.side); | |
| 69 | const killer = able.find((m) => named(m, mark)) ?? able[0] ?? null; | |
| 70 | if (killer) credit.set(killer.id, (credit.get(killer.id) ?? 1) - 1); | |
| 71 | falls.push({ | |
| 72 | killer: killer ? killer.name : null, | |
| 73 | victim: mark.name, | |
| 74 | weapon: killer && context.shooters.has(killer.id) ? killer.weapon : null, | |
| 75 | headshot: killer ? context.events.includes(`${killer.name} headshot ${mark.name}`) : false, | |
| 76 | at: context.at, | |
| 77 | }); | |
| 78 | } | |
| 79 | return falls; | |
| 80 | } | |
| 81 | ||
| 82 | /** The feed as it is shown: the last few falls, and only the recent ones. */ | |
| 83 | export const trimFeed = (feed: Fall[], now: number, keep = 5, life = 8): Fall[] => | |
| 84 | feed.filter((f) => now - f.at <= life).slice(-keep); |
src/match.test.ts +26
| @@ -8,6 +8,7 @@ import { | ||
| 8 | 8 | DEFUSE_CHECKPOINT, |
| 9 | 9 | DEFUSE_TIME, |
| 10 | 10 | FUSE_TIME, |
| 11 | makePlayer, | |
| 11 | 12 | newMatch, |
| 12 | 13 | PLANT_TIME, |
| 13 | 14 | ROUND_TIME, |
| @@ -371,3 +372,28 @@ test("bots play the round: they buy, they take a site and they use their kit", ( | ||
| 371 | 372 | expect(state.players.some((p) => p.weapon !== "classic")).toBe(true); |
| 372 | 373 | expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0); |
| 373 | 374 | }); |
| 375 | ||
| 376 | ||
| 377 | test("no match ever has two players with the same name", () => { | |
| 378 | // The kill feed, the round log and the scoreboard all address people by | |
| 379 | // name; two Kits read as a team kill this game cannot have. A thousand | |
| 380 | // rosters, no collision. | |
| 381 | for (let seed = 0; seed < 1000; seed++) { | |
| 382 | const names = newMatch(seed).players.map((p) => p.name); | |
| 383 | expect(names.length).toBe(10); | |
| 384 | expect(new Set(names).size).toBe(10); | |
| 385 | } | |
| 386 | }); | |
| 387 | ||
| 388 | test("a seat made on its own still gets a name of its own", () => { | |
| 389 | const state = newMatch(1234); | |
| 390 | const names = new Set<string>(); | |
| 391 | for (const team of ["red", "blue"] as const) | |
| 392 | for (let i = 0; i < 5; i++) names.add(makePlayer(state, team, i).name); | |
| 393 | expect(names.size).toBe(10); | |
| 394 | }); | |
| 395 | ||
| 396 | test("the deal is the seeded shuffle, so the same match replays the same", () => { | |
| 397 | expect(newMatch(77).players.map((p) => p.name)).toEqual(newMatch(77).players.map((p) => p.name)); | |
| 398 | expect(newMatch(77).players.map((p) => p.name)).not.toEqual(newMatch(78).players.map((p) => p.name)); | |
| 399 | }); |
src/match.ts +35 −3
| @@ -69,9 +69,39 @@ const BOT_NAMES = [ | ||
| 69 | 69 | "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove", |
| 70 | 70 | ]; |
| 71 | 71 | |
| 72 | export function makePlayer(state: MatchState, team: Team, index: number): Player { | |
| 72 | /** | |
| 73 | * Twenty names, shuffled, dealt to the ten seats — drawn without replacement. | |
| 74 | * | |
| 75 | * A name is how a person is addressed: in the kill feed, in the round log, on | |
| 76 | * the scoreboard. Two bots called Kit make "Kit killed Kit", which reads as a | |
| 77 | * team kill that this game cannot even have. Picking each name independently | |
| 78 | * collided in most matches — a uniform pick over twenty names for ten seats | |
| 79 | * is a birthday problem, and no arithmetic spacing survives adding a uniform | |
| 80 | * term to it. Shuffling once and dealing cannot collide at all. | |
| 81 | */ | |
| 82 | export function dealNames(state: MatchState, count = TEAMS.length * ROSTER): string[] { | |
| 83 | const bag = BOT_NAMES.slice(); | |
| 84 | for (let i = bag.length - 1; i > 0; i--) { | |
| 85 | const j = Math.floor(random(state) * (i + 1)); | |
| 86 | const swap = bag[i]!; | |
| 87 | bag[i] = bag[j]!; | |
| 88 | bag[j] = swap; | |
| 89 | } | |
| 90 | return bag.slice(0, Math.min(count, bag.length)); | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * One seat. The name is dealt by the caller so a whole roster is drawn | |
| 95 | * without replacement; on its own it falls back to the seat's own slot, | |
| 96 | * which is distinct for every seat in a match too. | |
| 97 | */ | |
| 98 | export function makePlayer( | |
| 99 | state: MatchState, | |
| 100 | team: Team, | |
| 101 | index: number, | |
| 102 | name = BOT_NAMES[(TEAMS.indexOf(team) * ROSTER + index) % BOT_NAMES.length]!, | |
| 103 | ): Player { | |
| 73 | 104 | const agent = ["ember", "veil", "sable", "mender", "rook"][index % 5]!; |
| 74 | const name = BOT_NAMES[(TEAMS.indexOf(team) * 7 + index * 3 + Math.floor(random(state) * 20)) % BOT_NAMES.length]!; | |
| 75 | 105 | return { |
| 76 | 106 | id: `bot-${team}-${index}`, |
| 77 | 107 | name: `${name}`, |
| @@ -132,8 +162,10 @@ export function newMatch(seed = Date.now() % 100000): MatchState { | ||
| 132 | 162 | seed, |
| 133 | 163 | now: 0, |
| 134 | 164 | }; |
| 165 | const names = dealNames(state); | |
| 135 | 166 | for (const team of TEAMS) |
| 136 | for (let i = 0; i < ROSTER; i++) state.players.push(makePlayer(state, team, i)); | |
| 167 | for (let i = 0; i < ROSTER; i++) | |
| 168 | state.players.push(makePlayer(state, team, i, names[state.players.length])); | |
| 137 | 169 | startRound(state, true); |
| 138 | 170 | say(state, "Round 1 — buy phase"); |
| 139 | 171 | return state; |
src/pages/game.ts +83 −8
| @@ -70,6 +70,41 @@ export function gamePage(opts: { | ||
| 70 | 70 | #flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; } |
| 71 | 71 | #dead { position: absolute; inset: 0; display: none; place-items: center; background: #0008; |
| 72 | 72 | font-size: 1.1rem; letter-spacing: .1em; text-transform: uppercase; } |
| 73 | #prompt { position: absolute; left: 50%; transform: translateX(-50%); bottom: .6rem; | |
| 74 | max-width: 92%; padding: .35rem .7rem; border-radius: .4rem; background: #0b0d11cc; | |
| 75 | border: 1px solid #2f3541; font-size: .9rem; text-align: center; pointer-events: none; } | |
| 76 | #aimhint { position: absolute; inset: auto 0 45% 0; display: none; place-items: center; | |
| 77 | font-size: .85rem; letter-spacing: .08em; text-transform: uppercase; | |
| 78 | text-shadow: 0 1px 4px #000; pointer-events: none; } | |
| 79 | #help { position: fixed; inset: 0; z-index: 10; display: none; place-items: center; | |
| 80 | background: #080a0ef2; padding: .8rem; overflow: auto; } | |
| 81 | #help > div { max-width: 40rem; } | |
| 82 | #help h2 { font-size: 1rem; margin: 0 0 .4rem; letter-spacing: .1em; text-transform: uppercase; } | |
| 83 | #help p, #help ul { margin: .4rem 0; font-size: .88rem; } | |
| 84 | #help ul { padding-left: 1.1rem; } | |
| 85 | #help kbd { background: #222732; border: 1px solid #39404e; border-radius: .25rem; | |
| 86 | padding: 0 .3rem; font: inherit; font-size: .82rem; } | |
| 87 | #helpclose { min-height: 44px; padding: 0 1.1rem; margin-top: .5rem; background: #3a4356; | |
| 88 | color: var(--ink); border: 0; border-radius: .45rem; font: inherit; cursor: pointer; } | |
| 89 | #roster { display: grid; grid-template-columns: repeat(5, 1fr); gap: .35rem; margin: .5rem 0; | |
| 90 | min-height: 3.1rem; } | |
| 91 | .slot { background: #171a21; border: 1px solid #262b35; border-left: 3px solid #4a5162; | |
| 92 | border-radius: .35rem; padding: .2rem .35rem; font-size: .72rem; overflow: hidden; } | |
| 93 | .slot .who { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 94 | .slot .agent { display: block; opacity: .6; font-size: .66rem; white-space: nowrap; | |
| 95 | overflow: hidden; text-overflow: ellipsis; } | |
| 96 | .slot .hp { display: block; height: 5px; margin-top: .2rem; border-radius: 3px; background: #2a2f3a; } | |
| 97 | .slot .hp i { display: block; height: 100%; border-radius: 3px; background: #6fcf6f; } | |
| 98 | .slot.mine { border-color: #6f8ecf; background: #1b2130; } | |
| 99 | .slot.down { opacity: .42; filter: grayscale(1); } | |
| 100 | .slot.down .who { text-decoration: line-through; } | |
| 101 | .slot .spike { color: #f0d060; } | |
| 102 | #feed { position: absolute; top: .5rem; right: .5rem; width: min(17rem, 52%); display: grid; | |
| 103 | gap: .18rem; justify-items: end; pointer-events: none; font-size: .76rem; } | |
| 104 | #feed div { background: #0b0d11cc; border: 1px solid #2f3541; border-radius: .3rem; | |
| 105 | padding: .1rem .4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| 106 | max-width: 100%; } | |
| 107 | #feed .k { color: #8ef0c4; } #feed .v { color: #e39a95; } #feed .w { opacity: .65; } | |
| 73 | 108 | #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; |
| 74 | 109 | font-variant-numeric: tabular-nums; } |
| 75 | 110 | .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem; |
| @@ -118,9 +153,42 @@ export function gamePage(opts: { | ||
| 118 | 153 | <div id="stage"> |
| 119 | 154 | <canvas id="view" width="900" height="570"></canvas> |
| 120 | 155 | <div id="flash"></div> |
| 156 | <div id="aimhint">click to aim · esc releases</div> | |
| 121 | 157 | <div id="dead">waiting for the next round</div> |
| 158 | <div id="feed"></div> | |
| 159 | <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div> | |
| 160 | <div id="help" role="dialog" aria-label="How to play val"><div> | |
| 161 | <h2>How to play</h2> | |
| 162 | <p><strong>The round.</strong> Attackers carry the spike to site A or B and plant it; | |
| 163 | defenders stop them, or defuse it once it is down. Plant and it runs a 45s fuse. | |
| 164 | First team to 13 rounds wins, and the sides swap at 12.</p> | |
| 165 | <p><strong>The teams.</strong> You are <span class="red">red</span> or | |
| 166 | <span class="blue">blue</span>; each round one team attacks and the other defends. | |
| 167 | Team mates carry a name tag over their head, enemies never do.</p> | |
| 168 | <p><strong>Controls.</strong> <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move · | |
| 169 | click the view to capture the mouse and look, <kbd>Esc</kbd> releases · | |
| 170 | click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up · | |
| 171 | <kbd>Tab</kbd> holds the big map open · <kbd>V</kbd> swaps to the top-down view · | |
| 172 | <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back.</p> | |
| 173 | <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and | |
| 174 | last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is | |
| 175 | the ultimate, paid for in ult points you earn by playing.</p> | |
| 176 | <p><strong>Telling people apart.</strong> Your five are always listed under the view with their | |
| 177 | health — struck through when they are down — and the last few kills scroll in the top right. | |
| 178 | In the world an enemy wears a red chevron over their head and a hard dark outline, a team mate | |
| 179 | wears their name and a health pip, and anybody lying flat on the floor is dead. On the map your | |
| 180 | team is always shown (crossed out when down) and enemies appear only while you can see them.</p> | |
| 181 | <p><strong>On a phone</strong> the view stays top-down: the left thumb walks, the right | |
| 182 | thumb aims and fires, and the buttons under the map buy and cast.</p> | |
| 183 | <ul id="helpagents"></ul> | |
| 184 | <button id="helpclose" type="button">Got it — play</button> | |
| 185 | </div></div> | |
| 122 | 186 | </div> |
| 123 | 187 | |
| 188 | <div id="roster" aria-label="your team">${["", "", "", "", ""] | |
| 189 | .map(() => `<div class="slot"><span class="who">—</span><span class="hp"><i style="width:0"></i></span></div>`) | |
| 190 | .join("")}</div> | |
| 191 | ||
| 124 | 192 | <div id="hud"> |
| 125 | 193 | <span class="chip" id="hp">100 hp</span> |
| 126 | 194 | <span class="chip" id="gun">Classic 12</span> |
| @@ -128,6 +196,8 @@ export function gamePage(opts: { | ||
| 128 | 196 | <span class="chip" id="ultmeter">ult 0/6</span> |
| 129 | 197 | <span class="kit" id="kit"></span> |
| 130 | 198 | <button id="buytoggle" class="chip" type="button">buy (B)</button> |
| 199 | <button id="viewtoggle" class="chip" type="button">view (V)</button> | |
| 200 | <button id="helptoggle" class="chip" type="button">how to play (H)</button> | |
| 131 | 201 | </div> |
| 132 | 202 | |
| 133 | 203 | <div id="buy"> |
| @@ -136,13 +206,16 @@ export function gamePage(opts: { | ||
| 136 | 206 | (a) => `<button type="button" data-agent="${escape(a.id)}" title="${escape(a.blurb)}">${escape(a.name)} <small>${escape(a.role)}</small></button>`, |
| 137 | 207 | ).join("")}</div> |
| 138 | 208 | <div class="buys" id="guns">${weaponIds() |
| 139 | .map((id) => `<button type="button" data-buy="${id}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`) | |
| 209 | .map( | |
| 210 | (id) => | |
| 211 | `<button type="button" data-buy="${id}" data-cost="${WEAPONS[id].cost}" title="${escape(WEAPONS[id].name)} — ${WEAPONS[id].cost} credits, ${WEAPONS[id].magazine} rounds, ${WEAPONS[id].auto ? "automatic" : "semi-automatic"}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`, | |
| 212 | ) | |
| 140 | 213 | .join("")}</div> |
| 141 | 214 | <div class="buys" id="gear"> |
| 142 | <button type="button" data-buy="light">Light shield <b>400</b></button> | |
| 143 | <button type="button" data-buy="heavy">Heavy shield <b>1000</b></button> | |
| 144 | <button type="button" data-buy="c">Ability C</button> | |
| 145 | <button type="button" data-buy="q">Ability Q</button> | |
| 215 | <button type="button" data-buy="light" data-cost="400" title="25 armour that soaks a quarter of what hits you">Light shield <b>400</b></button> | |
| 216 | <button type="button" data-buy="heavy" data-cost="1000" title="50 armour that soaks half of what hits you">Heavy shield <b>1000</b></button> | |
| 217 | <button type="button" data-buy="c" data-cost="0">C ability <b>—</b></button> | |
| 218 | <button type="button" data-buy="q" data-cost="0">Q ability <b>—</b></button> | |
| 146 | 219 | </div> |
| 147 | 220 | </div> |
| 148 | 221 | |
| @@ -162,9 +235,11 @@ export function gamePage(opts: { | ||
| 162 | 235 | </div> |
| 163 | 236 | |
| 164 | 237 | <footer> |
| 165 | <p><strong>Controls.</strong> WASD move · mouse aims · click fires · R reloads · | |
| 166 | <strong>F</strong> plants and defuses (hold it) · C / Q / E / X cast · B opens the buy. | |
| 167 | On a phone: left thumb walks, right thumb aims and fires, the buttons under the map do the rest.</p> | |
| 238 | <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look, | |
| 239 | Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) · | |
| 240 | C / Q / E / X cast · Tab holds the map open · V swaps to the top-down view · | |
| 241 | B opens the buy · H shows how to play. | |
| 242 | On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p> | |
| 168 | 243 | <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse, |
| 169 | 244 | 7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat |
| 170 | 245 | nobody is in, and they use their kit.</p> |
src/prompt.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { choosePrompt } from "../static/prompt.js"; | |
| 3 | ||
| 4 | type Snap = ReturnType<typeof snapshot>; | |
| 5 | ||
| 6 | const snapshot = (over: Record<string, unknown> = {}, me: Record<string, unknown> = {}, spike: Record<string, unknown> = {}) => ({ | |
| 7 | phase: "live", | |
| 8 | clock: 40, | |
| 9 | result: null, | |
| 10 | me: { side: "atk", alive: true, blind: 0, planting: 0, spike: false, ...me }, | |
| 11 | spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: 100, y: 100, ...spike }, | |
| 12 | ...over, | |
| 13 | }); | |
| 14 | ||
| 15 | describe("the one prompt on screen", () => { | |
| 16 | test("the buy phase says what to do and how long there is", () => { | |
| 17 | const text = choosePrompt(snapshot({ phase: "buy", clock: 14.2 })); | |
| 18 | expect(text).toBe("Buy phase — 15s left. Click a gun you can afford."); | |
| 19 | }); | |
| 20 | ||
| 21 | test("the terminal is told about keys, not clicks", () => { | |
| 22 | const text = choosePrompt(snapshot({ phase: "buy", clock: 14.2 }), { input: "keys" }); | |
| 23 | expect(text).toBe("Buy phase — 15s left. Press b and buy a gun you can afford."); | |
| 24 | }); | |
| 25 | ||
| 26 | test("carrying the spike off a site sends you to one", () => { | |
| 27 | expect(choosePrompt(snapshot({}, { spike: true }), { onSite: false })).toBe( | |
| 28 | "You have the spike — get to site A or B", | |
| 29 | ); | |
| 30 | }); | |
| 31 | ||
| 32 | test("carrying it onto a site says how to plant", () => { | |
| 33 | expect(choosePrompt(snapshot({}, { spike: true }), { onSite: true })).toBe("Hold F to plant"); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a defender near a planted spike is told to defuse, with the fuse", () => { | |
| 37 | const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 31.4 }), { nearSpike: true }); | |
| 38 | expect(text).toBe("Spike down, 32s — hold F to defuse"); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a defender across the map is told to get to it", () => { | |
| 42 | const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 31.4 }), { nearSpike: false }); | |
| 43 | expect(text).toBe("Spike down, 32s — get to it and hold F"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("an attacker with it planted is told to hold the site", () => { | |
| 47 | expect(choosePrompt(snapshot({}, {}, { planted: true, fuse: 10 }))).toBe( | |
| 48 | "Spike down, 10s — keep them off it", | |
| 49 | ); | |
| 50 | }); | |
| 51 | ||
| 52 | test("blind beats everything else on screen", () => { | |
| 53 | const text = choosePrompt(snapshot({}, { blind: 1.4, spike: true }), { onSite: true }); | |
| 54 | expect(text).toBe("You are blind — back off until it clears"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("dead in a live round says you are still in the match", () => { | |
| 58 | expect(choosePrompt(snapshot({}, { alive: false }))).toBe("Waiting for the next round — you're in it"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("the end of a round shows the result and the countdown", () => { | |
| 62 | const text = choosePrompt(snapshot({ phase: "end", clock: 4.1, result: "Spike defused" })); | |
| 63 | expect(text).toBe("Spike defused — next round in 5s"); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a live defender is told what defending is", () => { | |
| 67 | expect(choosePrompt(snapshot({}, { side: "def" }))).toBe("Defending — hold A and B, 40s"); | |
| 68 | }); | |
| 69 | ||
| 70 | test("an attacker beside a loose spike is told to walk onto it", () => { | |
| 71 | expect(choosePrompt(snapshot(), { nearSpike: true })).toBe("Walk onto the spike to pick it up"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("defusing counts down the defuse, not the fuse", () => { | |
| 75 | const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 20, defuse: 3.5 }), { | |
| 76 | nearSpike: true, | |
| 77 | }); | |
| 78 | expect(text).toBe("Defusing — keep holding F, 4s"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("no snapshot yet is no prompt", () => { | |
| 82 | expect(choosePrompt(null as unknown as Snap)).toBe(""); | |
| 83 | }); | |
| 84 | }); |
src/raycast.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { | |
| 3 | angleColumn, | |
| 4 | bandLight, | |
| 5 | castRay, | |
| 6 | columnAngle, | |
| 7 | columnOffset, | |
| 8 | FOV, | |
| 9 | hitResult, | |
| 10 | project, | |
| 11 | projection, | |
| 12 | projectResult, | |
| 13 | sliceHeight, | |
| 14 | smokeAlong, | |
| 15 | spriteColumnVisible, | |
| 16 | wallLight, | |
| 17 | } from "../static/raycast.js"; | |
| 18 | ||
| 19 | const W = 900; | |
| 20 | ||
| 21 | describe("columns and angles", () => { | |
| 22 | test("the middle column looks straight ahead", () => { | |
| 23 | expect(columnOffset(W / 2 - 0.5, W)).toBeCloseTo(0, 6); | |
| 24 | expect(columnAngle(W / 2 - 0.5, W, 1.2)).toBeCloseTo(1.2, 6); | |
| 25 | }); | |
| 26 | ||
| 27 | test("the outer columns sit at the edges of the field of view", () => { | |
| 28 | expect(columnOffset(0, W)).toBeCloseTo(-FOV / 2, 2); | |
| 29 | expect(columnOffset(W - 1, W)).toBeCloseTo(FOV / 2, 2); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a column maps to an angle and back again", () => { | |
| 33 | for (const col of [0, 123, 450, 899]) { | |
| 34 | expect(angleColumn(columnOffset(col, W), W)).toBeCloseTo(col + 0.5, 4); | |
| 35 | } | |
| 36 | }); | |
| 37 | ||
| 38 | test("columns are not spread evenly — that is what keeps walls straight", () => { | |
| 39 | const middle = columnOffset(451, W) - columnOffset(450, W); | |
| 40 | const edge = columnOffset(1, W) - columnOffset(0, W); | |
| 41 | expect(edge).toBeLessThan(middle); | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("wall slices", () => { | |
| 46 | test("a wall twice as far is half as tall", () => { | |
| 47 | const proj = projection(W); | |
| 48 | expect(sliceHeight(160, proj, 80)).toBeCloseTo(sliceHeight(80, proj, 80) / 2, 6); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a north–south face is darker than an east–west one at the same range", () => { | |
| 52 | expect(wallLight(200, 1, 80)).toBeLessThan(wallLight(200, 0, 80)); | |
| 53 | }); | |
| 54 | ||
| 55 | test("further is dimmer, and never fully black", () => { | |
| 56 | expect(wallLight(900, 0, 80)).toBeLessThan(wallLight(120, 0, 80)); | |
| 57 | expect(wallLight(5000, 1, 80)).toBeGreaterThan(0); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the floor is dimmest at the horizon", () => { | |
| 61 | expect(bandLight(2, 200)).toBeLessThan(bandLight(180, 200)); | |
| 62 | }); | |
| 63 | }); | |
| 64 | ||
| 65 | // A four-by-four room: solid border, open inside. | |
| 66 | const room = (col: number, row: number): boolean => col <= 0 || row <= 0 || col >= 3 || row >= 3; | |
| 67 | ||
| 68 | describe("castRay", () => { | |
| 69 | const out = hitResult(); | |
| 70 | ||
| 71 | test("finds the wall straight ahead and calls it an east–west face", () => { | |
| 72 | castRay(room, 120, 120, 0, 80, 2000, out); | |
| 73 | expect(out.wall).toBe(true); | |
| 74 | expect(out.dist).toBeCloseTo(120, 6); // from x=120 to the face at x=240 | |
| 75 | expect(out.side).toBe(0); | |
| 76 | expect(out.col).toBe(3); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a ray upward strikes a north–south face", () => { | |
| 80 | castRay(room, 120, 120, -Math.PI / 2, 80, 2000, out); | |
| 81 | expect(out.wall).toBe(true); | |
| 82 | expect(out.dist).toBeCloseTo(40, 6); | |
| 83 | expect(out.side).toBe(1); | |
| 84 | expect(out.row).toBe(0); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a ray that runs out of range reports no wall", () => { | |
| 88 | castRay(room, 120, 120, 0, 80, 50, out); | |
| 89 | expect(out.wall).toBe(false); | |
| 90 | expect(out.dist).toBe(50); | |
| 91 | }); | |
| 92 | ||
| 93 | test("standing inside a wall is zero distance, not an infinite loop", () => { | |
| 94 | castRay(room, 10, 10, 0.3, 80, 2000, out); | |
| 95 | expect(out.dist).toBe(0); | |
| 96 | expect(out.wall).toBe(true); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a diagonal ray agrees with walking the same line by hand", () => { | |
| 100 | castRay(room, 120, 120, Math.PI / 4, 80, 2000, out); | |
| 101 | let step = 0; | |
| 102 | while (step < 2000 && !room(Math.floor((120 + Math.cos(Math.PI / 4) * step) / 80), Math.floor((120 + Math.sin(Math.PI / 4) * step) / 80))) | |
| 103 | step += 0.25; | |
| 104 | expect(out.dist).toBeCloseTo(step, 0); | |
| 105 | }); | |
| 106 | ||
| 107 | test("it fills the caller's object and allocates nothing", () => { | |
| 108 | const same = castRay(room, 120, 120, 0, 80, 2000, out); | |
| 109 | expect(same).toBe(out); | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("projecting a body onto the screen", () => { | |
| 114 | const proj = projection(W); | |
| 115 | const out = projectResult(); | |
| 116 | ||
| 117 | test("something dead ahead lands in the middle of the screen", () => { | |
| 118 | project(100, 100, 0, 500, 100, W, proj, 80, out); | |
| 119 | expect(out.behind).toBe(false); | |
| 120 | expect(out.column).toBeCloseTo(W / 2, 4); | |
| 121 | expect(out.perp).toBeCloseTo(400, 6); | |
| 122 | }); | |
| 123 | ||
| 124 | test("something to the right lands right of centre", () => { | |
| 125 | project(100, 100, 0, 500, 260, W, proj, 80, out); | |
| 126 | expect(out.column).toBeGreaterThan(W / 2); | |
| 127 | }); | |
| 128 | ||
| 129 | test("something behind the camera is not drawn at all", () => { | |
| 130 | project(100, 100, 0, -400, 100, W, proj, 80, out); | |
| 131 | expect(out.behind).toBe(true); | |
| 132 | expect(out.size).toBe(0); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the further body is the smaller one", () => { | |
| 136 | project(100, 100, 0, 300, 100, W, proj, 80, out); | |
| 137 | const near = out.size; | |
| 138 | project(100, 100, 0, 900, 100, W, proj, 80, out); | |
| 139 | expect(out.size).toBeLessThan(near); | |
| 140 | }); | |
| 141 | ||
| 142 | test("perpendicular depth, not straight-line distance, so the edges do not bulge", () => { | |
| 143 | project(100, 100, 0, 500, 300, W, proj, 80, out); | |
| 144 | expect(out.perp).toBeLessThan(out.dist); | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | describe("z-clip", () => { | |
| 149 | test("a body nearer than the wall behind it is drawn", () => { | |
| 150 | expect(spriteColumnVisible(300, 900)).toBe(true); | |
| 151 | }); | |
| 152 | ||
| 153 | test("a body behind the corner is hidden", () => { | |
| 154 | expect(spriteColumnVisible(900, 300)).toBe(false); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a body flush against the wall does not bleed through it", () => { | |
| 158 | expect(spriteColumnVisible(299, 300)).toBe(false); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("smoke as a volume", () => { | |
| 163 | test("a ray through the middle of a smoke is fully fogged", () => { | |
| 164 | expect(smokeAlong(0, 0, 1, 0, 400, 0, 110, 1000)).toBe(1); | |
| 165 | }); | |
| 166 | ||
| 167 | test("a ray that misses it is not fogged at all", () => { | |
| 168 | expect(smokeAlong(0, 0, 1, 0, 400, 400, 110, 1000)).toBe(0); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a wall in front of the smoke is not fogged by it", () => { | |
| 172 | expect(smokeAlong(0, 0, 1, 0, 400, 0, 110, 200)).toBe(0); | |
| 173 | }); | |
| 174 | ||
| 175 | test("clipping the edge fogs less than going through the middle", () => { | |
| 176 | const edge = smokeAlong(0, 0, 1, 0, 400, 100, 110, 1000); | |
| 177 | expect(edge).toBeGreaterThan(0); | |
| 178 | expect(edge).toBeLessThan(1); | |
| 179 | }); | |
| 180 | ||
| 181 | test("standing in the middle of one is nearly opaque in every direction", () => { | |
| 182 | expect(smokeAlong(400, 0, 1, 0, 400, 0, 110, 1000)).toBeGreaterThan(0.9); | |
| 183 | expect(smokeAlong(400, 0, 0, -1, 400, 0, 110, 1000)).toBeGreaterThan(0.9); | |
| 184 | }); | |
| 185 | }); |
src/roster.test.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { roster, rosterKey } from "../static/roster.js"; | |
| 3 | ||
| 4 | const snapshot = (players: unknown[]) => ({ | |
| 5 | me: { id: "p3" }, | |
| 6 | players, | |
| 7 | }); | |
| 8 | ||
| 9 | const mate = (id: string, over: Record<string, unknown> = {}) => ({ | |
| 10 | id, | |
| 11 | name: id.toUpperCase(), | |
| 12 | agent: "veil", | |
| 13 | hp: 100, | |
| 14 | alive: true, | |
| 15 | mate: true, | |
| 16 | spike: false, | |
| 17 | ...over, | |
| 18 | }); | |
| 19 | ||
| 20 | describe("the team roster", () => { | |
| 21 | test("is your own side, everybody, seen or not", () => { | |
| 22 | const rows = roster( | |
| 23 | snapshot([mate("p1"), mate("p2"), mate("p3"), mate("p4"), mate("p5"), mate("e1", { mate: false })]), | |
| 24 | ); | |
| 25 | expect(rows.length).toBe(5); | |
| 26 | expect(rows.some((r) => r.id === "e1")).toBe(false); | |
| 27 | }); | |
| 28 | ||
| 29 | test("keeps one order whatever order the snapshot arrives in", () => { | |
| 30 | const one = roster(snapshot([mate("p3"), mate("p1"), mate("p2")])); | |
| 31 | const two = roster(snapshot([mate("p2"), mate("p3"), mate("p1")])); | |
| 32 | expect(one.map((r) => r.id)).toEqual(two.map((r) => r.id)); | |
| 33 | expect(one.map((r) => r.id)).toEqual(["p1", "p2", "p3"]); | |
| 34 | }); | |
| 35 | ||
| 36 | test("marks the dead and gives them no health", () => { | |
| 37 | const rows = roster(snapshot([mate("p1", { alive: false, hp: 37 })])); | |
| 38 | expect(rows[0]!.alive).toBe(false); | |
| 39 | expect(rows[0]!.hp).toBe(0); | |
| 40 | }); | |
| 41 | ||
| 42 | test("clamps a health the server rounded past the ends", () => { | |
| 43 | expect(roster(snapshot([mate("p1", { hp: 140 })]))[0]!.hp).toBe(100); | |
| 44 | expect(roster(snapshot([mate("p1", { hp: -5 })]))[0]!.hp).toBe(0); | |
| 45 | }); | |
| 46 | ||
| 47 | test("knows which row is you, and who has the spike", () => { | |
| 48 | const rows = roster(snapshot([mate("p1"), mate("p3", { spike: true })])); | |
| 49 | expect(rows.find((r) => r.id === "p3")!.mine).toBe(true); | |
| 50 | expect(rows.find((r) => r.id === "p3")!.spike).toBe(true); | |
| 51 | expect(rows.find((r) => r.id === "p1")!.mine).toBe(false); | |
| 52 | }); | |
| 53 | ||
| 54 | test("no snapshot is no rows, not a crash", () => { | |
| 55 | expect(roster(null)).toEqual([]); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the key changes when anything on the panel changes, and not otherwise", () => { | |
| 59 | const before = roster(snapshot([mate("p1", { hp: 80 })])); | |
| 60 | expect(rosterKey(before)).toBe(rosterKey(roster(snapshot([mate("p1", { hp: 80 })])))); | |
| 61 | expect(rosterKey(before)).not.toBe(rosterKey(roster(snapshot([mate("p1", { hp: 40 })])))); | |
| 62 | expect(rosterKey(before)).not.toBe(rosterKey(roster(snapshot([mate("p1", { hp: 80, alive: false })])))); | |
| 63 | }); | |
| 64 | }); |
src/scene.test.ts added +231
| @@ -0,0 +1,231 @@ | ||
| 1 | /** | |
| 2 | * The renderer, run for real against a canvas that writes down what it was | |
| 3 | * asked to draw. No browser, but every line of the draw path is executed — | |
| 4 | * which is what catches a typo in a frame nobody can see from a unit test. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { drawGun, makeGun } from "../static/gun.js"; | |
| 9 | import { drawScene, makeScene } from "../static/scene.js"; | |
| 10 | import { drawTopdown } from "../static/topdown.js"; | |
| 11 | import { gridRows, TILE } from "./map.ts"; | |
| 12 | ||
| 13 | type Op = { op: string; args: unknown[] }; | |
| 14 | ||
| 15 | /** A canvas that records. Every call the renderer makes has to exist here. */ | |
| 16 | function fakeContext() { | |
| 17 | const ops: Op[] = []; | |
| 18 | const note = | |
| 19 | (op: string) => | |
| 20 | (...args: unknown[]) => { | |
| 21 | ops.push({ op, args }); | |
| 22 | }; | |
| 23 | const ctx = { | |
| 24 | ops, | |
| 25 | fillStyle: "", | |
| 26 | strokeStyle: "", | |
| 27 | lineWidth: 1, | |
| 28 | font: "", | |
| 29 | textAlign: "left", | |
| 30 | globalAlpha: 1, | |
| 31 | clearRect: note("clearRect"), | |
| 32 | fillRect: note("fillRect"), | |
| 33 | strokeRect: note("strokeRect"), | |
| 34 | beginPath: note("beginPath"), | |
| 35 | closePath: note("closePath"), | |
| 36 | moveTo: note("moveTo"), | |
| 37 | lineTo: note("lineTo"), | |
| 38 | arc: note("arc"), | |
| 39 | ellipse: note("ellipse"), | |
| 40 | fill: note("fill"), | |
| 41 | stroke: note("stroke"), | |
| 42 | clip: note("clip"), | |
| 43 | rect: note("rect"), | |
| 44 | save: note("save"), | |
| 45 | restore: note("restore"), | |
| 46 | translate: note("translate"), | |
| 47 | rotate: note("rotate"), | |
| 48 | fillText: note("fillText"), | |
| 49 | measureText: (text: string) => ({ width: text.length * 6 }), | |
| 50 | createLinearGradient: () => ({ addColorStop() {} }), | |
| 51 | }; | |
| 52 | return ctx; | |
| 53 | } | |
| 54 | ||
| 55 | // A room three tiles deep running east, with a pillar standing in it at | |
| 56 | // column 5 — enough geometry for a body to hide behind. | |
| 57 | const room = (col: number, row: number): boolean => | |
| 58 | col < 1 || col > 12 || row < 1 || row > 3 || col === 5; | |
| 59 | const world = { solid: room, tile: TILE }; | |
| 60 | ||
| 61 | const snapshot = (players: unknown[], zones: unknown[] = []) => ({ | |
| 62 | phase: "live", | |
| 63 | clock: 40, | |
| 64 | result: null, | |
| 65 | me: { id: "me", x: 120, y: 200, angle: 0, alive: true, side: "atk", hp: 100, blind: 0, ammo: 25, reloading: false, planting: 0, spike: false, agent: "ember" }, | |
| 66 | players, | |
| 67 | zones, | |
| 68 | orbs: [], | |
| 69 | shots: [], | |
| 70 | spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: null, y: null }, | |
| 71 | }); | |
| 72 | ||
| 73 | const body = (over: Record<string, unknown> = {}) => ({ | |
| 74 | id: "them", | |
| 75 | name: "Sable", | |
| 76 | agent: "sable", | |
| 77 | x: 280, | |
| 78 | y: 200, | |
| 79 | angle: 3.1, | |
| 80 | hp: 100, | |
| 81 | alive: true, | |
| 82 | mate: false, | |
| 83 | spike: false, | |
| 84 | blind: false, | |
| 85 | ...over, | |
| 86 | }); | |
| 87 | ||
| 88 | const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p }; | |
| 89 | const eye = { x: 120, y: 200, angle: 0 }; | |
| 90 | ||
| 91 | test("a frame draws walls: one filled column per screen column, and the horizon", () => { | |
| 92 | const ctx = fakeContext(); | |
| 93 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view); | |
| 94 | const rects = ctx.ops.filter((o) => o.op === "fillRect"); | |
| 95 | // The two bands and the horizon, then a slice for every column of the wall. | |
| 96 | expect(rects.length).toBeGreaterThan(800); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a body in the open is drawn; the same body behind the pillar is not", () => { | |
| 100 | const open = fakeContext(); | |
| 101 | drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 102 | // The head is the only arc in this scene, so it stands for the body. | |
| 103 | expect(open.ops.some((o) => o.op === "arc")).toBe(true); | |
| 104 | ||
| 105 | const hidden = fakeContext(); | |
| 106 | drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view); | |
| 107 | expect(hidden.ops.some((o) => o.op === "arc")).toBe(false); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a team mate gets a name tag and an enemy never does", () => { | |
| 111 | const mate = fakeContext(); | |
| 112 | drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); | |
| 113 | expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true); | |
| 114 | ||
| 115 | const enemy = fakeContext(); | |
| 116 | drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 117 | expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 118 | }); | |
| 119 | ||
| 120 | test("the dead lie on the floor and wear no markers at all", () => { | |
| 121 | const ctx = fakeContext(); | |
| 122 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view); | |
| 123 | // Flat on the ground: an ellipse, not an upright body with a head. | |
| 124 | expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true); | |
| 125 | // No name tag, and no chevron — the chevron is the only closed path here. | |
| 126 | expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 127 | expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 128 | }); | |
| 129 | ||
| 130 | test("an enemy wears a chevron and a team mate does not", () => { | |
| 131 | const enemy = fakeContext(); | |
| 132 | drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 133 | expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true); | |
| 134 | ||
| 135 | const mate = fakeContext(); | |
| 136 | drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); | |
| 137 | expect(mate.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 138 | expect(mate.ops.some((o) => o.op === "fillText")).toBe(true); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a body has a hard dark edge, whichever side it is on", () => { | |
| 142 | const ctx = fakeContext(); | |
| 143 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 144 | expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a smoke between the eye and a body hides it", () => { | |
| 148 | const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }]; | |
| 149 | const ctx = fakeContext(); | |
| 150 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view); | |
| 151 | expect(ctx.ops.some((o) => o.op === "arc")).toBe(false); | |
| 152 | ||
| 153 | // A smoke that is not on the line does not hide anything. | |
| 154 | const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }]; | |
| 155 | const clear = fakeContext(); | |
| 156 | drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view); | |
| 157 | expect(clear.ops.some((o) => o.op === "arc")).toBe(true); | |
| 158 | }); | |
| 159 | ||
| 160 | test("every kind of zone, the spike and a tracer draw without falling over", () => { | |
| 161 | const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({ | |
| 162 | k, | |
| 163 | x: 300 + i * 20, | |
| 164 | y: 200, | |
| 165 | r: 90, | |
| 166 | f: k === "flash" ? 0.3 : 0, | |
| 167 | l: 5, | |
| 168 | })); | |
| 169 | const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones); | |
| 170 | snap.orbs = [{ x: 360, y: 200 }] as never; | |
| 171 | snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never; | |
| 172 | snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never; | |
| 173 | const ctx = fakeContext(); | |
| 174 | drawScene(ctx as never, makeScene(), world, eye, snap, view); | |
| 175 | expect(ctx.ops.length).toBeGreaterThan(100); | |
| 176 | expect(ctx.globalAlpha).toBe(1); | |
| 177 | }); | |
| 178 | ||
| 179 | test("the top-down view draws the real map, small or large", () => { | |
| 180 | const map = { rows: gridRows(), tile: TILE }; | |
| 181 | const snap = snapshot([body({ mate: true })]); | |
| 182 | const big = fakeContext(); | |
| 183 | drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 }); | |
| 184 | const small = fakeContext(); | |
| 185 | drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false }); | |
| 186 | expect(big.ops.some((o) => o.op === "fillText")).toBe(true); | |
| 187 | expect(small.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 188 | expect(small.ops.some((o) => o.op === "translate")).toBe(true); | |
| 189 | }); | |
| 190 | ||
| 191 | test("on the map a dead team mate is crossed out and a dead enemy is not there", () => { | |
| 192 | const map = { rows: gridRows(), tile: TILE }; | |
| 193 | const box = { x: 0, y: 0, w: 900, h: 570 }; | |
| 194 | const look = (p: never) => p; | |
| 195 | ||
| 196 | const mate = fakeContext(); | |
| 197 | drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box); | |
| 198 | // Two strokes through the dot, and no dot: a cross is not a live player. | |
| 199 | expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2); | |
| 200 | expect(mate.ops.some((o) => o.op === "arc")).toBe(false); | |
| 201 | ||
| 202 | const enemy = fakeContext(); | |
| 203 | drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box); | |
| 204 | const empty = fakeContext(); | |
| 205 | drawTopdown(empty as never, map, snapshot([]), look, box); | |
| 206 | expect(enemy.ops.length).toBe(empty.ops.length); | |
| 207 | }); | |
| 208 | ||
| 209 | test("a living team mate carries their initial on the big map", () => { | |
| 210 | const map = { rows: gridRows(), tile: TILE }; | |
| 211 | const big = fakeContext(); | |
| 212 | drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, { | |
| 213 | x: 0, | |
| 214 | y: 0, | |
| 215 | w: 900, | |
| 216 | h: 570, | |
| 217 | }); | |
| 218 | expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true); | |
| 219 | }); | |
| 220 | ||
| 221 | test("the gun and the crosshair draw, and the hit marker only when there is one", () => { | |
| 222 | const gun = makeGun(); | |
| 223 | const ctx = fakeContext(); | |
| 224 | drawGun(ctx as never, gun, 900, 570, snapshot([])); | |
| 225 | const quiet = ctx.ops.length; | |
| 226 | gun.mark = 1; | |
| 227 | gun.kick = 1; | |
| 228 | const loud = fakeContext(); | |
| 229 | drawGun(loud as never, gun, 900, 570, snapshot([])); | |
| 230 | expect(loud.ops.length).toBeGreaterThan(quiet); | |
| 231 | }); |
src/server.test.ts +44 −1
| @@ -6,7 +6,7 @@ import { join } from "node:path"; | ||
| 6 | 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 7 | 7 | process.env.PORT = "0"; |
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | const { match, snapshot, advance } = await import("./arena.ts"); | |
| 9 | const { match, snapshot, advance, takeSeat } = await import("./arena.ts"); | |
| 10 | 10 | const base = `http://${server.hostname}:${server.port}`; |
| 11 | 11 | |
| 12 | 12 | test("serves a live match on the first paint, with no script needed", async () => { |
| @@ -123,6 +123,11 @@ test("a snapshot only carries the enemies that viewer can see", () => { | ||
| 123 | 123 | viewer.x = 1200; |
| 124 | 124 | viewer.y = 1320; |
| 125 | 125 | viewer.blind = 0; |
| 126 | // Both of them alive on purpose: the arena is one live match that other | |
| 127 | // tests run forward, and a dead enemy is invisible for a different reason | |
| 128 | // than the one this test is about. | |
| 129 | viewer.alive = true; | |
| 130 | enemy.alive = true; | |
| 126 | 131 | enemy.x = 1200; |
| 127 | 132 | enemy.y = 1240; |
| 128 | 133 | enemy.revealed = 0; |
| @@ -176,3 +181,41 @@ test("the match keeps running and is never parked", () => { | ||
| 176 | 181 | expect(state.now).toBeGreaterThan(before); |
| 177 | 182 | expect(["buy", "live", "end", "over"]).toContain(state.phase); |
| 178 | 183 | }); |
| 184 | ||
| 185 | test("the kit is public: every ability, its price and what it does", async () => { | |
| 186 | const response = await fetch(base + "/agents.json"); | |
| 187 | expect(response.status).toBe(200); | |
| 188 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 189 | const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] }; | |
| 190 | expect(body.agents.length).toBe(5); | |
| 191 | const veil = body.agents.find((a) => a.id === "veil")!; | |
| 192 | expect(veil.abilities.c.name).toBe("Cloudburst"); | |
| 193 | expect(veil.abilities.c.cost).toBe(100); | |
| 194 | expect(veil.abilities.e.text).toContain("smoke"); | |
| 195 | // The ultimate is never for sale, and no server-only state leaks out. | |
| 196 | expect(veil.abilities.x.cost).toBe(0); | |
| 197 | expect(JSON.stringify(body)).not.toContain("kind"); | |
| 198 | }); | |
| 199 | ||
| 200 | test("the page carries the first-run card and the prompt line", async () => { | |
| 201 | const body = await (await fetch(base + "/")).text(); | |
| 202 | expect(body).toContain('id="help"'); | |
| 203 | expect(body).toContain("How to play"); | |
| 204 | expect(body).toContain('id="prompt"'); | |
| 205 | // Every buy button says what it costs, so the panel is readable at a glance. | |
| 206 | expect(body).toContain('data-buy="vandal" data-cost="2900"'); | |
| 207 | expect(body).toContain('id="viewtoggle"'); | |
| 208 | }); | |
| 209 | ||
| 210 | test("two people who pick the same name do not end up with the same name", () => { | |
| 211 | // Everything that addresses a player — the kill feed, the round log, the | |
| 212 | // scoreboard — does it by name, so the match may never hold two of one. | |
| 213 | const first = takeSeat("clash-a", "Twin")!; | |
| 214 | const second = takeSeat("clash-b", "Twin")!; | |
| 215 | expect(first.name).toBe("Twin"); | |
| 216 | expect(second.name).not.toBe("Twin"); | |
| 217 | expect(second.name.startsWith("Twin")).toBe(true); | |
| 218 | expect(second.name.length).toBeLessThanOrEqual(16); | |
| 219 | const names = match().players.map((p) => p.name); | |
| 220 | expect(new Set(names).size).toBe(names.length); | |
| 221 | }); |
src/server.ts +44 −4
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { createHash, randomUUID } from "node:crypto"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | import { AGENTS, SLOTS } from "./abilities.ts"; | |
| 12 | 13 | import { attach, command, findPlayer, match, release, snapshot, startArena, takeSeat, type Command } from "./arena.ts"; |
| 13 | 14 | import { recentResults } from "./db.ts"; |
| 14 | 15 | import { gridRows, TILE } from "./map.ts"; |
| @@ -103,10 +104,17 @@ async function readForm(request: Request): Promise<FormData | undefined> { | ||
| 103 | 104 | |
| 104 | 105 | // ------------------------------------------------------------- the client |
| 105 | 106 | |
| 106 | // The client script is named by its content: a change is a new URL, so a | |
| 107 | // year-long cache is never stale. | |
| 108 | const scriptPath = join(import.meta.dir, "..", "static", "val.js"); | |
| 109 | const scriptBody = await Bun.file(scriptPath).text(); | |
| 107 | // The client is a handful of small modules under static/ — the renderer, the | |
| 108 | // map, the crosshair, the ray maths — bundled into one file at start-up so a | |
| 109 | // browser makes one request and the maths can still be imported by the tests. | |
| 110 | // It is named by its content: a change is a new URL, so a year-long cache is | |
| 111 | // never stale. | |
| 112 | const built = await Bun.build({ | |
| 113 | entrypoints: [join(import.meta.dir, "..", "static", "val.js")], | |
| 114 | target: "browser", | |
| 115 | }); | |
| 116 | if (!built.success) throw new AggregateError(built.logs, "the client would not build"); | |
| 117 | const scriptBody = await built.outputs[0]!.text(); | |
| 110 | 118 | const scriptHash = createHash("sha256").update(scriptBody).digest("hex").slice(0, 10); |
| 111 | 119 | const scriptUrl = `/static/val.${scriptHash}.js`; |
| 112 | 120 | |
| @@ -192,6 +200,38 @@ export const server = Bun.serve<SocketData, Record<string, never>>({ | ||
| 192 | 200 | : response; |
| 193 | 201 | } |
| 194 | 202 | |
| 203 | // The agents and their kits: what every ability does, its price and its | |
| 204 | // charges, so the buy panel can label itself. The same table the server | |
| 205 | // plays by, and no secret in it. | |
| 206 | if (url.pathname === "/agents.json" && (request.method === "GET" || request.method === "HEAD")) | |
| 207 | return Response.json( | |
| 208 | { | |
| 209 | agents: AGENTS.map((a) => ({ | |
| 210 | id: a.id, | |
| 211 | name: a.name, | |
| 212 | role: a.role, | |
| 213 | blurb: a.blurb, | |
| 214 | abilities: Object.fromEntries( | |
| 215 | SLOTS.map((slot) => { | |
| 216 | const ability = a.abilities[slot]; | |
| 217 | return [ | |
| 218 | slot, | |
| 219 | { | |
| 220 | name: ability.name, | |
| 221 | cost: ability.cost, | |
| 222 | charges: ability.charges, | |
| 223 | cooldown: ability.cooldown, | |
| 224 | points: ability.points, | |
| 225 | text: ability.text, | |
| 226 | }, | |
| 227 | ]; | |
| 228 | }), | |
| 229 | ), | |
| 230 | })), | |
| 231 | }, | |
| 232 | { headers: { "cache-control": "public, max-age=300", ...SECURITY } }, | |
| 233 | ); | |
| 234 | ||
| 195 | 235 | // The map is the same for everybody and changes only when the code does. |
| 196 | 236 | if (url.pathname === "/map.json" && (request.method === "GET" || request.method === "HEAD")) |
| 197 | 237 | return Response.json( |
src/steer.test.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { moveVector } from "../static/steer.js"; | |
| 3 | ||
| 4 | test("W walks the way you are looking, whichever way that is", () => { | |
| 5 | const east = moveVector(0, -1, 0); | |
| 6 | expect(east.x).toBeCloseTo(1, 6); | |
| 7 | expect(east.y).toBeCloseTo(0, 6); | |
| 8 | const south = moveVector(0, -1, Math.PI / 2); | |
| 9 | expect(south.x).toBeCloseTo(0, 6); | |
| 10 | expect(south.y).toBeCloseTo(1, 6); | |
| 11 | }); | |
| 12 | ||
| 13 | test("D strafes across the facing, not along it", () => { | |
| 14 | const right = moveVector(1, 0, 0); | |
| 15 | expect(right.x).toBeCloseTo(0, 6); | |
| 16 | expect(right.y).toBeCloseTo(1, 6); | |
| 17 | const other = moveVector(-1, 0, 0); | |
| 18 | expect(other.y).toBeCloseTo(-1, 6); | |
| 19 | }); | |
| 20 | ||
| 21 | test("forward and strafe together keep their length", () => { | |
| 22 | const both = moveVector(1, -1, 0.7); | |
| 23 | expect(Math.hypot(both.x, both.y)).toBeCloseTo(Math.SQRT2, 6); | |
| 24 | }); | |
| 25 | ||
| 26 | test("standing still stays still", () => { | |
| 27 | const still = moveVector(0, 0, 2.2); | |
| 28 | expect(still.x).toBeCloseTo(0, 6); | |
| 29 | expect(still.y).toBeCloseTo(0, 6); | |
| 30 | }); | |
| 31 | ||
| 32 | test("it fills the object it was given, so the input loop allocates nothing", () => { | |
| 33 | const out = { x: 0, y: 0 }; | |
| 34 | expect(moveVector(0, -1, 0, out)).toBe(out); | |
| 35 | }); |
src/tty.ts +29 −1
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | * The map is the same grid, one character a tile. Plain ANSI, no dependencies. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { choosePrompt, REACH } from "../static/prompt.js"; | |
| 11 | ||
| 10 | 12 | const base = |
| 11 | 13 | process.env.VAL_URL ?? |
| 12 | 14 | (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ?? |
| @@ -38,6 +40,7 @@ let status = "connecting"; | ||
| 38 | 40 | let aim = -Math.PI / 2; |
| 39 | 41 | let move = { mx: 0, my: 0, until: 0 }; |
| 40 | 42 | let firing = 0; |
| 43 | let helping = false; | |
| 41 | 44 | let using = 0; |
| 42 | 45 | let buying = false; |
| 43 | 46 | |
| @@ -71,6 +74,28 @@ async function connect(): Promise<void> { | ||
| 71 | 74 | |
| 72 | 75 | const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`; |
| 73 | 76 | |
| 77 | /** The same line the page shows, from the same function. */ | |
| 78 | function prompt(s: Snapshot): string { | |
| 79 | const row = map ? map.rows[Math.floor(s.me.y / map.tile)] : ""; | |
| 80 | const cell = row ? row[Math.floor(s.me.x / map.tile)] : ""; | |
| 81 | return choosePrompt(s, { | |
| 82 | input: "keys", | |
| 83 | onSite: cell === "A" || cell === "B", | |
| 84 | nearSpike: | |
| 85 | s.spike.x !== null && s.spike.y !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH, | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | const HOW_TO = [ | |
| 90 | "How to play — attackers carry the spike to site A or B and plant it (hold f);", | |
| 91 | "defenders stop them, or hold f on a planted spike to defuse it. 45s fuse, first to 13.", | |
| 92 | "· wasd walks, ←/→ turns the aim, space fires, r reloads", | |
| 93 | "· c q e x cast the kit: c and q are bought in the buy phase, e is free on a cooldown, x is the ultimate", | |
| 94 | "· b opens the buy — the same prices the page charges, refused server-side if you cannot afford them", | |
| 95 | "· A and B on the map are the sites, @ is you, cyan is a team mate, red is an enemy you can see", | |
| 96 | "h closes this.", | |
| 97 | ]; | |
| 98 | ||
| 74 | 99 | function draw(): void { |
| 75 | 100 | if (!map) { |
| 76 | 101 | out(`${esc}2J${esc}Hval — ${status}\r\n`); |
| @@ -101,6 +126,7 @@ function draw(): void { | ||
| 101 | 126 | lines.push( |
| 102 | 127 | `${me.alive ? colour(`${me.hp}hp${me.armour ? "+" + me.armour : ""}`, 32) : colour("dead", 90)} ${me.weapon} ${me.ammo} ${me.credits}cr ult ${me.ult}/${me.ultNeeds} C${me.charges.c} Q${me.charges.q} E${me.charges.e}${me.spike ? colour(" SPIKE", 33) : ""}${me.blind > 0 ? colour(" BLIND", 97) : ""} aim ${((aim * 180) / Math.PI).toFixed(0)}°`, |
| 103 | 128 | ); |
| 129 | lines.push(colour("› " + prompt(snap), 93)); | |
| 104 | 130 | } else lines.push(`val — ${status}`); |
| 105 | 131 | lines.push(""); |
| 106 | 132 | for (const row of rows) lines.push(" " + row.join("")); |
| @@ -117,10 +143,11 @@ function draw(): void { | ||
| 117 | 143 | for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90)); |
| 118 | 144 | } |
| 119 | 145 | lines.push(""); |
| 146 | if (helping) for (const line of HOW_TO) lines.push(colour(line, 96)); | |
| 120 | 147 | lines.push( |
| 121 | 148 | buying |
| 122 | 149 | ? colour("buy: 1 Classic 2 Ghost 3 Sheriff 4 Spectre 5 Phantom 6 Vandal 7 Operator · 8 light 9 heavy · c/q ability · b closes", 33) |
| 123 | : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · q quit", 90), | |
| 150 | : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · h how to play · q quit", 90), | |
| 124 | 151 | ); |
| 125 | 152 | out(`${esc}H${esc}J` + lines.join("\r\n")); |
| 126 | 153 | } |
| @@ -146,6 +173,7 @@ process.stdin.on("data", (key: string) => { | ||
| 146 | 173 | return; |
| 147 | 174 | } |
| 148 | 175 | if (key === "b") return void (buying = true); |
| 176 | if (key === "h" || key === "?") return void (helping = !helping); | |
| 149 | 177 | if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 }; |
| 150 | 178 | if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 }; |
| 151 | 179 | if (key === "a") move = { mx: -1, my: move.my, until: now + 400 }; |
static/beep.js added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * The spike, out loud. | |
| 3 | * | |
| 4 | * One oscillator per beep, scheduled by how much fuse is left: a slow tick | |
| 5 | * when it is planted, a stutter when it is about to go. No audio files, and | |
| 6 | * nothing is created until the person has clicked the page — a browser will | |
| 7 | * not start an audio context before that, and should not. | |
| 8 | */ | |
| 9 | ||
| 10 | /** Seconds between beeps for this much fuse left. */ | |
| 11 | export function beepInterval(fuse) { | |
| 12 | if (fuse <= 0) return 0; | |
| 13 | return Math.max(0.12, Math.min(1, fuse / 30)); | |
| 14 | } | |
| 15 | ||
| 16 | /** The pitch rises as the fuse burns, so it is audible without watching. */ | |
| 17 | export const beepPitch = (fuse) => 760 + 620 * (1 - Math.min(1, fuse / 45)); | |
| 18 | ||
| 19 | export function makeBeeper() { | |
| 20 | let audio = null; | |
| 21 | let next = 0; | |
| 22 | ||
| 23 | const context = () => { | |
| 24 | if (audio) return audio; | |
| 25 | const Ctor = globalThis.AudioContext || globalThis.webkitAudioContext; | |
| 26 | if (!Ctor) return null; | |
| 27 | audio = new Ctor(); | |
| 28 | return audio; | |
| 29 | }; | |
| 30 | ||
| 31 | return { | |
| 32 | /** Called on a click: browsers only allow audio to start from a gesture. */ | |
| 33 | wake() { | |
| 34 | const ctx = context(); | |
| 35 | if (ctx && ctx.state === "suspended") ctx.resume(); | |
| 36 | }, | |
| 37 | /** Called every frame with the fuse the server last sent. */ | |
| 38 | update(planted, fuse, now) { | |
| 39 | if (!planted || fuse <= 0) { | |
| 40 | next = 0; | |
| 41 | return; | |
| 42 | } | |
| 43 | if (!audio) return; // no gesture yet; nothing to play into | |
| 44 | if (now < next) return; | |
| 45 | next = now + beepInterval(fuse); | |
| 46 | beep(audio, beepPitch(fuse)); | |
| 47 | }, | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | function beep(audio, hz) { | |
| 52 | const osc = audio.createOscillator(); | |
| 53 | const gain = audio.createGain(); | |
| 54 | osc.type = "square"; | |
| 55 | osc.frequency.value = hz; | |
| 56 | gain.gain.setValueAtTime(0.0001, audio.currentTime); | |
| 57 | gain.gain.exponentialRampToValueAtTime(0.09, audio.currentTime + 0.01); | |
| 58 | gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.09); | |
| 59 | osc.connect(gain).connect(audio.destination); | |
| 60 | osc.start(); | |
| 61 | osc.stop(audio.currentTime + 0.1); | |
| 62 | } |
static/gun.js added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * The gun in your hands: a crosshair, a shape at the bottom of the screen | |
| 3 | * that kicks when you fire, and a marker when a shot of yours lands. | |
| 4 | * | |
| 5 | * The client never decides whether it hit anything — it reads that off the | |
| 6 | * shots the server sent, the same ones the top-down view draws. | |
| 7 | */ | |
| 8 | ||
| 9 | export const makeGun = () => ({ kick: 0, mark: 0, ammo: null }); | |
| 10 | ||
| 11 | /** Recoil and the hit marker both fade the same way: fast, and to zero. */ | |
| 12 | export const fade = (value, dt, life) => Math.max(0, value - dt / life); | |
| 13 | ||
| 14 | /** A shot happened when the magazine went down, never when it was reloaded. */ | |
| 15 | export const firedNow = (before, ammo, reloading) => | |
| 16 | before !== null && !reloading && ammo < before; | |
| 17 | ||
| 18 | /** One of my shots landed: it started where I am standing and the server says it hit. */ | |
| 19 | export function hitMarked(shots, me) { | |
| 20 | for (const shot of shots) { | |
| 21 | if (!shot.hit) continue; | |
| 22 | if (Math.hypot(shot.x1 - me.x, shot.y1 - me.y) < 30) return true; | |
| 23 | } | |
| 24 | return false; | |
| 25 | } | |
| 26 | ||
| 27 | /** Fold a fresh snapshot into the gun's state: kick on a shot, mark on a hit. */ | |
| 28 | export function gunSnapshot(gun, snap) { | |
| 29 | if (firedNow(gun.ammo, snap.me.ammo, snap.me.reloading)) gun.kick = 1; | |
| 30 | if (snap.me.alive && hitMarked(snap.shots, snap.me)) gun.mark = 1; | |
| 31 | gun.ammo = snap.me.ammo; | |
| 32 | } | |
| 33 | ||
| 34 | export function gunTick(gun, dt) { | |
| 35 | gun.kick = fade(gun.kick, dt, 0.12); | |
| 36 | gun.mark = fade(gun.mark, dt, 0.35); | |
| 37 | } | |
| 38 | ||
| 39 | /** The crosshair, the weapon and the marker, in that order of importance. */ | |
| 40 | export function drawGun(ctx, gun, width, height, snap) { | |
| 41 | const cx = width / 2; | |
| 42 | const cy = height / 2; | |
| 43 | const gap = 6 + gun.kick * 10; | |
| 44 | const arm = 9; | |
| 45 | ctx.lineWidth = 2; | |
| 46 | ctx.strokeStyle = "rgba(20,24,30,0.8)"; | |
| 47 | ctx.beginPath(); | |
| 48 | for (const [dx, dy] of [ | |
| 49 | [1, 0], | |
| 50 | [-1, 0], | |
| 51 | [0, 1], | |
| 52 | [0, -1], | |
| 53 | ]) { | |
| 54 | ctx.moveTo(cx + dx * gap, cy + dy * gap); | |
| 55 | ctx.lineTo(cx + dx * (gap + arm), cy + dy * (gap + arm)); | |
| 56 | } | |
| 57 | ctx.stroke(); | |
| 58 | ctx.strokeStyle = snap.me.alive ? "#8ef0c4" : "#888"; | |
| 59 | ctx.lineWidth = 1.4; | |
| 60 | ctx.stroke(); | |
| 61 | ||
| 62 | if (gun.mark > 0) { | |
| 63 | ctx.strokeStyle = `rgba(255,120,110,${gun.mark})`; | |
| 64 | ctx.lineWidth = 2.5; | |
| 65 | ctx.beginPath(); | |
| 66 | for (const [dx, dy] of [ | |
| 67 | [1, 1], | |
| 68 | [-1, -1], | |
| 69 | [1, -1], | |
| 70 | [-1, 1], | |
| 71 | ]) { | |
| 72 | ctx.moveTo(cx + dx * 7, cy + dy * 7); | |
| 73 | ctx.lineTo(cx + dx * 15, cy + dy * 15); | |
| 74 | } | |
| 75 | ctx.stroke(); | |
| 76 | } | |
| 77 | ||
| 78 | if (!snap.me.alive) return; | |
| 79 | // The weapon: a barrel and a body, off to the right, rising with the kick. | |
| 80 | const scale = height / 570; | |
| 81 | const lift = gun.kick * 22 * scale + (snap.me.reloading ? 40 * scale : 0); | |
| 82 | const bx = width * 0.66; | |
| 83 | const by = height + lift; | |
| 84 | ctx.fillStyle = "#23262e"; | |
| 85 | ctx.beginPath(); | |
| 86 | ctx.moveTo(bx - 34 * scale, by); | |
| 87 | ctx.lineTo(bx - 20 * scale, by - 130 * scale); | |
| 88 | ctx.lineTo(bx + 16 * scale, by - 150 * scale); | |
| 89 | ctx.lineTo(bx + 30 * scale, by - 40 * scale); | |
| 90 | ctx.lineTo(bx + 60 * scale, by); | |
| 91 | ctx.closePath(); | |
| 92 | ctx.fill(); | |
| 93 | ctx.fillStyle = "#3b414e"; | |
| 94 | ctx.fillRect(bx - 8 * scale, by - 160 * scale, 22 * scale, 26 * scale); | |
| 95 | if (gun.kick > 0.35) { | |
| 96 | ctx.fillStyle = `rgba(255,220,140,${gun.kick})`; | |
| 97 | ctx.beginPath(); | |
| 98 | ctx.arc(bx + 3 * scale, by - 162 * scale, 18 * scale * gun.kick, 0, 7); | |
| 99 | ctx.fill(); | |
| 100 | } | |
| 101 | } |
static/prompt.js added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * One line of advice, in the player's own words. | |
| 3 | * | |
| 4 | * A pure function of the snapshot the server sent plus two things the client | |
| 5 | * works out from the map it was given: whether you are standing on a site, | |
| 6 | * and whether you are within reach of the spike. Never more than one prompt — | |
| 7 | * the most urgent thing wins, because two instructions at once is none. | |
| 8 | */ | |
| 9 | ||
| 10 | /** Within this many units the server lets you pick up, plant or defuse. */ | |
| 11 | export const REACH = 46; | |
| 12 | ||
| 13 | const secs = (n) => Math.max(0, Math.ceil(n)); | |
| 14 | ||
| 15 | export function choosePrompt(snap, world = {}) { | |
| 16 | if (!snap) return ""; | |
| 17 | const me = snap.me; | |
| 18 | const spike = snap.spike; | |
| 19 | const onSite = world.onSite === true; | |
| 20 | const nearSpike = world.nearSpike === true; | |
| 21 | // The page is played with a mouse and the terminal with keys; one line | |
| 22 | // names the input, so it says the thing the reader can actually do. | |
| 23 | const buyWith = world.input === "keys" ? "Press b and buy a gun you can afford." : "Click a gun you can afford."; | |
| 24 | ||
| 25 | if (snap.phase === "over") return snap.result ? `Match over — ${snap.result}` : "Match over"; | |
| 26 | if (me.blind > 0.05) return "You are blind — back off until it clears"; | |
| 27 | if (!me.alive) | |
| 28 | return snap.phase === "buy" | |
| 29 | ? "Buy phase — you are back next round" | |
| 30 | : "Waiting for the next round — you're in it"; | |
| 31 | if (snap.phase === "end") return snap.result ? `${snap.result} — next round in ${secs(snap.clock)}s` : "Round over"; | |
| 32 | if (snap.phase === "buy") return `Buy phase — ${secs(snap.clock)}s left. ${buyWith}`; | |
| 33 | ||
| 34 | if (me.planting > 0) return `Planting — hold F for ${secs(4 - me.planting)}s more`; | |
| 35 | if (spike.planted) { | |
| 36 | if (me.side === "def") { | |
| 37 | if (spike.defuse > 0) return `Defusing — keep holding F, ${secs(7 - spike.defuse)}s`; | |
| 38 | return nearSpike | |
| 39 | ? `Spike down, ${secs(spike.fuse)}s — hold F to defuse` | |
| 40 | : `Spike down, ${secs(spike.fuse)}s — get to it and hold F`; | |
| 41 | } | |
| 42 | return `Spike down, ${secs(spike.fuse)}s — keep them off it`; | |
| 43 | } | |
| 44 | ||
| 45 | if (me.side === "atk") { | |
| 46 | if (me.spike) return onSite ? "Hold F to plant" : "You have the spike — get to site A or B"; | |
| 47 | if (spike.carried) return "Your team has the spike — go with them and take a site"; | |
| 48 | if (nearSpike) return "Walk onto the spike to pick it up"; | |
| 49 | return "Somebody has to carry the spike — it is on the ground"; | |
| 50 | } | |
| 51 | return `Defending — hold A and B, ${secs(snap.clock)}s`; | |
| 52 | } |
static/raycast.js added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * The ray maths. Pure numbers in, pure numbers out: no canvas, no DOM, no | |
| 3 | * state — so every decision the first-person view makes can be tested | |
| 4 | * without a browser. | |
| 5 | * | |
| 6 | * The camera is a flat projection plane at `projection()` pixels from the | |
| 7 | * eye. A column maps to an angle through that plane, not by sweeping the | |
| 8 | * field of view evenly, which is what keeps straight walls straight. Depth | |
| 9 | * is always the perpendicular distance to the plane: the fish-eye correction | |
| 10 | * and the sprite z-clip both depend on the two being the same measure. | |
| 11 | * | |
| 12 | * Nothing here allocates. The DDA writes its answer into a result object the | |
| 13 | * caller owns, so a 900-column frame allocates nothing at all. | |
| 14 | */ | |
| 15 | ||
| 16 | export const FOV = (75 * Math.PI) / 180; | |
| 17 | ||
| 18 | /** Pixels from the eye to the projection plane for a screen this wide. */ | |
| 19 | export const projection = (width, fov = FOV) => width / 2 / Math.tan(fov / 2); | |
| 20 | ||
| 21 | /** The angle of the ray through screen column `col`, relative to the view. */ | |
| 22 | export function columnOffset(col, width, fov = FOV) { | |
| 23 | const cameraX = (2 * (col + 0.5)) / width - 1; | |
| 24 | return Math.atan(cameraX * Math.tan(fov / 2)); | |
| 25 | } | |
| 26 | ||
| 27 | /** The world angle of the ray through screen column `col`. */ | |
| 28 | export const columnAngle = (col, width, view, fov = FOV) => view + columnOffset(col, width, fov); | |
| 29 | ||
| 30 | /** The screen column a world angle falls on, fractional, off-screen allowed. */ | |
| 31 | export function angleColumn(offset, width, fov = FOV) { | |
| 32 | return (width / 2) * (1 + Math.tan(offset) / Math.tan(fov / 2)); | |
| 33 | } | |
| 34 | ||
| 35 | /** A wall `tall` units high at this depth covers this many pixels. */ | |
| 36 | export const sliceHeight = (perp, proj, tall) => (tall * proj) / Math.max(perp, 1e-6); | |
| 37 | ||
| 38 | /** A fresh result object for `castRay` to fill. One per renderer, not per ray. */ | |
| 39 | export const hitResult = () => ({ dist: 0, side: 0, col: 0, row: 0, wall: false }); | |
| 40 | ||
| 41 | /** | |
| 42 | * March a ray through the tile grid until it meets a solid tile. | |
| 43 | * | |
| 44 | * `solid(col, row)` answers for the grid; `out` is filled with the distance | |
| 45 | * travelled, which side of the tile was struck (0 = an east or west face, | |
| 46 | * 1 = a north or south face) and the tile itself. A ray that runs out of | |
| 47 | * range reports `wall: false` at `max`. | |
| 48 | */ | |
| 49 | export function castRay(solid, x, y, angle, tile, max, out) { | |
| 50 | const dx = Math.cos(angle); | |
| 51 | const dy = Math.sin(angle); | |
| 52 | let col = Math.floor(x / tile); | |
| 53 | let row = Math.floor(y / tile); | |
| 54 | const deltaX = dx === 0 ? Infinity : Math.abs(tile / dx); | |
| 55 | const deltaY = dy === 0 ? Infinity : Math.abs(tile / dy); | |
| 56 | const stepX = dx < 0 ? -1 : 1; | |
| 57 | const stepY = dy < 0 ? -1 : 1; | |
| 58 | let sideX = dx === 0 ? Infinity : dx < 0 ? (x - col * tile) / -dx : ((col + 1) * tile - x) / dx; | |
| 59 | let sideY = dy === 0 ? Infinity : dy < 0 ? (y - row * tile) / -dy : ((row + 1) * tile - y) / dy; | |
| 60 | ||
| 61 | if (solid(col, row)) { | |
| 62 | out.dist = 0; | |
| 63 | out.side = 0; | |
| 64 | out.col = col; | |
| 65 | out.row = row; | |
| 66 | out.wall = true; | |
| 67 | return out; | |
| 68 | } | |
| 69 | for (;;) { | |
| 70 | let dist; | |
| 71 | let side; | |
| 72 | if (sideX < sideY) { | |
| 73 | dist = sideX; | |
| 74 | col += stepX; | |
| 75 | sideX += deltaX; | |
| 76 | side = 0; | |
| 77 | } else { | |
| 78 | dist = sideY; | |
| 79 | row += stepY; | |
| 80 | sideY += deltaY; | |
| 81 | side = 1; | |
| 82 | } | |
| 83 | if (dist > max) { | |
| 84 | out.dist = max; | |
| 85 | out.side = side; | |
| 86 | out.col = col; | |
| 87 | out.row = row; | |
| 88 | out.wall = false; | |
| 89 | return out; | |
| 90 | } | |
| 91 | if (solid(col, row)) { | |
| 92 | out.dist = dist; | |
| 93 | out.side = side; | |
| 94 | out.col = col; | |
| 95 | out.row = row; | |
| 96 | out.wall = true; | |
| 97 | return out; | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * How lit a wall slice is: one over the distance, floored so a far wall is | |
| 104 | * dim rather than black, and a north–south face darker than an east–west one | |
| 105 | * so a corner reads as a corner. | |
| 106 | */ | |
| 107 | export function wallLight(perp, side, tile) { | |
| 108 | const far = 1 / (1 + (perp / (tile * 7)) ** 2); | |
| 109 | const face = side === 1 ? 0.62 : 1; | |
| 110 | return Math.max(0.12, Math.min(1, 0.25 + 0.75 * far)) * face; | |
| 111 | } | |
| 112 | ||
| 113 | /** A floor or ceiling band dims the same way, by how far down the screen it is. */ | |
| 114 | export const bandLight = (rowFromHorizon, halfHeight) => | |
| 115 | Math.max(0.1, Math.min(1, rowFromHorizon / Math.max(1, halfHeight))); | |
| 116 | ||
| 117 | /** | |
| 118 | * Where a thing in the world lands on the screen. | |
| 119 | * | |
| 120 | * Returns the fractional column of its centre, its perpendicular depth and | |
| 121 | * the pixel height of something `tall` units high there. `behind` is true | |
| 122 | * when it is at or past the camera plane and must not be drawn at all. | |
| 123 | */ | |
| 124 | export function project(px, py, view, sx, sy, width, proj, tall, out, fov = FOV) { | |
| 125 | const dx = sx - px; | |
| 126 | const dy = sy - py; | |
| 127 | const dist = Math.hypot(dx, dy); | |
| 128 | let offset = Math.atan2(dy, dx) - view; | |
| 129 | while (offset > Math.PI) offset -= Math.PI * 2; | |
| 130 | while (offset < -Math.PI) offset += Math.PI * 2; | |
| 131 | const perp = dist * Math.cos(offset); | |
| 132 | out.dist = dist; | |
| 133 | out.offset = offset; | |
| 134 | out.perp = perp; | |
| 135 | out.behind = perp <= 1 || Math.abs(offset) > fov / 2 + 0.8; | |
| 136 | out.column = out.behind ? -1 : angleColumn(offset, width, fov); | |
| 137 | out.size = out.behind ? 0 : sliceHeight(perp, proj, tall); | |
| 138 | return out; | |
| 139 | } | |
| 140 | ||
| 141 | export const projectResult = () => ({ dist: 0, offset: 0, perp: 0, behind: true, column: -1, size: 0 }); | |
| 142 | ||
| 143 | /** | |
| 144 | * Is a sprite column in front of the wall drawn there? A sprite slightly | |
| 145 | * nearer than the wall still counts as hidden by a hair of slack, so a body | |
| 146 | * flush against a corner does not bleed through it. | |
| 147 | */ | |
| 148 | export const spriteColumnVisible = (spritePerp, wallPerp) => spritePerp < wallPerp - 2; | |
| 149 | ||
| 150 | /** | |
| 151 | * How much smoke a ray crosses between the eye and `dist`. | |
| 152 | * | |
| 153 | * The chord of the circle that falls inside the segment, as a fraction of the | |
| 154 | * circle's width: a ray that clips the edge of a smoke barely fogs, one that | |
| 155 | * goes through the middle fogs completely. That is what makes a smoke read as | |
| 156 | * a volume you are standing in rather than a disc painted on the floor. | |
| 157 | */ | |
| 158 | export function smokeAlong(x, y, dx, dy, cx, cy, r, dist) { | |
| 159 | const ox = x - cx; | |
| 160 | const oy = y - cy; | |
| 161 | const b = ox * dx + oy * dy; | |
| 162 | const c = ox * ox + oy * oy - r * r; | |
| 163 | const disc = b * b - c; | |
| 164 | if (disc <= 0) return 0; | |
| 165 | const root = Math.sqrt(disc); | |
| 166 | const enter = Math.max(0, -b - root); | |
| 167 | const leave = Math.min(dist, -b + root); | |
| 168 | if (leave <= enter) return 0; | |
| 169 | return Math.min(1, (leave - enter) / (r * 1.1)); | |
| 170 | } |
static/roster.js added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | /** | |
| 2 | * Your five, always. | |
| 3 | * | |
| 4 | * Who is on your team and who is still standing must not depend on whether | |
| 5 | * you can see them — for most of a round you cannot see anybody. Every team | |
| 6 | * mate is in every snapshot already (the server shows you your own side), so | |
| 7 | * this is a read of what is there: a fixed order, health clamped, and the | |
| 8 | * dead marked. | |
| 9 | */ | |
| 10 | ||
| 11 | /** The player's own team, in a stable order, ready to draw as rows. */ | |
| 12 | export function roster(snap) { | |
| 13 | if (!snap || !snap.players) return []; | |
| 14 | return snap.players | |
| 15 | .filter((p) => p.mate) | |
| 16 | .map((p) => ({ | |
| 17 | id: p.id, | |
| 18 | name: p.name, | |
| 19 | agent: p.agent, | |
| 20 | hp: p.alive ? Math.max(0, Math.min(100, p.hp)) : 0, | |
| 21 | alive: p.alive === true, | |
| 22 | spike: p.spike === true, | |
| 23 | mine: p.id === snap.me.id, | |
| 24 | })) | |
| 25 | .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); | |
| 26 | } | |
| 27 | ||
| 28 | /** Enough of the roster to know when the panel has to be rebuilt. */ | |
| 29 | export const rosterKey = (rows) => | |
| 30 | rows.map((r) => `${r.id}:${r.agent}:${Math.round(r.hp)}:${r.alive ? 1 : 0}:${r.spike ? 1 : 0}`).join("|"); |
static/scene.js added +389
| @@ -0,0 +1,389 @@ | ||
| 1 | /** | |
| 2 | * The world, drawn from inside the player's head. | |
| 3 | * | |
| 4 | * One pass of rays across the screen builds the walls and a depth buffer; | |
| 5 | * everything else — bodies, the spike, ability zones, tracers — is a | |
| 6 | * billboard tested against that buffer, so a body behind a corner is not | |
| 7 | * drawn at all. Smoke is measured along each ray, not painted as a disc: | |
| 8 | * cross one and everything past it fogs. | |
| 9 | * | |
| 10 | * The ray loop allocates nothing. Colours come out of a table built once, | |
| 11 | * the depth and fog buffers are kept between frames, and the sprite list is | |
| 12 | * sorted in a buffer the scene owns. | |
| 13 | */ | |
| 14 | ||
| 15 | import { | |
| 16 | castRay, | |
| 17 | columnOffset, | |
| 18 | hitResult, | |
| 19 | project, | |
| 20 | projection, | |
| 21 | projectResult, | |
| 22 | sliceHeight, | |
| 23 | smokeAlong, | |
| 24 | spriteColumnVisible, | |
| 25 | wallLight, | |
| 26 | } from "./raycast.js"; | |
| 27 | ||
| 28 | const EYE = 40; // half a tile up: the horizon sits on the middle of the wall | |
| 29 | const WALL = 80; // walls are one tile tall | |
| 30 | const BODY = 58; // a person, in world units | |
| 31 | const MAX_SIGHT = 1800; | |
| 32 | const LEVELS = 48; | |
| 33 | ||
| 34 | export const AGENT_COLOUR = { | |
| 35 | ember: "#f08a4b", | |
| 36 | veil: "#8f7fe8", | |
| 37 | sable: "#4fd6a0", | |
| 38 | mender: "#e0c060", | |
| 39 | rook: "#d96a8f", | |
| 40 | }; | |
| 41 | ||
| 42 | // Wall colours, quantised into levels and built once, so the ray loop never | |
| 43 | // builds a string. How dark a slice is comes from `wallLight`, which already | |
| 44 | // knows about distance and which face was struck. | |
| 45 | const SHADES = (() => { | |
| 46 | const out = []; | |
| 47 | for (let i = 0; i < LEVELS; i++) { | |
| 48 | const k = (i + 0.5) / LEVELS; | |
| 49 | out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`); | |
| 50 | } | |
| 51 | return out; | |
| 52 | })(); | |
| 53 | const FOG = "rgb(198,203,212)"; | |
| 54 | ||
| 55 | export function makeScene() { | |
| 56 | return { | |
| 57 | depth: new Float32Array(0), | |
| 58 | fog: new Float32Array(0), | |
| 59 | hit: hitResult(), | |
| 60 | proj: projectResult(), | |
| 61 | order: [], | |
| 62 | smokes: [], | |
| 63 | sky: null, | |
| 64 | skyKey: "", | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0))]; | |
| 69 | ||
| 70 | /** | |
| 71 | * Draw one frame. | |
| 72 | * | |
| 73 | * `world` carries the map (`solid`, `tile`), `eye` is where the camera | |
| 74 | * stands and looks, `snap` is the last snapshot the server sent, and `view` | |
| 75 | * is the frame: its pixel size, and `look(player)` for the smoothed position | |
| 76 | * to draw somebody at. | |
| 77 | */ | |
| 78 | export function drawScene(ctx, scene, world, eye, snap, view) { | |
| 79 | const W = view.width; | |
| 80 | const H = view.height; | |
| 81 | const step = W > 1100 ? 2 : 1; | |
| 82 | const columns = Math.ceil(W / step); | |
| 83 | if (scene.depth.length !== columns) { | |
| 84 | scene.depth = new Float32Array(columns); | |
| 85 | scene.fog = new Float32Array(columns); | |
| 86 | } | |
| 87 | const proj = projection(W); | |
| 88 | const horizon = H / 2; | |
| 89 | ||
| 90 | drawSky(ctx, scene, W, H, horizon); | |
| 91 | ||
| 92 | const smokes = scene.smokes; | |
| 93 | smokes.length = 0; | |
| 94 | for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone); | |
| 95 | ||
| 96 | const depth = scene.depth; | |
| 97 | const fog = scene.fog; | |
| 98 | const hit = scene.hit; | |
| 99 | for (let i = 0; i < columns; i++) { | |
| 100 | const offset = columnOffset(i * step + step / 2, W); | |
| 101 | const angle = eye.angle + offset; | |
| 102 | castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit); | |
| 103 | const perp = Math.max(1, hit.dist * Math.cos(offset)); | |
| 104 | depth[i] = perp; | |
| 105 | let haze = 0; | |
| 106 | if (smokes.length) { | |
| 107 | const dx = Math.cos(angle); | |
| 108 | const dy = Math.sin(angle); | |
| 109 | for (let s = 0; s < smokes.length; s++) { | |
| 110 | const z = smokes[s]; | |
| 111 | haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist); | |
| 112 | if (haze >= 1) break; | |
| 113 | } | |
| 114 | haze = Math.min(1, haze); | |
| 115 | } | |
| 116 | fog[i] = haze; | |
| 117 | if (!hit.wall) continue; | |
| 118 | const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); | |
| 119 | const top = horizon - h / 2; | |
| 120 | ctx.fillStyle = shadeOf(wallLight(perp, hit.side, world.tile)); | |
| 121 | ctx.fillRect(i * step, top, step + 1, h); | |
| 122 | if (haze > 0.01) { | |
| 123 | ctx.globalAlpha = haze; | |
| 124 | ctx.fillStyle = FOG; | |
| 125 | ctx.fillRect(i * step, top, step + 1, h); | |
| 126 | ctx.globalAlpha = 1; | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns); | |
| 131 | } | |
| 132 | ||
| 133 | /** The two bands: a dark ceiling above the horizon, a warmer floor below. */ | |
| 134 | function drawSky(ctx, scene, W, H, horizon) { | |
| 135 | const key = `${W}x${H}`; | |
| 136 | if (scene.skyKey !== key || !scene.sky) { | |
| 137 | const sky = ctx.createLinearGradient(0, 0, 0, H); | |
| 138 | sky.addColorStop(0, "#0a0c11"); | |
| 139 | sky.addColorStop(0.49, "#23262f"); | |
| 140 | sky.addColorStop(0.5, "#3a3026"); | |
| 141 | sky.addColorStop(1, "#15120e"); | |
| 142 | scene.sky = sky; | |
| 143 | scene.skyKey = key; | |
| 144 | } | |
| 145 | ctx.fillStyle = scene.sky; | |
| 146 | ctx.fillRect(0, 0, W, H); | |
| 147 | ctx.fillStyle = "rgba(255,255,255,0.09)"; | |
| 148 | ctx.fillRect(0, horizon - 1, W, 2); | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Everything that is not a wall, far to near, each one clipped to the screen | |
| 153 | * columns where it beats the wall behind it. | |
| 154 | */ | |
| 155 | function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns) { | |
| 156 | const W = view.width; | |
| 157 | const things = scene.order; | |
| 158 | things.length = 0; | |
| 159 | ||
| 160 | for (const zone of snap.zones) { | |
| 161 | if (zone.k === "smoke") continue; | |
| 162 | things.push({ kind: "zone", zone, x: zone.x, y: zone.y }); | |
| 163 | } | |
| 164 | for (const orb of snap.orbs) things.push({ kind: "orb", x: orb.x, y: orb.y }); | |
| 165 | if (snap.spike.x !== null && !snap.spike.carried) | |
| 166 | things.push({ kind: "spike", x: snap.spike.x, y: snap.spike.y }); | |
| 167 | for (const player of snap.players) { | |
| 168 | if (player.id === snap.me.id) continue; | |
| 169 | const at = view.look(player); | |
| 170 | things.push({ kind: "body", player, x: at.x, y: at.y, angle: at.angle }); | |
| 171 | } | |
| 172 | ||
| 173 | const p = scene.proj; | |
| 174 | for (const thing of things) { | |
| 175 | project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p); | |
| 176 | thing.perp = p.perp; | |
| 177 | thing.column = p.column; | |
| 178 | thing.size = p.size; | |
| 179 | thing.behind = p.behind; | |
| 180 | } | |
| 181 | things.sort((a, b) => b.perp - a.perp); | |
| 182 | ||
| 183 | for (const thing of things) { | |
| 184 | if (thing.behind) continue; | |
| 185 | const floorY = horizon + (EYE * proj) / thing.perp; | |
| 186 | const haze = hazeAt(scene, thing.column, step, columns); | |
| 187 | ctx.globalAlpha = Math.max(0, 1 - haze); | |
| 188 | if (ctx.globalAlpha < 0.05) continue; | |
| 189 | if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns); | |
| 190 | else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns); | |
| 191 | else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns); | |
| 192 | else drawZone(ctx, scene, thing, floorY, proj, step, columns); | |
| 193 | ctx.globalAlpha = 1; | |
| 194 | } | |
| 195 | ctx.globalAlpha = 1; | |
| 196 | ||
| 197 | drawTracers(ctx, scene, eye, snap, W, proj, horizon); | |
| 198 | } | |
| 199 | ||
| 200 | const hazeAt = (scene, column, step, columns) => { | |
| 201 | const i = Math.max(0, Math.min(columns - 1, Math.round(column / step))); | |
| 202 | return scene.fog[i] ?? 0; | |
| 203 | }; | |
| 204 | ||
| 205 | /** | |
| 206 | * The visible runs of a sprite: the stretches of screen column where it is | |
| 207 | * nearer than the wall. Two rectangles at most in practice, and the caller | |
| 208 | * clips its drawing to each. | |
| 209 | */ | |
| 210 | function withRuns(ctx, scene, left, right, perp, step, columns, draw) { | |
| 211 | let runStart = -1; | |
| 212 | const from = Math.max(0, Math.floor(left / step)); | |
| 213 | const to = Math.min(columns - 1, Math.ceil(right / step)); | |
| 214 | for (let i = from; i <= to + 1; i++) { | |
| 215 | const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]); | |
| 216 | if (shown && runStart < 0) runStart = i; | |
| 217 | if (!shown && runStart >= 0) { | |
| 218 | ctx.save(); | |
| 219 | ctx.beginPath(); | |
| 220 | ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5); | |
| 221 | ctx.clip(); | |
| 222 | draw(); | |
| 223 | ctx.restore(); | |
| 224 | runStart = -1; | |
| 225 | } | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | function drawBody(ctx, scene, thing, floorY, step, columns) { | |
| 230 | const p = thing.player; | |
| 231 | const h = thing.size; | |
| 232 | const w = h * 0.46; | |
| 233 | const cx = thing.column; | |
| 234 | const top = floorY - h; | |
| 235 | const mate = p.mate; | |
| 236 | ||
| 237 | // The dead lie down. No tag, no chevron, no agent colour: if something is | |
| 238 | // upright with a marker over it, it is alive and it can shoot you. | |
| 239 | if (!p.alive) { | |
| 240 | const lie = h * 0.16; | |
| 241 | withRuns(ctx, scene, cx - w * 1.1, cx + w * 1.1, thing.perp, step, columns, () => { | |
| 242 | ctx.fillStyle = "#4a4e57"; | |
| 243 | ctx.beginPath(); | |
| 244 | ctx.ellipse(cx, floorY - lie * 0.4, w * 1.05, lie, 0, 0, 7); | |
| 245 | ctx.fill(); | |
| 246 | ctx.strokeStyle = "#23262d"; | |
| 247 | ctx.lineWidth = Math.max(1, h * 0.02); | |
| 248 | ctx.stroke(); | |
| 249 | }); | |
| 250 | return; | |
| 251 | } | |
| 252 | ||
| 253 | const skin = mate ? "#3f9fd6" : "#d8483f"; | |
| 254 | const trim = AGENT_COLOUR[p.agent] || "#999"; | |
| 255 | const edge = Math.max(1.2, h * 0.05); | |
| 256 | ||
| 257 | withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.5, thing.perp, step, columns, () => { | |
| 258 | // Body and head, each with a hard dark edge: the silhouette is what | |
| 259 | // carries across a dim room, where the colour does not. | |
| 260 | ctx.fillStyle = skin; | |
| 261 | ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 262 | ctx.strokeStyle = "#0b0d11"; | |
| 263 | ctx.lineWidth = edge; | |
| 264 | ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 265 | ctx.fillStyle = trim; | |
| 266 | ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1); | |
| 267 | ctx.beginPath(); | |
| 268 | ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7); | |
| 269 | ctx.fillStyle = skin; | |
| 270 | ctx.fill(); | |
| 271 | ctx.strokeStyle = "#0b0d11"; | |
| 272 | ctx.stroke(); | |
| 273 | ||
| 274 | if (p.spike) { | |
| 275 | ctx.fillStyle = "#f0d060"; | |
| 276 | ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3); | |
| 277 | } | |
| 278 | if (p.hp < 100) { | |
| 279 | ctx.fillStyle = "#000a"; | |
| 280 | ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06); | |
| 281 | ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a"; | |
| 282 | ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06); | |
| 283 | } | |
| 284 | ||
| 285 | if (mate) tag(ctx, p, cx, top, h); | |
| 286 | else chevron(ctx, cx, top, h); | |
| 287 | }); | |
| 288 | } | |
| 289 | ||
| 290 | /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read | |
| 291 | * red and blue the same way. */ | |
| 292 | function chevron(ctx, cx, top, h) { | |
| 293 | const size = Math.max(4, h * 0.16); | |
| 294 | const y = top - h * 0.16; | |
| 295 | ctx.beginPath(); | |
| 296 | ctx.moveTo(cx - size, y - size); | |
| 297 | ctx.lineTo(cx + size, y - size); | |
| 298 | ctx.lineTo(cx, y); | |
| 299 | ctx.closePath(); | |
| 300 | ctx.fillStyle = "#ff6f5e"; | |
| 301 | ctx.fill(); | |
| 302 | ctx.lineWidth = Math.max(1, h * 0.02); | |
| 303 | ctx.strokeStyle = "#140a08"; | |
| 304 | ctx.stroke(); | |
| 305 | } | |
| 306 | ||
| 307 | /** A team mate wears their name and a pip of their health — never an enemy. */ | |
| 308 | function tag(ctx, p, cx, top, h) { | |
| 309 | const size = Math.max(9, Math.min(18, h * 0.16)); | |
| 310 | const y = top - h * 0.34; | |
| 311 | ctx.font = `${size}px system-ui, sans-serif`; | |
| 312 | ctx.textAlign = "center"; | |
| 313 | const width = ctx.measureText(p.name).width + size * 1.4; | |
| 314 | ctx.fillStyle = "rgba(10,12,16,0.65)"; | |
| 315 | ctx.fillRect(cx - width / 2, y, width, size * 1.4); | |
| 316 | ctx.fillStyle = p.hp > 60 ? "#6fcf6f" : p.hp > 25 ? "#e0a040" : "#e05a4a"; | |
| 317 | ctx.fillRect(cx - width / 2 + size * 0.25, y + size * 0.35, size * 0.45, size * 0.7); | |
| 318 | ctx.fillStyle = "#cfe8ff"; | |
| 319 | ctx.fillText(p.name, cx + size * 0.4, y + size * 1.05); | |
| 320 | ctx.textAlign = "left"; | |
| 321 | } | |
| 322 | ||
| 323 | function drawSpike(ctx, scene, thing, floorY, snap, step, columns) { | |
| 324 | const h = thing.size * 0.42; | |
| 325 | const cx = thing.column; | |
| 326 | const planted = snap.spike.planted; | |
| 327 | const blink = planted && Math.floor(Date.now() / 220) % 2 === 0; | |
| 328 | withRuns(ctx, scene, cx - h, cx + h, thing.perp, step, columns, () => { | |
| 329 | ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c"; | |
| 330 | ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h); | |
| 331 | ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418"; | |
| 332 | ctx.fillRect(cx - h * 0.14, floorY - h * 0.85, h * 0.28, h * 0.22); | |
| 333 | }); | |
| 334 | } | |
| 335 | ||
| 336 | function drawBlob(ctx, scene, thing, floorY, colour, scale, step, columns) { | |
| 337 | const r = thing.size * scale; | |
| 338 | const cx = thing.column; | |
| 339 | withRuns(ctx, scene, cx - r, cx + r, thing.perp, step, columns, () => { | |
| 340 | ctx.fillStyle = colour; | |
| 341 | ctx.beginPath(); | |
| 342 | ctx.arc(cx, floorY - r, r, 0, 7); | |
| 343 | ctx.fill(); | |
| 344 | }); | |
| 345 | } | |
| 346 | ||
| 347 | function drawZone(ctx, scene, thing, floorY, proj, step, columns) { | |
| 348 | const z = thing.zone; | |
| 349 | const wide = (z.r * proj) / thing.perp; | |
| 350 | const cx = thing.column; | |
| 351 | if (z.k === "wall") { | |
| 352 | const h = (WALL * proj) / thing.perp; | |
| 353 | withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { | |
| 354 | ctx.fillStyle = "rgba(120,200,255,0.45)"; | |
| 355 | ctx.fillRect(cx - wide, floorY - h, wide * 2, h); | |
| 356 | ctx.strokeStyle = "rgba(180,225,255,0.8)"; | |
| 357 | ctx.strokeRect(cx - wide, floorY - h, wide * 2, h); | |
| 358 | }); | |
| 359 | return; | |
| 360 | } | |
| 361 | const colour = | |
| 362 | z.k === "molly" ? "rgba(235,120,50,0.6)" : z.k === "flash" ? "rgba(255,235,170,0.9)" : z.k === "heal" ? "rgba(120,230,140,0.55)" : z.k === "trip" ? "rgba(230,200,90,0.75)" : "rgba(90,220,170,0.5)"; | |
| 363 | withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { | |
| 364 | ctx.fillStyle = colour; | |
| 365 | ctx.beginPath(); | |
| 366 | ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7); | |
| 367 | ctx.fill(); | |
| 368 | }); | |
| 369 | } | |
| 370 | ||
| 371 | /** Shots the server let us see, as a line through the world. */ | |
| 372 | function drawTracers(ctx, scene, eye, snap, W, proj, horizon) { | |
| 373 | if (!snap.shots.length) return; | |
| 374 | const p = scene.proj; | |
| 375 | ctx.lineWidth = 2; | |
| 376 | for (const shot of snap.shots) { | |
| 377 | project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p); | |
| 378 | if (p.behind) continue; | |
| 379 | const x1 = p.column; | |
| 380 | const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6; | |
| 381 | project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p); | |
| 382 | if (p.behind) continue; | |
| 383 | ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)"; | |
| 384 | ctx.beginPath(); | |
| 385 | ctx.moveTo(x1, y1); | |
| 386 | ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6); | |
| 387 | ctx.stroke(); | |
| 388 | } | |
| 389 | } |
static/steer.js added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * Keys to a direction on the map. | |
| 3 | * | |
| 4 | * The server has always been told a world-space direction, and that contract | |
| 5 | * does not change: from inside the player's head W means "the way I am | |
| 6 | * looking", so the keys are turned by the view angle before they are sent. | |
| 7 | * In the top-down view the keys are the compass they always were. | |
| 8 | */ | |
| 9 | ||
| 10 | /** | |
| 11 | * `ax` is right on the keyboard (+1 for D), `ay` is down the screen (+1 for | |
| 12 | * S, so forward is -1). `out` is filled and returned: no allocation. | |
| 13 | */ | |
| 14 | export function moveVector(ax, ay, yaw, out = { x: 0, y: 0 }) { | |
| 15 | const cos = Math.cos(yaw); | |
| 16 | const sin = Math.sin(yaw); | |
| 17 | out.x = cos * -ay - sin * ax; | |
| 18 | out.y = sin * -ay + cos * ax; | |
| 19 | return out; | |
| 20 | } |
static/topdown.js added +217
| @@ -0,0 +1,217 @@ | ||
| 1 | /** | |
| 2 | * The board from above — the drawing this game started as. | |
| 3 | * | |
| 4 | * It is three things now: the minimap in the corner, the full-screen map Tab | |
| 5 | * holds open, and the view a phone plays in, where a raycaster with touch | |
| 6 | * look would be miserable. One function, drawn into whatever box it is given. | |
| 7 | */ | |
| 8 | ||
| 9 | import { AGENT_COLOUR } from "./scene.js"; | |
| 10 | ||
| 11 | const TILE_COLOUR = { | |
| 12 | "#": "#20242d", | |
| 13 | A: "#2c2a22", | |
| 14 | B: "#2c2a22", | |
| 15 | a: "#2a2024", | |
| 16 | d: "#1e2630", | |
| 17 | ".": "#171a21", | |
| 18 | }; | |
| 19 | ||
| 20 | /** | |
| 21 | * Draw the whole map into `box` (canvas pixels). `look(player)` gives the | |
| 22 | * smoothed position; `opts.labels` draws site letters and names, which only | |
| 23 | * fit on the big version. | |
| 24 | */ | |
| 25 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | |
| 26 | const cols = map.rows[0].length; | |
| 27 | const s = box.w / (cols * map.tile); | |
| 28 | const labels = opts.labels !== false; | |
| 29 | ctx.save(); | |
| 30 | ctx.beginPath(); | |
| 31 | ctx.rect(box.x, box.y, box.w, box.h); | |
| 32 | ctx.clip(); | |
| 33 | ctx.translate(box.x, box.y); | |
| 34 | ||
| 35 | const t = map.tile * s; | |
| 36 | for (let row = 0; row < map.rows.length; row++) | |
| 37 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 38 | const cell = map.rows[row][col]; | |
| 39 | ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21"; | |
| 40 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 41 | if (labels && (cell === "A" || cell === "B")) { | |
| 42 | ctx.fillStyle = "#6b5f3a"; | |
| 43 | ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; | |
| 44 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | if (!snap) { | |
| 49 | ctx.restore(); | |
| 50 | return; | |
| 51 | } | |
| 52 | ||
| 53 | for (const orb of snap.orbs) { | |
| 54 | ctx.fillStyle = "#c9a0ff"; | |
| 55 | ctx.beginPath(); | |
| 56 | ctx.arc(orb.x * s, orb.y * s, Math.max(2, 9 * s), 0, 7); | |
| 57 | ctx.fill(); | |
| 58 | } | |
| 59 | for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(ctx, zone, s); | |
| 60 | ||
| 61 | if (snap.spike.x !== null) { | |
| 62 | const x = snap.spike.x * s; | |
| 63 | const y = snap.spike.y * s; | |
| 64 | const r = Math.max(2.5, 7 * s); | |
| 65 | ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c"; | |
| 66 | ctx.fillRect(x - r, y - r, r * 2, r * 2); | |
| 67 | if (snap.spike.planted && snap.spike.defuse > 0) { | |
| 68 | ctx.fillStyle = "#4fd6a0"; | |
| 69 | ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 3); | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | for (const shot of snap.shots) { | |
| 74 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.5)"; | |
| 75 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 76 | ctx.beginPath(); | |
| 77 | ctx.moveTo(shot.x1 * s, shot.y1 * s); | |
| 78 | ctx.lineTo(shot.x2 * s, shot.y2 * s); | |
| 79 | ctx.stroke(); | |
| 80 | } | |
| 81 | ||
| 82 | for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels); | |
| 83 | // Smoke last: on this view it hides what is under it, as it should. | |
| 84 | for (const zone of snap.zones) if (zone.k === "smoke" && zone.f <= 0) drawZone(ctx, zone, s); | |
| 85 | ctx.restore(); | |
| 86 | } | |
| 87 | ||
| 88 | function drawZone(ctx, z, s) { | |
| 89 | const x = z.x * s; | |
| 90 | const y = z.y * s; | |
| 91 | const r = z.r * s; | |
| 92 | ctx.lineWidth = Math.max(1, s); | |
| 93 | if (z.k === "smoke") { | |
| 94 | ctx.fillStyle = "rgba(190,195,205,0.72)"; | |
| 95 | disc(ctx, x, y, r, true); | |
| 96 | } else if (z.k === "molly") { | |
| 97 | ctx.fillStyle = "rgba(230,110,50,0.45)"; | |
| 98 | ctx.strokeStyle = "#f08a4b"; | |
| 99 | disc(ctx, x, y, r, true, true); | |
| 100 | } else if (z.k === "wall") { | |
| 101 | ctx.fillStyle = "rgba(120,200,255,0.55)"; | |
| 102 | disc(ctx, x, y, r, true); | |
| 103 | } else if (z.k === "flash") { | |
| 104 | ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)"; | |
| 105 | disc(ctx, x, y, z.f > 0 ? 5 * s : 30 * s, true); | |
| 106 | } else if (z.k === "dart") { | |
| 107 | ctx.strokeStyle = "rgba(90,220,170,0.7)"; | |
| 108 | disc(ctx, x, y, r, false, true); | |
| 109 | } else if (z.k === "trip") { | |
| 110 | ctx.strokeStyle = "rgba(230,200,90,0.8)"; | |
| 111 | disc(ctx, x, y, r, false, true); | |
| 112 | } else if (z.k === "heal") { | |
| 113 | ctx.fillStyle = "rgba(120,230,140,0.5)"; | |
| 114 | disc(ctx, x, y, 14 * s, true); | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | function disc(ctx, x, y, r, fill, stroke) { | |
| 119 | ctx.beginPath(); | |
| 120 | ctx.arc(x, y, r, 0, 7); | |
| 121 | if (fill) ctx.fill(); | |
| 122 | if (stroke) ctx.stroke(); | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Everybody is a dot and you are an arrow — but a team mate is on the map | |
| 127 | * whether you can see them or not, with their initial in the dot and a cross | |
| 128 | * when they are down, and an enemy is only ever drawn while they are alive | |
| 129 | * and in sight. The map stays honest about vision and legible about your own. | |
| 130 | */ | |
| 131 | function drawPlayer(ctx, p, pos, s, me, labels) { | |
| 132 | const mine = p.id === me.id; | |
| 133 | if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out | |
| 134 | const x = pos.x * s; | |
| 135 | const y = pos.y * s; | |
| 136 | const r = Math.max(2.5, 16 * s); | |
| 137 | const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; | |
| 138 | ||
| 139 | if (mine && p.alive) { | |
| 140 | ctx.save(); | |
| 141 | ctx.translate(x, y); | |
| 142 | ctx.rotate(pos.angle); | |
| 143 | ctx.fillStyle = body; | |
| 144 | ctx.beginPath(); | |
| 145 | ctx.moveTo(r * 1.5, 0); | |
| 146 | ctx.lineTo(-r, r * 0.9); | |
| 147 | ctx.lineTo(-r * 0.4, 0); | |
| 148 | ctx.lineTo(-r, -r * 0.9); | |
| 149 | ctx.closePath(); | |
| 150 | ctx.fill(); | |
| 151 | ctx.restore(); | |
| 152 | return; | |
| 153 | } | |
| 154 | ||
| 155 | // The dead of your own team keep their place on the map, crossed out. | |
| 156 | if (!p.alive) { | |
| 157 | ctx.strokeStyle = "#6a707c"; | |
| 158 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 159 | ctx.beginPath(); | |
| 160 | ctx.moveTo(x - r * 0.8, y - r * 0.8); | |
| 161 | ctx.lineTo(x + r * 0.8, y + r * 0.8); | |
| 162 | ctx.moveTo(x + r * 0.8, y - r * 0.8); | |
| 163 | ctx.lineTo(x - r * 0.8, y + r * 0.8); | |
| 164 | ctx.stroke(); | |
| 165 | if (labels) { | |
| 166 | ctx.fillStyle = "rgba(180,185,195,0.6)"; | |
| 167 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; | |
| 168 | ctx.textAlign = "center"; | |
| 169 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 170 | ctx.textAlign = "left"; | |
| 171 | } | |
| 172 | return; | |
| 173 | } | |
| 174 | ||
| 175 | ctx.fillStyle = body; | |
| 176 | ctx.beginPath(); | |
| 177 | ctx.arc(x, y, r, 0, 7); | |
| 178 | ctx.fill(); | |
| 179 | ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; | |
| 180 | ctx.lineWidth = Math.max(1, 3 * s); | |
| 181 | ctx.stroke(); | |
| 182 | ctx.strokeStyle = body; | |
| 183 | ctx.beginPath(); | |
| 184 | ctx.moveTo(x, y); | |
| 185 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 186 | ctx.stroke(); | |
| 187 | ||
| 188 | // The initial inside the dot, when the dot is big enough to hold one. | |
| 189 | if (p.mate && r >= 6) { | |
| 190 | ctx.fillStyle = "#0c1016"; | |
| 191 | ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`; | |
| 192 | ctx.textAlign = "center"; | |
| 193 | ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45); | |
| 194 | ctx.textAlign = "left"; | |
| 195 | } | |
| 196 | if (p.spike) { | |
| 197 | ctx.fillStyle = "#f0d060"; | |
| 198 | ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); | |
| 199 | } | |
| 200 | if (p.blind) { | |
| 201 | ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| 202 | disc(ctx, x, y - r * 1.6, r * 0.35, true); | |
| 203 | } | |
| 204 | if (labels) { | |
| 205 | ctx.fillStyle = "rgba(235,235,235,0.75)"; | |
| 206 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; | |
| 207 | ctx.textAlign = "center"; | |
| 208 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 209 | ctx.textAlign = "left"; | |
| 210 | } | |
| 211 | if (p.hp < 100) { | |
| 212 | ctx.fillStyle = "#0008"; | |
| 213 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); | |
| 214 | ctx.fillStyle = "#6fcf6f"; | |
| 215 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); | |
| 216 | } | |
| 217 | } |
static/val.js +414 −215
| @@ -3,25 +3,53 @@ | ||
| 3 | 3 | * person does — nothing else. There is no position here the server did not |
| 4 | 4 | * hand over, and no enemy in a snapshot the server decided you cannot see, |
| 5 | 5 | * so a patched client learns nothing a plain one does not. |
| 6 | * | |
| 7 | * This file is the wiring: the socket, the keys and the pointer, the HUD. | |
| 8 | * The drawing lives beside it — `scene.js` for the first-person view, | |
| 9 | * `topdown.js` for the map, `gun.js` for the crosshair, `prompt.js` for the | |
| 10 | * line of advice and `raycast.js` for the maths all of it stands on. | |
| 6 | 11 | */ |
| 7 | 12 | "use strict"; |
| 8 | 13 | |
| 14 | import { makeBeeper } from "./beep.js"; | |
| 15 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; | |
| 16 | import { choosePrompt, REACH } from "./prompt.js"; | |
| 17 | import { roster, rosterKey } from "./roster.js"; | |
| 18 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; | |
| 19 | import { moveVector } from "./steer.js"; | |
| 20 | import { drawTopdown } from "./topdown.js"; | |
| 21 | ||
| 9 | 22 | const $ = (id) => document.getElementById(id); |
| 10 | 23 | const view = $("view"); |
| 11 | 24 | const ctx = view.getContext("2d"); |
| 12 | 25 | |
| 13 | 26 | const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" }; |
| 14 | const AGENT_COLOUR = { ember: "#f08a4b", veil: "#8f7fe8", sable: "#4fd6a0", mender: "#e0c060", rook: "#d96a8f" }; | |
| 27 | const COARSE = matchMedia("(pointer: coarse)").matches; | |
| 28 | const LOOK = 0.0022; // radians per pixel of mouse travel | |
| 29 | const HELP_SEEN = "val.help.seen"; | |
| 15 | 30 | |
| 16 | 31 | let map = null; |
| 32 | let solid = () => true; | |
| 17 | 33 | let snap = null; |
| 18 | 34 | let socket = null; |
| 35 | let agents = null; | |
| 19 | 36 | const shown = new Map(); // smoothed positions, so 15 frames a second look like 60 |
| 20 | 37 | const keys = new Set(); |
| 21 | let pointer = null; // aim target in canvas pixels | |
| 38 | let yaw = 0; // where this person is looking, theirs to move between snapshots | |
| 39 | let locked = false; | |
| 22 | 40 | let firing = false; |
| 41 | let mode = COARSE ? "top" : "fp"; // a raycaster with touch look is miserable | |
| 42 | let mapOpen = false; | |
| 43 | let pointer = null; // aim target in canvas pixels, top-down only | |
| 23 | 44 | let stick = null; // { id, ox, oy, mx, my } |
| 24 | 45 | let aimTouch = null; |
| 46 | let helpShown = false; | |
| 47 | let last = performance.now(); | |
| 48 | ||
| 49 | const walk = { x: 0, y: 0 }; | |
| 50 | const scene = makeScene(); | |
| 51 | const gun = makeGun(); | |
| 52 | const beeper = makeBeeper(); | |
| 25 | 53 | |
| 26 | 54 | // ------------------------------------------------------------------ socket |
| 27 | 55 | |
| @@ -35,10 +63,13 @@ function connect() { | ||
| 35 | 63 | }; |
| 36 | 64 | socket.onmessage = (event) => { |
| 37 | 65 | const data = JSON.parse(event.data); |
| 38 | if (data.t === "s") { | |
| 39 | snap = data; | |
| 40 | paintHud(data); | |
| 41 | } | |
| 66 | if (data.t !== "s") return; | |
| 67 | const first = snap === null; | |
| 68 | snap = data; | |
| 69 | if (first && mode === "fp") yaw = data.me.angle; | |
| 70 | gunSnapshot(gun, data); | |
| 71 | paintHud(data); | |
| 72 | maybeHelp(data); | |
| 42 | 73 | }; |
| 43 | 74 | } |
| 44 | 75 | |
| @@ -48,8 +79,30 @@ const send = (message) => { | ||
| 48 | 79 | |
| 49 | 80 | // ------------------------------------------------------------------- input |
| 50 | 81 | |
| 82 | function setMode(next) { | |
| 83 | mode = next; | |
| 84 | $("stage").dataset.mode = next; | |
| 85 | if (next !== "fp" && document.pointerLockElement === view) document.exitPointerLock(); | |
| 86 | if (snap) yaw = snap.me.angle; | |
| 87 | } | |
| 88 | ||
| 51 | 89 | addEventListener("keydown", (event) => { |
| 52 | 90 | if (event.target instanceof HTMLInputElement) return; |
| 91 | if (event.code === "Tab") { | |
| 92 | mapOpen = true; | |
| 93 | event.preventDefault(); | |
| 94 | return; | |
| 95 | } | |
| 96 | if (event.code === "KeyH" || event.key === "?") { | |
| 97 | toggleHelp(); | |
| 98 | event.preventDefault(); | |
| 99 | return; | |
| 100 | } | |
| 101 | if (event.code === "KeyV") { | |
| 102 | setMode(mode === "fp" ? "top" : "fp"); | |
| 103 | event.preventDefault(); | |
| 104 | return; | |
| 105 | } | |
| 53 | 106 | if (event.code === "KeyB") { |
| 54 | 107 | toggleBuy(); |
| 55 | 108 | event.preventDefault(); |
| @@ -61,30 +114,74 @@ addEventListener("keydown", (event) => { | ||
| 61 | 114 | return; |
| 62 | 115 | } |
| 63 | 116 | keys.add(event.code); |
| 64 | if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR"].includes(event.code)) event.preventDefault(); | |
| 117 | if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR", "ArrowLeft", "ArrowRight"].includes(event.code)) | |
| 118 | event.preventDefault(); | |
| 119 | }); | |
| 120 | addEventListener("keyup", (event) => { | |
| 121 | if (event.code === "Tab") mapOpen = false; | |
| 122 | keys.delete(event.code); | |
| 123 | }); | |
| 124 | addEventListener("blur", () => { | |
| 125 | keys.clear(); | |
| 126 | mapOpen = false; | |
| 127 | firing = false; | |
| 65 | 128 | }); |
| 66 | addEventListener("keyup", (event) => keys.delete(event.code)); | |
| 67 | addEventListener("blur", () => keys.clear()); | |
| 68 | 129 | |
| 69 | view.addEventListener("pointermove", (event) => { | |
| 70 | if (event.pointerType === "touch") return; | |
| 130 | // Aiming is pointer lock: click the canvas to capture the mouse, move to | |
| 131 | // look, Escape gives it back. The angle sent is the same angle as ever. | |
| 132 | view.addEventListener("click", () => { | |
| 133 | beeper.wake(); | |
| 134 | if (mode !== "fp" || locked) return; | |
| 135 | if (!view.requestPointerLock) return setMode("top"); // no lock, no first person | |
| 136 | // Asked for too soon after the last release, a browser rejects this; that | |
| 137 | // is a shrug, not an error, and the hint stays up until it takes. | |
| 138 | const asked = view.requestPointerLock(); | |
| 139 | if (asked && asked.catch) asked.catch(() => {}); | |
| 140 | }); | |
| 141 | document.addEventListener("pointerlockchange", () => { | |
| 142 | locked = document.pointerLockElement === view; | |
| 143 | if (!locked) firing = false; | |
| 144 | $("stage").dataset.locked = locked ? "1" : ""; | |
| 145 | }); | |
| 146 | addEventListener("mousemove", (event) => { | |
| 147 | if (locked) yaw += event.movementX * LOOK; | |
| 148 | }); | |
| 149 | addEventListener("mousedown", (event) => { | |
| 150 | if (locked && event.button === 0) firing = true; | |
| 151 | }); | |
| 152 | addEventListener("mouseup", (event) => { | |
| 153 | if (locked && event.button === 0) firing = false; | |
| 154 | }); | |
| 155 | ||
| 156 | // The top-down view keeps the pointer aim it always had: it is the phone | |
| 157 | // control scheme, and the fallback when the first-person view is not wanted. | |
| 158 | const canvasPoint = (event) => { | |
| 71 | 159 | const box = view.getBoundingClientRect(); |
| 72 | pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 160 | return { | |
| 161 | x: ((event.clientX - box.left) / box.width) * view.width, | |
| 162 | y: ((event.clientY - box.top) / box.height) * view.height, | |
| 163 | }; | |
| 164 | }; | |
| 165 | view.addEventListener("pointermove", (event) => { | |
| 166 | if (mode !== "top" || event.pointerType === "touch") return; | |
| 167 | pointer = canvasPoint(event); | |
| 73 | 168 | }); |
| 74 | 169 | view.addEventListener("pointerdown", (event) => { |
| 170 | beeper.wake(); | |
| 171 | if (mode !== "top") return; | |
| 75 | 172 | view.setPointerCapture(event.pointerId); |
| 76 | 173 | const box = view.getBoundingClientRect(); |
| 77 | const x = event.clientX - box.left; | |
| 78 | 174 | if (event.pointerType === "touch") { |
| 79 | if (x < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 }; | |
| 175 | if (event.clientX - box.left < box.width / 2) | |
| 176 | stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 }; | |
| 80 | 177 | else { |
| 81 | aimTouch = { id: event.pointerId, x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 178 | aimTouch = { id: event.pointerId, ...canvasPoint(event) }; | |
| 82 | 179 | firing = true; |
| 83 | 180 | } |
| 84 | 181 | event.preventDefault(); |
| 85 | 182 | return; |
| 86 | 183 | } |
| 87 | pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 184 | pointer = canvasPoint(event); | |
| 88 | 185 | firing = true; |
| 89 | 186 | }); |
| 90 | 187 | view.addEventListener("pointermove", (event) => { |
| @@ -96,11 +193,7 @@ view.addEventListener("pointermove", (event) => { | ||
| 96 | 193 | stick.mx = (dx / len) * reach; |
| 97 | 194 | stick.my = (dy / len) * reach; |
| 98 | 195 | } |
| 99 | if (aimTouch && event.pointerId === aimTouch.id) { | |
| 100 | const box = view.getBoundingClientRect(); | |
| 101 | aimTouch.x = ((event.clientX - box.left) / box.width) * view.width; | |
| 102 | aimTouch.y = ((event.clientY - box.top) / box.height) * view.height; | |
| 103 | } | |
| 196 | if (aimTouch && event.pointerId === aimTouch.id) Object.assign(aimTouch, canvasPoint(event)); | |
| 104 | 197 | }); |
| 105 | 198 | const endPointer = (event) => { |
| 106 | 199 | if (stick && event.pointerId === stick.id) stick = null; |
| @@ -113,34 +206,37 @@ view.addEventListener("pointerup", endPointer); | ||
| 113 | 206 | view.addEventListener("pointercancel", endPointer); |
| 114 | 207 | view.addEventListener("contextmenu", (event) => event.preventDefault()); |
| 115 | 208 | |
| 116 | // The buy opens itself every buy phase; closing it is remembered until the | |
| 117 | // next one, so nobody has to fight the panel to watch the round start. | |
| 118 | function toggleBuy() { | |
| 119 | const open = $("buy").classList.toggle("on"); | |
| 120 | $("buy").dataset.closed = open ? "" : "1"; | |
| 121 | } | |
| 122 | $("buytoggle").addEventListener("click", toggleBuy); | |
| 123 | for (const button of document.querySelectorAll("[data-buy]")) | |
| 124 | button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy })); | |
| 125 | for (const button of document.querySelectorAll("[data-agent]")) | |
| 126 | button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent })); | |
| 127 | ||
| 128 | // Twenty times a second the server hears what this person is doing. | |
| 209 | // Twenty times a second the server hears what this person is doing. In the | |
| 210 | // first-person view the keys are read against the way you are facing; in the | |
| 211 | // top-down view they are the compass, the way they always were. | |
| 129 | 212 | setInterval(() => { |
| 130 | 213 | if (!snap || !map) return; |
| 131 | let mx = 0; | |
| 132 | let my = 0; | |
| 133 | if (keys.has("KeyW")) my -= 1; | |
| 134 | if (keys.has("KeyS")) my += 1; | |
| 135 | if (keys.has("KeyA")) mx -= 1; | |
| 136 | if (keys.has("KeyD")) mx += 1; | |
| 214 | let ax = 0; | |
| 215 | let ay = 0; | |
| 216 | if (keys.has("KeyW")) ay -= 1; | |
| 217 | if (keys.has("KeyS")) ay += 1; | |
| 218 | if (keys.has("KeyA")) ax -= 1; | |
| 219 | if (keys.has("KeyD")) ax += 1; | |
| 137 | 220 | if (stick) { |
| 138 | mx = stick.mx; | |
| 139 | my = stick.my; | |
| 221 | ax = stick.mx; | |
| 222 | ay = stick.my; | |
| 223 | } | |
| 224 | let mx = ax; | |
| 225 | let my = ay; | |
| 226 | let angle; | |
| 227 | if (mode === "fp") { | |
| 228 | if (keys.has("ArrowLeft")) yaw -= 0.045; | |
| 229 | if (keys.has("ArrowRight")) yaw += 0.045; | |
| 230 | angle = yaw; | |
| 231 | moveVector(ax, ay, yaw, walk); | |
| 232 | mx = walk.x; | |
| 233 | my = walk.y; | |
| 234 | } else { | |
| 235 | const aim = aimTouch || pointer; | |
| 236 | const me = worldToScreen(snap.me); | |
| 237 | angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 238 | yaw = angle; | |
| 140 | 239 | } |
| 141 | const aim = aimTouch || pointer; | |
| 142 | const me = worldToScreen(snap.me); | |
| 143 | const angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 144 | 240 | send({ |
| 145 | 241 | t: "i", |
| 146 | 242 | mx, |
| @@ -168,168 +264,69 @@ const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() }); | ||
| 168 | 264 | function smooth(id, x, y, angle) { |
| 169 | 265 | const now = shown.get(id); |
| 170 | 266 | if (!now) { |
| 171 | shown.set(id, { x, y, angle }); | |
| 172 | return { x, y, angle }; | |
| 267 | const fresh = { x, y, angle }; | |
| 268 | shown.set(id, fresh); | |
| 269 | return fresh; | |
| 173 | 270 | } |
| 174 | 271 | now.x += (x - now.x) * 0.35; |
| 175 | 272 | now.y += (y - now.y) * 0.35; |
| 176 | let turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 273 | const turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 177 | 274 | now.angle += turn * 0.4; |
| 178 | 275 | return now; |
| 179 | 276 | } |
| 180 | 277 | |
| 181 | function drawMap(s) { | |
| 182 | const t = map.tile * s; | |
| 183 | for (let row = 0; row < map.rows.length; row++) | |
| 184 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 185 | const cell = map.rows[row][col]; | |
| 186 | ctx.fillStyle = | |
| 187 | cell === "#" ? "#20242d" : cell === "A" || cell === "B" ? "#2c2a22" : cell === "a" ? "#2a2024" : cell === "d" ? "#1e2630" : "#171a21"; | |
| 188 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 189 | if (cell === "A" || cell === "B") { | |
| 190 | ctx.fillStyle = "#6b5f3a"; | |
| 191 | ctx.font = `${Math.round(t * 0.5)}px system-ui`; | |
| 192 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 193 | } | |
| 194 | } | |
| 195 | } | |
| 278 | const look = (p) => smooth(p.id, p.x, p.y, p.angle); | |
| 196 | 279 | |
| 197 | function drawZone(z, s) { | |
| 198 | const x = z.x * s; | |
| 199 | const y = z.y * s; | |
| 200 | const r = z.r * s; | |
| 201 | ctx.save(); | |
| 202 | if (z.k === "smoke") { | |
| 203 | ctx.fillStyle = "rgba(190,195,205,0.72)"; | |
| 204 | ctx.beginPath(); | |
| 205 | ctx.arc(x, y, r, 0, 7); | |
| 206 | ctx.fill(); | |
| 207 | } else if (z.k === "molly") { | |
| 208 | ctx.fillStyle = "rgba(230,110,50,0.45)"; | |
| 209 | ctx.strokeStyle = "#f08a4b"; | |
| 210 | ctx.beginPath(); | |
| 211 | ctx.arc(x, y, r, 0, 7); | |
| 212 | ctx.fill(); | |
| 213 | ctx.stroke(); | |
| 214 | } else if (z.k === "wall") { | |
| 215 | ctx.fillStyle = "rgba(120,200,255,0.55)"; | |
| 216 | ctx.beginPath(); | |
| 217 | ctx.arc(x, y, r, 0, 7); | |
| 218 | ctx.fill(); | |
| 219 | } else if (z.k === "flash") { | |
| 220 | ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)"; | |
| 221 | ctx.beginPath(); | |
| 222 | ctx.arc(x, y, z.f > 0 ? 5 * s : 30 * s, 0, 7); | |
| 223 | ctx.fill(); | |
| 224 | } else if (z.k === "dart") { | |
| 225 | ctx.strokeStyle = "rgba(90,220,170,0.7)"; | |
| 226 | ctx.beginPath(); | |
| 227 | ctx.arc(x, y, r, 0, 7); | |
| 228 | ctx.stroke(); | |
| 229 | } else if (z.k === "trip") { | |
| 230 | ctx.strokeStyle = "rgba(230,200,90,0.8)"; | |
| 231 | ctx.beginPath(); | |
| 232 | ctx.arc(x, y, r, 0, 7); | |
| 233 | ctx.stroke(); | |
| 234 | } else if (z.k === "heal") { | |
| 235 | ctx.fillStyle = "rgba(120,230,140,0.5)"; | |
| 236 | ctx.beginPath(); | |
| 237 | ctx.arc(x, y, 14 * s, 0, 7); | |
| 238 | ctx.fill(); | |
| 239 | } | |
| 240 | ctx.restore(); | |
| 241 | } | |
| 242 | ||
| 243 | function drawPlayer(p, s, me) { | |
| 244 | const pos = smooth(p.id, p.x, p.y, p.angle); | |
| 245 | const x = pos.x * s; | |
| 246 | const y = pos.y * s; | |
| 247 | const r = 16 * s; | |
| 248 | const mine = p.id === me.id; | |
| 249 | ctx.fillStyle = p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; | |
| 250 | if (!p.alive) ctx.fillStyle = "#555a63"; | |
| 251 | ctx.beginPath(); | |
| 252 | ctx.arc(x, y, r, 0, 7); | |
| 253 | ctx.fill(); | |
| 254 | ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; | |
| 255 | ctx.lineWidth = Math.max(1, 3 * s); | |
| 256 | ctx.stroke(); | |
| 257 | if (p.alive) { | |
| 258 | ctx.strokeStyle = mine ? "#fff" : ctx.fillStyle; | |
| 259 | ctx.beginPath(); | |
| 260 | ctx.moveTo(x, y); | |
| 261 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 262 | ctx.stroke(); | |
| 263 | } | |
| 264 | if (p.spike) { | |
| 265 | ctx.fillStyle = "#f0d060"; | |
| 266 | ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); | |
| 267 | } | |
| 268 | if (p.blind) { | |
| 269 | ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| 270 | ctx.beginPath(); | |
| 271 | ctx.arc(x, y - r * 1.6, r * 0.35, 0, 7); | |
| 272 | ctx.fill(); | |
| 273 | } | |
| 274 | if (!mine) { | |
| 275 | ctx.fillStyle = "rgba(235,235,235,0.75)"; | |
| 276 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui`; | |
| 277 | ctx.textAlign = "center"; | |
| 278 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 279 | ctx.textAlign = "left"; | |
| 280 | } | |
| 281 | if (p.alive && p.hp < 100) { | |
| 282 | ctx.fillStyle = "#0008"; | |
| 283 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); | |
| 284 | ctx.fillStyle = "#6fcf6f"; | |
| 285 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | function frame() { | |
| 280 | function frame(now) { | |
| 290 | 281 | requestAnimationFrame(frame); |
| 282 | const dt = Math.min(0.1, (now - last) / 1000); | |
| 283 | last = now; | |
| 291 | 284 | if (!map) return; |
| 292 | const s = scale(); | |
| 285 | gunTick(gun, dt); | |
| 293 | 286 | ctx.clearRect(0, 0, view.width, view.height); |
| 294 | drawMap(s); | |
| 295 | if (!snap) return; | |
| 296 | 287 | |
| 297 | for (const orb of snap.orbs) { | |
| 298 | ctx.fillStyle = "#c9a0ff"; | |
| 299 | ctx.beginPath(); | |
| 300 | ctx.arc(orb.x * s, orb.y * s, 9 * s, 0, 7); | |
| 301 | ctx.fill(); | |
| 288 | if (!snap) { | |
| 289 | drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height }); | |
| 290 | return; | |
| 302 | 291 | } |
| 303 | for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(zone, s); | |
| 292 | beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000); | |
| 293 | const eye = look(snap.me); | |
| 294 | const facing = mode === "fp" ? yaw : eye.angle; | |
| 304 | 295 | |
| 305 | if (snap.spike.x !== null) { | |
| 306 | const x = snap.spike.x * s; | |
| 307 | const y = snap.spike.y * s; | |
| 308 | ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c"; | |
| 309 | ctx.fillRect(x - 7 * s, y - 7 * s, 14 * s, 14 * s); | |
| 310 | if (snap.spike.planted && snap.spike.defuse > 0) { | |
| 311 | ctx.fillStyle = "#4fd6a0"; | |
| 312 | ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 4); | |
| 313 | } | |
| 296 | if (mode === "fp" && !mapOpen) { | |
| 297 | drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, { | |
| 298 | width: view.width, | |
| 299 | height: view.height, | |
| 300 | look, | |
| 301 | }); | |
| 302 | drawGun(ctx, gun, view.width, view.height, snap); | |
| 303 | drawMinimap(); | |
| 304 | } else { | |
| 305 | drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height }); | |
| 314 | 306 | } |
| 315 | 307 | |
| 316 | for (const shot of snap.shots) { | |
| 317 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.55)"; | |
| 318 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 319 | ctx.beginPath(); | |
| 320 | ctx.moveTo(shot.x1 * s, shot.y1 * s); | |
| 321 | ctx.lineTo(shot.x2 * s, shot.y2 * s); | |
| 322 | ctx.stroke(); | |
| 323 | } | |
| 324 | ||
| 325 | for (const player of snap.players) drawPlayer(player, s, snap.me); | |
| 326 | // Smoke goes on last: it hides what is behind it on screen too. | |
| 327 | for (const zone of snap.zones) if (zone.k === "smoke") drawZone(zone, s); | |
| 328 | ||
| 329 | 308 | if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting"); |
| 330 | else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") bar(snap.spike.defuse / 7, "#4fd6a0", "defusing"); | |
| 309 | else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") | |
| 310 | bar(snap.spike.defuse / 7, "#4fd6a0", "defusing"); | |
| 331 | 311 | $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8); |
| 332 | 312 | $("dead").style.display = snap.me.alive ? "none" : "grid"; |
| 313 | $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none"; | |
| 314 | } | |
| 315 | ||
| 316 | /** The same top-down drawing, small, in the corner, with you as an arrow. */ | |
| 317 | function drawMinimap() { | |
| 318 | const w = Math.min(view.width * 0.3, 260 * (view.width / 900)); | |
| 319 | const h = (w * map.rows.length) / map.rows[0].length; | |
| 320 | const pad = 8; | |
| 321 | ctx.save(); | |
| 322 | ctx.globalAlpha = 0.85; | |
| 323 | ctx.fillStyle = "#0b0d11"; | |
| 324 | ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4); | |
| 325 | drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false }); | |
| 326 | ctx.restore(); | |
| 327 | ctx.strokeStyle = "rgba(255,255,255,0.18)"; | |
| 328 | ctx.lineWidth = 1; | |
| 329 | ctx.strokeRect(pad - 2, pad - 2, w + 4, h + 4); | |
| 333 | 330 | } |
| 334 | 331 | |
| 335 | 332 | function bar(fraction, colour, label) { |
| @@ -341,7 +338,7 @@ function bar(fraction, colour, label) { | ||
| 341 | 338 | ctx.fillStyle = colour; |
| 342 | 339 | ctx.fillRect(x, y, w * Math.min(1, fraction), 16); |
| 343 | 340 | ctx.fillStyle = "#fff"; |
| 344 | ctx.font = "12px system-ui"; | |
| 341 | ctx.font = "12px system-ui, sans-serif"; | |
| 345 | 342 | ctx.textAlign = "center"; |
| 346 | 343 | ctx.fillText(label, view.width / 2, y + 12); |
| 347 | 344 | ctx.textAlign = "left"; |
| @@ -349,6 +346,50 @@ function bar(fraction, colour, label) { | ||
| 349 | 346 | |
| 350 | 347 | // --------------------------------------------------------------------- hud |
| 351 | 348 | |
| 349 | // The buy opens itself every buy phase; closing it is remembered until the | |
| 350 | // next one, so nobody has to fight the panel to watch the round start. | |
| 351 | function toggleBuy() { | |
| 352 | const open = $("buy").classList.toggle("on"); | |
| 353 | $("buy").dataset.closed = open ? "" : "1"; | |
| 354 | } | |
| 355 | $("buytoggle").addEventListener("click", toggleBuy); | |
| 356 | $("viewtoggle").addEventListener("click", () => setMode(mode === "fp" ? "top" : "fp")); | |
| 357 | $("helptoggle").addEventListener("click", () => toggleHelp()); | |
| 358 | $("helpclose").addEventListener("click", () => toggleHelp(false)); | |
| 359 | for (const button of document.querySelectorAll("[data-buy]")) | |
| 360 | button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy })); | |
| 361 | for (const button of document.querySelectorAll("[data-agent]")) | |
| 362 | button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent })); | |
| 363 | ||
| 364 | function toggleHelp(force) { | |
| 365 | const open = force === undefined ? $("help").style.display !== "grid" : force; | |
| 366 | $("help").style.display = open ? "grid" : "none"; | |
| 367 | if (!open) { | |
| 368 | helpShown = true; | |
| 369 | try { | |
| 370 | localStorage.setItem(HELP_SEEN, "1"); | |
| 371 | } catch { | |
| 372 | // A browser that refuses storage still gets the card, every visit. | |
| 373 | } | |
| 374 | } | |
| 375 | } | |
| 376 | ||
| 377 | /** The first-run card, once per browser, and never over a round in progress. */ | |
| 378 | function maybeHelp(s) { | |
| 379 | if (helpShown) return; | |
| 380 | try { | |
| 381 | if (localStorage.getItem(HELP_SEEN) === "1") { | |
| 382 | helpShown = true; | |
| 383 | return; | |
| 384 | } | |
| 385 | } catch { | |
| 386 | /* no storage: show it and move on */ | |
| 387 | } | |
| 388 | if (s.phase === "live" && s.me.alive) return; | |
| 389 | helpShown = true; | |
| 390 | $("help").style.display = "grid"; | |
| 391 | } | |
| 392 | ||
| 352 | 393 | const KITS = {}; |
| 353 | 394 | |
| 354 | 395 | function paintHud(s) { |
| @@ -364,28 +405,22 @@ function paintHud(s) { | ||
| 364 | 405 | $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1"); |
| 365 | 406 | if (s.phase !== "buy") $("buy").dataset.closed = ""; |
| 366 | 407 | |
| 367 | const kit = $("kit"); | |
| 368 | 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; | |
| 369 | if (KITS.shape !== shape) { | |
| 370 | KITS.shape = shape; | |
| 371 | kit.innerHTML = ""; | |
| 372 | for (const slot of ["c", "q", "e", "x"]) { | |
| 373 | const button = document.createElement("button"); | |
| 374 | const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0; | |
| 375 | button.disabled = !ready; | |
| 376 | 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]; | |
| 377 | button.innerHTML = `${slot.toUpperCase()} <b>${count}</b>`; | |
| 378 | button.type = "button"; | |
| 379 | button.addEventListener("click", () => send({ t: "i", c: slot })); | |
| 380 | kit.append(button); | |
| 381 | } | |
| 382 | } | |
| 408 | const text = choosePrompt(s, { | |
| 409 | onSite: onSite(s.me), | |
| 410 | nearSpike: s.spike.x !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH, | |
| 411 | }); | |
| 412 | if ($("prompt").textContent !== text) $("prompt").textContent = text; | |
| 413 | ||
| 414 | paintKit(s); | |
| 415 | paintBuy(s); | |
| 416 | paintRoster(s); | |
| 417 | paintFeed(s); | |
| 383 | 418 | |
| 384 | 419 | const log = $("log"); |
| 385 | const text = s.events.slice(0, 3).join("\n"); | |
| 386 | if (log.dataset.text !== text) { | |
| 387 | log.dataset.text = text; | |
| 388 | log.innerHTML = ""; | |
| 420 | const lines = s.events.slice(0, 3).join("\n"); | |
| 421 | if (log.dataset.text !== lines) { | |
| 422 | log.dataset.text = lines; | |
| 423 | log.replaceChildren(); | |
| 389 | 424 | for (const line of s.events.slice(0, 3)) { |
| 390 | 425 | const div = document.createElement("div"); |
| 391 | 426 | div.textContent = line; |
| @@ -399,7 +434,7 @@ function paintHud(s) { | ||
| 399 | 434 | const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join(); |
| 400 | 435 | if (body.dataset.key === key) continue; |
| 401 | 436 | body.dataset.key = key; |
| 402 | body.innerHTML = ""; | |
| 437 | body.replaceChildren(); | |
| 403 | 438 | for (const player of rows) { |
| 404 | 439 | const tr = document.createElement("tr"); |
| 405 | 440 | const cells = [ |
| @@ -419,11 +454,175 @@ function paintHud(s) { | ||
| 419 | 454 | } |
| 420 | 455 | } |
| 421 | 456 | |
| 422 | fetch("/map.json") | |
| 423 | .then((r) => r.json()) | |
| 424 | .then((data) => { | |
| 425 | map = data; | |
| 457 | /** Is this player standing where the spike arms? The grid says so. */ | |
| 458 | function onSite(me) { | |
| 459 | if (!map) return false; | |
| 460 | const row = map.rows[Math.floor(me.y / map.tile)]; | |
| 461 | const cell = row ? row[Math.floor(me.x / map.tile)] : ""; | |
| 462 | return cell === "A" || cell === "B"; | |
| 463 | } | |
| 464 | ||
| 465 | /** | |
| 466 | * What just happened to whom: newest first, and gone a few seconds later. | |
| 467 | * The server sends names and a gun and nothing else, so there is nothing | |
| 468 | * here to read a position out of. | |
| 469 | */ | |
| 470 | function paintFeed(s) { | |
| 471 | const box = $("feed"); | |
| 472 | const lines = (s.feed ?? []).slice().reverse(); | |
| 473 | // The age is bucketed into the key: an entry fades once on its way out | |
| 474 | // rather than rebuilding the panel every frame. | |
| 475 | const key = lines.map((l) => `${l.k}>${l.v}:${l.w}:${l.h}:${l.t > 6 ? "old" : "new"}`).join("|"); | |
| 476 | if (box.dataset.key === key) return; | |
| 477 | box.dataset.key = key; | |
| 478 | box.replaceChildren(); | |
| 479 | for (const line of lines) { | |
| 480 | const row = document.createElement("div"); | |
| 481 | row.style.opacity = line.t > 6 ? "0.5" : "1"; | |
| 482 | const killer = document.createElement("span"); | |
| 483 | killer.className = "k"; | |
| 484 | killer.textContent = line.k ?? "—"; | |
| 485 | const victim = document.createElement("span"); | |
| 486 | victim.className = "v"; | |
| 487 | victim.textContent = line.v; | |
| 488 | const how = document.createElement("span"); | |
| 489 | how.className = "w"; | |
| 490 | how.textContent = `${line.w ? " " + line.w : " ability"}${line.h ? " ✧" : ""}`; | |
| 491 | const arrow = document.createElement("span"); | |
| 492 | arrow.textContent = " ▸ "; | |
| 493 | row.append(killer, arrow, victim, how); | |
| 494 | box.append(row); | |
| 495 | } | |
| 496 | } | |
| 497 | ||
| 498 | /** | |
| 499 | * Your team, always on screen: five slots, health, and struck through when | |
| 500 | * somebody is dead. The one place both questions — who is with me, who is | |
| 501 | * still up — are answered whether anybody is in sight or not. | |
| 502 | */ | |
| 503 | function paintRoster(s) { | |
| 504 | const rows = roster(s); | |
| 505 | const panel = $("roster"); | |
| 506 | const key = rosterKey(rows); | |
| 507 | if (panel.dataset.key === key) return; | |
| 508 | panel.dataset.key = key; | |
| 509 | panel.replaceChildren(); | |
| 510 | for (const row of rows) { | |
| 511 | const slot = document.createElement("div"); | |
| 512 | slot.className = `slot${row.mine ? " mine" : ""}${row.alive ? "" : " down"}`; | |
| 513 | slot.style.borderLeftColor = AGENT_COLOUR[row.agent] || "#4a5162"; | |
| 514 | const who = document.createElement("span"); | |
| 515 | who.className = "who"; | |
| 516 | who.textContent = row.name; | |
| 517 | const agent = document.createElement("span"); | |
| 518 | agent.className = "agent"; | |
| 519 | agent.textContent = row.alive ? row.agent : `${row.agent} · dead`; | |
| 520 | if (row.spike) { | |
| 521 | const spike = document.createElement("span"); | |
| 522 | spike.className = "spike"; | |
| 523 | spike.textContent = " spike"; | |
| 524 | agent.append(spike); | |
| 525 | } | |
| 526 | const bar = document.createElement("span"); | |
| 527 | bar.className = "hp"; | |
| 528 | const fill = document.createElement("i"); | |
| 529 | fill.style.width = `${row.hp}%`; | |
| 530 | fill.style.background = row.hp > 40 ? "#6fcf6f" : "#e0a040"; | |
| 531 | bar.append(fill); | |
| 532 | slot.append(who, agent, bar); | |
| 533 | panel.append(slot); | |
| 534 | } | |
| 535 | } | |
| 536 | ||
| 537 | const kitOf = (id) => (agents ? agents.find((a) => a.id === id) : null); | |
| 538 | ||
| 539 | /** The four ability buttons, each saying what it does and what it has left. */ | |
| 540 | function paintKit(s) { | |
| 541 | const kit = $("kit"); | |
| 542 | const agent = kitOf(s.me.agent); | |
| 543 | const shape = | |
| 544 | ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") + | |
| 545 | s.me.agent + | |
| 546 | s.me.ult + | |
| 547 | (agent ? "k" : ""); | |
| 548 | if (KITS.shape === shape) return; | |
| 549 | KITS.shape = shape; | |
| 550 | kit.replaceChildren(); | |
| 551 | for (const slot of ["c", "q", "e", "x"]) { | |
| 552 | const ability = agent ? agent.abilities[slot] : null; | |
| 553 | const button = document.createElement("button"); | |
| 554 | const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0; | |
| 555 | button.disabled = !ready; | |
| 556 | button.type = "button"; | |
| 557 | const count = | |
| 558 | slot === "x" | |
| 559 | ? `${s.me.ult}/${s.me.ultNeeds}` | |
| 560 | : s.me.cooldown[slot] > 0 | |
| 561 | ? `${Math.ceil(s.me.cooldown[slot])}s` | |
| 562 | : s.me.charges[slot]; | |
| 563 | const name = document.createElement("span"); | |
| 564 | name.textContent = `${slot.toUpperCase()}${ability ? " " + ability.name : ""}`; | |
| 565 | const tally = document.createElement("b"); | |
| 566 | tally.textContent = String(count); | |
| 567 | button.append(name, tally); | |
| 568 | if (ability) button.title = `${ability.name} — ${ability.text}`; | |
| 569 | button.addEventListener("click", () => send({ t: "i", c: slot })); | |
| 570 | kit.append(button); | |
| 571 | } | |
| 572 | } | |
| 573 | ||
| 574 | /** Prices on every button, greyed when you cannot afford it. */ | |
| 575 | function paintBuy(s) { | |
| 576 | const agent = kitOf(s.me.agent); | |
| 577 | for (const button of document.querySelectorAll("[data-buy]")) { | |
| 578 | const what = button.dataset.buy; | |
| 579 | let cost = Number(button.dataset.cost ?? 0); | |
| 580 | if ((what === "c" || what === "q") && agent) { | |
| 581 | const ability = agent.abilities[what]; | |
| 582 | cost = ability.cost; | |
| 583 | button.replaceChildren(); | |
| 584 | const name = document.createElement("span"); | |
| 585 | name.textContent = `${what.toUpperCase()} ${ability.name}`; | |
| 586 | const price = document.createElement("b"); | |
| 587 | price.textContent = `${ability.cost} · ${s.me.charges[what]}/${ability.charges}`; | |
| 588 | button.append(name, price); | |
| 589 | button.title = `${ability.text} (${ability.charges} charge${ability.charges === 1 ? "" : "s"} a round)`; | |
| 590 | } | |
| 591 | button.disabled = cost > s.me.credits; | |
| 592 | } | |
| 593 | for (const button of document.querySelectorAll("[data-agent]")) { | |
| 594 | const picked = button.dataset.agent === s.me.agent; | |
| 595 | button.style.outline = picked ? "2px solid #6fcf9f" : ""; | |
| 596 | } | |
| 597 | } | |
| 598 | ||
| 599 | // ------------------------------------------------------------------- start | |
| 600 | ||
| 601 | Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").then((r) => r.json())]) | |
| 602 | .then(([mapData, kit]) => { | |
| 603 | map = mapData; | |
| 604 | agents = kit.agents; | |
| 605 | const rows = map.rows; | |
| 606 | const cols = rows[0].length; | |
| 607 | solid = (col, row) => | |
| 608 | col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#"; | |
| 609 | $("stage").dataset.mode = mode; | |
| 610 | fillHelp(); | |
| 426 | 611 | resize(); |
| 427 | 612 | connect(); |
| 428 | }); | |
| 613 | }) | |
| 614 | .catch(() => ($("conn").textContent = "offline")); | |
| 615 | ||
| 616 | /** The card says the round, the sides, the keys and the kit, in that order. */ | |
| 617 | function fillHelp() { | |
| 618 | const list = $("helpagents"); | |
| 619 | if (!list || !agents) return; | |
| 620 | list.replaceChildren(); | |
| 621 | for (const agent of agents) { | |
| 622 | const li = document.createElement("li"); | |
| 623 | li.textContent = `${agent.name} (${agent.role}) — ${agent.blurb}`; | |
| 624 | list.append(li); | |
| 625 | } | |
| 626 | } | |
| 627 | ||
| 429 | 628 | requestAnimationFrame(frame); |