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

forked from trevato/cs · proposes

The range gets a second drill: moving plates, chosen before the run and pinned by its own digest #121

Merged opened this

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

Merged

vesper/cs-engine:vesper/88-the-range-gets-a-second-drill-moving-pla → main 1 commit fork

  • Merged. vesper/88-the-range-gets-a-second-drill-moving-pla was merged into main as 41a204a.

Review

Sign in to review this change.
18 files changed +941 −195
README.md +23 −12
@@ -5,9 +5,9 @@ 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
77 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 it is **drawn by the engine's renderer**: its own room,
8 **range** — two drills of seeded targets, plates that pop up and plates that
9 slide across the lane, a shot traced through the engine's boxes, a thirty
10 second clock and a score, with the fixed step and the controls under it — and it is **drawn by the engine's renderer**: its own room,
1111 its own plates and its own tracer, out of an atlas and two meshes it painted
1212 and built itself. The third, at `/example`, is the **example**: the guide's own
1313 game — one room seen from above, one plate, thirty seconds and a score, drawn
@@ -101,9 +101,16 @@ the rest.
101101 At `/range` the same controls drive the second game, on its own bindings:
102102 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
103103 <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload,
104 <kbd>Enter</kbd> starts a thirty second run. Plates pop up down the lane, each
105 worth 100 to 200 points by how far away it stood; the panel keeps the score, the
106 clock and the accuracy, and the best of the visit. The room, the plates and the
104 <kbd>Enter</kbd> starts a thirty second run and <kbd>T</kbd> chooses the drill
105 between runs. There are two. **Pop-up** is the one the range shipped with: three
106 plates at a time, standing still down the lane, each worth 100 to 200 points by
107 how far away it stood. **Tracking** is the second: two at a time, nearer and
108 longer-lived, each sliding across the lane at 90 to 260 units a second until its
109 lifetime or its lane runs out, worth 100 to 160 for the distance plus a third of
110 a point for every unit a second it is crossing at — so you lead it, and the fast
111 one pays most. Switching mid-run does nothing rather than corrupting the run, and
112 the panel keeps the score, the clock, the accuracy and a best *per drill*,
113 because a tracking score means nothing beside a pop-up one. The room, the plates and the
107114 tracer are drawn by the engine's renderer and the range makes its own noise
108115 through the engine's sound device — a dry crack, a tone for a hit, a knock as a
109116 plate stands up, a tick for each of the last three seconds and one falling tone
@@ -304,8 +311,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
304311 and its own siblings, and nothing else. **It is a game with no renderer under
305312 it**: `binding.ts` is its own key table (50 Hz, its own actions — no buy,
306313 no scoreboard, no weapon slots) and the one function from held actions to an
307 intent; `targets.ts` is the game itself — a seeded `rng` pops plates up as
308 `Box`es down a lane, `step` runs their lifetimes and the run's clock, and
314 intent; `targets.ts` is the game itself — a seeded `rng` stands plates up as
315 `Box`es down a lane, `step` slides them by their own velocity and runs their
316 lifetimes and the run's clock, retiring what has expired or left the lane, and
309317 `shoot` traces the engine's `rayBox` along the view and folds the hit, the
310318 score and the accuracy in, all plain data in and plain data out with no canvas
311319 anywhere near it; `readout.ts` draws a horizon, the plates on it as blips
@@ -370,10 +378,13 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
370378 canvases, draws its ticks, answers a key and — given WebGL2 — uploads its
371379 atlas and draws the room — so a bundling mistake fails
372380 `bun test` rather than the preview. The range's rules are pinned the way the
373 shooter's round is: `pin.ts` plays one seeded thirty-second run through one
374 fixed script of shots and hashes every tick, against the digest committed in
375 `pin.test.ts` — so a renderer, an atlas or a movement tuning that changes the
376 game says so, naming the second of the run where it changed. `src/pages/range.ts` is its page: its own title, prose and overlay
381 shooter's round is: `pin.ts` plays one seeded thirty-second run per drill
382 through one fixed script of shots each and hashes every tick, against the two
383 digests committed in `pin.test.ts` — so a renderer, an atlas or a movement
384 tuning that changes the game says so, naming the drill and the second of the
385 run where it changed. A drill is data: one table in `targets.ts` holds the
386 spawn rate, the lifetime, the speed range and the scoring of each, `createRun`
387 takes which one, and a third drill is one more entry in it. `src/pages/range.ts` is its page: its own title, prose and overlay
377388 through the same shell, with no database, no endpoint and no cookie behind it.
378389 - `src/example/` — the third game, at `/example`, and the wiring of one and
379390 nothing else: `main.ts` finds the canvas, hands the engine's controls the
src/games.ts +10 −1
@@ -87,6 +87,15 @@ export type Game = {
8787 * the app's bundle and in neither line of the range's. Measured 2026-09-18 after
8888 * #106: app 71,646 raw / 33,273 gzipped, range 27,268 raw / 11,674 gzipped.
8989 * Both fit, so neither ceiling moved.
90 * #88 gave the range a second drill — plates that slide across the lane, chosen
91 * before a run — and the range grew 1,545 raw / 572 gzipped bytes for it: a
92 * velocity on every plate, a second entry in the drill table, the retirement of
93 * a plate that has left the lane, and the drill's name and its per-drill best on
94 * the panel. Measured 2026-09-20 after #88: range 28,813 raw / 12,246 gzipped,
95 * which fits today's 29,400 / 12,700 with 587 raw and 454 gzipped bytes to
96 * spare, so the ceiling did not move — but that is the thinnest headroom on this
97 * page, and the next thing the range gains will want it raised. Neither other
98 * bundle moved a byte: nothing outside `src/range/` changed.
9099 * #80 added a third entry, `example`, and its ceiling is the first number in
91100 * this file nobody had to argue about: the guide's game with a page around it
92101 * builds to 8,754 raw / 4,229 gzipped, so its ceiling is 9,700 / 4,700, today's
@@ -112,7 +121,7 @@ export const GAMES: readonly Game[] = [
112121 entry: "src/range/main.ts",
113122 ceiling: { raw: 29_400, gzip: 12_700 },
114123 blurb:
115 "thirty seconds on the clock, pop-up plates down a lane, a shot traced in three dimensions and a score — every line of it out of the engine and none of it out of the shooter.",
124 "thirty seconds on the clock and two drills down one lane — plates that pop up, and plates that slide across it — a shot traced in three dimensions and a score, every line of it out of the engine and none of it out of the shooter.",
116125 },
117126 {
118127 name: "example",
src/pages/range.ts +34 −16
@@ -17,18 +17,22 @@ import { page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1717 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">;
1818
1919 const NOTE = `
20 <h2>The range: thirty seconds on the clock</h2>
21 <p>Capture the mouse, press <kbd>Enter</kbd>, and shoot the plates as they pop up: each one
22 is a box in space, traced by the engine and drawn by it, and the panel keeps the score,
23 the clock and your accuracy. You stand at the firing line and turn on the spot — there is
24 no walking yet. On a browser with no WebGL2 the range falls back to the flat horizon it
25 shipped with, plates as squares projected from the angle each subtends, and the same game
26 plays on it.</p>
20 <h2>The range: thirty seconds on the clock, two drills</h2>
21 <p>Capture the mouse, press <kbd>Enter</kbd>, and shoot the plates: each one is a box in
22 space, traced by the engine and drawn by it, and the panel keeps the score, the clock and
23 your accuracy. There are two drills and <kbd>T</kbd> switches between them before a run —
24 <strong>pop-up</strong>, where plates stand up down the lane and drop when their time is
25 up, and <strong>tracking</strong>, where they stand up and slide across it at their own
26 speed, worth more the faster they are going. You stand at the firing line and turn on the
27 spot — there is no walking yet. On a browser with no WebGL2 the range falls back to the
28 flat horizon it shipped with, plates as squares projected from the angle each subtends,
29 and the same two drills play on it.</p>
2730 <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div>
2831 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch ·
2932 <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload ·
30 <kbd>Enter</kbd> start · <kbd>Esc</kbd> gives the mouse back. On a touch screen the left of
31 the view is a stick, the right looks around, and the buttons do the rest.</p>
33 <kbd>Enter</kbd> start · <kbd>T</kbd> the other drill · <kbd>Esc</kbd> gives the mouse
34 back. On a touch screen the left of the view is a stick, the right looks around, and the
35 buttons do the rest.</p>
3236 <p class="hint">It makes its own noise, through the engine's sound device and the range's own
3337 recipes: a short dry crack, a clean tone for a hit, a knock as a plate stands up — quieter
3438 the farther down the lane it is — a tick for each of the last three seconds and one falling
@@ -54,7 +58,7 @@ export function rangePage(options: PageOptions): string {
5458 { action: "walk", label: "walk" },
5559 ],
5660 [
57 { action: "reload", label: "reload" },
61 { action: "drill", label: "drill" },
5862 { action: "start", label: "start" },
5963 { action: "fire", label: "fire" },
6064 ],
@@ -72,12 +76,26 @@ export function rangePage(options: PageOptions): string {
7276 actions are held, what movement they and the thumb stick add up to, and where you are
7377 looking.</p>
7478 <p>There is a game on top of that, and the engine's renderer under it. Press <kbd>Enter</kbd> and
75 thirty seconds start: plates pop up down the lane from a seeded spawn, each with its own
76 lifetime and worth 100 to 200 points depending on how far away it stood, and a click traces
77 a ray from your eye along your own view angles with the engine's own box trace — so a hit
78 is a hit in three dimensions, not a hit on a picture. The clock ends the run, stops the
79 trigger and reports the score, the hits, the shots and the accuracy; <kbd>Enter</kbd>
80 starts the next one, and the best of this visit is kept on the page and nowhere else.</p>
79 thirty seconds start: plates stand up down the lane from a seeded spawn, each with its own
80 lifetime, and a click traces a ray from your eye along your own view angles with the
81 engine's own box trace — so a hit is a hit in three dimensions, not a hit on a picture. The
82 clock ends the run, stops the trigger and reports the score, the hits, the shots and the
83 accuracy; <kbd>Enter</kbd> starts the next one, and the best of this visit is kept on the
84 page and nowhere else.</p>
85 <p>Two drills share that lane, and <kbd>T</kbd> chooses between them before a run — mid-run
86 the key does nothing rather than splicing two games into one score. <strong>Pop-up</strong>
87 is the drill this range shipped with, unchanged in every number: three plates at a time,
88 standing still, worth 100 to 200 points by how far down the lane they stood.
89 <strong>Tracking</strong> is the second: two at a time, nearer and longer-lived, each
90 sliding across the lane at 90 to 260 units a second until its time or its lane runs out,
91 and worth 100 to 160 for the distance plus a third of a point for every unit a second it is
92 crossing at — so the fast one is the one to take first, and you have to lead it. The best
93 score is kept per drill, because a tracking score means nothing beside a pop-up one. A
94 drill is one entry in a table in <code>src/range/targets.ts</code> — spawn rate, lifetime,
95 speed range, scoring — and nothing under this page branches on which one is being played: a
96 third drill is one more row. Each has a digest of its own in
97 <code>src/range/pin.test.ts</code>, a seeded run played through a fixed script and hashed
98 tick by tick, so a tuning of one says which one it changed.</p>
8199 <p>You can see it now, and by the same renderer the shooter uses: the room is one mesh built
82100 from this game's own numbers, every plate is one instance of one cube sized to its own box,
83101 the shot leaves a tracer for eighty milliseconds, and the atlas they are painted from is
src/range/binding.test.ts +5 −2
@@ -18,11 +18,13 @@ test("the range's bindings are its own, not the shooter's", () => {
1818 expect(RANGE_KEYS.ShiftLeft).toBe("walk");
1919 expect(RANGE_KEYS.KeyR).toBe("reload");
2020 expect(RANGE_KEYS.Enter).toBe("start");
21 // One key for the one choice this game offers: which drill the next run is.
22 expect(RANGE_KEYS.KeyT).toBe("drill");
2123 // A keyboard reaches the trigger too: pointer lock is not the only way in.
2224 expect(RANGE_KEYS.KeyF).toBe("fire");
2325 expect(RANGE_MOUSE[0]).toBe("fire");
2426 expect(RANGE_MOUSE[2]).toBeUndefined();
25 expect([...RANGE_TAP]).toEqual(["jump", "reload", "start"]);
27 expect([...RANGE_TAP]).toEqual(["jump", "reload", "start", "drill"]);
2628 // Nothing cs-shaped: no buy, no scoreboard, no weapon slots.
2729 for (const code of ["KeyB", "Tab", "Digit1", "Digit2", "Digit3"])
2830 expect(RANGE_KEYS[code]).toBeUndefined();
@@ -52,13 +54,14 @@ test("the stick adds to the keys and the sum clamps to ±1", () => {
5254 });
5355
5456 test("every held action lands on its field", () => {
55 const held = intent(["jump", "duck", "walk", "fire", "reload", "start"]);
57 const held = intent(["jump", "duck", "walk", "fire", "reload", "start", "drill"]);
5658 expect(held.jump).toBe(true);
5759 expect(held.duck).toBe(true);
5860 expect(held.walk).toBe(true);
5961 expect(held.fire).toBe(true);
6062 expect(held.reload).toBe(true);
6163 expect(held.start).toBe(true);
64 expect(held.drill).toBe(true);
6265 });
6366
6467 test("an action the range does not know changes nothing", () => {
src/range/binding.ts +12 −4
@@ -15,14 +15,14 @@ export const RANGE_KEYS: Record<string, string> = {
1515 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
1616 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
1717 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
18 KeyF: "fire", KeyR: "reload", Enter: "start",
18 KeyF: "fire", KeyR: "reload", Enter: "start", KeyT: "drill",
1919 };
2020
2121 /** One weapon, one button: the left one. There is nothing to scope here. */
2222 export const RANGE_MOUSE: Record<number, string> = { 0: "fire" };
2323
2424 /** On-screen buttons that are a tap, not a hold. */
25 export const RANGE_TAP = ["jump", "reload", "start"] as const;
25 export const RANGE_TAP = ["jump", "reload", "start", "drill"] as const;
2626
2727 /**
2828 * Every action the range knows, in the order the readout prints them. An
@@ -30,7 +30,7 @@ export const RANGE_TAP = ["jump", "reload", "start"] as const;
3030 * `data-action` — is collected by the controls and ignored here.
3131 */
3232 export const RANGE_ACTIONS = [
33 "forward", "back", "left", "right", "jump", "duck", "walk", "fire", "reload", "start",
33 "forward", "back", "left", "right", "jump", "duck", "walk", "fire", "reload", "start", "drill",
3434 ] as const;
3535
3636 export type RangeAction = (typeof RANGE_ACTIONS)[number];
@@ -51,11 +51,18 @@ export type Intent = {
5151 reload: boolean;
5252 /** Begin a run — the button and <kbd>Enter</kbd> both say it. */
5353 start: boolean;
54 /**
55 * Switch to the next drill — <kbd>T</kbd> and the button say it. What it
56 * means is the game's: `targets.ts` refuses it mid-run, and this file only
57 * reports that it was asked for.
58 */
59 drill: boolean;
5460 };
5561
5662 /** Nothing held, nothing pushed. */
5763 export const NO_INTENT: Intent = {
58 forward: 0, side: 0, jump: false, duck: false, walk: false, fire: false, reload: false, start: false,
64 forward: 0, side: 0, jump: false, duck: false, walk: false, fire: false, reload: false,
65 start: false, drill: false,
5966 };
6067
6168 export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: number }): Intent {
@@ -69,6 +76,7 @@ export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: num
6976 fire: down("fire"),
7077 reload: down("reload"),
7178 start: down("start"),
79 drill: down("drill"),
7280 };
7381 }
7482
src/range/boot.test.ts +85 −14
@@ -172,8 +172,8 @@ test("Enter starts a run, the clock runs and the plates pop up", () => {
172172 const { p, undo } = booted();
173173 try {
174174 p.step(20);
175 expect(hudTexts(p)).toContain("press Enter for a 30 second run");
176 expect(hudTexts(p)).toContain("score 0 · best 0 · 0/0 hits · 0%");
175 expect(hudTexts(p)).toContain("press Enter for a 30 second run · T for tracking");
176 expect(hudTexts(p)).toContain("pop-up · score 0 · best 0 · 0/0 hits · 0%");
177177
178178 p.window.fire("keydown", { code: "Enter" });
179179 p.step(20); // one 50 Hz tick: 0.02 s off the clock, and the first plate up
@@ -218,19 +218,19 @@ test("a mouse plays it: turn onto a plate, click, and the score moves", () => {
218218 // first: the bullet goes where the crosshair ended up.
219219 p.window.fire("keydown", { code: "KeyF" });
220220 p.step(20);
221 const score = latest(p, "score");
222 expect(score).toMatch(/^score \d+ · best 0 · 1\/1 hits · 100%$/);
223 expect(Number(/^score (\d+)/.exec(score)?.[1])).toBeGreaterThanOrEqual(100);
221 const score = latest(p, "pop-up");
222 expect(score).toMatch(/^pop-up · score \d+ · best 0 · 1\/1 hits · 100%$/);
223 expect(Number(/score (\d+)/.exec(score)?.[1])).toBeGreaterThanOrEqual(100);
224224
225225 // The trigger held down is one shot, not one a frame.
226226 p.step(20);
227 expect(latest(p, "score")).toContain("1/1 hits");
227 expect(latest(p, "pop-up")).toContain("1/1 hits");
228228 // Letting go and pulling again is a second shot, and the plate is gone.
229229 p.window.fire("keyup", { code: "KeyF" });
230230 p.step(20);
231231 p.window.fire("keydown", { code: "KeyF" });
232232 p.step(20);
233 expect(latest(p, "score")).toContain("1/2 hits · 50%");
233 expect(latest(p, "pop-up")).toContain("1/2 hits · 50%");
234234 } finally {
235235 undo();
236236 }
@@ -246,24 +246,24 @@ test("the clock ends the run, the entry stops shooting and the best score stays"
246246 p.window.fire("keydown", { code: "KeyF" });
247247 p.step(20);
248248 p.window.fire("keyup", { code: "KeyF" });
249 expect(latest(p, "score")).toContain("/1 hits");
249 expect(latest(p, "pop-up")).toContain("/1 hits");
250250
251251 // Thirty seconds, eight 50 Hz ticks to a frame: 188 frames of 160 ms.
252252 for (let i = 0; i < 190; i++) p.step(160);
253 expect(latest(p, "run over")).toMatch(/^run over · \d+ points · Enter for another$/);
253 expect(latest(p, "run over")).toMatch(/^run over · \d+ points · Enter for another · T for tracking$/);
254254
255255 // The clock at zero refuses the trigger: still the one shot.
256256 p.window.fire("keydown", { code: "KeyF" });
257257 p.step(20);
258 expect(latest(p, "score")).toContain("/1 hits");
258 expect(latest(p, "pop-up")).toContain("/1 hits");
259259 // And the run that just ended is the best so far, however it went.
260 const score = /^score (\d+) · best (\d+)/.exec(latest(p, "score"));
260 const score = /score (\d+) · best (\d+)/.exec(latest(p, "pop-up"));
261261 expect(score?.[2]).toBe(score?.[1] as string);
262262
263263 // Enter gives a fresh thirty seconds, and the best survives it.
264264 p.window.fire("keydown", { code: "Enter" });
265265 p.step(20);
266 expect(latest(p, "score")).toMatch(/^score 0 · best \d+ · 0\/0 hits · 0%$/);
266 expect(latest(p, "pop-up")).toMatch(/^pop-up · score 0 · best \d+ · 0\/0 hits · 0%$/);
267267 expect(hudTexts(p)).toContain("30.0 s left · 1 up");
268268 } finally {
269269 undo();
@@ -422,7 +422,7 @@ test("a run makes its own noise: one device, a plate popping up, a crack and a h
422422 played = sounds(p).length;
423423 p.window.fire("keydown", { code: "KeyF" });
424424 p.step(20);
425 expect(latest(p, "score")).toContain("1/1 hits");
425 expect(latest(p, "pop-up")).toContain("1/1 hits");
426426 expect(sincePlayed(p, played).filter((sound) => sound !== "pop")).toEqual(["crack", "hit"]);
427427
428428 // The trigger held down is one shot, so it is one crack and not one a frame.
@@ -456,7 +456,7 @@ test("a miss cracks and nothing more, and a trigger outside a run is silent", ()
456456 p.window.fire("keydown", { code: "KeyF" });
457457 p.step(20);
458458 p.window.fire("keyup", { code: "KeyF" });
459 expect(latest(p, "score")).toContain("0/1 hits");
459 expect(latest(p, "pop-up")).toContain("0/1 hits");
460460 expect(sincePlayed(p, played).filter((sound) => sound !== "pop")).toEqual(["crack"]);
461461
462462 // Run the clock out, then pull the trigger: the run refuses the shot, so
@@ -528,3 +528,74 @@ test("a browser with no WebGL2 keeps the game on the 2D horizon, and says so", (
528528 undo();
529529 }
530530 });
531
532 test("T chooses the drill between runs, and does nothing during one", () => {
533 const { p, undo } = booted();
534 try {
535 p.step(20);
536 expect(latest(p, "pop-up")).toContain("score 0");
537 // One press is one switch, held down or not.
538 p.window.fire("keydown", { code: "KeyT" });
539 p.step(20);
540 p.step(20);
541 expect(latest(p, "tracking")).toContain("score 0 · best 0");
542 expect(hudTexts(p)).toContain("press Enter for a 30 second run · T for pop-up");
543 p.window.fire("keyup", { code: "KeyT" });
544
545 // Mid-run it is refused: the drill, the clock and the plates carry on.
546 p.window.fire("keydown", { code: "Enter" });
547 p.step(20);
548 p.window.fire("keyup", { code: "Enter" });
549 p.step(200);
550 const during = latest(p, "tracking");
551 p.window.fire("keydown", { code: "KeyT" });
552 p.step(20);
553 expect(latest(p, "tracking")).toStartWith("tracking · ");
554 expect(during).toStartWith("tracking · ");
555 expect(hudTexts(p).some((line) => /^29\.\d s left · \d+ up$/.test(line))).toBe(true);
556 p.window.fire("keyup", { code: "KeyT" });
557 p.step(20);
558
559 // And the plates of that drill move: the one standing is drawn in two
560 // places two frames apart, which a pop-up plate never is.
561 const where = (): number => {
562 const before = p.scene.paint?.calls.length ?? 0;
563 p.step(20);
564 return (platesDrawn(p, before)[0] as [string, number, ...unknown[]])[1];
565 };
566 expect(where()).not.toBe(where());
567 } finally {
568 undo();
569 }
570 });
571
572 test("a best score belongs to its drill and is never shown beside the other", () => {
573 const { p, undo } = booted();
574 try {
575 // A pop-up run, played to the end: whatever it scored is the pop-up best.
576 p.window.fire("keydown", { code: "Enter" });
577 p.step(20);
578 p.window.fire("keyup", { code: "Enter" });
579 p.window.fire("keydown", { code: "KeyF" });
580 p.step(20);
581 p.window.fire("keyup", { code: "KeyF" });
582 for (let i = 0; i < 190; i++) p.step(160);
583 const popup = /score (\d+) · best (\d+)/.exec(latest(p, "pop-up")) as RegExpExecArray;
584 expect(popup[2]).toBe(popup[1] as string);
585
586 // Switching drills shows that drill's own best, which is nothing yet.
587 p.window.fire("keydown", { code: "KeyT" });
588 p.step(20);
589 p.window.fire("keyup", { code: "KeyT" });
590 expect(latest(p, "tracking")).toBe("tracking · score 0 · best 0 · 0/0 hits · 0%");
591 // And switching back finds the pop-up best where it was left. The key has
592 // to come up first: one press is one switch, and this is the second press.
593 p.step(20);
594 p.window.fire("keydown", { code: "KeyT" });
595 p.step(20);
596 p.window.fire("keyup", { code: "KeyT" });
597 expect(latest(p, "pop-up")).toBe(`pop-up · score 0 · best ${popup[2]} · 0/0 hits · 0%`);
598 } finally {
599 undo();
600 }
601 });
src/range/main.ts +31 −6
@@ -7,6 +7,13 @@
77 * the readout's panel on the HUD canvas above it, and `audio.ts` is what the
88 * same frame sounds like through the engine's sound device.
99 *
10 * There are two drills and the choice between them is one key: <kbd>T</kbd>, or
11 * the button beside it, asks `targets.ts` for the next one in its table. The
12 * game itself decides whether that is allowed — a live run refuses — and this
13 * file only passes the press on and prints which drill is being played and what
14 * the key would switch to. The best score is kept per drill, here and nowhere
15 * else: nothing on this page is stored.
16 *
1017 * Every sound is a crossing this file found in the game's own facts: a plate
1118 * with an id the last frame had not reached stood up, the shot `shoot` handed
1219 * back was taken and maybe hit, the clock passed one of the last three whole
@@ -27,7 +34,10 @@ import { createRangeAudio } from "./audio.ts";
2734 import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts";
2835 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
2936 import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts";
30 import { EYE, createRun, report, shoot, start, step, type Target } from "./targets.ts";
37 import {
38 DRILLS, EYE, createRun, nextDrill, report, setDrill, shoot, start, step,
39 type DrillName, type Target,
40 } from "./targets.ts";
3141 import {
3242 angleVectors, attachButtons, clamp, createControls, createLoop, createRenderer,
3343 } from "../engine/index.ts";
@@ -113,15 +123,22 @@ export function boot(): void {
113123 // two runs are two different thirty seconds, and a run is still reproducible
114124 // from the one number it was spawned with.
115125 const run = createRun(freshSeed());
116 let best = 0;
117 // Both of these are edges, not holds: a held trigger is one shot, and a held
118 // Enter is one run. The mouse says "fire" for as long as the button is down.
126 /**
127 * The best of this visit, per drill: a tracking score next to a pop-up best
128 * would be two games in one number. Nothing is stored anywhere — this page
129 * keeps no cookie and writes no table — so both start at zero.
130 */
131 const bests: Record<DrillName, number> = { popup: 0, tracking: 0 };
132 // All three of these are edges, not holds: a held trigger is one shot, a held
133 // Enter is one run and a held T is one switch. The mouse says "fire" for as
134 // long as the button is down.
119135 let firing = false;
120136 let starting = false;
137 let switching = false;
121138
122139 /** Remember the score of a run that is finishing, whatever finished it. */
123140 const keepBest = (): void => {
124 best = Math.max(best, run.score);
141 bests[run.drill.name] = Math.max(bests[run.drill.name], run.score);
125142 };
126143
127144 // The mouse only turns the view under pointer lock; a finger on the stage
@@ -168,6 +185,13 @@ export function boot(): void {
168185 // the trigger and Enter half of it is the game.
169186 const intent = readIntent(controls.held, controls.stick);
170187
188 // The drill is chosen between runs: `setDrill` refuses a live one, so a T
189 // pressed mid-run changes nothing at all rather than half a game.
190 // A finished run has already had its score kept, and one that has not
191 // started has none, so there is nothing to remember here.
192 if (intent.drill && !switching) setDrill(run, DRILLS[nextDrill(run.drill.name)]);
193 switching = intent.drill;
194
171195 if (intent.start && !starting) {
172196 keepBest();
173197 start(run, freshSeed());
@@ -233,7 +257,8 @@ export function boot(): void {
233257 hudCtx,
234258 {
235259 run: report(run),
236 best,
260 best: bests[run.drill.name],
261 next: DRILLS[nextDrill(run.drill.name)].label,
237262 hz: Math.round(1 / TICK),
238263 ticks,
239264 frameMs,
src/range/pin.test.ts +88 −11
@@ -1,13 +1,19 @@
11 /**
2 * The range's golden digest: what this game does, pinned.
2 * The range's golden digests: what each drill does, pinned.
33 *
4 * One run — thirty seconds at 50 Hz from seed 20260917, played through the
5 * fixed script in `pin.ts` — hashed tick by tick. If it fails, read the
6 * message: it names the first second of the run at which the play and the pin
7 * part company and prints the constants to paste in. Paste them only when the
8 * change to the game was deliberate, and say in the pull what moved and why.
9 * A renderer, an atlas or a movement tuning that moves this digest has changed
10 * the game and is not any of those three things.
4 * Two runs — thirty seconds at 50 Hz from seed 20260917, one per drill, each
5 * played through its script in `pin.ts` — hashed tick by tick. If one fails,
6 * read the message: it names the drill, the first second of the run at which
7 * the play and the pin part company, and prints the constants to paste in.
8 * Paste them only when the change to the game was deliberate, and say in the
9 * pull what moved and why. A renderer, an atlas or a movement tuning that moves
10 * a digest has changed the game and is not any of those three things.
11 *
12 * Two digests because a drill is a different game: tuning the tracking drill
13 * moves `TRACKING` and leaves `RANGE` where it is, so the pull that does it
14 * says which game it changed. `RANGE` has not moved since it was first written
15 * down, and that is this pull's own claim checked: a second drill that costs
16 * the first one a byte is not a second drill, it is a retuning of the first.
1117 *
1218 * Measured: the pinned thirty seconds fold in 8 ms (14 ms cold), and the whole
1319 * file is 60 ms on its own, Bun's start and all — `bun test` is 4.2 s before
@@ -18,9 +24,9 @@
1824 import { expect, test } from "bun:test";
1925 import {
2026 CHECKPOINT_CHARS, compare, fold, play, PLATE_FIELDS, QUANTUM, RUN_FIELDS, SCRIPT, snapshot,
21 TICK, TICKS_PER_SECOND, trigger, type Pin, type Pinned,
27 TICK, TICKS_PER_SECOND, TRACKING_SCRIPT, trigger, type Pin, type Pinned,
2228 } from "./pin.ts";
23 import { accuracy, createRun, EYE, shoot, start, step, type Run } from "./targets.ts";
29 import { accuracy, createRun, DRILLS, EYE, shoot, start, step, type Run } from "./targets.ts";
2430 import { angleVectors } from "../engine/index.ts";
2531
2632 /** The run: a named seed, thirty seconds of it, at the range's own 50 Hz. */
@@ -46,7 +52,31 @@ const RANGE_SECONDS = [
4652 ];
4753
4854 const RANGE_PIN: Pin = {
49 label: "thirty-second range run", constant: "RANGE", digest: RANGE, checkpoints: RANGE_SECONDS,
55 label: "thirty-second pop-up run", constant: "RANGE", digest: RANGE, checkpoints: RANGE_SECONDS,
56 };
57
58 /**
59 * Thirty seconds of the tracking drill on the same seed, played through
60 * `TRACKING_SCRIPT`: seventeen trigger pulls on the same seventeen ticks as the
61 * pop-up run's, aimed at this drill's own lane. Computed when #88 added the
62 * drill, and moved by any change to how a plate slides, how fast it may go, how
63 * long it lives or what its speed is worth.
64 */
65 const TRACKING = "8462440f5c6fb9aa3ef1f345243351003b0042f17dbf9f1c42fdd7525173ff59";
66 const TRACKING_SECONDS = [
67 "e5e0793137c0", "3e49fb9e82aa", "4396dfb53c12", "913963a811b6",
68 "8fbce1a4635b", "c30f7e1d537f", "28ca6bc5d103", "190d91d28b22",
69 "63c8d3c43631", "5d94ccd33204", "549a60fa98bd", "d9acc4c3879d",
70 "197ad3519f9c", "5fe779484245", "5260c155c8e0", "dd67cade9b06",
71 "ae28ec143c97", "f8e1607029fc", "5a01e5ec8ad8", "e0e3b6678692",
72 "b52b87f23250", "dfdb918f998b", "8e4815615b48", "92ad91b2e9ac",
73 "0125b182c493", "ccdba1fef546", "ffa32e68868b", "74ecc60ced90",
74 "a490df99d433", "ded614fadd7b",
75 ];
76
77 const TRACKING_PIN: Pin = {
78 label: "thirty-second tracking run", constant: "TRACKING", digest: TRACKING,
79 checkpoints: TRACKING_SECONDS,
5080 };
5181
5282 /** A run that has not been played yet, seeded and live. */
@@ -56,6 +86,13 @@ function ready(seed: number = SEED): Run {
5686 return run;
5787 }
5888
89 /** The same, on the tracking drill. */
90 function tracking(seed: number = SEED): Run {
91 const run = createRun(seed, DRILLS.tracking);
92 start(run, seed);
93 return run;
94 }
95
5996 const must = (run: Pinned, pin: Pin): void => {
6097 const complaint = compare(run, pin);
6198 if (complaint !== null) throw new Error(complaint);
@@ -93,6 +130,46 @@ test("the pinned run is a real one: a score, fourteen hits and three misses", ()
93130 expect(SCRIPT.every((aim) => aim.tick >= 0 && aim.tick < TICKS)).toBe(true);
94131 });
95132
133 test("a seeded thirty-second tracking run plays exactly as it played", () => {
134 const run = tracking();
135 const pinned = play(run, TICKS, TRACKING_SCRIPT);
136 must(pinned, TRACKING_PIN);
137 expect(pinned.digest).toBe(TRACKING);
138 expect(pinned.checkpoints).toEqual(TRACKING_SECONDS);
139 });
140
141 /**
142 * The tracking run in numbers, beside the pop-up one: the same seventeen shots
143 * land fourteen again, but on plates that were sliding — so the score is made
144 * of depth and speed together, and the 2,534 points are 309 more than the same
145 * count of hits paid on the standing drill.
146 */
147 test("the pinned tracking run is a real one: moving plates, hit where they were", () => {
148 const run = tracking();
149 play(run, TICKS, TRACKING_SCRIPT);
150 expect(run.phase).toBe("over");
151 expect(run.shots).toBe(17);
152 expect(run.hits).toBe(14);
153 expect(run.score).toBe(2534);
154 expect(run.spawned).toBe(33);
155 expect(TRACKING_SCRIPT.map((aim) => aim.tick)).toEqual(SCRIPT.map((aim) => aim.tick));
156 });
157
158 /**
159 * The two digests are two games. Same seed, same seventeen ticks, and not one
160 * second of the thirty hashes the same — the drill's salt gives it its own lane
161 * rather than the pop-up lane with speeds bolted on.
162 */
163 test("the two drills never share a run: not a digest, not a second, not a plate", () => {
164 expect(TRACKING).not.toBe(RANGE);
165 for (let i = 0; i < SECONDS; i++) expect(TRACKING_SECONDS[i]).not.toBe(RANGE_SECONDS[i]);
166 // Even unshot and unscripted, the lanes differ from the first plate on.
167 const bare = (run: Run): Pinned => fold(run, 4 * TICKS_PER_SECOND, () => step(run, TICK));
168 expect(bare(tracking()).digest).not.toBe(bare(ready()).digest);
169 // And each is its own repeatable run: the same seed and drill, twice.
170 expect(bare(tracking()).digest).toBe(bare(tracking()).digest);
171 });
172
96173 test("the same seed is the same run, and another seed is another run", () => {
97174 const four = 4 * TICKS_PER_SECOND;
98175 const a = play(ready(), four);
src/range/pin.ts +46 −1
@@ -1,5 +1,6 @@
11 /**
2 * The range's pin: one seeded run, one fixed script of shots, one sha256.
2 * The range's pin: one seeded run per drill, one fixed script of shots each,
3 * one sha256 each.
34 *
45 * A renderer, an atlas and a movement tuning are all about to land under this
56 * game, and every one of them will end with the claim "the range plays exactly
@@ -20,6 +21,15 @@
2021 * a sentence saying what behaviour changed and why. A digest updated without
2122 * that sentence is a pin that has stopped pinning anything.
2223 *
24 * **One digest per drill.** A drill is a different game, so it gets a number of
25 * its own: a tuning of the tracking drill moves the tracking digest and leaves
26 * the pop-up one alone, and the failure says which. Neither field list below
27 * changed when the second drill landed, which is how the first digest survived
28 * it — a plate's velocity is not hashed because it does not need to be. A plate
29 * that moves is at another place on the next tick, and the place is hashed on
30 * every one of them: 1,500 positions pin a speed far more tightly than one
31 * number would, and a plate that started sliding the wrong way shows on tick 1.
32 *
2333 * What is hashed is the run's rules playing out — the phase, the clock, the
2434 * score, the hits, the shots and every standing plate's place, size, lifetime
2535 * and worth. Not a plate's id and not the order of the lane: those are how
@@ -146,6 +156,41 @@ export const SCRIPT: Aim[] = [
146156 { tick: 1420, yaw: 11.77, pitch: 0.84 }, // hit, 172
147157 ];
148158
159 /**
160 * The same seventeen trigger pulls, on the same seventeen ticks, aimed at the
161 * tracking drill's lane: fourteen at the plate with the longest left to stand
162 * on that tick, two six degrees wide of one, and the same sixty degrees of sky.
163 * The drill's own salt makes it a different lane, so the angles are its own and
164 * not the pop-up script's — this is the same script, re-aimed, not a second
165 * kind of script.
166 *
167 * Every hit here is a hit on a plate that was *moving*, at the place the tick
168 * had just put it: these angles were read off the run after its step and before
169 * its trigger, which is the order `play` below fires in and the order `main.ts`
170 * does. Aimed one tick behind, at 260 units a second and 5.2 units of travel a
171 * tick, a plate 18 units from centre to edge is still hit — but the digest
172 * moves, and that is the point of pinning it.
173 */
174 export const TRACKING_SCRIPT: Aim[] = [
175 { tick: 40, yaw: -18.89, pitch: -6.3 }, // hit, 139 points at 101 u/s
176 { tick: 130, yaw: -0.41, pitch: 1.42 }, // hit, 205 at 155 u/s
177 { tick: 220, yaw: -33.62, pitch: -1.38 }, // hit, 217 at 221 u/s
178 { tick: 300, yaw: -9, pitch: -2.8 }, // 6° wide of the plate at tick 300: a miss
179 { tick: 320, yaw: -12.44, pitch: -2.84 }, // hit, 197 at 106 u/s
180 { tick: 430, yaw: 15.88, pitch: 1.07 }, // hit, 158 at 120 u/s
181 { tick: 540, yaw: 40.03, pitch: -2.47 }, // hit, 188 at 243 u/s
182 { tick: 650, yaw: 40.44, pitch: -3.25 }, // hit, 186 at 229 u/s
183 { tick: 700, yaw: 0, pitch: -60 }, // sky: a miss with nothing anywhere near it
184 { tick: 760, yaw: 5.55, pitch: -3.48 }, // hit, 164 at 152 u/s
185 { tick: 870, yaw: -24.21, pitch: -3.75 }, // hit, 200 at 157 u/s
186 { tick: 980, yaw: -34.84, pitch: -4.78 }, // hit, 163 at 176 u/s
187 { tick: 1000, yaw: 9.41, pitch: 2.06 }, // 6° wide again: a miss
188 { tick: 1090, yaw: -33.33, pitch: 0.04 }, // hit, 155 at 154 u/s
189 { tick: 1200, yaw: -16, pitch: -1.22 }, // hit, 170 at 166 u/s
190 { tick: 1310, yaw: -15.84, pitch: -4.74 }, // hit, 163 at 105 u/s
191 { tick: 1420, yaw: 6.58, pitch: -1.23 }, // hit, 229 at 241 u/s
192 ];
193
149194 /**
150195 * A cursor over the script: hand it a tick and it fires whatever that tick
151196 * holds, in order. A tick with no aim on it is a tick with the trigger off,
src/range/readout.test.ts +27 −6
@@ -35,17 +35,20 @@ function recorder(): Paint & { calls: Call[]; texts: string[]; lines: Call[] } {
3535 } as Paint & { calls: Call[]; texts: string[]; lines: Call[] };
3636 }
3737
38 const RUN = { phase: "live" as const, clock: 12.34, score: 700, hits: 4, shots: 6, accuracy: 4 / 6, up: 3 };
38 const RUN = {
39 drill: "popup" as const, label: "pop-up", phase: "live" as const,
40 clock: 12.34, score: 700, hits: 4, shots: 6, accuracy: 4 / 6, up: 3,
41 };
3942
4043 const READOUT: Readout = {
41 run: RUN, best: 1200,
44 run: RUN, best: 1200, next: "tracking",
4245 hz: 50, ticks: 1234, frameMs: 16.66, held: ["forward", "fire"],
4346 move: { forward: 1, side: -0.5 }, yaw: 45, pitch: -10, locked: true, drawn: true,
4447 };
4548
4649 test("the panel leads with the score and the clock, then the loop and the controls", () => {
4750 const lines = readoutLines(READOUT);
48 expect(lines[0]).toBe("score 700 · best 1200 · 4/6 hits · 67%");
51 expect(lines[0]).toBe("pop-up · score 700 · best 1200 · 4/6 hits · 67%");
4952 expect(lines[1]).toBe("12.3 s left · 3 up");
5053 expect(lines[2]).toBe("tick 1234 · 50 Hz fixed step");
5154 expect(lines[3]).toBe("frame 16.7 ms · 60 fps");
@@ -60,12 +63,12 @@ test("the panel leads with the score and the clock, then the loop and the contro
6063
6164 test("the clock line says how to start a run, how long is left, and how it went", () => {
6265 const ready = readoutLines({ ...READOUT, run: { ...RUN, phase: "ready", clock: 30, up: 0 } });
63 expect(ready[1]).toBe("press Enter for a 30 second run");
66 expect(ready[1]).toBe("press Enter for a 30 second run · T for tracking");
6467 const over = readoutLines({ ...READOUT, run: { ...RUN, phase: "over", clock: 0, up: 0 } });
65 expect(over[1]).toBe("run over · 700 points · Enter for another");
68 expect(over[1]).toBe("run over · 700 points · Enter for another · T for tracking");
6669 // No shots taken is no accuracy, not a NaN on the page.
6770 const fresh = readoutLines({ ...READOUT, run: { ...RUN, score: 0, hits: 0, shots: 0, accuracy: 0 }, best: 0 });
68 expect(fresh[0]).toBe("score 0 · best 0 · 0/0 hits · 0%");
71 expect(fresh[0]).toBe("pop-up · score 0 · best 0 · 0/0 hits · 0%");
6972 });
7073
7174 test("nothing held says so, and an unlocked pointer says how to lock it", () => {
@@ -186,3 +189,21 @@ test("a run's own targets are what the scene draws, and they go when the run doe
186189 expect(paint.calls.filter((c) => c[0] === "fillRect").length).toBe(2 + run.targets.length);
187190 expect(readoutLines({ ...READOUT, run: report(run) })[1]).toContain(`${run.targets.length} up`);
188191 });
192
193 test("the panel names the drill, and the score beside it is that drill's own", () => {
194 const popup = readoutLines(READOUT);
195 expect(popup[0]).toStartWith("pop-up · ");
196 // The same numbers on the other drill read as the other drill's: the label
197 // and the best move together, because a best belongs to one of them.
198 const tracked = readoutLines({
199 ...READOUT,
200 run: { ...RUN, drill: "tracking", label: "tracking" },
201 best: 90,
202 next: "pop-up",
203 });
204 expect(tracked[0]).toBe("tracking · score 700 · best 90 · 4/6 hits · 67%");
205 // Mid-run the switch is not offered, because mid-run it does nothing.
206 expect(tracked[1]).toBe("12.3 s left · 3 up");
207 expect(readoutLines({ ...READOUT, run: { ...RUN, phase: "ready" }, next: "tracking" })[1])
208 .toContain("T for tracking");
209 });
src/range/readout.ts +23 −9
@@ -28,10 +28,16 @@ export type View = { yaw: number; pitch: number };
2828
2929 /** Everything the panel prints, gathered by `main.ts` once a frame. */
3030 export type Readout = View & {
31 /** The run: the score, the clock and what they are made of. */
31 /** The run: which drill it is, the score, the clock and what they are made of. */
3232 run: Report;
33 /** The best score of this visit. Nothing is stored, so it starts at zero. */
33 /**
34 * The best score of this visit *on this drill*. Nothing is stored, so it
35 * starts at zero, and a tracking score is never shown beside a pop-up best:
36 * they are different games and the number would mean nothing.
37 */
3438 best: number;
39 /** What the drill key would switch to next. Named, so the key is discoverable. */
40 next: string;
3541 /** Seconds of simulation per tick, as the rate it is usually said at. */
3642 hz: number;
3743 /** Ticks the loop has run since the page booted. */
@@ -62,24 +68,32 @@ const perDegree = (height: number): number => height / 90;
6268 /** Degrees into 0..360, so the compass reads like a compass. */
6369 export const wrapDegrees = (angle: number): number => ((angle % 360) + 360) % 360;
6470
65 /** What the score line says about the run, whichever part of one it is in. */
71 /**
72 * What the score line says about the run, whichever part of one it is in. The
73 * drill leads it: the score and the best under it are that drill's and no
74 * other's, so the name has to be the first thing read.
75 */
6676 export function scoreLine(run: Report, best: number): string {
6777 const percent = (run.accuracy * 100).toFixed(0);
68 return `score ${run.score} · best ${best} · ${run.hits}/${run.shots} hits · ${percent}%`;
78 return `${run.label} · score ${run.score} · best ${best} · ${run.hits}/${run.shots} hits · ${percent}%`;
6979 }
7080
71 /** What the clock line says: how to start one, how long is left, or how it went. */
72 export function clockLine(run: Report): string {
73 if (run.phase === "ready") return `press Enter for a ${run.clock.toFixed(0)} second run`;
81 /**
82 * What the clock line says: how to start one, how long is left, or how it went
83 * — and, whenever a run is not in progress, what the drill key would switch to.
84 * Mid-run it says nothing about switching, because mid-run the key does nothing.
85 */
86 export function clockLine(run: Report, next: string): string {
87 if (run.phase === "ready") return `press Enter for a ${run.clock.toFixed(0)} second run · T for ${next}`;
7488 if (run.phase === "live") return `${run.clock.toFixed(1)} s left · ${run.up} up`;
75 return `run over · ${run.score} points · Enter for another`;
89 return `run over · ${run.score} points · Enter for another · T for ${next}`;
7690 }
7791
7892 /** The panel's lines, in order. Separate from the drawing so a test can read them. */
7993 export function readoutLines(r: Readout): string[] {
8094 return [
8195 scoreLine(r.run, r.best),
82 clockLine(r.run),
96 clockLine(r.run, r.next),
8397 `tick ${r.ticks} · ${r.hz} Hz fixed step`,
8498 `frame ${r.frameMs.toFixed(1)} ms · ${(1000 / Math.max(0.1, r.frameMs)).toFixed(0)} fps`,
8599 `held ${r.held.length === 0 ? "nothing" : r.held.join(" ")}`,
src/range/scene.test.ts +52 −1
@@ -14,7 +14,9 @@ import {
1414 MESH, PLATE_HALF, RANGE_SKY, TRACER_LIFE, buildRangeScene, createRangeScene, rangeAssets,
1515 rangeMeshes, type Trail,
1616 } from "./scene.ts";
17 import { EYE, PLATE_MAT, createRun, type Run, type Target } from "./targets.ts";
17 import {
18 DRILLS, EYE, PLATE_MAT, createRun, start, step, type Run, type Target,
19 } from "./targets.ts";
1820
1921 /** A plate standing at `at`, with `half` its half-width and `left` of its life. */
2022 function plate(id: number, at: [number, number, number], half = PLATE_HALF, left = 1): Target {
@@ -180,3 +182,52 @@ test("a frame allocates nothing: the pool and the buffer are the ones from befor
180182 expect([...scene.instances]).toEqual(objects);
181183 expect(scene.lines).toBe(lines);
182184 });
185
186 /**
187 * A moving plate is drawn where it is. The run is a real tracking one, stepped
188 * a tick at a time, and every frame's instance is compared with the box the
189 * trace would use on that same tick — so an instance built from last frame's
190 * position, or a scene that cached a place, fails here.
191 */
192 test("a sliding plate is drawn where the tick left it, not where it was", () => {
193 const run = createRun(20260917, DRILLS.tracking);
194 start(run, 20260917);
195 const scene = createRangeScene();
196 let moved = 0;
197 let previous: number | null = null;
198 for (let tick = 0; tick < 200; tick++) {
199 step(run, 1 / 50);
200 buildRangeScene(run, VIEW, null, tick / 50, scene);
201 const drawn = live(scene).slice(1);
202 expect(drawn.length).toBe(run.targets.length);
203 run.targets.forEach((target, i) => {
204 const box = target.box;
205 expect(drawn[i]?.pos).toEqual([
206 (box.min[0] + box.max[0]) / 2,
207 (box.min[1] + box.max[1]) / 2,
208 (box.min[2] + box.max[2]) / 2,
209 ]);
210 });
211 const first = run.targets[0];
212 if (first !== undefined && previous !== null && (drawn[0]?.pos[1] as number) !== previous) moved += 1;
213 previous = first === undefined ? null : (drawn[0]?.pos[1] as number);
214 }
215 // It really was moving: a still lane would have drawn the same y all along.
216 expect(moved).toBeGreaterThan(100);
217 });
218
219 test("a plate that slid out of the lane is not drawn any more", () => {
220 const run = standing([plate(1, [500, 0, 64])]);
221 // The tracking drill's lane, with nothing new allowed to stand up in it: what
222 // is drawn at the end is whatever is left of the one plate that was there.
223 run.drill = { ...DRILLS.tracking, atOnce: 0 };
224 const plateOne = run.targets[0] as Target;
225 plateOne.vel = [0, 400, 0];
226 plateOne.life = 30;
227 const scene = createRangeScene();
228 expect(buildRangeScene(run, VIEW, null, 0, scene).instanceCount).toBe(2);
229 // Four seconds at 400 units a second is well past the drill's own lane.
230 for (let i = 0; i < 200; i++) step(run, 1 / 50);
231 expect(run.targets).not.toContain(plateOne);
232 expect(live(buildRangeScene(run, VIEW, null, 4, scene)).map((i) => i.mesh)).not.toContain(MESH.PLATE);
233 });
src/range/scene.ts +2 −2
@@ -55,7 +55,7 @@ export const LINE_CAPACITY = 2;
5555 /**
5656 * Instances one frame may place: the room, and one plate for every target
5757 * standing. `STANDARD.atOnce` is three, so a standard frame is four; the
58 * headroom is for a `Rules` that stands more, and past it a plate is simply not
58 * headroom is for a drill that stands more, and past it a plate is simply not
5959 * drawn — the engine's policy at a cap, which the tracer above has always had.
6060 */
6161 export const INSTANCE_CAPACITY = 16;
@@ -63,7 +63,7 @@ export const INSTANCE_CAPACITY = 16;
6363 /**
6464 * The half-width the plate mesh is built at. Every plate is drawn from that one
6565 * cube, sized by `Instance.size` to the half-width its box actually has — so a
66 * `Rules` with bigger plates draws bigger plates without a second mesh.
66 * drill with bigger plates draws bigger plates without a second mesh.
6767 */
6868 export const PLATE_HALF = STANDARD.half;
6969
src/range/targets.test.ts +197 −3
@@ -7,7 +7,8 @@
77 import { expect, test } from "bun:test";
88 import { angleVectors, dist, type Vec3 } from "../engine/index.ts";
99 import {
10 accuracy, createRun, EYE, report, shoot, start, step, STANDARD, type Rules, type Run,
10 accuracy, createRun, DRILL_NAMES, DRILLS, EYE, nextDrill, report, setDrill, shoot, start, step,
11 STANDARD, type Drill, type Run,
1112 } from "./targets.ts";
1213
1314 /**
@@ -16,7 +17,7 @@ import {
1617 * Everything below that is not about the spawn uses this, so the geometry is a
1718 * sum rather than a seed.
1819 */
19 const FLAT: Rules = {
20 const FLAT: Drill = {
2021 ...STANDARD,
2122 runTime: 10,
2223 atOnce: 1,
@@ -36,8 +37,40 @@ function lane(): Run {
3637 return run;
3738 }
3839
40 /**
41 * The same flat lane, with the plates sliding north at exactly 100 units a
42 * second and 400 units of lane to do it in: a moving plate whose place after
43 * any number of ticks is a multiplication, not a seed.
44 */
45 const CROSS: Drill = {
46 ...FLAT,
47 name: "tracking",
48 label: "tracking",
49 life: { min: 10, max: 10 },
50 speed: { min: 100, max: 100 },
51 escape: 400,
52 points: { near: 100, far: 100, perSpeed: 0.5 },
53 };
54
3955 const down = (yaw: number, pitch: number): Vec3 => angleVectors(yaw, pitch).forward;
4056
57 /** Where a plate's centre is across the lane, which is the only axis one moves on. */
58 const across = (run: Run, i = 0): number => {
59 const box = (run.targets[i] as NonNullable<(typeof run.targets)[0]>).box;
60 return (box.min[1] + box.max[1]) / 2;
61 };
62
63 /** The yaw that looks at a point `y` across the lane, 512 units down it. */
64 const yawTo = (y: number): number => (180 / Math.PI) * Math.atan2(y, 512);
65
66 /** A live run on `CROSS` with its first plate already sliding. */
67 function crossing(seed = 1): Run {
68 const run = createRun(seed, CROSS);
69 start(run);
70 step(run, 0.02);
71 return run;
72 }
73
4174 test("a plate pops up down the lane, as a box arithmetic can place", () => {
4275 const run = lane();
4376 expect(run.targets.length).toBe(1);
@@ -179,7 +212,10 @@ test("accuracy is what a hand count says, and no shots is not a division", () =>
179212
180213 test("the clock runs the run: thirty seconds, then it is over and the lane is empty", () => {
181214 const run = createRun(3);
182 expect(report(run)).toEqual({ phase: "ready", clock: 30, score: 0, hits: 0, shots: 0, accuracy: 0, up: 0 });
215 expect(report(run)).toEqual({
216 drill: "popup", label: "pop-up", phase: "ready", clock: 30,
217 score: 0, hits: 0, shots: 0, accuracy: 0, up: 0,
218 });
183219 start(run);
184220 for (let i = 0; i < 1499; i++) step(run, 0.02);
185221 expect(run.phase).toBe("live");
@@ -256,3 +292,161 @@ test("the plates stand in front of the shooter, inside the lane the rules give",
256292 }
257293 expect(run.spawned).toBeGreaterThan(20);
258294 });
295
296 // --- the drills -----------------------------------------------------------
297
298 test("a moving plate is somewhere else after a tick, by its own speed", () => {
299 const run = crossing();
300 const plate = run.targets[0] as NonNullable<(typeof run.targets)[0]>;
301 // 100 units a second, one way or the other: the drill's speed, exactly.
302 expect(Math.abs(plate.vel[1])).toBe(100);
303 expect(plate.vel[0]).toBe(0);
304 expect(plate.vel[2]).toBe(0);
305 expect(plate.rate).toBe(100);
306
307 const before = across(run);
308 step(run, 0.02);
309 expect(across(run) - before).toBeCloseTo(plate.vel[1] * 0.02, 9);
310 // A second of it is a hundred units, and only across the lane: the depth and
311 // the height a plate stood up at are the depth and the height it keeps.
312 for (let i = 0; i < 49; i++) step(run, 0.02);
313 expect(across(run) - before).toBeCloseTo(plate.vel[1], 6);
314 expect(plate.box.min[0]).toBe(496);
315 expect(plate.box.min[2]).toBe(48);
316 // And the distance it is at now, not the one it stood up at.
317 expect(plate.distance).toBeCloseTo(Math.hypot(512, across(run)), 6);
318 });
319
320 test("a pop-up plate does not move: the drill that stands still stands still", () => {
321 const run = lane();
322 const plate = run.targets[0] as NonNullable<(typeof run.targets)[0]>;
323 const min = [...plate.box.min];
324 const max = [...plate.box.max];
325 expect(plate.vel).toEqual([0, 0, 0]);
326 expect(plate.rate).toBe(0);
327 for (let i = 0; i < 40; i++) step(run, 0.02);
328 expect(plate.box.min).toEqual(min as typeof plate.box.min);
329 expect(plate.box.max).toEqual(max as typeof plate.box.max);
330 });
331
332 test("a shot meets a moving plate where the tick left it, not where it was", () => {
333 const run = crossing();
334 const plate = run.targets[0] as NonNullable<(typeof run.targets)[0]>;
335 const was = across(run);
336 // 200 ms of travel is 20 units, and the plate is 16 from its centre to its
337 // edge: where it was is now outside it, and where it is is inside.
338 for (let i = 0; i < 10; i++) step(run, 0.02);
339 const is = across(run);
340 expect(Math.abs(is - was)).toBeCloseTo(20, 6);
341
342 expect(shoot(run, EYE, down(yawTo(was), 0))?.target).toBeNull();
343 const hit = shoot(run, EYE, down(yawTo(is), 0));
344 expect(hit?.target).toBe(plate);
345 expect(run.hits).toBe(1);
346 expect(run.shots).toBe(2);
347 });
348
349 test("a plate that slides out of the lane retires, with life still on it", () => {
350 const run = crossing();
351 const plate = run.targets[0] as NonNullable<(typeof run.targets)[0]>;
352 // 400 units of lane at 100 a second: four seconds of sliding, and ten of life.
353 for (let i = 0; i < 200; i++) step(run, 0.02);
354 expect(run.targets).toContain(plate);
355 step(run, 0.02);
356 expect(run.targets).not.toContain(plate);
357 expect(plate.life).toBeGreaterThan(5);
358 expect(Math.abs((plate.box.min[1] + plate.box.max[1]) / 2)).toBeGreaterThan(CROSS.escape);
359 // It is gone from the game, so it cannot be shot: the lane has moved on.
360 expect(shoot(run, EYE, down(yawTo(across(run)), 0))?.target).not.toBe(plate);
361 });
362
363 test("a faster plate is worth more, by the drill's own rate", () => {
364 const slow = createRun(1, { ...CROSS, speed: { min: 40, max: 40 } });
365 start(slow);
366 step(slow, 0.02);
367 const fast = createRun(1, { ...CROSS, speed: { min: 240, max: 240 } });
368 start(fast);
369 step(fast, 0.02);
370 // 100 for the depth, and half a point for every unit a second it crosses at.
371 expect(slow.targets[0]?.points).toBe(120);
372 expect(fast.targets[0]?.points).toBe(220);
373 // A pop-up plate's speed pays nothing, because there is none of it.
374 expect(lane().targets[0]?.points).toBe(100);
375 });
376
377 test("the same seed and drill play the same run twice, plates and speeds alike", () => {
378 const play = (drill: typeof DRILLS.popup, seed: number): string => {
379 const run = createRun(seed, drill);
380 start(run);
381 const seen: string[] = [];
382 for (let i = 0; i < 300; i++) {
383 step(run, 0.02);
384 for (const t of run.targets) seen.push(`${t.id} ${t.box.min.join()} ${t.vel.join()} ${t.points}`);
385 }
386 return seen.join("\n");
387 };
388 expect(play(DRILLS.tracking, 2026)).toBe(play(DRILLS.tracking, 2026));
389 expect(play(DRILLS.tracking, 2026)).not.toBe(play(DRILLS.tracking, 7));
390 // Two drills on one seed are two lanes: not the same plates at other speeds.
391 expect(play(DRILLS.tracking, 2026)).not.toBe(play(DRILLS.popup, 2026));
392 const first = (drill: typeof DRILLS.popup): number[] => {
393 const run = createRun(11, drill);
394 start(run);
395 step(run, 0.02);
396 return [...(run.targets[0] as NonNullable<(typeof run.targets)[0]>).box.min];
397 };
398 expect(first(DRILLS.tracking)).not.toEqual(first(DRILLS.popup));
399 });
400
401 test("the drill is chosen between runs and never during one", () => {
402 const run = createRun(5);
403 expect(run.drill.name).toBe("popup");
404 expect(setDrill(run, DRILLS.tracking)).toBe(true);
405 expect(run.drill.name).toBe("tracking");
406 expect(report(run).label).toBe("tracking");
407
408 // Mid-run it does nothing at all: not the drill, not the lane, not the score.
409 start(run);
410 for (let i = 0; i < 100; i++) step(run, 0.02);
411 shoot(run, EYE, down(0, 0));
412 const before = { ...report(run), targets: run.targets.length, spawned: run.spawned };
413 expect(setDrill(run, DRILLS.popup)).toBe(false);
414 expect(run.drill.name).toBe("tracking");
415 expect({ ...report(run), targets: run.targets.length, spawned: run.spawned }).toEqual(before);
416
417 // Once the clock is out it takes, and the lane starts over on the new drill.
418 for (let i = 0; i < 1500; i++) step(run, 0.02);
419 expect(run.phase).toBe("over");
420 expect(setDrill(run, DRILLS.popup)).toBe(true);
421 expect(report(run)).toEqual({
422 drill: "popup", label: "pop-up", phase: "ready", clock: 30,
423 score: 0, hits: 0, shots: 0, accuracy: 0, up: 0,
424 });
425 start(run);
426 step(run, 0.02);
427 expect(run.targets[0]?.vel).toEqual([0, 0, 0]);
428 });
429
430 test("the drills are a table, and the one control walks round it", () => {
431 expect(DRILL_NAMES).toEqual(["popup", "tracking"]);
432 expect(nextDrill("popup")).toBe("tracking");
433 // However many there are, the last one wraps back to the first.
434 expect(nextDrill(DRILL_NAMES[DRILL_NAMES.length - 1] as "popup")).toBe(DRILL_NAMES[0] as "popup");
435 for (const name of DRILL_NAMES) {
436 const drill = DRILLS[name];
437 expect(drill.name).toBe(name);
438 expect(drill.label.length).toBeGreaterThan(0);
439 expect(drill.about.length).toBeGreaterThan(0);
440 // Every plate stands up inside the lane it may not leave, and a plate that
441 // never moves can never reach the edge of one.
442 expect(drill.escape).toBeGreaterThanOrEqual(drill.spread);
443 expect(drill.speed.min).toBeLessThanOrEqual(drill.speed.max);
444 expect(drill.runTime).toBeGreaterThan(0);
445 }
446 // The pop-up drill is the default and its salt is zero: the run that was
447 // pinned is the run a caller who names no drill still gets.
448 expect(STANDARD).toBe(DRILLS.popup);
449 expect(DRILLS.popup.salt).toBe(0);
450 expect(DRILLS.popup.speed).toEqual({ min: 0, max: 0 });
451 expect(createRun(1).drill).toBe(DRILLS.popup);
452 });
src/range/targets.ts +243 −48
@@ -1,10 +1,11 @@
11 /**
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.
2 * The range's game: plates down a lane, a traced shot, a clock and a score.
3 * Plain data in, plain data out — no canvas, no document, no room, no physics.
4 * A `Run` is a mutable record and the verbs below move it: `start` begins one,
5 * `step` advances the clock and the targets, `shoot` traces a bullet, and
6 * `setDrill` swaps which drill the next run will be. Nothing in here draws
7 * anything and nothing in here reads a clock of its own, which is why the whole
8 * game plays through in a test in a millisecond.
89 *
910 * Units are the engine's: Source units, one unit about an inch, X east, Y north
1011 * and Z up. The shooter stands at the origin looking down +X (yaw 0) with an
@@ -14,6 +15,20 @@
1415 * Everything random comes from the engine's `rng`, drawn in one fixed order, so
1516 * a seed reproduces a run exactly: the same seed pops the same plates up at the
1617 * same moments with the same lifetimes.
18 *
19 * **A drill is data.** `DRILLS` below is the whole difference between one kind
20 * of run and another — how often a plate stands up, how long it stands, how
21 * fast it slides and what hitting it is worth — and `createRun(seed, drill)`
22 * takes which one. There is no `if (tracking)` anywhere under this file: a
23 * still plate is a moving plate whose speed is zero, and a third drill is one
24 * more entry in that table.
25 *
26 * **The tick boundary.** A plate moves at the top of a tick and a bullet leaves
27 * at the end of one: `main.ts` steps the run and then reads the trigger, and
28 * `pin.ts` folds the digest in that same order. So a shot is traced against
29 * where the plate *is* on the tick it was fired — never where it was on the one
30 * before — and `scene.ts` draws the same box the trace used. Three places, one
31 * answer, and `targets.test.ts` holds them to it.
1732 */
1833
1934 import {
@@ -31,8 +46,27 @@ export const EYE: Vec3 = [0, 0, 64];
3146 */
3247 export const PLATE_MAT = 0;
3348
34 /** The dial settings of one kind of run. Plain numbers, all of them tunable. */
35 export type Rules = {
49 /** What the drills are called. One name per entry in `DRILLS`, and no others. */
50 export type DrillName = "popup" | "tracking";
51
52 /**
53 * One drill: the dial settings of one kind of run, and what to call it. Plain
54 * numbers, all of them tunable, and the only thing that separates two kinds of
55 * run at this range.
56 */
57 export type Drill = {
58 /** Which drill this is. The key it is filed under in `DRILLS`. */
59 name: DrillName;
60 /** What the panel and the page call it. Text, escaped where it is rendered. */
61 label: string;
62 /** The one sentence that says what this drill is for. */
63 about: string;
64 /**
65 * Mixed into the seed, so two drills on one seed are two different lanes
66 * rather than the same plates with different speeds. The pop-up drill's is
67 * zero — that is what keeps its thirty seconds the thirty seconds they were.
68 */
69 salt: number;
3670 /** How long one run lasts, in seconds. */
3771 runTime: number;
3872 /** Most plates standing at once. */
@@ -43,50 +77,129 @@ export type Rules = {
4377 life: { min: number; max: number };
4478 /** How far down the lane a plate may stand, in units. */
4579 depth: { min: number; max: number };
46 /** Half the width of the lane, in units: a plate stands within ±this of it. */
80 /** Half the width a plate may *stand up* in, in units: ±this across the lane. */
4781 spread: number;
82 /**
83 * How far across the lane a plate may get before it retires, in units. A
84 * plate that slides past ±this has left and is taken down, hit or not. It is
85 * not `spread`, so a moving plate has room to travel past where any plate
86 * stands up, and it is inside the room's own ±640 wall in `level.ts`, so what
87 * retires has left the lane and not gone through the concrete. A still drill
88 * stands up inside `spread` and never moves, so it never reaches this.
89 */
90 escape: number;
4891 /** The band a plate's centre stands in, in units above the floor. */
4992 height: { min: number; max: number };
93 /**
94 * How fast a plate slides across the lane, in units a second: the slowest and
95 * the fastest. `{ min: 0, max: 0 }` is a drill whose plates stand still, and
96 * that is the whole of what "pop-up" means here — no branch, one zero.
97 */
98 speed: { min: number; max: number };
5099 /** Half the side of a plate, in units: they are cubes, so this is all of it. */
51100 half: number;
52 /** What a plate is worth at `depth.min`, and at `depth.max`. */
53 points: { near: number; far: number };
101 /**
102 * What a plate is worth at `depth.min`, at `depth.max`, and per unit a second
103 * of the speed it was sliding at. A still drill's `perSpeed` never multiplies
104 * anything but zero.
105 */
106 points: { near: number; far: number; perSpeed: number };
54107 /** How far a bullet carries, in units. Hitscan: it arrives or it does not. */
55108 range: number;
56109 };
57110
58111 /**
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.
112 * The drills, in the order the one control cycles them. Adding a third is an
113 * entry here and a name in `DrillName` — nothing else in the range branches on
114 * which drill is being played.
115 *
116 * **pop-up** is the run this range shipped with and every number in it is
117 * untouched: thirty seconds, three plates up at a time, each worth between 100
118 * and 200 points depending on how far away it stood — a 36-unit cube at 1,200
119 * units is about 1.7° wide, which is a shot worth taking with a mouse and not
120 * one worth taking by accident. Its speed is zero and its salt is zero, which
121 * together are why `pin.test.ts`'s first digest has not moved.
122 *
123 * **tracking** is the second: fewer plates, standing up nearer and for longer,
124 * each sliding across the lane at 90 to 260 units a second until it runs out of
125 * life or runs out of lane. It is worth less for the distance and more for the
126 * speed — a plate crossing at 260 is worth 91 points of its own on top of the
127 * 100 to 160 its depth pays, so the fast one is the one to take first.
63128 */
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,
129 export const DRILLS: Record<DrillName, Drill> = {
130 popup: {
131 name: "popup",
132 label: "pop-up",
133 about: "plates stand up down the lane and drop when their time is up: find them and hit them.",
134 salt: 0,
135 runTime: 30,
136 atOnce: 3,
137 gap: 0.6,
138 life: { min: 1.1, max: 2.4 },
139 depth: { min: 320, max: 1200 },
140 spread: 420,
141 escape: 560,
142 height: { min: 28, max: 148 },
143 speed: { min: 0, max: 0 },
144 half: 18,
145 points: { near: 100, far: 200, perSpeed: 0 },
146 range: 4096,
147 },
148 tracking: {
149 name: "tracking",
150 label: "tracking",
151 about: "plates stand up and slide across the lane: lead them, and the fast ones pay most.",
152 // "trac", which is only a number nobody else uses.
153 salt: 0x74_72_61_63,
154 runTime: 30,
155 atOnce: 2,
156 gap: 0.9,
157 life: { min: 2, max: 3.4 },
158 depth: { min: 360, max: 900 },
159 spread: 300,
160 escape: 560,
161 height: { min: 40, max: 120 },
162 speed: { min: 90, max: 260 },
163 half: 18,
164 points: { near: 100, far: 160, perSpeed: 0.35 },
165 range: 4096,
166 },
75167 };
76168
169 /** The drills in the order the control cycles them: the table's own order. */
170 export const DRILL_NAMES = Object.keys(DRILLS) as DrillName[];
171
172 /** The next drill round the table, wrapping. One control, however many drills. */
173 export const nextDrill = (name: DrillName): DrillName =>
174 DRILL_NAMES[(DRILL_NAMES.indexOf(name) + 1) % DRILL_NAMES.length] as DrillName;
175
176 /**
177 * The drill a run gets when its caller named none: the pop-up one, under the
178 * name the rest of the range has always called it by.
179 */
180 export const STANDARD: Drill = DRILLS.popup;
181
77182 /** One plate, standing. */
78183 export type Target = {
79184 /** 1, 2, 3… in the order they popped up this run. */
80185 id: number;
81186 /** Where it is, as the engine traces it. */
82187 box: Box;
188 /**
189 * How fast it is sliding, in units a second on each axis. Every plate has
190 * one; a pop-up plate's is three zeroes, and adding zero times a tick to its
191 * box is how it stands still without anything asking whether it should.
192 */
193 vel: Vec3;
194 /** How fast it is going, in units a second. What its speed was worth, once. */
195 rate: number;
83196 /** Seconds left before it drops. */
84197 life: number;
85198 /** Seconds it was given, so a readout can show how much of that is left. */
86199 span: number;
87 /** What hitting it is worth. Farther is worth more. */
200 /** What hitting it is worth. Farther is worth more, and faster is worth more. */
88201 points: number;
89 /** Units from `EYE` to its centre, kept because every reader wants it. */
202 /** Units from `EYE` to its centre right now, kept because every reader wants it. */
90203 distance: number;
91204 };
92205
@@ -94,7 +207,8 @@ export type Target = {
94207 export type Phase = "ready" | "live" | "over";
95208
96209 export type Run = {
97 rules: Rules;
210 /** Which drill is being played, whole. A run is never between two of them. */
211 drill: Drill;
98212 phase: Phase;
99213 /** Seconds left in the run. `runTime` before it starts, zero once it is over. */
100214 clock: number;
@@ -111,12 +225,21 @@ export type Run = {
111225 random: () => number;
112226 };
113227
228 /**
229 * The stream a run draws its plates from: the seed it was given, mixed with the
230 * drill's own salt. Two drills on one seed are two different lanes, which is
231 * what stops a person memorising one drill's spawns and playing the other by
232 * heart — and the pop-up drill's salt is zero, so its stream is the stream it
233 * always was.
234 */
235 const stream = (seed: number, drill: Drill): (() => number) => rng(((seed >>> 0) ^ drill.salt) >>> 0);
236
114237 /** 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 {
238 export function createRun(seed: number, drill: Drill = STANDARD): Run {
116239 return {
117 rules,
240 drill,
118241 phase: "ready",
119 clock: rules.runTime,
242 clock: drill.runTime,
120243 nextIn: 0,
121244 targets: [],
122245 spawned: 0,
@@ -124,7 +247,7 @@ export function createRun(seed: number, rules: Rules = STANDARD): Run {
124247 hits: 0,
125248 score: 0,
126249 seed: seed >>> 0,
127 random: rng(seed),
250 random: stream(seed, drill),
128251 };
129252 }
130253
@@ -135,9 +258,9 @@ export function createRun(seed: number, rules: Rules = STANDARD): Run {
135258 */
136259 export function start(run: Run, seed: number = run.seed): void {
137260 run.seed = seed >>> 0;
138 run.random = rng(run.seed);
261 run.random = stream(run.seed, run.drill);
139262 run.phase = "live";
140 run.clock = run.rules.runTime;
263 run.clock = run.drill.runTime;
141264 run.nextIn = 0;
142265 run.targets = [];
143266 run.spawned = 0;
@@ -146,17 +269,55 @@ export function start(run: Run, seed: number = run.seed): void {
146269 run.score = 0;
147270 }
148271
149 /** One plate, drawn from the run's rng in this order: depth, side, height, life. */
272 /**
273 * Choose the drill the next run will be, and say whether it took. A live run is
274 * left strictly alone — a drill swapped halfway through would be a score made
275 * of two games — so this answers false and changes nothing until the clock has
276 * run out. Choosing between runs clears the lane back to `ready`: a finished
277 * run's score belongs to the drill it was played on and is not carried across.
278 */
279 export function setDrill(run: Run, drill: Drill): boolean {
280 if (run.phase === "live") return false;
281 run.drill = drill;
282 run.phase = "ready";
283 run.clock = drill.runTime;
284 run.nextIn = 0;
285 run.targets = [];
286 run.spawned = 0;
287 run.shots = 0;
288 run.hits = 0;
289 run.score = 0;
290 run.random = stream(run.seed, drill);
291 return true;
292 }
293
294 /**
295 * One plate, drawn from the run's rng in this order: depth, side, height, life
296 * — and then, on a drill whose plates move, how fast and which way.
297 *
298 * Those last two are drawn only when `speed.max` is above zero, and that is the
299 * one guard in this file that exists for the sake of a digest: a drill that
300 * stands its plates still asks its stream for four numbers a plate, exactly as
301 * this game always did, so the pop-up run is the run that was pinned. A drill
302 * that moves asks for six.
303 */
150304 function pop(run: Run): Target {
151 const r = run.rules;
305 const r = run.drill;
152306 const depth = lerp(r.depth.min, r.depth.max, run.random());
153307 const side = lerp(-r.spread, r.spread, run.random());
154308 const centre = lerp(r.height.min, r.height.max, run.random());
155309 const span = lerp(r.life.min, r.life.max, run.random());
310 const rate = r.speed.max > 0 ? lerp(r.speed.min, r.speed.max, run.random()) : 0;
311 // Across the lane, one way or the other: the plate slides in ±Y and nothing
312 // else, so a shot at it is a lead and not a guess about depth.
313 const vel: Vec3 = [0, rate === 0 ? 0 : run.random() < 0.5 ? -rate : rate, 0];
156314 const at: Vec3 = [depth, side, centre];
157315 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));
316 // Points are linear in how far down the lane it stood and in how fast it is
317 // crossing, rounded to a whole. A still plate adds nothing for its speed.
318 const points = Math.round(
319 lerp(r.points.near, r.points.far, reach > 0 ? (depth - r.depth.min) / reach : 0) + r.points.perSpeed * rate,
320 );
160321 run.spawned += 1;
161322 return {
162323 id: run.spawned,
@@ -165,6 +326,8 @@ function pop(run: Run): Target {
165326 max: [at[0] + r.half, at[1] + r.half, at[2] + r.half],
166327 mat: PLATE_MAT,
167328 },
329 vel,
330 rate,
168331 life: span,
169332 span,
170333 points,
@@ -179,11 +342,19 @@ function pop(run: Run): Target {
179342 */
180343 const SPENT = 1e-9;
181344
345 /** The one vector a tick writes a plate's centre into: a step allocates nothing. */
346 const CENTRE: Vec3 = [0, 0, 0];
347
182348 /**
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.
349 * Advance `dt` seconds: the clock runs down, standing plates slide by their own
350 * velocity and lose that much of their lifetime, the expired ones and the ones
351 * that have left the lane drop, and new ones pop up on the gap until the lane
352 * is full. When the clock reaches zero the run is over and the lane is cleared
353 * — a plate cannot be shot after time, because it is not there.
354 *
355 * The plates move first and the shot is fired afterwards, by `main.ts` and by
356 * `pin.ts` alike: a bullet leaves at the end of the tick it was pulled on, and
357 * meets the plates where this step has just put them.
187358 *
188359 * Only a live run moves. A step outside one, or with a dt that is not a
189360 * positive number, does nothing at all.
@@ -191,13 +362,31 @@ const SPENT = 1e-9;
191362 export function step(run: Run, dt: number): void {
192363 if (run.phase !== "live" || !Number.isFinite(dt) || dt <= 0) return;
193364 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);
365 for (const target of run.targets) {
366 target.life -= dt;
367 // Every plate is moved. A still one is moved by zero, which is the same
368 // double it already held — no branch, and no drill named in this loop.
369 const box = target.box;
370 const vel = target.vel;
371 box.min[0] += vel[0] * dt; box.max[0] += vel[0] * dt;
372 box.min[1] += vel[1] * dt; box.max[1] += vel[1] * dt;
373 box.min[2] += vel[2] * dt; box.max[2] += vel[2] * dt;
374 // A plate that moved is a plate at another distance, and the readers of
375 // that — the panel, the sound of a hit — want where it is now.
376 CENTRE[0] = (box.min[0] + box.max[0]) / 2;
377 CENTRE[1] = (box.min[1] + box.max[1]) / 2;
378 CENTRE[2] = (box.min[2] + box.max[2]) / 2;
379 target.distance = segmentLength(EYE, CENTRE);
380 }
381 const escape = run.drill.escape;
382 run.targets = run.targets.filter(
383 (target) => target.life > 0 && Math.abs((target.box.min[1] + target.box.max[1]) / 2) <= escape,
384 );
196385
197386 run.nextIn -= dt;
198 while (run.clock > SPENT && run.nextIn <= 0 && run.targets.length < run.rules.atOnce) {
387 while (run.clock > SPENT && run.nextIn <= 0 && run.targets.length < run.drill.atOnce) {
199388 run.targets.push(pop(run));
200 run.nextIn += run.rules.gap;
389 run.nextIn += run.drill.gap;
201390 }
202391 if (run.clock <= SPENT) {
203392 run.phase = "over";
@@ -234,7 +423,7 @@ export function shoot(run: Run, eye: Vec3, direction: Vec3): Shot | null {
234423 const aim = normalize(direction);
235424 if (length(aim) < 0.5) return null;
236425
237 const to = add(eye, scale(aim, run.rules.range));
426 const to = add(eye, scale(aim, run.drill.range));
238427 run.shots += 1;
239428 const hit = traceWorld(eye, to, run.targets.map((target) => target.box));
240429 const target = hit === null ? null : run.targets.find((t) => t.box === hit.what) ?? null;
@@ -252,6 +441,10 @@ export const accuracy = (hits: number, shots: number): number => (shots > 0 ? hi
252441
253442 /** Everything a scoreboard or a readout needs, and nothing it would have to compute. */
254443 export type Report = {
444 /** Which drill this is a run of. A score means nothing without it. */
445 drill: DrillName;
446 /** That drill's name for a person, ready to print. */
447 label: string;
255448 phase: Phase;
256449 /** Seconds left, never below zero. */
257450 clock: number;
@@ -264,6 +457,8 @@ export type Report = {
264457 };
265458
266459 export const report = (run: Run): Report => ({
460 drill: run.drill.name,
461 label: run.drill.label,
267462 phase: run.phase,
268463 clock: Math.max(0, run.clock),
269464 score: run.score,
src/server.test.ts +5 −1
@@ -80,8 +80,12 @@ test("the range page carries every id and action its entry reaches for", async (
8080 // the engine's controls; nothing type-checks that, so the page is checked.
8181 const body = await (await fetch(base + "/range")).text();
8282 for (const id of ["scene", "hud", "fps", "touch", "note", "look"]) expect(body).toContain(`id="${id}"`);
83 for (const action of ["duck", "jump", "walk", "reload", "start", "fire"])
83 // Six buttons, and `drill` is one of them: a phone chooses the drill the way a
84 // keyboard does with T. It took `reload`'s place, which this game has nothing
85 // to do — there is no magazine at this range, and the key still says so.
86 for (const action of ["duck", "jump", "walk", "drill", "start", "fire"])
8487 expect(body).toContain(`data-action="${action}"`);
88 expect(body).not.toContain('data-action="reload"');
8589 // cs's ids are not on this page: a shared shell is not a shared game.
8690 for (const id of ["menu", "pause", "buy", "stats", "play"]) expect(body).not.toContain(`id="${id}"`);
8791 });
static/range.37362a7d93.js deleted −58

Diff not shown — a line 15,483 characters wide (limit 2,000). −58

View file
static/range.79e3fb073e.js added +58

Diff not shown — a line 17,026 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs