Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The overlay becomes engine: 2D drawing tools behind the door, and the range draws through them #60
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/49-the-overlay-becomes-engine-2d-drawing-to → main 1 commit fork
11 files
changed +453
−46
README.md +11 −4
| @@ -108,7 +108,11 @@ from the angle it subtends, and you turn on the spot. | ||
| 108 | 108 | knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture` |
| 109 | 109 | (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext` |
| 110 | 110 | with noise bursts and tones), `loop` (a fixed step with a clamp on a long |
| 111 | frame) and `controls` (a bindings table in, held actions out). | |
| 111 | frame), `controls` (a bindings table in, held actions out) and `overlay` (the | |
| 112 | 2D over the scene: a scale and a pad off the canvas width, scaled fonts, | |
| 113 | panels, aligned and faded text, a crosshair, a bar, right-aligned columns and | |
| 114 | a `m:ss` clock, drawn onto the seventeen members of a 2D context it uses and | |
| 115 | holding no colour a game chose). | |
| 112 | 116 | Three rules keep it a package, and a test holds each: the engine imports |
| 113 | 117 | nothing outside itself, it needs nothing from a browser to load, and |
| 114 | 118 | everything outside it comes in through one door. |
| @@ -137,9 +141,10 @@ from the angle it subtends, and you turn on the spot. | ||
| 137 | 141 | `Grid`; from `mesh` `builder`, `addBox`, `finish`, `quadMesh`, `Builder` and |
| 138 | 142 | `Mesh`; from `texture` `hash`, `noise`, `fbm`, `paintAtlas` and `Shader`; from |
| 139 | 143 | `sound` `attenuate`, `createSoundDevice`, `Burst`, `Tone`, `SoundDevice` and |
| 140 | `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; and from | |
| 144 | `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; from | |
| 141 | 145 | `controls` `createControls`, `attachButtons` and the event and option types |
| 142 | they are driven with. `boundary.test.ts` fails a deep import from outside, a | |
| 146 | they are driven with; and from `overlay` `overlay`, `Overlay` and the `Paint` | |
| 147 | a 2D game draws onto. `boundary.test.ts` fails a deep import from outside, a | |
| 143 | 148 | module that is not behind the door, and any change to that list of names that |
| 144 | 149 | is not also a change to the list written out in the test. One exception, and |
| 145 | 150 | it is written down there too: a `.test.ts` may import another `.test.ts`, which |
| @@ -194,7 +199,9 @@ from the angle it subtends, and you turn on the spot. | ||
| 194 | 199 | score and the accuracy in, all plain data in and plain data out with no canvas |
| 195 | 200 | anywhere near it; `readout.ts` draws a horizon, the plates on it as blips |
| 196 | 201 | projected from the angle each subtends, and a panel with the score, the clock |
| 197 | and what the loop and the controls are doing, in plain 2D calls; and `main.ts` | |
| 202 | and what the loop and the controls are doing — the panel, its lines and the | |
| 203 | crosshair through the engine's `overlay`, which is the range proving a second | |
| 204 | game gets that furniture without copying a line out of the shooter; and `main.ts` | |
| 198 | 205 | wires the engine's `loop` and `controls` to the shell's ids, steps the run in |
| 199 | 206 | whole ticks and fires one shot per press along the engine's `angleVectors`. |
| 200 | 207 | No room, no models, no movement, no WebGL — those need the renderer and the |
scripts/build.ts +6 −1
| @@ -28,7 +28,12 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling }; | ||
| 28 | 28 | * The ceilings are today's sizes plus about 10%, rounded up to a round number: |
| 29 | 29 | * enough headroom that an honest feature lands without a ceiling edit, tight |
| 30 | 30 | * enough that a stray import of something large trips the test. Measured |
| 31 | * 2026-09-17: app 69,711 raw / 32,142 gzipped, range 11,575 raw / 5,241 gzipped. | |
| 31 | * 2026-09-17: app 69,711 raw / 32,139 gzipped, range 12,548 raw / 5,726 gzipped. | |
| 32 | * The range grew 973 bytes for #49, which moved its panel, its lines and its | |
| 33 | * crosshair onto the engine's overlay — a tool set is bundled whole, so a game | |
| 34 | * that uses three of its tools still ships all of them. It fits, with about 250 | |
| 35 | * raw and 70 gzipped bytes to spare: the next thing the range gains will want | |
| 36 | * this ceiling raised, and #18 takes the same bytes back out of the shooter. | |
| 32 | 37 | * The app grew 1,254 bytes for #47, which moved every decision about a frame |
| 33 | 38 | * into `src/client/scene.ts`; it fits under today's ceiling, so no ceiling moved |
| 34 | 39 | * with it. The range's ceiling was raised for #41, which gave it a game: the targets, |
src/engine/GUIDE.md +26 −1
| @@ -52,7 +52,7 @@ parameter. Controls want a `Host` — `{ surface, window, document }`, where | ||
| 52 | 52 | ts |
| 53 | 53 | // src/client/main.ts |
| 54 | 54 | const controls = createControls( |
| 55 | { surface: scene, window: win, document: doc }, | |
| 55 | { surface: sceneCanvas, window: win, document: doc }, | |
| 56 | 56 | { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 }, |
| 57 | 57 | ); |
| 58 | 58 | |
| @@ -248,6 +248,31 @@ const controls = createControls( | ||
| 248 | 248 | attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP }); |
| 249 | 249 | |
| 250 | 250 | |
| 251 | ### overlay | |
| 252 | ||
| 253 | The 2D a game draws over its scene. `overlay` takes the surface, the width and | |
| 254 | the height and hands back an `Overlay`: `Overlay.scale`, the canvas width over | |
| 255 | 1280 clamped to 0.7–1.4, and `Overlay.pad`, the margin everything is laid out | |
| 256 | inside; `Overlay.font` for a size in that scale; `Overlay.panel` for a scrim, | |
| 257 | `Overlay.text` for one string with an alignment, a font, a colour and a fade | |
| 258 | that is always put back, `Overlay.crosshair` for four arms around a centre, | |
| 259 | `Overlay.bar` for a fraction of a width, `Overlay.columns` for a row of | |
| 260 | right-aligned figures and `Overlay.clock` for seconds as `m:ss`. Build it on a | |
| 261 | resize, not in a frame: the tools it returns allocate nothing per call. | |
| 262 | ||
| 263 | What it draws onto is a `Paint` — the seventeen members of a 2D context these | |
| 264 | tools touch, and not a whole context. A browser's is one; so is a recorder in a | |
| 265 | test, which is how every pixel decision in `overlay.test.ts` is checked with no | |
| 266 | canvas in the room, and how `src/range/readout.test.ts` checks the range's | |
| 267 | panel. There is no colour in here a game chose, beyond one neutral scrim. | |
| 268 | ||
| 269 | ts | |
| 270 | // src/range/readout.ts | |
| 271 | const o = overlay(paint, width, height); | |
| 272 | o.panel(pad, pad, width - pad * 2, step * lines.length + pad, "rgba(11,13,17,.72)"); | |
| 273 | o.crosshair(Math.round(width / 2), Math.round(height / 2), 0, Math.max(6, Math.round(width / 90)), "#8ea"); | |
| 274 | ||
| 275 | ||
| 251 | 276 | ## The seam, named as pairs |
| 252 | 277 | |
| 253 | 278 | Two games exist here, and every place they differ is a place the engine chose to |
src/engine/boundary.test.ts +2 −2
| @@ -222,12 +222,12 @@ test("no module in the engine goes through the engine's own door", () => { | ||
| 222 | 222 | export const SURFACE = [ |
| 223 | 223 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Controls", "Ctor", "Frames", "Grid", |
| 224 | 224 | "Hit", "Host", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", |
| 225 | "MouseEventish", "Options", "Shader", "SoundDevice", "Stick", "Surface", "Tone", | |
| 225 | "MouseEventish", "Options", "Overlay", "Paint", "Shader", "SoundDevice", "Stick", "Surface", "Tone", | |
| 226 | 226 | "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors", |
| 227 | 227 | "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf", "createControls", |
| 228 | 228 | "createLoop", "createSoundDevice", "cross", "deg", "dist", "dist2d", "dot", "fbm", |
| 229 | 229 | "findPath", "finish", "hash", "inside", "length", "length2d", "lerp", "mat4", "mergeSolid", |
| 230 | "modelMatrix", "mul", "multiply", "noise", "normalize", "paintAtlas", "perspective", | |
| 230 | "modelMatrix", "mul", "multiply", "noise", "normalize", "overlay", "paintAtlas", "perspective", | |
| 231 | 231 | "quadMesh", "rad", "rayBox", "rng", "rowOf", "scale", "segmentLength", "sub", "traceWorld", |
| 232 | 232 | "v3", "viewMatrix", "visible", "worldOf", |
| 233 | 233 | ]; |
src/engine/index.ts +4
| @@ -48,3 +48,7 @@ export type { | ||
| 48 | 48 | Button, ButtonOptions, Controls, Host, KeyEventish, Listens, LockOwner, MouseEventish, |
| 49 | 49 | Options, Stick, Surface, TouchEventish, Touchish, |
| 50 | 50 | } from "./controls.ts"; |
| 51 | ||
| 52 | /** The 2D over the scene: panels, scaled text, a crosshair, a bar and a clock. */ | |
| 53 | export { overlay } from "./overlay.ts"; | |
| 54 | export type { Overlay, Paint } from "./overlay.ts"; |
src/engine/overlay.test.ts added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | /** | |
| 2 | * The tools are driven by a stub that writes down every call it was asked to | |
| 3 | * make, so what would land on a canvas is checked without one: no browser, no | |
| 4 | * `CanvasRenderingContext2D`, no cast. That the stub is a `Paint` at all is | |
| 5 | * half the point of the type. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { overlay, type Paint } from "./overlay.ts"; | |
| 10 | ||
| 11 | type Call = [string, ...unknown[]]; | |
| 12 | ||
| 13 | /** A `Paint` that records what it was told to do, and what it was set to. */ | |
| 14 | function recorder(): Paint & { calls: Call[]; texts: string[]; points: Call[] } { | |
| 15 | const calls: Call[] = []; | |
| 16 | const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]); | |
| 17 | return { | |
| 18 | calls, | |
| 19 | get texts() { | |
| 20 | return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1])); | |
| 21 | }, | |
| 22 | get points() { | |
| 23 | return calls.filter((c) => c[0] === "moveTo" || c[0] === "lineTo"); | |
| 24 | }, | |
| 25 | fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", | |
| 26 | globalAlpha: 1, | |
| 27 | clearRect: note("clearRect"), | |
| 28 | fillRect: note("fillRect"), | |
| 29 | fillText: note("fillText"), | |
| 30 | beginPath: note("beginPath"), | |
| 31 | moveTo: note("moveTo"), | |
| 32 | lineTo: note("lineTo"), | |
| 33 | rect: note("rect"), | |
| 34 | arc: note("arc"), | |
| 35 | fill: note("fill"), | |
| 36 | stroke: note("stroke"), | |
| 37 | } as Paint & { calls: Call[]; texts: string[]; points: Call[] }; | |
| 38 | } | |
| 39 | ||
| 40 | /** A tool set on a recorder, since every test below wants both. */ | |
| 41 | const tools = (width = 1280, height = 720) => { | |
| 42 | const paint = recorder(); | |
| 43 | return { paint, o: overlay(paint, width, height) }; | |
| 44 | }; | |
| 45 | ||
| 46 | const rects = (paint: ReturnType<typeof recorder>): Call[] => | |
| 47 | paint.calls.filter((c) => c[0] === "fillRect"); | |
| 48 | ||
| 49 | test("the scale is one at the reference width and clamps at both ends", () => { | |
| 50 | expect(tools(1280).o.scale).toBe(1); | |
| 51 | expect(tools(640).o.scale).toBe(0.7); | |
| 52 | expect(tools(360).o.scale).toBe(0.7); | |
| 53 | expect(tools(4000).o.scale).toBe(1.4); | |
| 54 | // Between the clamps it is the plain ratio. | |
| 55 | expect(tools(960).o.scale).toBeCloseTo(0.75, 10); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the pad is a fiftieth of the width and never tighter than a thumb likes", () => { | |
| 59 | expect(tools(1280).o.pad).toBe(26); | |
| 60 | expect(tools(960).o.pad).toBe(19); | |
| 61 | expect(tools(360).o.pad).toBe(12); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a font is that many pixels of one family, scaled with the canvas", () => { | |
| 65 | expect(tools(1280).o.font(30)).toBe("30px system-ui, sans-serif"); | |
| 66 | expect(tools(1280).o.font(15)).toBe("15px system-ui, sans-serif"); | |
| 67 | // 0.7 × 30 is 21, and the size is a whole number of pixels. | |
| 68 | expect(tools(360).o.font(30)).toBe("21px system-ui, sans-serif"); | |
| 69 | expect(tools(360).o.font(19)).toBe("13px system-ui, sans-serif"); | |
| 70 | // The size carries no canvas of its own: asking twice asks nothing of the paint. | |
| 71 | const { paint, o } = tools(); | |
| 72 | o.font(15); | |
| 73 | expect(paint.calls).toEqual([]); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a panel is a filled rectangle, in the colour it was given or the neutral one", () => { | |
| 77 | const { paint, o } = tools(); | |
| 78 | o.panel(10, 20, 300, 40); | |
| 79 | expect(paint.fillStyle).toBe("rgba(12, 14, 18, 0.55)"); | |
| 80 | o.panel(10, 20, 300, 40, "#123"); | |
| 81 | expect(paint.fillStyle).toBe("#123"); | |
| 82 | expect(rects(paint)).toEqual([ | |
| 83 | ["fillRect", 10, 20, 300, 40], | |
| 84 | ["fillRect", 10, 20, 300, 40], | |
| 85 | ]); | |
| 86 | }); | |
| 87 | ||
| 88 | test("text draws once, where it was put, in the alignment it was asked for", () => { | |
| 89 | const { paint, o } = tools(); | |
| 90 | o.text("score", 40, 60); | |
| 91 | expect(paint.textAlign).toBe("left"); | |
| 92 | o.text("0:42", 640, 30, { align: "center", font: "30px system-ui, sans-serif", fill: "#fff" }); | |
| 93 | expect(paint.textAlign).toBe("center"); | |
| 94 | expect(paint.font).toBe("30px system-ui, sans-serif"); | |
| 95 | expect(paint.fillStyle).toBe("#fff"); | |
| 96 | expect(paint.calls.filter((c) => c[0] === "fillText")).toEqual([ | |
| 97 | ["fillText", "score", 40, 60], | |
| 98 | ["fillText", "0:42", 640, 30], | |
| 99 | ]); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a faded line leaves the context solid, even when it draws nothing", () => { | |
| 103 | const { paint, o } = tools(); | |
| 104 | o.text("fading", 10, 10, { alpha: 0.25 }); | |
| 105 | expect(paint.calls.filter((c) => c[0] === "fillText").length).toBe(1); | |
| 106 | expect(paint.globalAlpha).toBe(1); | |
| 107 | ||
| 108 | // Gone entirely: nothing is drawn, and the next line is still drawn solid. | |
| 109 | o.text("gone", 10, 30, { alpha: 0 }); | |
| 110 | expect(paint.texts).toEqual(["fading"]); | |
| 111 | expect(paint.globalAlpha).toBe(1); | |
| 112 | // And an alpha from outside 0..1 is clamped rather than handed on. | |
| 113 | o.text("over", 10, 50, { alpha: 4 }); | |
| 114 | o.text("under", 10, 70, { alpha: -1 }); | |
| 115 | expect(paint.texts).toEqual(["fading", "over"]); | |
| 116 | expect(paint.globalAlpha).toBe(1); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the crosshair strokes four arms, each between the gap and the gap plus the arm", () => { | |
| 120 | const { paint, o } = tools(); | |
| 121 | o.crosshair(200, 100, 5, 7, "#8ea"); | |
| 122 | expect(paint.strokeStyle).toBe("#8ea"); | |
| 123 | expect(paint.calls.filter((c) => c[0] === "beginPath").length).toBe(1); | |
| 124 | expect(paint.calls.filter((c) => c[0] === "stroke").length).toBe(1); | |
| 125 | expect(paint.points).toEqual([ | |
| 126 | ["moveTo", 212, 100], ["lineTo", 205, 100], | |
| 127 | ["moveTo", 188, 100], ["lineTo", 195, 100], | |
| 128 | ["moveTo", 200, 112], ["lineTo", 200, 105], | |
| 129 | ["moveTo", 200, 88], ["lineTo", 200, 95], | |
| 130 | ]); | |
| 131 | }); | |
| 132 | ||
| 133 | test("no gap is a plain cross: the four arms meet at the centre", () => { | |
| 134 | const { paint, o } = tools(); | |
| 135 | o.crosshair(100, 100, 0, 10, "#fff"); | |
| 136 | expect(paint.points.filter((c) => c[1] === 100 && c[2] === 100).length).toBe(4); | |
| 137 | expect(paint.points).toContainEqual(["moveTo", 100, 90]); | |
| 138 | expect(paint.points).toContainEqual(["moveTo", 110, 100]); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a bar fills the fraction of its width, and only ever a fraction of it", () => { | |
| 142 | const { paint, o } = tools(); | |
| 143 | o.bar(10, 20, 200, 8, 0.25, "#6fa8e8"); | |
| 144 | expect(paint.fillStyle).toBe("#6fa8e8"); | |
| 145 | o.bar(10, 20, 200, 8, 1.4, "#fff"); | |
| 146 | o.bar(10, 20, 200, 8, -1, "#fff"); | |
| 147 | o.bar(10, 20, 200, 8, 1, "#fff"); | |
| 148 | expect(rects(paint)).toEqual([ | |
| 149 | ["fillRect", 10, 20, 50, 8], | |
| 150 | ["fillRect", 10, 20, 200, 8], | |
| 151 | ["fillRect", 10, 20, 0, 8], | |
| 152 | ["fillRect", 10, 20, 200, 8], | |
| 153 | ]); | |
| 154 | }); | |
| 155 | ||
| 156 | test("columns walk leftwards from their right edge, one stride at a time", () => { | |
| 157 | const { paint, o } = tools(); | |
| 158 | o.columns([], ["12", "3", "$4000"], { x: 600, y: 200 }, 90); | |
| 159 | expect(paint.textAlign).toBe("right"); | |
| 160 | expect(paint.calls.filter((c) => c[0] === "fillText")).toEqual([ | |
| 161 | ["fillText", "12", 600, 200], | |
| 162 | ["fillText", "3", 510, 200], | |
| 163 | ["fillText", "$4000", 420, 200], | |
| 164 | ]); | |
| 165 | }); | |
| 166 | ||
| 167 | test("a label rides above its column, by the lead it was given or the scaled one", () => { | |
| 168 | const { paint, o } = tools(); | |
| 169 | o.columns(["kills", ""], ["12", "3"], { x: 600, y: 200 }, 90); | |
| 170 | // 18 at this width, and the empty label is no label at all. | |
| 171 | expect(paint.calls.filter((c) => c[0] === "fillText")).toEqual([ | |
| 172 | ["fillText", "kills", 600, 182], | |
| 173 | ["fillText", "12", 600, 200], | |
| 174 | ["fillText", "3", 510, 200], | |
| 175 | ]); | |
| 176 | ||
| 177 | const said = tools(); | |
| 178 | said.o.columns(["hp"], ["100"], { x: 300, y: 400, lead: 40 }, 60); | |
| 179 | expect(said.paint.calls.filter((c) => c[0] === "fillText")).toEqual([ | |
| 180 | ["fillText", "hp", 300, 360], | |
| 181 | ["fillText", "100", 300, 400], | |
| 182 | ]); | |
| 183 | }); | |
| 184 | ||
| 185 | test("the clock counts down in m:ss and stops at zero", () => { | |
| 186 | const { o } = tools(); | |
| 187 | expect(o.clock(115)).toBe("1:55"); | |
| 188 | expect(o.clock(0)).toBe("0:00"); | |
| 189 | expect(o.clock(-1)).toBe("0:00"); | |
| 190 | expect(o.clock(600)).toBe("10:00"); | |
| 191 | // A countdown rounds up: 0.1 s left is still a second on the clock. | |
| 192 | expect(o.clock(0.1)).toBe("0:01"); | |
| 193 | expect(o.clock(59.9)).toBe("1:00"); | |
| 194 | expect(o.clock(Number.NaN)).toBe("0:00"); | |
| 195 | }); | |
| 196 | ||
| 197 | test("the tools are built once and the size they were built at is on them", () => { | |
| 198 | const { o } = tools(960, 540); | |
| 199 | expect([o.width, o.height]).toEqual([960, 540]); | |
| 200 | // Nothing here reads the paint on the way in: building the tools draws nothing. | |
| 201 | const { paint } = tools(960, 540); | |
| 202 | expect(paint.calls).toEqual([]); | |
| 203 | }); |
src/engine/overlay.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** | |
| 2 | * The overlay: the 2D a game draws over its scene. Every game has the same | |
| 3 | * furniture — a scrim behind some numbers, a line of text somewhere with an | |
| 4 | * alignment and a fade, a crosshair, a progress bar, a column of figures, a | |
| 5 | * clock that reads `m:ss` — and none of it knows what game it is on. The two | |
| 6 | * here had a copy each; this is the copy they share. | |
| 7 | * | |
| 8 | * What it draws onto is a `Paint`: the members of a 2D context these tools | |
| 9 | * actually touch, and not a whole `CanvasRenderingContext2D`. A browser's | |
| 10 | * context is one, and so is a recorder in a test, which is how every test of | |
| 11 | * this file runs with no canvas in the room. The styles are `unknown` rather | |
| 12 | * than `string` because a real context's `fillStyle` is a string *or* a | |
| 13 | * gradient *or* a pattern, and a narrower type here would force a cast there. | |
| 14 | * | |
| 15 | * `overlay(paint, width, height)` is built once — on a resize, not in a frame — | |
| 16 | * and the tools it hands back allocate nothing per call. It holds no colour a | |
| 17 | * game chose beyond one neutral scrim, and no word from either game. | |
| 18 | */ | |
| 19 | ||
| 20 | import { clamp } from "./math.ts"; | |
| 21 | ||
| 22 | /** Everything these tools ask of the thing they draw on. Nothing else. */ | |
| 23 | export type Paint = { | |
| 24 | fillStyle: unknown; | |
| 25 | strokeStyle: unknown; | |
| 26 | lineWidth: number; | |
| 27 | font: unknown; | |
| 28 | textAlign: unknown; | |
| 29 | textBaseline: unknown; | |
| 30 | globalAlpha: number; | |
| 31 | clearRect(x: number, y: number, width: number, height: number): void; | |
| 32 | fillRect(x: number, y: number, width: number, height: number): void; | |
| 33 | fillText(text: string, x: number, y: number): void; | |
| 34 | beginPath(): void; | |
| 35 | moveTo(x: number, y: number): void; | |
| 36 | lineTo(x: number, y: number): void; | |
| 37 | rect(x: number, y: number, width: number, height: number): void; | |
| 38 | arc(x: number, y: number, radius: number, start: number, end: number, counterclockwise?: boolean): void; | |
| 39 | fill(rule?: string): void; | |
| 40 | stroke(): void; | |
| 41 | }; | |
| 42 | ||
| 43 | /** The canvas width one unit of `scale` is a pixel at. */ | |
| 44 | const REFERENCE = 1280; | |
| 45 | ||
| 46 | // The clamp is `src/client/hud.ts`'s, to the number: `clamp(width / 1280, 0.7, | |
| 47 | // 1.4)`. When the HUD moves onto these tools it is a move and not a retune. | |
| 48 | const MIN_SCALE = 0.7; | |
| 49 | const MAX_SCALE = 1.4; | |
| 50 | ||
| 51 | /** The margin: a fiftieth of the width, and never tighter than a thumb likes. */ | |
| 52 | const PAD_FRACTION = 0.02; | |
| 53 | const MIN_PAD = 12; | |
| 54 | ||
| 55 | /** The one family the scaled fonts are built in. A game that wants another writes it out. */ | |
| 56 | const FAMILY = "system-ui, sans-serif"; | |
| 57 | ||
| 58 | /** The scrim a panel is drawn in when the caller does not name one. */ | |
| 59 | const SCRIM = "rgba(12, 14, 18, 0.55)"; | |
| 60 | ||
| 61 | /** How far a column's label sits above its value, before the scale. */ | |
| 62 | const LEAD = 18; | |
| 63 | ||
| 64 | /** The four ways a crosshair arm points, made once rather than once a frame. */ | |
| 65 | const ARMS: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]]; | |
| 66 | ||
| 67 | /** What `text` is given when it is given nothing, so the default allocates nothing. */ | |
| 68 | const PLAIN: TextOptions = {}; | |
| 69 | ||
| 70 | /** How one string is drawn: anything left out is whatever the context already had. */ | |
| 71 | export type TextOptions = { | |
| 72 | /** `"left"`, `"center"` or `"right"`; left when it is not said. */ | |
| 73 | align?: string; | |
| 74 | /** A whole font string — `Overlay.font` builds the scaled ones. */ | |
| 75 | font?: string; | |
| 76 | fill?: string; | |
| 77 | /** 0..1. Zero draws nothing, and either way the context is left at 1. */ | |
| 78 | alpha?: number; | |
| 79 | }; | |
| 80 | ||
| 81 | /** Where a row of columns starts: its right edge, its baseline, and the lift of a label. */ | |
| 82 | export type ColumnsAt = { x: number; y: number; lead?: number }; | |
| 83 | ||
| 84 | /** The tools, bound to one paint surface and one size. */ | |
| 85 | export type Overlay = { | |
| 86 | readonly width: number; | |
| 87 | readonly height: number; | |
| 88 | /** `width / 1280`, clamped to 0.7..1.4: the number every size here is scaled by. */ | |
| 89 | readonly scale: number; | |
| 90 | /** The margin from the edge of the canvas that everything is laid out inside. */ | |
| 91 | readonly pad: number; | |
| 92 | /** `font(30)` is `"30px system-ui, sans-serif"` on a 1280-wide canvas, and less on a phone. */ | |
| 93 | font(px: number): string; | |
| 94 | /** A scrim behind something else: a filled rectangle, in `fill` or the neutral one. */ | |
| 95 | panel(x: number, y: number, w: number, h: number, fill?: string): void; | |
| 96 | /** One string, and `globalAlpha` back at 1 whatever it was drawn at. */ | |
| 97 | text(s: string, x: number, y: number, options?: TextOptions): void; | |
| 98 | /** | |
| 99 | * Four arms around a centre, each stroked from its outer end in to the gap, | |
| 100 | * so a gap of zero is a plain cross and a wide one is an open sight. | |
| 101 | */ | |
| 102 | crosshair(cx: number, cy: number, gap: number, arm: number, color: string): void; | |
| 103 | /** The filled part of a bar: `fraction` of `w`, with the fraction clamped to 0..1. */ | |
| 104 | bar(x: number, y: number, w: number, h: number, fraction: number, fill: string): void; | |
| 105 | /** | |
| 106 | * A row of right-aligned columns walking leftwards from `at.x`, one every | |
| 107 | * `stride`: the value on the baseline `at.y`, and the label — where there is | |
| 108 | * one at that index — a lead above it. The font and the fill are whatever the | |
| 109 | * caller last set, because a row is one colour and one size. | |
| 110 | */ | |
| 111 | columns(labels: readonly string[], values: readonly string[], at: ColumnsAt, stride: number): void; | |
| 112 | /** Seconds as `m:ss`, rounded up the way a countdown is read. Never below `0:00`. */ | |
| 113 | clock(seconds: number): string; | |
| 114 | }; | |
| 115 | ||
| 116 | export function overlay(paint: Paint, width: number, height: number): Overlay { | |
| 117 | const scale = clamp(width / REFERENCE, MIN_SCALE, MAX_SCALE); | |
| 118 | const pad = Math.max(MIN_PAD, Math.round(width * PAD_FRACTION)); | |
| 119 | const lead = Math.round(LEAD * scale); | |
| 120 | ||
| 121 | return { | |
| 122 | width, | |
| 123 | height, | |
| 124 | scale, | |
| 125 | pad, | |
| 126 | font: (px) => `${Math.round(px * scale)}px ${FAMILY}`, | |
| 127 | panel(x, y, w, h, fill = SCRIM) { | |
| 128 | paint.fillStyle = fill; | |
| 129 | paint.fillRect(x, y, w, h); | |
| 130 | }, | |
| 131 | text(s, x, y, options = PLAIN) { | |
| 132 | if (options.font !== undefined) paint.font = options.font; | |
| 133 | if (options.fill !== undefined) paint.fillStyle = options.fill; | |
| 134 | paint.textAlign = options.align ?? "left"; | |
| 135 | const alpha = options.alpha === undefined ? 1 : clamp(options.alpha, 0, 1); | |
| 136 | if (alpha > 0) { | |
| 137 | paint.globalAlpha = alpha; | |
| 138 | paint.fillText(s, x, y); | |
| 139 | } | |
| 140 | // Whatever happened above, the next thing drawn is drawn solid: a faded | |
| 141 | // line that leaves the context faded is a bug in every later line. | |
| 142 | paint.globalAlpha = 1; | |
| 143 | }, | |
| 144 | crosshair(cx, cy, gap, arm, color) { | |
| 145 | paint.strokeStyle = color; | |
| 146 | paint.lineWidth = 2; | |
| 147 | paint.beginPath(); | |
| 148 | for (const [dx, dy] of ARMS) { | |
| 149 | paint.moveTo(cx + dx * (gap + arm), cy + dy * (gap + arm)); | |
| 150 | paint.lineTo(cx + dx * gap, cy + dy * gap); | |
| 151 | } | |
| 152 | paint.stroke(); | |
| 153 | }, | |
| 154 | bar(x, y, w, h, fraction, fill) { | |
| 155 | paint.fillStyle = fill; | |
| 156 | paint.fillRect(x, y, w * clamp(fraction, 0, 1), h); | |
| 157 | }, | |
| 158 | columns(labels, values, at, stride) { | |
| 159 | const above = at.lead ?? lead; | |
| 160 | paint.textAlign = "right"; | |
| 161 | const count = Math.max(labels.length, values.length); | |
| 162 | for (let i = 0; i < count; i++) { | |
| 163 | const x = at.x - i * stride; | |
| 164 | const label = labels[i]; | |
| 165 | const value = values[i]; | |
| 166 | if (label !== undefined && label !== "") paint.fillText(label, x, at.y - above); | |
| 167 | if (value !== undefined) paint.fillText(value, x, at.y); | |
| 168 | } | |
| 169 | }, | |
| 170 | clock(seconds) { | |
| 171 | const s = Number.isFinite(seconds) ? Math.max(0, Math.ceil(seconds)) : 0; | |
| 172 | return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; | |
| 173 | }, | |
| 174 | }; | |
| 175 | } |
src/range/readout.ts +23 −35
| @@ -8,27 +8,18 @@ | ||
| 8 | 8 | * |
| 9 | 9 | * It takes the calls it makes as a parameter, typed by exactly those calls, so |
| 10 | 10 | * a stub records them in a test and a real `CanvasRenderingContext2D` fits |
| 11 | * without a cast. It reads a `Readout` and draws; it changes nothing. | |
| 11 | * without a cast. That type is the engine's `Paint` now, and the panel is drawn | |
| 12 | * with the engine's `overlay` — the scrim, the lines and the crosshair are the | |
| 13 | * furniture every game has, and the range is the proof a second game gets them | |
| 14 | * without copying a line out of the shooter. It reads a `Readout` and draws; it | |
| 15 | * changes nothing. | |
| 12 | 16 | */ |
| 13 | 17 | |
| 14 | import { angleDelta, clamp, deg, type Vec3 } from "../engine/index.ts"; | |
| 18 | import { angleDelta, clamp, deg, overlay, type Paint, type Vec3 } from "../engine/index.ts"; | |
| 15 | 19 | import { EYE, type Report, type Target } from "./targets.ts"; |
| 16 | 20 | |
| 17 | /** The 2D calls this module makes, and nothing else. */ | |
| 18 | export type Paint = { | |
| 19 | fillStyle: unknown; | |
| 20 | strokeStyle: unknown; | |
| 21 | lineWidth: number; | |
| 22 | font: unknown; | |
| 23 | textBaseline: unknown; | |
| 24 | clearRect(x: number, y: number, width: number, height: number): void; | |
| 25 | fillRect(x: number, y: number, width: number, height: number): void; | |
| 26 | fillText(text: string, x: number, y: number): void; | |
| 27 | beginPath(): void; | |
| 28 | moveTo(x: number, y: number): void; | |
| 29 | lineTo(x: number, y: number): void; | |
| 30 | stroke(): void; | |
| 31 | }; | |
| 21 | /** The 2D calls this module makes: the engine's surface, re-exported so `main.ts` can name it. */ | |
| 22 | export type { Paint }; | |
| 32 | 23 | |
| 33 | 24 | /** Where the view points. Degrees, the engine's way round: pitch is positive down. */ |
| 34 | 25 | export type View = { yaw: number; pitch: number }; |
| @@ -194,30 +185,27 @@ export function drawHorizon( | ||
| 194 | 185 | export function drawReadout(paint: Paint, r: Readout, width: number, height: number): void { |
| 195 | 186 | paint.clearRect(0, 0, width, height); |
| 196 | 187 | |
| 197 | const pad = Math.max(10, Math.round(width * 0.02)); | |
| 188 | // The tools are built here rather than kept, because this is the one place | |
| 189 | // the range knows its size: a frame is a few dozen calls, not a few thousand. | |
| 190 | const o = overlay(paint, width, height); | |
| 191 | // The engine's margin: a fiftieth of the width, as this always was, and never | |
| 192 | // under 12 px rather than under 10 — which is the same number above 600 px | |
| 193 | // wide and two pixels kinder to a phone below it. | |
| 194 | const pad = o.pad; | |
| 195 | // The panel is monospaced, which `Overlay.font` does not build: a column of | |
| 196 | // numbers that jitters as it counts is worse than a font nobody scaled. | |
| 198 | 197 | const size = clamp(Math.round(width / 46), 12, 20); |
| 198 | const font = `${size}px ui-monospace, SFMono-Regular, Menlo, monospace`; | |
| 199 | 199 | const step = Math.round(size * 1.6); |
| 200 | 200 | const lines = readoutLines(r); |
| 201 | 201 | |
| 202 | paint.fillStyle = "rgba(11,13,17,.72)"; | |
| 203 | paint.fillRect(pad, pad, width - pad * 2, step * lines.length + pad); | |
| 204 | paint.font = `${size}px ui-monospace, SFMono-Regular, Menlo, monospace`; | |
| 202 | o.panel(pad, pad, width - pad * 2, step * lines.length + pad, "rgba(11,13,17,.72)"); | |
| 205 | 203 | paint.textBaseline = "top"; |
| 206 | 204 | lines.forEach((line, i) => { |
| 207 | paint.fillStyle = i === 0 ? TEXT : DIM; | |
| 208 | paint.fillText(line, pad * 1.6, pad * 1.4 + i * step); | |
| 205 | o.text(line, pad * 1.6, pad * 1.4 + i * step, { font, fill: i === 0 ? TEXT : DIM }); | |
| 209 | 206 | }); |
| 210 | 207 | |
| 211 | // The crosshair marks the centre the angles are measured from. | |
| 212 | const cx = Math.round(width / 2); | |
| 213 | const cy = Math.round(height / 2); | |
| 214 | const arm = Math.max(6, Math.round(width / 90)); | |
| 215 | paint.strokeStyle = "#8ea"; | |
| 216 | paint.lineWidth = 2; | |
| 217 | paint.beginPath(); | |
| 218 | paint.moveTo(cx - arm, cy); | |
| 219 | paint.lineTo(cx + arm, cy); | |
| 220 | paint.moveTo(cx, cy - arm); | |
| 221 | paint.lineTo(cx, cy + arm); | |
| 222 | paint.stroke(); | |
| 208 | // The crosshair marks the centre the angles are measured from: no gap, so the | |
| 209 | // four arms meet and it reads as the plain cross it always was. | |
| 210 | o.crosshair(Math.round(width / 2), Math.round(height / 2), 0, Math.max(6, Math.round(width / 90)), "#8ea"); | |
| 223 | 211 | } |
static/app.de0b8ba773.js → static/app.e528bd2a25.js renamed +2 −2
Diff not shown — 136,324 bytes of diff (limit 100,000). +2 −2
View filestatic/range.422be53cf7.js deleted −1
Diff not shown — a line 11,560 characters wide (limit 2,000). −1
View filestatic/range.8fd26bf773.js added +1
Diff not shown — a line 12,533 characters wide (limit 2,000). +1
View file
new above
offline