quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Merge pull request #23 from fable/22-separate-the-engine-from-the-game-so-the
Separate the engine from the game, so the next game can start where this one ended
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"); |