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

Merged opened this

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

Merged

vesper/cs-engine:vesper/49-the-overlay-becomes-engine-2d-drawing-to → main 1 commit fork

  • Merged. vesper/49-the-overlay-becomes-engine-2d-drawing-to was merged into main as 154a043.

Review

Sign in to review this change.
11 files changed +453 −46
README.md +11 −4
@@ -108,7 +108,11 @@ from the angle it subtends, and you turn on the spot.
108108 knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture`
109109 (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext`
110110 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).
112116 Three rules keep it a package, and a test holds each: the engine imports
113117 nothing outside itself, it needs nothing from a browser to load, and
114118 everything outside it comes in through one door.
@@ -137,9 +141,10 @@ from the angle it subtends, and you turn on the spot.
137141 `Grid`; from `mesh` `builder`, `addBox`, `finish`, `quadMesh`, `Builder` and
138142 `Mesh`; from `texture` `hash`, `noise`, `fbm`, `paintAtlas` and `Shader`; from
139143 `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
141145 `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
143148 module that is not behind the door, and any change to that list of names that
144149 is not also a change to the list written out in the test. One exception, and
145150 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.
194199 score and the accuracy in, all plain data in and plain data out with no canvas
195200 anywhere near it; `readout.ts` draws a horizon, the plates on it as blips
196201 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`
198205 wires the engine's `loop` and `controls` to the shell's ids, steps the run in
199206 whole ticks and fires one shot per press along the engine's `angleVectors`.
200207 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 };
2828 * The ceilings are today's sizes plus about 10%, rounded up to a round number:
2929 * enough headroom that an honest feature lands without a ceiling edit, tight
3030 * 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.
3237 * The app grew 1,254 bytes for #47, which moved every decision about a frame
3338 * into `src/client/scene.ts`; it fits under today's ceiling, so no ceiling moved
3439 * 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
5252 ```ts
5353 // src/client/main.ts
5454 const controls = createControls(
55 { surface: scene, window: win, document: doc },
55 { surface: sceneCanvas, window: win, document: doc },
5656 { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 },
5757 );
5858 ```
@@ -248,6 +248,31 @@ const controls = createControls(
248248 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP });
249249 ```
250250
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
251276 ## The seam, named as pairs
252277
253278 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", () => {
222222 export const SURFACE = [
223223 "Box", "Builder", "Burst", "Button", "ButtonOptions", "Controls", "Ctor", "Frames", "Grid",
224224 "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",
226226 "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors",
227227 "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf", "createControls",
228228 "createLoop", "createSoundDevice", "cross", "deg", "dist", "dist2d", "dot", "fbm",
229229 "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",
231231 "quadMesh", "rad", "rayBox", "rng", "rowOf", "scale", "segmentLength", "sub", "traceWorld",
232232 "v3", "viewMatrix", "visible", "worldOf",
233233 ];
src/engine/index.ts +4
@@ -48,3 +48,7 @@ export type {
4848 Button, ButtonOptions, Controls, Host, KeyEventish, Listens, LockOwner, MouseEventish,
4949 Options, Stick, Surface, TouchEventish, Touchish,
5050 } 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 @@
88 *
99 * It takes the calls it makes as a parameter, typed by exactly those calls, so
1010 * 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.
1216 */
1317
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";
1519 import { EYE, type Report, type Target } from "./targets.ts";
1620
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 };
3223
3324 /** Where the view points. Degrees, the engine's way round: pitch is positive down. */
3425 export type View = { yaw: number; pitch: number };
@@ -194,30 +185,27 @@ export function drawHorizon(
194185 export function drawReadout(paint: Paint, r: Readout, width: number, height: number): void {
195186 paint.clearRect(0, 0, width, height);
196187
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.
198197 const size = clamp(Math.round(width / 46), 12, 20);
198 const font = `${size}px ui-monospace, SFMono-Regular, Menlo, monospace`;
199199 const step = Math.round(size * 1.6);
200200 const lines = readoutLines(r);
201201
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)");
205203 paint.textBaseline = "top";
206204 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 });
209206 });
210207
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");
223211 }
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 file
static/range.422be53cf7.js deleted −1

Diff not shown — a line 11,560 characters wide (limit 2,000). −1

View file
static/range.8fd26bf773.js added +1

Diff not shown — a line 12,533 characters wide (limit 2,000). +1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs