wren/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Draw the line between the engine and the game
The machinery any served game stands on moves under src/engine/ and static/engine/, and the game imports it rather than the other way round. No engine module imports a game module, and src/engine/boundary.test.ts asserts it, along with a scan for this game's words in engine code. Server: glide.ts and aim.ts move as they are. The map's mechanics become engine/grid.ts (makeGrid binds solid tiles, sliding, sight, rays and walk distances to any rows); src/map.ts keeps this map's rows and what its letters mean, re-exporting the bound functions under the same names. The served-match shape becomes engine/arena.ts: the idle gate, seats rationed per address, the reload-safe release, the positionless spectator view, the per-viewer vision filter and the sticky snapshot field, taking the game as callbacks (step, sit, stand, visible, snapshot shape, spectator shape, command). src/arena.ts is the Valorant binding with the same exports. Client: interp, predict, presence, yaw, steer, raycast, slices, resolution, budget and gun move as they are. scene.js and topdown.js are split: the engine halves draw walls, fog, bodies, marks and tracers and take the game's props, fog discs, trim colour and badge through hooks; the game halves at the old paths supply the spike, orbs, zones and agent colours, so every import path stays the same. Tests move with their module. The mixed ones are split so an engine test imports only the engine: the bot half of aim.test.ts stays with the game, the match-motion test joins feel.test.ts, the real-map slide test becomes map.test.ts, predict.test.ts walks a grid of its own. Seven new tests prove the served-match rules over a toy game; three keep the boundary. Nothing on the wire changes: with the clock pinned, a seat's snapshot is byte-identical to main (3816 B, same hash) and so is the spectator's. The one measurable change: last-seen marks no longer carry an agent field, which nothing drew. Co-Authored-By: Claude Fable 5.1 <[email protected]>
48 files
changed +2118
−1454
README.md +10 −10
| @@ -51,7 +51,7 @@ server: | ||
| 51 | 51 | with hands: 200–350ms to react to a target they have just seen, an aim |
| 52 | 52 | cone that opens several degrees on the swing and only settles while they |
| 53 | 53 | hold the same target, and more of both when you are crossing their view |
| 54 | (`src/aim.ts`). Every roll of it comes from the match's seeded random, so | |
| 54 | (`src/engine/aim.ts`). Every roll of it comes from the match's seeded random, so | |
| 55 | 55 | a seed replays the same round. |
| 56 | 56 | |
| 57 | 57 | Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits |
| @@ -64,12 +64,12 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 64 | 64 | |
| 65 | 65 | - Your own body is *drawn* from a guess — the browser runs your own movement |
| 66 | 66 | forward from your own keys between snapshots, through the same |
| 67 | step-and-slide the server uses (`src/glide.ts`), and carries the last | |
| 67 | step-and-slide the server uses (`src/engine/glide.ts`), and carries the last | |
| 68 | 68 | snapshot forward by up to a quarter second of held input. It is a drawing |
| 69 | 69 | optimisation only: nothing is sent, every snapshot leans the drawn body |
| 70 | 70 | back onto the server's answer, and a real disagreement is taken outright. |
| 71 | 71 | The lag your own keys take to reach the server (a send every 50ms, a hop, |
| 72 | a tick — `LEAD_SLACK` in `static/predict.js`) is not a disagreement: the | |
| 72 | a tick — `LEAD_SLACK` in `static/engine/predict.js`) is not a disagreement: the | |
| 73 | 73 | drawn body may lead the answer by that much along the way it is going and |
| 74 | 74 | is left alone there, so a start is not yanked back and a stop is not |
| 75 | 75 | dragged on. Anywhere the server would refuse — a wall, the spawn pen in |
| @@ -79,8 +79,8 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 79 | 79 | second kick; whether it landed is still only the server's to say. |
| 80 | 80 | Where you are *looking* is the browser's too: the mouse moves it at frame |
| 81 | 81 | rate, and the server's angle is taken back only when it is telling you |
| 82 | something you did not say (`static/yaw.js`). | |
| 83 | - Everybody else is drawn by entity interpolation (`static/interp.js`): each | |
| 82 | something you did not say (`static/engine/yaw.js`). | |
| 83 | - Everybody else is drawn by entity interpolation (`static/engine/interp.js`): each | |
| 84 | 84 | body keeps the last few snapshots, and the frame is drawn about one |
| 85 | 85 | snapshot interval in the past, between the two that bracket that moment. |
| 86 | 86 | So they move at the speed the server moved them and arrive where it put |
| @@ -101,19 +101,19 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 101 | 101 | no looking up or down, and everything that is not a wall — bodies, the |
| 102 | 102 | spike, ability zones — is a flat billboard scaled by distance and clipped |
| 103 | 103 | against the walls. It is drawn into an offscreen canvas 960 columns wide |
| 104 | (`static/resolution.js`, never more than the canvas has pixels), one ray | |
| 104 | (`static/engine/resolution.js`, never more than the canvas has pixels), one ray | |
| 105 | 105 | every other column, and scaled onto the visible one, so the frame costs the |
| 106 | 106 | same on a retina screen as on any other; the crosshair, the HUD and the |
| 107 | 107 | minimap are drawn on top at full device resolution. Columns that would draw |
| 108 | the same rectangle are batched into one fill (`static/slices.js`), the | |
| 108 | the same rectangle are batched into one fill (`static/engine/slices.js`), the | |
| 109 | 109 | map's tiles are drawn once a size and blitted after that, and if the frame |
| 110 | 110 | still runs over 20ms the render width drops a rung until it fits |
| 111 | (`static/budget.js`). Fidelity is cheap here and frame rate is not. | |
| 111 | (`static/engine/budget.js`). Fidelity is cheap here and frame rate is not. | |
| 112 | 112 | All of it is presentation: the simulation and the snapshots are untouched. |
| 113 | 113 | - A body you have only just been shown is faded and grown into sight over a |
| 114 | 114 | tenth of a second, and slid in from the direction it came if it was seen |
| 115 | 115 | near there before; where somebody was last seen alive leaves an outline |
| 116 | that dims away over a second (`static/presence.js`). The server still sends | |
| 116 | that dims away over a second (`static/engine/presence.js`). The server still sends | |
| 117 | 117 | only what you can see — that is the rule, and nothing here bends it. This |
| 118 | 118 | is a drawing about information you already had: an enemy who steps into a |
| 119 | 119 | sightline has no history, and without it they appear mid-stride rather than |
| @@ -177,7 +177,7 @@ says how. | ||
| 177 | 177 | |
| 178 | 178 | ## Shape |
| 179 | 179 | |
| 180 | `src/map.ts` the grid and its geometry · `src/weapons.ts` the damage table · | |
| 180 | `src/engine/` the machinery any game on this server stands on — the grid and its collision (`grid.ts`, `glide.ts`), the bot aim model (`aim.ts`) and the served match with its idle gate, per-address seats, positionless spectator view and per-viewer vision filter (`arena.ts`); nothing in it imports the game, and `src/engine/boundary.test.ts` keeps it that way · `static/engine/` the client feel stack and renderer — interpolation, prediction, presence, yaw, steer, raycast, scene, slices, resolution, budget, topdown, gun · `src/map.ts` this map's rows and what its letters mean · `src/weapons.ts` the damage table · | |
| 181 | 181 | `src/economy.ts` credits · `src/abilities.ts` the agent kits · |
| 182 | 182 | `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes |
| 183 | 183 | no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one |
src/abilities.test.ts +1 −1
| @@ -18,7 +18,7 @@ import { | ||
| 18 | 18 | import { HEIGHT, WIDTH } from "./map.ts"; |
| 19 | 19 | import { newMatch, startRound, tick } from "./match.ts"; |
| 20 | 20 | import type { MatchState, Player, Zone } from "./state.ts"; |
| 21 | import { FOV } from "../static/raycast.js"; | |
| 21 | import { FOV } from "../static/engine/raycast.js"; | |
| 22 | 22 | |
| 23 | 23 | const flashes = (): Ability[] => AGENTS.flatMap((a) => SLOTS.map((s) => a.abilities[s])).filter((a) => a.kind === "flash"); |
| 24 | 24 |
src/abilities.ts +1 −1
| @@ -143,7 +143,7 @@ export const buyableSlots: Slot[] = ["c", "q"]; | ||
| 143 | 143 | * of the flash you get flashed, and if you are not, you do not. Facing is |
| 144 | 144 | * the gate. Distance only decides how long. |
| 145 | 145 | * |
| 146 | * The browser draws a 75° field of view (`FOV` in `static/raycast.js`), so | |
| 146 | * The browser draws a 75° field of view (`FOV` in `static/engine/raycast.js`), so | |
| 147 | 147 | * anything inside half of that was on your screen: you looked at it, you |
| 148 | 148 | * pay the whole duration. Past the edge of the screen there is a little |
| 149 | 149 | * grace for the fact that you were probably turning, falling away quickly |
src/aim.test.ts +3 −117
| @@ -1,24 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | * How a bot points a gun, tested as the pure maths it is, and then once | |
| 3 | * through a whole bot to prove the gate is actually wired to the trigger. | |
| 2 | * The bot's aim (`engine/aim.ts`), once through a whole bot to prove the | |
| 3 | * reaction gate is actually wired to the trigger and the cone to the barrel. | |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { expect, test } from "bun:test"; |
| 7 | import { | |
| 8 | acquire, | |
| 9 | aimCone, | |
| 10 | canFire, | |
| 11 | CONE_FLOOR, | |
| 12 | crossRate, | |
| 13 | drift, | |
| 14 | emptyTrack, | |
| 15 | FORGET, | |
| 16 | heldFor, | |
| 17 | lose, | |
| 18 | REACTION_MAX, | |
| 19 | REACTION_MIN, | |
| 20 | reactionTime, | |
| 21 | } from "./aim.ts"; | |
| 7 | import { acquire, crossRate, emptyTrack, REACTION_MAX, REACTION_MIN } from "./engine/aim.ts"; | |
| 22 | 8 | import { botThink } from "./bots.ts"; |
| 23 | 9 | import { newMatch, RUN_SPEED } from "./match.ts"; |
| 24 | 10 | import type { MatchState, Player } from "./state.ts"; |
| @@ -28,106 +14,6 @@ const rolls = (...values: number[]) => { | ||
| 28 | 14 | return () => values[at++ % values.length]!; |
| 29 | 15 | }; |
| 30 | 16 | |
| 31 | test("the reaction a bot draws is between 200 and 350ms, whatever the roll", () => { | |
| 32 | expect(reactionTime(0)).toBeCloseTo(REACTION_MIN, 9); | |
| 33 | expect(reactionTime(1)).toBeCloseTo(REACTION_MAX, 9); | |
| 34 | expect(reactionTime(0.5)).toBeCloseTo((REACTION_MIN + REACTION_MAX) / 2, 9); | |
| 35 | // A roll out of range is still a reaction, not a bot that never shoots. | |
| 36 | expect(reactionTime(-3)).toBe(REACTION_MIN); | |
| 37 | expect(reactionTime(9)).toBe(REACTION_MAX); | |
| 38 | }); | |
| 39 | ||
| 40 | test("the trigger gate is a pure function of when acquisition happened", () => { | |
| 41 | const track = emptyTrack(); | |
| 42 | // Nothing in sight is never a shot, however long the round has run. | |
| 43 | expect(canFire(track, 100)).toBe(false); | |
| 44 | ||
| 45 | acquire(track, "bot-red-1", 10, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 46 | expect(track.reaction).toBeCloseTo(REACTION_MIN, 9); | |
| 47 | expect(canFire(track, 10)).toBe(false); | |
| 48 | expect(canFire(track, 10.19)).toBe(false); | |
| 49 | expect(canFire(track, 10.21)).toBe(true); | |
| 50 | expect(canFire(track, 12)).toBe(true); | |
| 51 | expect(heldFor(track, 12)).toBeCloseTo(2, 9); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a new target starts the reaction again; the same one does not", () => { | |
| 55 | const track = emptyTrack(); | |
| 56 | acquire(track, "one", 5, { x: 100, y: 100 }, rolls(1, 0.5)); | |
| 57 | expect(track.since).toBe(5); | |
| 58 | acquire(track, "one", 5.3, { x: 180, y: 100 }, rolls(0, 0.5)); | |
| 59 | expect(track.since).toBe(5); // the hold carries on | |
| 60 | expect(canFire(track, 5.4)).toBe(true); | |
| 61 | acquire(track, "two", 5.4, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 62 | expect(track.since).toBe(5.4); // swinging onto somebody else costs again | |
| 63 | expect(canFire(track, 5.5)).toBe(false); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a hold survives a blink out of sight, and no longer", () => { | |
| 67 | const track = emptyTrack(); | |
| 68 | acquire(track, "one", 1, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 69 | lose(track, 1 + FORGET / 2); | |
| 70 | expect(track.id).toBe("one"); | |
| 71 | // Back a moment later: the same hold, already through its reaction. | |
| 72 | acquire(track, "one", 1 + FORGET / 2, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 73 | expect(track.since).toBe(1); | |
| 74 | // Gone for longer than that and it is a fresh acquisition. | |
| 75 | lose(track, 3); | |
| 76 | expect(track.id).toBe(null); | |
| 77 | acquire(track, "one", 3, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 78 | expect(track.since).toBe(3); | |
| 79 | expect(canFire(track, 3.1)).toBe(false); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the cone starts wide, settles the longer one target is held, and never closes", () => { | |
| 83 | const swing = aimCone(0, 0); | |
| 84 | const second = aimCone(1, 0); | |
| 85 | const settled = aimCone(3, 0); | |
| 86 | expect(swing).toBeGreaterThan(0.08); // several degrees on the swing | |
| 87 | expect(second).toBeLessThan(swing / 2); | |
| 88 | expect(settled).toBeLessThan(second); | |
| 89 | expect(settled).toBeGreaterThanOrEqual(CONE_FLOOR); | |
| 90 | expect(aimCone(99, 0)).toBeCloseTo(CONE_FLOOR, 6); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a target crossing the view costs the bot accuracy", () => { | |
| 94 | expect(aimCone(3, 1)).toBeGreaterThan(aimCone(3, 0) * 3); | |
| 95 | expect(aimCone(3, 0.5)).toBeGreaterThan(aimCone(3, 0)); | |
| 96 | expect(aimCone(3, 0.5)).toBeLessThan(aimCone(3, 1)); | |
| 97 | // Nonsense from the caller is clamped, not multiplied out. | |
| 98 | expect(aimCone(3, 50)).toBeCloseTo(aimCone(3, 1), 9); | |
| 99 | }); | |
| 100 | ||
| 101 | test("crossing is measured across the sight line, not along it", () => { | |
| 102 | const bot = { x: 0, y: 0 }; | |
| 103 | const walking = emptyTrack(); | |
| 104 | acquire(walking, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); | |
| 105 | // Straight down the barrel: nothing to track. | |
| 106 | expect(crossRate(walking, bot, { x: 260, y: 0 }, 0.1)).toBeCloseTo(0, 6); | |
| 107 | const strafing = emptyTrack(); | |
| 108 | acquire(strafing, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); | |
| 109 | // Straight across it at a run: all of it but the turn of the sight line. | |
| 110 | expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(250, -1); | |
| 111 | // And it remembers where the target went, so the next tick measures again. | |
| 112 | expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(0, 6); | |
| 113 | }); | |
| 114 | ||
| 115 | test("the error wanders inside the cone instead of being redrawn every tick", () => { | |
| 116 | const track = emptyTrack(); | |
| 117 | acquire(track, "one", 0, { x: 0, y: 0 }, rolls(0, 1)); | |
| 118 | // Drawn at the edge of the swing cone on acquisition. | |
| 119 | expect(Math.abs(track.error)).toBeCloseTo(aimCone(0, 0), 9); | |
| 120 | // One tick moves it a little, not to somewhere unrelated. | |
| 121 | const before = track.error; | |
| 122 | const after = drift(track, aimCone(0, 0), 1 / 30, rolls(0.9)); | |
| 123 | expect(Math.abs(after - before)).toBeLessThan(aimCone(0, 0) / 4); | |
| 124 | // And it is squeezed toward the barrel as the cone closes around it. | |
| 125 | for (let i = 0; i < 90; i++) drift(track, aimCone(3, 0), 1 / 30, rolls(0.5, 1, 0)); | |
| 126 | expect(Math.abs(track.error)).toBeLessThanOrEqual(aimCone(3, 0) + 1e-12); | |
| 127 | }); | |
| 128 | ||
| 129 | // ------------------------------------------------------------- through a bot | |
| 130 | ||
| 131 | 17 | const facing = (state: MatchState): { bot: Player; prey: Player } => { |
| 132 | 18 | const bot = state.players.find((p) => p.side === "def")!; |
| 133 | 19 | const prey = state.players.find((p) => p.side === "atk")!; |
src/arena.ts +92 −300
| @@ -1,240 +1,63 @@ | ||
| 1 | 1 | /** |
| 2 | * The arena: one match, always running, and the seats people sit in. | |
| 2 | * The arena: this game's one live match, served the way `engine/arena.ts` | |
| 3 | * serves any match — ticking only while somebody is connected, one playing | |
| 4 | * seat an address, a positionless view for whoever has no seat, and every | |
| 5 | * snapshot built for one viewer from what they can see. | |
| 3 | 6 | * |
| 4 | * The match ticks whether anybody is watching or not, so a visitor never | |
| 5 | * arrives at a frozen board — they get the round that is happening. Taking a | |
| 6 | * seat replaces a bot in place: same body, same credits, same position, so | |
| 7 | * joining mid-round hands you a live player rather than a lobby. | |
| 8 | * | |
| 9 | * Every snapshot is built for one viewer: enemies they cannot see are not in | |
| 10 | * it. The client never learns a position it has no line of sight to. | |
| 7 | * What is this game's own is here: taking a seat replaces a bot in the | |
| 8 | * emptiest team (same body, same credits, same position, so joining | |
| 9 | * mid-round hands you a live player rather than a lobby), the commands a | |
| 10 | * client may send, the shape of the snapshot, and the kill feed read off the | |
| 11 | * match a tick at a time. | |
| 11 | 12 | */ |
| 12 | 13 | |
| 13 | 14 | import { agentById, sightBlocked } from "./abilities.ts"; |
| 14 | 15 | import { runBots } from "./bots.ts"; |
| 15 | 16 | import { recordResult } from "./db.ts"; |
| 17 | import { makeArena, type Command, type Why } from "./engine/arena.ts"; | |
| 16 | 18 | import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; |
| 17 | 19 | import type { Point } from "./map.ts"; |
| 18 | import { | |
| 19 | buyAbility, | |
| 20 | buyShield, | |
| 21 | buyWeapon, | |
| 22 | newMatch, | |
| 23 | pickAgent, | |
| 24 | setInput, | |
| 25 | tick, | |
| 26 | visible, | |
| 27 | } from "./match.ts"; | |
| 20 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, tick, visible } from "./match.ts"; | |
| 28 | 21 | import type { MatchState, Player } from "./state.ts"; |
| 29 | 22 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 30 | 23 | |
| 31 | export const TICK = 1 / 30; | |
| 24 | export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; | |
| 32 | 25 | |
| 33 | /** | |
| 34 | * How many ticks apart the snapshots go out. | |
| 35 | * | |
| 36 | * The simulation runs at 30Hz and used to broadcast every other tick, which | |
| 37 | * gave the browser fifteen positions a second to draw sixty frames from — | |
| 38 | * not many, and with interpolation it also doubles the delay the drawing has | |
| 39 | * to sit behind the present. A small room gets every tick: it is the case | |
| 40 | * that matters (one or two people playing each other), the payload is a | |
| 41 | * couple of kilobytes of JSON a seat, and the arena only runs at all while | |
| 42 | * somebody is connected. Above that the cost multiplies by seats and the | |
| 43 | * older cadence comes back. | |
| 44 | */ | |
| 45 | export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2); | |
| 46 | ||
| 47 | export type Seat = { | |
| 48 | playerId: string; | |
| 49 | token: string; | |
| 50 | address: string; | |
| 51 | send: (data: string) => void; | |
| 52 | /** The scoreboard as this seat last received it, so it is only resent when it changes. */ | |
| 53 | board: string; | |
| 54 | }; | |
| 55 | ||
| 56 | let state: MatchState = newMatch(); | |
| 57 | const seats = new Map<string, Seat>(); | |
| 58 | // A session is a cookie, and a cookie costs one HTTP request — free for | |
| 59 | // anyone to mint as many of as they like. The address is the one thing here | |
| 60 | // a single machine cannot hand itself two of, so it is what a playing seat | |
| 61 | // is actually rationed by; the session on top of it is only what lets a | |
| 62 | // reload find its way back to the same seat rather than a fresh one. | |
| 63 | const addressSeats = new Map<string, string>(); | |
| 64 | let ticks = 0; | |
| 26 | const state: MatchState = newMatch(); | |
| 65 | 27 | let lastResult: string | null = null; |
| 66 | 28 | // The kill feed is read off the match a tick at a time; the rules keep no |
| 67 | 29 | // list of their own, and this is a view, not a rule. |
| 68 | 30 | let marks: Mark[] = marksOf(state.players); |
| 69 | 31 | let feed: Fall[] = []; |
| 70 | 32 | |
| 71 | export const match = (): MatchState => state; | |
| 33 | const round2 = (n: number): number => Math.round(n * 10) / 10; | |
| 72 | 34 | |
| 73 | export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id); | |
| 74 | ||
| 75 | /** | |
| 76 | * A name nobody else in this match is using. | |
| 77 | * | |
| 78 | * The kill feed, the round log and the scoreboard all address people by | |
| 79 | * name: two players called Kit make a line that reads as a team kill this | |
| 80 | * game cannot have. The bots are dealt distinct names; a person who picks | |
| 81 | * one that is taken gets a number after it, inside the sixteen characters a | |
| 82 | * name is allowed. | |
| 83 | */ | |
| 84 | function freeName(want: string, exceptId: string): string { | |
| 85 | const taken = new Set(state.players.filter((p) => p.id !== exceptId).map((p) => p.name)); | |
| 86 | if (!taken.has(want)) return want; | |
| 87 | for (let n = 2; n < 100; n++) { | |
| 88 | const tried = `${want.slice(0, 13)} ${n}`; | |
| 89 | if (!taken.has(tried)) return tried; | |
| 90 | } | |
| 91 | return want; | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Whether this address already plays under a different session. Checked | |
| 96 | * before a socket is even offered a seat, so the caller can hand it a | |
| 97 | * spectator view instead of upgrading it into a second side's vision. | |
| 98 | */ | |
| 99 | export function addressCapped(address: string, session: string): boolean { | |
| 100 | const holder = addressSeats.get(address); | |
| 101 | return holder !== undefined && holder !== session; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Sit somebody down in the emptiest team, replacing a bot where it stands. | |
| 106 | * | |
| 107 | * Both claims go in right here, synchronously, before the caller ever opens | |
| 108 | * a socket. A websocket upgrade completes on a later tick, so two requests | |
| 109 | * arriving close together both used to read the maps before either had | |
| 110 | * written to them — one client, two seats, opposite sides, the vision of | |
| 111 | * both. Claiming inline closes that window regardless of how the two | |
| 112 | * requests were related. | |
| 113 | */ | |
| 114 | export function takeSeat(address: string, session: string, name: string): Player | null { | |
| 115 | const existing = seats.get(session); | |
| 116 | const held = existing ? findPlayer(existing.playerId) : undefined; | |
| 117 | if (held) { | |
| 118 | held.name = freeName(name, held.id); | |
| 119 | return held; | |
| 120 | } | |
| 121 | if (addressCapped(address, session)) return null; | |
| 35 | /** Sit in the emptiest team, on a bot's seat, keeping its body as it stands. */ | |
| 36 | function sit(state: MatchState): Player | null { | |
| 122 | 37 | const humans = { red: 0, blue: 0 }; |
| 123 | 38 | for (const player of state.players) if (!player.bot) humans[player.team] += 1; |
| 124 | 39 | const wanted = humans.red <= humans.blue ? "red" : "blue"; |
| 125 | const seat = | |
| 126 | state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); | |
| 40 | const seat = state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); | |
| 127 | 41 | if (!seat) return null; |
| 128 | 42 | seat.bot = false; |
| 129 | seat.name = freeName(name, seat.id); | |
| 130 | 43 | seat.think = 0; |
| 131 | 44 | seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; |
| 132 | addressSeats.set(address, session); | |
| 133 | seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, board: "" }); | |
| 134 | 45 | return seat; |
| 135 | 46 | } |
| 136 | 47 | |
| 137 | /** The socket that actually opened fills in how to reach it and its token. */ | |
| 138 | export function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void { | |
| 139 | seats.set(session, { playerId, token, address, send, board: "" }); | |
| 48 | /** The seat goes back to a bot, mid-round and all. */ | |
| 49 | function stand(_state: MatchState, player: Player): void { | |
| 50 | player.bot = true; | |
| 51 | player.think = 0; | |
| 52 | player.input.fire = false; | |
| 53 | player.input.use = false; | |
| 140 | 54 | } |
| 141 | 55 | |
| 142 | /** | |
| 143 | * A person leaving gives the seat back to a bot, mid-round and all — but only | |
| 144 | * if this is still the socket the session is on. A reload opens the new | |
| 145 | * socket before the old one's close event arrives; without the token, that | |
| 146 | * late close would delete the reconnect's seat entry and hand a live human's | |
| 147 | * seat to a bot mid-round. | |
| 148 | */ | |
| 149 | export function release(session: string, token: string): void { | |
| 150 | const seat = seats.get(session); | |
| 151 | if (!seat || seat.token !== token) return; | |
| 152 | seats.delete(session); | |
| 153 | if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address); | |
| 154 | const player = findPlayer(seat.playerId); | |
| 155 | if (player && !stillHeld(seat.playerId)) { | |
| 156 | player.bot = true; | |
| 157 | player.think = 0; | |
| 158 | player.input.fire = false; | |
| 159 | player.input.use = false; | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | /** | |
| 164 | * Test-only hygiene: give back whatever this address currently holds. The | |
| 165 | * arena is one match shared by the whole test run, over real HTTP, from one | |
| 166 | * loopback address — this is how a test that needs that address free gets it | |
| 167 | * free, rather than depending on every other test in the file to clean up | |
| 168 | * after itself. | |
| 169 | */ | |
| 170 | export function releaseAddress(address: string): void { | |
| 171 | const session = addressSeats.get(address); | |
| 172 | if (session !== undefined) release(session, seats.get(session)?.token ?? ""); | |
| 173 | } | |
| 174 | ||
| 175 | const stillHeld = (playerId: string): boolean => | |
| 176 | [...seats.values()].some((s) => s.playerId === playerId); | |
| 177 | ||
| 178 | export const seatCount = (): number => seats.size; | |
| 179 | ||
| 180 | // -------------------------------------------------------------- spectators | |
| 181 | ||
| 182 | // A connection that never carried a session the page minted gets no side and | |
| 183 | // no seat: only the public state everyone already sees on the page itself. | |
| 184 | const spectators = new Map<string, { send: (data: string) => void; why: Why }>(); | |
| 185 | ||
| 186 | /** | |
| 187 | * Why this connection is watching. The client says so in words, and the two | |
| 188 | * have different answers — open the tab that is playing, or let the page keep | |
| 189 | * its cookie — so which one it is travels with the snapshot. | |
| 190 | */ | |
| 191 | export type Why = "address" | "cookie" | "unsaid"; | |
| 192 | ||
| 193 | export function spectatorSnapshot(why: Why = "unsaid"): string { | |
| 194 | return JSON.stringify({ | |
| 195 | t: "s", | |
| 196 | spectator: true, | |
| 197 | why, | |
| 198 | phase: state.phase, | |
| 199 | clock: round2(Math.max(0, state.clock)), | |
| 200 | round: state.round, | |
| 201 | score: state.score, | |
| 202 | attackers: state.attackers, | |
| 203 | result: state.result, | |
| 204 | last: state.last, | |
| 205 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 206 | // Names and a gun, the same as a seat is sent, and for the same reason it | |
| 207 | // is safe there: the feed says what happened, never where. | |
| 208 | feed: feed.map((f) => ({ | |
| 209 | k: f.killer, | |
| 210 | v: f.victim, | |
| 211 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 212 | h: f.headshot, | |
| 213 | t: round2(Math.max(0, state.now - f.at)), | |
| 214 | })), | |
| 215 | }); | |
| 216 | } | |
| 217 | ||
| 218 | export function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void { | |
| 219 | spectators.set(token, { send, why }); | |
| 220 | send(spectatorSnapshot(why)); | |
| 221 | } | |
| 222 | ||
| 223 | export function unwatch(token: string): void { | |
| 224 | spectators.delete(token); | |
| 225 | } | |
| 226 | ||
| 227 | // ------------------------------------------------------------- the commands | |
| 228 | ||
| 229 | export type Command = { t: string; [key: string]: unknown }; | |
| 230 | ||
| 231 | 56 | const num = (value: unknown): number | undefined => |
| 232 | 57 | typeof value === "number" && Number.isFinite(value) ? value : undefined; |
| 233 | 58 | |
| 234 | 59 | /** Everything a client may ask for, validated here and nowhere else. */ |
| 235 | export function command(playerId: string, message: Command): void { | |
| 236 | const player = findPlayer(playerId); | |
| 237 | if (!player || player.bot) return; | |
| 60 | function runCommand(state: MatchState, player: Player, message: Command): void { | |
| 238 | 61 | switch (message.t) { |
| 239 | 62 | case "i": |
| 240 | 63 | setInput(player, { |
| @@ -260,36 +83,38 @@ export function command(playerId: string, message: Command): void { | ||
| 260 | 83 | } |
| 261 | 84 | } |
| 262 | 85 | |
| 263 | // ------------------------------------------------------------- the snapshot | |
| 86 | // Names and a gun, never a position: the feed says what happened, not | |
| 87 | // where, so it can never be read as a call-out. | |
| 88 | const feedFor = (state: MatchState) => | |
| 89 | feed.map((f) => ({ | |
| 90 | k: f.killer, | |
| 91 | v: f.victim, | |
| 92 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 93 | h: f.headshot, | |
| 94 | t: round2(Math.max(0, state.now - f.at)), | |
| 95 | })); | |
| 264 | 96 | |
| 265 | const round2 = (n: number): number => Math.round(n * 10) / 10; | |
| 266 | ||
| 267 | /** The full snapshot for one viewer, as one string. */ | |
| 268 | export const snapshot = (viewerId: string): string => JSON.stringify(snapshotFor(viewerId)); | |
| 269 | ||
| 270 | /** | |
| 271 | * The snapshot a seat is actually sent on this tick. The scoreboard — ten | |
| 272 | * names, agents and tallies — was almost half of every snapshot and changes | |
| 273 | * a few times a round, so it goes out only when it differs from what this | |
| 274 | * seat last received; the client keeps the one it has. Everything else is | |
| 275 | * the full snapshot, every time. | |
| 276 | */ | |
| 277 | export function snapshotForSeat(seat: Seat): string { | |
| 278 | const full = snapshotFor(seat.playerId); | |
| 279 | const board = JSON.stringify(full.board); | |
| 280 | if (board === seat.board) delete (full as { board?: unknown }).board; | |
| 281 | else seat.board = board; | |
| 282 | return JSON.stringify(full); | |
| 97 | /** The public state everyone already sees on the page itself. */ | |
| 98 | function spectatorShape(state: MatchState, _why: Why) { | |
| 99 | return { | |
| 100 | phase: state.phase, | |
| 101 | clock: round2(Math.max(0, state.clock)), | |
| 102 | round: state.round, | |
| 103 | score: state.score, | |
| 104 | attackers: state.attackers, | |
| 105 | result: state.result, | |
| 106 | last: state.last, | |
| 107 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 108 | feed: feedFor(state), | |
| 109 | }; | |
| 283 | 110 | } |
| 284 | 111 | |
| 285 | function snapshotFor(viewerId: string) { | |
| 286 | const me = findPlayer(viewerId) ?? state.players[0]!; | |
| 112 | /** The snapshot for one seat: `seen` is exactly who the arena says they may see. */ | |
| 113 | function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | |
| 287 | 114 | const agent = agentById(me.agent); |
| 288 | const seen = state.players.filter((p) => visible(state, me, p)); | |
| 289 | 115 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); |
| 290 | 116 | |
| 291 | 117 | return { |
| 292 | t: "s", | |
| 293 | 118 | phase: state.phase, |
| 294 | 119 | clock: round2(Math.max(0, state.clock)), |
| 295 | 120 | round: state.round, |
| @@ -356,6 +181,9 @@ function snapshotFor(viewerId: string) { | ||
| 356 | 181 | }, |
| 357 | 182 | orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), |
| 358 | 183 | shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), |
| 184 | // The scoreboard — ten names, agents and tallies — was almost half of | |
| 185 | // every snapshot and changes a few times a round, so the arena sends it | |
| 186 | // only when it differs from what the seat last received. | |
| 359 | 187 | board: state.players.map((p) => ({ |
| 360 | 188 | id: p.id, |
| 361 | 189 | name: p.name, |
| @@ -372,15 +200,7 @@ function snapshotFor(viewerId: string) { | ||
| 372 | 200 | ultNeeds: agentById(p.agent).abilities.x.points, |
| 373 | 201 | })), |
| 374 | 202 | events: state.events.slice(0, 5).map((e) => e.text), |
| 375 | // Names and a gun, never a position: the feed says what happened, not | |
| 376 | // where, so it can never be read as a call-out. | |
| 377 | feed: feed.map((f) => ({ | |
| 378 | k: f.killer, | |
| 379 | v: f.victim, | |
| 380 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 381 | h: f.headshot, | |
| 382 | t: round2(Math.max(0, state.now - f.at)), | |
| 383 | })), | |
| 203 | feed: feedFor(state), | |
| 384 | 204 | }; |
| 385 | 205 | } |
| 386 | 206 | |
| @@ -389,7 +209,7 @@ function snapshotFor(viewerId: string) { | ||
| 389 | 209 | * killer stands is what tells a gun kill from an ability kill — the position |
| 390 | 210 | * is used here and goes no further. |
| 391 | 211 | */ |
| 392 | function readFalls(): void { | |
| 212 | function readFalls(state: MatchState): void { | |
| 393 | 213 | const after = marksOf(state.players); |
| 394 | 214 | const shooters = new Set<string>(); |
| 395 | 215 | for (const shot of state.shots) { |
| @@ -407,77 +227,49 @@ function readFalls(): void { | ||
| 407 | 227 | feed = trimFeed(feed, state.now); |
| 408 | 228 | } |
| 409 | 229 | |
| 410 | // ----------------------------------------------------------------- the loop | |
| 411 | ||
| 412 | /** | |
| 413 | * Nobody watching, nothing happens. | |
| 414 | * | |
| 415 | * The arena used to tick around the clock, so bots played out rounds for an | |
| 416 | * empty room — burning a share of a small box's CPU and filling the record | |
| 417 | * with matches no one saw. The world now freezes when the last connection | |
| 418 | * goes and carries on from exactly where it stopped when someone arrives, so | |
| 419 | * a visitor never finds a score that ran up while they were away. | |
| 420 | * | |
| 421 | * A spectator counts as somebody: watching is watching. | |
| 422 | */ | |
| 423 | const attended = (): boolean => seats.size > 0 || spectators.size > 0; | |
| 424 | ||
| 425 | /** How many connections the arena is playing for: seats plus watchers. */ | |
| 426 | export const attendance = (): number => seats.size + spectators.size; | |
| 427 | ||
| 428 | function step(): void { | |
| 429 | runBots(state, TICK); | |
| 430 | tick(state, TICK); | |
| 431 | readFalls(); | |
| 230 | /** One tick of this game: the bots, the rules, the feed, and the record. */ | |
| 231 | function step(state: MatchState, dt: number): void { | |
| 232 | runBots(state, dt); | |
| 233 | tick(state, dt); | |
| 234 | readFalls(state); | |
| 432 | 235 | if (state.result && state.result !== lastResult) { |
| 433 | 236 | lastResult = state.result; |
| 434 | 237 | recordResult(state.result, state.score.red, state.score.blue, state.round); |
| 435 | 238 | } |
| 436 | 239 | if (state.result === null) lastResult = null; |
| 437 | ticks += 1; | |
| 438 | if (ticks % broadcastEvery(seats.size) !== 0) return; | |
| 439 | for (const seat of seats.values()) { | |
| 440 | try { | |
| 441 | seat.send(snapshotForSeat(seat)); | |
| 442 | } catch { | |
| 443 | // A socket that will not take a frame is closed already; the close | |
| 444 | // handler gives the seat back. | |
| 445 | } | |
| 446 | } | |
| 447 | for (const spectator of spectators.values()) { | |
| 448 | try { | |
| 449 | spectator.send(spectatorSnapshot(spectator.why)); | |
| 450 | } catch { | |
| 451 | // Same story: the close handler drops it from the set. | |
| 452 | } | |
| 453 | } | |
| 454 | 240 | } |
| 455 | 241 | |
| 456 | let timer: ReturnType<typeof setInterval> | null = null; | |
| 242 | const arena = makeArena<MatchState, Player>({ | |
| 243 | state, | |
| 244 | players: (s) => s.players, | |
| 245 | step, | |
| 246 | sit, | |
| 247 | stand, | |
| 248 | visible, | |
| 249 | snapshot: snapshotShape, | |
| 250 | sticky: "board", | |
| 251 | spectatorSnapshot: spectatorShape, | |
| 252 | command: runCommand, | |
| 253 | }); | |
| 457 | 254 | |
| 458 | /** | |
| 459 | * One beat of real time. The gate lives here rather than inside `step` so the | |
| 460 | * tests can still drive the simulation directly with `advance` — a test is an | |
| 461 | * attendance of its own, and making it prove otherwise would only teach the | |
| 462 | * suite to work around the rule. | |
| 463 | */ | |
| 464 | export function beat(): void { | |
| 465 | if (attended()) step(); | |
| 466 | } | |
| 467 | ||
| 468 | export function startArena(): void { | |
| 469 | if (timer) return; | |
| 470 | timer = setInterval(beat, TICK * 1000); | |
| 471 | // The clock must never hold the process open on its own. | |
| 472 | (timer as unknown as { unref?: () => void }).unref?.(); | |
| 473 | } | |
| 474 | ||
| 475 | export function stopArena(): void { | |
| 476 | if (timer) clearInterval(timer); | |
| 477 | timer = null; | |
| 478 | } | |
| 479 | ||
| 480 | /** Used by the tests: run the arena forward without waiting for real time. */ | |
| 481 | export function advance(seconds: number): void { | |
| 482 | for (let i = 0; i < Math.round(seconds / TICK); i++) step(); | |
| 483 | } | |
| 255 | export const { | |
| 256 | match, | |
| 257 | findPlayer, | |
| 258 | addressCapped, | |
| 259 | takeSeat, | |
| 260 | attach, | |
| 261 | release, | |
| 262 | releaseAddress, | |
| 263 | seatCount, | |
| 264 | watch, | |
| 265 | unwatch, | |
| 266 | spectatorSnapshot, | |
| 267 | command, | |
| 268 | snapshot, | |
| 269 | snapshotForSeat, | |
| 270 | attendance, | |
| 271 | beat, | |
| 272 | advance, | |
| 273 | } = arena; | |
| 274 | export const startArena = arena.start; | |
| 275 | export const stopArena = arena.stop; |
src/bots.ts +1 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { agentById, castable, flashDuration, sightBlocked, type Ability } from "./abilities.ts"; |
| 11 | import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./aim.ts"; | |
| 11 | import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./engine/aim.ts"; | |
| 12 | 12 | import { pathStep, rayHitWall, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts"; |
| 13 | 13 | import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts"; |
| 14 | 14 | import type { MatchState, Player, Slot } from "./state.ts"; |
src/client.test.ts +2 −2
| @@ -400,8 +400,8 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 400 | 400 | // the rule. |
| 401 | 401 | // The lead is the cap plus the slack the guess is allowed over the answer |
| 402 | 402 | // (predict.js LEAD_SLACK: the lag your own keys take to reach the server). |
| 403 | const { moveSpeed } = await import("./glide.ts"); | |
| 404 | const { LEAD_MAX, LEAD_SLACK } = await import("../static/predict.js"); | |
| 403 | const { moveSpeed } = await import("./engine/glide.ts"); | |
| 404 | const { LEAD_MAX, LEAD_SLACK } = await import("../static/engine/predict.js"); | |
| 405 | 405 | expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10); |
| 406 | 406 | expect(Math.abs(sideways)).toBeLessThan(6); |
| 407 | 407 | // Nothing on the server moved: this is a drawing, and the match is still |
src/engine/aim.test.ts added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | /** | |
| 2 | * How a bot points a gun, tested as the pure maths it is. That the gate is | |
| 3 | * actually wired to a trigger is the game's to prove: `src/aim.test.ts`. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { | |
| 8 | acquire, | |
| 9 | aimCone, | |
| 10 | canFire, | |
| 11 | CONE_FLOOR, | |
| 12 | crossRate, | |
| 13 | drift, | |
| 14 | emptyTrack, | |
| 15 | FORGET, | |
| 16 | heldFor, | |
| 17 | lose, | |
| 18 | REACTION_MAX, | |
| 19 | REACTION_MIN, | |
| 20 | reactionTime, | |
| 21 | } from "./aim.ts"; | |
| 22 | ||
| 23 | const rolls = (...values: number[]) => { | |
| 24 | let at = 0; | |
| 25 | return () => values[at++ % values.length]!; | |
| 26 | }; | |
| 27 | ||
| 28 | test("the reaction a bot draws is between 200 and 350ms, whatever the roll", () => { | |
| 29 | expect(reactionTime(0)).toBeCloseTo(REACTION_MIN, 9); | |
| 30 | expect(reactionTime(1)).toBeCloseTo(REACTION_MAX, 9); | |
| 31 | expect(reactionTime(0.5)).toBeCloseTo((REACTION_MIN + REACTION_MAX) / 2, 9); | |
| 32 | // A roll out of range is still a reaction, not a bot that never shoots. | |
| 33 | expect(reactionTime(-3)).toBe(REACTION_MIN); | |
| 34 | expect(reactionTime(9)).toBe(REACTION_MAX); | |
| 35 | }); | |
| 36 | ||
| 37 | test("the trigger gate is a pure function of when acquisition happened", () => { | |
| 38 | const track = emptyTrack(); | |
| 39 | // Nothing in sight is never a shot, however long the round has run. | |
| 40 | expect(canFire(track, 100)).toBe(false); | |
| 41 | ||
| 42 | acquire(track, "bot-red-1", 10, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 43 | expect(track.reaction).toBeCloseTo(REACTION_MIN, 9); | |
| 44 | expect(canFire(track, 10)).toBe(false); | |
| 45 | expect(canFire(track, 10.19)).toBe(false); | |
| 46 | expect(canFire(track, 10.21)).toBe(true); | |
| 47 | expect(canFire(track, 12)).toBe(true); | |
| 48 | expect(heldFor(track, 12)).toBeCloseTo(2, 9); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a new target starts the reaction again; the same one does not", () => { | |
| 52 | const track = emptyTrack(); | |
| 53 | acquire(track, "one", 5, { x: 100, y: 100 }, rolls(1, 0.5)); | |
| 54 | expect(track.since).toBe(5); | |
| 55 | acquire(track, "one", 5.3, { x: 180, y: 100 }, rolls(0, 0.5)); | |
| 56 | expect(track.since).toBe(5); // the hold carries on | |
| 57 | expect(canFire(track, 5.4)).toBe(true); | |
| 58 | acquire(track, "two", 5.4, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 59 | expect(track.since).toBe(5.4); // swinging onto somebody else costs again | |
| 60 | expect(canFire(track, 5.5)).toBe(false); | |
| 61 | }); | |
| 62 | ||
| 63 | test("a hold survives a blink out of sight, and no longer", () => { | |
| 64 | const track = emptyTrack(); | |
| 65 | acquire(track, "one", 1, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 66 | lose(track, 1 + FORGET / 2); | |
| 67 | expect(track.id).toBe("one"); | |
| 68 | // Back a moment later: the same hold, already through its reaction. | |
| 69 | acquire(track, "one", 1 + FORGET / 2, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 70 | expect(track.since).toBe(1); | |
| 71 | // Gone for longer than that and it is a fresh acquisition. | |
| 72 | lose(track, 3); | |
| 73 | expect(track.id).toBe(null); | |
| 74 | acquire(track, "one", 3, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 75 | expect(track.since).toBe(3); | |
| 76 | expect(canFire(track, 3.1)).toBe(false); | |
| 77 | }); | |
| 78 | ||
| 79 | test("the cone starts wide, settles the longer one target is held, and never closes", () => { | |
| 80 | const swing = aimCone(0, 0); | |
| 81 | const second = aimCone(1, 0); | |
| 82 | const settled = aimCone(3, 0); | |
| 83 | expect(swing).toBeGreaterThan(0.08); // several degrees on the swing | |
| 84 | expect(second).toBeLessThan(swing / 2); | |
| 85 | expect(settled).toBeLessThan(second); | |
| 86 | expect(settled).toBeGreaterThanOrEqual(CONE_FLOOR); | |
| 87 | expect(aimCone(99, 0)).toBeCloseTo(CONE_FLOOR, 6); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a target crossing the view costs the bot accuracy", () => { | |
| 91 | expect(aimCone(3, 1)).toBeGreaterThan(aimCone(3, 0) * 3); | |
| 92 | expect(aimCone(3, 0.5)).toBeGreaterThan(aimCone(3, 0)); | |
| 93 | expect(aimCone(3, 0.5)).toBeLessThan(aimCone(3, 1)); | |
| 94 | // Nonsense from the caller is clamped, not multiplied out. | |
| 95 | expect(aimCone(3, 50)).toBeCloseTo(aimCone(3, 1), 9); | |
| 96 | }); | |
| 97 | ||
| 98 | test("crossing is measured across the sight line, not along it", () => { | |
| 99 | const bot = { x: 0, y: 0 }; | |
| 100 | const walking = emptyTrack(); | |
| 101 | acquire(walking, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); | |
| 102 | // Straight down the barrel: nothing to track. | |
| 103 | expect(crossRate(walking, bot, { x: 260, y: 0 }, 0.1)).toBeCloseTo(0, 6); | |
| 104 | const strafing = emptyTrack(); | |
| 105 | acquire(strafing, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); | |
| 106 | // Straight across it at a run: all of it but the turn of the sight line. | |
| 107 | expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(250, -1); | |
| 108 | // And it remembers where the target went, so the next tick measures again. | |
| 109 | expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(0, 6); | |
| 110 | }); | |
| 111 | ||
| 112 | test("the error wanders inside the cone instead of being redrawn every tick", () => { | |
| 113 | const track = emptyTrack(); | |
| 114 | acquire(track, "one", 0, { x: 0, y: 0 }, rolls(0, 1)); | |
| 115 | // Drawn at the edge of the swing cone on acquisition. | |
| 116 | expect(Math.abs(track.error)).toBeCloseTo(aimCone(0, 0), 9); | |
| 117 | // One tick moves it a little, not to somewhere unrelated. | |
| 118 | const before = track.error; | |
| 119 | const after = drift(track, aimCone(0, 0), 1 / 30, rolls(0.9)); | |
| 120 | expect(Math.abs(after - before)).toBeLessThan(aimCone(0, 0) / 4); | |
| 121 | // And it is squeezed toward the barrel as the cone closes around it. | |
| 122 | for (let i = 0; i < 90; i++) drift(track, aimCone(3, 0), 1 / 30, rolls(0.5, 1, 0)); | |
| 123 | expect(Math.abs(track.error)).toBeLessThanOrEqual(aimCone(3, 0) + 1e-12); | |
| 124 | }); |
src/aim.ts → src/engine/aim.ts renamed
No content changes.
src/engine/arena.test.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * The shape of a served match, over a toy game: the rules every served game | |
| 3 | * needs and none should have to re-derive. These are the two holes that were | |
| 4 | * closed the hard way — a seat is rationed by address, and a client is sent | |
| 5 | * only what it can see — plus the idle gate and the reload race. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { broadcastEvery, makeArena, TICK, type Command } from "./arena.ts"; | |
| 10 | ||
| 11 | type Toy = { id: string; name: string; bot: boolean; side: "a" | "b"; x: number; seen: boolean }; | |
| 12 | type World = { ticks: number; players: Toy[]; heard: Command[] }; | |
| 13 | ||
| 14 | /** Two sides of two; everybody on side "b" is out of side "a"'s sight unless marked seen. */ | |
| 15 | function toyGame() { | |
| 16 | const world: World = { | |
| 17 | ticks: 0, | |
| 18 | heard: [], | |
| 19 | players: [ | |
| 20 | { id: "a1", name: "Ash", bot: true, side: "a", x: 10, seen: false }, | |
| 21 | { id: "a2", name: "Bex", bot: true, side: "a", x: 20, seen: false }, | |
| 22 | { id: "b1", name: "Cade", bot: true, side: "b", x: 30, seen: false }, | |
| 23 | { id: "b2", name: "Dune", bot: true, side: "b", x: 40, seen: true }, | |
| 24 | ], | |
| 25 | }; | |
| 26 | return makeArena<World, Toy>({ | |
| 27 | state: world, | |
| 28 | players: (s) => s.players, | |
| 29 | step: (s) => void (s.ticks += 1), | |
| 30 | sit: (s) => { | |
| 31 | const seat = s.players.find((p) => p.bot) ?? null; | |
| 32 | if (seat) seat.bot = false; | |
| 33 | return seat; | |
| 34 | }, | |
| 35 | stand: (_s, p) => void (p.bot = true), | |
| 36 | visible: (_s, viewer, other) => viewer.side === other.side || other.seen, | |
| 37 | snapshot: (s, me, seen) => ({ | |
| 38 | me: { id: me.id, x: me.x }, | |
| 39 | players: seen.map((p) => ({ id: p.id, x: p.x })), | |
| 40 | board: s.players.map((p) => ({ id: p.id, name: p.name, bot: p.bot })), | |
| 41 | }), | |
| 42 | sticky: "board", | |
| 43 | spectatorSnapshot: (s) => ({ ticks: s.ticks, names: s.players.map((p) => p.name) }), | |
| 44 | command: (s, _p, message) => void s.heard.push(message), | |
| 45 | }); | |
| 46 | } | |
| 47 | ||
| 48 | test("nobody watching, nothing happens; a watcher is somebody; a test drives it regardless", () => { | |
| 49 | const arena = toyGame(); | |
| 50 | arena.beat(); | |
| 51 | arena.beat(); | |
| 52 | expect(arena.match().ticks).toBe(0); | |
| 53 | arena.watch("w", () => {}); | |
| 54 | arena.beat(); | |
| 55 | expect(arena.match().ticks).toBe(1); | |
| 56 | arena.unwatch("w"); | |
| 57 | arena.beat(); | |
| 58 | expect(arena.match().ticks).toBe(1); | |
| 59 | arena.advance(1); | |
| 60 | expect(arena.match().ticks).toBe(1 + Math.round(1 / TICK)); | |
| 61 | expect(arena.attendance()).toBe(0); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a playing seat is rationed by address, and a session finds its own seat back", () => { | |
| 65 | const arena = toyGame(); | |
| 66 | const first = arena.takeSeat("10.0.0.1", "session-1", "Kit"); | |
| 67 | expect(first?.bot).toBe(false); | |
| 68 | expect(first?.name).toBe("Kit"); | |
| 69 | // A second cookie from the same machine is not a second seat. | |
| 70 | expect(arena.addressCapped("10.0.0.1", "session-2")).toBe(true); | |
| 71 | expect(arena.takeSeat("10.0.0.1", "session-2", "Kit 2")).toBeNull(); | |
| 72 | // The same session asking again is handed the seat it holds, renamed. | |
| 73 | const again = arena.takeSeat("10.0.0.1", "session-1", "Kat"); | |
| 74 | expect(again?.id).toBe(first!.id); | |
| 75 | expect(again?.name).toBe("Kat"); | |
| 76 | expect(arena.seatCount()).toBe(1); | |
| 77 | // Another machine sits down beside them. | |
| 78 | const other = arena.takeSeat("10.0.0.2", "session-3", "Kat"); | |
| 79 | expect(other?.id).not.toBe(first!.id); | |
| 80 | // And two people may not share a name: the second gets a number. | |
| 81 | expect(other?.name).toBe("Kat 2"); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a late close from a reload does not take the live seat with it", () => { | |
| 85 | const arena = toyGame(); | |
| 86 | const seat = arena.takeSeat("10.0.0.1", "s", "Kit")!; | |
| 87 | arena.attach("s", seat.id, "10.0.0.1", "old-token", () => {}); | |
| 88 | arena.attach("s", seat.id, "10.0.0.1", "new-token", () => {}); | |
| 89 | arena.release("s", "old-token"); | |
| 90 | expect(seat.bot).toBe(false); | |
| 91 | expect(arena.seatCount()).toBe(1); | |
| 92 | arena.release("s", "new-token"); | |
| 93 | expect(seat.bot).toBe(true); | |
| 94 | expect(arena.seatCount()).toBe(0); | |
| 95 | expect(arena.addressCapped("10.0.0.1", "elsewhere")).toBe(false); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a viewer is sent only the bodies the game says they can see", () => { | |
| 99 | const arena = toyGame(); | |
| 100 | const snap = JSON.parse(arena.snapshot("a1")) as { t: string; players: { id: string }[] }; | |
| 101 | expect(snap.t).toBe("s"); | |
| 102 | expect(snap.players.map((p) => p.id).sort()).toEqual(["a1", "a2", "b2"]); | |
| 103 | expect(JSON.stringify(snap.players)).not.toContain('"b1"'); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a spectator is sent the public state and never a position", () => { | |
| 107 | const arena = toyGame(); | |
| 108 | const got: string[] = []; | |
| 109 | arena.watch("w", (data) => got.push(data), "cookie"); | |
| 110 | expect(got.length).toBe(1); | |
| 111 | const snap = JSON.parse(got[0]!) as Record<string, unknown>; | |
| 112 | expect(snap).toMatchObject({ t: "s", spectator: true, why: "cookie", ticks: 0 }); | |
| 113 | expect(snap.players).toBeUndefined(); | |
| 114 | expect(snap.me).toBeUndefined(); | |
| 115 | expect(got[0]).not.toContain('"x"'); | |
| 116 | arena.unwatch("w"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the sticky field goes out once, then only when it changes", () => { | |
| 120 | const arena = toyGame(); | |
| 121 | const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, board: "" }; | |
| 122 | const first = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] }; | |
| 123 | expect(first.board?.length).toBe(4); | |
| 124 | const second = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] }; | |
| 125 | expect(second.board).toBeUndefined(); | |
| 126 | arena.match().players[3]!.name = "Eve"; | |
| 127 | const third = JSON.parse(arena.snapshotForSeat(seat)) as { board?: { name: string }[] }; | |
| 128 | expect(third.board?.[3]?.name).toBe("Eve"); | |
| 129 | // The full snapshot is always the full snapshot. | |
| 130 | expect((JSON.parse(arena.snapshot("a1")) as { board: unknown[] }).board.length).toBe(4); | |
| 131 | }); | |
| 132 | ||
| 133 | test("the loop sends at the cadence the room asks for, and a bot's commands are dropped", () => { | |
| 134 | const arena = toyGame(); | |
| 135 | const got: string[] = []; | |
| 136 | const seat = arena.takeSeat("10.0.0.1", "s", "Kit")!; | |
| 137 | arena.attach("s", seat.id, "10.0.0.1", "t", (data) => got.push(data)); | |
| 138 | arena.advance(1); | |
| 139 | expect(got.length).toBe(Math.round(1 / TICK) / broadcastEvery(1)); | |
| 140 | arena.command(seat.id, { t: "i" }); | |
| 141 | arena.command("b2", { t: "i" }); // a bot: nobody may drive it from outside | |
| 142 | expect(arena.match().heard.length).toBe(1); | |
| 143 | }); |
src/engine/arena.ts added +332
| @@ -0,0 +1,332 @@ | ||
| 1 | /** | |
| 2 | * The shape of a served match: one world, always running while somebody is | |
| 3 | * connected, and the seats people sit in. | |
| 4 | * | |
| 5 | * Nothing here knows what the game is. The game hands over its state and a | |
| 6 | * handful of answers — how to step it, who is in it, what one viewer may see | |
| 7 | * and what a snapshot looks like — and this module keeps the rules every | |
| 8 | * served game needs and none should re-derive: | |
| 9 | * | |
| 10 | * - The idle gate: nobody watching, nothing happens. The world freezes when | |
| 11 | * the last connection goes and carries on when someone arrives. | |
| 12 | * - A playing seat is rationed by address, not by cookie: a cookie costs one | |
| 13 | * HTTP request, an address is the one thing a machine cannot mint two of. | |
| 14 | * Both claims go in synchronously, before a socket is ever offered a seat. | |
| 15 | * - A connection with no seat watches: it is sent the public state and no | |
| 16 | * position in it. | |
| 17 | * - Every snapshot is built for one viewer, from the bodies the game says | |
| 18 | * that viewer can see. The client never learns a position it has no line | |
| 19 | * of sight to. | |
| 20 | */ | |
| 21 | ||
| 22 | export const TICK = 1 / 30; | |
| 23 | ||
| 24 | /** | |
| 25 | * How many ticks apart the snapshots go out. | |
| 26 | * | |
| 27 | * The simulation runs at 30Hz. A small room gets every tick: it is the case | |
| 28 | * that matters (one or two people playing each other), the payload is a | |
| 29 | * couple of kilobytes of JSON a seat, and the arena only runs at all while | |
| 30 | * somebody is connected. Above that the cost multiplies by seats and the | |
| 31 | * older cadence — every other tick — comes back. | |
| 32 | */ | |
| 33 | export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2); | |
| 34 | ||
| 35 | export type Seat = { | |
| 36 | playerId: string; | |
| 37 | token: string; | |
| 38 | address: string; | |
| 39 | send: (data: string) => void; | |
| 40 | /** The sticky part of the snapshot as this seat last received it, so it is only resent when it changes. */ | |
| 41 | board: string; | |
| 42 | }; | |
| 43 | ||
| 44 | /** | |
| 45 | * Why this connection is watching. The client says so in words, and the two | |
| 46 | * have different answers — open the tab that is playing, or let the page keep | |
| 47 | * its cookie — so which one it is travels with the snapshot. | |
| 48 | */ | |
| 49 | export type Why = "address" | "cookie" | "unsaid"; | |
| 50 | ||
| 51 | export type Command = { t: string; [key: string]: unknown }; | |
| 52 | ||
| 53 | /** What the arena needs of a body: a way to find it, address it, and tell a bot from a person. */ | |
| 54 | export type Body = { id: string; name: string; bot: boolean }; | |
| 55 | ||
| 56 | /** The game, as the arena sees it. */ | |
| 57 | export type Game<S, P extends Body> = { | |
| 58 | /** The one world. Mutated in place; the arena never replaces it. */ | |
| 59 | state: S; | |
| 60 | players: (state: S) => P[]; | |
| 61 | /** One fixed step of the world, bots and rules and all. */ | |
| 62 | step: (state: S, dt: number) => void; | |
| 63 | /** Hand a bot's seat to a person: the same body, made human. Null when there is none. */ | |
| 64 | sit: (state: S) => P | null; | |
| 65 | /** Give a seat back to a bot. */ | |
| 66 | stand: (state: S, player: P) => void; | |
| 67 | /** May this viewer be sent this body? The server's answer, never the client's. */ | |
| 68 | visible: (state: S, viewer: P, other: P) => boolean; | |
| 69 | /** The snapshot for one viewer, given exactly the bodies they may see. */ | |
| 70 | snapshot: (state: S, viewer: P, seen: P[]) => Record<string, unknown>; | |
| 71 | /** | |
| 72 | * A field of the snapshot resent only when it differs from what the seat | |
| 73 | * last received; the client keeps the one it has. Null sends everything. | |
| 74 | */ | |
| 75 | sticky: string | null; | |
| 76 | /** The public view for a connection with no seat. Must carry no position. */ | |
| 77 | spectatorSnapshot: (state: S, why: Why) => Record<string, unknown>; | |
| 78 | /** Everything a client may ask for, validated by the game and nowhere else. */ | |
| 79 | command: (state: S, player: P, message: Command) => void; | |
| 80 | }; | |
| 81 | ||
| 82 | export function makeArena<S, P extends Body>(game: Game<S, P>) { | |
| 83 | const state = game.state; | |
| 84 | const seats = new Map<string, Seat>(); | |
| 85 | // A session is a cookie, and a cookie costs one HTTP request — free for | |
| 86 | // anyone to mint as many of as they like. The address is the one thing here | |
| 87 | // a single machine cannot hand itself two of, so it is what a playing seat | |
| 88 | // is actually rationed by; the session on top of it is only what lets a | |
| 89 | // reload find its way back to the same seat rather than a fresh one. | |
| 90 | const addressSeats = new Map<string, string>(); | |
| 91 | let ticks = 0; | |
| 92 | ||
| 93 | const match = (): S => state; | |
| 94 | const findPlayer = (id: string): P | undefined => game.players(state).find((p) => p.id === id); | |
| 95 | ||
| 96 | /** | |
| 97 | * A name nobody else in this match is using. Two players under one name | |
| 98 | * make a feed line that reads as a team kill; a person who picks a taken | |
| 99 | * name gets a number after it, inside the sixteen characters a name is | |
| 100 | * allowed. | |
| 101 | */ | |
| 102 | function freeName(want: string, exceptId: string): string { | |
| 103 | const taken = new Set( | |
| 104 | game | |
| 105 | .players(state) | |
| 106 | .filter((p) => p.id !== exceptId) | |
| 107 | .map((p) => p.name), | |
| 108 | ); | |
| 109 | if (!taken.has(want)) return want; | |
| 110 | for (let n = 2; n < 100; n++) { | |
| 111 | const tried = `${want.slice(0, 13)} ${n}`; | |
| 112 | if (!taken.has(tried)) return tried; | |
| 113 | } | |
| 114 | return want; | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * Whether this address already plays under a different session. Checked | |
| 119 | * before a socket is even offered a seat, so the caller can hand it a | |
| 120 | * spectator view instead of upgrading it into a second side's vision. | |
| 121 | */ | |
| 122 | function addressCapped(address: string, session: string): boolean { | |
| 123 | const holder = addressSeats.get(address); | |
| 124 | return holder !== undefined && holder !== session; | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * Sit somebody down, replacing a bot where it stands. | |
| 129 | * | |
| 130 | * Both claims go in right here, synchronously, before the caller ever opens | |
| 131 | * a socket. A websocket upgrade completes on a later tick, so two requests | |
| 132 | * arriving close together both used to read the maps before either had | |
| 133 | * written to them — one client, two seats, opposite sides, the vision of | |
| 134 | * both. Claiming inline closes that window regardless of how the two | |
| 135 | * requests were related. | |
| 136 | */ | |
| 137 | function takeSeat(address: string, session: string, name: string): P | null { | |
| 138 | const existing = seats.get(session); | |
| 139 | const held = existing ? findPlayer(existing.playerId) : undefined; | |
| 140 | if (held) { | |
| 141 | held.name = freeName(name, held.id); | |
| 142 | return held; | |
| 143 | } | |
| 144 | if (addressCapped(address, session)) return null; | |
| 145 | const seat = game.sit(state); | |
| 146 | if (!seat) return null; | |
| 147 | seat.name = freeName(name, seat.id); | |
| 148 | addressSeats.set(address, session); | |
| 149 | seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, board: "" }); | |
| 150 | return seat; | |
| 151 | } | |
| 152 | ||
| 153 | /** The socket that actually opened fills in how to reach it and its token. */ | |
| 154 | function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void { | |
| 155 | seats.set(session, { playerId, token, address, send, board: "" }); | |
| 156 | } | |
| 157 | ||
| 158 | const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId); | |
| 159 | ||
| 160 | /** | |
| 161 | * A person leaving gives the seat back to a bot, mid-round and all — but only | |
| 162 | * if this is still the socket the session is on. A reload opens the new | |
| 163 | * socket before the old one's close event arrives; without the token, that | |
| 164 | * late close would delete the reconnect's seat entry and hand a live human's | |
| 165 | * seat to a bot mid-round. | |
| 166 | */ | |
| 167 | function release(session: string, token: string): void { | |
| 168 | const seat = seats.get(session); | |
| 169 | if (!seat || seat.token !== token) return; | |
| 170 | seats.delete(session); | |
| 171 | if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address); | |
| 172 | const player = findPlayer(seat.playerId); | |
| 173 | if (player && !stillHeld(seat.playerId)) game.stand(state, player); | |
| 174 | } | |
| 175 | ||
| 176 | /** | |
| 177 | * Test-only hygiene: give back whatever this address currently holds. The | |
| 178 | * arena is one match shared by the whole test run, over real HTTP, from one | |
| 179 | * loopback address — this is how a test that needs that address free gets it | |
| 180 | * free, rather than depending on every other test in the file to clean up | |
| 181 | * after itself. | |
| 182 | */ | |
| 183 | function releaseAddress(address: string): void { | |
| 184 | const session = addressSeats.get(address); | |
| 185 | if (session !== undefined) release(session, seats.get(session)?.token ?? ""); | |
| 186 | } | |
| 187 | ||
| 188 | const seatCount = (): number => seats.size; | |
| 189 | ||
| 190 | // ------------------------------------------------------------ spectators | |
| 191 | ||
| 192 | // A connection that never carried a session the page minted gets no side and | |
| 193 | // no seat: only the public state everyone already sees on the page itself. | |
| 194 | const spectators = new Map<string, { send: (data: string) => void; why: Why }>(); | |
| 195 | ||
| 196 | const spectatorSnapshot = (why: Why = "unsaid"): string => | |
| 197 | JSON.stringify({ t: "s", spectator: true, why, ...game.spectatorSnapshot(state, why) }); | |
| 198 | ||
| 199 | function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void { | |
| 200 | spectators.set(token, { send, why }); | |
| 201 | send(spectatorSnapshot(why)); | |
| 202 | } | |
| 203 | ||
| 204 | function unwatch(token: string): void { | |
| 205 | spectators.delete(token); | |
| 206 | } | |
| 207 | ||
| 208 | // ----------------------------------------------------------- the commands | |
| 209 | ||
| 210 | function command(playerId: string, message: Command): void { | |
| 211 | const player = findPlayer(playerId); | |
| 212 | if (!player || player.bot) return; | |
| 213 | game.command(state, player, message); | |
| 214 | } | |
| 215 | ||
| 216 | // ----------------------------------------------------------- the snapshot | |
| 217 | ||
| 218 | function snapshotFor(viewerId: string): Record<string, unknown> { | |
| 219 | const players = game.players(state); | |
| 220 | const me = findPlayer(viewerId) ?? players[0]!; | |
| 221 | const seen = players.filter((p) => game.visible(state, me, p)); | |
| 222 | return { t: "s", ...game.snapshot(state, me, seen) }; | |
| 223 | } | |
| 224 | ||
| 225 | /** The full snapshot for one viewer, as one string. */ | |
| 226 | const snapshot = (viewerId: string): string => JSON.stringify(snapshotFor(viewerId)); | |
| 227 | ||
| 228 | /** | |
| 229 | * The snapshot a seat is actually sent on this tick: everything, every time, | |
| 230 | * except the sticky field, which goes out only when it differs from what | |
| 231 | * this seat last received. | |
| 232 | */ | |
| 233 | function snapshotForSeat(seat: Seat): string { | |
| 234 | const full = snapshotFor(seat.playerId); | |
| 235 | if (game.sticky !== null) { | |
| 236 | const part = JSON.stringify(full[game.sticky]); | |
| 237 | if (part === seat.board) delete full[game.sticky]; | |
| 238 | else seat.board = part; | |
| 239 | } | |
| 240 | return JSON.stringify(full); | |
| 241 | } | |
| 242 | ||
| 243 | // --------------------------------------------------------------- the loop | |
| 244 | ||
| 245 | /** | |
| 246 | * Nobody watching, nothing happens. | |
| 247 | * | |
| 248 | * An arena that ticks around the clock plays out rounds for an empty room — | |
| 249 | * burning a share of a small box's CPU and filling the record with matches | |
| 250 | * no one saw. The world freezes when the last connection goes and carries on | |
| 251 | * from exactly where it stopped when someone arrives, so a visitor never | |
| 252 | * finds a score that ran up while they were away. | |
| 253 | * | |
| 254 | * A spectator counts as somebody: watching is watching. | |
| 255 | */ | |
| 256 | const attended = (): boolean => seats.size > 0 || spectators.size > 0; | |
| 257 | ||
| 258 | /** How many connections the arena is playing for: seats plus watchers. */ | |
| 259 | const attendance = (): number => seats.size + spectators.size; | |
| 260 | ||
| 261 | function step(): void { | |
| 262 | game.step(state, TICK); | |
| 263 | ticks += 1; | |
| 264 | if (ticks % broadcastEvery(seats.size) !== 0) return; | |
| 265 | for (const seat of seats.values()) { | |
| 266 | try { | |
| 267 | seat.send(snapshotForSeat(seat)); | |
| 268 | } catch { | |
| 269 | // A socket that will not take a frame is closed already; the close | |
| 270 | // handler gives the seat back. | |
| 271 | } | |
| 272 | } | |
| 273 | for (const spectator of spectators.values()) { | |
| 274 | try { | |
| 275 | spectator.send(spectatorSnapshot(spectator.why)); | |
| 276 | } catch { | |
| 277 | // Same story: the close handler drops it from the set. | |
| 278 | } | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | let timer: ReturnType<typeof setInterval> | null = null; | |
| 283 | ||
| 284 | /** | |
| 285 | * One beat of real time. The gate lives here rather than inside `step` so the | |
| 286 | * tests can still drive the simulation directly with `advance` — a test is an | |
| 287 | * attendance of its own, and making it prove otherwise would only teach the | |
| 288 | * suite to work around the rule. | |
| 289 | */ | |
| 290 | function beat(): void { | |
| 291 | if (attended()) step(); | |
| 292 | } | |
| 293 | ||
| 294 | function start(): void { | |
| 295 | if (timer) return; | |
| 296 | timer = setInterval(beat, TICK * 1000); | |
| 297 | // The clock must never hold the process open on its own. | |
| 298 | (timer as unknown as { unref?: () => void }).unref?.(); | |
| 299 | } | |
| 300 | ||
| 301 | function stop(): void { | |
| 302 | if (timer) clearInterval(timer); | |
| 303 | timer = null; | |
| 304 | } | |
| 305 | ||
| 306 | /** Used by the tests: run the arena forward without waiting for real time. */ | |
| 307 | function advance(seconds: number): void { | |
| 308 | for (let i = 0; i < Math.round(seconds / TICK); i++) step(); | |
| 309 | } | |
| 310 | ||
| 311 | return { | |
| 312 | match, | |
| 313 | findPlayer, | |
| 314 | addressCapped, | |
| 315 | takeSeat, | |
| 316 | attach, | |
| 317 | release, | |
| 318 | releaseAddress, | |
| 319 | seatCount, | |
| 320 | watch, | |
| 321 | unwatch, | |
| 322 | spectatorSnapshot, | |
| 323 | command, | |
| 324 | snapshot, | |
| 325 | snapshotForSeat, | |
| 326 | attendance, | |
| 327 | beat, | |
| 328 | start, | |
| 329 | stop, | |
| 330 | advance, | |
| 331 | }; | |
| 332 | } |
src/engine/boundary.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** | |
| 2 | * The property that makes the engine folders copyable at all: nothing under | |
| 3 | * `src/engine/` or `static/engine/` imports anything outside them. A module | |
| 4 | * that reached for the game's map, agents or match would come along with the | |
| 5 | * copy and bring the whole game with it. | |
| 6 | * | |
| 7 | * Tests count: an engine test that needs the game to run is a test the next | |
| 8 | * game cannot take with it. | |
| 9 | */ | |
| 10 | ||
| 11 | import { expect, test } from "bun:test"; | |
| 12 | import { readdirSync, readFileSync } from "node:fs"; | |
| 13 | import { dirname, join, resolve } from "node:path"; | |
| 14 | ||
| 15 | const ROOT = resolve(import.meta.dir, "..", ".."); | |
| 16 | const ENGINE = [join(ROOT, "src", "engine"), join(ROOT, "static", "engine")]; | |
| 17 | ||
| 18 | const inEngine = (path: string): boolean => ENGINE.some((dir) => resolve(path).startsWith(dir + "/")); | |
| 19 | ||
| 20 | /** Every import specifier in a file: static, re-exported, or dynamic. */ | |
| 21 | function importsOf(source: string): string[] { | |
| 22 | const out: string[] = []; | |
| 23 | for (const match of source.matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']/gm)) out.push(match[1]!); | |
| 24 | for (const match of source.matchAll(/\bimport\(\s*["']([^"']+)["']\s*\)/g)) out.push(match[1]!); | |
| 25 | return out; | |
| 26 | } | |
| 27 | ||
| 28 | const files = ENGINE.flatMap((dir) => | |
| 29 | readdirSync(dir) | |
| 30 | .filter((name) => /\.(ts|js)$/.test(name)) | |
| 31 | .map((name) => join(dir, name)), | |
| 32 | ); | |
| 33 | ||
| 34 | test("the engine folders exist and hold the pieces a game stands on", () => { | |
| 35 | const names = files.map((f) => f.split("/").slice(-2).join("/")); | |
| 36 | for (const need of [ | |
| 37 | "engine/glide.ts", | |
| 38 | "engine/aim.ts", | |
| 39 | "engine/grid.ts", | |
| 40 | "engine/arena.ts", | |
| 41 | "engine/interp.js", | |
| 42 | "engine/predict.js", | |
| 43 | "engine/presence.js", | |
| 44 | "engine/yaw.js", | |
| 45 | "engine/steer.js", | |
| 46 | "engine/raycast.js", | |
| 47 | "engine/scene.js", | |
| 48 | "engine/slices.js", | |
| 49 | "engine/resolution.js", | |
| 50 | "engine/budget.js", | |
| 51 | "engine/topdown.js", | |
| 52 | "engine/gun.js", | |
| 53 | ]) | |
| 54 | expect(names).toContain(need); | |
| 55 | }); | |
| 56 | ||
| 57 | test("no engine module, and no engine test, imports a game module", () => { | |
| 58 | const crossings: string[] = []; | |
| 59 | for (const file of files) { | |
| 60 | for (const spec of importsOf(readFileSync(file, "utf8"))) { | |
| 61 | if (/^(bun|node):/.test(spec)) continue; | |
| 62 | if (!spec.startsWith(".")) { | |
| 63 | crossings.push(`${file}: ${spec} (a package)`); | |
| 64 | continue; | |
| 65 | } | |
| 66 | const target = resolve(dirname(file), spec); | |
| 67 | if (!inEngine(target)) crossings.push(`${file.slice(ROOT.length + 1)} -> ${spec}`); | |
| 68 | } | |
| 69 | } | |
| 70 | expect(crossings).toEqual([]); | |
| 71 | }); | |
| 72 | ||
| 73 | test("the engine does not know what a spike is", () => { | |
| 74 | // The words of this game. An engine file that says one of them has a hole | |
| 75 | // that should have been a parameter. | |
| 76 | const words = /\b(spike|agent|credits|molly|defus\w*|plant\w*|orb|valorant)\b/i; | |
| 77 | const said: string[] = []; | |
| 78 | for (const file of files) { | |
| 79 | if (file.endsWith(".test.ts")) continue; | |
| 80 | const source = readFileSync(file, "utf8") | |
| 81 | // Comments may explain things by example; the code may not. | |
| 82 | .replace(/\/\*[\s\S]*?\*\//g, "") | |
| 83 | .replace(/\/\/.*$/gm, ""); | |
| 84 | const hit = source.match(words); | |
| 85 | if (hit) said.push(`${file.slice(ROOT.length + 1)}: ${hit[0]}`); | |
| 86 | } | |
| 87 | expect(said).toEqual([]); | |
| 88 | }); |
src/budget.test.ts → src/engine/budget.test.ts renamed +1 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | import { FAST, makeBudget, measure, PATIENCE, SLOW, WINDOW } from "../static/budget.js"; | |
| 8 | import { FAST, makeBudget, measure, PATIENCE, SLOW, WINDOW } from "../../static/engine/budget.js"; | |
| 9 | 9 | |
| 10 | 10 | /** `seconds` of frames, each `ms` long, and how many times the level moved. */ |
| 11 | 11 | function run(budget: ReturnType<typeof makeBudget>, ms: number, seconds: number, from = 0) { |
src/glide.test.ts → src/engine/glide.test.ts renamed −9
| @@ -6,7 +6,6 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | 8 | import { BODY_R, circleBlocked, moveSpeed, RUN, slideCircle } from "./glide.ts"; |
| 9 | import { moveCircle } from "./map.ts"; | |
| 10 | 9 | |
| 11 | 10 | const TILE = 80; |
| 12 | 11 | // A room with solid walls, open from (1,1) to (3,3). |
| @@ -93,14 +92,6 @@ test("a step never ends inside a wall, from anywhere in the room", () => { | ||
| 93 | 92 | } |
| 94 | 93 | }); |
| 95 | 94 | |
| 96 | test("the real map moves by the same maths", () => { | |
| 97 | // Mid, walking east into whatever is there: the map's own binding. | |
| 98 | const to = moveCircle(1200, 640, 9, 0, BODY_R); | |
| 99 | expect(to.x).toBeGreaterThanOrEqual(1200); | |
| 100 | expect(to.x).toBeLessThanOrEqual(1209); | |
| 101 | expect(to.y).toBe(640); | |
| 102 | }); | |
| 103 | ||
| 104 | 95 | test("speed is the one table both sides read", () => { |
| 105 | 96 | expect(moveSpeed({})).toBe(RUN); |
| 106 | 97 | expect(moveSpeed({ haste: true })).toBeCloseTo(RUN * 1.35, 9); |
src/glide.ts → src/engine/glide.ts renamed
No content changes.
src/engine/grid.ts added +224
| @@ -0,0 +1,224 @@ | ||
| 1 | /** | |
| 2 | * A tile grid and the questions everything else asks of it: which tiles are | |
| 3 | * solid, where a circle slides to, whether a wall stands between two points, | |
| 4 | * where a ray stops, and how far a walk is. | |
| 5 | * | |
| 6 | * The grid is rows of letters. `#` is a wall; every other letter is floor, | |
| 7 | * and what a letter means beyond that is the game's business — this module | |
| 8 | * hands back the cells of a letter and never asks what they are for. | |
| 9 | * | |
| 10 | * `makeGrid` binds the maths to one set of rows and one tile size. The | |
| 11 | * collision itself is `glide.ts`, which the browser runs too; the walk | |
| 12 | * distances are cached per goal for the round and cleared by the caller. | |
| 13 | */ | |
| 14 | ||
| 15 | import { circleBlocked, slideCircle } from "./glide.ts"; | |
| 16 | ||
| 17 | export type Point = { x: number; y: number }; | |
| 18 | ||
| 19 | export type Grid = { | |
| 20 | lines: string[]; | |
| 21 | cols: number; | |
| 22 | rows: number; | |
| 23 | tile: number; | |
| 24 | width: number; | |
| 25 | height: number; | |
| 26 | /** The letter at a tile; anything off the grid is a wall. */ | |
| 27 | at: (col: number, row: number) => string; | |
| 28 | solidTile: (col: number, row: number) => boolean; | |
| 29 | tileOf: (x: number, y: number) => { col: number; row: number }; | |
| 30 | centreOf: (col: number, row: number) => Point; | |
| 31 | solidAt: (x: number, y: number) => boolean; | |
| 32 | /** The letter under a point. */ | |
| 33 | cellAt: (x: number, y: number) => string; | |
| 34 | /** The centres of every tile carrying this letter, row by row. */ | |
| 35 | cellsOf: (mark: string) => Point[]; | |
| 36 | /** Slide a circle through this grid. */ | |
| 37 | moveCircle: (x: number, y: number, dx: number, dy: number, r: number) => Point; | |
| 38 | /** True when a circle at (x,y) overlaps any solid tile. */ | |
| 39 | blockedCircle: (x: number, y: number, r: number) => boolean; | |
| 40 | wallBetween: (a: Point, b: Point) => boolean; | |
| 41 | rayHitWall: (from: Point, angle: number, range: number) => Point; | |
| 42 | walkDistance: (from: Point, to: Point) => number; | |
| 43 | pathStep: (from: Point, to: Point) => Point; | |
| 44 | clearPaths: () => void; | |
| 45 | }; | |
| 46 | ||
| 47 | /** Closest approach of a segment to a point, as distance and travel along it. */ | |
| 48 | export function segmentPoint(a: Point, b: Point, p: Point): { dist: number; travel: number } { | |
| 49 | const dx = b.x - a.x; | |
| 50 | const dy = b.y - a.y; | |
| 51 | const len2 = dx * dx + dy * dy; | |
| 52 | const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2)); | |
| 53 | const cx = a.x + dx * t; | |
| 54 | const cy = a.y + dy * t; | |
| 55 | return { dist: Math.hypot(p.x - cx, p.y - cy), travel: t * Math.sqrt(len2) }; | |
| 56 | } | |
| 57 | ||
| 58 | export const segmentHitsCircle = (a: Point, b: Point, c: Point, r: number): boolean => | |
| 59 | segmentPoint(a, b, c).dist <= r; | |
| 60 | ||
| 61 | const STEPS = [ | |
| 62 | [1, 0], | |
| 63 | [-1, 0], | |
| 64 | [0, 1], | |
| 65 | [0, -1], | |
| 66 | ] as const; | |
| 67 | ||
| 68 | export function makeGrid(lines: string[], tile: number): Grid { | |
| 69 | const cols = lines[0]!.length; | |
| 70 | const rows = lines.length; | |
| 71 | ||
| 72 | const at = (col: number, row: number): string => | |
| 73 | row < 0 || row >= rows || col < 0 || col >= cols ? "#" : lines[row]![col]!; | |
| 74 | const solidTile = (col: number, row: number): boolean => at(col, row) === "#"; | |
| 75 | const tileOf = (x: number, y: number): { col: number; row: number } => ({ | |
| 76 | col: Math.floor(x / tile), | |
| 77 | row: Math.floor(y / tile), | |
| 78 | }); | |
| 79 | const centreOf = (col: number, row: number): Point => ({ | |
| 80 | x: col * tile + tile / 2, | |
| 81 | y: row * tile + tile / 2, | |
| 82 | }); | |
| 83 | const solidAt = (x: number, y: number): boolean => { | |
| 84 | const t = tileOf(x, y); | |
| 85 | return solidTile(t.col, t.row); | |
| 86 | }; | |
| 87 | const cellAt = (x: number, y: number): string => { | |
| 88 | const t = tileOf(x, y); | |
| 89 | return at(t.col, t.row); | |
| 90 | }; | |
| 91 | const cellsOf = (mark: string): Point[] => { | |
| 92 | const out: Point[] = []; | |
| 93 | for (let row = 0; row < rows; row++) | |
| 94 | for (let col = 0; col < cols; col++) if (at(col, row) === mark) out.push(centreOf(col, row)); | |
| 95 | return out; | |
| 96 | }; | |
| 97 | ||
| 98 | const moveCircle = (x: number, y: number, dx: number, dy: number, r: number): Point => | |
| 99 | slideCircle(x, y, dx, dy, r, solidTile, tile); | |
| 100 | const blockedCircle = (x: number, y: number, r: number): boolean => circleBlocked(x, y, r, solidTile, tile); | |
| 101 | ||
| 102 | /** | |
| 103 | * Does a wall stand between two points? Sampled every third of a tile: a | |
| 104 | * wall is a whole tile thick, so nothing is ever stepped over. | |
| 105 | */ | |
| 106 | function wallBetween(a: Point, b: Point): boolean { | |
| 107 | const dx = b.x - a.x; | |
| 108 | const dy = b.y - a.y; | |
| 109 | const dist = Math.hypot(dx, dy); | |
| 110 | const steps = Math.ceil(dist / (tile / 3)); | |
| 111 | for (let i = 1; i < steps; i++) { | |
| 112 | const t = i / steps; | |
| 113 | if (solidAt(a.x + dx * t, a.y + dy * t)) return true; | |
| 114 | } | |
| 115 | return false; | |
| 116 | } | |
| 117 | ||
| 118 | /** Where a shot stops: the first wall along the ray, or its full range. */ | |
| 119 | function rayHitWall(from: Point, angle: number, range: number): Point { | |
| 120 | const dx = Math.cos(angle); | |
| 121 | const dy = Math.sin(angle); | |
| 122 | const step = tile / 4; | |
| 123 | for (let d = step; d <= range; d += step) { | |
| 124 | const x = from.x + dx * d; | |
| 125 | const y = from.y + dy * d; | |
| 126 | if (solidAt(x, y)) return { x: from.x + dx * (d - step), y: from.y + dy * (d - step) }; | |
| 127 | } | |
| 128 | return { x: from.x + dx * range, y: from.y + dy * range }; | |
| 129 | } | |
| 130 | ||
| 131 | // Walk distance from a goal tile, breadth-first over floor. Bots ask for it | |
| 132 | // every few ticks; the field for a goal is built once and kept for the round. | |
| 133 | const fields = new Map<number, Int32Array>(); | |
| 134 | const clearPaths = (): void => void fields.clear(); | |
| 135 | ||
| 136 | function field(goalCol: number, goalRow: number): Int32Array { | |
| 137 | const key = goalRow * cols + goalCol; | |
| 138 | const cached = fields.get(key); | |
| 139 | if (cached) return cached; | |
| 140 | const dist = new Int32Array(cols * rows).fill(-1); | |
| 141 | if (!solidTile(goalCol, goalRow)) { | |
| 142 | dist[key] = 0; | |
| 143 | const queue: number[] = [key]; | |
| 144 | for (let head = 0; head < queue.length; head++) { | |
| 145 | const here = queue[head]!; | |
| 146 | const col = here % cols; | |
| 147 | const row = (here - col) / cols; | |
| 148 | const d = dist[here]!; | |
| 149 | for (const [dc, dr] of STEPS) { | |
| 150 | const nc = col + dc; | |
| 151 | const nr = row + dr; | |
| 152 | if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) continue; | |
| 153 | const nk = nr * cols + nc; | |
| 154 | if (dist[nk] !== -1 || solidTile(nc, nr)) continue; | |
| 155 | dist[nk] = d + 1; | |
| 156 | queue.push(nk); | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | if (fields.size > 64) fields.clear(); | |
| 161 | fields.set(key, dist); | |
| 162 | return dist; | |
| 163 | } | |
| 164 | ||
| 165 | /** Walk distance in tiles between two points, or -1 when unreachable. */ | |
| 166 | function walkDistance(from: Point, to: Point): number { | |
| 167 | const f = tileOf(from.x, from.y); | |
| 168 | const g = tileOf(to.x, to.y); | |
| 169 | return field(g.col, g.row)[f.row * cols + f.col] ?? -1; | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * The next point to walk toward on the way to a goal: the centre of the | |
| 174 | * neighbouring tile that is one step closer, so a walker rounds corners | |
| 175 | * instead of grinding into them. | |
| 176 | */ | |
| 177 | function pathStep(from: Point, to: Point): Point { | |
| 178 | const f = tileOf(from.x, from.y); | |
| 179 | const g = tileOf(to.x, to.y); | |
| 180 | if (f.col === g.col && f.row === g.row) return to; | |
| 181 | const dist = field(g.col, g.row); | |
| 182 | const here = dist[f.row * cols + f.col] ?? -1; | |
| 183 | if (here <= 0) return to; | |
| 184 | let best: Point | null = null; | |
| 185 | let bestD = here; | |
| 186 | for (const [dc, dr] of STEPS) { | |
| 187 | const nc = f.col + dc; | |
| 188 | const nr = f.row + dr; | |
| 189 | if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) continue; | |
| 190 | const d = dist[nr * cols + nc] ?? -1; | |
| 191 | if (d !== -1 && d < bestD) { | |
| 192 | bestD = d; | |
| 193 | best = centreOf(nc, nr); | |
| 194 | } | |
| 195 | } | |
| 196 | // Straight to the goal when nothing stands in the way — smoother than | |
| 197 | // walking tile centre to tile centre down an open corridor. | |
| 198 | if (best && !wallBetween(from, to) && (walkDistance(from, to) ?? 99) < 6) return to; | |
| 199 | return best ?? to; | |
| 200 | } | |
| 201 | ||
| 202 | return { | |
| 203 | lines, | |
| 204 | cols, | |
| 205 | rows, | |
| 206 | tile, | |
| 207 | width: cols * tile, | |
| 208 | height: rows * tile, | |
| 209 | at, | |
| 210 | solidTile, | |
| 211 | tileOf, | |
| 212 | centreOf, | |
| 213 | solidAt, | |
| 214 | cellAt, | |
| 215 | cellsOf, | |
| 216 | moveCircle, | |
| 217 | blockedCircle, | |
| 218 | wallBetween, | |
| 219 | rayHitWall, | |
| 220 | walkDistance, | |
| 221 | pathStep, | |
| 222 | clearPaths, | |
| 223 | }; | |
| 224 | } |
src/gun.test.ts → src/engine/gun.test.ts renamed +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { fade, firedNow, gunFire, gunSnapshot, gunTick, hitMarked, makeGun } from "../static/gun.js"; | |
| 2 | import { fade, firedNow, gunFire, gunSnapshot, gunTick, hitMarked, makeGun } from "../../static/engine/gun.js"; | |
| 3 | 3 | |
| 4 | 4 | const snap = (over: Record<string, unknown> = {}, shots: unknown[] = []) => ({ |
| 5 | 5 | me: { x: 100, y: 100, ammo: 25, reloading: false, alive: true, ...over }, |
src/interp.test.ts → src/engine/interp.test.ts renamed +1 −64
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { expect, test } from "bun:test"; |
| 12 | import { arrive, bodyAt, delay, feed, GONE, LAG, LAG_MAX, LAG_MIN, makeTimeline, sample } from "../static/interp.js"; | |
| 12 | import { arrive, bodyAt, delay, feed, GONE, LAG, LAG_MAX, LAG_MIN, makeTimeline, sample } from "../../static/engine/interp.js"; | |
| 13 | 13 | |
| 14 | 14 | type Mark = { t: number; x: number; y: number; angle: number }; |
| 15 | 15 | const pair = (a: Mark, b: Mark) => [a, b]; |
| @@ -165,66 +165,3 @@ test("the moment being drawn is always bracketed by samples on hand", () => { | ||
| 165 | 165 | }); |
| 166 | 166 | |
| 167 | 167 | // --------------------------------------------- against the real simulation |
| 168 | ||
| 169 | /** | |
| 170 | * The match's own motion, sampled at the rate the server broadcasts it and | |
| 171 | * drawn at the rate a browser draws: what a bot walking across the map | |
| 172 | * actually looks like. The samples carry exactly what a snapshot carries — | |
| 173 | * a position rounded to a tenth, thirty times a second. | |
| 174 | * | |
| 175 | * The measure is the spread of the per-frame steps. A body drawn at the | |
| 176 | * server's speed takes even steps; the exponential chase this replaced took | |
| 177 | * a long step just after each snapshot and shorter ones after it, which is | |
| 178 | * choppiness with a number on it — twice this spread, measured the same way. | |
| 179 | */ | |
| 180 | test("the simulation's own motion is drawn at an even speed", async () => { | |
| 181 | const { runBots } = await import("./bots.ts"); | |
| 182 | const { newMatch, tick } = await import("./match.ts"); | |
| 183 | const round2 = (n: number) => Math.round(n * 10) / 10; | |
| 184 | ||
| 185 | // A match of its own: nothing here touches the arena everybody shares. | |
| 186 | const state = newMatch(); | |
| 187 | const step = () => { | |
| 188 | runBots(state, 1 / 30); | |
| 189 | tick(state, 1 / 30); | |
| 190 | }; | |
| 191 | for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); | |
| 192 | const stream: { t: number; bodies: { id: string; x: number; y: number; angle: number }[] }[] = []; | |
| 193 | for (let i = 0; i < 60; i++) { | |
| 194 | step(); | |
| 195 | stream.push({ | |
| 196 | t: i / 30, | |
| 197 | bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), | |
| 198 | }); | |
| 199 | } | |
| 200 | ||
| 201 | const timeline = makeTimeline(); | |
| 202 | const paths = new Map<string, { x: number; y: number }[]>(); | |
| 203 | let next = 0; | |
| 204 | for (let f = 0; f / 60 < 1.8; f++) { | |
| 205 | const now = f / 60; | |
| 206 | while (next < stream.length && stream[next]!.t <= now) { | |
| 207 | arrive(timeline, stream[next]!.t); | |
| 208 | for (const p of stream[next]!.bodies) feed(timeline, p.id, p, stream[next]!.t); | |
| 209 | next += 1; | |
| 210 | } | |
| 211 | for (const p of stream[Math.max(0, next - 1)]!.bodies) { | |
| 212 | const at = bodyAt(timeline, p, now - delay(timeline)); | |
| 213 | paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | // Whoever walked furthest in that second and a half. | |
| 218 | const walked = [...paths.values()] | |
| 219 | .filter((path) => path.length > 80) | |
| 220 | .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) | |
| 221 | .map((steps) => steps.filter((step) => step > 0.001)) | |
| 222 | .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; | |
| 223 | expect(walked.length).toBeGreaterThan(30); | |
| 224 | const mean = walked.reduce((s, x) => s + x, 0) / walked.length; | |
| 225 | const spread = Math.sqrt(walked.reduce((s, x) => s + (x - mean) ** 2, 0) / walked.length) / mean; | |
| 226 | // A body moving at the server's own speed: about four units every sixtieth | |
| 227 | // of a second, which is the run speed. | |
| 228 | expect(mean * 60).toBeGreaterThan(150); | |
| 229 | expect(spread).toBeLessThan(0.15); | |
| 230 | }); |
src/predict.test.ts → src/engine/predict.test.ts renamed +32 −7
| @@ -5,11 +5,36 @@ | ||
| 5 | 5 | |
| 6 | 6 | import { expect, test } from "bun:test"; |
| 7 | 7 | import { BODY_R, moveSpeed } from "./glide.ts"; |
| 8 | import { moveCircle, solidTile, TILE } from "./map.ts"; | |
| 9 | import { advance, anchor, holdBack, insideWall, LEAD_MAX, makeLocal, project, reconcile, SNAP } from "../static/predict.js"; | |
| 8 | import { makeGrid } from "./grid.ts"; | |
| 9 | import { advance, anchor, holdBack, insideWall, LEAD_MAX, makeLocal, project, reconcile, SNAP } from "../../static/engine/predict.js"; | |
| 10 | 10 | |
| 11 | // The world the browser builds out of map.json, which is the map the server | |
| 12 | // plays on: the same rows, the same tile, the same body. | |
| 11 | // A map of the shape a served one has: an outer wall, rooms, pillars and a | |
| 12 | // corridor, bound to the same grid the server plays on. The browser builds | |
| 13 | // the same world out of map.json: the same rows, the same tile, the same body. | |
| 14 | const TILE = 80; | |
| 15 | const ROWS = [ | |
| 16 | "##############################", | |
| 17 | "#........###......###........#", | |
| 18 | "#........###......###........#", | |
| 19 | "#........##........##........#", | |
| 20 | "#........##.#....#.##........#", | |
| 21 | "#........##........##........#", | |
| 22 | "#...##......#....#......##...#", | |
| 23 | "#..####....#......#....####..#", | |
| 24 | "#..####..###......###..####..#", | |
| 25 | "#..####..####....####..####..#", | |
| 26 | "#..####..####.##.####..####..#", | |
| 27 | "#........####.##.####........#", | |
| 28 | "#..####..####....####..####..#", | |
| 29 | "#..####..#####..#####..####..#", | |
| 30 | "#..####..#####..#####..####..#", | |
| 31 | "#..####..###......###..####..#", | |
| 32 | "#........##........##........#", | |
| 33 | "#............................#", | |
| 34 | "##############################", | |
| 35 | ]; | |
| 36 | const grid = makeGrid(ROWS, TILE); | |
| 37 | const { moveCircle, solidTile } = grid; | |
| 13 | 38 | const world = { solid: solidTile, tile: TILE, r: BODY_R }; |
| 14 | 39 | |
| 15 | 40 | test("nothing is drawn until the server has said where you are", () => { |
| @@ -38,11 +63,11 @@ test("a frame of movement is the speed it was given, in the direction it was giv | ||
| 38 | 63 | }); |
| 39 | 64 | |
| 40 | 65 | test("the guess resolves a wall exactly the way the server does", () => { |
| 41 | // Every open tile of the real map, walked into from eight directions: the | |
| 66 | // Every open tile of the map, walked into from eight directions: the | |
| 42 | 67 | // browser's step and the server's land in the same place, or the camera |
| 43 | 68 | // spends the round being dragged off whatever it guessed. |
| 44 | const rows = 19; | |
| 45 | const cols = 30; | |
| 69 | const rows = grid.rows; | |
| 70 | const cols = grid.cols; | |
| 46 | 71 | let tried = 0; |
| 47 | 72 | for (let row = 1; row < rows - 1; row++) |
| 48 | 73 | for (let col = 1; col < cols - 1; col++) { |
src/presence.test.ts → src/engine/presence.test.ts renamed +3 −4
| @@ -18,7 +18,7 @@ import { | ||
| 18 | 18 | slide, |
| 19 | 19 | SMALL, |
| 20 | 20 | swell, |
| 21 | } from "../static/presence.js"; | |
| 21 | } from "../../static/engine/presence.js"; | |
| 22 | 22 | |
| 23 | 23 | const body = (over: Record<string, unknown> = {}) => ({ |
| 24 | 24 | id: "them", |
| @@ -27,7 +27,6 @@ const body = (over: Record<string, unknown> = {}) => ({ | ||
| 27 | 27 | angle: 0, |
| 28 | 28 | alive: true, |
| 29 | 29 | mate: false, |
| 30 | agent: "sable", | |
| 31 | 30 | ...over, |
| 32 | 31 | }); |
| 33 | 32 | |
| @@ -103,13 +102,13 @@ test("a body that has not moved since has no direction to come from", () => { | ||
| 103 | 102 | |
| 104 | 103 | test("where somebody was last seen is marked, and the mark dims away", () => { |
| 105 | 104 | const p = makePresence(); |
| 106 | sighted(p, [body({ x: 640, y: 480, agent: "veil" })], 10); | |
| 105 | sighted(p, [body({ x: 640, y: 480 })], 10); | |
| 107 | 106 | expect(ghosts(p, 10)).toEqual([]); |
| 108 | 107 | |
| 109 | 108 | sighted(p, [], 10.5); |
| 110 | 109 | const fresh = ghosts(p, 10.5); |
| 111 | 110 | expect(fresh.length).toBe(1); |
| 112 | expect(fresh[0]).toMatchObject({ id: "them", x: 640, y: 480, agent: "veil", fade: 1 }); | |
| 111 | expect(fresh[0]).toMatchObject({ id: "them", x: 640, y: 480, mate: false, fade: 1 }); | |
| 113 | 112 | expect(ghosts(p, 10.5 + GHOST / 2)[0]!.fade).toBeCloseTo(0.5, 5); |
| 114 | 113 | expect(ghosts(p, 10.5 + GHOST)).toEqual([]); |
| 115 | 114 | // And it is forgotten, not kept for ever. |
src/raycast.test.ts → src/engine/raycast.test.ts renamed +1 −1
| @@ -14,7 +14,7 @@ import { | ||
| 14 | 14 | smokeAlong, |
| 15 | 15 | spriteColumnVisible, |
| 16 | 16 | wallLight, |
| 17 | } from "../static/raycast.js"; | |
| 17 | } from "../../static/engine/raycast.js"; | |
| 18 | 18 | |
| 19 | 19 | const W = 900; |
| 20 | 20 |
src/resolution.test.ts → src/engine/resolution.test.ts renamed +1 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | import { renderSize, WIDTHS } from "../static/resolution.js"; | |
| 8 | import { renderSize, WIDTHS } from "../../static/engine/resolution.js"; | |
| 9 | 9 | |
| 10 | 10 | test("a retina window draws the world at a fixed width, not at its pixels", () => { |
| 11 | 11 | const size = renderSize(3024, 1915, 0); |
src/engine/scene.test.ts added +236
| @@ -0,0 +1,236 @@ | ||
| 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 | * This is the engine's half: walls, bodies, marks, arrivals. What a game puts | |
| 7 | * on the floor besides is drawn through hooks and tested with the game. | |
| 8 | */ | |
| 9 | ||
| 10 | import { expect, test } from "bun:test"; | |
| 11 | import { GHOST, ghosts, makePresence, shown, sighted } from "../../static/engine/presence.js"; | |
| 12 | import { drawScene, makeScene } from "../../static/engine/scene.js"; | |
| 13 | ||
| 14 | type Op = { op: string; args: unknown[]; alpha: number }; | |
| 15 | ||
| 16 | /** A canvas that records. Every call the renderer makes has to exist here. */ | |
| 17 | function fakeContext() { | |
| 18 | const ops: Op[] = []; | |
| 19 | const note = | |
| 20 | (op: string) => | |
| 21 | (...args: unknown[]) => { | |
| 22 | // The opacity in force when the call was made: how faded a body | |
| 23 | // arriving into sight is, and how far a last-seen mark has dimmed. | |
| 24 | ops.push({ op, args, alpha: ctx.globalAlpha }); | |
| 25 | }; | |
| 26 | const ctx = { | |
| 27 | ops, | |
| 28 | fillStyle: "", | |
| 29 | strokeStyle: "", | |
| 30 | lineWidth: 1, | |
| 31 | font: "", | |
| 32 | textAlign: "left", | |
| 33 | globalAlpha: 1, | |
| 34 | clearRect: note("clearRect"), | |
| 35 | fillRect: note("fillRect"), | |
| 36 | strokeRect: note("strokeRect"), | |
| 37 | beginPath: note("beginPath"), | |
| 38 | closePath: note("closePath"), | |
| 39 | moveTo: note("moveTo"), | |
| 40 | lineTo: note("lineTo"), | |
| 41 | arc: note("arc"), | |
| 42 | ellipse: note("ellipse"), | |
| 43 | fill: note("fill"), | |
| 44 | stroke: note("stroke"), | |
| 45 | clip: note("clip"), | |
| 46 | rect: note("rect"), | |
| 47 | save: note("save"), | |
| 48 | restore: note("restore"), | |
| 49 | translate: note("translate"), | |
| 50 | rotate: note("rotate"), | |
| 51 | fillText: note("fillText"), | |
| 52 | measureText: (text: string) => ({ width: text.length * 6 }), | |
| 53 | createLinearGradient: () => ({ addColorStop() {} }), | |
| 54 | }; | |
| 55 | return ctx; | |
| 56 | } | |
| 57 | ||
| 58 | // A room three tiles deep running east, with a pillar standing in it at | |
| 59 | // column 5 — enough geometry for a body to hide behind. | |
| 60 | const room = (col: number, row: number): boolean => | |
| 61 | col < 1 || col > 12 || row < 1 || row > 3 || col === 5; | |
| 62 | const TILE = 80; | |
| 63 | const world = { solid: room, tile: TILE }; | |
| 64 | ||
| 65 | // What the engine's renderer needs of a snapshot: you, who you can see, | |
| 66 | // and the shots you were let see. Nothing of the game. | |
| 67 | const snapshot = (players: unknown[]) => ({ | |
| 68 | me: { id: "me", x: 120, y: 200, angle: 0, alive: true, hp: 100 }, | |
| 69 | players, | |
| 70 | shots: [], | |
| 71 | }); | |
| 72 | ||
| 73 | const body = (over: Record<string, unknown> = {}) => ({ | |
| 74 | id: "them", | |
| 75 | name: "Sable", | |
| 76 | x: 280, | |
| 77 | y: 200, | |
| 78 | angle: 3.1, | |
| 79 | hp: 100, | |
| 80 | alive: true, | |
| 81 | mate: false, | |
| 82 | ...over, | |
| 83 | }); | |
| 84 | ||
| 85 | const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p }; | |
| 86 | const eye = { x: 120, y: 200, angle: 0 }; | |
| 87 | ||
| 88 | test("a frame draws walls in runs: the whole width covered, far fewer fills", () => { | |
| 89 | const ctx = fakeContext(); | |
| 90 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view); | |
| 91 | const rects = ctx.ops.filter((o) => o.op === "fillRect"); | |
| 92 | // The bands are the full width; the rest are wall runs. | |
| 93 | const walls = rects | |
| 94 | .map((o) => ({ x: Number(o.args[0]), w: Number(o.args[2]) })) | |
| 95 | .filter((r) => r.w < view.width) | |
| 96 | .sort((a, b) => a.x - b.x); | |
| 97 | // This room is closed, so every column of the picture is wall: the runs | |
| 98 | // start at the left edge, meet end to end and reach the right one. That is | |
| 99 | // the same picture the old one-fill-a-column loop drew. | |
| 100 | expect(walls[0]!.x).toBe(0); | |
| 101 | let reach = 0; | |
| 102 | for (const rect of walls) { | |
| 103 | expect(rect.x).toBeLessThanOrEqual(reach); | |
| 104 | reach = Math.max(reach, rect.x + rect.w); | |
| 105 | } | |
| 106 | expect(reach).toBeGreaterThanOrEqual(view.width); | |
| 107 | // And it costs a fraction of the fills: one a column would be 900. | |
| 108 | expect(walls.length).toBeLessThan(view.width / 2); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a body in the open is drawn; the same body behind the pillar is not", () => { | |
| 112 | const open = fakeContext(); | |
| 113 | drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 114 | // The head is the only arc in this scene, so it stands for the body. | |
| 115 | expect(open.ops.some((o) => o.op === "arc")).toBe(true); | |
| 116 | ||
| 117 | const hidden = fakeContext(); | |
| 118 | drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view); | |
| 119 | expect(hidden.ops.some((o) => o.op === "arc")).toBe(false); | |
| 120 | }); | |
| 121 | ||
| 122 | test("a team mate gets a name tag and an enemy never does", () => { | |
| 123 | const mate = fakeContext(); | |
| 124 | drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); | |
| 125 | expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true); | |
| 126 | ||
| 127 | const enemy = fakeContext(); | |
| 128 | drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 129 | expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 130 | }); | |
| 131 | ||
| 132 | test("the dead lie on the floor and wear no markers at all", () => { | |
| 133 | const ctx = fakeContext(); | |
| 134 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view); | |
| 135 | // Flat on the ground: an ellipse, not an upright body with a head. | |
| 136 | expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true); | |
| 137 | // No name tag, and no chevron — the chevron is the only closed path here. | |
| 138 | expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 139 | expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 140 | }); | |
| 141 | ||
| 142 | test("an enemy wears a chevron and a team mate does not", () => { | |
| 143 | const enemy = fakeContext(); | |
| 144 | drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 145 | expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true); | |
| 146 | ||
| 147 | const mate = fakeContext(); | |
| 148 | drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); | |
| 149 | expect(mate.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 150 | expect(mate.ops.some((o) => o.op === "fillText")).toBe(true); | |
| 151 | }); | |
| 152 | ||
| 153 | test("a body has a hard dark edge, whichever side it is on", () => { | |
| 154 | const ctx = fakeContext(); | |
| 155 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 156 | expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true); | |
| 157 | }); | |
| 158 | ||
| 159 | // ------------------------------------------- coming into sight, and leaving | |
| 160 | ||
| 161 | test("a body arriving is drawn faded and small, and solid once it is in", () => { | |
| 162 | const edgeOf = (ctx: ReturnType<typeof fakeContext>) => ctx.ops.find((o) => o.op === "strokeRect")!; | |
| 163 | ||
| 164 | const arriving = fakeContext(); | |
| 165 | drawScene(arriving as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 0.4 }); | |
| 166 | const solid = fakeContext(); | |
| 167 | drawScene(solid as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 1 }); | |
| 168 | ||
| 169 | expect(edgeOf(arriving).alpha).toBeCloseTo(0.4, 5); | |
| 170 | expect(edgeOf(solid).alpha).toBe(1); | |
| 171 | // And smaller: it grows from its feet as it comes in. | |
| 172 | expect(Number(edgeOf(arriving).args[2])).toBeLessThan(Number(edgeOf(solid).args[2])); | |
| 173 | expect(Number(edgeOf(arriving).args[2])).toBeGreaterThan(Number(edgeOf(solid).args[2]) * 0.7); | |
| 174 | }); | |
| 175 | ||
| 176 | test("a body the presence store has only just seen is the one being faded", () => { | |
| 177 | const seen = makePresence(); | |
| 178 | const them = body(); | |
| 179 | sighted(seen, [them], 100); | |
| 180 | const draw = (at: number) => { | |
| 181 | const ctx = fakeContext(); | |
| 182 | drawScene(ctx as never, makeScene(), world, eye, snapshot([them]), { | |
| 183 | ...view, | |
| 184 | shown: (p: { id: string }) => shown(seen, p.id, at), | |
| 185 | }); | |
| 186 | return ctx.ops.find((o) => o.op === "strokeRect")!; | |
| 187 | }; | |
| 188 | expect(draw(100.03).alpha).toBeGreaterThan(0); | |
| 189 | expect(draw(100.03).alpha).toBeLessThan(1); | |
| 190 | expect(draw(100.5).alpha).toBe(1); | |
| 191 | }); | |
| 192 | ||
| 193 | test("a last-seen mark is an outline, dimming, and never a body", () => { | |
| 194 | const marks = [{ id: "them", x: 280, y: 200, angle: 0, mate: false, fade: 0.6 }]; | |
| 195 | const ctx = fakeContext(); | |
| 196 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks }); | |
| 197 | const edge = ctx.ops.find((o) => o.op === "strokeRect"); | |
| 198 | expect(edge).toBeDefined(); | |
| 199 | expect(edge!.alpha).toBeCloseTo(0.6, 5); | |
| 200 | // No head, no chevron, no name: a memory of a sighting, not a sighting. | |
| 201 | expect(ctx.ops.some((o) => o.op === "arc")).toBe(false); | |
| 202 | expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 203 | expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 204 | expect(GHOST).toBeGreaterThan(0); | |
| 205 | }); | |
| 206 | ||
| 207 | test("a body that steps out of sight leaves the outline the store hands over", () => { | |
| 208 | const seen = makePresence(); | |
| 209 | const them = body(); | |
| 210 | sighted(seen, [them], 100); // in sight, in the open | |
| 211 | sighted(seen, [], 100.1); // and gone behind the corner | |
| 212 | const ctx = fakeContext(); | |
| 213 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { | |
| 214 | ...view, | |
| 215 | marks: ghosts(seen, 100.1), | |
| 216 | }); | |
| 217 | // Nobody is in the snapshot at all, and there is still something there. | |
| 218 | const edge = ctx.ops.find((o) => o.op === "strokeRect"); | |
| 219 | expect(edge).toBeDefined(); | |
| 220 | expect(edge!.alpha).toBeCloseTo(1, 2); | |
| 221 | ||
| 222 | // A second later the store hands over nothing, and nothing is drawn. | |
| 223 | const after = fakeContext(); | |
| 224 | drawScene(after as never, makeScene(), world, eye, snapshot([]), { | |
| 225 | ...view, | |
| 226 | marks: ghosts(seen, 100.1 + GHOST), | |
| 227 | }); | |
| 228 | expect(after.ops.some((o) => o.op === "strokeRect")).toBe(false); | |
| 229 | }); | |
| 230 | ||
| 231 | test("a mark where the wall now stands between you and it is not drawn", () => { | |
| 232 | const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }]; | |
| 233 | const ctx = fakeContext(); | |
| 234 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks }); | |
| 235 | expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(false); | |
| 236 | }); |
src/slices.test.ts → src/engine/slices.test.ts renamed +1 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | import { covered, GEOM, hazeStep, makeColumns, SKY, wallRuns } from "../static/slices.js"; | |
| 8 | import { covered, GEOM, hazeStep, makeColumns, SKY, wallRuns } from "../../static/engine/slices.js"; | |
| 9 | 9 | |
| 10 | 10 | type Col = { level: number; side?: number; top?: number; high?: number; haze?: number }; |
| 11 | 11 |
src/steer.test.ts → src/engine/steer.test.ts renamed +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { moveVector } from "../static/steer.js"; | |
| 2 | import { moveVector } from "../../static/engine/steer.js"; | |
| 3 | 3 | |
| 4 | 4 | test("W walks the way you are looking, whichever way that is", () => { |
| 5 | 5 | const east = moveVector(0, -1, 0); |
src/yaw.test.ts → src/engine/yaw.test.ts renamed +1 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { expect, test } from "bun:test"; |
| 7 | import { makeYaw, settle, shortWay, told, turn, SENT_TOLERANCE } from "../static/yaw.js"; | |
| 7 | import { makeYaw, settle, shortWay, told, turn, SENT_TOLERANCE } from "../../static/engine/yaw.js"; | |
| 8 | 8 | |
| 9 | 9 | const me = (angle: number, extra: Partial<{ id: string; alive: boolean }> = {}) => ({ |
| 10 | 10 | id: "p1", |
src/feel.test.ts +67 −4
| @@ -11,13 +11,13 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | import { BODY_R, moveSpeed, RUN } from "./glide.ts"; | |
| 14 | import { BODY_R, moveSpeed, RUN } from "./engine/glide.ts"; | |
| 15 | 15 | import { solidTile, TILE } from "./map.ts"; |
| 16 | 16 | import { newMatch, setInput, startRound, tick } from "./match.ts"; |
| 17 | 17 | import { runBots } from "./bots.ts"; |
| 18 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/interp.js"; | |
| 19 | import { makeLocal, stepLocal } from "../static/predict.js"; | |
| 20 | import { makeYaw, settle, told } from "../static/yaw.js"; | |
| 18 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js"; | |
| 19 | import { makeLocal, stepLocal } from "../static/engine/predict.js"; | |
| 20 | import { makeYaw, settle, told } from "../static/engine/yaw.js"; | |
| 21 | 21 | |
| 22 | 22 | const round2 = (n: number) => Math.round(n * 10) / 10; |
| 23 | 23 | const world = { solid: solidTile, tile: TILE, r: BODY_R }; |
| @@ -361,3 +361,66 @@ test("a round start points you at the site once, and nothing stomps it a tick la | ||
| 361 | 361 | // At most one a round — the spawn — and never a second one to angle zero. |
| 362 | 362 | expect(worstRound).toBeLessThanOrEqual(1); |
| 363 | 363 | }); |
| 364 | ||
| 365 | /** | |
| 366 | * The match's own motion, sampled at the rate the server broadcasts it and | |
| 367 | * drawn at the rate a browser draws: what a bot walking across the map | |
| 368 | * actually looks like. The samples carry exactly what a snapshot carries — | |
| 369 | * a position rounded to a tenth, thirty times a second. | |
| 370 | * | |
| 371 | * The measure is the spread of the per-frame steps. A body drawn at the | |
| 372 | * server's speed takes even steps; the exponential chase this replaced took | |
| 373 | * a long step just after each snapshot and shorter ones after it, which is | |
| 374 | * choppiness with a number on it — twice this spread, measured the same way. | |
| 375 | */ | |
| 376 | test("the simulation's own motion is drawn at an even speed", async () => { | |
| 377 | const { runBots } = await import("./bots.ts"); | |
| 378 | const { newMatch, tick } = await import("./match.ts"); | |
| 379 | const round2 = (n: number) => Math.round(n * 10) / 10; | |
| 380 | ||
| 381 | // A match of its own: nothing here touches the arena everybody shares. | |
| 382 | const state = newMatch(); | |
| 383 | const step = () => { | |
| 384 | runBots(state, 1 / 30); | |
| 385 | tick(state, 1 / 30); | |
| 386 | }; | |
| 387 | for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); | |
| 388 | const stream: { t: number; bodies: { id: string; x: number; y: number; angle: number }[] }[] = []; | |
| 389 | for (let i = 0; i < 60; i++) { | |
| 390 | step(); | |
| 391 | stream.push({ | |
| 392 | t: i / 30, | |
| 393 | bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), | |
| 394 | }); | |
| 395 | } | |
| 396 | ||
| 397 | const timeline = makeTimeline(); | |
| 398 | const paths = new Map<string, { x: number; y: number }[]>(); | |
| 399 | let next = 0; | |
| 400 | for (let f = 0; f / 60 < 1.8; f++) { | |
| 401 | const now = f / 60; | |
| 402 | while (next < stream.length && stream[next]!.t <= now) { | |
| 403 | arrive(timeline, stream[next]!.t); | |
| 404 | for (const p of stream[next]!.bodies) feed(timeline, p.id, p, stream[next]!.t); | |
| 405 | next += 1; | |
| 406 | } | |
| 407 | for (const p of stream[Math.max(0, next - 1)]!.bodies) { | |
| 408 | const at = bodyAt(timeline, p, now - delay(timeline)); | |
| 409 | paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); | |
| 410 | } | |
| 411 | } | |
| 412 | ||
| 413 | // Whoever walked furthest in that second and a half. | |
| 414 | const walked = [...paths.values()] | |
| 415 | .filter((path) => path.length > 80) | |
| 416 | .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) | |
| 417 | .map((steps) => steps.filter((step) => step > 0.001)) | |
| 418 | .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; | |
| 419 | expect(walked.length).toBeGreaterThan(30); | |
| 420 | const mean = walked.reduce((s, x) => s + x, 0) / walked.length; | |
| 421 | const spread = Math.sqrt(walked.reduce((s, x) => s + (x - mean) ** 2, 0) / walked.length) / mean; | |
| 422 | // A body moving at the server's own speed: about four units every sixtieth | |
| 423 | // of a second, which is the run speed. | |
| 424 | expect(mean * 60).toBeGreaterThan(150); | |
| 425 | expect(spread).toBeLessThan(0.15); | |
| 426 | }); |
src/map.test.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | /** | |
| 2 | * This map, bound to the engine's grid: the rows are the game's, the maths is | |
| 3 | * shared with the browser, and the binding has to hand one to the other. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { BODY_R } from "./engine/glide.ts"; | |
| 8 | import { moveCircle } from "./map.ts"; | |
| 9 | ||
| 10 | test("the real map moves by the same maths", () => { | |
| 11 | // Mid, walking east into whatever is there: the map's own binding. | |
| 12 | const to = moveCircle(1200, 640, 9, 0, BODY_R); | |
| 13 | expect(to.x).toBeGreaterThanOrEqual(1200); | |
| 14 | expect(to.x).toBeLessThanOrEqual(1209); | |
| 15 | expect(to.y).toBe(640); | |
| 16 | }); |
src/map.ts +28 −175
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The map: one grid, and the geometry everything else asks questions of. | |
| 2 | * The map: this game's one grid, and what its letters mean. | |
| 3 | 3 | * |
| 4 | 4 | * Two sites (A left, B right), a mid corridor between the spawns, and a back |
| 5 | 5 | * corridor along the bottom that links A main, spawn and B main — the flank. |
| @@ -7,11 +7,15 @@ | ||
| 7 | 7 | * corridor is comfortably wide and a wall is never thin enough for a ray to |
| 8 | 8 | * step over. |
| 9 | 9 | * |
| 10 | * Everything here is a pure function of the grid: collision, line of sight | |
| 11 | * and the walk distance a bot uses to path. No state. | |
| 10 | * The mechanics — collision, line of sight, the walk distance a bot uses to | |
| 11 | * path — are `engine/grid.ts`, bound here to these rows. What is this | |
| 12 | * game's own is the rows themselves and the reading of their letters: a | |
| 13 | * plant site, a spawn, where the orbs sit. | |
| 12 | 14 | */ |
| 13 | 15 | |
| 14 | import { circleBlocked, slideCircle } from "./glide.ts"; | |
| 16 | import { makeGrid, segmentHitsCircle, segmentPoint, type Point } from "./engine/grid.ts"; | |
| 17 | ||
| 18 | export { segmentHitsCircle, segmentPoint, type Point }; | |
| 15 | 19 | |
| 16 | 20 | export const TILE = 80; |
| 17 | 21 | |
| @@ -38,53 +42,38 @@ const ROWS_SRC = [ | ||
| 38 | 42 | "##############################", |
| 39 | 43 | ]; |
| 40 | 44 | |
| 41 | export const COLS = ROWS_SRC[0]!.length; | |
| 42 | export const ROWS = ROWS_SRC.length; | |
| 43 | export const WIDTH = COLS * TILE; | |
| 44 | export const HEIGHT = ROWS * TILE; | |
| 45 | const board = makeGrid(ROWS_SRC, TILE); | |
| 46 | ||
| 47 | export const COLS = board.cols; | |
| 48 | export const ROWS = board.rows; | |
| 49 | export const WIDTH = board.width; | |
| 50 | export const HEIGHT = board.height; | |
| 45 | 51 | |
| 46 | 52 | export const grid: string[] = ROWS_SRC; |
| 47 | 53 | export const gridRows = (): string[] => ROWS_SRC.slice(); |
| 48 | 54 | |
| 49 | 55 | export type Site = "A" | "B"; |
| 50 | export type Point = { x: number; y: number }; | |
| 51 | 56 | |
| 52 | const at = (col: number, row: number): string => | |
| 53 | row < 0 || row >= ROWS || col < 0 || col >= COLS ? "#" : ROWS_SRC[row]![col]!; | |
| 54 | ||
| 55 | export const solidTile = (col: number, row: number): boolean => at(col, row) === "#"; | |
| 56 | ||
| 57 | export const tileOf = (x: number, y: number): { col: number; row: number } => ({ | |
| 58 | col: Math.floor(x / TILE), | |
| 59 | row: Math.floor(y / TILE), | |
| 60 | }); | |
| 61 | ||
| 62 | export const centreOf = (col: number, row: number): Point => ({ | |
| 63 | x: col * TILE + TILE / 2, | |
| 64 | y: row * TILE + TILE / 2, | |
| 65 | }); | |
| 66 | ||
| 67 | export const solidAt = (x: number, y: number): boolean => { | |
| 68 | const t = tileOf(x, y); | |
| 69 | return solidTile(t.col, t.row); | |
| 70 | }; | |
| 57 | export const solidTile = board.solidTile; | |
| 58 | export const tileOf = board.tileOf; | |
| 59 | export const centreOf = board.centreOf; | |
| 60 | export const solidAt = board.solidAt; | |
| 61 | export const moveCircle = board.moveCircle; | |
| 62 | export const blockedCircle = board.blockedCircle; | |
| 63 | export const wallBetween = board.wallBetween; | |
| 64 | export const rayHitWall = board.rayHitWall; | |
| 65 | export const walkDistance = board.walkDistance; | |
| 66 | export const pathStep = board.pathStep; | |
| 67 | export const clearPaths = board.clearPaths; | |
| 71 | 68 | |
| 72 | 69 | /** The site a point plants on, or null: the spike only arms inside a site. */ |
| 73 | 70 | export const siteAt = (x: number, y: number): Site | null => { |
| 74 | const t = tileOf(x, y); | |
| 75 | const c = at(t.col, t.row); | |
| 71 | const c = board.cellAt(x, y); | |
| 76 | 72 | return c === "A" || c === "B" ? c : null; |
| 77 | 73 | }; |
| 78 | 74 | |
| 79 | const cellsOf = (mark: string): Point[] => { | |
| 80 | const out: Point[] = []; | |
| 81 | for (let row = 0; row < ROWS; row++) | |
| 82 | for (let col = 0; col < COLS; col++) if (at(col, row) === mark) out.push(centreOf(col, row)); | |
| 83 | return out; | |
| 84 | }; | |
| 85 | ||
| 86 | export const spawnCells = (side: "atk" | "def"): Point[] => cellsOf(side === "atk" ? "a" : "d"); | |
| 87 | export const siteCells = (site: Site): Point[] => cellsOf(site); | |
| 75 | export const spawnCells = (side: "atk" | "def"): Point[] => board.cellsOf(side === "atk" ? "a" : "d"); | |
| 76 | export const siteCells = (site: Site): Point[] => board.cellsOf(site); | |
| 88 | 77 | |
| 89 | 78 | export const siteCentre = (site: Site): Point => { |
| 90 | 79 | const cells = siteCells(site); |
| @@ -94,139 +83,3 @@ export const siteCentre = (site: Site): Point => { | ||
| 94 | 83 | |
| 95 | 84 | /** Ult orbs: one on each half of mid, free for whoever walks over them. */ |
| 96 | 85 | export const orbSpots = (): Point[] => [centreOf(4, 11), centreOf(25, 11)]; |
| 97 | ||
| 98 | /** | |
| 99 | * Slide a circle through this grid. The maths is in `glide.ts`, which the | |
| 100 | * browser runs too; here it is bound to this map's tiles. | |
| 101 | */ | |
| 102 | export const moveCircle = (x: number, y: number, dx: number, dy: number, r: number): Point => | |
| 103 | slideCircle(x, y, dx, dy, r, solidTile, TILE); | |
| 104 | ||
| 105 | /** True when a circle at (x,y) overlaps any solid tile. */ | |
| 106 | export const blockedCircle = (x: number, y: number, r: number): boolean => | |
| 107 | circleBlocked(x, y, r, solidTile, TILE); | |
| 108 | ||
| 109 | /** | |
| 110 | * Does a wall stand between two points? Sampled every third of a tile: a | |
| 111 | * wall is a whole tile thick, so nothing is ever stepped over. | |
| 112 | */ | |
| 113 | export function wallBetween(a: Point, b: Point): boolean { | |
| 114 | const dx = b.x - a.x; | |
| 115 | const dy = b.y - a.y; | |
| 116 | const dist = Math.hypot(dx, dy); | |
| 117 | const steps = Math.ceil(dist / (TILE / 3)); | |
| 118 | for (let i = 1; i < steps; i++) { | |
| 119 | const t = i / steps; | |
| 120 | if (solidAt(a.x + dx * t, a.y + dy * t)) return true; | |
| 121 | } | |
| 122 | return false; | |
| 123 | } | |
| 124 | ||
| 125 | /** Where a shot stops: the first wall along the ray, or its full range. */ | |
| 126 | export function rayHitWall(from: Point, angle: number, range: number): Point { | |
| 127 | const dx = Math.cos(angle); | |
| 128 | const dy = Math.sin(angle); | |
| 129 | const step = TILE / 4; | |
| 130 | for (let d = step; d <= range; d += step) { | |
| 131 | const x = from.x + dx * d; | |
| 132 | const y = from.y + dy * d; | |
| 133 | if (solidAt(x, y)) return { x: from.x + dx * (d - step), y: from.y + dy * (d - step) }; | |
| 134 | } | |
| 135 | return { x: from.x + dx * range, y: from.y + dy * range }; | |
| 136 | } | |
| 137 | ||
| 138 | /** Closest approach of a segment to a point, as distance and travel along it. */ | |
| 139 | export function segmentPoint(a: Point, b: Point, p: Point): { dist: number; travel: number } { | |
| 140 | const dx = b.x - a.x; | |
| 141 | const dy = b.y - a.y; | |
| 142 | const len2 = dx * dx + dy * dy; | |
| 143 | const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2)); | |
| 144 | const cx = a.x + dx * t; | |
| 145 | const cy = a.y + dy * t; | |
| 146 | return { dist: Math.hypot(p.x - cx, p.y - cy), travel: t * Math.sqrt(len2) }; | |
| 147 | } | |
| 148 | ||
| 149 | export const segmentHitsCircle = (a: Point, b: Point, c: Point, r: number): boolean => | |
| 150 | segmentPoint(a, b, c).dist <= r; | |
| 151 | ||
| 152 | // Walk distance from a goal tile, breadth-first over floor. Bots ask for it | |
| 153 | // every few ticks; the field for a goal is built once and kept for the round. | |
| 154 | const fields = new Map<number, Int32Array>(); | |
| 155 | ||
| 156 | export const clearPaths = (): void => void fields.clear(); | |
| 157 | ||
| 158 | function field(goalCol: number, goalRow: number): Int32Array { | |
| 159 | const key = goalRow * COLS + goalCol; | |
| 160 | const cached = fields.get(key); | |
| 161 | if (cached) return cached; | |
| 162 | const dist = new Int32Array(COLS * ROWS).fill(-1); | |
| 163 | if (!solidTile(goalCol, goalRow)) { | |
| 164 | dist[key] = 0; | |
| 165 | const queue: number[] = [key]; | |
| 166 | for (let head = 0; head < queue.length; head++) { | |
| 167 | const here = queue[head]!; | |
| 168 | const col = here % COLS; | |
| 169 | const row = (here - col) / COLS; | |
| 170 | const d = dist[here]!; | |
| 171 | for (const [dc, dr] of [ | |
| 172 | [1, 0], | |
| 173 | [-1, 0], | |
| 174 | [0, 1], | |
| 175 | [0, -1], | |
| 176 | ] as const) { | |
| 177 | const nc = col + dc; | |
| 178 | const nr = row + dr; | |
| 179 | if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue; | |
| 180 | const nk = nr * COLS + nc; | |
| 181 | if (dist[nk] !== -1 || solidTile(nc, nr)) continue; | |
| 182 | dist[nk] = d + 1; | |
| 183 | queue.push(nk); | |
| 184 | } | |
| 185 | } | |
| 186 | } | |
| 187 | if (fields.size > 64) fields.clear(); | |
| 188 | fields.set(key, dist); | |
| 189 | return dist; | |
| 190 | } | |
| 191 | ||
| 192 | /** Walk distance in tiles between two points, or -1 when unreachable. */ | |
| 193 | export function walkDistance(from: Point, to: Point): number { | |
| 194 | const f = tileOf(from.x, from.y); | |
| 195 | const g = tileOf(to.x, to.y); | |
| 196 | return field(g.col, g.row)[f.row * COLS + f.col] ?? -1; | |
| 197 | } | |
| 198 | ||
| 199 | /** | |
| 200 | * The next point to walk toward on the way to a goal: the centre of the | |
| 201 | * neighbouring tile that is one step closer, so bots round corners instead | |
| 202 | * of grinding into them. | |
| 203 | */ | |
| 204 | export function pathStep(from: Point, to: Point): Point { | |
| 205 | const f = tileOf(from.x, from.y); | |
| 206 | const g = tileOf(to.x, to.y); | |
| 207 | if (f.col === g.col && f.row === g.row) return to; | |
| 208 | const dist = field(g.col, g.row); | |
| 209 | const here = dist[f.row * COLS + f.col] ?? -1; | |
| 210 | if (here <= 0) return to; | |
| 211 | let best: Point | null = null; | |
| 212 | let bestD = here; | |
| 213 | for (const [dc, dr] of [ | |
| 214 | [1, 0], | |
| 215 | [-1, 0], | |
| 216 | [0, 1], | |
| 217 | [0, -1], | |
| 218 | ] as const) { | |
| 219 | const nc = f.col + dc; | |
| 220 | const nr = f.row + dr; | |
| 221 | if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue; | |
| 222 | const d = dist[nr * COLS + nc] ?? -1; | |
| 223 | if (d !== -1 && d < bestD) { | |
| 224 | bestD = d; | |
| 225 | best = centreOf(nc, nr); | |
| 226 | } | |
| 227 | } | |
| 228 | // Straight to the goal when nothing stands in the way — smoother than | |
| 229 | // walking tile centre to tile centre down an open corridor. | |
| 230 | if (best && !wallBetween(from, to) && (walkDistance(from, to) ?? 99) < 6) return to; | |
| 231 | return best ?? to; | |
| 232 | } |
src/match.ts +3 −3
| @@ -9,8 +9,8 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; |
| 12 | import { emptyTrack } from "./aim.ts"; | |
| 13 | import { BODY_R, moveSpeed, RUN } from "./glide.ts"; | |
| 12 | import { emptyTrack } from "./engine/aim.ts"; | |
| 13 | import { BODY_R, moveSpeed, RUN } from "./engine/glide.ts"; | |
| 14 | 14 | import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts"; |
| 15 | 15 | import { |
| 16 | 16 | blockedCircle, |
| @@ -27,7 +27,7 @@ import { emptyInput, type Input, type MatchState, type Player, type Side, type S | ||
| 27 | 27 | import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts"; |
| 28 | 28 | |
| 29 | 29 | // A body's size and its speed are shared with the browser, which predicts |
| 30 | // your own movement between snapshots: `src/glide.ts` is the one copy. | |
| 30 | // your own movement between snapshots: `src/engine/glide.ts` is the one copy. | |
| 31 | 31 | export const PLAYER_R = BODY_R; |
| 32 | 32 | export const RUN_SPEED = RUN; |
| 33 | 33 | export const BUY_TIME = 14; |
src/scene.test.ts +4 −152
| @@ -2,11 +2,13 @@ | ||
| 2 | 2 | * The renderer, run for real against a canvas that writes down what it was |
| 3 | 3 | * asked to draw. No browser, but every line of the draw path is executed — |
| 4 | 4 | * which is what catches a typo in a frame nobody can see from a unit test. |
| 5 | * | |
| 6 | * This is the game's half: the spike, the orbs, the zones, the real map and | |
| 7 | * the gun. The walls and the bodies are proved in `engine/scene.test.ts`. | |
| 5 | 8 | */ |
| 6 | 9 | |
| 7 | 10 | import { expect, test } from "bun:test"; |
| 8 | import { drawGun, makeGun } from "../static/gun.js"; | |
| 9 | import { GHOST, ghosts, makePresence, shown, sighted } from "../static/presence.js"; | |
| 11 | import { drawGun, makeGun } from "../static/engine/gun.js"; | |
| 10 | 12 | import { drawScene, makeScene } from "../static/scene.js"; |
| 11 | 13 | import { drawTopdown } from "../static/topdown.js"; |
| 12 | 14 | import { gridRows, TILE } from "./map.ts"; |
| @@ -91,77 +93,6 @@ const body = (over: Record<string, unknown> = {}) => ({ | ||
| 91 | 93 | const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p }; |
| 92 | 94 | const eye = { x: 120, y: 200, angle: 0 }; |
| 93 | 95 | |
| 94 | test("a frame draws walls in runs: the whole width covered, far fewer fills", () => { | |
| 95 | const ctx = fakeContext(); | |
| 96 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view); | |
| 97 | const rects = ctx.ops.filter((o) => o.op === "fillRect"); | |
| 98 | // The bands are the full width; the rest are wall runs. | |
| 99 | const walls = rects | |
| 100 | .map((o) => ({ x: Number(o.args[0]), w: Number(o.args[2]) })) | |
| 101 | .filter((r) => r.w < view.width) | |
| 102 | .sort((a, b) => a.x - b.x); | |
| 103 | // This room is closed, so every column of the picture is wall: the runs | |
| 104 | // start at the left edge, meet end to end and reach the right one. That is | |
| 105 | // the same picture the old one-fill-a-column loop drew. | |
| 106 | expect(walls[0]!.x).toBe(0); | |
| 107 | let reach = 0; | |
| 108 | for (const rect of walls) { | |
| 109 | expect(rect.x).toBeLessThanOrEqual(reach); | |
| 110 | reach = Math.max(reach, rect.x + rect.w); | |
| 111 | } | |
| 112 | expect(reach).toBeGreaterThanOrEqual(view.width); | |
| 113 | // And it costs a fraction of the fills: one a column would be 900. | |
| 114 | expect(walls.length).toBeLessThan(view.width / 2); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a body in the open is drawn; the same body behind the pillar is not", () => { | |
| 118 | const open = fakeContext(); | |
| 119 | drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 120 | // The head is the only arc in this scene, so it stands for the body. | |
| 121 | expect(open.ops.some((o) => o.op === "arc")).toBe(true); | |
| 122 | ||
| 123 | const hidden = fakeContext(); | |
| 124 | drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view); | |
| 125 | expect(hidden.ops.some((o) => o.op === "arc")).toBe(false); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a team mate gets a name tag and an enemy never does", () => { | |
| 129 | const mate = fakeContext(); | |
| 130 | drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); | |
| 131 | expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true); | |
| 132 | ||
| 133 | const enemy = fakeContext(); | |
| 134 | drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 135 | expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 136 | }); | |
| 137 | ||
| 138 | test("the dead lie on the floor and wear no markers at all", () => { | |
| 139 | const ctx = fakeContext(); | |
| 140 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view); | |
| 141 | // Flat on the ground: an ellipse, not an upright body with a head. | |
| 142 | expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true); | |
| 143 | // No name tag, and no chevron — the chevron is the only closed path here. | |
| 144 | expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 145 | expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 146 | }); | |
| 147 | ||
| 148 | test("an enemy wears a chevron and a team mate does not", () => { | |
| 149 | const enemy = fakeContext(); | |
| 150 | drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 151 | expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true); | |
| 152 | ||
| 153 | const mate = fakeContext(); | |
| 154 | drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); | |
| 155 | expect(mate.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 156 | expect(mate.ops.some((o) => o.op === "fillText")).toBe(true); | |
| 157 | }); | |
| 158 | ||
| 159 | test("a body has a hard dark edge, whichever side it is on", () => { | |
| 160 | const ctx = fakeContext(); | |
| 161 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 162 | expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true); | |
| 163 | }); | |
| 164 | ||
| 165 | 96 | test("a smoke between the eye and a body hides it", () => { |
| 166 | 97 | const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }]; |
| 167 | 98 | const ctx = fakeContext(); |
| @@ -248,85 +179,6 @@ test("the gun and the crosshair draw, and the hit marker only when there is one" | ||
| 248 | 179 | expect(loud.ops.length).toBeGreaterThan(quiet); |
| 249 | 180 | }); |
| 250 | 181 | |
| 251 | // ------------------------------------------- coming into sight, and leaving | |
| 252 | ||
| 253 | test("a body arriving is drawn faded and small, and solid once it is in", () => { | |
| 254 | const edgeOf = (ctx: ReturnType<typeof fakeContext>) => ctx.ops.find((o) => o.op === "strokeRect")!; | |
| 255 | ||
| 256 | const arriving = fakeContext(); | |
| 257 | drawScene(arriving as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 0.4 }); | |
| 258 | const solid = fakeContext(); | |
| 259 | drawScene(solid as never, makeScene(), world, eye, snapshot([body()]), { ...view, shown: () => 1 }); | |
| 260 | ||
| 261 | expect(edgeOf(arriving).alpha).toBeCloseTo(0.4, 5); | |
| 262 | expect(edgeOf(solid).alpha).toBe(1); | |
| 263 | // And smaller: it grows from its feet as it comes in. | |
| 264 | expect(Number(edgeOf(arriving).args[2])).toBeLessThan(Number(edgeOf(solid).args[2])); | |
| 265 | expect(Number(edgeOf(arriving).args[2])).toBeGreaterThan(Number(edgeOf(solid).args[2]) * 0.7); | |
| 266 | }); | |
| 267 | ||
| 268 | test("a body the presence store has only just seen is the one being faded", () => { | |
| 269 | const seen = makePresence(); | |
| 270 | const them = body(); | |
| 271 | sighted(seen, [them], 100); | |
| 272 | const draw = (at: number) => { | |
| 273 | const ctx = fakeContext(); | |
| 274 | drawScene(ctx as never, makeScene(), world, eye, snapshot([them]), { | |
| 275 | ...view, | |
| 276 | shown: (p: { id: string }) => shown(seen, p.id, at), | |
| 277 | }); | |
| 278 | return ctx.ops.find((o) => o.op === "strokeRect")!; | |
| 279 | }; | |
| 280 | expect(draw(100.03).alpha).toBeGreaterThan(0); | |
| 281 | expect(draw(100.03).alpha).toBeLessThan(1); | |
| 282 | expect(draw(100.5).alpha).toBe(1); | |
| 283 | }); | |
| 284 | ||
| 285 | test("a last-seen mark is an outline, dimming, and never a body", () => { | |
| 286 | const marks = [{ id: "them", x: 280, y: 200, angle: 0, mate: false, fade: 0.6 }]; | |
| 287 | const ctx = fakeContext(); | |
| 288 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks }); | |
| 289 | const edge = ctx.ops.find((o) => o.op === "strokeRect"); | |
| 290 | expect(edge).toBeDefined(); | |
| 291 | expect(edge!.alpha).toBeCloseTo(0.6, 5); | |
| 292 | // No head, no chevron, no name: a memory of a sighting, not a sighting. | |
| 293 | expect(ctx.ops.some((o) => o.op === "arc")).toBe(false); | |
| 294 | expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 295 | expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 296 | expect(GHOST).toBeGreaterThan(0); | |
| 297 | }); | |
| 298 | ||
| 299 | test("a body that steps out of sight leaves the outline the store hands over", () => { | |
| 300 | const seen = makePresence(); | |
| 301 | const them = body(); | |
| 302 | sighted(seen, [them], 100); // in sight, in the open | |
| 303 | sighted(seen, [], 100.1); // and gone behind the corner | |
| 304 | const ctx = fakeContext(); | |
| 305 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { | |
| 306 | ...view, | |
| 307 | marks: ghosts(seen, 100.1), | |
| 308 | }); | |
| 309 | // Nobody is in the snapshot at all, and there is still something there. | |
| 310 | const edge = ctx.ops.find((o) => o.op === "strokeRect"); | |
| 311 | expect(edge).toBeDefined(); | |
| 312 | expect(edge!.alpha).toBeCloseTo(1, 2); | |
| 313 | ||
| 314 | // A second later the store hands over nothing, and nothing is drawn. | |
| 315 | const after = fakeContext(); | |
| 316 | drawScene(after as never, makeScene(), world, eye, snapshot([]), { | |
| 317 | ...view, | |
| 318 | marks: ghosts(seen, 100.1 + GHOST), | |
| 319 | }); | |
| 320 | expect(after.ops.some((o) => o.op === "strokeRect")).toBe(false); | |
| 321 | }); | |
| 322 | ||
| 323 | test("a mark where the wall now stands between you and it is not drawn", () => { | |
| 324 | const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }]; | |
| 325 | const ctx = fakeContext(); | |
| 326 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { ...view, marks }); | |
| 327 | expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(false); | |
| 328 | }); | |
| 329 | ||
| 330 | 182 | test("on the map a last-seen ring is hollow, and dims with the mark", () => { |
| 331 | 183 | const map = { rows: gridRows(), tile: TILE }; |
| 332 | 184 | const box = { x: 0, y: 0, w: 900, h: 570 }; |
src/server.ts +2 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * val — the door. Routing, sessions, sockets and the security floor; the |
| 3 | * game itself is in src/match.ts and the live one in src/arena.ts. | |
| 3 | * game itself is in src/match.ts, the live one in src/arena.ts, and the | |
| 4 | * machinery that serves any game in src/engine/. | |
| 4 | 5 | * |
| 5 | 6 | * A seat belongs to a session cookie, so the same browser gets the same |
| 6 | 7 | * player back across a reload, and a socket is only ever given a seat the |
src/state.ts +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | * can both speak about a player without importing each other. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import type { Track } from "./aim.ts"; | |
| 6 | import type { Track } from "./engine/aim.ts"; | |
| 7 | 7 | import type { Point, Site } from "./map.ts"; |
| 8 | 8 | import type { WeaponId } from "./weapons.ts"; |
| 9 | 9 |
static/budget.js → static/engine/budget.js renamed
No content changes.
static/gun.js → static/engine/gun.js renamed
No content changes.
static/interp.js → static/engine/interp.js renamed
No content changes.
static/predict.js → static/engine/predict.js renamed +1 −1
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | * drawing optimisation and nothing here is ever sent anywhere. |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | import { circleBlocked, slideCircle } from "../src/glide.ts"; | |
| 18 | import { circleBlocked, slideCircle } from "../../src/engine/glide.ts"; | |
| 19 | 19 | |
| 20 | 20 | /** Past this much disagreement the server simply wins: a spawn, a teleport. */ |
| 21 | 21 | export const SNAP = 40; |
static/presence.js → static/engine/presence.js renamed +1 −2
| @@ -74,7 +74,6 @@ function remember(it, body, seq) { | ||
| 74 | 74 | it.angle = body.angle; |
| 75 | 75 | it.alive = body.alive; |
| 76 | 76 | it.mate = body.mate; |
| 77 | it.agent = body.agent; | |
| 78 | 77 | } |
| 79 | 78 | |
| 80 | 79 | /** |
| @@ -125,7 +124,7 @@ export function ghosts(presence, now) { | ||
| 125 | 124 | presence.last.delete(id); |
| 126 | 125 | continue; |
| 127 | 126 | } |
| 128 | out.push({ id, x: it.x, y: it.y, angle: it.angle, agent: it.agent, mate: it.mate, fade: left }); | |
| 127 | out.push({ id, x: it.x, y: it.y, angle: it.angle, mate: it.mate, fade: left }); | |
| 129 | 128 | } |
| 130 | 129 | return out; |
| 131 | 130 | } |
static/raycast.js → static/engine/raycast.js renamed
No content changes.
static/resolution.js → static/engine/resolution.js renamed
No content changes.
static/engine/scene.js added +424
| @@ -0,0 +1,424 @@ | ||
| 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 game's own props, tracers — is a billboard | |
| 6 | * tested against that buffer, so a body behind a corner is not drawn at all. | |
| 7 | * Fog is measured along each ray, not painted as a disc: cross one and | |
| 8 | * everything past it fogs. | |
| 9 | * | |
| 10 | * The ray loop allocates nothing and draws nothing: it writes a shade level, | |
| 11 | * a face and a slice into the column arrays, and `slices.js` collapses those | |
| 12 | * into runs so a flat wall costs one `fillRect` and not one a column. | |
| 13 | * Colours come out of a table built once, the depth and column buffers are | |
| 14 | * kept between frames, and the sprite list is sorted in a buffer the scene | |
| 15 | * owns. | |
| 16 | * | |
| 17 | * Nothing here knows what the game is. What a body is and how it hides is | |
| 18 | * generic; what stands on the floor besides bodies, and what fogs a ray, the | |
| 19 | * game says through `view` — see `drawScene`. | |
| 20 | */ | |
| 21 | ||
| 22 | import { swell } from "./presence.js"; | |
| 23 | import { makeColumns, SKY, wallRuns } from "./slices.js"; | |
| 24 | import { | |
| 25 | castRay, | |
| 26 | columnOffset, | |
| 27 | hitResult, | |
| 28 | project, | |
| 29 | projection, | |
| 30 | projectResult, | |
| 31 | sliceHeight, | |
| 32 | smokeAlong, | |
| 33 | spriteColumnVisible, | |
| 34 | wallLight, | |
| 35 | } from "./raycast.js"; | |
| 36 | ||
| 37 | export const EYE = 40; // half a tile up: the horizon sits on the middle of the wall | |
| 38 | export const WALL = 80; // walls are one tile tall | |
| 39 | const BODY = 58; // a person, in world units | |
| 40 | const MAX_SIGHT = 1800; | |
| 41 | const LEVELS = 48; | |
| 42 | ||
| 43 | // Wall colours, quantised into levels and built once, so the ray loop never | |
| 44 | // builds a string. How dark a slice is comes from `wallLight`, which already | |
| 45 | // knows about distance and which face was struck. | |
| 46 | const SHADES = (() => { | |
| 47 | const out = []; | |
| 48 | for (let i = 0; i < LEVELS; i++) { | |
| 49 | const k = (i + 0.5) / LEVELS; | |
| 50 | out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`); | |
| 51 | } | |
| 52 | return out; | |
| 53 | })(); | |
| 54 | const FOG = "rgb(198,203,212)"; | |
| 55 | ||
| 56 | const NO_FOG = () => {}; | |
| 57 | const NO_PROPS = () => {}; | |
| 58 | const PLAIN = () => "#999"; | |
| 59 | const NO_BADGE = () => null; | |
| 60 | ||
| 61 | export function makeScene() { | |
| 62 | return { | |
| 63 | depth: new Float32Array(0), | |
| 64 | cols: makeColumns(0), | |
| 65 | runs: [], | |
| 66 | hit: hitResult(), | |
| 67 | proj: projectResult(), | |
| 68 | order: [], | |
| 69 | smokes: [], | |
| 70 | sky: null, | |
| 71 | skyKey: "", | |
| 72 | }; | |
| 73 | } | |
| 74 | ||
| 75 | const levelOf = (light) => Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0)); | |
| 76 | ||
| 77 | /** | |
| 78 | * The walls, a rectangle a run rather than a rectangle a column. The runs | |
| 79 | * are stretches where the shade level, the face, the fog and the geometry | |
| 80 | * all agree — see `slices.js` — so this is the same picture the per-column | |
| 81 | * loop drew, in a fraction of the calls. | |
| 82 | */ | |
| 83 | function drawWalls(ctx, scene, step) { | |
| 84 | const runs = scene.runs; | |
| 85 | const used = wallRuns(scene.cols, runs); | |
| 86 | for (let r = 0; r < used; r++) { | |
| 87 | const run = runs[r]; | |
| 88 | const x = run.from * step; | |
| 89 | const wide = (run.to - run.from + 1) * step + 1; | |
| 90 | ctx.fillStyle = SHADES[run.level]; | |
| 91 | ctx.fillRect(x, run.top, wide, run.high); | |
| 92 | if (run.haze > 0.01) { | |
| 93 | ctx.globalAlpha = run.haze; | |
| 94 | ctx.fillStyle = FOG; | |
| 95 | ctx.fillRect(x, run.top, wide, run.high); | |
| 96 | ctx.globalAlpha = 1; | |
| 97 | } | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Draw one frame. | |
| 103 | * | |
| 104 | * `world` carries the map (`solid`, `tile`), `eye` is where the camera | |
| 105 | * stands and looks, `snap` is the last snapshot the server sent — it must | |
| 106 | * carry `me`, `players` and `shots` — and `view` is the frame: its pixel | |
| 107 | * size, `look(player)` for the smoothed position to draw somebody at, and | |
| 108 | * the game's own holes in the picture, each optional: | |
| 109 | * | |
| 110 | * - `fog(snap, out)` pushes the discs `{x, y, r}` a ray fogs through. | |
| 111 | * - `props(snap, out)` pushes what stands on the floor besides bodies, each | |
| 112 | * `{x, y, draw}`; `draw(ctx, thing, floorY, proj, clip)` is handed the | |
| 113 | * projected thing (`column`, `perp`, `size`) and a `clip(thing, left, | |
| 114 | * right, fn)` that runs `fn` only where the thing beats the wall. | |
| 115 | * - `trim(player)` is the colour of the stripe across a body. | |
| 116 | * - `badge(player)` is a colour to mark a body with, or null. | |
| 117 | */ | |
| 118 | export function drawScene(ctx, scene, world, eye, snap, view) { | |
| 119 | const W = view.width; | |
| 120 | const H = view.height; | |
| 121 | // One ray every other column of the buffer, and the wall slice two columns | |
| 122 | // wide: flat shading does not reward a ray a pixel, and the sprites are | |
| 123 | // still drawn at the buffer's own resolution. Below that width a picture | |
| 124 | // this coarse stops being one. | |
| 125 | const step = W >= 640 ? 2 : 1; | |
| 126 | const columns = Math.ceil(W / step); | |
| 127 | if (scene.depth.length !== columns) { | |
| 128 | scene.depth = new Float32Array(columns); | |
| 129 | scene.cols = makeColumns(columns); | |
| 130 | } | |
| 131 | const proj = projection(W); | |
| 132 | const horizon = H / 2; | |
| 133 | ||
| 134 | drawSky(ctx, scene, W, H, horizon); | |
| 135 | ||
| 136 | const smokes = scene.smokes; | |
| 137 | smokes.length = 0; | |
| 138 | (view.fog || NO_FOG)(snap, smokes); | |
| 139 | ||
| 140 | const depth = scene.depth; | |
| 141 | const cols = scene.cols; | |
| 142 | const fog = cols.haze; | |
| 143 | const hit = scene.hit; | |
| 144 | for (let i = 0; i < columns; i++) { | |
| 145 | const offset = columnOffset(i * step + step / 2, W); | |
| 146 | const angle = eye.angle + offset; | |
| 147 | castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit); | |
| 148 | const perp = Math.max(1, hit.dist * Math.cos(offset)); | |
| 149 | depth[i] = perp; | |
| 150 | let haze = 0; | |
| 151 | if (smokes.length) { | |
| 152 | const dx = Math.cos(angle); | |
| 153 | const dy = Math.sin(angle); | |
| 154 | for (let s = 0; s < smokes.length; s++) { | |
| 155 | const z = smokes[s]; | |
| 156 | haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist); | |
| 157 | if (haze >= 1) break; | |
| 158 | } | |
| 159 | haze = Math.min(1, haze); | |
| 160 | } | |
| 161 | fog[i] = haze; | |
| 162 | if (!hit.wall) { | |
| 163 | cols.level[i] = SKY; | |
| 164 | continue; | |
| 165 | } | |
| 166 | const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); | |
| 167 | cols.level[i] = levelOf(wallLight(perp, hit.side, world.tile)); | |
| 168 | cols.side[i] = hit.side; | |
| 169 | cols.top[i] = horizon - h / 2; | |
| 170 | cols.high[i] = h; | |
| 171 | } | |
| 172 | drawWalls(ctx, scene, step); | |
| 173 | ||
| 174 | drawThings(ctx, scene, eye, snap, view, proj, horizon, step, columns); | |
| 175 | } | |
| 176 | ||
| 177 | /** The two bands: a dark ceiling above the horizon, a warmer floor below. */ | |
| 178 | function drawSky(ctx, scene, W, H, horizon) { | |
| 179 | const key = `${W}x${H}`; | |
| 180 | if (scene.skyKey !== key || !scene.sky) { | |
| 181 | const sky = ctx.createLinearGradient(0, 0, 0, H); | |
| 182 | sky.addColorStop(0, "#0a0c11"); | |
| 183 | sky.addColorStop(0.49, "#23262f"); | |
| 184 | sky.addColorStop(0.5, "#3a3026"); | |
| 185 | sky.addColorStop(1, "#15120e"); | |
| 186 | scene.sky = sky; | |
| 187 | scene.skyKey = key; | |
| 188 | } | |
| 189 | ctx.fillStyle = scene.sky; | |
| 190 | ctx.fillRect(0, 0, W, H); | |
| 191 | ctx.fillStyle = "rgba(255,255,255,0.09)"; | |
| 192 | ctx.fillRect(0, horizon - 1, W, 2); | |
| 193 | } | |
| 194 | ||
| 195 | /** | |
| 196 | * Everything that is not a wall, far to near, each one clipped to the screen | |
| 197 | * columns where it beats the wall behind it. The game's props go in first, | |
| 198 | * then bodies, then marks: a tie in depth keeps that order. | |
| 199 | */ | |
| 200 | function drawThings(ctx, scene, eye, snap, view, proj, horizon, step, columns) { | |
| 201 | const W = view.width; | |
| 202 | const things = scene.order; | |
| 203 | things.length = 0; | |
| 204 | ||
| 205 | (view.props || NO_PROPS)(snap, things); | |
| 206 | const trim = view.trim || PLAIN; | |
| 207 | const badge = view.badge || NO_BADGE; | |
| 208 | for (const player of snap.players) { | |
| 209 | if (player.id === snap.me.id) continue; | |
| 210 | const at = view.look(player); | |
| 211 | // How far into sight this body is: 0 the moment it appears, 1 solid. | |
| 212 | // See presence.js — a body that pops into existence reads as a bug. | |
| 213 | const shown = view.shown ? view.shown(player) : 1; | |
| 214 | things.push({ kind: "body", player, shown, x: at.x, y: at.y, angle: at.angle }); | |
| 215 | } | |
| 216 | // Where somebody was last seen, dimming away. It stands where they were, | |
| 217 | // so a wall between you and that spot hides it like anything else. | |
| 218 | if (view.marks) for (const mark of view.marks) things.push({ kind: "mark", mark, x: mark.x, y: mark.y }); | |
| 219 | ||
| 220 | const p = scene.proj; | |
| 221 | for (const thing of things) { | |
| 222 | project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p); | |
| 223 | thing.perp = p.perp; | |
| 224 | thing.column = p.column; | |
| 225 | thing.size = p.size; | |
| 226 | thing.behind = p.behind; | |
| 227 | } | |
| 228 | things.sort((a, b) => b.perp - a.perp); | |
| 229 | ||
| 230 | const clip = (thing, left, right, fn) => withRuns(ctx, scene, left, right, thing.perp, step, columns, fn); | |
| 231 | for (const thing of things) { | |
| 232 | if (thing.behind) continue; | |
| 233 | const floorY = horizon + (EYE * proj) / thing.perp; | |
| 234 | const haze = hazeAt(scene, thing.column, step, columns); | |
| 235 | const arriving = thing.kind === "body" ? thing.shown : thing.kind === "mark" ? thing.mark.fade : 1; | |
| 236 | ctx.globalAlpha = Math.max(0, 1 - haze) * arriving; | |
| 237 | if (ctx.globalAlpha < 0.05) continue; | |
| 238 | if (thing.kind === "body") drawBody(ctx, thing, floorY, clip, trim, badge); | |
| 239 | else if (thing.kind === "mark") drawMark(ctx, thing, floorY, clip); | |
| 240 | else thing.draw(ctx, thing, floorY, proj, clip); | |
| 241 | ctx.globalAlpha = 1; | |
| 242 | } | |
| 243 | ctx.globalAlpha = 1; | |
| 244 | ||
| 245 | drawTracers(ctx, scene, eye, snap, W, proj, horizon); | |
| 246 | } | |
| 247 | ||
| 248 | const hazeAt = (scene, column, step, columns) => { | |
| 249 | const i = Math.max(0, Math.min(columns - 1, Math.round(column / step))); | |
| 250 | return scene.cols.haze[i] ?? 0; | |
| 251 | }; | |
| 252 | ||
| 253 | /** | |
| 254 | * The visible runs of a sprite: the stretches of screen column where it is | |
| 255 | * nearer than the wall. Two rectangles at most in practice, and the caller | |
| 256 | * clips its drawing to each. | |
| 257 | */ | |
| 258 | function withRuns(ctx, scene, left, right, perp, step, columns, draw) { | |
| 259 | let runStart = -1; | |
| 260 | const from = Math.max(0, Math.floor(left / step)); | |
| 261 | const to = Math.min(columns - 1, Math.ceil(right / step)); | |
| 262 | for (let i = from; i <= to + 1; i++) { | |
| 263 | const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]); | |
| 264 | if (shown && runStart < 0) runStart = i; | |
| 265 | if (!shown && runStart >= 0) { | |
| 266 | ctx.save(); | |
| 267 | ctx.beginPath(); | |
| 268 | ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5); | |
| 269 | ctx.clip(); | |
| 270 | draw(); | |
| 271 | ctx.restore(); | |
| 272 | runStart = -1; | |
| 273 | } | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | function drawBody(ctx, thing, floorY, clip, trim, badge) { | |
| 278 | const p = thing.player; | |
| 279 | // A body arriving grows from its feet as it fades in, so it reads as | |
| 280 | // somebody coming round the corner rather than one appearing there. | |
| 281 | const h = thing.size * swell(thing.shown ?? 1); | |
| 282 | const w = h * 0.46; | |
| 283 | const cx = thing.column; | |
| 284 | const top = floorY - h; | |
| 285 | const mate = p.mate; | |
| 286 | ||
| 287 | // The dead lie down. No tag, no chevron, no trim colour: if something is | |
| 288 | // upright with a marker over it, it is alive and it can shoot you. | |
| 289 | if (!p.alive) { | |
| 290 | const lie = h * 0.16; | |
| 291 | clip(thing, cx - w * 1.1, cx + w * 1.1, () => { | |
| 292 | ctx.fillStyle = "#4a4e57"; | |
| 293 | ctx.beginPath(); | |
| 294 | ctx.ellipse(cx, floorY - lie * 0.4, w * 1.05, lie, 0, 0, 7); | |
| 295 | ctx.fill(); | |
| 296 | ctx.strokeStyle = "#23262d"; | |
| 297 | ctx.lineWidth = Math.max(1, h * 0.02); | |
| 298 | ctx.stroke(); | |
| 299 | }); | |
| 300 | return; | |
| 301 | } | |
| 302 | ||
| 303 | const skin = mate ? "#3f9fd6" : "#d8483f"; | |
| 304 | const stripe = trim(p); | |
| 305 | const mark = badge(p); | |
| 306 | const edge = Math.max(1.2, h * 0.05); | |
| 307 | ||
| 308 | clip(thing, cx - w * 1.2, cx + w * 1.5, () => { | |
| 309 | // Body and head, each with a hard dark edge: the silhouette is what | |
| 310 | // carries across a dim room, where the colour does not. | |
| 311 | ctx.fillStyle = skin; | |
| 312 | ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 313 | ctx.strokeStyle = "#0b0d11"; | |
| 314 | ctx.lineWidth = edge; | |
| 315 | ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 316 | ctx.fillStyle = stripe; | |
| 317 | ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1); | |
| 318 | ctx.beginPath(); | |
| 319 | ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7); | |
| 320 | ctx.fillStyle = skin; | |
| 321 | ctx.fill(); | |
| 322 | ctx.strokeStyle = "#0b0d11"; | |
| 323 | ctx.stroke(); | |
| 324 | ||
| 325 | if (mark) { | |
| 326 | ctx.fillStyle = mark; | |
| 327 | ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3); | |
| 328 | } | |
| 329 | if (p.hp < 100) { | |
| 330 | ctx.fillStyle = "#000a"; | |
| 331 | ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06); | |
| 332 | ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a"; | |
| 333 | ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06); | |
| 334 | } | |
| 335 | ||
| 336 | if (mate) tag(ctx, p, cx, top, h); | |
| 337 | else chevron(ctx, cx, top, h); | |
| 338 | }); | |
| 339 | } | |
| 340 | ||
| 341 | /** | |
| 342 | * A mark where somebody was last seen: an empty outline and a scuff on the | |
| 343 | * floor, dimming away over a second. Deliberately not a body — nothing is | |
| 344 | * filled, there is no head and no chevron — because it is a memory of a | |
| 345 | * sighting and must never be mistaken for one. | |
| 346 | */ | |
| 347 | function drawMark(ctx, thing, floorY, clip) { | |
| 348 | const h = thing.size * 0.9; | |
| 349 | const w = h * 0.46; | |
| 350 | const cx = thing.column; | |
| 351 | const top = floorY - h; | |
| 352 | clip(thing, cx - w * 1.2, cx + w * 1.2, () => { | |
| 353 | ctx.strokeStyle = thing.mark.mate ? "rgba(120,180,230,0.85)" : "rgba(235,150,120,0.9)"; | |
| 354 | ctx.lineWidth = Math.max(1, h * 0.035); | |
| 355 | ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 356 | ctx.fillStyle = "rgba(10,12,16,0.45)"; | |
| 357 | ctx.fillRect(cx - w * 0.7, floorY - h * 0.04, w * 1.4, h * 0.05); | |
| 358 | }); | |
| 359 | } | |
| 360 | ||
| 361 | /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read | |
| 362 | * red and blue the same way. */ | |
| 363 | function chevron(ctx, cx, top, h) { | |
| 364 | const size = Math.max(4, h * 0.16); | |
| 365 | const y = top - h * 0.16; | |
| 366 | ctx.beginPath(); | |
| 367 | ctx.moveTo(cx - size, y - size); | |
| 368 | ctx.lineTo(cx + size, y - size); | |
| 369 | ctx.lineTo(cx, y); | |
| 370 | ctx.closePath(); | |
| 371 | ctx.fillStyle = "#ff6f5e"; | |
| 372 | ctx.fill(); | |
| 373 | ctx.lineWidth = Math.max(1, h * 0.02); | |
| 374 | ctx.strokeStyle = "#140a08"; | |
| 375 | ctx.stroke(); | |
| 376 | } | |
| 377 | ||
| 378 | /** A team mate wears their name and a pip of their health — never an enemy. */ | |
| 379 | function tag(ctx, p, cx, top, h) { | |
| 380 | const size = Math.max(9, Math.min(18, h * 0.16)); | |
| 381 | const y = top - h * 0.34; | |
| 382 | ctx.font = `${size}px system-ui, sans-serif`; | |
| 383 | ctx.textAlign = "center"; | |
| 384 | const width = ctx.measureText(p.name).width + size * 1.4; | |
| 385 | ctx.fillStyle = "rgba(10,12,16,0.65)"; | |
| 386 | ctx.fillRect(cx - width / 2, y, width, size * 1.4); | |
| 387 | ctx.fillStyle = p.hp > 60 ? "#6fcf6f" : p.hp > 25 ? "#e0a040" : "#e05a4a"; | |
| 388 | ctx.fillRect(cx - width / 2 + size * 0.25, y + size * 0.35, size * 0.45, size * 0.7); | |
| 389 | ctx.fillStyle = "#cfe8ff"; | |
| 390 | ctx.fillText(p.name, cx + size * 0.4, y + size * 1.05); | |
| 391 | ctx.textAlign = "left"; | |
| 392 | } | |
| 393 | ||
| 394 | /** A round thing on the floor, for a game to draw its pickups with. */ | |
| 395 | export function drawBlob(ctx, thing, floorY, colour, scale, clip) { | |
| 396 | const r = thing.size * scale; | |
| 397 | const cx = thing.column; | |
| 398 | clip(thing, cx - r, cx + r, () => { | |
| 399 | ctx.fillStyle = colour; | |
| 400 | ctx.beginPath(); | |
| 401 | ctx.arc(cx, floorY - r, r, 0, 7); | |
| 402 | ctx.fill(); | |
| 403 | }); | |
| 404 | } | |
| 405 | ||
| 406 | /** Shots the server let us see, as a line through the world. */ | |
| 407 | function drawTracers(ctx, scene, eye, snap, W, proj, horizon) { | |
| 408 | if (!snap.shots.length) return; | |
| 409 | const p = scene.proj; | |
| 410 | ctx.lineWidth = 2; | |
| 411 | for (const shot of snap.shots) { | |
| 412 | project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p); | |
| 413 | if (p.behind) continue; | |
| 414 | const x1 = p.column; | |
| 415 | const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6; | |
| 416 | project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p); | |
| 417 | if (p.behind) continue; | |
| 418 | ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)"; | |
| 419 | ctx.beginPath(); | |
| 420 | ctx.moveTo(x1, y1); | |
| 421 | ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6); | |
| 422 | ctx.stroke(); | |
| 423 | } | |
| 424 | } |
static/slices.js → static/engine/slices.js renamed
No content changes.
static/steer.js → static/engine/steer.js renamed
No content changes.
static/engine/topdown.js added +206
| @@ -0,0 +1,206 @@ | ||
| 1 | /** | |
| 2 | * The board from above. | |
| 3 | * | |
| 4 | * It is three things: 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 | * Nothing here knows what the game is. The tiles, the shots the server let | |
| 9 | * us see, the last-seen marks and the bodies are generic; what a letter on | |
| 10 | * the grid is coloured, what else lies on the floor and what a body wears | |
| 11 | * for a badge, the game says through `opts` — see `drawTopdown`. | |
| 12 | */ | |
| 13 | ||
| 14 | const WALL_COLOUR = "#20242d"; | |
| 15 | const FLOOR_COLOUR = "#171a21"; | |
| 16 | ||
| 17 | const plainTile = (cell) => (cell === "#" ? WALL_COLOUR : FLOOR_COLOUR); | |
| 18 | const never = () => false; | |
| 19 | const nothing = () => {}; | |
| 20 | const grey = () => "#888"; | |
| 21 | ||
| 22 | /** | |
| 23 | * Draw the whole map into `box` (canvas pixels). `look(player)` gives the | |
| 24 | * smoothed position; `opts.labels` draws the grid's letters and names, which | |
| 25 | * only fit on the big version; `opts.marks` are the last-seen rings from | |
| 26 | * `presence.js`, which answer "where did they go" without claiming anybody | |
| 27 | * is there now; `opts.tiles: false` leaves out the map itself, for a caller | |
| 28 | * that has already drawn it once and kept it. | |
| 29 | * | |
| 30 | * The game's holes, each optional: `opts.tile(cell)` colours a letter, | |
| 31 | * `opts.label(cell)` says whether the letter is written on the big map, | |
| 32 | * `opts.under(ctx, snap, s)` draws what lies on the floor beneath the bodies, | |
| 33 | * `opts.over(ctx, snap, s)` what hides them from above, `opts.trim(player)` | |
| 34 | * is the ring round a body and `opts.badges(ctx, player, x, y, r)` whatever it | |
| 35 | * wears. `s` is world units to canvas pixels. | |
| 36 | * | |
| 37 | * Called with no snapshot it draws the tiles and stops, which is exactly | |
| 38 | * that layer. | |
| 39 | */ | |
| 40 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | |
| 41 | const cols = map.rows[0].length; | |
| 42 | const s = box.w / (cols * map.tile); | |
| 43 | const labels = opts.labels !== false; | |
| 44 | const tiles = opts.tiles !== false; | |
| 45 | const tileColour = opts.tile || plainTile; | |
| 46 | const label = opts.label || never; | |
| 47 | ctx.save(); | |
| 48 | ctx.beginPath(); | |
| 49 | ctx.rect(box.x, box.y, box.w, box.h); | |
| 50 | ctx.clip(); | |
| 51 | ctx.translate(box.x, box.y); | |
| 52 | ||
| 53 | const t = map.tile * s; | |
| 54 | if (tiles) | |
| 55 | for (let row = 0; row < map.rows.length; row++) | |
| 56 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 57 | const cell = map.rows[row][col]; | |
| 58 | ctx.fillStyle = tileColour(cell); | |
| 59 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 60 | if (labels && label(cell)) { | |
| 61 | ctx.fillStyle = "#6b5f3a"; | |
| 62 | ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; | |
| 63 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | if (!snap) { | |
| 68 | ctx.restore(); | |
| 69 | return; | |
| 70 | } | |
| 71 | ||
| 72 | (opts.under || nothing)(ctx, snap, s); | |
| 73 | ||
| 74 | for (const shot of snap.shots) { | |
| 75 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.5)"; | |
| 76 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 77 | ctx.beginPath(); | |
| 78 | ctx.moveTo(shot.x1 * s, shot.y1 * s); | |
| 79 | ctx.lineTo(shot.x2 * s, shot.y2 * s); | |
| 80 | ctx.stroke(); | |
| 81 | } | |
| 82 | ||
| 83 | // Under the living: a ring where somebody was last seen is never allowed | |
| 84 | // to sit on top of a body that is actually in sight. | |
| 85 | if (opts.marks) for (const mark of opts.marks) drawMark(ctx, mark, s); | |
| 86 | const trim = opts.trim || grey; | |
| 87 | const badges = opts.badges || nothing; | |
| 88 | for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels, trim, badges); | |
| 89 | (opts.over || nothing)(ctx, snap, s); | |
| 90 | ctx.restore(); | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Where somebody was last seen: a hollow ring and the way they were facing, | |
| 95 | * dimming away. Hollow because a filled dot on this map means a body, and | |
| 96 | * this is a memory of one. | |
| 97 | */ | |
| 98 | function drawMark(ctx, m, s) { | |
| 99 | const x = m.x * s; | |
| 100 | const y = m.y * s; | |
| 101 | const r = Math.max(2.5, 16 * s); | |
| 102 | ctx.save(); | |
| 103 | ctx.globalAlpha = 0.8 * m.fade; | |
| 104 | ctx.strokeStyle = m.mate ? "#4fb3d9" : "#e35d5b"; | |
| 105 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 106 | disc(ctx, x, y, r, false, true); | |
| 107 | ctx.beginPath(); | |
| 108 | ctx.moveTo(x, y); | |
| 109 | ctx.lineTo(x + Math.cos(m.angle) * r * 1.7, y + Math.sin(m.angle) * r * 1.7); | |
| 110 | ctx.stroke(); | |
| 111 | ctx.restore(); | |
| 112 | } | |
| 113 | ||
| 114 | export function disc(ctx, x, y, r, fill, stroke) { | |
| 115 | ctx.beginPath(); | |
| 116 | ctx.arc(x, y, r, 0, 7); | |
| 117 | if (fill) ctx.fill(); | |
| 118 | if (stroke) ctx.stroke(); | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * Everybody is a dot and you are an arrow — but a team mate is on the map | |
| 123 | * whether you can see them or not, with their initial in the dot and a cross | |
| 124 | * when they are down, and an enemy is only ever drawn while they are alive | |
| 125 | * and in sight. The map stays honest about vision and legible about your own. | |
| 126 | */ | |
| 127 | function drawPlayer(ctx, p, pos, s, me, labels, trim, badges) { | |
| 128 | const mine = p.id === me.id; | |
| 129 | if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out | |
| 130 | const x = pos.x * s; | |
| 131 | const y = pos.y * s; | |
| 132 | const r = Math.max(2.5, 16 * s); | |
| 133 | const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; | |
| 134 | ||
| 135 | if (mine && p.alive) { | |
| 136 | ctx.save(); | |
| 137 | ctx.translate(x, y); | |
| 138 | ctx.rotate(pos.angle); | |
| 139 | ctx.fillStyle = body; | |
| 140 | ctx.beginPath(); | |
| 141 | ctx.moveTo(r * 1.5, 0); | |
| 142 | ctx.lineTo(-r, r * 0.9); | |
| 143 | ctx.lineTo(-r * 0.4, 0); | |
| 144 | ctx.lineTo(-r, -r * 0.9); | |
| 145 | ctx.closePath(); | |
| 146 | ctx.fill(); | |
| 147 | ctx.restore(); | |
| 148 | return; | |
| 149 | } | |
| 150 | ||
| 151 | // The dead of your own team keep their place on the map, crossed out. | |
| 152 | if (!p.alive) { | |
| 153 | ctx.strokeStyle = "#6a707c"; | |
| 154 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 155 | ctx.beginPath(); | |
| 156 | ctx.moveTo(x - r * 0.8, y - r * 0.8); | |
| 157 | ctx.lineTo(x + r * 0.8, y + r * 0.8); | |
| 158 | ctx.moveTo(x + r * 0.8, y - r * 0.8); | |
| 159 | ctx.lineTo(x - r * 0.8, y + r * 0.8); | |
| 160 | ctx.stroke(); | |
| 161 | if (labels) { | |
| 162 | ctx.fillStyle = "rgba(180,185,195,0.6)"; | |
| 163 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; | |
| 164 | ctx.textAlign = "center"; | |
| 165 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 166 | ctx.textAlign = "left"; | |
| 167 | } | |
| 168 | return; | |
| 169 | } | |
| 170 | ||
| 171 | ctx.fillStyle = body; | |
| 172 | ctx.beginPath(); | |
| 173 | ctx.arc(x, y, r, 0, 7); | |
| 174 | ctx.fill(); | |
| 175 | ctx.strokeStyle = trim(p); | |
| 176 | ctx.lineWidth = Math.max(1, 3 * s); | |
| 177 | ctx.stroke(); | |
| 178 | ctx.strokeStyle = body; | |
| 179 | ctx.beginPath(); | |
| 180 | ctx.moveTo(x, y); | |
| 181 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 182 | ctx.stroke(); | |
| 183 | ||
| 184 | // The initial inside the dot, when the dot is big enough to hold one. | |
| 185 | if (p.mate && r >= 6) { | |
| 186 | ctx.fillStyle = "#0c1016"; | |
| 187 | ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`; | |
| 188 | ctx.textAlign = "center"; | |
| 189 | ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45); | |
| 190 | ctx.textAlign = "left"; | |
| 191 | } | |
| 192 | badges(ctx, p, x, y, r); | |
| 193 | if (labels) { | |
| 194 | ctx.fillStyle = "rgba(235,235,235,0.75)"; | |
| 195 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; | |
| 196 | ctx.textAlign = "center"; | |
| 197 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 198 | ctx.textAlign = "left"; | |
| 199 | } | |
| 200 | if (p.hp < 100) { | |
| 201 | ctx.fillStyle = "#0008"; | |
| 202 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); | |
| 203 | ctx.fillStyle = "#6fcf6f"; | |
| 204 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); | |
| 205 | } | |
| 206 | } |
static/yaw.js → static/engine/yaw.js renamed
No content changes.
static/scene.js +25 −398
| @@ -1,40 +1,13 @@ | ||
| 1 | 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 and draws nothing: it writes a shade level, | |
| 11 | * a face and a slice into the column arrays, and `slices.js` collapses those | |
| 12 | * into runs so a flat wall costs one `fillRect` and not one a column. | |
| 13 | * Colours come out of a table built once, the depth and column buffers are | |
| 14 | * kept between frames, and the sprite list is sorted in a buffer the scene | |
| 15 | * owns. | |
| 2 | * This game's first-person picture: the engine's renderer (`engine/scene.js`) | |
| 3 | * with the Valorant things in it — the spike, the ult orbs, the ability | |
| 4 | * zones, an agent's colour on a body and the spike on the carrier's back. | |
| 5 | * Smoke and a wall fog the rays; nothing else does. | |
| 16 | 6 | */ |
| 17 | 7 | |
| 18 | import { swell } from "./presence.js"; | |
| 19 | import { makeColumns, SKY, wallRuns } from "./slices.js"; | |
| 20 | import { | |
| 21 | castRay, | |
| 22 | columnOffset, | |
| 23 | hitResult, | |
| 24 | project, | |
| 25 | projection, | |
| 26 | projectResult, | |
| 27 | sliceHeight, | |
| 28 | smokeAlong, | |
| 29 | spriteColumnVisible, | |
| 30 | wallLight, | |
| 31 | } from "./raycast.js"; | |
| 8 | import { drawBlob, drawScene as drawWorld, makeScene, WALL } from "./engine/scene.js"; | |
| 32 | 9 | |
| 33 | const EYE = 40; // half a tile up: the horizon sits on the middle of the wall | |
| 34 | const WALL = 80; // walls are one tile tall | |
| 35 | const BODY = 58; // a person, in world units | |
| 36 | const MAX_SIGHT = 1800; | |
| 37 | const LEVELS = 48; | |
| 10 | export { makeScene }; | |
| 38 | 11 | |
| 39 | 12 | export const AGENT_COLOUR = { |
| 40 | 13 | ember: "#f08a4b", |
| @@ -44,352 +17,35 @@ export const AGENT_COLOUR = { | ||
| 44 | 17 | rook: "#d96a8f", |
| 45 | 18 | }; |
| 46 | 19 | |
| 47 | // Wall colours, quantised into levels and built once, so the ray loop never | |
| 48 | // builds a string. How dark a slice is comes from `wallLight`, which already | |
| 49 | // knows about distance and which face was struck. | |
| 50 | const SHADES = (() => { | |
| 51 | const out = []; | |
| 52 | for (let i = 0; i < LEVELS; i++) { | |
| 53 | const k = (i + 0.5) / LEVELS; | |
| 54 | out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`); | |
| 55 | } | |
| 56 | return out; | |
| 57 | })(); | |
| 58 | const FOG = "rgb(198,203,212)"; | |
| 59 | ||
| 60 | export function makeScene() { | |
| 61 | return { | |
| 62 | depth: new Float32Array(0), | |
| 63 | cols: makeColumns(0), | |
| 64 | runs: [], | |
| 65 | hit: hitResult(), | |
| 66 | proj: projectResult(), | |
| 67 | order: [], | |
| 68 | smokes: [], | |
| 69 | sky: null, | |
| 70 | skyKey: "", | |
| 71 | }; | |
| 20 | /** What a ray fogs through: a smoke, or a wall an ability put up, once it has landed. */ | |
| 21 | function fog(snap, out) { | |
| 22 | for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) out.push(zone); | |
| 72 | 23 | } |
| 73 | 24 | |
| 74 | const levelOf = (light) => Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0)); | |
| 75 | ||
| 76 | /** | |
| 77 | * The walls, a rectangle a run rather than a rectangle a column. The runs | |
| 78 | * are stretches where the shade level, the face, the fog and the geometry | |
| 79 | * all agree — see `slices.js` — so this is the same picture the per-column | |
| 80 | * loop drew, in a fraction of the calls. | |
| 81 | */ | |
| 82 | function drawWalls(ctx, scene, step) { | |
| 83 | const runs = scene.runs; | |
| 84 | const used = wallRuns(scene.cols, runs); | |
| 85 | for (let r = 0; r < used; r++) { | |
| 86 | const run = runs[r]; | |
| 87 | const x = run.from * step; | |
| 88 | const wide = (run.to - run.from + 1) * step + 1; | |
| 89 | ctx.fillStyle = SHADES[run.level]; | |
| 90 | ctx.fillRect(x, run.top, wide, run.high); | |
| 91 | if (run.haze > 0.01) { | |
| 92 | ctx.globalAlpha = run.haze; | |
| 93 | ctx.fillStyle = FOG; | |
| 94 | ctx.fillRect(x, run.top, wide, run.high); | |
| 95 | ctx.globalAlpha = 1; | |
| 96 | } | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Draw one frame. | |
| 102 | * | |
| 103 | * `world` carries the map (`solid`, `tile`), `eye` is where the camera | |
| 104 | * stands and looks, `snap` is the last snapshot the server sent, and `view` | |
| 105 | * is the frame: its pixel size, and `look(player)` for the smoothed position | |
| 106 | * to draw somebody at. | |
| 107 | */ | |
| 108 | export function drawScene(ctx, scene, world, eye, snap, view) { | |
| 109 | const W = view.width; | |
| 110 | const H = view.height; | |
| 111 | // One ray every other column of the buffer, and the wall slice two columns | |
| 112 | // wide: flat shading does not reward a ray a pixel, and the sprites are | |
| 113 | // still drawn at the buffer's own resolution. Below that width a picture | |
| 114 | // this coarse stops being one. | |
| 115 | const step = W >= 640 ? 2 : 1; | |
| 116 | const columns = Math.ceil(W / step); | |
| 117 | if (scene.depth.length !== columns) { | |
| 118 | scene.depth = new Float32Array(columns); | |
| 119 | scene.cols = makeColumns(columns); | |
| 120 | } | |
| 121 | const proj = projection(W); | |
| 122 | const horizon = H / 2; | |
| 123 | ||
| 124 | drawSky(ctx, scene, W, H, horizon); | |
| 125 | ||
| 126 | const smokes = scene.smokes; | |
| 127 | smokes.length = 0; | |
| 128 | for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone); | |
| 129 | ||
| 130 | const depth = scene.depth; | |
| 131 | const cols = scene.cols; | |
| 132 | const fog = cols.haze; | |
| 133 | const hit = scene.hit; | |
| 134 | for (let i = 0; i < columns; i++) { | |
| 135 | const offset = columnOffset(i * step + step / 2, W); | |
| 136 | const angle = eye.angle + offset; | |
| 137 | castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit); | |
| 138 | const perp = Math.max(1, hit.dist * Math.cos(offset)); | |
| 139 | depth[i] = perp; | |
| 140 | let haze = 0; | |
| 141 | if (smokes.length) { | |
| 142 | const dx = Math.cos(angle); | |
| 143 | const dy = Math.sin(angle); | |
| 144 | for (let s = 0; s < smokes.length; s++) { | |
| 145 | const z = smokes[s]; | |
| 146 | haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist); | |
| 147 | if (haze >= 1) break; | |
| 148 | } | |
| 149 | haze = Math.min(1, haze); | |
| 150 | } | |
| 151 | fog[i] = haze; | |
| 152 | if (!hit.wall) { | |
| 153 | cols.level[i] = SKY; | |
| 154 | continue; | |
| 155 | } | |
| 156 | const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); | |
| 157 | cols.level[i] = levelOf(wallLight(perp, hit.side, world.tile)); | |
| 158 | cols.side[i] = hit.side; | |
| 159 | cols.top[i] = horizon - h / 2; | |
| 160 | cols.high[i] = h; | |
| 161 | } | |
| 162 | drawWalls(ctx, scene, step); | |
| 163 | ||
| 164 | drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns); | |
| 165 | } | |
| 166 | ||
| 167 | /** The two bands: a dark ceiling above the horizon, a warmer floor below. */ | |
| 168 | function drawSky(ctx, scene, W, H, horizon) { | |
| 169 | const key = `${W}x${H}`; | |
| 170 | if (scene.skyKey !== key || !scene.sky) { | |
| 171 | const sky = ctx.createLinearGradient(0, 0, 0, H); | |
| 172 | sky.addColorStop(0, "#0a0c11"); | |
| 173 | sky.addColorStop(0.49, "#23262f"); | |
| 174 | sky.addColorStop(0.5, "#3a3026"); | |
| 175 | sky.addColorStop(1, "#15120e"); | |
| 176 | scene.sky = sky; | |
| 177 | scene.skyKey = key; | |
| 178 | } | |
| 179 | ctx.fillStyle = scene.sky; | |
| 180 | ctx.fillRect(0, 0, W, H); | |
| 181 | ctx.fillStyle = "rgba(255,255,255,0.09)"; | |
| 182 | ctx.fillRect(0, horizon - 1, W, 2); | |
| 183 | } | |
| 184 | ||
| 185 | /** | |
| 186 | * Everything that is not a wall, far to near, each one clipped to the screen | |
| 187 | * columns where it beats the wall behind it. | |
| 188 | */ | |
| 189 | function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns) { | |
| 190 | const W = view.width; | |
| 191 | const things = scene.order; | |
| 192 | things.length = 0; | |
| 193 | ||
| 25 | /** Everything on the floor that is not a body: zones, orbs, the spike. */ | |
| 26 | function props(snap, out) { | |
| 194 | 27 | for (const zone of snap.zones) { |
| 195 | 28 | if (zone.k === "smoke") continue; |
| 196 | things.push({ kind: "zone", zone, x: zone.x, y: zone.y }); | |
| 29 | out.push({ kind: "zone", zone, x: zone.x, y: zone.y, draw: drawZone }); | |
| 197 | 30 | } |
| 198 | for (const orb of snap.orbs) things.push({ kind: "orb", x: orb.x, y: orb.y }); | |
| 31 | for (const orb of snap.orbs) out.push({ kind: "orb", x: orb.x, y: orb.y, draw: drawOrb }); | |
| 199 | 32 | if (snap.spike.x !== null && !snap.spike.carried) |
| 200 | things.push({ kind: "spike", x: snap.spike.x, y: snap.spike.y }); | |
| 201 | for (const player of snap.players) { | |
| 202 | if (player.id === snap.me.id) continue; | |
| 203 | const at = view.look(player); | |
| 204 | // How far into sight this body is: 0 the moment it appears, 1 solid. | |
| 205 | // See presence.js — a body that pops into existence reads as a bug. | |
| 206 | const shown = view.shown ? view.shown(player) : 1; | |
| 207 | things.push({ kind: "body", player, shown, x: at.x, y: at.y, angle: at.angle }); | |
| 208 | } | |
| 209 | // Where somebody was last seen, dimming away. It stands where they were, | |
| 210 | // so a wall between you and that spot hides it like anything else. | |
| 211 | if (view.marks) for (const mark of view.marks) things.push({ kind: "mark", mark, x: mark.x, y: mark.y }); | |
| 212 | ||
| 213 | const p = scene.proj; | |
| 214 | for (const thing of things) { | |
| 215 | project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p); | |
| 216 | thing.perp = p.perp; | |
| 217 | thing.column = p.column; | |
| 218 | thing.size = p.size; | |
| 219 | thing.behind = p.behind; | |
| 220 | } | |
| 221 | things.sort((a, b) => b.perp - a.perp); | |
| 222 | ||
| 223 | for (const thing of things) { | |
| 224 | if (thing.behind) continue; | |
| 225 | const floorY = horizon + (EYE * proj) / thing.perp; | |
| 226 | const haze = hazeAt(scene, thing.column, step, columns); | |
| 227 | const arriving = thing.kind === "body" ? thing.shown : thing.kind === "mark" ? thing.mark.fade : 1; | |
| 228 | ctx.globalAlpha = Math.max(0, 1 - haze) * arriving; | |
| 229 | if (ctx.globalAlpha < 0.05) continue; | |
| 230 | if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns); | |
| 231 | else if (thing.kind === "mark") drawMark(ctx, scene, thing, floorY, step, columns); | |
| 232 | else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns); | |
| 233 | else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns); | |
| 234 | else drawZone(ctx, scene, thing, floorY, proj, step, columns); | |
| 235 | ctx.globalAlpha = 1; | |
| 236 | } | |
| 237 | ctx.globalAlpha = 1; | |
| 238 | ||
| 239 | drawTracers(ctx, scene, eye, snap, W, proj, horizon); | |
| 33 | out.push({ kind: "spike", x: snap.spike.x, y: snap.spike.y, planted: snap.spike.planted, draw: drawSpike }); | |
| 240 | 34 | } |
| 241 | 35 | |
| 242 | const hazeAt = (scene, column, step, columns) => { | |
| 243 | const i = Math.max(0, Math.min(columns - 1, Math.round(column / step))); | |
| 244 | return scene.cols.haze[i] ?? 0; | |
| 245 | }; | |
| 36 | const trim = (p) => AGENT_COLOUR[p.agent] || "#999"; | |
| 37 | const badge = (p) => (p.spike ? "#f0d060" : null); | |
| 246 | 38 | |
| 247 | /** | |
| 248 | * The visible runs of a sprite: the stretches of screen column where it is | |
| 249 | * nearer than the wall. Two rectangles at most in practice, and the caller | |
| 250 | * clips its drawing to each. | |
| 251 | */ | |
| 252 | function withRuns(ctx, scene, left, right, perp, step, columns, draw) { | |
| 253 | let runStart = -1; | |
| 254 | const from = Math.max(0, Math.floor(left / step)); | |
| 255 | const to = Math.min(columns - 1, Math.ceil(right / step)); | |
| 256 | for (let i = from; i <= to + 1; i++) { | |
| 257 | const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]); | |
| 258 | if (shown && runStart < 0) runStart = i; | |
| 259 | if (!shown && runStart >= 0) { | |
| 260 | ctx.save(); | |
| 261 | ctx.beginPath(); | |
| 262 | ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5); | |
| 263 | ctx.clip(); | |
| 264 | draw(); | |
| 265 | ctx.restore(); | |
| 266 | runStart = -1; | |
| 267 | } | |
| 268 | } | |
| 39 | export function drawScene(ctx, scene, world, eye, snap, view) { | |
| 40 | drawWorld(ctx, scene, world, eye, snap, { ...view, fog, props, trim, badge }); | |
| 269 | 41 | } |
| 270 | 42 | |
| 271 | function drawBody(ctx, scene, thing, floorY, step, columns) { | |
| 272 | const p = thing.player; | |
| 273 | // A body arriving grows from its feet as it fades in, so it reads as | |
| 274 | // somebody coming round the corner rather than one appearing there. | |
| 275 | const h = thing.size * swell(thing.shown ?? 1); | |
| 276 | const w = h * 0.46; | |
| 277 | const cx = thing.column; | |
| 278 | const top = floorY - h; | |
| 279 | const mate = p.mate; | |
| 280 | ||
| 281 | // The dead lie down. No tag, no chevron, no agent colour: if something is | |
| 282 | // upright with a marker over it, it is alive and it can shoot you. | |
| 283 | if (!p.alive) { | |
| 284 | const lie = h * 0.16; | |
| 285 | withRuns(ctx, scene, cx - w * 1.1, cx + w * 1.1, thing.perp, step, columns, () => { | |
| 286 | ctx.fillStyle = "#4a4e57"; | |
| 287 | ctx.beginPath(); | |
| 288 | ctx.ellipse(cx, floorY - lie * 0.4, w * 1.05, lie, 0, 0, 7); | |
| 289 | ctx.fill(); | |
| 290 | ctx.strokeStyle = "#23262d"; | |
| 291 | ctx.lineWidth = Math.max(1, h * 0.02); | |
| 292 | ctx.stroke(); | |
| 293 | }); | |
| 294 | return; | |
| 295 | } | |
| 296 | ||
| 297 | const skin = mate ? "#3f9fd6" : "#d8483f"; | |
| 298 | const trim = AGENT_COLOUR[p.agent] || "#999"; | |
| 299 | const edge = Math.max(1.2, h * 0.05); | |
| 300 | ||
| 301 | withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.5, thing.perp, step, columns, () => { | |
| 302 | // Body and head, each with a hard dark edge: the silhouette is what | |
| 303 | // carries across a dim room, where the colour does not. | |
| 304 | ctx.fillStyle = skin; | |
| 305 | ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 306 | ctx.strokeStyle = "#0b0d11"; | |
| 307 | ctx.lineWidth = edge; | |
| 308 | ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 309 | ctx.fillStyle = trim; | |
| 310 | ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1); | |
| 311 | ctx.beginPath(); | |
| 312 | ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7); | |
| 313 | ctx.fillStyle = skin; | |
| 314 | ctx.fill(); | |
| 315 | ctx.strokeStyle = "#0b0d11"; | |
| 316 | ctx.stroke(); | |
| 317 | ||
| 318 | if (p.spike) { | |
| 319 | ctx.fillStyle = "#f0d060"; | |
| 320 | ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3); | |
| 321 | } | |
| 322 | if (p.hp < 100) { | |
| 323 | ctx.fillStyle = "#000a"; | |
| 324 | ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06); | |
| 325 | ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a"; | |
| 326 | ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06); | |
| 327 | } | |
| 328 | ||
| 329 | if (mate) tag(ctx, p, cx, top, h); | |
| 330 | else chevron(ctx, cx, top, h); | |
| 331 | }); | |
| 332 | } | |
| 333 | ||
| 334 | /** | |
| 335 | * A mark where somebody was last seen: an empty outline and a scuff on the | |
| 336 | * floor, dimming away over a second. Deliberately not a body — nothing is | |
| 337 | * filled, there is no head and no chevron — because it is a memory of a | |
| 338 | * sighting and must never be mistaken for one. | |
| 339 | */ | |
| 340 | function drawMark(ctx, scene, thing, floorY, step, columns) { | |
| 341 | const h = thing.size * 0.9; | |
| 342 | const w = h * 0.46; | |
| 343 | const cx = thing.column; | |
| 344 | const top = floorY - h; | |
| 345 | withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.2, thing.perp, step, columns, () => { | |
| 346 | ctx.strokeStyle = thing.mark.mate ? "rgba(120,180,230,0.85)" : "rgba(235,150,120,0.9)"; | |
| 347 | ctx.lineWidth = Math.max(1, h * 0.035); | |
| 348 | ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 349 | ctx.fillStyle = "rgba(10,12,16,0.45)"; | |
| 350 | ctx.fillRect(cx - w * 0.7, floorY - h * 0.04, w * 1.4, h * 0.05); | |
| 351 | }); | |
| 352 | } | |
| 353 | ||
| 354 | /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read | |
| 355 | * red and blue the same way. */ | |
| 356 | function chevron(ctx, cx, top, h) { | |
| 357 | const size = Math.max(4, h * 0.16); | |
| 358 | const y = top - h * 0.16; | |
| 359 | ctx.beginPath(); | |
| 360 | ctx.moveTo(cx - size, y - size); | |
| 361 | ctx.lineTo(cx + size, y - size); | |
| 362 | ctx.lineTo(cx, y); | |
| 363 | ctx.closePath(); | |
| 364 | ctx.fillStyle = "#ff6f5e"; | |
| 365 | ctx.fill(); | |
| 366 | ctx.lineWidth = Math.max(1, h * 0.02); | |
| 367 | ctx.strokeStyle = "#140a08"; | |
| 368 | ctx.stroke(); | |
| 369 | } | |
| 370 | ||
| 371 | /** A team mate wears their name and a pip of their health — never an enemy. */ | |
| 372 | function tag(ctx, p, cx, top, h) { | |
| 373 | const size = Math.max(9, Math.min(18, h * 0.16)); | |
| 374 | const y = top - h * 0.34; | |
| 375 | ctx.font = `${size}px system-ui, sans-serif`; | |
| 376 | ctx.textAlign = "center"; | |
| 377 | const width = ctx.measureText(p.name).width + size * 1.4; | |
| 378 | ctx.fillStyle = "rgba(10,12,16,0.65)"; | |
| 379 | ctx.fillRect(cx - width / 2, y, width, size * 1.4); | |
| 380 | ctx.fillStyle = p.hp > 60 ? "#6fcf6f" : p.hp > 25 ? "#e0a040" : "#e05a4a"; | |
| 381 | ctx.fillRect(cx - width / 2 + size * 0.25, y + size * 0.35, size * 0.45, size * 0.7); | |
| 382 | ctx.fillStyle = "#cfe8ff"; | |
| 383 | ctx.fillText(p.name, cx + size * 0.4, y + size * 1.05); | |
| 384 | ctx.textAlign = "left"; | |
| 385 | } | |
| 386 | ||
| 387 | function drawSpike(ctx, scene, thing, floorY, snap, step, columns) { | |
| 43 | function drawSpike(ctx, thing, floorY, proj, clip) { | |
| 388 | 44 | const h = thing.size * 0.42; |
| 389 | 45 | const cx = thing.column; |
| 390 | const planted = snap.spike.planted; | |
| 46 | const planted = thing.planted; | |
| 391 | 47 | const blink = planted && Math.floor(Date.now() / 220) % 2 === 0; |
| 392 | withRuns(ctx, scene, cx - h, cx + h, thing.perp, step, columns, () => { | |
| 48 | clip(thing, cx - h, cx + h, () => { | |
| 393 | 49 | ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c"; |
| 394 | 50 | ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h); |
| 395 | 51 | ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418"; |
| @@ -397,24 +53,15 @@ function drawSpike(ctx, scene, thing, floorY, snap, step, columns) { | ||
| 397 | 53 | }); |
| 398 | 54 | } |
| 399 | 55 | |
| 400 | function drawBlob(ctx, scene, thing, floorY, colour, scale, step, columns) { | |
| 401 | const r = thing.size * scale; | |
| 402 | const cx = thing.column; | |
| 403 | withRuns(ctx, scene, cx - r, cx + r, thing.perp, step, columns, () => { | |
| 404 | ctx.fillStyle = colour; | |
| 405 | ctx.beginPath(); | |
| 406 | ctx.arc(cx, floorY - r, r, 0, 7); | |
| 407 | ctx.fill(); | |
| 408 | }); | |
| 409 | } | |
| 56 | const drawOrb = (ctx, thing, floorY, proj, clip) => drawBlob(ctx, thing, floorY, "#c9a0ff", 0.35, clip); | |
| 410 | 57 | |
| 411 | function drawZone(ctx, scene, thing, floorY, proj, step, columns) { | |
| 58 | function drawZone(ctx, thing, floorY, proj, clip) { | |
| 412 | 59 | const z = thing.zone; |
| 413 | 60 | const wide = (z.r * proj) / thing.perp; |
| 414 | 61 | const cx = thing.column; |
| 415 | 62 | if (z.k === "wall") { |
| 416 | 63 | const h = (WALL * proj) / thing.perp; |
| 417 | withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { | |
| 64 | clip(thing, cx - wide, cx + wide, () => { | |
| 418 | 65 | ctx.fillStyle = "rgba(120,200,255,0.45)"; |
| 419 | 66 | ctx.fillRect(cx - wide, floorY - h, wide * 2, h); |
| 420 | 67 | ctx.strokeStyle = "rgba(180,225,255,0.8)"; |
| @@ -424,30 +71,10 @@ function drawZone(ctx, scene, thing, floorY, proj, step, columns) { | ||
| 424 | 71 | } |
| 425 | 72 | const colour = |
| 426 | 73 | 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)"; |
| 427 | withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { | |
| 74 | clip(thing, cx - wide, cx + wide, () => { | |
| 428 | 75 | ctx.fillStyle = colour; |
| 429 | 76 | ctx.beginPath(); |
| 430 | 77 | ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7); |
| 431 | 78 | ctx.fill(); |
| 432 | 79 | }); |
| 433 | 80 | } |
| 434 | ||
| 435 | /** Shots the server let us see, as a line through the world. */ | |
| 436 | function drawTracers(ctx, scene, eye, snap, W, proj, horizon) { | |
| 437 | if (!snap.shots.length) return; | |
| 438 | const p = scene.proj; | |
| 439 | ctx.lineWidth = 2; | |
| 440 | for (const shot of snap.shots) { | |
| 441 | project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p); | |
| 442 | if (p.behind) continue; | |
| 443 | const x1 = p.column; | |
| 444 | const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6; | |
| 445 | project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p); | |
| 446 | if (p.behind) continue; | |
| 447 | ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)"; | |
| 448 | ctx.beginPath(); | |
| 449 | ctx.moveTo(x1, y1); | |
| 450 | ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6); | |
| 451 | ctx.stroke(); | |
| 452 | } | |
| 453 | } |
static/topdown.js +29 −183
| @@ -1,11 +1,11 @@ | ||
| 1 | 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. | |
| 2 | * This game's map from above: the engine's board (`engine/topdown.js`) with | |
| 3 | * the Valorant things on it — site letters, spawn tints, the orbs, the ability | |
| 4 | * zones, the spike and its defuse bar, an agent's colour round a dot, the | |
| 5 | * spike on the carrier and a blinded player's white eye. | |
| 7 | 6 | */ |
| 8 | 7 | |
| 8 | import { disc, drawTopdown as drawBoard } from "./engine/topdown.js"; | |
| 9 | 9 | import { AGENT_COLOUR } from "./scene.js"; |
| 10 | 10 | |
| 11 | 11 | const TILE_COLOUR = { |
| @@ -17,48 +17,12 @@ const TILE_COLOUR = { | ||
| 17 | 17 | ".": "#171a21", |
| 18 | 18 | }; |
| 19 | 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; `opts.marks` are the last-seen rings from | |
| 24 | * `presence.js`, which answer "where did they go" without claiming anybody | |
| 25 | * is there now; `opts.tiles: false` leaves out the map itself, for a caller | |
| 26 | * that has already drawn it once and kept it — it is 570 rectangles of a | |
| 27 | * map that never changes. | |
| 28 | * | |
| 29 | * Called with no snapshot it draws the tiles and stops, which is exactly | |
| 30 | * that layer. | |
| 31 | */ | |
| 32 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | |
| 33 | const cols = map.rows[0].length; | |
| 34 | const s = box.w / (cols * map.tile); | |
| 35 | const labels = opts.labels !== false; | |
| 36 | const tiles = opts.tiles !== false; | |
| 37 | ctx.save(); | |
| 38 | ctx.beginPath(); | |
| 39 | ctx.rect(box.x, box.y, box.w, box.h); | |
| 40 | ctx.clip(); | |
| 41 | ctx.translate(box.x, box.y); | |
| 42 | ||
| 43 | const t = map.tile * s; | |
| 44 | if (tiles) | |
| 45 | for (let row = 0; row < map.rows.length; row++) | |
| 46 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 47 | const cell = map.rows[row][col]; | |
| 48 | ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21"; | |
| 49 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 50 | if (labels && (cell === "A" || cell === "B")) { | |
| 51 | ctx.fillStyle = "#6b5f3a"; | |
| 52 | ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; | |
| 53 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | if (!snap) { | |
| 58 | ctx.restore(); | |
| 59 | return; | |
| 60 | } | |
| 20 | const tile = (cell) => TILE_COLOUR[cell] ?? "#171a21"; | |
| 21 | const label = (cell) => cell === "A" || cell === "B"; | |
| 22 | const trim = (p) => AGENT_COLOUR[p.agent] || "#888"; | |
| 61 | 23 | |
| 24 | /** Orbs, then every zone but smoke, then the spike: what lies under the bodies. */ | |
| 25 | function under(ctx, snap, s) { | |
| 62 | 26 | for (const orb of snap.orbs) { |
| 63 | 27 | ctx.fillStyle = "#c9a0ff"; |
| 64 | 28 | ctx.beginPath(); |
| @@ -78,23 +42,27 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | ||
| 78 | 42 | ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 3); |
| 79 | 43 | } |
| 80 | 44 | } |
| 45 | } | |
| 81 | 46 | |
| 82 | for (const shot of snap.shots) { | |
| 83 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.5)"; | |
| 84 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 85 | ctx.beginPath(); | |
| 86 | ctx.moveTo(shot.x1 * s, shot.y1 * s); | |
| 87 | ctx.lineTo(shot.x2 * s, shot.y2 * s); | |
| 88 | ctx.stroke(); | |
| 89 | } | |
| 90 | ||
| 91 | // Under the living: a ring where somebody was last seen is never allowed | |
| 92 | // to sit on top of a body that is actually in sight. | |
| 93 | if (opts.marks) for (const mark of opts.marks) drawMark(ctx, mark, s); | |
| 94 | for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels); | |
| 95 | // Smoke last: on this view it hides what is under it, as it should. | |
| 47 | /** Smoke last: on this view it hides what is under it, as it should. */ | |
| 48 | function over(ctx, snap, s) { | |
| 96 | 49 | for (const zone of snap.zones) if (zone.k === "smoke" && zone.f <= 0) drawZone(ctx, zone, s); |
| 97 | ctx.restore(); | |
| 50 | } | |
| 51 | ||
| 52 | /** The spike on a carrier's back, and the white eye of somebody blinded. */ | |
| 53 | function badges(ctx, p, x, y, r) { | |
| 54 | if (p.spike) { | |
| 55 | ctx.fillStyle = "#f0d060"; | |
| 56 | ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); | |
| 57 | } | |
| 58 | if (p.blind) { | |
| 59 | ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| 60 | disc(ctx, x, y - r * 1.6, r * 0.35, true); | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | |
| 65 | drawBoard(ctx, map, snap, look, box, { ...opts, tile, label, under, over, trim, badges }); | |
| 98 | 66 | } |
| 99 | 67 | |
| 100 | 68 | function drawZone(ctx, z, s) { |
| @@ -126,125 +94,3 @@ function drawZone(ctx, z, s) { | ||
| 126 | 94 | disc(ctx, x, y, 14 * s, true); |
| 127 | 95 | } |
| 128 | 96 | } |
| 129 | ||
| 130 | /** | |
| 131 | * Where somebody was last seen: a hollow ring and the way they were facing, | |
| 132 | * dimming away. Hollow because a filled dot on this map means a body, and | |
| 133 | * this is a memory of one. | |
| 134 | */ | |
| 135 | function drawMark(ctx, m, s) { | |
| 136 | const x = m.x * s; | |
| 137 | const y = m.y * s; | |
| 138 | const r = Math.max(2.5, 16 * s); | |
| 139 | ctx.save(); | |
| 140 | ctx.globalAlpha = 0.8 * m.fade; | |
| 141 | ctx.strokeStyle = m.mate ? "#4fb3d9" : "#e35d5b"; | |
| 142 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 143 | disc(ctx, x, y, r, false, true); | |
| 144 | ctx.beginPath(); | |
| 145 | ctx.moveTo(x, y); | |
| 146 | ctx.lineTo(x + Math.cos(m.angle) * r * 1.7, y + Math.sin(m.angle) * r * 1.7); | |
| 147 | ctx.stroke(); | |
| 148 | ctx.restore(); | |
| 149 | } | |
| 150 | ||
| 151 | function disc(ctx, x, y, r, fill, stroke) { | |
| 152 | ctx.beginPath(); | |
| 153 | ctx.arc(x, y, r, 0, 7); | |
| 154 | if (fill) ctx.fill(); | |
| 155 | if (stroke) ctx.stroke(); | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Everybody is a dot and you are an arrow — but a team mate is on the map | |
| 160 | * whether you can see them or not, with their initial in the dot and a cross | |
| 161 | * when they are down, and an enemy is only ever drawn while they are alive | |
| 162 | * and in sight. The map stays honest about vision and legible about your own. | |
| 163 | */ | |
| 164 | function drawPlayer(ctx, p, pos, s, me, labels) { | |
| 165 | const mine = p.id === me.id; | |
| 166 | if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out | |
| 167 | const x = pos.x * s; | |
| 168 | const y = pos.y * s; | |
| 169 | const r = Math.max(2.5, 16 * s); | |
| 170 | const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; | |
| 171 | ||
| 172 | if (mine && p.alive) { | |
| 173 | ctx.save(); | |
| 174 | ctx.translate(x, y); | |
| 175 | ctx.rotate(pos.angle); | |
| 176 | ctx.fillStyle = body; | |
| 177 | ctx.beginPath(); | |
| 178 | ctx.moveTo(r * 1.5, 0); | |
| 179 | ctx.lineTo(-r, r * 0.9); | |
| 180 | ctx.lineTo(-r * 0.4, 0); | |
| 181 | ctx.lineTo(-r, -r * 0.9); | |
| 182 | ctx.closePath(); | |
| 183 | ctx.fill(); | |
| 184 | ctx.restore(); | |
| 185 | return; | |
| 186 | } | |
| 187 | ||
| 188 | // The dead of your own team keep their place on the map, crossed out. | |
| 189 | if (!p.alive) { | |
| 190 | ctx.strokeStyle = "#6a707c"; | |
| 191 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 192 | ctx.beginPath(); | |
| 193 | ctx.moveTo(x - r * 0.8, y - r * 0.8); | |
| 194 | ctx.lineTo(x + r * 0.8, y + r * 0.8); | |
| 195 | ctx.moveTo(x + r * 0.8, y - r * 0.8); | |
| 196 | ctx.lineTo(x - r * 0.8, y + r * 0.8); | |
| 197 | ctx.stroke(); | |
| 198 | if (labels) { | |
| 199 | ctx.fillStyle = "rgba(180,185,195,0.6)"; | |
| 200 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; | |
| 201 | ctx.textAlign = "center"; | |
| 202 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 203 | ctx.textAlign = "left"; | |
| 204 | } | |
| 205 | return; | |
| 206 | } | |
| 207 | ||
| 208 | ctx.fillStyle = body; | |
| 209 | ctx.beginPath(); | |
| 210 | ctx.arc(x, y, r, 0, 7); | |
| 211 | ctx.fill(); | |
| 212 | ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; | |
| 213 | ctx.lineWidth = Math.max(1, 3 * s); | |
| 214 | ctx.stroke(); | |
| 215 | ctx.strokeStyle = body; | |
| 216 | ctx.beginPath(); | |
| 217 | ctx.moveTo(x, y); | |
| 218 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 219 | ctx.stroke(); | |
| 220 | ||
| 221 | // The initial inside the dot, when the dot is big enough to hold one. | |
| 222 | if (p.mate && r >= 6) { | |
| 223 | ctx.fillStyle = "#0c1016"; | |
| 224 | ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`; | |
| 225 | ctx.textAlign = "center"; | |
| 226 | ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45); | |
| 227 | ctx.textAlign = "left"; | |
| 228 | } | |
| 229 | if (p.spike) { | |
| 230 | ctx.fillStyle = "#f0d060"; | |
| 231 | ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); | |
| 232 | } | |
| 233 | if (p.blind) { | |
| 234 | ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| 235 | disc(ctx, x, y - r * 1.6, r * 0.35, true); | |
| 236 | } | |
| 237 | if (labels) { | |
| 238 | ctx.fillStyle = "rgba(235,235,235,0.75)"; | |
| 239 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; | |
| 240 | ctx.textAlign = "center"; | |
| 241 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 242 | ctx.textAlign = "left"; | |
| 243 | } | |
| 244 | if (p.hp < 100) { | |
| 245 | ctx.fillStyle = "#0008"; | |
| 246 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); | |
| 247 | ctx.fillStyle = "#6fcf6f"; | |
| 248 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); | |
| 249 | } | |
| 250 | } |
static/val.js +11 −11
| @@ -6,27 +6,27 @@ | ||
| 6 | 6 | * |
| 7 | 7 | * This file is the wiring: the socket, the keys and the pointer, the HUD. |
| 8 | 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. | |
| 9 | * `topdown.js` for the map, `prompt.js` for the line of advice — and the | |
| 10 | * machinery under `engine/`: the renderer, the feel stack, the gun. | |
| 11 | 11 | */ |
| 12 | 12 | "use strict"; |
| 13 | 13 | |
| 14 | 14 | import { banner, drawBanner } from "./banner.js"; |
| 15 | 15 | import { makeBeeper } from "./beep.js"; |
| 16 | import { makeBudget, measure } from "./budget.js"; | |
| 17 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./interp.js"; | |
| 18 | import { BODY_R, moveSpeed } from "../src/glide.ts"; | |
| 19 | import { drawGun, gunFire, gunSnapshot, gunTick, makeGun } from "./gun.js"; | |
| 20 | import { makeLocal, stepLocal } from "./predict.js"; | |
| 21 | import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js"; | |
| 16 | import { makeBudget, measure } from "./engine/budget.js"; | |
| 17 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./engine/interp.js"; | |
| 18 | import { BODY_R, moveSpeed } from "../src/engine/glide.ts"; | |
| 19 | import { drawGun, gunFire, gunSnapshot, gunTick, makeGun } from "./engine/gun.js"; | |
| 20 | import { makeLocal, stepLocal } from "./engine/predict.js"; | |
| 21 | import { forget, ghosts, makePresence, shown, sighted, slide } from "./engine/presence.js"; | |
| 22 | 22 | import { choosePrompt, REACH } from "./prompt.js"; |
| 23 | import { renderSize, WIDTHS } from "./resolution.js"; | |
| 23 | import { renderSize, WIDTHS } from "./engine/resolution.js"; | |
| 24 | 24 | import { roster, rosterKey } from "./roster.js"; |
| 25 | 25 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| 26 | import { moveVector } from "./steer.js"; | |
| 26 | import { moveVector } from "./engine/steer.js"; | |
| 27 | 27 | import { drawTopdown } from "./topdown.js"; |
| 28 | 28 | import { drawWatching, watching, watchLines } from "./watch.js"; |
| 29 | import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js"; | |
| 29 | import { aim as aimAt, makeYaw, settle, told, turn } from "./engine/yaw.js"; | |
| 30 | 30 | |
| 31 | 31 | const $ = (id) => document.getElementById(id); |
| 32 | 32 | const view = $("view"); |