Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The range becomes a game without a renderer: seeded pop-up targets, a traced shot, a clock and a score #51

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/41-the-range-becomes-a-game-without-a-rende → main 1 commit fork

  • Merged. torque/41-the-range-becomes-a-game-without-a-rende was merged into main as a6ebf5b.

Review

Sign in to review this change.
13 files changed +950 −67
README.md +34 −18
@@ -4,8 +4,10 @@ A browser game engine with two games on it. The first, at `/`, is **cs**: a
44 Counter-Strike-shaped defusal shooter, five against five on one map — bomb
55 rounds, a buy menu and an economy, bots that walk to a site and shoot back —
66 and the leaderboard of the matches people played. The second, at `/range`, is
7 built only out of the engine the shooter was taken apart into, and so far it is
8 that plumbing: the fixed step, the controls and a readout of both. The engine in
7 built only out of the engine the shooter was taken apart into: a shooting
8 **range** — pop-up targets from a seeded spawn, a shot traced through the
9 engine's boxes, a thirty second clock and a score, with the fixed step and the
10 controls under it and no renderer yet. The engine in
911 `src/engine/` is the point of the repository; the shooter is what proves it.
1012
1113 ```sh
@@ -90,8 +92,11 @@ the rest.
9092 At `/range` the same controls drive the second game, on its own bindings:
9193 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
9294 <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload,
93 <kbd>Enter</kbd> starts. Until #13 lands there is nothing to shoot: what the
94 page shows is the loop and the controls themselves, as numbers that move.
95 <kbd>Enter</kbd> starts a thirty second run. Plates pop up down the lane, each
96 worth 100 to 200 points by how far away it stood; the panel keeps the score, the
97 clock and the accuracy, and the best of the visit. Until the renderer lands
98 there is no room and no walking: a plate is a square on the 2D horizon, drawn
99 from the angle it subtends, and you turn on the spot.
95100
96101 ## Shape
97102
@@ -165,23 +170,34 @@ page shows is the loop and the controls themselves, as numbers that move.
165170 recorder and the page can still hand it a canvas.
166171 - `src/range/` — the second game, at `/range`, and the proof that the engine is a
167172 package: a module in here may import the engine through its door, `src/pages/shell.ts`
168 and its own siblings, and nothing else. **Today it is the plumbing and only the
169 plumbing**: `binding.ts` is its own key table (50 Hz, its own actions — no buy,
173 and its own siblings, and nothing else. **It is a game with no renderer under
174 it**: `binding.ts` is its own key table (50 Hz, its own actions — no buy,
170175 no scoreboard, no weapon slots) and the one function from held actions to an
171 intent, `readout.ts` draws a horizon and a panel of numbers with plain 2D calls,
172 and `main.ts` wires the engine's `loop` and `controls` to the shell's ids and
173 draws what they say: ticks run, milliseconds a frame, which actions are held, the
174 movement they and the thumb stick add up to, and where you are looking. No room, no targets, no physics, no WebGL — those are the
175 game, and #13 builds them on this. `independence.test.ts` reads every source in
176 intent; `targets.ts` is the game itself — a seeded `rng` pops plates up as
177 `Box`es down a lane, `step` runs their lifetimes and the run's clock, and
178 `shoot` traces the engine's `rayBox` along the view and folds the hit, the
179 score and the accuracy in, all plain data in and plain data out with no canvas
180 anywhere near it; `readout.ts` draws a horizon, the plates on it as blips
181 projected from the angle each subtends, and a panel with the score, the clock
182 and what the loop and the controls are doing, in plain 2D calls; and `main.ts`
183 wires the engine's `loop` and `controls` to the shell's ids, steps the run in
184 whole ticks and fires one shot per press along the engine's `angleVectors`.
185 No room, no models, no movement, no WebGL — those need the renderer and the
186 physics, and #13 builds the finale on this. `independence.test.ts` reads every source in
176187 here and fails on an import that leaves those three places, reusing the
177188 `sources`/`specifiersOf` helpers from `src/engine/boundary.test.ts` rather than a
178189 second copy of the rule — a test may read another test's helpers, which is the
179 one path past the door, because a `.test.ts` is in no bundle; `binding.test.ts` and `readout.test.ts` pin the table,
180 the intent and the drawing headlessly, the second with a stub that records every
181 2D call; and `boot.test.ts` boots the entry itself against the stub page in
182 `dom.ts` — a document of plain objects and a clock the test moves — so a key, a
183 button, a thumb stick, a locked mouse and a canvas that gives no context are all
184 checked with no browser in the room. `bundle.test.ts` then does the same to
190 one path past the door, because a `.test.ts` is in no bundle; `binding.test.ts`,
191 `targets.test.ts` and `readout.test.ts` pin the table, the intent, the game and
192 the drawing headlessly — a lane with its randomness dialled flat puts a plate
193 where arithmetic knows, so a ray down the barrel hits, a ray 5° wide of it
194 misses, a retired plate cannot be hit, the same seed plays the same run twice
195 and the clock at zero refuses the trigger; and `boot.test.ts` boots the entry
196 itself against the stub page in `dom.ts` — a document of plain objects and a
197 clock the test moves — so a key, a button, a thumb stick, a locked mouse, a
198 canvas that gives no context, and a whole run played with the mouse (turn onto
199 the blip the scene drew, click, watch the score move) are all checked with no
200 browser in the room. `bundle.test.ts` then does the same to
185201 `static/range.<hash>.js`: the committed bundle is a build artifact kept in git,
186202 and this is what checks that the file a browser downloads boots, sizes its
187203 canvases, draws its ticks and answers a key — so a bundling mistake fails
@@ -246,7 +262,7 @@ page shows is the loop and the controls themselves, as numbers that move.
246262 | bundle | raw | ceiling | gzipped | ceiling |
247263 | ------- | ------ | ------- | ------- | ------- |
248264 | `app` | 68,457 | 75,400 | 31,661 | 34,900 |
249 | `range` | 7,067 | 7,800 | 3,258 | 3,600 |
265 | `range` | 11,575 | 12,800 | 5,241 | 5,800 |
250266
251267 The ceilings are today's sizes plus about 10%. Gzip is measured at level 9
252268 and only at level 9, so the number does not move between a laptop and the
scripts/build.test.ts +1 −1
@@ -37,7 +37,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
3737 test("the entry table is what the app ships today, each with the size it may not pass", () => {
3838 expect(ENTRIES).toEqual([
3939 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
40 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 7_800, gzip: 3_600 } },
40 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 12_800, gzip: 5_800 } },
4141 ]);
4242 });
4343
scripts/build.ts +5 −2
@@ -28,13 +28,16 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling };
2828 * The ceilings are today's sizes plus about 10%, rounded up to a round number:
2929 * enough headroom that an honest feature lands without a ceiling edit, tight
3030 * enough that a stray import of something large trips the test. Measured
31 * 2026-09-17: app 68,457 raw / 31,661 gzipped, range 7,067 raw / 3,258 gzipped.
31 * 2026-09-17: app 68,457 raw / 31,661 gzipped, range 11,575 raw / 5,241 gzipped —
32 * the range's ceiling was raised for #41, which gave it a game: the targets,
33 * the clock and the score, the shot traced through the engine's `rayBox`, and
34 * the blips the horizon draws them as.
3235 * Raising a ceiling is allowed, and it is a line in the pull that says what was
3336 * added and why.
3437 */
3538 export const ENTRIES: Entry[] = [
3639 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
37 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 7_800, gzip: 3_600 } },
40 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 12_800, gzip: 5_800 } },
3841 ];
3942
4043 /**
src/pages/range.ts +21 −13
@@ -4,10 +4,10 @@
44 * and the footer; what is left here is the range's — its one overlay, its touch
55 * buttons and its prose.
66 *
7 * It says what the page is today — the engine's loop and controls, drawn as a
8 * readout — and what it is not yet, so nobody arrives expecting the game that
9 * #13 will build on it. There is no database on this page: the leaderboard and
10 * the terminal stay Counter-Strike's.
7 * It says what the page is today — a clock, a score and plates to shoot, with
8 * the engine's loop and controls under them — and what it is not yet, so nobody
9 * arrives expecting the drawn room #13 will build on it. There is no database
10 * on this page: the leaderboard and the terminal stay Counter-Strike's.
1111 */
1212
1313 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
@@ -16,10 +16,11 @@ import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.t
1616 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">;
1717
1818 const NOTE = `
19 <h2>The range: plumbing first</h2>
20 <p>The engine's fixed step and the engine's controls, wired to a page and drawn as a
21 readout. Move and look, and watch the tick count, the frame time, the held actions and
22 the view angles follow you.</p>
19 <h2>The range: thirty seconds on the clock</h2>
20 <p>Capture the mouse, press <kbd>Enter</kbd>, and shoot the plates as they pop up: each one
21 is a box in space, traced by the engine, and the panel keeps the score, the clock and
22 your accuracy. There is no room drawn yet — a plate is a square on the horizon, and you
23 turn on the spot.</p>
2324 <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div>
2425 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch ·
2526 <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload ·
@@ -32,7 +33,7 @@ export function rangePage(options: PageOptions): string {
3233 ...options,
3334 title: "range — the engine as a package",
3435 description:
35 "A shooting range built only from this app's engine. Today it is the loop, the controls and a readout of both.",
36 "A shooting range built only from this app's engine: seeded pop-up targets, a traced shot, a thirty second clock and a score.",
3637 heading: "range",
3738 terminalLead: "The shooter's leaderboard in a terminal:",
3839 footer: "no database, no cookie: this page keeps nothing",
@@ -45,6 +46,7 @@ export function rangePage(options: PageOptions): string {
4546 ],
4647 [
4748 { action: "reload", label: "reload" },
49 { action: "start", label: "start" },
4850 { action: "fire", label: "fire" },
4951 ],
5052 ],
@@ -60,10 +62,16 @@ export function rangePage(options: PageOptions): string {
6062 bindings table, and a readout of what both say: ticks run, milliseconds a frame, which
6163 actions are held, what movement they and the thumb stick add up to, and where you are
6264 looking.</p>
63 <p>What is not here yet: the room, the pop-up targets, the shot, the score and the clock.
64 Those are the game, and they land next — with the range's own movement tuning, its own
65 atlas and its own sounds, all from the same engine. The horizon you are looking at is a
66 placeholder for that room, drawn with plain 2D calls.</p>
65 <p>There is a game on top of that now, and no renderer under it. Press <kbd>Enter</kbd> and
66 thirty seconds start: plates pop up down the lane from a seeded spawn, each with its own
67 lifetime and worth 100 to 200 points depending on how far away it stood, and a click traces
68 a ray from your eye along your own view angles with the engine's own box trace — so a hit
69 is a hit in three dimensions, not a hit on a picture. The clock ends the run, stops the
70 trigger and reports the score, the hits, the shots and the accuracy; <kbd>Enter</kbd>
71 starts the next one, and the best of this visit is kept on the page and nowhere else. What
72 still waits for the renderer is the seeing: a plate is drawn as a square on the 2D horizon,
73 projected from the angle it subtends, and you cannot move — the room, the models and the
74 walking arrive with the engine's renderer and its physics.</p>
6775 <p>Nothing on this page is stored. There is no endpoint, no table and no cookie behind it:
6876 the leaderboard, the database and the terminal belong to
6977 <a href="/">${escape("cs")}</a>, which is the game next door.</p>
src/range/boot.test.ts +115
@@ -147,3 +147,118 @@ test("a canvas with no context is a sentence on the page, not an exception", ()
147147 undo();
148148 }
149149 });
150
151 /** The fillRects of the last frame, less the sky and the ground: the plates. */
152 function platesDrawn(p: FakePage, from: number): [number, ...unknown[]][] {
153 const calls = (p.scene.paint?.calls ?? []).slice(from);
154 return calls.filter((c) => c[0] === "fillRect").slice(2) as [number, ...unknown[]][];
155 }
156
157 /** The most recent line the panel drew starting with `prefix`: this frame's. */
158 const latest = (p: FakePage, prefix: string): string =>
159 [...hudTexts(p)].reverse().find((line) => line.startsWith(prefix)) ?? "";
160
161 /** Degrees of view per unit of mouse movement: the engine's 2.2 × 0.022. */
162 const PER_COUNT = 2.2 * 0.022;
163
164 test("Enter starts a run, the clock runs and the plates pop up", () => {
165 const { p, undo } = booted();
166 try {
167 p.step(20);
168 expect(hudTexts(p)).toContain("press Enter for a 30 second run");
169 expect(hudTexts(p)).toContain("score 0 · best 0 · 0/0 hits · 0%");
170
171 p.window.fire("keydown", { code: "Enter" });
172 p.step(20); // one 50 Hz tick: 0.02 s off the clock, and the first plate up
173 expect(hudTexts(p)).toContain("30.0 s left · 1 up");
174 // Enter held down is still one run: the clock does not go back to 30.
175 p.step(200);
176 p.window.fire("keyup", { code: "Enter" });
177 expect(hudTexts(p)).toContain("29.8 s left · 1 up");
178 } finally {
179 undo();
180 }
181 });
182
183 test("a mouse plays it: turn onto a plate, click, and the score moves", () => {
184 const { p, undo } = booted();
185 try {
186 // Lock the pointer, or a mousemove turns nothing.
187 p.by("look").fire("click");
188 p.document.pointerLockElement = p.scene;
189 p.document.fire("pointerlockchange");
190
191 p.window.fire("keydown", { code: "Enter" });
192 p.step(20);
193 p.window.fire("keyup", { code: "Enter" });
194
195 // Where the scene put the one standing plate, in the frame just drawn.
196 const before = p.scene.paint?.calls.length ?? 0;
197 p.step(20);
198 const plate = platesDrawn(p, before)[0];
199 expect(plate).toBeDefined();
200 const [, left, top, size] = plate as [string, number, number, number];
201
202 // The canvas is 800 × 450, so the crosshair is at 400, 225 and a degree is
203 // five pixels. The angle from it to the plate's centre is the turn that
204 // puts the plate under the crosshair, in mouse counts.
205 const px = 450 / 90;
206 const offX = (left + size / 2 - 400) / px;
207 const offY = (top + size / 2 - 225) / px;
208 p.window.fire("mousemove", { movementX: offX / PER_COUNT, movementY: offY / PER_COUNT });
209
210 // The turn and the trigger land in the same frame, and the turn is applied
211 // first: the bullet goes where the crosshair ended up.
212 p.window.fire("keydown", { code: "KeyF" });
213 p.step(20);
214 const score = latest(p, "score");
215 expect(score).toMatch(/^score \d+ · best 0 · 1\/1 hits · 100%$/);
216 expect(Number(/^score (\d+)/.exec(score)?.[1])).toBeGreaterThanOrEqual(100);
217
218 // The trigger held down is one shot, not one a frame.
219 p.step(20);
220 expect(latest(p, "score")).toContain("1/1 hits");
221 // Letting go and pulling again is a second shot, and the plate is gone.
222 p.window.fire("keyup", { code: "KeyF" });
223 p.step(20);
224 p.window.fire("keydown", { code: "KeyF" });
225 p.step(20);
226 expect(latest(p, "score")).toContain("1/2 hits · 50%");
227 } finally {
228 undo();
229 }
230 });
231
232 test("the clock ends the run, the entry stops shooting and the best score stays", () => {
233 const { p, undo } = booted();
234 try {
235 p.window.fire("keydown", { code: "Enter" });
236 p.step(20);
237 p.window.fire("keyup", { code: "Enter" });
238 // One shot taken while the run is live, wherever it went.
239 p.window.fire("keydown", { code: "KeyF" });
240 p.step(20);
241 p.window.fire("keyup", { code: "KeyF" });
242 expect(latest(p, "score")).toContain("/1 hits");
243
244 // Thirty seconds, eight 50 Hz ticks to a frame: 188 frames of 160 ms.
245 for (let i = 0; i < 190; i++) p.step(160);
246 expect(latest(p, "run over")).toMatch(/^run over · \d+ points · Enter for another$/);
247
248 // The clock at zero refuses the trigger: still the one shot.
249 p.window.fire("keydown", { code: "KeyF" });
250 p.step(20);
251 expect(latest(p, "score")).toContain("/1 hits");
252 // And the run that just ended is the best so far, however it went.
253 const score = /^score (\d+) · best (\d+)/.exec(latest(p, "score"));
254 expect(score?.[2]).toBe(score?.[1] as string);
255
256 // Enter gives a fresh thirty seconds, and the best survives it.
257 p.window.fire("keydown", { code: "Enter" });
258 p.step(20);
259 expect(latest(p, "score")).toMatch(/^score 0 · best \d+ · 0\/0 hits · 0%$/);
260 expect(hudTexts(p)).toContain("30.0 s left · 1 up");
261 } finally {
262 undo();
263 }
264 });
src/range/main.ts +51 −10
@@ -1,9 +1,10 @@
11 /**
2 * The range's entry: the plumbing of a second game, and only the plumbing.
3 * The engine's loop runs a fixed step, the engine's controls collect the held
4 * actions and the view's turn, and `readout.ts` draws what those two say on the
5 * page's two canvases. There is no room, no target, no physics and no WebGL
6 * here — #13 builds the game on top of this.
2 * The range's entry: the wiring of a second game, and only the wiring. The
3 * engine's loop runs a fixed step, the engine's controls collect the held
4 * actions and the view's turn, `targets.ts` is the game those two play, and
5 * `readout.ts` draws the horizon, the plates and the score on the page's two
6 * canvases. There is still no room, no movement and no WebGL here: the shooter
7 * stands at the origin and turns on the spot, which is #3's and #5's to change.
78 *
89 * Nothing outside `src/engine/` and this directory is imported, and
910 * `independence.test.ts` reads these files to make sure it stays that way.
@@ -11,11 +12,15 @@
1112
1213 import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts";
1314 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
14 import { attachButtons, clamp, createControls, createLoop } from "../engine/index.ts";
15 import { EYE, createRun, report, shoot, start, step } from "./targets.ts";
16 import { angleVectors, attachButtons, clamp, createControls, createLoop } from "../engine/index.ts";
1517
1618 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
1719 const TICK = 1 / 50;
1820
21 /** A seed for the next run: the clock, which nobody has to remember or type. */
22 const freshSeed = (): number => (Date.now() & 0xffff) >>> 0;
23
1924 const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
2025
2126 /**
@@ -56,6 +61,21 @@ export function boot(): void {
5661 let ticks = 0;
5762 let frameMs = 16.7;
5863
64 // The game. It is seeded from the clock, and Enter gives it a new seed — so
65 // two runs are two different thirty seconds, and a run is still reproducible
66 // from the one number it was spawned with.
67 const run = createRun(freshSeed());
68 let best = 0;
69 // Both of these are edges, not holds: a held trigger is one shot, and a held
70 // Enter is one run. The mouse says "fire" for as long as the button is down.
71 let firing = false;
72 let starting = false;
73
74 /** Remember the score of a run that is finishing, whatever finished it. */
75 const keepBest = (): void => {
76 best = Math.max(best, run.score);
77 };
78
5979 // The mouse only turns the view under pointer lock; a finger on the stage
6080 // turns it either way, which is why the button is the only thing in the way.
6181 // A phone has no pointer to lock, and says so by throwing or by rejecting:
@@ -91,18 +111,39 @@ export function boot(): void {
91111 const [dx, dy] = controls.takeLook();
92112 yaw = wrapDegrees(yaw - dx);
93113 pitch = clamp(pitch + dy, -89, 89);
94 ticks += loop.advance(elapsed);
114 const steps = loop.advance(elapsed);
115 ticks += steps;
95116
96 // The intent #13's step function will take. Today it is drawn, not stepped,
97 // which is also how a thumb on the stick can see that it arrived.
117 // What was asked for this frame. The movement half of it is still only
118 // drawn — there is nothing to move until the engine's physics lands — and
119 // the trigger and Enter half of it is the game.
98120 const intent = readIntent(controls.held, controls.stick);
99121
122 if (intent.start && !starting) {
123 keepBest();
124 start(run, freshSeed());
125 }
126 starting = intent.start;
127
128 // The clock and the plates move in whole ticks, so a slow frame and a fast
129 // one play the same run.
130 const wasLive = run.phase === "live";
131 for (let i = 0; i < steps; i++) step(run, TICK);
132 if (wasLive && run.phase === "over") keepBest();
133
134 // One shot per press, down the barrel: the direction is the view's, and
135 // nothing else goes into it.
136 if (intent.fire && !firing) shoot(run, EYE, angleVectors(yaw, pitch).forward);
137 firing = intent.fire;
138
100139 const [sw, sh] = size(scene);
101 drawHorizon(sceneCtx, { yaw, pitch }, sw, sh);
140 drawHorizon(sceneCtx, { yaw, pitch }, sw, sh, { eye: EYE, targets: run.targets });
102141 const [hw, hh] = size(hud);
103142 drawReadout(
104143 hudCtx,
105144 {
145 run: report(run),
146 best,
106147 hz: Math.round(1 / TICK),
107148 ticks,
108149 frameMs,
src/range/readout.test.ts +100 −12
@@ -4,7 +4,11 @@
44 */
55
66 import { expect, test } from "bun:test";
7 import { drawHorizon, drawReadout, readoutLines, wrapDegrees, type Paint, type Readout } from "./readout.ts";
7 import {
8 blips, drawHorizon, drawReadout, readoutLines, wrapDegrees,
9 type Paint, type Readout, type Sight,
10 } from "./readout.ts";
11 import { EYE, createRun, report, start, step, type Target } from "./targets.ts";
812
913 type Call = [string, ...unknown[]];
1014
@@ -31,36 +35,51 @@ function recorder(): Paint & { calls: Call[]; texts: string[]; lines: Call[] } {
3135 } as Paint & { calls: Call[]; texts: string[]; lines: Call[] };
3236 }
3337
38 const RUN = { phase: "live" as const, clock: 12.34, score: 700, hits: 4, shots: 6, accuracy: 4 / 6, up: 3 };
39
3440 const READOUT: Readout = {
41 run: RUN, best: 1200,
3542 hz: 50, ticks: 1234, frameMs: 16.66, held: ["forward", "fire"],
3643 move: { forward: 1, side: -0.5 }, yaw: 45, pitch: -10, locked: true,
3744 };
3845
39 test("the panel prints the tick count, the frame time, the held actions and the angles", () => {
46 test("the panel leads with the score and the clock, then the loop and the controls", () => {
4047 const lines = readoutLines(READOUT);
41 expect(lines[0]).toBe("tick 1234 · 50 Hz fixed step");
42 expect(lines[1]).toBe("frame 16.7 ms · 60 fps");
43 expect(lines[2]).toBe("held forward fire");
44 expect(lines[3]).toBe("move 1.00 forward · -0.50 side");
45 expect(lines[4]).toBe("yaw 45.0° · pitch -10.0°");
46 expect(lines[5]).toContain("Esc");
48 expect(lines[0]).toBe("score 700 · best 1200 · 4/6 hits · 67%");
49 expect(lines[1]).toBe("12.3 s left · 3 up");
50 expect(lines[2]).toBe("tick 1234 · 50 Hz fixed step");
51 expect(lines[3]).toBe("frame 16.7 ms · 60 fps");
52 expect(lines[4]).toBe("held forward fire");
53 expect(lines[5]).toBe("move 1.00 forward · -0.50 side");
54 expect(lines[6]).toBe("yaw 45.0° · pitch -10.0°");
55 expect(lines[7]).toContain("Esc");
56 });
57
58 test("the clock line says how to start a run, how long is left, and how it went", () => {
59 const ready = readoutLines({ ...READOUT, run: { ...RUN, phase: "ready", clock: 30, up: 0 } });
60 expect(ready[1]).toBe("press Enter for a 30 second run");
61 const over = readoutLines({ ...READOUT, run: { ...RUN, phase: "over", clock: 0, up: 0 } });
62 expect(over[1]).toBe("run over · 700 points · Enter for another");
63 // No shots taken is no accuracy, not a NaN on the page.
64 const fresh = readoutLines({ ...READOUT, run: { ...RUN, score: 0, hits: 0, shots: 0, accuracy: 0 }, best: 0 });
65 expect(fresh[0]).toBe("score 0 · best 0 · 0/0 hits · 0%");
4766 });
4867
4968 test("nothing held says so, and an unlocked pointer says how to lock it", () => {
5069 const lines = readoutLines({ ...READOUT, held: [], locked: false });
51 expect(lines[2]).toBe("held nothing");
52 expect(lines[5]).toContain("click the view");
70 expect(lines[4]).toBe("held nothing");
71 expect(lines[7]).toContain("click the view");
5372 });
5473
5574 test("the yaw reads as a compass bearing however far it has been turned", () => {
5675 expect(wrapDegrees(0)).toBe(0);
5776 expect(wrapDegrees(-90)).toBe(270);
5877 expect(wrapDegrees(725)).toBe(5);
59 expect(readoutLines({ ...READOUT, yaw: -0.5 })[4]).toBe("yaw 359.5° · pitch -10.0°");
78 expect(readoutLines({ ...READOUT, yaw: -0.5 })[6]).toBe("yaw 359.5° · pitch -10.0°");
6079 });
6180
6281 test("a frame time of zero is not an infinite frame rate", () => {
63 expect(readoutLines({ ...READOUT, frameMs: 0 })[1]).toBe("frame 0.0 ms · 10000 fps");
82 expect(readoutLines({ ...READOUT, frameMs: 0 })[3]).toBe("frame 0.0 ms · 10000 fps");
6483 });
6584
6685 test("the HUD is cleared and every line is drawn once", () => {
@@ -95,3 +114,72 @@ test("the horizon follows the pitch and the compass follows the yaw", () => {
95114 expect(turned.texts).toContain("90");
96115 expect(turned.texts).not.toContain("0");
97116 });
117
118 /** A plate at a place, so the projection below is arithmetic and not a seed. */
119 const plate = (at: [number, number, number], half = 16, left = 1): Target => ({
120 id: 1,
121 box: { min: [at[0] - half, at[1] - half, at[2] - half], max: [at[0] + half, at[1] + half, at[2] + half], mat: 0 },
122 life: left,
123 span: 1,
124 points: 100,
125 distance: Math.hypot(at[0] - EYE[0], at[1] - EYE[1], at[2] - EYE[2]),
126 });
127
128 const sight = (targets: Target[]): Sight => ({ eye: EYE, targets });
129
130 test("a plate dead ahead is a blip on the crosshair, as wide as its angle", () => {
131 // 900 × 450, so a degree is 5 px. The plate is 512 units out, level with the
132 // eye at [0, 0, 64], and 16 units from centre to edge: 2·atan(16/512) =
133 // 3.5798°, which is 17.9 px.
134 const [blip] = blips({ yaw: 0, pitch: 0 }, sight([plate([512, 0, 64])]), 900, 450);
135 expect(blip?.x).toBeCloseTo(450, 6);
136 expect(blip?.y).toBeCloseTo(225, 6);
137 expect(blip?.size).toBeCloseTo(17.899, 3);
138 expect(blip?.left).toBe(1);
139 });
140
141 test("a blip sits where the angle off the crosshair puts it, the compass's way round", () => {
142 const at = (yaw: number, pitch: number, target: Target) =>
143 blips({ yaw, pitch }, sight([target]), 900, 450)[0];
144 // North of a shooter facing east is 45° to the left: 45 × 5 px.
145 expect(at(0, 0, plate([512, 512, 64]))?.x).toBeCloseTo(450 - 45 * 5, 6);
146 // Turning to face it puts it back on the crosshair.
147 expect(at(45, 0, plate([512, 512, 64]))?.x).toBeCloseTo(450, 6);
148 // 100 units above the eye at 512 out is atan(100/512) = 11.05146° up.
149 expect(at(0, 0, plate([512, 0, 164]))?.y).toBeCloseTo(225 - 11.05146 * 5, 3);
150 // Looking down 10° lifts everything 10° up the screen, exactly as it lifts
151 // the horizon: a plate level with the eye rides on it.
152 expect(at(0, 10, plate([512, 0, 64]))?.y).toBeCloseTo(225 - 50, 6);
153 // A plate at your back, and one far below the screen, are not drawn at all.
154 expect(blips({ yaw: 0, pitch: 0 }, sight([plate([-512, 0, 64])]), 900, 450)).toEqual([]);
155 expect(blips({ yaw: 0, pitch: -80 }, sight([plate([512, 0, 64])]), 900, 450)).toEqual([]);
156 });
157
158 test("the horizon draws one square per standing plate, dimmer as it runs out", () => {
159 const bare = recorder();
160 drawHorizon(bare, { yaw: 0, pitch: 0 }, 900, 450);
161 const squares = (paint: ReturnType<typeof recorder>): Call[] =>
162 paint.calls.filter((c) => c[0] === "fillRect").slice(2); // the first two are sky and ground
163 expect(squares(bare)).toEqual([]);
164
165 const drawn = recorder();
166 drawHorizon(drawn, { yaw: 0, pitch: 0 }, 900, 450, sight([plate([512, 0, 64]), plate([512, 0, 164], 16, 0.2)]));
167 const [near, going] = squares(drawn);
168 expect(near?.[1]).toBeCloseTo(450 - 17.899 / 2, 3);
169 expect(near?.[2]).toBeCloseTo(225 - 17.899 / 2, 3);
170 expect(going?.[2]).toBeLessThan(Number(near?.[2]));
171 expect(squares(drawn).length).toBe(2);
172 });
173
174 test("a run's own targets are what the scene draws, and they go when the run does", () => {
175 const run = createRun(2026);
176 start(run);
177 for (let i = 0; i < 60; i++) step(run, 0.02);
178 expect(run.targets.length).toBeGreaterThan(0);
179 const paint = recorder();
180 drawHorizon(paint, { yaw: 0, pitch: 0 }, 900, 450, { eye: EYE, targets: run.targets });
181 // Every plate this run has up is somewhere on the 160° the scene covers.
182 expect(blips({ yaw: 0, pitch: 0 }, { eye: EYE, targets: run.targets }, 900, 450).length).toBe(run.targets.length);
183 expect(paint.calls.filter((c) => c[0] === "fillRect").length).toBe(2 + run.targets.length);
184 expect(readoutLines({ ...READOUT, run: report(run) })[1]).toContain(`${run.targets.length} up`);
185 });
src/range/readout.ts +89 −9
@@ -1,15 +1,18 @@
11 /**
22 * What the range draws today: a horizon on the scene canvas so you can see
3 * where you are looking, and a panel of numbers on the HUD canvas so you can
4 * see what the loop and the controls are doing. Plain 2D calls — there is no
5 * WebGL here, and no room, no targets and no physics either. Those are #13's.
3 * where you are looking, the standing targets projected onto it as blips, and a
4 * panel on the HUD canvas with the score, the clock and what the loop and the
5 * controls are doing. Plain 2D calls — there is no WebGL here, and no room and
6 * no model: a blip is a target's box turned into an angle off the crosshair,
7 * not a second renderer. The drawn room is #5's, and #13's after that.
68 *
79 * It takes the calls it makes as a parameter, typed by exactly those calls, so
810 * a stub records them in a test and a real `CanvasRenderingContext2D` fits
911 * without a cast. It reads a `Readout` and draws; it changes nothing.
1012 */
1113
12 import { angleDelta, clamp } from "../engine/index.ts";
14 import { angleDelta, clamp, deg, type Vec3 } from "../engine/index.ts";
15 import { EYE, type Report, type Target } from "./targets.ts";
1316
1417 /** The 2D calls this module makes, and nothing else. */
1518 export type Paint = {
@@ -32,6 +35,10 @@ export type View = { yaw: number; pitch: number };
3235
3336 /** Everything the panel prints, gathered by `main.ts` once a frame. */
3437 export type Readout = View & {
38 /** The run: the score, the clock and what they are made of. */
39 run: Report;
40 /** The best score of this visit. Nothing is stored, so it starts at zero. */
41 best: number;
3542 /** Seconds of simulation per tick, as the rate it is usually said at. */
3643 hz: number;
3744 /** Ticks the loop has run since the page booted. */
@@ -51,6 +58,8 @@ const GROUND = "#17202c";
5158 const LINE = "#2e3a4c";
5259 const TEXT = "#e7eaf0";
5360 const DIM = "#9aa4b4";
61 const PLATE = "#e2b04a";
62 const PLATE_GOING = "#8a6a2a";
5463
5564 /** A 90° view, vertically: how many pixels one degree is worth. */
5665 const perDegree = (height: number): number => height / 90;
@@ -58,9 +67,24 @@ const perDegree = (height: number): number => height / 90;
5867 /** Degrees into 0..360, so the compass reads like a compass. */
5968 export const wrapDegrees = (angle: number): number => ((angle % 360) + 360) % 360;
6069
70 /** What the score line says about the run, whichever part of one it is in. */
71 export function scoreLine(run: Report, best: number): string {
72 const percent = (run.accuracy * 100).toFixed(0);
73 return `score ${run.score} · best ${best} · ${run.hits}/${run.shots} hits · ${percent}%`;
74 }
75
76 /** What the clock line says: how to start one, how long is left, or how it went. */
77 export function clockLine(run: Report): string {
78 if (run.phase === "ready") return `press Enter for a ${run.clock.toFixed(0)} second run`;
79 if (run.phase === "live") return `${run.clock.toFixed(1)} s left · ${run.up} up`;
80 return `run over · ${run.score} points · Enter for another`;
81 }
82
6183 /** The panel's lines, in order. Separate from the drawing so a test can read them. */
6284 export function readoutLines(r: Readout): string[] {
6385 return [
86 scoreLine(r.run, r.best),
87 clockLine(r.run),
6488 `tick ${r.ticks} · ${r.hz} Hz fixed step`,
6589 `frame ${r.frameMs.toFixed(1)} ms · ${(1000 / Math.max(0.1, r.frameMs)).toFixed(0)} fps`,
6690 `held ${r.held.length === 0 ? "nothing" : r.held.join(" ")}`,
@@ -70,13 +94,62 @@ export function readoutLines(r: Readout): string[] {
7094 ];
7195 }
7296
97 /** What is standing in the lane, and where it is looked at from. */
98 export type Sight = { eye: Vec3; targets: readonly Target[] };
99
100 /** Nothing up: the lane before a run, and the default for a caller with no game. */
101 export const EMPTY_SIGHT: Sight = { eye: EYE, targets: [] };
102
103 /** One target as the screen has it: a square, and how much of its life is left. */
104 export type Blip = { id: number; x: number; y: number; size: number; left: number; points: number };
105
73106 /**
74 * The scene: sky, ground, the horizon where the pitch puts it and a compass
75 * tick every 15° of yaw. It is a placeholder for #13's room, and it is drawn
76 * from the same two angles the readout prints, so what you see and what it
77 * says cannot disagree.
107 * A target projected onto the horizon: the angle from the crosshair to its
108 * centre, in the same degrees-to-pixels the compass is drawn in, and an angular
109 * size from how wide its box is at that distance — 2·atan(half / distance).
110 * Anything more than a right angle off the view is behind you and is dropped,
111 * as is anything the screen does not reach.
78112 */
79 export function drawHorizon(paint: Paint, view: View, width: number, height: number): void {
113 export function blips(view: View, sight: Sight, width: number, height: number): Blip[] {
114 const px = perDegree(height);
115 const found: Blip[] = [];
116 for (const target of sight.targets) {
117 const to: Vec3 = [
118 (target.box.min[0] + target.box.max[0]) / 2 - sight.eye[0],
119 (target.box.min[1] + target.box.max[1]) / 2 - sight.eye[1],
120 (target.box.min[2] + target.box.max[2]) / 2 - sight.eye[2],
121 ];
122 const flat = Math.hypot(to[0], to[1]);
123 const distance = Math.hypot(flat, to[2]);
124 if (distance < 1e-6) continue;
125 const bearing = deg(Math.atan2(to[1], to[0]));
126 // Pitch is positive downwards, so something above the eye is a negative one.
127 const rise = -deg(Math.atan2(to[2], flat));
128 const off = angleDelta(bearing, view.yaw);
129 if (Math.abs(off) > 90) continue;
130 const half = (target.box.max[1] - target.box.min[1]) / 2;
131 const size = Math.max(3, deg(2 * Math.atan(half / distance)) * px);
132 const x = width / 2 + off * px;
133 const y = height / 2 + (rise - view.pitch) * px;
134 if (x < -size || x > width + size || y < -size || y > height + size) continue;
135 found.push({ id: target.id, x, y, size, left: target.span > 0 ? target.life / target.span : 0, points: target.points });
136 }
137 return found;
138 }
139
140 /**
141 * The scene: sky, ground, the horizon where the pitch puts it, a compass tick
142 * every 15° of yaw, and the standing targets as squares on it. It is a
143 * placeholder for the room #5 draws, and every part of it comes from the same
144 * two angles the panel prints, so what you see and what it says cannot disagree.
145 */
146 export function drawHorizon(
147 paint: Paint,
148 view: View,
149 width: number,
150 height: number,
151 sight: Sight = EMPTY_SIGHT,
152 ): void {
80153 const px = perDegree(height);
81154 // Pitch is positive downwards, so looking down lifts the horizon up the screen.
82155 const horizon = clamp(height / 2 - view.pitch * px, -height, height * 2);
@@ -108,6 +181,13 @@ export function drawHorizon(paint: Paint, view: View, width: number, height: num
108181 paint.fillStyle = DIM;
109182 paint.fillText(`${angle}`, x + 4, horizon + 10);
110183 }
184
185 // The plates. A square the size the angle says, dimmed once it is most of
186 // the way through its life so a run has a rhythm you can see.
187 for (const blip of blips(view, sight, width, height)) {
188 paint.fillStyle = blip.left > 0.35 ? PLATE : PLATE_GOING;
189 paint.fillRect(blip.x - blip.size / 2, blip.y - blip.size / 2, blip.size, blip.size);
190 }
111191 }
112192
113193 /** The HUD: the panel of numbers, and the crosshair the view turns around. */
src/range/targets.test.ts added +258
@@ -0,0 +1,258 @@
1 /**
2 * The game, played headlessly. Every number in here is one a person can check
3 * by hand: a lane with its randomness dialled flat puts a plate at a place
4 * arithmetic knows, and the shots are taken along vectors written out in full.
5 */
6
7 import { expect, test } from "bun:test";
8 import { angleVectors, dist, type Vec3 } from "../engine/index.ts";
9 import {
10 accuracy, createRun, EYE, report, shoot, start, step, STANDARD, type Rules, type Run,
11 } from "./targets.ts";
12
13 /**
14 * A lane with no randomness left in it: one plate at a time, dead ahead at 512
15 * units, centred on the eye, 32 units on a side, up for a whole second.
16 * Everything below that is not about the spawn uses this, so the geometry is a
17 * sum rather than a seed.
18 */
19 const FLAT: Rules = {
20 ...STANDARD,
21 runTime: 10,
22 atOnce: 1,
23 gap: 0,
24 life: { min: 1, max: 1 },
25 depth: { min: 512, max: 512 },
26 spread: 0,
27 height: { min: 64, max: 64 },
28 half: 16,
29 };
30
31 /** A live run on `FLAT` with its first plate already up. */
32 function lane(): Run {
33 const run = createRun(1, FLAT);
34 start(run);
35 step(run, 0.02);
36 return run;
37 }
38
39 const down = (yaw: number, pitch: number): Vec3 => angleVectors(yaw, pitch).forward;
40
41 test("a plate pops up down the lane, as a box arithmetic can place", () => {
42 const run = lane();
43 expect(run.targets.length).toBe(1);
44 const target = run.targets[0] as NonNullable<(typeof run.targets)[0]>;
45 expect(target.id).toBe(1);
46 expect(target.box.min).toEqual([496, -16, 48]);
47 expect(target.box.max).toEqual([528, 16, 80]);
48 // 512 units out, level with the eye at [0, 0, 64]: the lane is that long.
49 expect(target.distance).toBeCloseTo(512, 6);
50 // At the near edge of the depth band, a plate is worth the near points.
51 expect(target.points).toBe(100);
52 // It popped up during this step, so none of its second has been spent yet.
53 expect(target.span).toBe(1);
54 expect(target.life).toBe(1);
55 });
56
57 test("the same seed spawns the same targets twice, and another seed does not", () => {
58 const play = (seed: number): string => {
59 const run = createRun(seed);
60 start(run);
61 const seen: string[] = [];
62 for (let i = 0; i < 250; i++) {
63 step(run, 0.02);
64 for (const t of run.targets) seen.push(`${t.id} ${t.box.min.join()} ${t.span.toFixed(6)}`);
65 }
66 return seen.join("\n");
67 };
68 expect(play(2026)).toBe(play(2026));
69 expect(play(2026)).not.toBe(play(7));
70 // Restarting a run from the same seed replays it: the plates are the plates.
71 const run = createRun(99);
72 start(run);
73 step(run, 0.02);
74 const first = run.targets.map((t) => t.box.min);
75 start(run, 99);
76 step(run, 0.02);
77 expect(run.targets.map((t) => t.box.min)).toEqual(first);
78 });
79
80 test("a ray down the barrel hits the plate, and a ray past it misses", () => {
81 const run = lane();
82 const hit = shoot(run, EYE, down(0, 0));
83 expect(hit?.target?.id).toBe(1);
84 // It stopped on the near face: 512 out less the 16-unit half.
85 expect(hit?.distance).toBeCloseTo(496, 6);
86 expect(hit?.point[0]).toBeCloseTo(496, 6);
87 expect(hit?.points).toBe(100);
88 expect(run.score).toBe(100);
89 expect(run.hits).toBe(1);
90 expect(run.shots).toBe(1);
91
92 // 5° off at 512 units is 512·tan5° = 44.8 units wide of a plate 16 units
93 // from its centre to its edge, so the bullet goes by.
94 step(run, 0.02);
95 const miss = shoot(run, EYE, down(5, 0));
96 expect(miss?.target).toBeNull();
97 expect(miss?.points).toBe(0);
98 // A miss carries to the end of the bullet's range and is still a shot.
99 expect(miss?.distance).toBeCloseTo(STANDARD.range, 3);
100 expect(run.score).toBe(100);
101 expect(run.shots).toBe(2);
102
103 // Pitch is positive downwards, and the plate is level with the eye.
104 expect(shoot(run, EYE, down(0, 20))?.target).toBeNull();
105 expect(shoot(run, EYE, down(0, 0))?.target?.id).toBe(2);
106 });
107
108 test("a plate that has been hit is gone, and the next one is a new one", () => {
109 const run = lane();
110 const first = run.targets[0];
111 expect(shoot(run, EYE, down(0, 0))?.target).toBe(first ?? null);
112 expect(run.targets).toEqual([]);
113 // Shooting the empty lane is a miss, not a second hit on a dead plate.
114 expect(shoot(run, EYE, down(0, 0))?.target).toBeNull();
115 expect(run.hits).toBe(1);
116 step(run, 0.02);
117 expect(run.targets[0]?.id).toBe(2);
118 });
119
120 test("a retired plate cannot be hit: its lifetime ran out and it dropped", () => {
121 const run = createRun(1, { ...FLAT, atOnce: 1, gap: 4 });
122 start(run);
123 step(run, 0.02);
124 expect(run.targets.length).toBe(1);
125 // One second of life, and the gap keeps the next one four seconds away.
126 step(run, 1.1);
127 expect(run.targets).toEqual([]);
128 const shot = shoot(run, EYE, down(0, 0));
129 expect(shot?.target).toBeNull();
130 expect(run.hits).toBe(0);
131 expect(run.shots).toBe(1);
132 });
133
134 test("the lane fills to its limit and no further, one plate per gap", () => {
135 const run = createRun(5, { ...STANDARD, atOnce: 3, gap: 0.5 });
136 start(run);
137 step(run, 0.02);
138 expect(run.targets.length).toBe(1);
139 step(run, 0.3);
140 expect(run.targets.length).toBe(1);
141 step(run, 0.3);
142 expect(run.targets.length).toBe(2);
143 step(run, 0.5);
144 expect(run.targets.length).toBe(3);
145 step(run, 0.5);
146 expect(run.targets.length).toBe(3);
147 expect(run.spawned).toBe(3);
148 });
149
150 test("a farther plate is worth more, in whole points, linear in the depth", () => {
151 const near = createRun(1, { ...FLAT, depth: { min: 400, max: 400 } });
152 const far = createRun(1, { ...STANDARD, depth: { min: 320, max: 1200 }, spread: 0 });
153 start(near);
154 step(near, 0.02);
155 expect(near.targets[0]?.points).toBe(100);
156 start(far);
157 step(far, 0.02);
158 const plate = far.targets[0] as NonNullable<(typeof far.targets)[0]>;
159 const at = (plate.box.min[0] + plate.box.max[0]) / 2;
160 expect(plate.points).toBe(Math.round(100 + ((at - 320) / 880) * 100));
161 expect(plate.points).toBeGreaterThanOrEqual(100);
162 expect(plate.points).toBeLessThanOrEqual(200);
163 });
164
165 test("accuracy is what a hand count says, and no shots is not a division", () => {
166 expect(accuracy(0, 0)).toBe(0);
167 expect(accuracy(3, 4)).toBe(0.75);
168 const run = lane();
169 // Three taken, two on the plate: 2/3.
170 expect(shoot(run, EYE, down(0, 0))?.target).not.toBeNull();
171 step(run, 0.02);
172 expect(shoot(run, EYE, down(40, 0))?.target).toBeNull();
173 expect(shoot(run, EYE, down(0, 0))?.target).not.toBeNull();
174 const card = report(run);
175 expect([card.hits, card.shots]).toEqual([2, 3]);
176 expect(card.accuracy).toBeCloseTo(2 / 3, 12);
177 expect(card.score).toBe(200);
178 });
179
180 test("the clock runs the run: thirty seconds, then it is over and the lane is empty", () => {
181 const run = createRun(3);
182 expect(report(run)).toEqual({ phase: "ready", clock: 30, score: 0, hits: 0, shots: 0, accuracy: 0, up: 0 });
183 start(run);
184 for (let i = 0; i < 1499; i++) step(run, 0.02);
185 expect(run.phase).toBe("live");
186 expect(run.clock).toBeCloseTo(0.02, 6);
187 expect(run.targets.length).toBeGreaterThan(0);
188 step(run, 0.02);
189 expect(run.phase).toBe("over");
190 expect(run.clock).toBe(0);
191 expect(run.targets).toEqual([]);
192 // Over stays over: another step moves nothing.
193 step(run, 5);
194 expect(report(run).phase).toBe("over");
195 });
196
197 test("the clock at zero refuses a shot, and so does the lane before Enter", () => {
198 const ready = createRun(3);
199 expect(shoot(ready, EYE, down(0, 0))).toBeNull();
200 expect(ready.shots).toBe(0);
201
202 const run = createRun(3, FLAT);
203 start(run);
204 for (let i = 0; i < 500; i++) step(run, 0.02);
205 expect(run.phase).toBe("over");
206 const before = report(run);
207 expect(shoot(run, EYE, down(0, 0))).toBeNull();
208 expect(report(run)).toEqual(before);
209
210 // And Enter gives a fresh one that accepts shots again.
211 start(run, 4);
212 step(run, 0.02);
213 expect(shoot(run, EYE, down(0, 0))?.target?.id).toBe(1);
214 expect(report(run).score).toBe(100);
215 });
216
217 test("a direction with no direction in it is refused, not a miss", () => {
218 const run = lane();
219 expect(shoot(run, EYE, [0, 0, 0])).toBeNull();
220 expect(shoot(run, EYE, [Number.NaN, 0, 0])).toBeNull();
221 expect(run.shots).toBe(0);
222 // A direction of any length aims the same way: it is normalised here.
223 expect(shoot(run, EYE, [4096, 0, 0])?.target?.id).toBe(1);
224 });
225
226 test("a step that is not a positive number of seconds does nothing", () => {
227 const run = lane();
228 const before = { ...report(run), clock: run.clock };
229 for (const dt of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) step(run, dt);
230 expect(run.clock).toBe(before.clock);
231 expect(run.targets.length).toBe(1);
232 });
233
234 test("the plates stand in front of the shooter, inside the lane the rules give", () => {
235 const run = createRun(31_337);
236 start(run);
237 const seen: Vec3[] = [];
238 for (let i = 0; i < 1500; i++) {
239 step(run, 0.02);
240 for (const t of run.targets)
241 seen.push([
242 (t.box.min[0] + t.box.max[0]) / 2,
243 (t.box.min[1] + t.box.max[1]) / 2,
244 (t.box.min[2] + t.box.max[2]) / 2,
245 ]);
246 }
247 expect(seen.length).toBeGreaterThan(0);
248 for (const at of seen) {
249 expect(at[0]).toBeGreaterThanOrEqual(STANDARD.depth.min);
250 expect(at[0]).toBeLessThanOrEqual(STANDARD.depth.max);
251 expect(Math.abs(at[1])).toBeLessThanOrEqual(STANDARD.spread);
252 expect(at[2]).toBeGreaterThanOrEqual(STANDARD.height.min);
253 expect(at[2]).toBeLessThanOrEqual(STANDARD.height.max);
254 // Never on top of the shooter: every plate is a shot away.
255 expect(dist(EYE, at)).toBeGreaterThan(300);
256 }
257 expect(run.spawned).toBeGreaterThan(20);
258 });
src/range/targets.ts added +274
@@ -0,0 +1,274 @@
1 /**
2 * The range's game: pop-up targets, a traced shot, a clock and a score. Plain
3 * data in, plain data out — no canvas, no document, no room, no physics. A
4 * `Run` is a mutable record and the three verbs below move it: `start` begins
5 * one, `step` advances the clock and the targets, `shoot` traces a bullet.
6 * Nothing in here draws anything and nothing in here reads a clock of its own,
7 * which is why the whole game plays through in a test in a millisecond.
8 *
9 * Units are the engine's: Source units, one unit about an inch, X east, Y north
10 * and Z up. The shooter stands at the origin looking down +X (yaw 0) with an
11 * eye at `EYE`, and the targets stand down the lane in front of that. Seconds
12 * everywhere for time; degrees are the view's business, not this module's.
13 *
14 * Everything random comes from the engine's `rng`, drawn in one fixed order, so
15 * a seed reproduces a run exactly: the same seed pops the same plates up at the
16 * same moments with the same lifetimes.
17 */
18
19 import {
20 add, lerp, length, normalize, scale, segmentLength, traceWorld,
21 rng, type Box, type Vec3,
22 } from "../engine/index.ts";
23
24 /** The shooter's eye: standing height, at the origin. The range's own tuning. */
25 export const EYE: Vec3 = [0, 0, 64];
26
27 /**
28 * What a target is made of, as far as a renderer will ever care. The engine's
29 * `Box` carries a material and nothing here paints one, so every plate is 0 —
30 * #13 gives them a surface out of the range's own atlas.
31 */
32 export const PLATE_MAT = 0;
33
34 /** The dial settings of one kind of run. Plain numbers, all of them tunable. */
35 export type Rules = {
36 /** How long one run lasts, in seconds. */
37 runTime: number;
38 /** Most plates standing at once. */
39 atOnce: number;
40 /** Seconds between one pop-up and the next. */
41 gap: number;
42 /** How long a plate stays up, in seconds: the shortest and the longest. */
43 life: { min: number; max: number };
44 /** How far down the lane a plate may stand, in units. */
45 depth: { min: number; max: number };
46 /** Half the width of the lane, in units: a plate stands within ±this of it. */
47 spread: number;
48 /** The band a plate's centre stands in, in units above the floor. */
49 height: { min: number; max: number };
50 /** Half the side of a plate, in units: they are cubes, so this is all of it. */
51 half: number;
52 /** What a plate is worth at `depth.min`, and at `depth.max`. */
53 points: { near: number; far: number };
54 /** How far a bullet carries, in units. Hitscan: it arrives or it does not. */
55 range: number;
56 };
57
58 /**
59 * The run a person gets by pressing Enter. Thirty seconds, three plates up at
60 * a time, each worth between 100 and 200 points depending on how far away it
61 * stood — a 36-unit cube at 1,200 units is about 1.7° wide, which is a shot
62 * worth taking with a mouse and not one worth taking by accident.
63 */
64 export const STANDARD: Rules = {
65 runTime: 30,
66 atOnce: 3,
67 gap: 0.6,
68 life: { min: 1.1, max: 2.4 },
69 depth: { min: 320, max: 1200 },
70 spread: 420,
71 height: { min: 28, max: 148 },
72 half: 18,
73 points: { near: 100, far: 200 },
74 range: 4096,
75 };
76
77 /** One plate, standing. */
78 export type Target = {
79 /** 1, 2, 3… in the order they popped up this run. */
80 id: number;
81 /** Where it is, as the engine traces it. */
82 box: Box;
83 /** Seconds left before it drops. */
84 life: number;
85 /** Seconds it was given, so a readout can show how much of that is left. */
86 span: number;
87 /** What hitting it is worth. Farther is worth more. */
88 points: number;
89 /** Units from `EYE` to its centre, kept because every reader wants it. */
90 distance: number;
91 };
92
93 /** Before the first run, during one, and after the clock ran out. */
94 export type Phase = "ready" | "live" | "over";
95
96 export type Run = {
97 rules: Rules;
98 phase: Phase;
99 /** Seconds left in the run. `runTime` before it starts, zero once it is over. */
100 clock: number;
101 /** Seconds until the next plate pops up. */
102 nextIn: number;
103 targets: Target[];
104 /** Plates popped up this run, which is also where the next id comes from. */
105 spawned: number;
106 shots: number;
107 hits: number;
108 score: number;
109 /** The seed this run was spawned from. The same one replays it exactly. */
110 seed: number;
111 random: () => number;
112 };
113
114 /** A run that has not started: the clock is full and the lane is empty. */
115 export function createRun(seed: number, rules: Rules = STANDARD): Run {
116 return {
117 rules,
118 phase: "ready",
119 clock: rules.runTime,
120 nextIn: 0,
121 targets: [],
122 spawned: 0,
123 shots: 0,
124 hits: 0,
125 score: 0,
126 seed: seed >>> 0,
127 random: rng(seed),
128 };
129 }
130
131 /**
132 * Begin a run: the score, the shots and the lane are cleared and the clock is
133 * wound back up. The seed is the run's, so passing the same one twice plays the
134 * same thirty seconds twice; a game hands in a fresh one each time it starts.
135 */
136 export function start(run: Run, seed: number = run.seed): void {
137 run.seed = seed >>> 0;
138 run.random = rng(run.seed);
139 run.phase = "live";
140 run.clock = run.rules.runTime;
141 run.nextIn = 0;
142 run.targets = [];
143 run.spawned = 0;
144 run.shots = 0;
145 run.hits = 0;
146 run.score = 0;
147 }
148
149 /** One plate, drawn from the run's rng in this order: depth, side, height, life. */
150 function pop(run: Run): Target {
151 const r = run.rules;
152 const depth = lerp(r.depth.min, r.depth.max, run.random());
153 const side = lerp(-r.spread, r.spread, run.random());
154 const centre = lerp(r.height.min, r.height.max, run.random());
155 const span = lerp(r.life.min, r.life.max, run.random());
156 const at: Vec3 = [depth, side, centre];
157 const reach = r.depth.max - r.depth.min;
158 // Points are linear in how far down the lane it stood, rounded to a whole.
159 const points = Math.round(lerp(r.points.near, r.points.far, reach > 0 ? (depth - r.depth.min) / reach : 0));
160 run.spawned += 1;
161 return {
162 id: run.spawned,
163 box: {
164 min: [at[0] - r.half, at[1] - r.half, at[2] - r.half],
165 max: [at[0] + r.half, at[1] + r.half, at[2] + r.half],
166 mat: PLATE_MAT,
167 },
168 life: span,
169 span,
170 points,
171 distance: segmentLength(EYE, at),
172 };
173 }
174
175 /**
176 * A clock this close to zero is zero. A tick is a binary fraction that is not
177 * a decimal one — 1,500 steps of 0.02 s do not land exactly on 30 — and a run
178 * that ends a tenth of a picosecond late would be a bug nobody could see.
179 */
180 const SPENT = 1e-9;
181
182 /**
183 * Advance `dt` seconds: the clock runs down, standing plates lose that much of
184 * their lifetime and the expired ones drop, and new ones pop up on the gap
185 * until the lane is full. When the clock reaches zero the run is over and the
186 * lane is cleared — a plate cannot be shot after time, because it is not there.
187 *
188 * Only a live run moves. A step outside one, or with a dt that is not a
189 * positive number, does nothing at all.
190 */
191 export function step(run: Run, dt: number): void {
192 if (run.phase !== "live" || !Number.isFinite(dt) || dt <= 0) return;
193 run.clock = Math.max(0, run.clock - dt);
194 for (const target of run.targets) target.life -= dt;
195 run.targets = run.targets.filter((target) => target.life > 0);
196
197 run.nextIn -= dt;
198 while (run.clock > SPENT && run.nextIn <= 0 && run.targets.length < run.rules.atOnce) {
199 run.targets.push(pop(run));
200 run.nextIn += run.rules.gap;
201 }
202 if (run.clock <= SPENT) {
203 run.phase = "over";
204 run.clock = 0;
205 run.nextIn = 0;
206 run.targets = [];
207 }
208 }
209
210 /** What one trigger pull did. */
211 export type Shot = {
212 /** The plate the bullet reached, or null when it went past everything. */
213 target: Target | null;
214 /** Where the bullet stopped: on a plate, or at the end of its range. */
215 point: Vec3;
216 /** Units from the eye to that point. */
217 distance: number;
218 /** What this shot scored. Zero on a miss. */
219 points: number;
220 };
221
222 /**
223 * Fire from `eye` along `direction` — any length; it is normalised here — and
224 * fold the shot into the run. The nearest plate along the segment is hit,
225 * scored and taken down; anything else is a miss, and a miss still counts as a
226 * shot, which is what makes the accuracy mean something.
227 *
228 * Returns null for a shot that was refused rather than taken: a run that is not
229 * live (the clock at zero, or Enter not pressed yet) and a direction with no
230 * direction in it. A refused shot changes nothing, counts as nothing.
231 */
232 export function shoot(run: Run, eye: Vec3, direction: Vec3): Shot | null {
233 if (run.phase !== "live") return null;
234 const aim = normalize(direction);
235 if (length(aim) < 0.5) return null;
236
237 const to = add(eye, scale(aim, run.rules.range));
238 run.shots += 1;
239 const hit = traceWorld(eye, to, run.targets.map((target) => target.box));
240 const target = hit === null ? null : run.targets.find((t) => t.box === hit.what) ?? null;
241 const point = hit === null ? to : hit.point;
242 if (target !== null) {
243 run.hits += 1;
244 run.score += target.points;
245 run.targets = run.targets.filter((t) => t !== target);
246 }
247 return { target, point, distance: segmentLength(eye, point), points: target?.points ?? 0 };
248 }
249
250 /** Hits over shots, 0..1. No shots is no accuracy, not a division by zero. */
251 export const accuracy = (hits: number, shots: number): number => (shots > 0 ? hits / shots : 0);
252
253 /** Everything a scoreboard or a readout needs, and nothing it would have to compute. */
254 export type Report = {
255 phase: Phase;
256 /** Seconds left, never below zero. */
257 clock: number;
258 score: number;
259 hits: number;
260 shots: number;
261 accuracy: number;
262 /** Plates standing right now. */
263 up: number;
264 };
265
266 export const report = (run: Run): Report => ({
267 phase: run.phase,
268 clock: Math.max(0, run.clock),
269 score: run.score,
270 hits: run.hits,
271 shots: run.shots,
272 accuracy: accuracy(run.hits, run.shots),
273 up: run.targets.length,
274 });
src/server.test.ts +1 −1
@@ -77,7 +77,7 @@ test("the range page carries every id and action its entry reaches for", async (
7777 // the engine's controls; nothing type-checks that, so the page is checked.
7878 const body = await (await fetch(base + "/range")).text();
7979 for (const id of ["scene", "hud", "fps", "touch", "note", "look"]) expect(body).toContain(`id="${id}"`);
80 for (const action of ["duck", "jump", "walk", "reload", "fire"])
80 for (const action of ["duck", "jump", "walk", "reload", "start", "fire"])
8181 expect(body).toContain(`data-action="${action}"`);
8282 // cs's ids are not on this page: a shared shell is not a shared game.
8383 for (const id of ["menu", "pause", "buy", "stats", "play"]) expect(body).not.toContain(`id="${id}"`);
static/range.06e0ed221e.js deleted −1

Diff not shown — a line 7,058 characters wide (limit 2,000). −1

View file
static/range.422be53cf7.js added +1

Diff not shown — a line 11,560 characters wide (limit 2,000). +1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs