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

forked from trevato/cs · proposes

The frame becomes engine: a fixed-step loop and a bindings-driven controller #14

Merged opened this

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

Merged

vesper/cs-engine:vesper/10-the-frame-becomes-engine-a-fixed-step-lo → main 2 commits fork

  • Merged. vesper/10-the-frame-becomes-engine-a-fixed-step-lo was merged into main as 7cafc22.

Review

Sign in to review this change.
13 files changed +1006 −285
README.md +12 −8
@@ -58,19 +58,23 @@ the rest.
5858 (rays against boxes, and the `Box` they are traced against), `grid` (a grid of
5959 cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no
6060 corner-cutting — which cells are solid or walkable arrive as predicates, so it
61 knows nothing about de_forge's alphabet), `mesh` (a box
62 builder), `texture` (hashed value noise, fbm and an atlas painter) and `sound`
63 (a lazy `AudioContext` with noise bursts and tones). `boundary.test.ts`
64 reads the imports of every file in here and fails if one of them points at
65 `src/client/`, at `src/rl/` or at a package.
61 knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture`
62 (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext`
63 with noise bursts and tones), `loop` (a fixed step with a clamp on a long
64 frame) and `controls` (a bindings table in, held actions out).
65 `boundary.test.ts` reads the imports of every file in here and fails if one of
66 them points at `src/client/`, at `src/rl/` or at a package. The last two take
67 their clock, their canvas and their window as parameters, so the tests drive
68 them with a stub instead of a browser.
6669 - `src/client/` — the game, in modules named for one thing each: `map` (de_forge
6770 itself: its areas, its crates, its cell alphabet and its spawns and sites, laid
6871 over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules),
6972 `world` (the simulation),
7073 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`,
71 `renderer`, `hud`, `input`, `audio`, `main`. The last five aside, everything is
72 headless and tested; `models`, `materials` and `audio` are this game's recipes
73 for the engine's three asset tools.
74 `binding`, `renderer`, `hud`, `audio`, `main`. The last four aside, everything
75 is headless and tested; `models`, `materials` and `audio` are this game's
76 recipes for the engine's three asset tools, and `binding` is its key table and
77 the one function from a held action to an `Input`.
7478 - `src/rl/` — training, which the game never imports: `arena` is the environment
7579 (a real duel on de_forge with the round and the economy taken out) and `ppo` is
7680 the algorithm, with its backward pass checked against finite differences.
src/client/binding.test.ts added +81
@@ -0,0 +1,81 @@
1 import { expect, test } from "bun:test";
2 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
3
4 const NONE = { x: 0, y: 0 };
5 const intent = (actions: string[], stick = NONE) => readIntent(new Set(actions), stick);
6
7 test("the bindings are the ones the keyboard has always had", () => {
8 expect(CS_KEYS.KeyW).toBe("forward");
9 expect(CS_KEYS.KeyS).toBe("back");
10 expect(CS_KEYS.KeyA).toBe("left");
11 expect(CS_KEYS.KeyD).toBe("right");
12 expect(CS_KEYS.ArrowUp).toBe("forward");
13 expect(CS_KEYS.Space).toBe("jump");
14 expect(CS_KEYS.ControlLeft).toBe("duck");
15 expect(CS_KEYS.KeyC).toBe("duck");
16 expect(CS_KEYS.ShiftLeft).toBe("walk");
17 expect(CS_KEYS.KeyR).toBe("reload");
18 expect(CS_KEYS.KeyE).toBe("use");
19 expect(CS_KEYS.KeyB).toBe("buy");
20 expect(CS_KEYS.Tab).toBe("scoreboard");
21 expect(CS_KEYS.Digit1).toBe("slot1");
22 expect(CS_KEYS.Digit3).toBe("slot3");
23 expect(CS_MOUSE[0]).toBe("fire");
24 expect(CS_MOUSE[2]).toBe("scope");
25 expect([...CS_TAP]).toEqual(["jump", "reload", "buy"]);
26 });
27
28 test("nothing held is nothing asked for", () => {
29 const { input, scoreboard, slotWanted } = intent([]);
30 expect(input.forward).toBe(0);
31 expect(input.side).toBe(0);
32 expect(input.jump).toBe(false);
33 expect(input.fire).toBe(false);
34 expect(input.switchTo).toBeNull();
35 expect(scoreboard).toBe(false);
36 expect(slotWanted).toBeNull();
37 });
38
39 test("the movement keys sum and opposites cancel", () => {
40 expect(intent(["forward"]).input.forward).toBe(1);
41 expect(intent(["back"]).input.forward).toBe(-1);
42 expect(intent(["forward", "back"]).input.forward).toBe(0);
43 expect(intent(["right"]).input.side).toBe(1);
44 expect(intent(["left"]).input.side).toBe(-1);
45 expect(intent(["left", "right"]).input.side).toBe(0);
46 });
47
48 test("the stick adds to the keys and the sum clamps to ±1", () => {
49 expect(intent([], { x: -0.5, y: 0.25 }).input.side).toBeCloseTo(-0.5, 10);
50 expect(intent([], { x: -0.5, y: 0.25 }).input.forward).toBeCloseTo(0.25, 10);
51 expect(intent(["forward"], { x: 0, y: 0.9 }).input.forward).toBe(1);
52 expect(intent(["left"], { x: -0.9, y: 0 }).input.side).toBe(-1);
53 });
54
55 test("every held action lands on its field", () => {
56 const { input } = intent(["jump", "duck", "walk", "reload", "use", "fire", "scope"]);
57 expect(input.jump).toBe(true);
58 expect(input.duck).toBe(true);
59 expect(input.walk).toBe(true);
60 expect(input.reload).toBe(true);
61 expect(input.use).toBe(true);
62 expect(input.fire).toBe(true);
63 expect(input.scope).toBe(true);
64 });
65
66 test("the scoreboard is not an input to the world", () => {
67 expect(intent(["scoreboard"]).scoreboard).toBe(true);
68 expect(intent(["scoreboard"]).input).toEqual(intent([]).input);
69 });
70
71 test("three beats two beats one", () => {
72 expect(intent(["slot1"]).slotWanted).toBe(1);
73 expect(intent(["slot2"]).slotWanted).toBe(2);
74 expect(intent(["slot3"]).slotWanted).toBe(3);
75 expect(intent(["slot1", "slot2"]).slotWanted).toBe(2);
76 expect(intent(["slot1", "slot2", "slot3"]).slotWanted).toBe(3);
77 });
78
79 test("an action the game does not know is not an input", () => {
80 expect(intent(["buy", "nonsense"]).input).toEqual(intent([]).input);
81 });
src/client/binding.ts added +58
@@ -0,0 +1,58 @@
1 /**
2 * The game's half of input: which keys mean what, and what a held action does
3 * to an `Input`. The engine's controls collect names — "forward", "slot2" —
4 * and know nothing else; this is where a name becomes the same struct a bot
5 * hands the simulation, so a person and a bot are the same kind of player.
6 */
7
8 import { clamp } from "../engine/math.ts";
9 import { NO_INPUT, type Input } from "./world.ts";
10
11 /** `KeyboardEvent.code` to action. Today's bindings, moved rather than tuned. */
12 export const CS_KEYS: Record<string, string> = {
13 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
14 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
15 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
16 KeyR: "reload", KeyE: "use", KeyB: "buy", Tab: "scoreboard",
17 Digit1: "slot1", Digit2: "slot2", Digit3: "slot3",
18 };
19
20 /** Left fires, right scopes. */
21 export const CS_MOUSE: Record<number, string> = { 0: "fire", 2: "scope" };
22
23 /** On-screen buttons that are a tap, not a hold. */
24 export const CS_TAP = ["jump", "reload", "buy"] as const;
25
26 export type Intent = {
27 input: Input;
28 scoreboard: boolean;
29 /** 1 primary, 2 pistol, 3 knife — `main` knows which weapon each is. */
30 slotWanted: 1 | 2 | 3 | null;
31 };
32
33 /**
34 * What the held actions and the thumb stick add up to this frame. `switchTo`
35 * stays null: only the world knows what is in slot 1, so `main` fills it from
36 * `slotWanted`.
37 */
38 export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: number }): Intent {
39 const down = (action: string): boolean => held.has(action);
40 const input: Input = {
41 ...NO_INPUT,
42 forward: clamp((down("forward") ? 1 : 0) - (down("back") ? 1 : 0) + stick.y, -1, 1),
43 side: clamp((down("right") ? 1 : 0) - (down("left") ? 1 : 0) + stick.x, -1, 1),
44 jump: down("jump"),
45 duck: down("duck"),
46 walk: down("walk"),
47 reload: down("reload"),
48 use: down("use"),
49 fire: down("fire"),
50 scope: down("scope"),
51 switchTo: null,
52 };
53 return {
54 input,
55 scoreboard: down("scoreboard"),
56 slotWanted: down("slot3") ? 3 : down("slot2") ? 2 : down("slot1") ? 1 : null,
57 };
58 }
src/client/input.ts deleted −188
@@ -1,188 +0,0 @@
1 /**
2 * Input: keyboard and mouse under pointer lock, and a thumb stick and buttons
3 * on a touch screen. It collects intent into the same Input the simulation
4 * takes from a bot, and hands over accumulated mouse movement once a frame.
5 */
6
7 import { clamp } from "../engine/math.ts";
8 import { NO_INPUT, type Input } from "./world.ts";
9
10 export type Controls = {
11 input: Input;
12 takeLook(): [number, number];
13 scoreboard: boolean;
14 slotWanted: 1 | 2 | 3 | null; // 1 primary, 2 pistol, 3 knife — main knows what they are
15 sensitivity: number;
16 locked: boolean;
17 setAction(action: string, down: boolean): void;
18 onBuy: () => void;
19 onPause: () => void;
20 detach(): void;
21 };
22
23 const KEY_ACTIONS: Record<string, string> = {
24 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
25 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
26 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
27 KeyR: "reload", KeyE: "use", KeyB: "buy", Tab: "scoreboard",
28 Digit1: "slot1", Digit2: "slot2", Digit3: "slot3",
29 };
30
31 export function createControls(canvas: HTMLCanvasElement, sensitivity = 2.2): Controls {
32 const held = new Set<string>();
33 let lookX = 0, lookY = 0;
34 const controls: Controls = {
35 input: { ...NO_INPUT },
36 takeLook() {
37 const out: [number, number] = [lookX, lookY];
38 lookX = 0;
39 lookY = 0;
40 return out;
41 },
42 scoreboard: false,
43 slotWanted: null,
44 sensitivity,
45 locked: false,
46 setAction: (action, down) => {
47 if (down) held.add(action);
48 else held.delete(action);
49 if (down && action === "buy") controls.onBuy();
50 apply();
51 },
52 onBuy: () => {},
53 onPause: () => {},
54 detach: () => {
55 window.removeEventListener("keydown", onKeyDown);
56 window.removeEventListener("keyup", onKeyUp);
57 document.removeEventListener("pointerlockchange", onLockChange);
58 },
59 };
60
61 function apply(): void {
62 const i = controls.input;
63 i.forward = (held.has("forward") ? 1 : 0) - (held.has("back") ? 1 : 0) + stick.y;
64 i.side = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0) + stick.x;
65 i.forward = clamp(i.forward, -1, 1);
66 i.side = clamp(i.side, -1, 1);
67 i.jump = held.has("jump");
68 i.duck = held.has("duck");
69 i.walk = held.has("walk");
70 i.reload = held.has("reload");
71 i.use = held.has("use");
72 i.fire = held.has("fire");
73 i.scope = held.has("scope");
74 i.switchTo = null;
75 controls.slotWanted = held.has("slot3") ? 3 : held.has("slot2") ? 2 : held.has("slot1") ? 1 : null;
76 controls.scoreboard = held.has("scoreboard");
77 }
78
79 const stick = { x: 0, y: 0 };
80
81 function onKeyDown(event: KeyboardEvent): void {
82 const action = KEY_ACTIONS[event.code];
83 if (action === undefined) return;
84 if (event.code === "Tab" || event.code === "Space") event.preventDefault();
85 if (event.repeat) return;
86 controls.setAction(action, true);
87 }
88 function onKeyUp(event: KeyboardEvent): void {
89 const action = KEY_ACTIONS[event.code];
90 if (action !== undefined) controls.setAction(action, false);
91 }
92 function onLockChange(): void {
93 controls.locked = document.pointerLockElement === canvas;
94 if (!controls.locked) {
95 held.clear();
96 apply();
97 controls.onPause();
98 }
99 }
100
101 window.addEventListener("keydown", onKeyDown);
102 window.addEventListener("keyup", onKeyUp);
103 document.addEventListener("pointerlockchange", onLockChange);
104
105 canvas.addEventListener("mousedown", (event) => {
106 if (!controls.locked) return;
107 event.preventDefault();
108 controls.setAction(event.button === 2 ? "scope" : "fire", true);
109 });
110 window.addEventListener("mouseup", (event) => {
111 controls.setAction(event.button === 2 ? "scope" : "fire", false);
112 });
113 canvas.addEventListener("contextmenu", (event) => event.preventDefault());
114 window.addEventListener("mousemove", (event) => {
115 if (!controls.locked) return;
116 lookX += event.movementX * controls.sensitivity * 0.022;
117 lookY += event.movementY * controls.sensitivity * 0.022;
118 });
119 window.addEventListener("blur", () => {
120 held.clear();
121 apply();
122 });
123
124 // --- touch: the left third is a stick, the rest of the canvas looks around
125 const touches = new Map<number, { startX: number; startY: number; x: number; y: number; stick: boolean }>();
126 canvas.addEventListener("touchstart", (event) => {
127 for (const touch of Array.from(event.changedTouches)) {
128 const isStick = touch.clientX < canvas.clientWidth * 0.38;
129 touches.set(touch.identifier, {
130 startX: touch.clientX, startY: touch.clientY, x: touch.clientX, y: touch.clientY, stick: isStick,
131 });
132 }
133 event.preventDefault();
134 }, { passive: false });
135 canvas.addEventListener("touchmove", (event) => {
136 for (const touch of Array.from(event.changedTouches)) {
137 const state = touches.get(touch.identifier);
138 if (state === undefined) continue;
139 if (state.stick) {
140 stick.x = clamp((touch.clientX - state.startX) / 60, -1, 1);
141 stick.y = clamp((state.startY - touch.clientY) / 60, -1, 1);
142 apply();
143 } else {
144 lookX += (touch.clientX - state.x) * controls.sensitivity * 0.06;
145 lookY += (touch.clientY - state.y) * controls.sensitivity * 0.06;
146 }
147 state.x = touch.clientX;
148 state.y = touch.clientY;
149 }
150 event.preventDefault();
151 }, { passive: false });
152 const endTouch = (event: TouchEvent): void => {
153 for (const touch of Array.from(event.changedTouches)) {
154 const state = touches.get(touch.identifier);
155 if (state?.stick === true) {
156 stick.x = 0;
157 stick.y = 0;
158 apply();
159 }
160 touches.delete(touch.identifier);
161 }
162 };
163 canvas.addEventListener("touchend", endTouch);
164 canvas.addEventListener("touchcancel", endTouch);
165
166 return controls;
167 }
168
169 /** The buttons a touch screen needs, wired to the same actions as the keys. */
170 export function attachButtons(root: ParentNode, controls: Controls): void {
171 for (const element of Array.from(root.querySelectorAll<HTMLElement>("[data-action]"))) {
172 const action = element.dataset.action as string;
173 const down = (event: Event): void => {
174 event.preventDefault();
175 controls.setAction(action, true);
176 if (action === "jump" || action === "reload" || action === "buy")
177 setTimeout(() => controls.setAction(action, false), 120);
178 };
179 const up = (event: Event): void => {
180 event.preventDefault();
181 if (action !== "jump" && action !== "reload" && action !== "buy") controls.setAction(action, false);
182 };
183 element.addEventListener("pointerdown", down);
184 element.addEventListener("pointerup", up);
185 element.addEventListener("pointercancel", up);
186 element.addEventListener("pointerleave", up);
187 }
188 }
src/client/main.ts +32 −28
@@ -1,13 +1,16 @@
11 /**
2 * The loop. Input is read once a frame, the world is stepped at a fixed 64 Hz
3 * however long the frame took, and then the scene and the HUD are drawn. The
4 * page's overlays — menu, buy, pause — are plain DOM, so they are reachable
5 * with a keyboard and big enough for a thumb.
2 * The frame. The engine's loop says how many 64 Hz steps this frame is worth
3 * and the engine's controls say which actions are held; `binding` turns those
4 * names into the `Input` the world takes, and then the scene and the HUD are
5 * drawn. The page's overlays — menu, buy, pause — are plain DOM, so they are
6 * reachable with a keyboard and big enough for a thumb.
67 */
78
89 import { createAudio } from "./audio.ts";
10 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
911 import { drawHud } from "./hud.ts";
10 import { attachButtons, createControls } from "./input.ts";
12 import { attachButtons, createControls } from "../engine/controls.ts";
13 import { createLoop } from "../engine/loop.ts";
1114 import { clamp } from "../engine/math.ts";
1215 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
1316 import { createRenderer, type Camera } from "./renderer.ts";
@@ -40,8 +43,16 @@ function boot(): void {
4043 }
4144 const hudCtx = hudCanvas.getContext("2d") as CanvasRenderingContext2D;
4245 const audio = createAudio();
43 const controls = createControls(scene, Number(sensField.value) || 2.2);
44 attachButtons(document, controls);
46 const controls = createControls(
47 { surface: scene, window, document },
48 { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 },
49 );
50 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: CS_TAP });
51 const loop = createLoop({ tick: TICK }, {
52 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
53 cancelAnimationFrame: (handle) => cancelAnimationFrame(handle),
54 now: () => performance.now(),
55 });
4556
4657 let world: World | null = null;
4758 let matchId = "";
@@ -96,8 +107,10 @@ function boot(): void {
96107 }
97108 }
98109
99 controls.onBuy = () => openBuy(buyPanel.hidden);
100 controls.onPause = () => {
110 controls.onAction = (action, down) => {
111 if (down && action === "buy") openBuy(buyPanel.hidden);
112 };
113 controls.onUnlock = () => {
101114 if (running && buyPanel.hidden) show(pause, true);
102115 };
103116
@@ -140,6 +153,7 @@ function boot(): void {
140153 world = createWorld(rules, name, team, 5);
141154 matchId = crypto.randomUUID();
142155 posted = -1;
156 loop.reset();
143157 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
144158 running = true;
145159 lastSound = 0;
@@ -184,13 +198,7 @@ function boot(): void {
184198 return current.players.find((p) => p.alive && p.team === current.me.team) ?? current.me;
185199 };
186200
187 let last = performance.now();
188 let accumulator = 0;
189
190 function frame(now: number): void {
191 requestAnimationFrame(frame);
192 const elapsed = Math.min(0.25, (now - last) / 1000);
193 last = now;
201 function frame(elapsed: number): void {
194202 frameAverage += (elapsed * 1000 - frameAverage) * 0.05;
195203 if (world === null || !running) return;
196204
@@ -200,19 +208,15 @@ function boot(): void {
200208 me.yaw -= dx;
201209 me.pitch = clamp(me.pitch + dy, -89, 89);
202210 }
203 const input = controls.input;
211 const { input, scoreboard, slotWanted } = readIntent(controls.held, controls.stick);
204212 input.switchTo =
205 controls.slotWanted === 3 ? "knife" :
206 controls.slotWanted === 2 ? me.secondary :
207 controls.slotWanted === 1 ? me.primary : null;
213 slotWanted === 3 ? "knife" :
214 slotWanted === 2 ? me.secondary :
215 slotWanted === 1 ? me.primary : null;
208216
209 accumulator += elapsed;
210 let steps = 0;
211 while (accumulator >= TICK && steps < 8) {
217 const steps = loop.advance(elapsed);
218 for (let step = 0; step < steps; step++)
212219 update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false });
213 accumulator -= TICK;
214 steps += 1;
215 }
216220 playSounds(world);
217221
218222 // The buy window closes itself when the round goes live.
@@ -245,10 +249,10 @@ function boot(): void {
245249 hudCanvas.width = w;
246250 hudCanvas.height = h;
247251 }
248 drawHud(hudCtx, world, w, h, controls.scoreboard);
252 drawHud(hudCtx, world, w, h, scoreboard);
249253 $("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`;
250254 }
251 requestAnimationFrame(frame);
255 loop.start(frame);
252256 }
253257
254258 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
src/engine/controls.test.ts added +250
@@ -0,0 +1,250 @@
1 import { expect, test } from "bun:test";
2 import { attachButtons, createControls, type Host } from "./controls.ts";
3
4 type Handler = (event: unknown) => void;
5 type Registration = { type: string; handler: Handler; options?: { passive?: boolean } };
6
7 /** An event target made of an array: the test is the browser. */
8 function stubTarget() {
9 const registered: Registration[] = [];
10 let removes = 0;
11 return {
12 registered,
13 get removes() {
14 return removes;
15 },
16 /** How a registration was made, for the listeners that must not be passive. */
17 optionsFor(type: string): { passive?: boolean } | undefined {
18 return registered.find((r) => r.type === type)?.options;
19 },
20 addEventListener(type: string, handler: Handler, options?: { passive?: boolean }) {
21 registered.push({ type, handler, options });
22 },
23 removeEventListener(type: string, handler: Handler) {
24 const at = registered.findIndex((r) => r.type === type && r.handler === handler);
25 if (at < 0) return;
26 registered.splice(at, 1);
27 removes += 1;
28 },
29 emit(type: string, event: unknown = {}) {
30 for (const r of registered.filter((one) => one.type === type)) r.handler(event);
31 },
32 };
33 }
34
35 type Stub = ReturnType<typeof stubTarget>;
36
37 const KEYS = { KeyW: "forward", KeyA: "left", Space: "jump", Tab: "scoreboard" };
38
39 function harness(options: Partial<Parameters<typeof createControls>[1]> = {}) {
40 const surface = Object.assign(stubTarget(), { clientWidth: 1000 });
41 const win = stubTarget();
42 const doc = Object.assign(stubTarget(), { pointerLockElement: null as unknown });
43 const host: Host = { surface, window: win, document: doc };
44 const controls = createControls(host, { keys: KEYS, mouse: { 0: "fire", 2: "scope" }, ...options });
45 const lock = (on: boolean): void => {
46 doc.pointerLockElement = on ? surface : null;
47 doc.emit("pointerlockchange", {});
48 };
49 return { controls, surface: surface as Stub & { clientWidth: number }, win, doc, lock };
50 }
51
52 const key = (code: string, extra: Record<string, unknown> = {}) => ({
53 code,
54 preventDefault() {
55 (this as { prevented?: boolean }).prevented = true;
56 },
57 ...extra,
58 });
59
60 const touchEvent = (touches: { identifier: number; clientX: number; clientY: number }[]) => {
61 const event = {
62 changedTouches: touches,
63 prevented: false,
64 preventDefault() {
65 event.prevented = true;
66 },
67 };
68 return event;
69 };
70
71 test("a bound key goes down and comes back up", () => {
72 const { controls, win } = harness();
73 win.emit("keydown", key("KeyW"));
74 expect([...controls.held]).toEqual(["forward"]);
75 win.emit("keyup", key("KeyW"));
76 expect(controls.held.size).toBe(0);
77 });
78
79 test("an unbound code is ignored, and so is its keyup", () => {
80 const { controls, win } = harness();
81 const seen: string[] = [];
82 controls.onAction = (action) => seen.push(action);
83 win.emit("keydown", key("KeyQ"));
84 win.emit("keyup", key("KeyQ"));
85 expect(controls.held.size).toBe(0);
86 expect(seen).toEqual([]);
87 });
88
89 test("a repeating keydown does not fire the action again", () => {
90 const { controls, win } = harness();
91 let downs = 0;
92 controls.onAction = (_action, down) => {
93 if (down) downs += 1;
94 };
95 win.emit("keydown", key("KeyW"));
96 win.emit("keydown", key("KeyW", { repeat: true }));
97 win.emit("keydown", key("KeyW", { repeat: true }));
98 expect(downs).toBe(1);
99 expect([...controls.held]).toEqual(["forward"]);
100 });
101
102 test("Tab and Space keep their default off the page, W does not need to", () => {
103 const { win } = harness();
104 const tab = key("Tab");
105 const space = key("Space");
106 const w = key("KeyW");
107 win.emit("keydown", tab);
108 win.emit("keydown", space);
109 win.emit("keydown", w);
110 expect((tab as { prevented?: boolean }).prevented).toBe(true);
111 expect((space as { prevented?: boolean }).prevented).toBe(true);
112 expect((w as { prevented?: boolean }).prevented).toBeUndefined();
113 });
114
115 test("mouse movement accumulates and takeLook drains it exactly once", () => {
116 const { controls, win, lock } = harness({ sensitivity: 2.2 });
117 lock(true);
118 win.emit("mousemove", { movementX: 10, movementY: -4 });
119 win.emit("mousemove", { movementX: 5, movementY: 2 });
120 const [dx, dy] = controls.takeLook();
121 expect(dx).toBeCloseTo(15 * 2.2 * 0.022, 10);
122 expect(dy).toBeCloseTo(-2 * 2.2 * 0.022, 10);
123 expect(controls.takeLook()).toEqual([0, 0]);
124 });
125
126 test("the mouse says nothing until the pointer is locked", () => {
127 const { controls, win, surface } = harness();
128 win.emit("mousemove", { movementX: 100, movementY: 100 });
129 surface.emit("mousedown", { button: 0, preventDefault() {} });
130 expect(controls.takeLook()).toEqual([0, 0]);
131 expect(controls.held.size).toBe(0);
132 });
133
134 test("the mouse buttons are bindings too", () => {
135 const { controls, win, surface, lock } = harness();
136 lock(true);
137 surface.emit("mousedown", { button: 2, preventDefault() {} });
138 expect([...controls.held]).toEqual(["scope"]);
139 win.emit("mouseup", { button: 2, preventDefault() {} });
140 expect(controls.held.size).toBe(0);
141 });
142
143 test("the stick clamps to ±1 and returns to zero when the touch ends", () => {
144 const { controls, surface } = harness();
145 surface.emit("touchstart", touchEvent([{ identifier: 1, clientX: 100, clientY: 400 }]));
146 surface.emit("touchmove", touchEvent([{ identifier: 1, clientX: 130, clientY: 370 }]));
147 expect(controls.stick.x).toBeCloseTo(0.5, 10);
148 expect(controls.stick.y).toBeCloseTo(0.5, 10);
149 surface.emit("touchmove", touchEvent([{ identifier: 1, clientX: 900, clientY: 900 }]));
150 expect(controls.stick.x).toBe(1);
151 expect(controls.stick.y).toBe(-1);
152 surface.emit("touchend", touchEvent([{ identifier: 1, clientX: 900, clientY: 900 }]));
153 expect(controls.stick).toEqual({ x: 0, y: 0 });
154 });
155
156 test("a touch on the right of the surface looks around instead", () => {
157 const { controls, surface } = harness({ sensitivity: 2 });
158 surface.emit("touchstart", touchEvent([{ identifier: 7, clientX: 800, clientY: 300 }]));
159 surface.emit("touchmove", touchEvent([{ identifier: 7, clientX: 820, clientY: 290 }]));
160 expect(controls.stick).toEqual({ x: 0, y: 0 });
161 const [dx, dy] = controls.takeLook();
162 expect(dx).toBeCloseTo(20 * 2 * 0.06, 10);
163 expect(dy).toBeCloseTo(-10 * 2 * 0.06, 10);
164 });
165
166 test("every touch handler takes the page's scroll away from the thumb", () => {
167 const { surface } = harness();
168 const start = touchEvent([{ identifier: 1, clientX: 10, clientY: 10 }]);
169 const move = touchEvent([{ identifier: 1, clientX: 20, clientY: 10 }]);
170 surface.emit("touchstart", start);
171 surface.emit("touchmove", move);
172 expect(start.prevented).toBe(true);
173 expect(move.prevented).toBe(true);
174 expect(surface.optionsFor("touchstart")).toEqual({ passive: false });
175 expect(surface.optionsFor("touchmove")).toEqual({ passive: false });
176 });
177
178 test("blur lets go of everything held", () => {
179 const { controls, win } = harness();
180 win.emit("keydown", key("KeyW"));
181 win.emit("keydown", key("Space"));
182 win.emit("blur", {});
183 expect(controls.held.size).toBe(0);
184 });
185
186 test("losing the pointer lock lets go of everything held and says so", () => {
187 const { controls, win, lock } = harness();
188 lock(true);
189 win.emit("keydown", key("KeyW"));
190 let unlocked = 0;
191 controls.onUnlock = () => {
192 unlocked += 1;
193 };
194 lock(false);
195 expect(controls.locked).toBe(false);
196 expect(controls.held.size).toBe(0);
197 expect(unlocked).toBe(1);
198 });
199
200 test("setAction is the on-screen button's way in", () => {
201 const { controls } = harness();
202 const seen: [string, boolean][] = [];
203 controls.onAction = (action, down) => seen.push([action, down]);
204 controls.setAction("buy", true);
205 expect([...controls.held]).toEqual(["buy"]);
206 controls.setAction("buy", false);
207 expect(seen).toEqual([["buy", true], ["buy", false]]);
208 });
209
210 test("detach removes every listener it added", () => {
211 const { controls, win, surface, doc } = harness();
212 const added = win.registered.length + surface.registered.length + doc.registered.length;
213 expect(added).toBeGreaterThan(8);
214 controls.detach();
215 expect(win.registered).toEqual([]);
216 expect(surface.registered).toEqual([]);
217 expect(doc.registered).toEqual([]);
218 expect(win.removes + surface.removes + doc.removes).toBe(added);
219 win.emit("keydown", key("KeyW"));
220 expect(controls.held.size).toBe(0);
221 });
222
223 // --- the buttons a thumb presses
224
225 const buttonsFor = (actions: string[]) =>
226 actions.map((action) => Object.assign(stubTarget(), { dataset: { action } }));
227
228 const press = () => ({ preventDefault() {} });
229
230 test("a button holds its action down until the pointer comes up", () => {
231 const { controls } = harness();
232 const buttons = buttonsFor(["fire"]);
233 attachButtons(buttons, controls, { tap: ["jump"] });
234 buttons[0]?.emit("pointerdown", press());
235 expect([...controls.held]).toEqual(["fire"]);
236 buttons[0]?.emit("pointerup", press());
237 expect(controls.held.size).toBe(0);
238 });
239
240 test("a tap button releases itself after 120 ms", async () => {
241 const { controls } = harness();
242 const buttons = buttonsFor(["jump"]);
243 attachButtons(buttons, controls, { tap: ["jump"] });
244 buttons[0]?.emit("pointerdown", press());
245 expect([...controls.held]).toEqual(["jump"]);
246 buttons[0]?.emit("pointerup", press());
247 expect([...controls.held]).toEqual(["jump"]); // the release does not cut a tap short
248 await Bun.sleep(200);
249 expect(controls.held.size).toBe(0);
250 });
src/engine/controls.ts added +264
@@ -0,0 +1,264 @@
1 /**
2 * Controls: a keyboard, a mouse under pointer lock, a thumb stick and a row of
3 * on-screen buttons, collected into a set of held action names and a heading
4 * delta. It knows no game. What a key means arrives as a bindings table —
5 * `{ KeyW: "forward" }` — and what an action does to a world is the game's
6 * half; this module only says which actions are down, how far the stick is
7 * pushed, and how much the view was asked to turn since the last frame.
8 *
9 * Every target it listens on is a parameter, typed by the two methods it
10 * actually calls, so a stub with `addEventListener`/`removeEventListener`
11 * drives the whole thing headlessly. Every listener it adds is remembered, so
12 * `detach()` really does leave the page as it found it.
13 */
14
15 import { clamp } from "./math.ts";
16
17 /**
18 * Anything you can listen to. The handler takes `any` because one target
19 * carries a keyboard event and the next a touch: the events below are the
20 * shapes this module actually reads.
21 */
22 export type Listens = {
23 addEventListener(type: string, handler: (event: any) => void, options?: { passive?: boolean }): void;
24 removeEventListener(type: string, handler: (event: any) => void): void;
25 };
26
27 /** The element the pointer locks to and the fingers land on. */
28 export type Surface = Listens & { readonly clientWidth: number };
29
30 /** Whatever owns pointer lock — a `Document` fits. */
31 export type LockOwner = Listens & { readonly pointerLockElement: unknown };
32
33 export type Host = { surface: Surface; window: Listens; document: LockOwner };
34
35 export type KeyEventish = { code: string; repeat?: boolean; preventDefault(): void };
36 export type MouseEventish = {
37 button?: number;
38 movementX?: number;
39 movementY?: number;
40 preventDefault(): void;
41 };
42 export type Touchish = { identifier: number; clientX: number; clientY: number };
43 export type TouchEventish = { changedTouches: ArrayLike<Touchish>; preventDefault(): void };
44
45 export type Stick = { readonly x: number; readonly y: number };
46
47 export type Options = {
48 /** `KeyboardEvent.code` to action name. Anything unbound is ignored. */
49 keys: Record<string, string>;
50 /** `MouseEvent.button` to action name; 0 is left, 2 is right. */
51 mouse?: Record<number, string>;
52 /** Codes whose default the page must not take — Tab moves focus, Space scrolls. */
53 preventKeys?: readonly string[];
54 /** Degrees of turn per unit of pointer movement, scaled by `sensitivity`. */
55 lookScale?: number;
56 /** The same, for a finger dragged across the surface. */
57 touchLookScale?: number;
58 sensitivity?: number;
59 /** Fraction of the surface's width, from the left, that is the stick. */
60 stickRegion?: number;
61 /** Pixels of drag that mean a fully pushed stick. */
62 stickRadius?: number;
63 /** Ignore mouse buttons and movement until the pointer is locked. */
64 requireLock?: boolean;
65 };
66
67 export type Controls = {
68 /** The actions held right now. Live: read it every frame. */
69 readonly held: ReadonlySet<string>;
70 /** The thumb stick, -1..1 on each axis, y positive forward. Live. */
71 readonly stick: Stick;
72 /** Turn asked for since the last call, in degrees, and then forgotten. */
73 takeLook(): [number, number];
74 sensitivity: number;
75 readonly locked: boolean;
76 /** Press or release an action by name — how an on-screen button speaks. */
77 setAction(action: string, down: boolean): void;
78 /** Every press and release, however it arrived. */
79 onAction: (action: string, down: boolean) => void;
80 /** Pointer lock was lost: the game probably wants to pause. */
81 onUnlock: () => void;
82 detach(): void;
83 };
84
85 const PREVENT_KEYS = ["Tab", "Space"] as const;
86
87 export function createControls(host: Host, options: Options): Controls {
88 const keys = options.keys;
89 const mouse = options.mouse ?? {};
90 const preventKeys = new Set(options.preventKeys ?? PREVENT_KEYS);
91 const lookScale = options.lookScale ?? 0.022;
92 const touchLookScale = options.touchLookScale ?? 0.06;
93 const stickRegion = options.stickRegion ?? 0.38;
94 const stickRadius = options.stickRadius ?? 60;
95 const requireLock = options.requireLock ?? true;
96
97 const held = new Set<string>();
98 const stick = { x: 0, y: 0 };
99 let lookX = 0;
100 let lookY = 0;
101 let locked = false;
102
103 // Every listener, so detach can undo exactly what attach did.
104 const added: [Listens, string, (event: never) => void][] = [];
105 const on = (
106 target: Listens,
107 type: string,
108 handler: (event: any) => void,
109 passive?: boolean,
110 ): void => {
111 target.addEventListener(type, handler, passive === undefined ? undefined : { passive });
112 added.push([target, type, handler]);
113 };
114
115 const controls: Controls = {
116 held,
117 stick,
118 takeLook() {
119 const out: [number, number] = [lookX, lookY];
120 lookX = 0;
121 lookY = 0;
122 return out;
123 },
124 sensitivity: options.sensitivity ?? 2.2,
125 get locked() {
126 return locked;
127 },
128 setAction(action, down) {
129 if (down) held.add(action);
130 else held.delete(action);
131 controls.onAction(action, down);
132 },
133 onAction: () => {},
134 onUnlock: () => {},
135 detach() {
136 for (const [target, type, handler] of added) target.removeEventListener(type, handler);
137 added.length = 0;
138 },
139 };
140
141 // --- keyboard
142 on(host.window, "keydown", (event: KeyEventish) => {
143 const action = keys[event.code];
144 if (action === undefined) return;
145 if (preventKeys.has(event.code)) event.preventDefault();
146 if (event.repeat === true) return; // a held key repeats; the action is already down
147 controls.setAction(action, true);
148 });
149 on(host.window, "keyup", (event: KeyEventish) => {
150 const action = keys[event.code];
151 if (action !== undefined) controls.setAction(action, false);
152 });
153
154 // --- mouse, under pointer lock
155 on(host.document, "pointerlockchange", () => {
156 locked = host.document.pointerLockElement === host.surface;
157 if (locked) return;
158 clearHeld();
159 controls.onUnlock();
160 });
161 on(host.surface, "mousedown", (event: MouseEventish) => {
162 if (requireLock && !locked) return;
163 event.preventDefault();
164 const action = mouse[event.button ?? 0];
165 if (action !== undefined) controls.setAction(action, true);
166 });
167 on(host.window, "mouseup", (event: MouseEventish) => {
168 const action = mouse[event.button ?? 0];
169 if (action !== undefined) controls.setAction(action, false);
170 });
171 on(host.surface, "contextmenu", (event: MouseEventish) => event.preventDefault());
172 on(host.window, "mousemove", (event: MouseEventish) => {
173 if (requireLock && !locked) return;
174 lookX += (event.movementX ?? 0) * controls.sensitivity * lookScale;
175 lookY += (event.movementY ?? 0) * controls.sensitivity * lookScale;
176 });
177 on(host.window, "blur", clearHeld);
178
179 // --- touch: the left of the surface is a stick, the rest of it looks around
180 const touches = new Map<number, { startX: number; startY: number; x: number; y: number; stick: boolean }>();
181 on(host.surface, "touchstart", (event: TouchEventish) => {
182 for (const touch of Array.from(event.changedTouches)) {
183 touches.set(touch.identifier, {
184 startX: touch.clientX,
185 startY: touch.clientY,
186 x: touch.clientX,
187 y: touch.clientY,
188 stick: touch.clientX < host.surface.clientWidth * stickRegion,
189 });
190 }
191 event.preventDefault();
192 }, false);
193 on(host.surface, "touchmove", (event: TouchEventish) => {
194 for (const touch of Array.from(event.changedTouches)) {
195 const state = touches.get(touch.identifier);
196 if (state === undefined) continue;
197 if (state.stick) {
198 stick.x = clamp((touch.clientX - state.startX) / stickRadius, -1, 1);
199 stick.y = clamp((state.startY - touch.clientY) / stickRadius, -1, 1);
200 } else {
201 lookX += (touch.clientX - state.x) * controls.sensitivity * touchLookScale;
202 lookY += (touch.clientY - state.y) * controls.sensitivity * touchLookScale;
203 }
204 state.x = touch.clientX;
205 state.y = touch.clientY;
206 }
207 event.preventDefault();
208 }, false);
209 const endTouch = (event: TouchEventish): void => {
210 for (const touch of Array.from(event.changedTouches)) {
211 if (touches.get(touch.identifier)?.stick === true) {
212 stick.x = 0;
213 stick.y = 0;
214 }
215 touches.delete(touch.identifier);
216 }
217 };
218 on(host.surface, "touchend", endTouch);
219 on(host.surface, "touchcancel", endTouch);
220
221 /** A key cannot stay down through a blur or a lost lock: that is how one sticks. */
222 function clearHeld(): void {
223 for (const action of Array.from(held)) controls.setAction(action, false);
224 }
225
226 return controls;
227 }
228
229 /** An element with a `data-action`, which is how a thumb reaches an action. */
230 export type Button = Listens & { readonly dataset: { action?: string } };
231
232 export type ButtonOptions = {
233 /** Actions a press fires and releases by itself — a jump is not a hold. */
234 tap?: readonly string[];
235 /** How long a tap stays down, in milliseconds. */
236 tapMs?: number;
237 };
238
239 /** Wire each `[data-action]` element to the same actions as the keys. */
240 export function attachButtons(
241 buttons: ArrayLike<Button>,
242 controls: Controls,
243 options: ButtonOptions = {},
244 ): void {
245 const tap = new Set(options.tap ?? []);
246 const tapMs = options.tapMs ?? 120;
247 for (const element of Array.from(buttons)) {
248 const action = element.dataset.action;
249 if (action === undefined || action === "") continue;
250 const down = (event: { preventDefault(): void }): void => {
251 event.preventDefault();
252 controls.setAction(action, true);
253 if (tap.has(action)) setTimeout(() => controls.setAction(action, false), tapMs);
254 };
255 const up = (event: { preventDefault(): void }): void => {
256 event.preventDefault();
257 if (!tap.has(action)) controls.setAction(action, false);
258 };
259 element.addEventListener("pointerdown", down);
260 element.addEventListener("pointerup", up);
261 element.addEventListener("pointercancel", up);
262 element.addEventListener("pointerleave", up);
263 }
264 }
src/engine/loop.test.ts added +142
@@ -0,0 +1,142 @@
1 import { expect, test } from "bun:test";
2 import { createLoop, type Frames } from "./loop.ts";
3
4 const TICK = 1 / 64;
5
6 /** A browser made of an array: frames run when the test says they run. */
7 function fakeFrames(): Frames & { run(now: number): void; pending: number; cancelled: number[] } {
8 let next: ((now: number) => void) | null = null;
9 let handle = 0;
10 const cancelled: number[] = [];
11 let clock = 0;
12 return {
13 requestAnimationFrame(callback) {
14 next = callback;
15 handle += 1;
16 return handle;
17 },
18 cancelAnimationFrame(h) {
19 cancelled.push(h);
20 next = null;
21 },
22 now: () => clock,
23 get pending() {
24 return next === null ? 0 : 1;
25 },
26 cancelled,
27 run(now) {
28 clock = now;
29 const callback = next;
30 next = null;
31 callback?.(now);
32 },
33 };
34 }
35
36 const loopOf = (options = {}) => createLoop({ tick: TICK, ...options }, fakeFrames());
37
38 test("a 0.05 s frame at 64 Hz runs three steps and keeps the remainder", () => {
39 const loop = loopOf();
40 expect(loop.advance(0.05)).toBe(3);
41 expect(loop.remainder).toBeCloseTo(0.05 - 3 * TICK, 10);
42 });
43
44 test("the remainder carries into the next frame", () => {
45 const loop = loopOf();
46 loop.advance(0.05); // 0.003125 s left over, which is less than a tick
47 expect(loop.advance(0.013)).toBe(1);
48 expect(loop.remainder).toBeCloseTo(0.05 + 0.013 - 4 * TICK, 10);
49 });
50
51 test("a frame shorter than a tick runs nothing and loses nothing", () => {
52 const loop = loopOf();
53 expect(loop.advance(0.01)).toBe(0);
54 expect(loop.remainder).toBeCloseTo(0.01, 10);
55 expect(loop.advance(0.01)).toBe(1);
56 expect(loop.remainder).toBeCloseTo(0.02 - TICK, 10);
57 });
58
59 test("a five-second stall is clamped to 0.25 s and eight steps, not 320", () => {
60 const loop = loopOf();
61 expect(loop.advance(5)).toBe(8);
62 // Only the clamped quarter-second was ever banked, so what is left drains in
63 // the next frame or two instead of spiralling forever.
64 expect(loop.remainder).toBeCloseTo(0.25 - 8 * TICK, 10);
65 expect(loop.advance(0)).toBe(8);
66 expect(loop.remainder).toBeCloseTo(0.25 - 16 * TICK, 10);
67 });
68
69 test("the clamps are the loop's, not the game's", () => {
70 const loop = loopOf({ maxSteps: 2, maxFrame: 1 });
71 expect(loop.advance(5)).toBe(2);
72 expect(loop.remainder).toBeCloseTo(1 - 2 * TICK, 10);
73 });
74
75 test("a clock that goes backwards banks nothing", () => {
76 const loop = loopOf();
77 expect(loop.advance(-3)).toBe(0);
78 expect(loop.advance(Number.NaN)).toBe(0);
79 expect(loop.remainder).toBe(0);
80 });
81
82 test("reset drops the remainder", () => {
83 const loop = loopOf();
84 loop.advance(0.01);
85 loop.reset();
86 expect(loop.remainder).toBe(0);
87 });
88
89 test("a tick of zero is not a loop", () => {
90 expect(() => createLoop({ tick: 0 }, fakeFrames())).toThrow(/tick/);
91 });
92
93 test("start drives frames from the injected clock and stop ends them", () => {
94 const frames = fakeFrames();
95 const loop = createLoop({ tick: TICK }, frames);
96 const seen: number[] = [];
97 loop.start((elapsed) => seen.push(elapsed));
98 expect(loop.running).toBe(true);
99
100 frames.run(16); // the first frame is measured from now() at start, which was 0
101 frames.run(32);
102 frames.run(5032); // a stall the callback must not see as five seconds
103 expect(seen[0]).toBeCloseTo(0.016, 10);
104 expect(seen[1]).toBeCloseTo(0.016, 10);
105 expect(seen[2]).toBeCloseTo(0.25, 10);
106
107 loop.stop();
108 expect(loop.running).toBe(false);
109 expect(frames.cancelled.length).toBe(1);
110 expect(frames.pending).toBe(0);
111 frames.run(48);
112 expect(seen.length).toBe(3);
113 });
114
115 test("starting twice keeps one frame in flight", () => {
116 const frames = fakeFrames();
117 const loop = createLoop({ tick: TICK }, frames);
118 let count = 0;
119 loop.start(() => {
120 count += 1;
121 });
122 loop.start(() => {
123 count += 100;
124 });
125 frames.run(16);
126 expect(count).toBe(1);
127 });
128
129 test("stopping from inside the callback cancels the frame it just asked for", () => {
130 const frames = fakeFrames();
131 const loop = createLoop({ tick: TICK }, frames);
132 let count = 0;
133 loop.start(() => {
134 count += 1;
135 loop.stop();
136 });
137 frames.run(16);
138 expect(count).toBe(1);
139 expect(frames.pending).toBe(0);
140 frames.run(32);
141 expect(count).toBe(1);
142 });
src/engine/loop.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The fixed step. A frame is however long the browser felt like giving us; a
3 * simulation wants a constant dt or it stops being reproducible, so the loop
4 * banks elapsed time and hands back whole ticks. Three numbers decide how that
5 * feels: the tick, the most steps one frame may run, and the longest frame the
6 * clock is allowed to report — a tab that slept for five seconds must not try
7 * to catch five seconds of physics up in one go.
8 *
9 * Nothing in here is a browser. `start` drives from an injected
10 * requestAnimationFrame and clock, so a test runs the whole loop headless and
11 * the game hands in `window`'s.
12 */
13
14 /** The clock and the frame source, injected so a test can be the browser. */
15 export type Frames = {
16 requestAnimationFrame(callback: (now: number) => void): number;
17 cancelAnimationFrame(handle: number): void;
18 /** Milliseconds, monotonic — the same clock rAF stamps its callback with. */
19 now(): number;
20 };
21
22 export type LoopOptions = {
23 /** Seconds of simulation per step. */
24 tick: number;
25 /** Most steps one frame may run, so catching up cannot outrun the frame. */
26 maxSteps?: number;
27 /** Longest frame, in seconds, the clock is believed about. */
28 maxFrame?: number;
29 };
30
31 export type Loop = {
32 readonly tick: number;
33 readonly maxSteps: number;
34 readonly maxFrame: number;
35 /** Seconds banked but not yet stepped; it carries into the next frame. */
36 readonly remainder: number;
37 readonly running: boolean;
38 /** Bank `elapsed` seconds and return how many whole ticks to run now. */
39 advance(elapsed: number): number;
40 /** Drop the banked remainder — a new world starts from nothing. */
41 reset(): void;
42 /** Call `onFrame(elapsed, now)` every animation frame until `stop()`. */
43 start(onFrame: (elapsed: number, now: number) => void): void;
44 stop(): void;
45 };
46
47 const MAX_STEPS = 8;
48 const MAX_FRAME = 0.25;
49
50 export function createLoop(options: LoopOptions, frames: Frames): Loop {
51 const tick = options.tick;
52 if (!(tick > 0)) throw new Error("a loop needs a tick longer than zero seconds");
53 const maxSteps = options.maxSteps ?? MAX_STEPS;
54 const maxFrame = options.maxFrame ?? MAX_FRAME;
55
56 let accumulator = 0;
57 let handle: number | null = null;
58 let last = 0;
59
60 /** Seconds this frame is worth: never negative, never longer than the clamp. */
61 const believable = (elapsed: number): number =>
62 Number.isFinite(elapsed) ? Math.min(maxFrame, Math.max(0, elapsed)) : 0;
63
64 return {
65 tick,
66 maxSteps,
67 maxFrame,
68 get remainder() {
69 return accumulator;
70 },
71 get running() {
72 return handle !== null;
73 },
74 advance(elapsed) {
75 accumulator += believable(elapsed);
76 let steps = 0;
77 while (accumulator >= tick && steps < maxSteps) {
78 accumulator -= tick;
79 steps += 1;
80 }
81 return steps;
82 },
83 reset() {
84 accumulator = 0;
85 },
86 start(onFrame) {
87 if (handle !== null) return;
88 last = frames.now();
89 const frame = (now: number): void => {
90 // Ask for the next frame first: a throw in the callback costs one
91 // frame, not the loop. `stop()` from inside cancels what we just asked.
92 handle = frames.requestAnimationFrame(frame);
93 const elapsed = believable((now - last) / 1000);
94 last = now;
95 onFrame(elapsed, now);
96 };
97 handle = frames.requestAnimationFrame(frame);
98 },
99 stop() {
100 if (handle === null) return;
101 frames.cancelAnimationFrame(handle);
102 handle = null;
103 },
104 };
105 }
src/pages/shell.ts +1 −1
@@ -68,7 +68,7 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; }
6868 @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } }
6969 `;
7070
71 /** One touch button: the action `src/client/input.ts` looks up, and its label. */
71 /** One touch button: the action `src/engine/controls.ts` holds, and its label. */
7272 export type TouchButton = { action: string; label: string };
7373
7474 /** One overlay over the stage — a menu, a pause screen — by the id the client finds it with. */
src/server.test.ts +3 −2
@@ -35,8 +35,9 @@ test("serves the game page with the leaderboard in the first paint", async () =>
3535 });
3636
3737 test("the page carries every id and action the client reaches for", async () => {
38 // src/client/main.ts finds these by getElementById and input.ts by
39 // [data-action]; nothing type-checks that, so the page is checked instead.
38 // src/client/main.ts finds these by getElementById and hands [data-action]
39 // to the engine's controls; nothing type-checks that, so the page is
40 // checked instead.
4041 // Dropping one while moving the markup around is a game that does not start.
4142 const body = await (await fetch(base + "/")).text();
4243 for (const id of ["scene", "hud", "fps", "touch", "menu", "pause", "buy", "stats",
static/app.876e3fcdf5.js deleted −58

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

View file
static/app.90bb5b2a7e.js added +58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs