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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/88-the-range-gets-a-second-drill-moving-pla → main 1 commit fork
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 | ||
| 5 | 5 | rounds, a buy menu and an economy, bots that walk to a site and shoot back — |
| 6 | 6 | and the leaderboard of the matches people played. The second, at `/range`, is |
| 7 | 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 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, | |
| 11 | 11 | its own plates and its own tracer, out of an atlas and two meshes it painted |
| 12 | 12 | and built itself. The third, at `/example`, is the **example**: the guide's own |
| 13 | 13 | game — one room seen from above, one plate, thirty seconds and a score, drawn |
| @@ -101,9 +101,16 @@ the rest. | ||
| 101 | 101 | At `/range` the same controls drive the second game, on its own bindings: |
| 102 | 102 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
| 103 | 103 | <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 | |
| 107 | 114 | tracer are drawn by the engine's renderer and the range makes its own noise |
| 108 | 115 | through the engine's sound device — a dry crack, a tone for a hit, a knock as a |
| 109 | 116 | 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 | ||
| 304 | 311 | and its own siblings, and nothing else. **It is a game with no renderer under |
| 305 | 312 | it**: `binding.ts` is its own key table (50 Hz, its own actions — no buy, |
| 306 | 313 | 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 | |
| 309 | 317 | `shoot` traces the engine's `rayBox` along the view and folds the hit, the |
| 310 | 318 | score and the accuracy in, all plain data in and plain data out with no canvas |
| 311 | 319 | 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 | ||
| 370 | 378 | canvases, draws its ticks, answers a key and — given WebGL2 — uploads its |
| 371 | 379 | atlas and draws the room — so a bundling mistake fails |
| 372 | 380 | `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 | |
| 377 | 388 | through the same shell, with no database, no endpoint and no cookie behind it. |
| 378 | 389 | - `src/example/` — the third game, at `/example`, and the wiring of one and |
| 379 | 390 | 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 = { | ||
| 87 | 87 | * the app's bundle and in neither line of the range's. Measured 2026-09-18 after |
| 88 | 88 | * #106: app 71,646 raw / 33,273 gzipped, range 27,268 raw / 11,674 gzipped. |
| 89 | 89 | * 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. | |
| 90 | 99 | * #80 added a third entry, `example`, and its ceiling is the first number in |
| 91 | 100 | * this file nobody had to argue about: the guide's game with a page around it |
| 92 | 101 | * 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[] = [ | ||
| 112 | 121 | entry: "src/range/main.ts", |
| 113 | 122 | ceiling: { raw: 29_400, gzip: 12_700 }, |
| 114 | 123 | 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.", | |
| 116 | 125 | }, |
| 117 | 126 | { |
| 118 | 127 | name: "example", |
src/pages/range.ts +34 −16
| @@ -17,18 +17,22 @@ import { page, type PageOptions as ShellOptions, stage } from "./shell.ts"; | ||
| 17 | 17 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">; |
| 18 | 18 | |
| 19 | 19 | 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> | |
| 27 | 30 | <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div> |
| 28 | 31 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · |
| 29 | 32 | <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> | |
| 32 | 36 | <p class="hint">It makes its own noise, through the engine's sound device and the range's own |
| 33 | 37 | recipes: a short dry crack, a clean tone for a hit, a knock as a plate stands up — quieter |
| 34 | 38 | 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 { | ||
| 54 | 58 | { action: "walk", label: "walk" }, |
| 55 | 59 | ], |
| 56 | 60 | [ |
| 57 | { action: "reload", label: "reload" }, | |
| 61 | { action: "drill", label: "drill" }, | |
| 58 | 62 | { action: "start", label: "start" }, |
| 59 | 63 | { action: "fire", label: "fire" }, |
| 60 | 64 | ], |
| @@ -72,12 +76,26 @@ export function rangePage(options: PageOptions): string { | ||
| 72 | 76 | actions are held, what movement they and the thumb stick add up to, and where you are |
| 73 | 77 | looking.</p> |
| 74 | 78 | <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> | |
| 81 | 99 | <p>You can see it now, and by the same renderer the shooter uses: the room is one mesh built |
| 82 | 100 | from this game's own numbers, every plate is one instance of one cube sized to its own box, |
| 83 | 101 | 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", () => { | ||
| 18 | 18 | expect(RANGE_KEYS.ShiftLeft).toBe("walk"); |
| 19 | 19 | expect(RANGE_KEYS.KeyR).toBe("reload"); |
| 20 | 20 | 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"); | |
| 21 | 23 | // A keyboard reaches the trigger too: pointer lock is not the only way in. |
| 22 | 24 | expect(RANGE_KEYS.KeyF).toBe("fire"); |
| 23 | 25 | expect(RANGE_MOUSE[0]).toBe("fire"); |
| 24 | 26 | expect(RANGE_MOUSE[2]).toBeUndefined(); |
| 25 | expect([...RANGE_TAP]).toEqual(["jump", "reload", "start"]); | |
| 27 | expect([...RANGE_TAP]).toEqual(["jump", "reload", "start", "drill"]); | |
| 26 | 28 | // Nothing cs-shaped: no buy, no scoreboard, no weapon slots. |
| 27 | 29 | for (const code of ["KeyB", "Tab", "Digit1", "Digit2", "Digit3"]) |
| 28 | 30 | expect(RANGE_KEYS[code]).toBeUndefined(); |
| @@ -52,13 +54,14 @@ test("the stick adds to the keys and the sum clamps to ±1", () => { | ||
| 52 | 54 | }); |
| 53 | 55 | |
| 54 | 56 | 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"]); | |
| 56 | 58 | expect(held.jump).toBe(true); |
| 57 | 59 | expect(held.duck).toBe(true); |
| 58 | 60 | expect(held.walk).toBe(true); |
| 59 | 61 | expect(held.fire).toBe(true); |
| 60 | 62 | expect(held.reload).toBe(true); |
| 61 | 63 | expect(held.start).toBe(true); |
| 64 | expect(held.drill).toBe(true); | |
| 62 | 65 | }); |
| 63 | 66 | |
| 64 | 67 | 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> = { | ||
| 15 | 15 | KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right", |
| 16 | 16 | ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right", |
| 17 | 17 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", |
| 18 | KeyF: "fire", KeyR: "reload", Enter: "start", | |
| 18 | KeyF: "fire", KeyR: "reload", Enter: "start", KeyT: "drill", | |
| 19 | 19 | }; |
| 20 | 20 | |
| 21 | 21 | /** One weapon, one button: the left one. There is nothing to scope here. */ |
| 22 | 22 | export const RANGE_MOUSE: Record<number, string> = { 0: "fire" }; |
| 23 | 23 | |
| 24 | 24 | /** 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; | |
| 26 | 26 | |
| 27 | 27 | /** |
| 28 | 28 | * 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; | ||
| 30 | 30 | * `data-action` — is collected by the controls and ignored here. |
| 31 | 31 | */ |
| 32 | 32 | 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", | |
| 34 | 34 | ] as const; |
| 35 | 35 | |
| 36 | 36 | export type RangeAction = (typeof RANGE_ACTIONS)[number]; |
| @@ -51,11 +51,18 @@ export type Intent = { | ||
| 51 | 51 | reload: boolean; |
| 52 | 52 | /** Begin a run — the button and <kbd>Enter</kbd> both say it. */ |
| 53 | 53 | 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; | |
| 54 | 60 | }; |
| 55 | 61 | |
| 56 | 62 | /** Nothing held, nothing pushed. */ |
| 57 | 63 | 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, | |
| 59 | 66 | }; |
| 60 | 67 | |
| 61 | 68 | 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 | ||
| 69 | 76 | fire: down("fire"), |
| 70 | 77 | reload: down("reload"), |
| 71 | 78 | start: down("start"), |
| 79 | drill: down("drill"), | |
| 72 | 80 | }; |
| 73 | 81 | } |
| 74 | 82 |
src/range/boot.test.ts +85 −14
| @@ -172,8 +172,8 @@ test("Enter starts a run, the clock runs and the plates pop up", () => { | ||
| 172 | 172 | const { p, undo } = booted(); |
| 173 | 173 | try { |
| 174 | 174 | 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%"); | |
| 177 | 177 | |
| 178 | 178 | p.window.fire("keydown", { code: "Enter" }); |
| 179 | 179 | 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", () => { | ||
| 218 | 218 | // first: the bullet goes where the crosshair ended up. |
| 219 | 219 | p.window.fire("keydown", { code: "KeyF" }); |
| 220 | 220 | 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); | |
| 224 | 224 | |
| 225 | 225 | // The trigger held down is one shot, not one a frame. |
| 226 | 226 | p.step(20); |
| 227 | expect(latest(p, "score")).toContain("1/1 hits"); | |
| 227 | expect(latest(p, "pop-up")).toContain("1/1 hits"); | |
| 228 | 228 | // Letting go and pulling again is a second shot, and the plate is gone. |
| 229 | 229 | p.window.fire("keyup", { code: "KeyF" }); |
| 230 | 230 | p.step(20); |
| 231 | 231 | p.window.fire("keydown", { code: "KeyF" }); |
| 232 | 232 | p.step(20); |
| 233 | expect(latest(p, "score")).toContain("1/2 hits · 50%"); | |
| 233 | expect(latest(p, "pop-up")).toContain("1/2 hits · 50%"); | |
| 234 | 234 | } finally { |
| 235 | 235 | undo(); |
| 236 | 236 | } |
| @@ -246,24 +246,24 @@ test("the clock ends the run, the entry stops shooting and the best score stays" | ||
| 246 | 246 | p.window.fire("keydown", { code: "KeyF" }); |
| 247 | 247 | p.step(20); |
| 248 | 248 | p.window.fire("keyup", { code: "KeyF" }); |
| 249 | expect(latest(p, "score")).toContain("/1 hits"); | |
| 249 | expect(latest(p, "pop-up")).toContain("/1 hits"); | |
| 250 | 250 | |
| 251 | 251 | // Thirty seconds, eight 50 Hz ticks to a frame: 188 frames of 160 ms. |
| 252 | 252 | 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$/); | |
| 254 | 254 | |
| 255 | 255 | // The clock at zero refuses the trigger: still the one shot. |
| 256 | 256 | p.window.fire("keydown", { code: "KeyF" }); |
| 257 | 257 | p.step(20); |
| 258 | expect(latest(p, "score")).toContain("/1 hits"); | |
| 258 | expect(latest(p, "pop-up")).toContain("/1 hits"); | |
| 259 | 259 | // 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")); | |
| 261 | 261 | expect(score?.[2]).toBe(score?.[1] as string); |
| 262 | 262 | |
| 263 | 263 | // Enter gives a fresh thirty seconds, and the best survives it. |
| 264 | 264 | p.window.fire("keydown", { code: "Enter" }); |
| 265 | 265 | 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%$/); | |
| 267 | 267 | expect(hudTexts(p)).toContain("30.0 s left · 1 up"); |
| 268 | 268 | } finally { |
| 269 | 269 | undo(); |
| @@ -422,7 +422,7 @@ test("a run makes its own noise: one device, a plate popping up, a crack and a h | ||
| 422 | 422 | played = sounds(p).length; |
| 423 | 423 | p.window.fire("keydown", { code: "KeyF" }); |
| 424 | 424 | p.step(20); |
| 425 | expect(latest(p, "score")).toContain("1/1 hits"); | |
| 425 | expect(latest(p, "pop-up")).toContain("1/1 hits"); | |
| 426 | 426 | expect(sincePlayed(p, played).filter((sound) => sound !== "pop")).toEqual(["crack", "hit"]); |
| 427 | 427 | |
| 428 | 428 | // 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", () | ||
| 456 | 456 | p.window.fire("keydown", { code: "KeyF" }); |
| 457 | 457 | p.step(20); |
| 458 | 458 | 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"); | |
| 460 | 460 | expect(sincePlayed(p, played).filter((sound) => sound !== "pop")).toEqual(["crack"]); |
| 461 | 461 | |
| 462 | 462 | // 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", ( | ||
| 528 | 528 | undo(); |
| 529 | 529 | } |
| 530 | 530 | }); |
| 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 @@ | ||
| 7 | 7 | * the readout's panel on the HUD canvas above it, and `audio.ts` is what the |
| 8 | 8 | * same frame sounds like through the engine's sound device. |
| 9 | 9 | * |
| 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 | * | |
| 10 | 17 | * Every sound is a crossing this file found in the game's own facts: a plate |
| 11 | 18 | * with an id the last frame had not reached stood up, the shot `shoot` handed |
| 12 | 19 | * back was taken and maybe hit, the clock passed one of the last three whole |
| @@ -27,7 +34,10 @@ import { createRangeAudio } from "./audio.ts"; | ||
| 27 | 34 | import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts"; |
| 28 | 35 | import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts"; |
| 29 | 36 | 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"; | |
| 31 | 41 | import { |
| 32 | 42 | angleVectors, attachButtons, clamp, createControls, createLoop, createRenderer, |
| 33 | 43 | } from "../engine/index.ts"; |
| @@ -113,15 +123,22 @@ export function boot(): void { | ||
| 113 | 123 | // two runs are two different thirty seconds, and a run is still reproducible |
| 114 | 124 | // from the one number it was spawned with. |
| 115 | 125 | 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. | |
| 119 | 135 | let firing = false; |
| 120 | 136 | let starting = false; |
| 137 | let switching = false; | |
| 121 | 138 | |
| 122 | 139 | /** Remember the score of a run that is finishing, whatever finished it. */ |
| 123 | 140 | const keepBest = (): void => { |
| 124 | best = Math.max(best, run.score); | |
| 141 | bests[run.drill.name] = Math.max(bests[run.drill.name], run.score); | |
| 125 | 142 | }; |
| 126 | 143 | |
| 127 | 144 | // The mouse only turns the view under pointer lock; a finger on the stage |
| @@ -168,6 +185,13 @@ export function boot(): void { | ||
| 168 | 185 | // the trigger and Enter half of it is the game. |
| 169 | 186 | const intent = readIntent(controls.held, controls.stick); |
| 170 | 187 | |
| 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 | ||
| 171 | 195 | if (intent.start && !starting) { |
| 172 | 196 | keepBest(); |
| 173 | 197 | start(run, freshSeed()); |
| @@ -233,7 +257,8 @@ export function boot(): void { | ||
| 233 | 257 | hudCtx, |
| 234 | 258 | { |
| 235 | 259 | run: report(run), |
| 236 | best, | |
| 260 | best: bests[run.drill.name], | |
| 261 | next: DRILLS[nextDrill(run.drill.name)].label, | |
| 237 | 262 | hz: Math.round(1 / TICK), |
| 238 | 263 | ticks, |
| 239 | 264 | frameMs, |
src/range/pin.test.ts +88 −11
| @@ -1,13 +1,19 @@ | ||
| 1 | 1 | /** |
| 2 | * The range's golden digest: what this game does, pinned. | |
| 2 | * The range's golden digests: what each drill does, pinned. | |
| 3 | 3 | * |
| 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. | |
| 11 | 17 | * |
| 12 | 18 | * Measured: the pinned thirty seconds fold in 8 ms (14 ms cold), and the whole |
| 13 | 19 | * file is 60 ms on its own, Bun's start and all — `bun test` is 4.2 s before |
| @@ -18,9 +24,9 @@ | ||
| 18 | 24 | import { expect, test } from "bun:test"; |
| 19 | 25 | import { |
| 20 | 26 | 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, | |
| 22 | 28 | } 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"; | |
| 24 | 30 | import { angleVectors } from "../engine/index.ts"; |
| 25 | 31 | |
| 26 | 32 | /** The run: a named seed, thirty seconds of it, at the range's own 50 Hz. */ |
| @@ -46,7 +52,31 @@ const RANGE_SECONDS = [ | ||
| 46 | 52 | ]; |
| 47 | 53 | |
| 48 | 54 | 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, | |
| 50 | 80 | }; |
| 51 | 81 | |
| 52 | 82 | /** A run that has not been played yet, seeded and live. */ |
| @@ -56,6 +86,13 @@ function ready(seed: number = SEED): Run { | ||
| 56 | 86 | return run; |
| 57 | 87 | } |
| 58 | 88 | |
| 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 | ||
| 59 | 96 | const must = (run: Pinned, pin: Pin): void => { |
| 60 | 97 | const complaint = compare(run, pin); |
| 61 | 98 | 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", () | ||
| 93 | 130 | expect(SCRIPT.every((aim) => aim.tick >= 0 && aim.tick < TICKS)).toBe(true); |
| 94 | 131 | }); |
| 95 | 132 | |
| 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 | ||
| 96 | 173 | test("the same seed is the same run, and another seed is another run", () => { |
| 97 | 174 | const four = 4 * TICKS_PER_SECOND; |
| 98 | 175 | const a = play(ready(), four); |
src/range/pin.ts +46 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 3 | 4 | * |
| 4 | 5 | * A renderer, an atlas and a movement tuning are all about to land under this |
| 5 | 6 | * game, and every one of them will end with the claim "the range plays exactly |
| @@ -20,6 +21,15 @@ | ||
| 20 | 21 | * a sentence saying what behaviour changed and why. A digest updated without |
| 21 | 22 | * that sentence is a pin that has stopped pinning anything. |
| 22 | 23 | * |
| 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 | * | |
| 23 | 33 | * What is hashed is the run's rules playing out — the phase, the clock, the |
| 24 | 34 | * score, the hits, the shots and every standing plate's place, size, lifetime |
| 25 | 35 | * 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[] = [ | ||
| 146 | 156 | { tick: 1420, yaw: 11.77, pitch: 0.84 }, // hit, 172 |
| 147 | 157 | ]; |
| 148 | 158 | |
| 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 | ||
| 149 | 194 | /** |
| 150 | 195 | * A cursor over the script: hand it a tick and it fires whatever that tick |
| 151 | 196 | * 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[] } { | ||
| 35 | 35 | } as Paint & { calls: Call[]; texts: string[]; lines: Call[] }; |
| 36 | 36 | } |
| 37 | 37 | |
| 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 | }; | |
| 39 | 42 | |
| 40 | 43 | const READOUT: Readout = { |
| 41 | run: RUN, best: 1200, | |
| 44 | run: RUN, best: 1200, next: "tracking", | |
| 42 | 45 | hz: 50, ticks: 1234, frameMs: 16.66, held: ["forward", "fire"], |
| 43 | 46 | move: { forward: 1, side: -0.5 }, yaw: 45, pitch: -10, locked: true, drawn: true, |
| 44 | 47 | }; |
| 45 | 48 | |
| 46 | 49 | test("the panel leads with the score and the clock, then the loop and the controls", () => { |
| 47 | 50 | 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%"); | |
| 49 | 52 | expect(lines[1]).toBe("12.3 s left · 3 up"); |
| 50 | 53 | expect(lines[2]).toBe("tick 1234 · 50 Hz fixed step"); |
| 51 | 54 | 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 | ||
| 60 | 63 | |
| 61 | 64 | test("the clock line says how to start a run, how long is left, and how it went", () => { |
| 62 | 65 | 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"); | |
| 64 | 67 | 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"); | |
| 66 | 69 | // No shots taken is no accuracy, not a NaN on the page. |
| 67 | 70 | 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%"); | |
| 69 | 72 | }); |
| 70 | 73 | |
| 71 | 74 | 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 | ||
| 186 | 189 | expect(paint.calls.filter((c) => c[0] === "fillRect").length).toBe(2 + run.targets.length); |
| 187 | 190 | expect(readoutLines({ ...READOUT, run: report(run) })[1]).toContain(`${run.targets.length} up`); |
| 188 | 191 | }); |
| 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 }; | ||
| 28 | 28 | |
| 29 | 29 | /** Everything the panel prints, gathered by `main.ts` once a frame. */ |
| 30 | 30 | 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. */ | |
| 32 | 32 | 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 | */ | |
| 34 | 38 | best: number; |
| 39 | /** What the drill key would switch to next. Named, so the key is discoverable. */ | |
| 40 | next: string; | |
| 35 | 41 | /** Seconds of simulation per tick, as the rate it is usually said at. */ |
| 36 | 42 | hz: number; |
| 37 | 43 | /** Ticks the loop has run since the page booted. */ |
| @@ -62,24 +68,32 @@ const perDegree = (height: number): number => height / 90; | ||
| 62 | 68 | /** Degrees into 0..360, so the compass reads like a compass. */ |
| 63 | 69 | export const wrapDegrees = (angle: number): number => ((angle % 360) + 360) % 360; |
| 64 | 70 | |
| 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 | */ | |
| 66 | 76 | export function scoreLine(run: Report, best: number): string { |
| 67 | 77 | 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}%`; | |
| 69 | 79 | } |
| 70 | 80 | |
| 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}`; | |
| 74 | 88 | 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}`; | |
| 76 | 90 | } |
| 77 | 91 | |
| 78 | 92 | /** The panel's lines, in order. Separate from the drawing so a test can read them. */ |
| 79 | 93 | export function readoutLines(r: Readout): string[] { |
| 80 | 94 | return [ |
| 81 | 95 | scoreLine(r.run, r.best), |
| 82 | clockLine(r.run), | |
| 96 | clockLine(r.run, r.next), | |
| 83 | 97 | `tick ${r.ticks} · ${r.hz} Hz fixed step`, |
| 84 | 98 | `frame ${r.frameMs.toFixed(1)} ms · ${(1000 / Math.max(0.1, r.frameMs)).toFixed(0)} fps`, |
| 85 | 99 | `held ${r.held.length === 0 ? "nothing" : r.held.join(" ")}`, |
src/range/scene.test.ts +52 −1
| @@ -14,7 +14,9 @@ import { | ||
| 14 | 14 | MESH, PLATE_HALF, RANGE_SKY, TRACER_LIFE, buildRangeScene, createRangeScene, rangeAssets, |
| 15 | 15 | rangeMeshes, type Trail, |
| 16 | 16 | } 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"; | |
| 18 | 20 | |
| 19 | 21 | /** A plate standing at `at`, with `half` its half-width and `left` of its life. */ |
| 20 | 22 | 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 | ||
| 180 | 182 | expect([...scene.instances]).toEqual(objects); |
| 181 | 183 | expect(scene.lines).toBe(lines); |
| 182 | 184 | }); |
| 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; | ||
| 55 | 55 | /** |
| 56 | 56 | * Instances one frame may place: the room, and one plate for every target |
| 57 | 57 | * 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 | |
| 59 | 59 | * drawn — the engine's policy at a cap, which the tracer above has always had. |
| 60 | 60 | */ |
| 61 | 61 | export const INSTANCE_CAPACITY = 16; |
| @@ -63,7 +63,7 @@ export const INSTANCE_CAPACITY = 16; | ||
| 63 | 63 | /** |
| 64 | 64 | * The half-width the plate mesh is built at. Every plate is drawn from that one |
| 65 | 65 | * 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. | |
| 67 | 67 | */ |
| 68 | 68 | export const PLATE_HALF = STANDARD.half; |
| 69 | 69 |
src/range/targets.test.ts +197 −3
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | 8 | import { angleVectors, dist, type Vec3 } from "../engine/index.ts"; |
| 9 | 9 | 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, | |
| 11 | 12 | } from "./targets.ts"; |
| 12 | 13 | |
| 13 | 14 | /** |
| @@ -16,7 +17,7 @@ import { | ||
| 16 | 17 | * Everything below that is not about the spawn uses this, so the geometry is a |
| 17 | 18 | * sum rather than a seed. |
| 18 | 19 | */ |
| 19 | const FLAT: Rules = { | |
| 20 | const FLAT: Drill = { | |
| 20 | 21 | ...STANDARD, |
| 21 | 22 | runTime: 10, |
| 22 | 23 | atOnce: 1, |
| @@ -36,8 +37,40 @@ function lane(): Run { | ||
| 36 | 37 | return run; |
| 37 | 38 | } |
| 38 | 39 | |
| 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 | ||
| 39 | 55 | const down = (yaw: number, pitch: number): Vec3 => angleVectors(yaw, pitch).forward; |
| 40 | 56 | |
| 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 | ||
| 41 | 74 | test("a plate pops up down the lane, as a box arithmetic can place", () => { |
| 42 | 75 | const run = lane(); |
| 43 | 76 | 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", () => | ||
| 179 | 212 | |
| 180 | 213 | test("the clock runs the run: thirty seconds, then it is over and the lane is empty", () => { |
| 181 | 214 | 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 | }); | |
| 183 | 219 | start(run); |
| 184 | 220 | for (let i = 0; i < 1499; i++) step(run, 0.02); |
| 185 | 221 | expect(run.phase).toBe("live"); |
| @@ -256,3 +292,161 @@ test("the plates stand in front of the shooter, inside the lane the rules give", | ||
| 256 | 292 | } |
| 257 | 293 | expect(run.spawned).toBeGreaterThan(20); |
| 258 | 294 | }); |
| 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 @@ | ||
| 1 | 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. | |
| 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. | |
| 8 | 9 | * |
| 9 | 10 | * Units are the engine's: Source units, one unit about an inch, X east, Y north |
| 10 | 11 | * and Z up. The shooter stands at the origin looking down +X (yaw 0) with an |
| @@ -14,6 +15,20 @@ | ||
| 14 | 15 | * Everything random comes from the engine's `rng`, drawn in one fixed order, so |
| 15 | 16 | * a seed reproduces a run exactly: the same seed pops the same plates up at the |
| 16 | 17 | * 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. | |
| 17 | 32 | */ |
| 18 | 33 | |
| 19 | 34 | import { |
| @@ -31,8 +46,27 @@ export const EYE: Vec3 = [0, 0, 64]; | ||
| 31 | 46 | */ |
| 32 | 47 | export const PLATE_MAT = 0; |
| 33 | 48 | |
| 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; | |
| 36 | 70 | /** How long one run lasts, in seconds. */ |
| 37 | 71 | runTime: number; |
| 38 | 72 | /** Most plates standing at once. */ |
| @@ -43,50 +77,129 @@ export type Rules = { | ||
| 43 | 77 | life: { min: number; max: number }; |
| 44 | 78 | /** How far down the lane a plate may stand, in units. */ |
| 45 | 79 | 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. */ | |
| 47 | 81 | 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; | |
| 48 | 91 | /** The band a plate's centre stands in, in units above the floor. */ |
| 49 | 92 | 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 }; | |
| 50 | 99 | /** Half the side of a plate, in units: they are cubes, so this is all of it. */ |
| 51 | 100 | 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 }; | |
| 54 | 107 | /** How far a bullet carries, in units. Hitscan: it arrives or it does not. */ |
| 55 | 108 | range: number; |
| 56 | 109 | }; |
| 57 | 110 | |
| 58 | 111 | /** |
| 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. | |
| 63 | 128 | */ |
| 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 | }, | |
| 75 | 167 | }; |
| 76 | 168 | |
| 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 | ||
| 77 | 182 | /** One plate, standing. */ |
| 78 | 183 | export type Target = { |
| 79 | 184 | /** 1, 2, 3… in the order they popped up this run. */ |
| 80 | 185 | id: number; |
| 81 | 186 | /** Where it is, as the engine traces it. */ |
| 82 | 187 | 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; | |
| 83 | 196 | /** Seconds left before it drops. */ |
| 84 | 197 | life: number; |
| 85 | 198 | /** Seconds it was given, so a readout can show how much of that is left. */ |
| 86 | 199 | 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. */ | |
| 88 | 201 | 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. */ | |
| 90 | 203 | distance: number; |
| 91 | 204 | }; |
| 92 | 205 | |
| @@ -94,7 +207,8 @@ export type Target = { | ||
| 94 | 207 | export type Phase = "ready" | "live" | "over"; |
| 95 | 208 | |
| 96 | 209 | export type Run = { |
| 97 | rules: Rules; | |
| 210 | /** Which drill is being played, whole. A run is never between two of them. */ | |
| 211 | drill: Drill; | |
| 98 | 212 | phase: Phase; |
| 99 | 213 | /** Seconds left in the run. `runTime` before it starts, zero once it is over. */ |
| 100 | 214 | clock: number; |
| @@ -111,12 +225,21 @@ export type Run = { | ||
| 111 | 225 | random: () => number; |
| 112 | 226 | }; |
| 113 | 227 | |
| 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 | ||
| 114 | 237 | /** 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 { | |
| 116 | 239 | return { |
| 117 | rules, | |
| 240 | drill, | |
| 118 | 241 | phase: "ready", |
| 119 | clock: rules.runTime, | |
| 242 | clock: drill.runTime, | |
| 120 | 243 | nextIn: 0, |
| 121 | 244 | targets: [], |
| 122 | 245 | spawned: 0, |
| @@ -124,7 +247,7 @@ export function createRun(seed: number, rules: Rules = STANDARD): Run { | ||
| 124 | 247 | hits: 0, |
| 125 | 248 | score: 0, |
| 126 | 249 | seed: seed >>> 0, |
| 127 | random: rng(seed), | |
| 250 | random: stream(seed, drill), | |
| 128 | 251 | }; |
| 129 | 252 | } |
| 130 | 253 | |
| @@ -135,9 +258,9 @@ export function createRun(seed: number, rules: Rules = STANDARD): Run { | ||
| 135 | 258 | */ |
| 136 | 259 | export function start(run: Run, seed: number = run.seed): void { |
| 137 | 260 | run.seed = seed >>> 0; |
| 138 | run.random = rng(run.seed); | |
| 261 | run.random = stream(run.seed, run.drill); | |
| 139 | 262 | run.phase = "live"; |
| 140 | run.clock = run.rules.runTime; | |
| 263 | run.clock = run.drill.runTime; | |
| 141 | 264 | run.nextIn = 0; |
| 142 | 265 | run.targets = []; |
| 143 | 266 | run.spawned = 0; |
| @@ -146,17 +269,55 @@ export function start(run: Run, seed: number = run.seed): void { | ||
| 146 | 269 | run.score = 0; |
| 147 | 270 | } |
| 148 | 271 | |
| 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 | */ | |
| 150 | 304 | function pop(run: Run): Target { |
| 151 | const r = run.rules; | |
| 305 | const r = run.drill; | |
| 152 | 306 | const depth = lerp(r.depth.min, r.depth.max, run.random()); |
| 153 | 307 | const side = lerp(-r.spread, r.spread, run.random()); |
| 154 | 308 | const centre = lerp(r.height.min, r.height.max, run.random()); |
| 155 | 309 | 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]; | |
| 156 | 314 | const at: Vec3 = [depth, side, centre]; |
| 157 | 315 | 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 | ); | |
| 160 | 321 | run.spawned += 1; |
| 161 | 322 | return { |
| 162 | 323 | id: run.spawned, |
| @@ -165,6 +326,8 @@ function pop(run: Run): Target { | ||
| 165 | 326 | max: [at[0] + r.half, at[1] + r.half, at[2] + r.half], |
| 166 | 327 | mat: PLATE_MAT, |
| 167 | 328 | }, |
| 329 | vel, | |
| 330 | rate, | |
| 168 | 331 | life: span, |
| 169 | 332 | span, |
| 170 | 333 | points, |
| @@ -179,11 +342,19 @@ function pop(run: Run): Target { | ||
| 179 | 342 | */ |
| 180 | 343 | const SPENT = 1e-9; |
| 181 | 344 | |
| 345 | /** The one vector a tick writes a plate's centre into: a step allocates nothing. */ | |
| 346 | const CENTRE: Vec3 = [0, 0, 0]; | |
| 347 | ||
| 182 | 348 | /** |
| 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. | |
| 187 | 358 | * |
| 188 | 359 | * Only a live run moves. A step outside one, or with a dt that is not a |
| 189 | 360 | * positive number, does nothing at all. |
| @@ -191,13 +362,31 @@ const SPENT = 1e-9; | ||
| 191 | 362 | export function step(run: Run, dt: number): void { |
| 192 | 363 | if (run.phase !== "live" || !Number.isFinite(dt) || dt <= 0) return; |
| 193 | 364 | 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 | ); | |
| 196 | 385 | |
| 197 | 386 | 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) { | |
| 199 | 388 | run.targets.push(pop(run)); |
| 200 | run.nextIn += run.rules.gap; | |
| 389 | run.nextIn += run.drill.gap; | |
| 201 | 390 | } |
| 202 | 391 | if (run.clock <= SPENT) { |
| 203 | 392 | run.phase = "over"; |
| @@ -234,7 +423,7 @@ export function shoot(run: Run, eye: Vec3, direction: Vec3): Shot | null { | ||
| 234 | 423 | const aim = normalize(direction); |
| 235 | 424 | if (length(aim) < 0.5) return null; |
| 236 | 425 | |
| 237 | const to = add(eye, scale(aim, run.rules.range)); | |
| 426 | const to = add(eye, scale(aim, run.drill.range)); | |
| 238 | 427 | run.shots += 1; |
| 239 | 428 | const hit = traceWorld(eye, to, run.targets.map((target) => target.box)); |
| 240 | 429 | 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 | ||
| 252 | 441 | |
| 253 | 442 | /** Everything a scoreboard or a readout needs, and nothing it would have to compute. */ |
| 254 | 443 | 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; | |
| 255 | 448 | phase: Phase; |
| 256 | 449 | /** Seconds left, never below zero. */ |
| 257 | 450 | clock: number; |
| @@ -264,6 +457,8 @@ export type Report = { | ||
| 264 | 457 | }; |
| 265 | 458 | |
| 266 | 459 | export const report = (run: Run): Report => ({ |
| 460 | drill: run.drill.name, | |
| 461 | label: run.drill.label, | |
| 267 | 462 | phase: run.phase, |
| 268 | 463 | clock: Math.max(0, run.clock), |
| 269 | 464 | 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 ( | ||
| 80 | 80 | // the engine's controls; nothing type-checks that, so the page is checked. |
| 81 | 81 | const body = await (await fetch(base + "/range")).text(); |
| 82 | 82 | 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"]) | |
| 84 | 87 | expect(body).toContain(`data-action="${action}"`); |
| 88 | expect(body).not.toContain('data-action="reload"'); | |
| 85 | 89 | // cs's ids are not on this page: a shared shell is not a shared game. |
| 86 | 90 | for (const id of ["menu", "pause", "buy", "stats", "play"]) expect(body).not.toContain(`id="${id}"`); |
| 87 | 91 | }); |
static/range.37362a7d93.js deleted −58
Diff not shown — a line 15,483 characters wide (limit 2,000). −58
View filestatic/range.79e3fb073e.js added +58
Diff not shown — a line 17,026 characters wide (limit 2,000). +58
View file
new above
offline