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

Merged opened this

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

Merged

vesper/cs-engine:vesper/63-the-range-walks-its-own-movement-tuning → main 2 commits fork

  • Merged. vesper/63-the-range-walks-its-own-movement-tuning was merged into main as 13eb730.

Review

Sign in to review this change.
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
139139 tracer are drawn by the engine's renderer and the range makes its own noise
140140 through the engine's sound device — a dry crack, a tone for a hit, a knock as a
141141 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
144161 on the 2D horizon it shipped with — a plate as a square drawn from the angle it
145162 subtends — and the panel says which of the two you are looking at.
146163
@@ -386,7 +403,11 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
386403 lifetimes and the run's clock, retiring what has expired or left the lane, and
387404 `shoot` traces the engine's `rayBox` along the view and folds the hit, the
388405 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
390411 projected from the angle each subtends, and a panel with the score, the clock
391412 and what the loop and the controls are doing — the panel, its lines and the
392413 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
3838 test("the entry table is what the app ships today, each with the size it may not pass", () => {
3939 expect(ENTRIES).toEqual([
4040 { 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 } },
4242 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } },
4343 ]);
4444 // 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",
104104 const scene = createRangeScene();
105105 const busy = standing(3);
106106 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);
108108 const pool = scene.instances;
109109 const objects = [...pool];
110110 const lines = scene.lines;
111111
112112 for (let frame = 0; frame < FRAMES; frame++) {
113113 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);
115115 }
116116
117117 expect(scene.instances).toBe(pool);
@@ -144,7 +144,7 @@ test("a frame that wants more than its pool holds is refused, in both games", ()
144144 // that fit — the room and fifteen plates — rather than growing for the rest.
145145 const range = createRangeScene();
146146 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);
148148 expect(range.instances).toBe(rangePool);
149149 expect(range.instances.length).toBe(RANGE_INSTANCES);
150150 expect(range.instanceCount).toBe(RANGE_INSTANCES);
src/games.ts +15 −1
@@ -211,6 +211,20 @@ export type Game = {
211211 * handed out over a module graph that changed shape — it ships no grenade, no
212212 * world and no weapons at all. Measured 2026-09-20 after #142 was merged with
213213 * #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.
214228 */
215229 export const GAMES: readonly Game[] = [
216230 {
@@ -227,7 +241,7 @@ export const GAMES: readonly Game[] = [
227241 href: "/range",
228242 label: "range",
229243 entry: "src/range/main.ts",
230 ceiling: { raw: 33_000, gzip: 14_000 },
244 ceiling: { raw: 37_000, gzip: 15_500 },
231245 blurb:
232246 "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.",
233247 },
src/pages/range.ts +18 −5
@@ -23,8 +23,10 @@ const NOTE = `
2323 your accuracy. There are two drills and <kbd>T</kbd> switches between them before a run —
2424 <strong>pop-up</strong>, where plates stand up down the lane and drop when their time is
2525 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
2830 flat horizon it shipped with, plates as squares projected from the angle each subtends,
2931 and the same two drills play on it.</p>
3032 <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 {
101103 the shot leaves a tracer for eighty milliseconds, and the atlas they are painted from is
102104 three 64-pixel layers of this game's own — not a line of the shooter's map, models or
103105 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
107107 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>
108121 <p>You can hear it too, and that is the last part of the engine this page was not using: its
109122 sound device, with five recipes of the range's own in <code>src/range/audio.ts</code> — the
110123 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", ()
637637 undo();
638638 }
639639 });
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 @@
1717 import { afterAll, expect, test } from "bun:test";
1818 import { readFileSync } from "node:fs";
1919 import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
20 import { RANGE_MOVE } from "./movement.ts";
2021 import { page, type RangePage } from "./stage.test-helper.ts";
2122
2223 afterAll(discardBundleCopies);
@@ -126,16 +127,26 @@ test("the range bundles none of the engine's policy network", () => {
126127 for (const name of ["Math.tanh", "atob", "btoa"]) expect(code).not.toContain(name);
127128 });
128129
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.
136138 const code = readFileSync(bundlePath("range"), "utf8");
137139 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");
139150 });
140151
141152 test("the stub page leaves nothing of itself behind", () => {
src/range/main.ts +48 −10
@@ -20,11 +20,17 @@
2020 * seconds, and the phase turned `over`. None of them is a comparison that
2121 * would be true for sixty frames in a row.
2222 *
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 *
2330 * A browser with no WebGL2 keeps the game rather than losing it: `createRenderer`
2431 * 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.
2834 *
2935 * Nothing outside `src/engine/` and this directory is imported, and
3036 * `src/independence.test.ts` reads these files to make sure it stays that way.
@@ -32,6 +38,10 @@
3238
3339 import { createRangeAudio } from "./audio.ts";
3440 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";
3545 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
3646 import { buildRangeScene, createRangeScene, rangeAssets, type Trail } from "./scene.ts";
3747 import {
@@ -40,6 +50,7 @@ import {
4050 } from "./targets.ts";
4151 import {
4252 angleVectors, attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer,
53 move,
4354 } from "../engine/index.ts";
4455
4556 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
@@ -112,9 +123,17 @@ export function boot(): void {
112123 now: () => performance.now(),
113124 });
114125
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
115134 // One view object for the whole run: the scene reads it, the readout prints
116135 // 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 };
118137 let ticks = 0;
119138 let frameMs = 16.7;
120139 // Seconds since the page booted. The run's clock counts down, so a tracer
@@ -200,10 +219,10 @@ export function boot(): void {
200219 const steps = loop.advance(elapsed);
201220 ticks += steps;
202221
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.
206224 const intent = readIntent(controls.held, controls.stick);
225 rangeWish(intent, view.yaw, wish);
207226
208227 // The drill is chosen between runs: `setDrill` refuses a live one, so a T
209228 // pressed mid-run changes nothing at all rather than half a game.
@@ -230,7 +249,15 @@ export function boot(): void {
230249 const wasLive = run.phase === "live";
231250 const clockBefore = run.clock;
232251 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);
234261 if (wasLive) {
235262 // A plate that was not there before this frame stood up in it.
236263 for (let i = 0; i < run.targets.length; i++) {
@@ -252,12 +279,17 @@ export function boot(): void {
252279 // nothing else goes into it. Where the bullet stopped is the tracer's far
253280 // end, which is the only thing the drawing takes from a shot.
254281 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);
256283 if (shot !== null) {
257284 // A shot the run refused is null, so a trigger pulled before Enter or
258285 // after the clock ran out is silent as well as harmless.
259286 audio.shot();
260287 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];
261293 trail.to[0] = shot.point[0];
262294 trail.to[1] = shot.point[1];
263295 trail.to[2] = shot.point[2];
@@ -270,7 +302,7 @@ export function boot(): void {
270302 renderer.draw(buildRangeScene(run, view, trail, time, frameScene), elapsed);
271303 } else {
272304 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 });
274306 }
275307 const [hw, hh] = size(hud);
276308 drawReadout(
@@ -284,6 +316,12 @@ export function boot(): void {
284316 frameMs,
285317 held: heldActions(controls.held),
286318 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 },
287325 yaw: view.yaw,
288326 pitch: view.pitch,
289327 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 @@
3333 * every one of them: 1,500 positions pin a speed far more tightly than one
3434 * number would, and a plate that started sliding the wrong way shows on tick 1.
3535 *
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 *
3647 * What is hashed is the run's rules playing out — the phase, the clock, the
3748 * score, the hits, the shots and every standing plate's place, size, lifetime
3849 * 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 {
88 blips, drawHorizon, drawReadout, readoutLines, wrapDegrees,
99 type Paint, type Readout, type Sight,
1010 } from "./readout.ts";
11 import { RANGE_HULL } from "./movement.ts";
1112 import { EYE, createRun, report, start, step, type Target } from "./targets.ts";
1213
1314 type Call = [string, ...unknown[]];
@@ -43,7 +44,9 @@ const RUN = {
4344 const READOUT: Readout = {
4445 run: RUN, best: 1200, next: "tracking",
4546 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,
4750 };
4851
4952 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
5457 expect(lines[3]).toBe("frame 16.7 ms · 60 fps");
5558 expect(lines[4]).toBe("held forward fire");
5659 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");
5965 // 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 );
6278 });
6379
6480 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"
7490 test("nothing held says so, and an unlocked pointer says how to lock it", () => {
7591 const lines = readoutLines({ ...READOUT, held: [], locked: false });
7692 expect(lines[4]).toBe("held nothing");
77 expect(lines[7]).toContain("click the view");
93 expect(lines[8]).toContain("click the view");
7894 });
7995
8096 test("the yaw reads as a compass bearing however far it has been turned", () => {
8197 expect(wrapDegrees(0)).toBe(0);
8298 expect(wrapDegrees(-90)).toBe(270);
8399 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°");
85101 });
86102
87103 test("a frame time of zero is not an infinite frame rate", () => {
src/range/readout.ts +13
@@ -26,6 +26,15 @@ export type { Paint };
2626 /** Where the view points. Degrees, the engine's way round: pitch is positive down. */
2727 export type View = { yaw: number; pitch: number };
2828
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
2938 /** Everything the panel prints, gathered by `main.ts` once a frame. */
3039 export type Readout = View & {
3140 /** The run: which drill it is, the score, the clock and what they are made of. */
@@ -48,6 +57,8 @@ export type Readout = View & {
4857 held: readonly string[];
4958 /** What those actions and the thumb stick ask for: -1..1 on each axis. */
5059 move: { forward: number; side: number };
60 /** Where the solver has actually put the body, and how fast it is going. */
61 stance: Stance;
5162 /** Is the pointer locked to the stage? */
5263 locked: boolean;
5364 /** Is the room being drawn, or is this browser on the 2D horizon instead? */
@@ -98,6 +109,8 @@ export function readoutLines(r: Readout): string[] {
98109 `frame ${r.frameMs.toFixed(1)} ms · ${(1000 / Math.max(0.1, r.frameMs)).toFixed(0)} fps`,
99110 `held ${r.held.length === 0 ? "nothing" : r.held.join(" ")}`,
100111 `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`,
101114 `yaw ${wrapDegrees(r.yaw).toFixed(1)}° · pitch ${clamp(r.pitch, -89, 89).toFixed(1)}°`,
102115 r.locked ? "mouse captured · Esc releases it" : "click the view, or drag it on a touch screen",
103116 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 {
4242 return run;
4343 }
4444
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 };
4647
4748 /** The live instances of a scene: the pool past `instanceCount` is last frame's. */
4849 const live = (scene: ReturnType<typeof createRangeScene>) =>
@@ -140,11 +141,22 @@ test("the tracer fades over the 80 ms rather than blinking out", () => {
140141 });
141142
142143 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());
144145 expect([scene.camera.yaw, scene.camera.pitch]).toEqual([-37.5, 12.25]);
145146 expect(scene.camera.fov).toBeGreaterThan(0);
146147 });
147148
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
148160 test("every mesh a scene names is one the renderer was handed", () => {
149161 const run = standing([plate(1, [400, 0, 60]), plate(2, [900, 100, 90])]);
150162 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;
7474 */
7575 export type RangeScene = Scene & { sky: Tint };
7676
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 };
7983
8084 /**
8185 * 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
158162
159163 /**
160164 * 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
162168 * — the shooter puts its level first for the same reason, so a game reading
163169 * either scene finds the world before the things standing in it.
164170 *
@@ -172,9 +178,9 @@ export function buildRangeScene(
172178 now: number,
173179 into: RangeScene,
174180 ): 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];
178184 into.camera.yaw = view.yaw;
179185 into.camera.pitch = view.pitch;
180186 into.camera.fov = RANGE_FOV;
src/range/targets.ts +18 −5
@@ -8,9 +8,11 @@
88 * game plays through in a test in a millisecond.
99 *
1010 * 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.
1416 *
1517 * Everything random comes from the engine's `rng`, drawn in one fixed order, so
1618 * a seed reproduces a run exactly: the same seed pops the same plates up at the
@@ -35,9 +37,20 @@ import {
3537 add, lerp, length, normalize, scale, segmentLength, traceWorld,
3638 rng, type Box, type Vec3,
3739 } from "../engine/index.ts";
40 import { RANGE_HULL, RANGE_START } from "./movement.ts";
3841
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];
4154
4255 /**
4356 * 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 file
static/range.fb4863848d.js deleted −58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs