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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/10-the-frame-becomes-engine-a-fixed-step-lo → main 2 commits fork
13 files
changed +1006
−285
README.md +12 −8
| @@ -58,19 +58,23 @@ the rest. | ||
| 58 | 58 | (rays against boxes, and the `Box` they are traced against), `grid` (a grid of |
| 59 | 59 | cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no |
| 60 | 60 | 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. | |
| 66 | 69 | - `src/client/` — the game, in modules named for one thing each: `map` (de_forge |
| 67 | 70 | itself: its areas, its crates, its cell alphabet and its spawns and sites, laid |
| 68 | 71 | over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules), |
| 69 | 72 | `world` (the simulation), |
| 70 | 73 | `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`. | |
| 74 | 78 | - `src/rl/` — training, which the game never imports: `arena` is the environment |
| 75 | 79 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
| 76 | 80 | 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import { createAudio } from "./audio.ts"; |
| 10 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts"; | |
| 9 | 11 | 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"; | |
| 11 | 14 | import { clamp } from "../engine/math.ts"; |
| 12 | 15 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 13 | 16 | import { createRenderer, type Camera } from "./renderer.ts"; |
| @@ -40,8 +43,16 @@ function boot(): void { | ||
| 40 | 43 | } |
| 41 | 44 | const hudCtx = hudCanvas.getContext("2d") as CanvasRenderingContext2D; |
| 42 | 45 | 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 | }); | |
| 45 | 56 | |
| 46 | 57 | let world: World | null = null; |
| 47 | 58 | let matchId = ""; |
| @@ -96,8 +107,10 @@ function boot(): void { | ||
| 96 | 107 | } |
| 97 | 108 | } |
| 98 | 109 | |
| 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 = () => { | |
| 101 | 114 | if (running && buyPanel.hidden) show(pause, true); |
| 102 | 115 | }; |
| 103 | 116 | |
| @@ -140,6 +153,7 @@ function boot(): void { | ||
| 140 | 153 | world = createWorld(rules, name, team, 5); |
| 141 | 154 | matchId = crypto.randomUUID(); |
| 142 | 155 | posted = -1; |
| 156 | loop.reset(); | |
| 143 | 157 | controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12); |
| 144 | 158 | running = true; |
| 145 | 159 | lastSound = 0; |
| @@ -184,13 +198,7 @@ function boot(): void { | ||
| 184 | 198 | return current.players.find((p) => p.alive && p.team === current.me.team) ?? current.me; |
| 185 | 199 | }; |
| 186 | 200 | |
| 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 { | |
| 194 | 202 | frameAverage += (elapsed * 1000 - frameAverage) * 0.05; |
| 195 | 203 | if (world === null || !running) return; |
| 196 | 204 | |
| @@ -200,19 +208,15 @@ function boot(): void { | ||
| 200 | 208 | me.yaw -= dx; |
| 201 | 209 | me.pitch = clamp(me.pitch + dy, -89, 89); |
| 202 | 210 | } |
| 203 | const input = controls.input; | |
| 211 | const { input, scoreboard, slotWanted } = readIntent(controls.held, controls.stick); | |
| 204 | 212 | 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; | |
| 208 | 216 | |
| 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++) | |
| 212 | 219 | update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false }); |
| 213 | accumulator -= TICK; | |
| 214 | steps += 1; | |
| 215 | } | |
| 216 | 220 | playSounds(world); |
| 217 | 221 | |
| 218 | 222 | // The buy window closes itself when the round goes live. |
| @@ -245,10 +249,10 @@ function boot(): void { | ||
| 245 | 249 | hudCanvas.width = w; |
| 246 | 250 | hudCanvas.height = h; |
| 247 | 251 | } |
| 248 | drawHud(hudCtx, world, w, h, controls.scoreboard); | |
| 252 | drawHud(hudCtx, world, w, h, scoreboard); | |
| 249 | 253 | $("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`; |
| 250 | 254 | } |
| 251 | requestAnimationFrame(frame); | |
| 255 | loop.start(frame); | |
| 252 | 256 | } |
| 253 | 257 | |
| 254 | 258 | 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; } | ||
| 68 | 68 | @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } } |
| 69 | 69 | `; |
| 70 | 70 | |
| 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. */ | |
| 72 | 72 | export type TouchButton = { action: string; label: string }; |
| 73 | 73 | |
| 74 | 74 | /** 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 () => | ||
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | 37 | 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. | |
| 40 | 41 | // Dropping one while moving the markup around is a game that does not start. |
| 41 | 42 | const body = await (await fetch(base + "/")).text(); |
| 42 | 43 | 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 filestatic/app.90bb5b2a7e.js added +58
Diff not shown — a line 57,162 characters wide (limit 2,000). +58
View file
new above
offline