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