Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The range walks: its own movement tuning and hull through the engine’s solver #163
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/63-the-range-walks-its-own-movement-tuning → main 2 commits fork
18 files
changed +867
−109
README.md +24 −3
| @@ -139,8 +139,25 @@ because a tracking score means nothing beside a pop-up one. The room, the plates | ||
| 139 | 139 | tracer are drawn by the engine's renderer and the range makes its own noise |
| 140 | 140 | through the engine's sound device — a dry crack, a tone for a hit, a knock as a |
| 141 | 141 | plate stands up, a tick for each of the last three seconds and one falling tone |
| 142 | when the clock runs out, none of them the shooter's; there is no walking yet, so | |
| 143 | you stand at the firing line and turn on the spot. A browser with no WebGL2 keeps the game | |
| 142 | when the clock runs out, none of them the shooter's. **You walk, and you walk on | |
| 143 | the range's own numbers.** Both games step the same solver — `move` in | |
| 144 | `src/engine/physics.ts`, which holds not one tuned value of its own — and hand | |
| 145 | it a different tuning and a different hull on every call: the shooter's are | |
| 146 | `CS_MOVE` and `CS_HULL` in `src/client/movement.ts`, the range's are `RANGE_MOVE` | |
| 147 | and `RANGE_HULL` in `src/range/movement.ts`, and they agree about nothing. The | |
| 148 | range is slower (180 u/s against 250), heavier (900 u/s² of gravity against | |
| 149 | 800, so its jump clears 40.5 units against 57), stickier (7 of friction against | |
| 150 | 5.2), a worse strafe-jumper on purpose (a quarter of the air acceleration and | |
| 151 | half the air cap), wider in the hull and deeper and faster in the crouch — which | |
| 152 | drops the eye 22 units and changes what you can hit down the lane, because the | |
| 153 | shot is traced from wherever the eye actually is. (The shooter's grenade is a | |
| 154 | third set of numbers through the same `move`, `NADE_MOVE` — the solver is used | |
| 155 | by three bodies now and holds the values of none of them.) That two games run | |
| 156 | one solver on two sets of numbers is the proof the physics extraction was real, | |
| 157 | and | |
| 158 | `src/range/movement.test.ts` checks the range's second of running, its jump's | |
| 159 | apex, its crouch and its walls against its own tuning rather than against a | |
| 160 | number typed twice. A browser with no WebGL2 keeps the game | |
| 144 | 161 | on the 2D horizon it shipped with — a plate as a square drawn from the angle it |
| 145 | 162 | subtends — and the panel says which of the two you are looking at. |
| 146 | 163 | |
| @@ -386,7 +403,11 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 386 | 403 | lifetimes and the run's clock, retiring what has expired or left the lane, and |
| 387 | 404 | `shoot` traces the engine's `rayBox` along the view and folds the hit, the |
| 388 | 405 | score and the accuracy in, all plain data in and plain data out with no canvas |
| 389 | anywhere near it; `readout.ts` draws a horizon, the plates on it as blips | |
| 406 | anywhere near it; `movement.ts` is how this game walks — `RANGE_MOVE` and | |
| 407 | `RANGE_HULL`, a sentence per number, handed to the engine's `move` on every | |
| 408 | call, plus the body, the wish and the eye every tick writes — and it is the | |
| 409 | first tuning that solver was handed by a game that is not the shooter; | |
| 410 | `readout.ts` draws a horizon, the plates on it as blips | |
| 390 | 411 | projected from the angle each subtends, and a panel with the score, the clock |
| 391 | 412 | and what the loop and the controls are doing — the panel, its lines and the |
| 392 | 413 | crosshair through the engine's `overlay`, which is the range proving a second |
scripts/build.test.ts +1 −1
| @@ -38,7 +38,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 38 | 38 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 39 | 39 | expect(ENTRIES).toEqual([ |
| 40 | 40 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 88_000, gzip: 40_500 } }, |
| 41 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 33_000, gzip: 14_000 } }, | |
| 41 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } }, | |
| 42 | 42 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } }, |
| 43 | 43 | ]); |
| 44 | 44 | // And it is the registry projected, not a second list beside it: a game's |
src/frames.test.ts +3 −3
| @@ -104,14 +104,14 @@ test("two hundred frames of the range leave the same pool and the same buffer", | ||
| 104 | 104 | const scene = createRangeScene(); |
| 105 | 105 | const busy = standing(3); |
| 106 | 106 | const empty = standing(0); |
| 107 | buildRangeScene(busy, { yaw: 0, pitch: 0 }, null, 0, scene); | |
| 107 | buildRangeScene(busy, { eye: EYE, yaw: 0, pitch: 0 }, null, 0, scene); | |
| 108 | 108 | const pool = scene.instances; |
| 109 | 109 | const objects = [...pool]; |
| 110 | 110 | const lines = scene.lines; |
| 111 | 111 | |
| 112 | 112 | for (let frame = 0; frame < FRAMES; frame++) { |
| 113 | 113 | const trail = frame % 2 === 0 ? { from: EYE, to: [900, 0, 60] as [number, number, number], at: frame } : null; |
| 114 | buildRangeScene(frame % 3 === 0 ? empty : busy, { yaw: frame, pitch: 0 }, trail, frame, scene); | |
| 114 | buildRangeScene(frame % 3 === 0 ? empty : busy, { eye: EYE, yaw: frame, pitch: 0 }, trail, frame, scene); | |
| 115 | 115 | } |
| 116 | 116 | |
| 117 | 117 | expect(scene.instances).toBe(pool); |
| @@ -144,7 +144,7 @@ test("a frame that wants more than its pool holds is refused, in both games", () | ||
| 144 | 144 | // that fit — the room and fifteen plates — rather than growing for the rest. |
| 145 | 145 | const range = createRangeScene(); |
| 146 | 146 | const rangePool = range.instances; |
| 147 | buildRangeScene(standing(RANGE_INSTANCES * 2), { yaw: 0, pitch: 0 }, null, 0, range); | |
| 147 | buildRangeScene(standing(RANGE_INSTANCES * 2), { eye: EYE, yaw: 0, pitch: 0 }, null, 0, range); | |
| 148 | 148 | expect(range.instances).toBe(rangePool); |
| 149 | 149 | expect(range.instances.length).toBe(RANGE_INSTANCES); |
| 150 | 150 | expect(range.instanceCount).toBe(RANGE_INSTANCES); |
src/games.ts +15 −1
| @@ -211,6 +211,20 @@ export type Game = { | ||
| 211 | 211 | * handed out over a module graph that changed shape — it ships no grenade, no |
| 212 | 212 | * world and no weapons at all. Measured 2026-09-20 after #142 was merged with |
| 213 | 213 | * #152 and #158, on the bundles this branch commits. |
| 214 | * #63 gave the range a person: `src/range/movement.ts`'s tuning and hull, and | |
| 215 | * the engine's solver stepping a body through the room every tick. It is the | |
| 216 | * first thing the range has ever bundled out of `src/engine/physics.ts` — the | |
| 217 | * line above about it shipping none of the solver was true until this pull and | |
| 218 | * is not any more — and the mover, the wish, the eye and the stance line | |
| 219 | * together cost 4,665 raw / 1,443 gzipped bytes: range 28,980 → 33,645 raw and | |
| 220 | * 12,704 → 14,147 gzipped. That is over the 33,000 / 14,000 #132 set, so the | |
| 221 | * ceiling is raised here to 37,000 / 15,500 — today's size plus about ten per | |
| 222 | * cent, which is what every other number on this page is. 134 raw of those | |
| 223 | * bytes are #142's: `restitution` is a required field on a `MoveTuning` now, so | |
| 224 | * the range writes its own zero — a person does not bounce off concrete — and | |
| 225 | * the solver's two new branches ship with the rest of it. Neither other bundle | |
| 226 | * moved a byte for this pull: nothing outside `src/range/` changed here. | |
| 227 | * Measured 2026-09-20 with #142, #152 and #158 merged in. | |
| 214 | 228 | */ |
| 215 | 229 | export const GAMES: readonly Game[] = [ |
| 216 | 230 | { |
| @@ -227,7 +241,7 @@ export const GAMES: readonly Game[] = [ | ||
| 227 | 241 | href: "/range", |
| 228 | 242 | label: "range", |
| 229 | 243 | entry: "src/range/main.ts", |
| 230 | ceiling: { raw: 33_000, gzip: 14_000 }, | |
| 244 | ceiling: { raw: 37_000, gzip: 15_500 }, | |
| 231 | 245 | blurb: |
| 232 | 246 | "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.", |
| 233 | 247 | }, |
src/pages/range.ts +18 −5
| @@ -23,8 +23,10 @@ const NOTE = ` | ||
| 23 | 23 | your accuracy. There are two drills and <kbd>T</kbd> switches between them before a run — |
| 24 | 24 | <strong>pop-up</strong>, where plates stand up down the lane and drop when their time is |
| 25 | 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 | |
| 26 | speed, worth more the faster they are going. You walk the lane on this game's own numbers: | |
| 27 | 180 units a second, a heavier 900 of gravity, a jump that clears 40.5 units and a crouch | |
| 28 | that drops your eye 22 — slower and stickier than the shooter next door, because a firing | |
| 29 | lane is walked and not sprinted. On a browser with no WebGL2 the range falls back to the | |
| 28 | 30 | flat horizon it shipped with, plates as squares projected from the angle each subtends, |
| 29 | 31 | and the same two drills play on it.</p> |
| 30 | 32 | <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div> |
| @@ -101,10 +103,21 @@ export function rangePage(options: PageOptions): string { | ||
| 101 | 103 | the shot leaves a tracer for eighty milliseconds, and the atlas they are painted from is |
| 102 | 104 | three 64-pixel layers of this game's own — not a line of the shooter's map, models or |
| 103 | 105 | materials. What a frame looks like is plain data in <code>src/range/scene.ts</code>, read |
| 104 | by a test with no browser in the room. Still missing is the walking: the mover is behind | |
| 105 | the engine's door now, and the eye stands at the firing line until this game hands it a | |
| 106 | tuning and a hull of its own. If your browser has no WebGL2, the flat | |
| 106 | by a test with no browser in the room. If your browser has no WebGL2, the flat | |
| 107 | 107 | horizon is still there and the panel says so.</p> |
| 108 | <p>You can walk it, and that is the part of the engine this page was missing: the mover in | |
| 109 | <code>src/engine/physics.ts</code>, which holds not one tuned number of its own, steps a body | |
| 110 | through this room fifty times a second on the tuning and hull in | |
| 111 | <code>src/range/movement.ts</code>. They are the range's and they are not Counter-Strike's: | |
| 112 | 180 units a second against 250, 900 of gravity against 800, 7 of friction against 5.2, a | |
| 113 | quarter of the air acceleration — there is no strafe-jumping at this range — a wider hull, a | |
| 114 | deeper crouch and a jump that clears 40.5 units where the shooter's clears 57. One number is | |
| 115 | the same on purpose and not by inheritance: a standing eye 64 units off the floor, which is | |
| 116 | what every plate's distance is measured from and what the thirty seconds pinned in | |
| 117 | <code>src/range/pin.ts</code> were shot from. The shot leaves the eye you actually have, so | |
| 118 | walking down the lane and crouching at the line change what you can hit; the panel prints | |
| 119 | where you are standing, how high your eye is and how fast you are going. Two games, one | |
| 120 | solver, two sets of numbers — which is the whole of what taking the physics apart was for.</p> | |
| 108 | 121 | <p>You can hear it too, and that is the last part of the engine this page was not using: its |
| 109 | 122 | sound device, with five recipes of the range's own in <code>src/range/audio.ts</code> — the |
| 110 | 123 | crack, the hit, the knock of a plate standing up, the tick of each of the last three seconds |
src/range/boot.test.ts +64
| @@ -637,3 +637,67 @@ test("a best score belongs to its drill and is never shown beside the other", () | ||
| 637 | 637 | undo(); |
| 638 | 638 | } |
| 639 | 639 | }); |
| 640 | ||
| 641 | test("W walks the person down the lane, and the camera and the panel go with them", () => { | |
| 642 | const { p, undo } = booted({ webgl: true }); | |
| 643 | try { | |
| 644 | p.step(20); | |
| 645 | expect(hudTexts(p)).toContain("lane 0 · across 0 · eye 64 · 0 u/s"); | |
| 646 | ||
| 647 | // Two seconds of forward, eight 50 Hz ticks to a frame: the body is at the | |
| 648 | // range's top speed and has covered most of two seconds of lane. | |
| 649 | p.window.fire("keydown", { code: "KeyW" }); | |
| 650 | for (let i = 0; i < 12; i++) p.step(160); | |
| 651 | // Read the panel with the key still down: the tick after letting go has | |
| 652 | // already had the range's friction taken out of it. | |
| 653 | const line = latest(p, "lane "); | |
| 654 | const lane = Number(/^lane (-?\d+)/.exec(line)?.[1]); | |
| 655 | expect(lane).toBeGreaterThan(300); | |
| 656 | expect(line).toContain("eye 64"); | |
| 657 | expect(line).toContain("180 u/s"); | |
| 658 | // The camera goes with them — `scene.test.ts` reads the camera itself; what | |
| 659 | // this frame says is that the room was still drawn from wherever they are. | |
| 660 | expect(named(glCalls(p), "drawElements").length).toBeGreaterThan(0); | |
| 661 | ||
| 662 | // Letting go stops them: the sticky floor is the range's own. | |
| 663 | p.window.fire("keyup", { code: "KeyW" }); | |
| 664 | for (let i = 0; i < 6; i++) p.step(160); | |
| 665 | expect(latest(p, "lane ")).toContain("0 u/s"); | |
| 666 | ||
| 667 | // And crouching drops the eye by the range's own 22 units. | |
| 668 | p.window.fire("keydown", { code: "KeyC" }); | |
| 669 | p.step(160); | |
| 670 | expect(latest(p, "lane ")).toContain("eye 42"); | |
| 671 | } finally { | |
| 672 | undo(); | |
| 673 | } | |
| 674 | }); | |
| 675 | ||
| 676 | test("walking changes what you can hit: the same aim from two places", () => { | |
| 677 | const { p, undo } = booted(); | |
| 678 | try { | |
| 679 | // A plate stands somewhere down the lane; walk twenty units left of the | |
| 680 | // firing line and the bearing to it is no longer the bearing it was. | |
| 681 | p.window.fire("keydown", { code: "Enter" }); | |
| 682 | p.step(20); | |
| 683 | p.window.fire("keyup", { code: "Enter" }); | |
| 684 | const before = p.scene.paint?.calls.length ?? 0; | |
| 685 | p.step(20); | |
| 686 | const first = platesDrawn(p, before)[0] as [string, number, number, number]; | |
| 687 | ||
| 688 | p.window.fire("keydown", { code: "KeyA" }); | |
| 689 | for (let i = 0; i < 4; i++) p.step(100); | |
| 690 | p.window.fire("keyup", { code: "KeyA" }); | |
| 691 | const moved = p.scene.paint?.calls.length ?? 0; | |
| 692 | p.step(20); | |
| 693 | const second = platesDrawn(p, moved)[0] as [string, number, number, number]; | |
| 694 | ||
| 695 | // The same plate, the same view angles, a different place on the screen: | |
| 696 | // the horizon is drawn from the eye the body has, not from a constant. | |
| 697 | expect(second).toBeDefined(); | |
| 698 | expect(latest(p, "lane ")).not.toContain("across 0"); | |
| 699 | expect(Math.abs(second[1] - first[1])).toBeGreaterThan(1); | |
| 700 | } finally { | |
| 701 | undo(); | |
| 702 | } | |
| 703 | }); |
src/range/bundle.test.ts +19 −8
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | import { afterAll, expect, test } from "bun:test"; |
| 18 | 18 | import { readFileSync } from "node:fs"; |
| 19 | 19 | import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts"; |
| 20 | import { RANGE_MOVE } from "./movement.ts"; | |
| 20 | 21 | import { page, type RangePage } from "./stage.test-helper.ts"; |
| 21 | 22 | |
| 22 | 23 | afterAll(discardBundleCopies); |
| @@ -126,16 +127,26 @@ test("the range bundles none of the engine's policy network", () => { | ||
| 126 | 127 | for (const name of ["Math.tanh", "atob", "btoa"]) expect(code).not.toContain(name); |
| 127 | 128 | }); |
| 128 | 129 | |
| 129 | test("the range bundles none of the engine's mover either", () => { | |
| 130 | // The mover went behind the door in #123 and the range still walks nobody, so | |
| 131 | // the door widening cost this bundle nothing. Read as property names, which | |
| 132 | // the minifier cannot rename because a game's tuning literal writes them: a | |
| 133 | // bundle that carried the solver would carry the fields it reads out of a | |
| 134 | // `MoveTuning`. The shooter's bundle has all four — `src/client/bundle.test.ts` | |
| 135 | // is the other end of this claim. | |
| 130 | test("the range bundles the engine's mover, on the range's own numbers", () => { | |
| 131 | // This test used to say the opposite, and it was right until #63: the mover | |
| 132 | // went behind the door in #123 and the range walked nobody, so the door | |
| 133 | // widening cost this bundle nothing. Now a person walks here, so the solver | |
| 134 | // ships — and what is worth reading in the file is *whose numbers* it ships | |
| 135 | // with. The four fields are property names a minifier cannot rename, because | |
| 136 | // a game's tuning literal writes them; the values beside them are this | |
| 137 | // game's, and none of them is Counter-Strike's. | |
| 136 | 138 | const code = readFileSync(bundlePath("range"), "utf8"); |
| 137 | 139 | for (const field of ["airSpeedCap", "duckRate", "jumpVelocity", "stopSpeed"]) |
| 138 | expect(code).not.toContain(field); | |
| 140 | expect(code).toContain(field); | |
| 141 | // The range's jump and top speed are in there; the shooter's are not. 301.993 | |
| 142 | // is a number only `src/client/movement.ts` holds, and 250 is its top speed | |
| 143 | // written as a property value — `jumpVelocity:301.993` is what the other | |
| 144 | // bundle carries, and `src/client/bundle.test.ts` is the other end of it. | |
| 145 | expect(code).toContain(`jumpVelocity:${RANGE_MOVE.jumpVelocity}`); | |
| 146 | expect(code).toContain(`maxSpeed:${RANGE_MOVE.maxSpeed}`); | |
| 147 | expect(code).not.toContain("301.993"); | |
| 148 | expect(code).not.toContain("jumpVelocity:301.993"); | |
| 149 | expect(code).not.toContain("maxSpeed:250"); | |
| 139 | 150 | }); |
| 140 | 151 | |
| 141 | 152 | test("the stub page leaves nothing of itself behind", () => { |
src/range/main.ts +48 −10
| @@ -20,11 +20,17 @@ | ||
| 20 | 20 | * seconds, and the phase turned `over`. None of them is a comparison that |
| 21 | 21 | * would be true for sixty frames in a row. |
| 22 | 22 | * |
| 23 | * A person walks here, on the range's own numbers: `movement.ts` holds the | |
| 24 | * tuning and the hull, the engine's `move` steps the body through | |
| 25 | * `RANGE_ROOM`'s six slabs every tick, and the eye the engine's `eyeHeight` | |
| 26 | * puts on top of that body is what the camera sees from, what the bullet leaves | |
| 27 | * from and what the tracer is drawn from. Nothing in this file knows how fast | |
| 28 | * the range walks — it hands the tuning in and the solver does the arithmetic. | |
| 29 | * | |
| 23 | 30 | * A browser with no WebGL2 keeps the game rather than losing it: `createRenderer` |
| 24 | 31 | * hands back null, the scene canvas takes a 2D context instead and |
| 25 | * `drawHorizon` draws the plates as blips the way this page always did. There | |
| 26 | * is still no movement — the shooter stands at the origin and turns on the | |
| 27 | * spot, which is the physics' to change, not the renderer's. | |
| 32 | * `drawHorizon` draws the plates as blips the way this page always did — from | |
| 33 | * the same eye, so walking moves that picture too. | |
| 28 | 34 | * |
| 29 | 35 | * Nothing outside `src/engine/` and this directory is imported, and |
| 30 | 36 | * `src/independence.test.ts` reads these files to make sure it stays that way. |
| @@ -32,6 +38,10 @@ | ||
| 32 | 38 | |
| 33 | 39 | import { createRangeAudio } from "./audio.ts"; |
| 34 | 40 | import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts"; |
| 41 | import { RANGE_ROOM } from "./level.ts"; | |
| 42 | import { | |
| 43 | RANGE_HULL, RANGE_MOVE, createRangeBody, createRangeWish, rangeEye, rangeWish, | |
| 44 | } from "./movement.ts"; | |
| 35 | 45 | import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts"; |
| 36 | 46 | import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts"; |
| 37 | 47 | import { |
| @@ -40,6 +50,7 @@ import { | ||
| 40 | 50 | } from "./targets.ts"; |
| 41 | 51 | import { |
| 42 | 52 | angleVectors, attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, |
| 53 | move, | |
| 43 | 54 | } from "../engine/index.ts"; |
| 44 | 55 | |
| 45 | 56 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ |
| @@ -112,9 +123,17 @@ export function boot(): void { | ||
| 112 | 123 | now: () => performance.now(), |
| 113 | 124 | }); |
| 114 | 125 | |
| 126 | // The person: a body the engine's solver walks through the range's room on | |
| 127 | // the range's own numbers, and the one wish a tick hands it. Both are made | |
| 128 | // once — a frame allocates nothing — and `view.eye` is where the body's feet | |
| 129 | // and its crouch put the eye this frame, which the scene, the trigger, the | |
| 130 | // tracer and the 2D horizon all read. | |
| 131 | const body = createRangeBody(); | |
| 132 | const wish = createRangeWish(); | |
| 133 | ||
| 115 | 134 | // One view object for the whole run: the scene reads it, the readout prints |
| 116 | 135 | // it and the trigger aims down it, and no frame makes a second one. |
| 117 | const view = { yaw: 0, pitch: 0 }; | |
| 136 | const view = { eye: rangeEye(body, [0, 0, 0]), yaw: 0, pitch: 0 }; | |
| 118 | 137 | let ticks = 0; |
| 119 | 138 | let frameMs = 16.7; |
| 120 | 139 | // Seconds since the page booted. The run's clock counts down, so a tracer |
| @@ -200,10 +219,10 @@ export function boot(): void { | ||
| 200 | 219 | const steps = loop.advance(elapsed); |
| 201 | 220 | ticks += steps; |
| 202 | 221 | |
| 203 | // What was asked for this frame. The movement half of it is still only | |
| 204 | // drawn — the engine's mover is there, and nothing here hands it a tuning yet — and | |
| 205 | // the trigger and Enter half of it is the game. | |
| 222 | // What was asked for this frame: the movement half of it goes to the | |
| 223 | // solver below, and the trigger and Enter half of it is the game. | |
| 206 | 224 | const intent = readIntent(controls.held, controls.stick); |
| 225 | rangeWish(intent, view.yaw, wish); | |
| 207 | 226 | |
| 208 | 227 | // The drill is chosen between runs: `setDrill` refuses a live one, so a T |
| 209 | 228 | // pressed mid-run changes nothing at all rather than half a game. |
| @@ -230,7 +249,15 @@ export function boot(): void { | ||
| 230 | 249 | const wasLive = run.phase === "live"; |
| 231 | 250 | const clockBefore = run.clock; |
| 232 | 251 | const spawnedBefore = run.spawned; |
| 233 | for (let i = 0; i < steps; i++) step(run, TICK); | |
| 252 | // The person and the plates move on the same tick, the body first: the eye | |
| 253 | // a bullet leaves from below is where this tick left it, and the plate it | |
| 254 | // meets is where this tick left that. The room is the wall the solver stops | |
| 255 | // the body at, so there is nowhere to walk out of. | |
| 256 | for (let i = 0; i < steps; i++) { | |
| 257 | move(body, wish, TICK, RANGE_ROOM, RANGE_MOVE, RANGE_HULL); | |
| 258 | step(run, TICK); | |
| 259 | } | |
| 260 | rangeEye(body, view.eye); | |
| 234 | 261 | if (wasLive) { |
| 235 | 262 | // A plate that was not there before this frame stood up in it. |
| 236 | 263 | for (let i = 0; i < run.targets.length; i++) { |
| @@ -252,12 +279,17 @@ export function boot(): void { | ||
| 252 | 279 | // nothing else goes into it. Where the bullet stopped is the tracer's far |
| 253 | 280 | // end, which is the only thing the drawing takes from a shot. |
| 254 | 281 | if (intent.fire && !firing) { |
| 255 | const shot = shoot(run, EYE, angleVectors(view.yaw, view.pitch).forward); | |
| 282 | const shot = shoot(run, view.eye, angleVectors(view.yaw, view.pitch).forward); | |
| 256 | 283 | if (shot !== null) { |
| 257 | 284 | // A shot the run refused is null, so a trigger pulled before Enter or |
| 258 | 285 | // after the clock ran out is silent as well as harmless. |
| 259 | 286 | audio.shot(); |
| 260 | 287 | if (shot.target !== null) audio.hit(shot.distance); |
| 288 | // The tracer leaves the eye it was fired from, which is not the firing | |
| 289 | // line once a person has walked anywhere. | |
| 290 | trail.from[0] = view.eye[0]; | |
| 291 | trail.from[1] = view.eye[1]; | |
| 292 | trail.from[2] = view.eye[2]; | |
| 261 | 293 | trail.to[0] = shot.point[0]; |
| 262 | 294 | trail.to[1] = shot.point[1]; |
| 263 | 295 | trail.to[2] = shot.point[2]; |
| @@ -270,7 +302,7 @@ export function boot(): void { | ||
| 270 | 302 | renderer.draw(buildRangeScene(run, view, trail, time, frameScene), elapsed); |
| 271 | 303 | } else { |
| 272 | 304 | const [sw, sh] = size(scene); |
| 273 | drawHorizon(sceneCtx as Paint, view, sw, sh, { eye: EYE, targets: run.targets }); | |
| 305 | drawHorizon(sceneCtx as Paint, view, sw, sh, { eye: view.eye, targets: run.targets }); | |
| 274 | 306 | } |
| 275 | 307 | const [hw, hh] = size(hud); |
| 276 | 308 | drawReadout( |
| @@ -284,6 +316,12 @@ export function boot(): void { | ||
| 284 | 316 | frameMs, |
| 285 | 317 | held: heldActions(controls.held), |
| 286 | 318 | move: { forward: intent.forward, side: intent.side }, |
| 319 | stance: { | |
| 320 | lane: body.pos[0], | |
| 321 | across: body.pos[1], | |
| 322 | eye: view.eye[2] - body.pos[2], | |
| 323 | speed: Math.hypot(body.vel[0], body.vel[1]), | |
| 324 | }, | |
| 287 | 325 | yaw: view.yaw, |
| 288 | 326 | pitch: view.pitch, |
| 289 | 327 | locked: controls.locked, |
src/range/movement.test.ts added +358
| @@ -0,0 +1,358 @@ | ||
| 1 | /** | |
| 2 | * The range walking, on its own numbers and in its own room. Every expectation | |
| 3 | * below is arithmetic on `RANGE_MOVE` and `RANGE_HULL` — never a number typed | |
| 4 | * twice — so retuning the range moves the tests with it and a tuning that | |
| 5 | * quietly became the shooter's fails here. | |
| 6 | * | |
| 7 | * That last part is the point of the file. `src/engine/physics.test.ts` already | |
| 8 | * runs the solver under two profiles that share no number; what is new is that | |
| 9 | * a *game* now brings its own pair, and the arithmetic has to come out at the | |
| 10 | * range's numbers and not at Counter-Strike's. So the first test checks a | |
| 11 | * second of running against 250 u/s as well as against 180: a solver that had | |
| 12 | * learned the shooter's numbers, or a tuning that was copied from them, fails | |
| 13 | * it. The shooter's values are written here once, as the thing being ruled out, | |
| 14 | * and this file imports nothing of the shooter's to get them — | |
| 15 | * `src/independence.test.ts` would fail if it did. | |
| 16 | * | |
| 17 | * No canvas, no document and no clock: a body, a room of boxes and a loop. | |
| 18 | */ | |
| 19 | ||
| 20 | import { expect, test } from "bun:test"; | |
| 21 | import { | |
| 22 | eyeHeight, hullHeight, move, stuck, type Body, type Box, type Vec3, type Wish, | |
| 23 | } from "../engine/index.ts"; | |
| 24 | import { NO_INTENT, type Intent } from "./binding.ts"; | |
| 25 | import { LANE, RANGE_ROOM } from "./level.ts"; | |
| 26 | import { | |
| 27 | RANGE_HULL, RANGE_MOVE, RANGE_START, createRangeBody, createRangeWish, rangeEye, rangeWish, | |
| 28 | } from "./movement.ts"; | |
| 29 | import { EYE } from "./targets.ts"; | |
| 30 | ||
| 31 | /** The range's step, as `main.ts` and `pin.ts` both run it: 50 Hz. */ | |
| 32 | const TICK = 1 / 50; | |
| 33 | ||
| 34 | /** | |
| 35 | * Counter-Strike's top speed and gravity, written down here as the numbers this | |
| 36 | * game is *not* tuned to. Nothing imports them: they are the control group. | |
| 37 | */ | |
| 38 | const CS = { maxSpeed: 250, gravity: 800, jumpApex: 301.993 ** 2 / 1600 }; | |
| 39 | ||
| 40 | /** An intent with these keys held and nothing else. */ | |
| 41 | const held = (over: Partial<Intent> = {}): Intent => ({ ...NO_INTENT, ...over }); | |
| 42 | ||
| 43 | /** Step `body` through the room for `seconds` of `intent`, looking `yaw` around. */ | |
| 44 | function walk(body: Body, intent: Intent, seconds: number, yaw = 0, boxes: Box[] = RANGE_ROOM): Body { | |
| 45 | const wish: Wish = createRangeWish(); | |
| 46 | const ticks = Math.round(seconds / TICK); | |
| 47 | for (let i = 0; i < ticks; i++) { | |
| 48 | rangeWish(intent, yaw, wish); | |
| 49 | move(body, wish, TICK, boxes, RANGE_MOVE, RANGE_HULL); | |
| 50 | } | |
| 51 | return body; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Is this body inside the lane's four walls, hull and all? `insideRoom` is a | |
| 56 | * test for a *point* and the feet are on the floor rather than above it, so the | |
| 57 | * body's own question is this one: its whole width, inside the air. | |
| 58 | */ | |
| 59 | const inLane = (body: Body): boolean => | |
| 60 | body.pos[0] - RANGE_HULL.radius >= -LANE.back && | |
| 61 | body.pos[0] + RANGE_HULL.radius <= LANE.far && | |
| 62 | body.pos[1] - RANGE_HULL.radius >= -LANE.half && | |
| 63 | body.pos[1] + RANGE_HULL.radius <= LANE.half; | |
| 64 | ||
| 65 | /** How far the body is from where it started, across the floor. */ | |
| 66 | const travelled = (body: Body, from: Vec3 = RANGE_START): number => | |
| 67 | Math.hypot(body.pos[0] - from[0], body.pos[1] - from[1]); | |
| 68 | ||
| 69 | // --- the range's numbers, not the shooter's ------------------------------- | |
| 70 | ||
| 71 | test("a second of forward input carries the body the range's distance, not cs's", () => { | |
| 72 | const body = walk(createRangeBody(), held({ forward: 1 }), 1); | |
| 73 | const far = travelled(body); | |
| 74 | ||
| 75 | // It is at the range's top speed by the end of the second — `accelerate` 8 | |
| 76 | // gets there in an eighth of one — so the distance is that speed less the | |
| 77 | // ramp, which is a handful of units. | |
| 78 | expect(Math.hypot(body.vel[0], body.vel[1])).toBeCloseTo(RANGE_MOVE.maxSpeed, 6); | |
| 79 | expect(far).toBeGreaterThan(RANGE_MOVE.maxSpeed * 0.9); | |
| 80 | expect(far).toBeLessThanOrEqual(RANGE_MOVE.maxSpeed); | |
| 81 | ||
| 82 | // And it is nowhere near what the shooter would have covered: 180 against | |
| 83 | // 250 is 70 units of lane a second, which is a plate's width every tick. | |
| 84 | expect(far).toBeLessThan(CS.maxSpeed * 0.8); | |
| 85 | expect(RANGE_MOVE.maxSpeed).not.toBe(CS.maxSpeed); | |
| 86 | expect(RANGE_MOVE.gravity).not.toBe(CS.gravity); | |
| 87 | }); | |
| 88 | ||
| 89 | test("the walk key and the crouch are the fractions of top speed this game named", () => { | |
| 90 | // Six seconds is long past the ramp, so what is left is the steady speed: | |
| 91 | // three stances, three of this tuning's numbers, exactly. It is six and not | |
| 92 | // one because the failure this catches is a slow one — a stance whose | |
| 93 | // friction beats its acceleration bleeds a couple of units a second and | |
| 94 | // looks fine for the first of them. | |
| 95 | const settled = (intent: Intent): number => { | |
| 96 | const body = walk(createRangeBody(), intent, 6); | |
| 97 | return Math.hypot(body.vel[0], body.vel[1]); | |
| 98 | }; | |
| 99 | expect(settled(held({ forward: 1 }))).toBeCloseTo(RANGE_MOVE.maxSpeed, 6); | |
| 100 | expect(settled(held({ forward: 1, walk: true }))).toBeCloseTo(RANGE_MOVE.walkSpeed, 6); | |
| 101 | expect(settled(held({ forward: 1, duck: true }))).toBeCloseTo(RANGE_MOVE.duckSpeed, 6); | |
| 102 | // Which are a half and a third of the top speed, as this game's own file says. | |
| 103 | expect(RANGE_MOVE.walkSpeed * 2).toBe(RANGE_MOVE.maxSpeed); | |
| 104 | expect(RANGE_MOVE.duckSpeed * 3).toBe(RANGE_MOVE.maxSpeed); | |
| 105 | }); | |
| 106 | ||
| 107 | test("every stance can hold the speed it asks for: acceleration beats friction", () => { | |
| 108 | // The invariant behind the test above, stated rather than played out. At a | |
| 109 | // wish speed `w`, the solver adds at most `accelerate · w · dt` and friction | |
| 110 | // takes `max(w, stopSpeed) · friction · dt`, so a stance whose numbers fail | |
| 111 | // this inequality decays to nothing however long you hold the key. This is | |
| 112 | // the one place the range's tuning had to be argued with rather than chosen, | |
| 113 | // and it is checked here so a later retune cannot quietly lose it. | |
| 114 | for (const wishSpeed of [RANGE_MOVE.maxSpeed, RANGE_MOVE.walkSpeed, RANGE_MOVE.duckSpeed]) { | |
| 115 | const gained = RANGE_MOVE.accelerate * wishSpeed; | |
| 116 | const lost = Math.max(wishSpeed, RANGE_MOVE.stopSpeed) * RANGE_MOVE.friction; | |
| 117 | expect(gained, `${wishSpeed} u/s is a speed this tuning can hold`).toBeGreaterThanOrEqual(lost); | |
| 118 | } | |
| 119 | }); | |
| 120 | ||
| 121 | test("a jump's apex is v²/2g for the range's gravity, and it is not the shooter's", () => { | |
| 122 | // By hand, from the two numbers `movement.ts` brings and nothing else: | |
| 123 | // v₀ = 270 u/s, g = 900 u/s² | |
| 124 | // apex v₀²/2g = 72900 / 1800 = 40.5 u | |
| 125 | // climb v₀/g = 0.3 s = 15 ticks at the range's 50 Hz | |
| 126 | // flight 2v₀/g = 0.6 s = 30 ticks | |
| 127 | // The solver samples the parabola exactly at each tick boundary and the | |
| 128 | // climb is a whole number of ticks, so the sampled peak is the analytic one. | |
| 129 | const apex = RANGE_MOVE.jumpVelocity ** 2 / (2 * RANGE_MOVE.gravity); | |
| 130 | const climb = RANGE_MOVE.jumpVelocity / RANGE_MOVE.gravity; | |
| 131 | expect(apex).toBeCloseTo(40.5, 10); | |
| 132 | ||
| 133 | const body = createRangeBody(); | |
| 134 | const wish = createRangeWish(); | |
| 135 | rangeWish(held({ jump: true }), 0, wish); | |
| 136 | move(body, wish, TICK, RANGE_ROOM, RANGE_MOVE, RANGE_HULL); | |
| 137 | expect(body.onGround).toBe(false); | |
| 138 | ||
| 139 | rangeWish(NO_INTENT, 0, wish); | |
| 140 | let peak = body.pos[2], peakTick = 1, ticks = 1; | |
| 141 | while (!body.onGround) { | |
| 142 | move(body, wish, TICK, RANGE_ROOM, RANGE_MOVE, RANGE_HULL); | |
| 143 | ticks += 1; | |
| 144 | if (body.pos[2] > peak) { peak = body.pos[2]; peakTick = ticks; } | |
| 145 | } | |
| 146 | expect(peak).toBeCloseTo(apex, 6); | |
| 147 | expect(peakTick).toBe(Math.round(climb / TICK)); | |
| 148 | expect(ticks).toBe(Math.round((2 * climb) / TICK)); | |
| 149 | // Back on the floor it jumped from, not a hair above it. | |
| 150 | expect(body.pos[2]).toBe(0); | |
| 151 | // The shooter clears 57 units; this hop clears 40.5, and the difference is | |
| 152 | // the range choosing a heavier body on purpose. | |
| 153 | expect(apex).toBeLessThan(CS.jumpApex - 15); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a crouch lowers the eye by the range's own amount, at the range's own rate", () => { | |
| 157 | const body = createRangeBody(); | |
| 158 | const eye: Vec3 = [0, 0, 0]; | |
| 159 | expect(rangeEye(body, eye)[2]).toBe(RANGE_HULL.eyeStand); | |
| 160 | ||
| 161 | // `duckRate` 8 closes the crouch in an eighth of a second; a quarter of one | |
| 162 | // is more than enough, and the eye is then the hull's ducked eye exactly. | |
| 163 | walk(body, held({ duck: true }), 0.25); | |
| 164 | expect(body.duckAmount).toBe(1); | |
| 165 | expect(body.ducking).toBe(true); | |
| 166 | expect(rangeEye(body, eye)[2]).toBe(RANGE_HULL.eyeDuck); | |
| 167 | expect(RANGE_HULL.eyeStand - RANGE_HULL.eyeDuck).toBe(22); | |
| 168 | // The hull shrinks with it, which is what makes a crouched body a smaller one. | |
| 169 | expect(hullHeight(body, RANGE_HULL)).toBe(RANGE_HULL.duck); | |
| 170 | ||
| 171 | // Half way through the close, the eye is half way down: the engine's | |
| 172 | // `eyeHeight` interpolates, and the range does not round it to a stance. | |
| 173 | const half = createRangeBody(); | |
| 174 | walk(half, held({ duck: true }), 4 * TICK); // 8 duck/s × 0.08 s = 0.64 | |
| 175 | expect(half.duckAmount).toBeCloseTo(0.64, 10); | |
| 176 | expect(rangeEye(half, eye)[2]).toBeCloseTo(eyeHeight(half, RANGE_HULL), 10); | |
| 177 | expect(eye[2]).toBeGreaterThan(RANGE_HULL.eyeDuck); | |
| 178 | expect(eye[2]).toBeLessThan(RANGE_HULL.eyeStand); | |
| 179 | ||
| 180 | // Letting go stands back up, at the same rate and to the same height. | |
| 181 | walk(body, NO_INTENT, 0.25); | |
| 182 | expect(body.duckAmount).toBe(0); | |
| 183 | expect(rangeEye(body, eye)[2]).toBe(RANGE_HULL.eyeStand); | |
| 184 | }); | |
| 185 | ||
| 186 | test("the eye the pinned script fires from is where a standing body's eye is", () => { | |
| 187 | // `pin.ts` fires every one of its shots from `EYE`, and thirty seconds of | |
| 188 | // digest in `pin.test.ts` rest on that being where a standing person is. It | |
| 189 | // is one number in two files agreeing, so it is a test rather than a hope. | |
| 190 | const eye = rangeEye(createRangeBody(), [0, 0, 0]); | |
| 191 | expect(eye).toEqual([EYE[0], EYE[1], EYE[2]]); | |
| 192 | expect(EYE[2]).toBe(RANGE_HULL.eyeStand); | |
| 193 | }); | |
| 194 | ||
| 195 | // --- you cannot leave the room -------------------------------------------- | |
| 196 | ||
| 197 | test("a body that runs at a wall stops at it, on every wall of the room", () => { | |
| 198 | // The far wall, four seconds of running: 1,536 units of lane at 180 u/s is | |
| 199 | // nine seconds away, so this is a body that arrives and stops rather than one | |
| 200 | // that ran out of run. Its centre comes to rest its own radius short. | |
| 201 | const far = walk(createRangeBody(), held({ forward: 1 }), 20); | |
| 202 | expect(far.pos[0]).toBeCloseTo(LANE.far - RANGE_HULL.radius, 6); | |
| 203 | ||
| 204 | // The other three, each walked into for long enough to reach it. | |
| 205 | const back = walk(createRangeBody(), held({ forward: -1 }), 6); | |
| 206 | expect(back.pos[0]).toBeCloseTo(-LANE.back + RANGE_HULL.radius, 6); | |
| 207 | const north = walk(createRangeBody(), held({ side: -1 }), 12); | |
| 208 | expect(north.pos[1]).toBeCloseTo(LANE.half - RANGE_HULL.radius, 6); | |
| 209 | const south = walk(createRangeBody(), held({ side: 1 }), 12); | |
| 210 | expect(south.pos[1]).toBeCloseTo(-LANE.half + RANGE_HULL.radius, 6); | |
| 211 | ||
| 212 | // Every one of them is standing on the floor, inside the lane's four walls | |
| 213 | // by its own radius, and none of them is in the concrete. | |
| 214 | for (const body of [far, back, north, south]) { | |
| 215 | expect(body.onGround).toBe(true); | |
| 216 | expect(body.pos[2]).toBe(0); | |
| 217 | expect(inLane(body)).toBe(true); | |
| 218 | expect(stuck(body.pos, hullHeight(body, RANGE_HULL), RANGE_ROOM, RANGE_HULL)).toBe(false); | |
| 219 | } | |
| 220 | }); | |
| 221 | ||
| 222 | test("a body that walks off nothing falls at the range's gravity and lands", () => { | |
| 223 | // A crate in the lane, taller than the range's 12-unit step so the only way | |
| 224 | // off it is down. Walking off the edge is a fall, and the fall is this | |
| 225 | // game's: 48 units at 900 u/s² is t = √(2h/g) ≈ 0.327 s. | |
| 226 | const top = 48; | |
| 227 | const crate: Box = { min: [200, -64, 0], max: [360, 64, top], mat: 0 }; | |
| 228 | const world = [...RANGE_ROOM, crate]; | |
| 229 | const body = createRangeBody(); | |
| 230 | body.pos[0] = 280; | |
| 231 | body.pos[2] = top; | |
| 232 | ||
| 233 | // One tick of nothing: it is standing on the crate, not falling through it. | |
| 234 | walk(body, NO_INTENT, TICK, 0, world); | |
| 235 | expect(body.onGround).toBe(true); | |
| 236 | expect(body.pos[2]).toBe(top); | |
| 237 | ||
| 238 | // Walk east off the edge. The crate ends at x = 360 and the hull is 20 wide, | |
| 239 | // so the feet leave support at 380 and the body is in the air from there. | |
| 240 | let airborne = 0; | |
| 241 | const wish = createRangeWish(); | |
| 242 | rangeWish(held({ forward: 1 }), 0, wish); | |
| 243 | for (let i = 0; i < 60; i++) { | |
| 244 | move(body, wish, TICK, world, RANGE_MOVE, RANGE_HULL); | |
| 245 | if (!body.onGround) airborne += 1; | |
| 246 | } | |
| 247 | expect(body.pos[0]).toBeGreaterThan(crate.max[0]); | |
| 248 | // It fell, it landed on the floor, and it spent about √(2h/g) in the air — | |
| 249 | // two ticks of slack for the tick it left on and the tick it landed on. | |
| 250 | expect(airborne).toBeGreaterThan(0); | |
| 251 | expect(body.onGround).toBe(true); | |
| 252 | expect(body.pos[2]).toBe(0); | |
| 253 | expect(airborne * TICK).toBeCloseTo(Math.sqrt((2 * top) / RANGE_MOVE.gravity), 1); | |
| 254 | // Under the shooter's lighter gravity the same drop would take longer, which | |
| 255 | // is the whole reason the number above is the range's. | |
| 256 | expect(Math.sqrt((2 * top) / RANGE_MOVE.gravity)).toBeLessThan(Math.sqrt((2 * top) / CS.gravity)); | |
| 257 | }); | |
| 258 | ||
| 259 | test("`stuck` is false on every tick of a run at the walls, the floor and the ceiling", () => { | |
| 260 | // A scripted quarter of a minute that spends its whole time trying to leave: | |
| 261 | // into every wall in turn, jumping and crouching against them, and the body | |
| 262 | // is asked after every single tick whether it is inside the geometry. | |
| 263 | const body = createRangeBody(); | |
| 264 | const wish = createRangeWish(); | |
| 265 | const script: Intent[] = [ | |
| 266 | held({ forward: 1, jump: true }), | |
| 267 | held({ forward: 1, side: 1 }), | |
| 268 | held({ side: 1, duck: true }), | |
| 269 | held({ forward: -1, side: 1, jump: true }), | |
| 270 | held({ forward: -1 }), | |
| 271 | held({ forward: -1, side: -1, duck: true }), | |
| 272 | held({ side: -1, jump: true }), | |
| 273 | held({ forward: 1, side: -1 }), | |
| 274 | ]; | |
| 275 | for (let tick = 0; tick < 25 / TICK; tick++) { | |
| 276 | // A new leg of the script every second and a half. | |
| 277 | const intent = script[Math.floor(tick / 75) % script.length] as Intent; | |
| 278 | rangeWish(intent, (tick % 360) * 3, wish); | |
| 279 | move(body, wish, TICK, RANGE_ROOM, RANGE_MOVE, RANGE_HULL); | |
| 280 | expect(stuck(body.pos, hullHeight(body, RANGE_HULL), RANGE_ROOM, RANGE_HULL)).toBe(false); | |
| 281 | expect(inLane(body)).toBe(true); | |
| 282 | expect(body.pos[2]).toBeGreaterThanOrEqual(0); | |
| 283 | expect(body.pos[2] + hullHeight(body, RANGE_HULL)).toBeLessThanOrEqual(LANE.height); | |
| 284 | } | |
| 285 | }); | |
| 286 | ||
| 287 | // --- the same inputs, twice ------------------------------------------------ | |
| 288 | ||
| 289 | test("the same seeded inputs from the same start put the body in the same place twice", () => { | |
| 290 | // A seeded script of intents — not random at play time, drawn once from a | |
| 291 | // seed and replayed — so "twice" means the same three hundred ticks and not | |
| 292 | // two coincidences. The solver reads no clock and no global, so this holds. | |
| 293 | const seeded = (seed: number, ticks: number): Intent[] => { | |
| 294 | let state = seed >>> 0; | |
| 295 | const next = (): number => { | |
| 296 | state = (state * 1664525 + 1013904223) >>> 0; | |
| 297 | return state / 0x1_00_00_00_00; | |
| 298 | }; | |
| 299 | return Array.from({ length: ticks }, () => | |
| 300 | held({ | |
| 301 | forward: Math.round(next() * 2 - 1), | |
| 302 | side: Math.round(next() * 2 - 1), | |
| 303 | jump: next() < 0.1, | |
| 304 | duck: next() < 0.2, | |
| 305 | walk: next() < 0.3, | |
| 306 | })); | |
| 307 | }; | |
| 308 | ||
| 309 | const play = (script: Intent[]): Body => { | |
| 310 | const body = createRangeBody(); | |
| 311 | const wish = createRangeWish(); | |
| 312 | script.forEach((intent, tick) => { | |
| 313 | rangeWish(intent, tick * 7, wish); | |
| 314 | move(body, wish, TICK, RANGE_ROOM, RANGE_MOVE, RANGE_HULL); | |
| 315 | }); | |
| 316 | return body; | |
| 317 | }; | |
| 318 | ||
| 319 | const script = seeded(20260920, 300); | |
| 320 | const first = play(script); | |
| 321 | const second = play(script); | |
| 322 | expect(second.pos).toEqual(first.pos); | |
| 323 | expect(second.vel).toEqual(first.vel); | |
| 324 | expect(second.duckAmount).toBe(first.duckAmount); | |
| 325 | expect(second.onGround).toBe(first.onGround); | |
| 326 | ||
| 327 | // It went somewhere: a test that passes because nobody moved is no test. | |
| 328 | expect(travelled(first)).toBeGreaterThan(50); | |
| 329 | ||
| 330 | // And another script is another place, so the comparison above has teeth. | |
| 331 | expect(play(seeded(20260921, 300)).pos).not.toEqual(first.pos); | |
| 332 | }); | |
| 333 | ||
| 334 | // --- the wiring ------------------------------------------------------------ | |
| 335 | ||
| 336 | test("a wish is the intent, the view's yaw and this game's top speed", () => { | |
| 337 | const wish = createRangeWish(); | |
| 338 | expect(wish.maxSpeed).toBe(RANGE_MOVE.maxSpeed); | |
| 339 | rangeWish(held({ forward: 1, side: -1, jump: true, duck: true, walk: true, fire: true }), 42.5, wish); | |
| 340 | expect(wish).toEqual({ | |
| 341 | forward: 1, side: -1, yaw: 42.5, jump: true, duck: true, walk: true, | |
| 342 | maxSpeed: RANGE_MOVE.maxSpeed, | |
| 343 | }); | |
| 344 | // The trigger is the game's, not the solver's: nothing of it reaches a wish. | |
| 345 | expect(Object.keys(wish)).not.toContain("fire"); | |
| 346 | // And the same object is written over rather than replaced, every tick. | |
| 347 | expect(rangeWish(NO_INTENT, 0, wish)).toBe(wish); | |
| 348 | }); | |
| 349 | ||
| 350 | test("the eye is written into the vector it was given, and follows the body", () => { | |
| 351 | const eye: Vec3 = [0, 0, 0]; | |
| 352 | const body = createRangeBody(); | |
| 353 | body.pos[0] = 640; | |
| 354 | body.pos[1] = -128; | |
| 355 | body.pos[2] = 16; | |
| 356 | expect(rangeEye(body, eye)).toBe(eye); | |
| 357 | expect(eye).toEqual([640, -128, 16 + RANGE_HULL.eyeStand]); | |
| 358 | }); |
src/range/movement.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * How the range moves: the numbers the engine's solver is handed here, and the | |
| 3 | * body they are handed with. It is this game's answer to | |
| 4 | * `src/client/movement.ts` — the same shape, none of the same numbers — and it | |
| 5 | * was written without reading that file's values back in: every number below | |
| 6 | * has a sentence saying what the range wants from it. | |
| 7 | * | |
| 8 | * The solver is `src/engine/physics.ts` and holds not one of these. A game | |
| 9 | * brings a `MoveTuning` and a `Hull` to every `move` call and gets its own feel | |
| 10 | * out of the same arithmetic; that the shooter and the range now walk on one | |
| 11 | * solver and disagree about everything it can be told is the whole of what the | |
| 12 | * extraction claimed, and `movement.test.ts` is where the claim is checked. | |
| 13 | * | |
| 14 | * **The range is not Counter-Strike and does not want to be.** A firing lane is | |
| 15 | * a place you walk down to change what you can hit, not a place you bunny-hop | |
| 16 | * across: so this body is slower, heavier, stickier and a worse jumper than the | |
| 17 | * shooter's, it strafe-jumps nowhere, and it crouches lower and faster because | |
| 18 | * crouching here is an aiming stance rather than a way through a vent. | |
| 19 | * | |
| 20 | * Units are the range's, which are the engine's: Source units, one about an | |
| 21 | * inch, X east down the lane, Y north across it, Z up from the floor at z = 0. | |
| 22 | */ | |
| 23 | ||
| 24 | import { eyeHeight, type Body, type Hull, type MoveTuning, type Vec3, type Wish } from "../engine/index.ts"; | |
| 25 | import type { Intent } from "./binding.ts"; | |
| 26 | ||
| 27 | /** | |
| 28 | * The firing line: where a person's feet are when the page loads. The origin, | |
| 29 | * on the floor, because that is the point `targets.ts` measures a plate's | |
| 30 | * distance from and the point the pinned script shoots from — and a body that | |
| 31 | * booted anywhere else would be a game whose first frame is not the game that | |
| 32 | * was pinned. | |
| 33 | */ | |
| 34 | export const RANGE_START: Vec3 = [0, 0, 0]; | |
| 35 | ||
| 36 | /** | |
| 37 | * The body, in units. | |
| 38 | * | |
| 39 | * `radius` is 20, wider than the shooter's 16: this is a padded bay, and a | |
| 40 | * 40-unit column stops a clear foot short of the concrete instead of grazing it | |
| 41 | * — and it cannot squeeze between a plate and a wall, which would be a place to | |
| 42 | * stand that the lane was never drawn for. | |
| 43 | * | |
| 44 | * `stand` is 70 and `duck` 48. The shooter's hull is 72 and 54; this one is a | |
| 45 | * touch shorter standing and a lot shorter ducked, because a deep crouch is the | |
| 46 | * range's own stance and the whole point of having one is that it changes the | |
| 47 | * shot. | |
| 48 | * | |
| 49 | * `eyeStand` is 64 — the one number here that is also Counter-Strike's, and it | |
| 50 | * was chosen rather than inherited. It is the height `targets.ts` measures every | |
| 51 | * plate's distance from and the height the thirty seconds pinned in | |
| 52 | * `pin.test.ts` were shot from, so moving it would move a digest to say nothing | |
| 53 | * at all. A standing body at the firing line has its eye exactly at `EYE`, and | |
| 54 | * `movement.test.ts` holds it there. | |
| 55 | * | |
| 56 | * `eyeDuck` is 42: crouching drops the eye 22 units, more than the shooter's | |
| 57 | * 18, so a plate the crouch takes out of the shot is visibly taken out of it. | |
| 58 | */ | |
| 59 | export const RANGE_HULL: Hull = { radius: 20, stand: 70, duck: 48, eyeStand: 64, eyeDuck: 42 }; | |
| 60 | ||
| 61 | /** | |
| 62 | * The tuning, sentence by sentence. Every number is the range's, and every one | |
| 63 | * of them differs from the shooter's. | |
| 64 | * | |
| 65 | * - `gravity` 900 against the shooter's 800: a heavier body, so a jump is a | |
| 66 | * hop out of the aiming stance and not a way of travelling. | |
| 67 | * - `maxSpeed` 180 against 250: the lane is 1,792 units long and the game is | |
| 68 | * the plates, so you walk it in ten seconds rather than sprinting it in | |
| 69 | * seven. | |
| 70 | * - `duckSpeed` 60 and `walkSpeed` 90: a third and a half of the top speed, two | |
| 71 | * clean fractions, so the modifier keys are a stance and not a fine dial. | |
| 72 | * - `accelerate` 8 against 5.6: the range is small and a step has to land the | |
| 73 | * moment it is asked for; at this rate the body is at speed in an eighth of a | |
| 74 | * second. | |
| 75 | * - `airAccelerate` 4 and `airSpeedCap` 15 against 12 and 30: **there is no | |
| 76 | * strafe-jumping at this range.** The lineage's signature is a consequence of | |
| 77 | * these two numbers, and the range turns it down to a quarter on purpose — | |
| 78 | * the solver still has it, the shooter still does it, and a person here gains | |
| 79 | * almost nothing by turning in the air. | |
| 80 | * - `friction` 7 against 5.2: a stickier floor. You stop where you let go, | |
| 81 | * which is what a person lining up a shot after a sidestep actually wants. | |
| 82 | * - `stopSpeed` 60 against 80 — the speed below which friction stops easing off | |
| 83 | * — is set at exactly the crouch's pace, and that is arithmetic and not | |
| 84 | * taste: friction at `stopSpeed` has to be something `accelerate` at the | |
| 85 | * slowest wish speed can still beat, or a crouched body is dragged to a halt | |
| 86 | * by its own floor. 8 × 60 > 7 × 60 holds; 8 × 60 > 7 × 70 does not, which is | |
| 87 | * what the first draft of this file had and what `movement.test.ts` caught. | |
| 88 | * - `jumpVelocity` 270 with that gravity is an apex of exactly 40.5 units — | |
| 89 | * 270²/1800 — reached in 0.3 s and back down in 0.6, which is 15 and 30 ticks | |
| 90 | * at the range's 50 Hz. The shooter clears 57. Nothing in this room is 40 | |
| 91 | * units tall, so a jump here buys you a different sight line and no ground. | |
| 92 | * - `stepSize` 12 against 18: there is nothing in the lane to step onto, and a | |
| 93 | * low step is a body that cannot be walked up the face of the wall it is | |
| 94 | * pressed against. | |
| 95 | * - `duckRate` 8 against 5: the crouch closes in an eighth of a second, because | |
| 96 | * it is an aiming input, and an aiming input that takes a fifth of a second | |
| 97 | * is one you stop using. | |
| 98 | */ | |
| 99 | export const RANGE_MOVE: MoveTuning = { | |
| 100 | gravity: 900, | |
| 101 | maxSpeed: 180, | |
| 102 | duckSpeed: 60, | |
| 103 | walkSpeed: 90, | |
| 104 | accelerate: 8, | |
| 105 | airAccelerate: 4, | |
| 106 | airSpeedCap: 15, | |
| 107 | friction: 7, | |
| 108 | stopSpeed: 60, | |
| 109 | jumpVelocity: 270, | |
| 110 | stepSize: 12, | |
| 111 | duckRate: 8, | |
| 112 | // A person does not bounce off concrete; they stop against it and slide along | |
| 113 | // it. The solver gained this knob for the shooter's grenade, which keeps 0.45 | |
| 114 | // of what it arrives with — nothing at this range is thrown, so nothing here | |
| 115 | // bounces, and the number that says so is written down rather than defaulted. | |
| 116 | restitution: 0, | |
| 117 | }; | |
| 118 | ||
| 119 | /** A person at the firing line, standing still on the floor, facing the lane. */ | |
| 120 | export const createRangeBody = (): Body => ({ | |
| 121 | pos: [RANGE_START[0], RANGE_START[1], RANGE_START[2]], | |
| 122 | vel: [0, 0, 0], | |
| 123 | onGround: true, | |
| 124 | ducking: false, | |
| 125 | duckAmount: 0, | |
| 126 | }); | |
| 127 | ||
| 128 | /** | |
| 129 | * Where this body looks from, written into `into` and handed back: its feet, | |
| 130 | * plus the engine's `eyeHeight` of this hull at this body's crouch. Every eye in | |
| 131 | * this game comes through here — the camera, the bullet, the tracer and the | |
| 132 | * fallback horizon — so walking and crouching change what you can hit because | |
| 133 | * they change the one vector all four read. | |
| 134 | * | |
| 135 | * It writes rather than returns a fresh array because a frame allocates nothing: | |
| 136 | * `main.ts` keeps one of these and passes it round. | |
| 137 | */ | |
| 138 | export function rangeEye(body: Body, into: Vec3): Vec3 { | |
| 139 | into[0] = body.pos[0]; | |
| 140 | into[1] = body.pos[1]; | |
| 141 | into[2] = body.pos[2] + eyeHeight(body, RANGE_HULL); | |
| 142 | return into; | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * What this frame's intent asks the solver for, looking `yaw` degrees around: | |
| 147 | * the engine's `Wish`, written into `into` so a tick allocates nothing. | |
| 148 | * | |
| 149 | * The carry speed is the range's top speed and nothing else. The shooter's | |
| 150 | * `Wish.maxSpeed` is the weapon you are holding — a rifle slows you down — and | |
| 151 | * there is one weapon here that never changes, so the field is the tuning's own | |
| 152 | * number every tick. | |
| 153 | */ | |
| 154 | export function rangeWish(intent: Intent, yaw: number, into: Wish): Wish { | |
| 155 | into.forward = intent.forward; | |
| 156 | into.side = intent.side; | |
| 157 | into.yaw = yaw; | |
| 158 | into.jump = intent.jump; | |
| 159 | into.duck = intent.duck; | |
| 160 | into.walk = intent.walk; | |
| 161 | into.maxSpeed = RANGE_MOVE.maxSpeed; | |
| 162 | return into; | |
| 163 | } | |
| 164 | ||
| 165 | /** The one wish a page keeps, before any key is down. */ | |
| 166 | export const createRangeWish = (): Wish => ({ | |
| 167 | forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: RANGE_MOVE.maxSpeed, | |
| 168 | }); |
src/range/pin.ts +11
| @@ -33,6 +33,17 @@ | ||
| 33 | 33 | * every one of them: 1,500 positions pin a speed far more tightly than one |
| 34 | 34 | * number would, and a plate that started sliding the wrong way shows on tick 1. |
| 35 | 35 | * |
| 36 | * **The walker is deliberately outside the digest.** #63 gave this game a body | |
| 37 | * and the range's own tuning to walk it on, and neither digest moved: the | |
| 38 | * script below stands at the firing line and fires from `EYE`, which is exactly | |
| 39 | * where `movement.ts` puts a standing person's eye, so the pinned thirty | |
| 40 | * seconds are the thirty seconds they were. That is a choice and not an | |
| 41 | * oversight — what is hashed here is the run's rules playing out, and where a | |
| 42 | * person chose to stand is not one of them. The body's own determinism is | |
| 43 | * pinned next door in `movement.test.ts`, which replays a seeded script of | |
| 44 | * intents twice and compares where it lands. The day a drill scores you for | |
| 45 | * where you stood, the position joins `RUN_FIELDS` and both digests move. | |
| 46 | * | |
| 36 | 47 | * What is hashed is the run's rules playing out — the phase, the clock, the |
| 37 | 48 | * score, the hits, the shots and every standing plate's place, size, lifetime |
| 38 | 49 | * and worth. Not a plate's id and not the order of the lane: those are how |
src/range/readout.test.ts +23 −7
| @@ -8,6 +8,7 @@ import { | ||
| 8 | 8 | blips, drawHorizon, drawReadout, readoutLines, wrapDegrees, |
| 9 | 9 | type Paint, type Readout, type Sight, |
| 10 | 10 | } from "./readout.ts"; |
| 11 | import { RANGE_HULL } from "./movement.ts"; | |
| 11 | 12 | import { EYE, createRun, report, start, step, type Target } from "./targets.ts"; |
| 12 | 13 | |
| 13 | 14 | type Call = [string, ...unknown[]]; |
| @@ -43,7 +44,9 @@ const RUN = { | ||
| 43 | 44 | const READOUT: Readout = { |
| 44 | 45 | run: RUN, best: 1200, next: "tracking", |
| 45 | 46 | hz: 50, ticks: 1234, frameMs: 16.66, held: ["forward", "fire"], |
| 46 | move: { forward: 1, side: -0.5 }, yaw: 45, pitch: -10, locked: true, drawn: true, | |
| 47 | move: { forward: 1, side: -0.5 }, | |
| 48 | stance: { lane: 128.4, across: -32.7, eye: 64, speed: 176.5 }, | |
| 49 | yaw: 45, pitch: -10, locked: true, drawn: true, | |
| 47 | 50 | }; |
| 48 | 51 | |
| 49 | 52 | test("the panel leads with the score and the clock, then the loop and the controls", () => { |
| @@ -54,11 +57,24 @@ test("the panel leads with the score and the clock, then the loop and the contro | ||
| 54 | 57 | expect(lines[3]).toBe("frame 16.7 ms · 60 fps"); |
| 55 | 58 | expect(lines[4]).toBe("held forward fire"); |
| 56 | 59 | expect(lines[5]).toBe("move 1.00 forward · -0.50 side"); |
| 57 | expect(lines[6]).toBe("yaw 45.0° · pitch -10.0°"); | |
| 58 | expect(lines[7]).toContain("Esc"); | |
| 60 | // Where the solver actually put the body, which is the line that says the | |
| 61 | // range walks: a crouch drops the eye and standing still reads 0 u/s. | |
| 62 | expect(lines[6]).toBe("lane 128 · across -33 · eye 64 · 177 u/s"); | |
| 63 | expect(lines[7]).toBe("yaw 45.0° · pitch -10.0°"); | |
| 64 | expect(lines[8]).toContain("Esc"); | |
| 59 | 65 | // And which way the scene itself was drawn, whichever way that was. |
| 60 | expect(lines[8]).toBe("drawn by the engine's renderer"); | |
| 61 | expect(readoutLines({ ...READOUT, drawn: false })[8]).toBe("no WebGL2 here · the 2D horizon instead"); | |
| 66 | expect(lines[9]).toBe("drawn by the engine's renderer"); | |
| 67 | expect(readoutLines({ ...READOUT, drawn: false })[9]).toBe("no WebGL2 here · the 2D horizon instead"); | |
| 68 | }); | |
| 69 | ||
| 70 | test("the stance line is the body's, and a crouch is visible in it", () => { | |
| 71 | const standing = { lane: 0, across: 0, eye: RANGE_HULL.eyeStand, speed: 0 }; | |
| 72 | expect(readoutLines({ ...READOUT, stance: standing })[6]).toBe( | |
| 73 | `lane 0 · across 0 · eye ${RANGE_HULL.eyeStand} · 0 u/s`, | |
| 74 | ); | |
| 75 | expect(readoutLines({ ...READOUT, stance: { ...standing, eye: RANGE_HULL.eyeDuck } })[6]).toBe( | |
| 76 | `lane 0 · across 0 · eye ${RANGE_HULL.eyeDuck} · 0 u/s`, | |
| 77 | ); | |
| 62 | 78 | }); |
| 63 | 79 | |
| 64 | 80 | test("the clock line says how to start a run, how long is left, and how it went", () => { |
| @@ -74,14 +90,14 @@ test("the clock line says how to start a run, how long is left, and how it went" | ||
| 74 | 90 | test("nothing held says so, and an unlocked pointer says how to lock it", () => { |
| 75 | 91 | const lines = readoutLines({ ...READOUT, held: [], locked: false }); |
| 76 | 92 | expect(lines[4]).toBe("held nothing"); |
| 77 | expect(lines[7]).toContain("click the view"); | |
| 93 | expect(lines[8]).toContain("click the view"); | |
| 78 | 94 | }); |
| 79 | 95 | |
| 80 | 96 | test("the yaw reads as a compass bearing however far it has been turned", () => { |
| 81 | 97 | expect(wrapDegrees(0)).toBe(0); |
| 82 | 98 | expect(wrapDegrees(-90)).toBe(270); |
| 83 | 99 | expect(wrapDegrees(725)).toBe(5); |
| 84 | expect(readoutLines({ ...READOUT, yaw: -0.5 })[6]).toBe("yaw 359.5° · pitch -10.0°"); | |
| 100 | expect(readoutLines({ ...READOUT, yaw: -0.5 })[7]).toBe("yaw 359.5° · pitch -10.0°"); | |
| 85 | 101 | }); |
| 86 | 102 | |
| 87 | 103 | test("a frame time of zero is not an infinite frame rate", () => { |
src/range/readout.ts +13
| @@ -26,6 +26,15 @@ export type { Paint }; | ||
| 26 | 26 | /** Where the view points. Degrees, the engine's way round: pitch is positive down. */ |
| 27 | 27 | export type View = { yaw: number; pitch: number }; |
| 28 | 28 | |
| 29 | /** | |
| 30 | * Where a person is standing, for the line that says walking is real: how far | |
| 31 | * down the lane, how far across it, how high the eye rides above the feet — so | |
| 32 | * a crouch is a number you can watch close — and how fast the body is moving | |
| 33 | * across the floor. All in the range's units, already worked out by `main.ts` | |
| 34 | * from the body the solver walked. | |
| 35 | */ | |
| 36 | export type Stance = { lane: number; across: number; eye: number; speed: number }; | |
| 37 | ||
| 29 | 38 | /** Everything the panel prints, gathered by `main.ts` once a frame. */ |
| 30 | 39 | export type Readout = View & { |
| 31 | 40 | /** The run: which drill it is, the score, the clock and what they are made of. */ |
| @@ -48,6 +57,8 @@ export type Readout = View & { | ||
| 48 | 57 | held: readonly string[]; |
| 49 | 58 | /** What those actions and the thumb stick ask for: -1..1 on each axis. */ |
| 50 | 59 | move: { forward: number; side: number }; |
| 60 | /** Where the solver has actually put the body, and how fast it is going. */ | |
| 61 | stance: Stance; | |
| 51 | 62 | /** Is the pointer locked to the stage? */ |
| 52 | 63 | locked: boolean; |
| 53 | 64 | /** Is the room being drawn, or is this browser on the 2D horizon instead? */ |
| @@ -98,6 +109,8 @@ export function readoutLines(r: Readout): string[] { | ||
| 98 | 109 | `frame ${r.frameMs.toFixed(1)} ms · ${(1000 / Math.max(0.1, r.frameMs)).toFixed(0)} fps`, |
| 99 | 110 | `held ${r.held.length === 0 ? "nothing" : r.held.join(" ")}`, |
| 100 | 111 | `move ${r.move.forward.toFixed(2)} forward · ${r.move.side.toFixed(2)} side`, |
| 112 | `lane ${r.stance.lane.toFixed(0)} · across ${r.stance.across.toFixed(0)} · eye ${ | |
| 113 | r.stance.eye.toFixed(0)} · ${r.stance.speed.toFixed(0)} u/s`, | |
| 101 | 114 | `yaw ${wrapDegrees(r.yaw).toFixed(1)}° · pitch ${clamp(r.pitch, -89, 89).toFixed(1)}°`, |
| 102 | 115 | r.locked ? "mouse captured · Esc releases it" : "click the view, or drag it on a touch screen", |
| 103 | 116 | r.drawn ? "drawn by the engine's renderer" : "no WebGL2 here · the 2D horizon instead", |
src/range/scene.test.ts +14 −2
| @@ -42,7 +42,8 @@ function standing(targets: Target[]): Run { | ||
| 42 | 42 | return run; |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | const VIEW = { yaw: 0, pitch: 0 }; | |
| 45 | /** Standing at the firing line, looking down the lane: the eye a body has there. */ | |
| 46 | const VIEW = { eye: EYE, yaw: 0, pitch: 0 }; | |
| 46 | 47 | |
| 47 | 48 | /** The live instances of a scene: the pool past `instanceCount` is last frame's. */ |
| 48 | 49 | const live = (scene: ReturnType<typeof createRangeScene>) => |
| @@ -140,11 +141,22 @@ test("the tracer fades over the 80 ms rather than blinking out", () => { | ||
| 140 | 141 | }); |
| 141 | 142 | |
| 142 | 143 | test("the view is the camera's, and nothing else moves the eye", () => { |
| 143 | const scene = buildRangeScene(standing([]), { yaw: -37.5, pitch: 12.25 }, null, 0, createRangeScene()); | |
| 144 | const scene = buildRangeScene(standing([]), { eye: EYE, yaw: -37.5, pitch: 12.25 }, null, 0, createRangeScene()); | |
| 144 | 145 | expect([scene.camera.yaw, scene.camera.pitch]).toEqual([-37.5, 12.25]); |
| 145 | 146 | expect(scene.camera.fov).toBeGreaterThan(0); |
| 146 | 147 | }); |
| 147 | 148 | |
| 149 | test("the camera stands where the eye does: a person who walked sees from there", () => { | |
| 150 | // The eye is the body's, worked out by `movement.ts` and handed in whole. | |
| 151 | // This file decides what is in a frame and never where anybody is standing. | |
| 152 | const walked: [number, number, number] = [420, -137, 42]; | |
| 153 | const scene = buildRangeScene(standing([]), { eye: walked, yaw: 0, pitch: 0 }, null, 0, createRangeScene()); | |
| 154 | expect(scene.camera.pos).toEqual(walked); | |
| 155 | // And the next frame, from back at the firing line, is back at the firing line. | |
| 156 | buildRangeScene(standing([]), { eye: EYE, yaw: 0, pitch: 0 }, null, 0, scene); | |
| 157 | expect(scene.camera.pos).toEqual([EYE[0], EYE[1], EYE[2]]); | |
| 158 | }); | |
| 159 | ||
| 148 | 160 | test("every mesh a scene names is one the renderer was handed", () => { |
| 149 | 161 | const run = standing([plate(1, [400, 0, 60]), plate(2, [900, 100, 90])]); |
| 150 | 162 | const scene = buildRangeScene(run, VIEW, { from: EYE, to: [900, 0, 60], at: 0 }, 0, createRangeScene()); |
src/range/scene.ts +12 −6
| @@ -74,8 +74,12 @@ export const PLATE_HALF = STANDARD.half; | ||
| 74 | 74 | */ |
| 75 | 75 | export type RangeScene = Scene & { sky: Tint }; |
| 76 | 76 | |
| 77 | /** Where the view points. Degrees, the engine's way round: pitch is positive down. */ | |
| 78 | export type View = { yaw: number; pitch: number }; | |
| 77 | /** | |
| 78 | * Where the view is and where it points: the eye the body's crouch and walk put | |
| 79 | * there, and degrees the engine's way round — pitch is positive down. The eye is | |
| 80 | * a vector this game keeps and writes over, never one a frame allocates. | |
| 81 | */ | |
| 82 | export type View = { eye: Vec3; yaw: number; pitch: number }; | |
| 79 | 83 | |
| 80 | 84 | /** |
| 81 | 85 | * The last shot, for as long as it is worth drawing: from the eye to wherever |
| @@ -158,7 +162,9 @@ function place(scene: RangeScene, mesh: string, pos: Vec3, size: number, tint: T | ||
| 158 | 162 | |
| 159 | 163 | /** |
| 160 | 164 | * Fill `into` with what this run looks like from this view, and hand the same |
| 161 | * object back. The order is the order it is drawn, and the room is always first | |
| 165 | * object back. The camera is the view's eye, wherever the body has carried it: | |
| 166 | * this file decides what is in a frame and never where a person is standing. | |
| 167 | * The order is the order it is drawn, and the room is always first | |
| 162 | 168 | * — the shooter puts its level first for the same reason, so a game reading |
| 163 | 169 | * either scene finds the world before the things standing in it. |
| 164 | 170 | * |
| @@ -172,9 +178,9 @@ export function buildRangeScene( | ||
| 172 | 178 | now: number, |
| 173 | 179 | into: RangeScene, |
| 174 | 180 | ): RangeScene { |
| 175 | into.camera.pos[0] = EYE[0]; | |
| 176 | into.camera.pos[1] = EYE[1]; | |
| 177 | into.camera.pos[2] = EYE[2]; | |
| 181 | into.camera.pos[0] = view.eye[0]; | |
| 182 | into.camera.pos[1] = view.eye[1]; | |
| 183 | into.camera.pos[2] = view.eye[2]; | |
| 178 | 184 | into.camera.yaw = view.yaw; |
| 179 | 185 | into.camera.pitch = view.pitch; |
| 180 | 186 | into.camera.fov = RANGE_FOV; |
src/range/targets.ts +18 −5
| @@ -8,9 +8,11 @@ | ||
| 8 | 8 | * game plays through in a test in a millisecond. |
| 9 | 9 | * |
| 10 | 10 | * Units are the engine's: Source units, one unit about an inch, X east, Y north |
| 11 | * and Z up. The shooter stands at the origin looking down +X (yaw 0) with an | |
| 12 | * eye at `EYE`, and the targets stand down the lane in front of that. Seconds | |
| 13 | * everywhere for time; degrees are the view's business, not this module's. | |
| 11 | * and Z up. A run starts at the firing line — the origin, looking down +X (yaw | |
| 12 | * 0), with an eye at `EYE` — and the targets stand down the lane in front of | |
| 13 | * that. Where a person has walked to since is `movement.ts`'s and arrives here | |
| 14 | * as the eye `shoot` is given. Seconds everywhere for time; degrees are the | |
| 15 | * view's business, not this module's. | |
| 14 | 16 | * |
| 15 | 17 | * Everything random comes from the engine's `rng`, drawn in one fixed order, so |
| 16 | 18 | * a seed reproduces a run exactly: the same seed pops the same plates up at the |
| @@ -35,9 +37,20 @@ import { | ||
| 35 | 37 | add, lerp, length, normalize, scale, segmentLength, traceWorld, |
| 36 | 38 | rng, type Box, type Vec3, |
| 37 | 39 | } from "../engine/index.ts"; |
| 40 | import { RANGE_HULL, RANGE_START } from "./movement.ts"; | |
| 38 | 41 | |
| 39 | /** The shooter's eye: standing height, at the origin. The range's own tuning. */ | |
| 40 | export const EYE: Vec3 = [0, 0, 64]; | |
| 42 | /** | |
| 43 | * The firing line's eye: where a standing body's eye is before anyone has | |
| 44 | * walked anywhere, which is the point every plate's `distance` is measured from | |
| 45 | * and the point `pin.ts`'s script shoots from. | |
| 46 | * | |
| 47 | * It is asked of `movement.ts` rather than written down again, so there is one | |
| 48 | * place that says how tall a person is here. A *live* shot does not come from | |
| 49 | * it — `shoot` takes the eye it is fired from, and `main.ts` hands in the one | |
| 50 | * the body actually has — and that is the whole of the difference walking makes | |
| 51 | * to this file. | |
| 52 | */ | |
| 53 | export const EYE: Vec3 = [RANGE_START[0], RANGE_START[1], RANGE_START[2] + RANGE_HULL.eyeStand]; | |
| 41 | 54 | |
| 42 | 55 | /** |
| 43 | 56 | * What a target is made of, as far as a renderer will ever care. The engine's |
static/range.27ebabc9ad.js added +58
Diff not shown — a line 18,622 characters wide (limit 2,000). +58
View filestatic/range.fb4863848d.js deleted −58
Diff not shown — a line 17,570 characters wide (limit 2,000). −58
View file
new above
offline