wren/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
benjivers committed 53c9cd7 merge Browse files
48 files changed +2118 −1454
README.md +10 −10
@@ -51,7 +51,7 @@ server:
5151 with hands: 200–350ms to react to a target they have just seen, an aim
5252 cone that opens several degrees on the swing and only settles while they
5353 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
5555 a seed replays the same round.
5656
5757 Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits
@@ -64,12 +64,12 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
6464
6565 - Your own body is *drawn* from a guess — the browser runs your own movement
6666 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
6868 snapshot forward by up to a quarter second of held input. It is a drawing
6969 optimisation only: nothing is sent, every snapshot leans the drawn body
7070 back onto the server's answer, and a real disagreement is taken outright.
7171 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
7373 drawn body may lead the answer by that much along the way it is going and
7474 is left alone there, so a start is not yanked back and a stop is not
7575 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.
7979 second kick; whether it landed is still only the server's to say.
8080 Where you are *looking* is the browser's too: the mouse moves it at frame
8181 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
8484 body keeps the last few snapshots, and the frame is drawn about one
8585 snapshot interval in the past, between the two that bracket that moment.
8686 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.
101101 no looking up or down, and everything that is not a wall — bodies, the
102102 spike, ability zones — is a flat billboard scaled by distance and clipped
103103 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
105105 every other column, and scaled onto the visible one, so the frame costs the
106106 same on a retina screen as on any other; the crosshair, the HUD and the
107107 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
109109 map's tiles are drawn once a size and blitted after that, and if the frame
110110 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.
112112 All of it is presentation: the simulation and the snapshots are untouched.
113113 - A body you have only just been shown is faded and grown into sight over a
114114 tenth of a second, and slid in from the direction it came if it was seen
115115 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
117117 only what you can see — that is the rule, and nothing here bends it. This
118118 is a drawing about information you already had: an enemy who steps into a
119119 sightline has no history, and without it they appear mid-stride rather than
@@ -177,7 +177,7 @@ says how.
177177
178178 ## Shape
179179
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 ·
181181 `src/economy.ts` credits · `src/abilities.ts` the agent kits ·
182182 `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes
183183 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 {
1818 import { HEIGHT, WIDTH } from "./map.ts";
1919 import { newMatch, startRound, tick } from "./match.ts";
2020 import type { MatchState, Player, Zone } from "./state.ts";
21 import { FOV } from "../static/raycast.js";
21 import { FOV } from "../static/engine/raycast.js";
2222
2323 const flashes = (): Ability[] => AGENTS.flatMap((a) => SLOTS.map((s) => a.abilities[s])).filter((a) => a.kind === "flash");
2424
src/abilities.ts +1 −1
@@ -143,7 +143,7 @@ export const buyableSlots: Slot[] = ["c", "q"];
143143 * of the flash you get flashed, and if you are not, you do not. Facing is
144144 * the gate. Distance only decides how long.
145145 *
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
147147 * anything inside half of that was on your screen: you looked at it, you
148148 * pay the whole duration. Past the edge of the screen there is a little
149149 * grace for the fact that you were probably turning, falling away quickly
src/aim.test.ts +3 −117
@@ -1,24 +1,10 @@
11 /**
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.
44 */
55
66 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";
228 import { botThink } from "./bots.ts";
239 import { newMatch, RUN_SPEED } from "./match.ts";
2410 import type { MatchState, Player } from "./state.ts";
@@ -28,106 +14,6 @@ const rolls = (...values: number[]) => {
2814 return () => values[at++ % values.length]!;
2915 };
3016
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
13117 const facing = (state: MatchState): { bot: Player; prey: Player } => {
13218 const bot = state.players.find((p) => p.side === "def")!;
13319 const prey = state.players.find((p) => p.side === "atk")!;
src/arena.ts +92 −300
@@ -1,240 +1,63 @@
11 /**
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.
36 *
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.
1112 */
1213
1314 import { agentById, sightBlocked } from "./abilities.ts";
1415 import { runBots } from "./bots.ts";
1516 import { recordResult } from "./db.ts";
17 import { makeArena, type Command, type Why } from "./engine/arena.ts";
1618 import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
1719 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";
2821 import type { MatchState, Player } from "./state.ts";
2922 import { isWeaponId, WEAPONS } from "./weapons.ts";
3023
31 export const TICK = 1 / 30;
24 export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts";
3225
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();
6527 let lastResult: string | null = null;
6628 // The kill feed is read off the match a tick at a time; the rules keep no
6729 // list of their own, and this is a view, not a rule.
6830 let marks: Mark[] = marksOf(state.players);
6931 let feed: Fall[] = [];
7032
71 export const match = (): MatchState => state;
33 const round2 = (n: number): number => Math.round(n * 10) / 10;
7234
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 {
12237 const humans = { red: 0, blue: 0 };
12338 for (const player of state.players) if (!player.bot) humans[player.team] += 1;
12439 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);
12741 if (!seat) return null;
12842 seat.bot = false;
129 seat.name = freeName(name, seat.id);
13043 seat.think = 0;
13144 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: "" });
13445 return seat;
13546 }
13647
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;
14054 }
14155
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
23156 const num = (value: unknown): number | undefined =>
23257 typeof value === "number" && Number.isFinite(value) ? value : undefined;
23358
23459 /** 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 {
23861 switch (message.t) {
23962 case "i":
24063 setInput(player, {
@@ -260,36 +83,38 @@ export function command(playerId: string, message: Command): void {
26083 }
26184 }
26285
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 }));
26496
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 };
283110 }
284111
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[]) {
287114 const agent = agentById(me.agent);
288 const seen = state.players.filter((p) => visible(state, me, p));
289115 const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones);
290116
291117 return {
292 t: "s",
293118 phase: state.phase,
294119 clock: round2(Math.max(0, state.clock)),
295120 round: state.round,
@@ -356,6 +181,9 @@ function snapshotFor(viewerId: string) {
356181 },
357182 orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })),
358183 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.
359187 board: state.players.map((p) => ({
360188 id: p.id,
361189 name: p.name,
@@ -372,15 +200,7 @@ function snapshotFor(viewerId: string) {
372200 ultNeeds: agentById(p.agent).abilities.x.points,
373201 })),
374202 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),
384204 };
385205 }
386206
@@ -389,7 +209,7 @@ function snapshotFor(viewerId: string) {
389209 * killer stands is what tells a gun kill from an ability kill — the position
390210 * is used here and goes no further.
391211 */
392 function readFalls(): void {
212 function readFalls(state: MatchState): void {
393213 const after = marksOf(state.players);
394214 const shooters = new Set<string>();
395215 for (const shot of state.shots) {
@@ -407,77 +227,49 @@ function readFalls(): void {
407227 feed = trimFeed(feed, state.now);
408228 }
409229
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);
432235 if (state.result && state.result !== lastResult) {
433236 lastResult = state.result;
434237 recordResult(state.result, state.score.red, state.score.blue, state.round);
435238 }
436239 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 }
454240 }
455241
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 });
457254
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 @@
88 */
99
1010 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";
1212 import { pathStep, rayHitWall, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts";
1313 import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts";
1414 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 () =
400400 // the rule.
401401 // The lead is the cap plus the slack the guess is allowed over the answer
402402 // (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");
405405 expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10);
406406 expect(Math.abs(sideways)).toBeLessThan(6);
407407 // 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 @@
55 */
66
77 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";
99
1010 /** `seconds` of frames, each `ms` long, and how many times the level moved. */
1111 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 @@
66
77 import { expect, test } from "bun:test";
88 import { BODY_R, circleBlocked, moveSpeed, RUN, slideCircle } from "./glide.ts";
9 import { moveCircle } from "./map.ts";
109
1110 const TILE = 80;
1211 // 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", () => {
9392 }
9493 });
9594
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
10495 test("speed is the one table both sides read", () => {
10596 expect(moveSpeed({})).toBe(RUN);
10697 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 @@
11 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";
33
44 const snap = (over: Record<string, unknown> = {}, shots: unknown[] = []) => ({
55 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 @@
99 */
1010
1111 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";
1313
1414 type Mark = { t: number; x: number; y: number; angle: number };
1515 const pair = (a: Mark, b: Mark) => [a, b];
@@ -165,66 +165,3 @@ test("the moment being drawn is always bracketed by samples on hand", () => {
165165 });
166166
167167 // --------------------------------------------- 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 @@
55
66 import { expect, test } from "bun:test";
77 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";
1010
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;
1338 const world = { solid: solidTile, tile: TILE, r: BODY_R };
1439
1540 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
3863 });
3964
4065 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
4267 // browser's step and the server's land in the same place, or the camera
4368 // 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;
4671 let tried = 0;
4772 for (let row = 1; row < rows - 1; row++)
4873 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 {
1818 slide,
1919 SMALL,
2020 swell,
21 } from "../static/presence.js";
21 } from "../../static/engine/presence.js";
2222
2323 const body = (over: Record<string, unknown> = {}) => ({
2424 id: "them",
@@ -27,7 +27,6 @@ const body = (over: Record<string, unknown> = {}) => ({
2727 angle: 0,
2828 alive: true,
2929 mate: false,
30 agent: "sable",
3130 ...over,
3231 });
3332
@@ -103,13 +102,13 @@ test("a body that has not moved since has no direction to come from", () => {
103102
104103 test("where somebody was last seen is marked, and the mark dims away", () => {
105104 const p = makePresence();
106 sighted(p, [body({ x: 640, y: 480, agent: "veil" })], 10);
105 sighted(p, [body({ x: 640, y: 480 })], 10);
107106 expect(ghosts(p, 10)).toEqual([]);
108107
109108 sighted(p, [], 10.5);
110109 const fresh = ghosts(p, 10.5);
111110 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 });
113112 expect(ghosts(p, 10.5 + GHOST / 2)[0]!.fade).toBeCloseTo(0.5, 5);
114113 expect(ghosts(p, 10.5 + GHOST)).toEqual([]);
115114 // 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 {
1414 smokeAlong,
1515 spriteColumnVisible,
1616 wallLight,
17 } from "../static/raycast.js";
17 } from "../../static/engine/raycast.js";
1818
1919 const W = 900;
2020
src/resolution.test.ts → src/engine/resolution.test.ts renamed +1 −1
@@ -5,7 +5,7 @@
55 */
66
77 import { expect, test } from "bun:test";
8 import { renderSize, WIDTHS } from "../static/resolution.js";
8 import { renderSize, WIDTHS } from "../../static/engine/resolution.js";
99
1010 test("a retina window draws the world at a fixed width, not at its pixels", () => {
1111 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 @@
55 */
66
77 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";
99
1010 type Col = { level: number; side?: number; top?: number; high?: number; haze?: number };
1111
src/steer.test.ts → src/engine/steer.test.ts renamed +1 −1
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { moveVector } from "../static/steer.js";
2 import { moveVector } from "../../static/engine/steer.js";
33
44 test("W walks the way you are looking, whichever way that is", () => {
55 const east = moveVector(0, -1, 0);
src/yaw.test.ts → src/engine/yaw.test.ts renamed +1 −1
@@ -4,7 +4,7 @@
44 */
55
66 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";
88
99 const me = (angle: number, extra: Partial<{ id: string; alive: boolean }> = {}) => ({
1010 id: "p1",
src/feel.test.ts +67 −4
@@ -11,13 +11,13 @@
1111 */
1212
1313 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";
1515 import { solidTile, TILE } from "./map.ts";
1616 import { newMatch, setInput, startRound, tick } from "./match.ts";
1717 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";
2121
2222 const round2 = (n: number) => Math.round(n * 10) / 10;
2323 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
361361 // At most one a round — the spawn — and never a second one to angle zero.
362362 expect(worstRound).toBeLessThanOrEqual(1);
363363 });
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 @@
11 /**
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.
33 *
44 * Two sites (A left, B right), a mid corridor between the spawns, and a back
55 * corridor along the bottom that links A main, spawn and B main — the flank.
@@ -7,11 +7,15 @@
77 * corridor is comfortably wide and a wall is never thin enough for a ray to
88 * step over.
99 *
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.
1214 */
1315
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 };
1519
1620 export const TILE = 80;
1721
@@ -38,53 +42,38 @@ const ROWS_SRC = [
3842 "##############################",
3943 ];
4044
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;
4551
4652 export const grid: string[] = ROWS_SRC;
4753 export const gridRows = (): string[] => ROWS_SRC.slice();
4854
4955 export type Site = "A" | "B";
50 export type Point = { x: number; y: number };
5156
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;
7168
7269 /** The site a point plants on, or null: the spike only arms inside a site. */
7370 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);
7672 return c === "A" || c === "B" ? c : null;
7773 };
7874
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);
8877
8978 export const siteCentre = (site: Site): Point => {
9079 const cells = siteCells(site);
@@ -94,139 +83,3 @@ export const siteCentre = (site: Site): Point => {
9483
9584 /** Ult orbs: one on each half of mid, free for whoever walks over them. */
9685 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 @@
99 */
1010
1111 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";
1414 import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts";
1515 import {
1616 blockedCircle,
@@ -27,7 +27,7 @@ import { emptyInput, type Input, type MatchState, type Player, type Side, type S
2727 import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts";
2828
2929 // 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.
3131 export const PLAYER_R = BODY_R;
3232 export const RUN_SPEED = RUN;
3333 export const BUY_TIME = 14;
src/scene.test.ts +4 −152
@@ -2,11 +2,13 @@
22 * The renderer, run for real against a canvas that writes down what it was
33 * asked to draw. No browser, but every line of the draw path is executed —
44 * 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`.
58 */
69
710 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";
1012 import { drawScene, makeScene } from "../static/scene.js";
1113 import { drawTopdown } from "../static/topdown.js";
1214 import { gridRows, TILE } from "./map.ts";
@@ -91,77 +93,6 @@ const body = (over: Record<string, unknown> = {}) => ({
9193 const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p };
9294 const eye = { x: 120, y: 200, angle: 0 };
9395
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
16596 test("a smoke between the eye and a body hides it", () => {
16697 const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }];
16798 const ctx = fakeContext();
@@ -248,85 +179,6 @@ test("the gun and the crosshair draw, and the hit marker only when there is one"
248179 expect(loud.ops.length).toBeGreaterThan(quiet);
249180 });
250181
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
330182 test("on the map a last-seen ring is hollow, and dims with the mark", () => {
331183 const map = { rows: gridRows(), tile: TILE };
332184 const box = { x: 0, y: 0, w: 900, h: 570 };
src/server.ts +2 −1
@@ -1,6 +1,7 @@
11 /**
22 * 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/.
45 *
56 * A seat belongs to a session cookie, so the same browser gets the same
67 * player back across a reload, and a socket is only ever given a seat the
src/state.ts +1 −1
@@ -3,7 +3,7 @@
33 * can both speak about a player without importing each other.
44 */
55
6 import type { Track } from "./aim.ts";
6 import type { Track } from "./engine/aim.ts";
77 import type { Point, Site } from "./map.ts";
88 import type { WeaponId } from "./weapons.ts";
99
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 @@
1515 * drawing optimisation and nothing here is ever sent anywhere.
1616 */
1717
18 import { circleBlocked, slideCircle } from "../src/glide.ts";
18 import { circleBlocked, slideCircle } from "../../src/engine/glide.ts";
1919
2020 /** Past this much disagreement the server simply wins: a spawn, a teleport. */
2121 export const SNAP = 40;
static/presence.js → static/engine/presence.js renamed +1 −2
@@ -74,7 +74,6 @@ function remember(it, body, seq) {
7474 it.angle = body.angle;
7575 it.alive = body.alive;
7676 it.mate = body.mate;
77 it.agent = body.agent;
7877 }
7978
8079 /**
@@ -125,7 +124,7 @@ export function ghosts(presence, now) {
125124 presence.last.delete(id);
126125 continue;
127126 }
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 });
129128 }
130129 return out;
131130 }
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 @@
11 /**
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.
166 */
177
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";
329
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 };
3811
3912 export const AGENT_COLOUR = {
4013 ember: "#f08a4b",
@@ -44,352 +17,35 @@ export const AGENT_COLOUR = {
4417 rook: "#d96a8f",
4518 };
4619
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);
7223 }
7324
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) {
19427 for (const zone of snap.zones) {
19528 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 });
19730 }
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 });
19932 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 });
24034 }
24135
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);
24638
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 });
26941 }
27042
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) {
38844 const h = thing.size * 0.42;
38945 const cx = thing.column;
390 const planted = snap.spike.planted;
46 const planted = thing.planted;
39147 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, () => {
39349 ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c";
39450 ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h);
39551 ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418";
@@ -397,24 +53,15 @@ function drawSpike(ctx, scene, thing, floorY, snap, step, columns) {
39753 });
39854 }
39955
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);
41057
411 function drawZone(ctx, scene, thing, floorY, proj, step, columns) {
58 function drawZone(ctx, thing, floorY, proj, clip) {
41259 const z = thing.zone;
41360 const wide = (z.r * proj) / thing.perp;
41461 const cx = thing.column;
41562 if (z.k === "wall") {
41663 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, () => {
41865 ctx.fillStyle = "rgba(120,200,255,0.45)";
41966 ctx.fillRect(cx - wide, floorY - h, wide * 2, h);
42067 ctx.strokeStyle = "rgba(180,225,255,0.8)";
@@ -424,30 +71,10 @@ function drawZone(ctx, scene, thing, floorY, proj, step, columns) {
42471 }
42572 const colour =
42673 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, () => {
42875 ctx.fillStyle = colour;
42976 ctx.beginPath();
43077 ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7);
43178 ctx.fill();
43279 });
43380 }
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 @@
11 /**
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.
76 */
87
8 import { disc, drawTopdown as drawBoard } from "./engine/topdown.js";
99 import { AGENT_COLOUR } from "./scene.js";
1010
1111 const TILE_COLOUR = {
@@ -17,48 +17,12 @@ const TILE_COLOUR = {
1717 ".": "#171a21",
1818 };
1919
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";
6123
24 /** Orbs, then every zone but smoke, then the spike: what lies under the bodies. */
25 function under(ctx, snap, s) {
6226 for (const orb of snap.orbs) {
6327 ctx.fillStyle = "#c9a0ff";
6428 ctx.beginPath();
@@ -78,23 +42,27 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
7842 ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 3);
7943 }
8044 }
45 }
8146
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) {
9649 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 });
9866 }
9967
10068 function drawZone(ctx, z, s) {
@@ -126,125 +94,3 @@ function drawZone(ctx, z, s) {
12694 disc(ctx, x, y, 14 * s, true);
12795 }
12896 }
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 @@
66 *
77 * This file is the wiring: the socket, the keys and the pointer, the HUD.
88 * 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.
1111 */
1212 "use strict";
1313
1414 import { banner, drawBanner } from "./banner.js";
1515 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";
2222 import { choosePrompt, REACH } from "./prompt.js";
23 import { renderSize, WIDTHS } from "./resolution.js";
23 import { renderSize, WIDTHS } from "./engine/resolution.js";
2424 import { roster, rosterKey } from "./roster.js";
2525 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
26 import { moveVector } from "./steer.js";
26 import { moveVector } from "./engine/steer.js";
2727 import { drawTopdown } from "./topdown.js";
2828 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";
3030
3131 const $ = (id) => document.getElementById(id);
3232 const view = $("view");
↑↓ navigate ↵ open esc close

Keyboard shortcuts