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

forked from trevato/cs · proposes

The readout becomes engine: a 2D overlay the HUD draws through, and a second game can too #158

Merged opened this

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

Merged

vesper/cs-engine:vesper/18-the-readout-becomes-engine-a-2d-overlay → main 2 commits fork

  • Merged. vesper/18-the-readout-becomes-engine-a-2d-overlay was merged into main as 4e6895a.

Review

Sign in to review this change.
6 files changed +234 −220
README.md +10 −3
@@ -203,7 +203,8 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
203203 2D over the scene: a scale and a pad off the canvas width, scaled fonts,
204204 panels, aligned and faded text, a crosshair, a bar, right-aligned columns and
205205 a `m:ss` clock, drawn onto the seventeen members of a 2D context it uses and
206 holding no colour a game chose), `scene` (a frame as plain data and the pool
206 holding no colour a game chose — the shooter's HUD, the range's panel and the
207 example's whole screen are drawn with it), `scene` (a frame as plain data and the pool
207208 it is kept in: `createScene` builds both buffers whole at the two capacities a
208209 game picks, `resetScene` puts the counts back at the top of a frame,
209210 `nextInstance` hands out the next pooled instance and `pushLine` writes one
@@ -306,8 +307,14 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
306307 (the rules),
307308 `world` (the simulation),
308309 `bots`, `observe`, `action`, `weights`, `models`, `materials`,
309 `binding`, `scene`, `hud`, `spectate` (whose eyes a frame is drawn from once
310 you are dead, and the camera that eases onto them), `audio`, `main`. Everything in here is headless and
310 `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round
311 clock, the kill feed and the scoreboard — every one of them drawn through the
312 engine's `overlay` since #18, so what is left in the file is this game's
313 layout, its team colours, which phases have a clock and its words, and the
314 scale, the scrim, the fonts, the fade, the bar, the columns and the `m:ss` are
315 the same code the range's panel is drawn with), `spectate` (whose eyes a frame
316 is drawn from once you are dead, and the camera that eases onto them),
317 `audio`, `main`. Everything in here is headless and
311318 tested: the WebGL2 is the engine's now, and this game has no renderer of its
312319 own. The level is handed in rather than imported: `createWorld(level, …)`
313320 keeps it on the world and the bots, the bullets, the bomb and the meshes read
src/client/hud.test.ts +46 −16
@@ -1,6 +1,6 @@
11 /**
2 * This test exists so #18 can move the HUD onto the engine's overlay and prove
3 * it drew the same thing.
2 * This test exists so #18 could move the HUD onto the engine's overlay and
3 * prove it drew the same thing. It did, and this is what says it still does.
44 *
55 * It says what a player reads off the screen — the number that goes red, the
66 * clock that stops, the crosshair that opens, the feed that fades — and not how
@@ -9,14 +9,13 @@
99 * not a change a player sees, and a pin that fails on one is a pin someone
1010 * deletes rather than uses.
1111 *
12 * The `Paint` it draws onto is the HUD's own, not the engine's: `hud.ts` types
13 * its styles `string` where `src/engine/overlay.ts` types them `unknown`, so a
14 * recorder declared as the engine's cannot be handed to `drawHud` today. A
15 * recorder declared as the HUD's *can* be handed to `overlay()`, which is what
16 * the move needs. The recorder is this file's own because it writes down the
17 * brush as well as the call — the colour a line was drawn in is half of what is
18 * pinned here, and neither of the two recorders already in the repository
19 * (`stage.test-helper.ts`, `overlay.test.ts`) keeps it.
12 * The `Paint` it draws onto is the HUD's — the engine's surface with the styles
13 * narrowed to the strings this game writes into them, which is why a recorder
14 * declared as it hands a `string` back when the brush is read. The recorder is
15 * this file's own because it writes down the brush as well as the call: the
16 * colour a line was drawn in is half of what is pinned here, and neither of the
17 * two recorders already in the repository (`stage.test-helper.ts`,
18 * `overlay.test.ts`) keeps it.
2019 *
2120 * No canvas, no document, no browser.
2221 */
@@ -131,11 +130,22 @@ function line(paint: Recorder, text: string): Call {
131130
132131 // --- the crosshair ---------------------------------------------------------
133132
133 /**
134 * The crosshair's first arm, as how far each of its two ends is from the middle
135 * of the screen. Which end is stroked first is not something anybody can see —
136 * the engine's `crosshair` draws an arm outer end inwards, the HUD used to draw
137 * it inner end out, and it is the same segment — so this reads near and far
138 * rather than first and second.
139 */
140 function armOf(paint: Recorder, width = WIDTH): { near: number; far: number } {
141 const start = paint.calls.findIndex((c) => c.op === "moveTo");
142 const ends = [paint.calls[start], paint.calls[start + 1]]
143 .map((c) => Math.abs(Number(c?.args[0]) - width / 2));
144 return { near: Math.min(...ends), far: Math.max(...ends) };
145 }
146
134147 /** How far the crosshair's first arm starts from the middle of the screen. */
135 const gapOf = (paint: Recorder, width = WIDTH): number => {
136 const first = paint.calls.find((c) => c.op === "moveTo");
137 return Number(first?.args[0]) - width / 2;
138 };
148 const gapOf = (paint: Recorder, width = WIDTH): number => armOf(paint, width).near;
139149
140150 const spreadOf = (world: World, moving: number): number =>
141151 inaccuracy(currentWeapon(world.me), {
@@ -151,8 +161,7 @@ test("the crosshair opens exactly as far as the gun is inaccurate, and is gone w
151161 // At a standstill it is the gun's own spread and nothing else.
152162 expect(gapOf(rest)).toBeCloseTo(4 + spreadOf(still, 0) * 14, 10);
153163 // Each arm is seven pixels long, from the gap outwards.
154 const arm = rest.calls.find((c) => c.op === "lineTo");
155 expect(Number(arm?.args[0]) - WIDTH / 2).toBeCloseTo(gapOf(rest) + 7, 10);
164 expect(armOf(rest).far).toBeCloseTo(gapOf(rest) + 7, 10);
156165
157166 // Running wide open: any speed at or over the walk cap counts as fully moving.
158167 const running = scene((w) => { w.me.vel = [1000, 0, 0]; });
@@ -561,3 +570,24 @@ test("the whole readout fits a phone and a desk, and says the same amount at bot
561570 expect(phone.calls[0]?.op).toBe("clearRect");
562571 expect(phone.calls[0]?.args).toEqual([0, 0, 360, 640]);
563572 });
573
574 test("the same canvas turned sideways is drawn at the new size, not the one before", () => {
575 // The engine's tools are built for one surface at one size and kept between
576 // frames, because a frame that rebuilt them would allocate for no new number.
577 // A phone rotating is the frame where a cache that ignored the size would
578 // draw the old one: same canvas, same world, different width.
579 const world = busyWorld();
580 const paint = recorder();
581 drawHud(paint, world, 1280, 720, false, world.me);
582 const before = paint.calls.length;
583 drawHud(paint, world, 360, 640, false, world.me);
584 const after = paint.calls.slice(before);
585
586 expect(after[0]?.args).toEqual([0, 0, 360, 640]);
587 expect([...new Set(after.filter((c) => c.op === "fillText").map((c) => c.font))].sort()).toEqual([
588 "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif",
589 ]);
590 // The clock bar is 240 units wide at the scale the *new* width says.
591 const bar = after.find((c) => c.op === "fillRect") as Call;
592 expect(bar.args[2]).toBe(Math.round(240 * 0.7));
593 });
src/client/hud.ts +110 −143
@@ -6,48 +6,70 @@
66 * What it draws onto is a `Paint` — the seventeen members of a 2D context it
77 * actually uses, and not a whole `CanvasRenderingContext2D`. A browser's
88 * context is one; so is a recorder in a test, which is how `main.test.ts` runs
9 * a frame of this with no canvas in the room. The same trick
10 * `src/range/readout.ts` plays with its own, smaller `Paint`.
9 * a frame of this with no canvas in the room. It is the engine's `Paint`, with
10 * the styles narrowed to the strings this game writes into them.
11 *
12 * The furniture is the engine's: `overlay` hands back the scale, the pad, the
13 * scaled fonts, the scrim, a line of text with an alignment and a fade, the
14 * crosshair, a progress bar, a row of right-aligned columns and the `m:ss`
15 * clock — the same tools `src/range/readout.ts` draws its panel with. What is
16 * left in this file is Counter-Strike's and nothing else: where a thing sits,
17 * what colour a team is, which phase has a clock, and the words.
1118 */
1219
13 import { clamp } from "../engine/index.ts";
20 import { clamp, overlay, type Overlay, type Paint as Surface } from "../engine/index.ts";
1421 import { CS_MOVE } from "./movement.ts";
1522 import { inaccuracy, WEAPONS } from "./weapons.ts";
1623 import { currentWeapon, useProgress, type Player, type World } from "./world.ts";
1724
18 /** Everything this module asks of the thing it draws on. Nothing else. */
19 export type Paint = {
25 /**
26 * Everything this module asks of the thing it draws on. Nothing else: it is the
27 * engine's surface, with `fillStyle` and the rest narrowed to `string`, because
28 * this game only ever writes colours into them and a test that records the
29 * brush wants a string back.
30 */
31 export type Paint = Surface & {
2032 fillStyle: string;
2133 strokeStyle: string;
22 lineWidth: number;
2334 font: string;
2435 textAlign: string;
2536 textBaseline: string;
26 globalAlpha: number;
27 clearRect(x: number, y: number, w: number, h: number): void;
28 fillRect(x: number, y: number, w: number, h: number): void;
29 fillText(text: string, x: number, y: number): void;
30 beginPath(): void;
31 moveTo(x: number, y: number): void;
32 lineTo(x: number, y: number): void;
33 rect(x: number, y: number, w: number, h: number): void;
34 arc(x: number, y: number, radius: number, start: number, end: number, counterclockwise?: boolean): void;
35 fill(rule?: string): void;
36 stroke(): void;
3737 };
3838
3939 const T_COLOR = "#e2b04a";
4040 const CT_COLOR = "#6fa8e8";
41 const WHITE = "#f3f5f8";
42 const RED = "#ff6b5b";
43 const BLUE = "#9fd0ff";
44 const GREY = "#c8ccd4";
45 const CROSSHAIR = "rgba(120, 255, 140, 0.9)";
4146
4247 /** What a dead player is told, in the two cases there are. */
4348 const DEAD = "You are dead — watching the round out";
4449 const WATCHING = "Watching";
4550 const SWITCH = "fire — next player · aim — the one before";
4651
47 const clock = (seconds: number): string => {
48 const s = Math.max(0, Math.ceil(seconds));
49 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
50 };
52 /** The scoreboard's heading row, and the empty other half of each `columns` call. */
53 const FIGURES = ["kills", "deaths", "hs", "damage", "money"] as const;
54 const NOTHING: readonly string[] = [];
55
56 /**
57 * The tools, kept from frame to frame. `overlay` is built for one surface at
58 * one size — a resize, not a frame — and a frame that rebuilt it would allocate
59 * an object and ten closures sixty times a second for no new number. The cache
60 * is one entry deep and keyed on what it was built from, so a test drawing onto
61 * a fresh recorder gets fresh tools and never another test's.
62 */
63 let tools: Overlay | null = null;
64 let builtOn: Paint | null = null;
65
66 function toolsFor(ctx: Paint, width: number, height: number): Overlay {
67 if (tools === null || builtOn !== ctx || tools.width !== width || tools.height !== height) {
68 tools = overlay(ctx, width, height);
69 builtOn = ctx;
70 }
71 return tools;
72 }
5173
5274 /**
5375 * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or
@@ -66,10 +88,11 @@ export function drawHud(
6688 showScoreboard: boolean,
6789 view: Player = world.me,
6890 ): void {
91 const ui = toolsFor(ctx, width, height);
6992 ctx.clearRect(0, 0, width, height);
7093 ctx.textBaseline = "alphabetic";
7194 const weapon = currentWeapon(view);
72 const pad = Math.max(12, Math.round(width * 0.02));
95 const pad = ui.pad;
7396
7497 // --- crosshair, opening up exactly as much as the gun is inaccurate
7598 if (view.alive) {
@@ -80,16 +103,7 @@ export function drawHud(
80103 ducking: view.duckAmount > 0.5,
81104 scoped: view.scoped,
82105 });
83 const gap = 4 + spread * 14;
84 const arm = 7;
85 ctx.strokeStyle = "rgba(120, 255, 140, 0.9)";
86 ctx.lineWidth = 2;
87 ctx.beginPath();
88 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
89 ctx.moveTo(width / 2 + dx * gap, height / 2 + dy * gap);
90 ctx.lineTo(width / 2 + dx * (gap + arm), height / 2 + dy * (gap + arm));
91 }
92 ctx.stroke();
106 ui.crosshair(width / 2, height / 2, 4 + spread * 14, 7, CROSSHAIR);
93107 if (view.scoped) {
94108 ctx.fillStyle = "rgba(0,0,0,0.55)";
95109 ctx.beginPath();
@@ -99,7 +113,8 @@ export function drawHud(
99113 }
100114 }
101115
102 // --- hit markers
116 // --- hit markers. Four arms like the crosshair's, but on the diagonals and
117 // growing as the mark ages, which is not a shape the engine hands out.
103118 for (const effect of world.effects) {
104119 if (effect.kind !== "hitmark") continue;
105120 const age = world.time - effect.at;
@@ -115,172 +130,124 @@ export function drawHud(
115130 ctx.stroke();
116131 }
117132
118 const scale = clamp(width / 1280, 0.7, 1.4);
119 const big = `${Math.round(30 * scale)}px system-ui, sans-serif`;
120 const mid = `${Math.round(19 * scale)}px system-ui, sans-serif`;
121 const small = `${Math.round(15 * scale)}px system-ui, sans-serif`;
122
123 const panel = (x: number, y: number, w: number, h: number): void => {
124 ctx.fillStyle = "rgba(12, 14, 18, 0.55)";
125 ctx.fillRect(x, y, w, h);
126 };
133 const scale = ui.scale;
134 const big = ui.font(30);
135 const mid = ui.font(19);
136 const small = ui.font(15);
127137
128138 // --- round clock and score
129139 const barW = Math.round(240 * scale);
130 panel(width / 2 - barW / 2, 0, barW, Math.round(44 * scale));
131 ctx.textAlign = "center";
132 ctx.font = big;
140 ui.panel(width / 2 - barW / 2, 0, barW, Math.round(44 * scale));
133141 const phase = world.match.phase;
134 const time = phase === "freeze" || phase === "live" || phase === "planted" ? clock(world.match.timer) : "--:--";
135 ctx.fillStyle = phase === "planted" ? "#ff6b5b" : "#f3f5f8";
136 ctx.fillText(time, width / 2, Math.round(32 * scale));
137 ctx.font = mid;
138 ctx.fillStyle = T_COLOR;
139 ctx.textAlign = "right";
140 ctx.fillText(String(world.match.score.t), width / 2 - barW / 2 - 8 * scale, Math.round(32 * scale));
141 ctx.fillStyle = CT_COLOR;
142 ctx.textAlign = "left";
143 ctx.fillText(String(world.match.score.ct), width / 2 + barW / 2 + 8 * scale, Math.round(32 * scale));
142 const timed = phase === "freeze" || phase === "live" || phase === "planted";
143 const clockY = Math.round(32 * scale);
144 ui.text(timed ? ui.clock(world.match.timer) : "--:--", width / 2, clockY, {
145 align: "center", font: big, fill: phase === "planted" ? RED : WHITE,
146 });
147 ui.text(String(world.match.score.t), width / 2 - barW / 2 - 8 * scale, clockY,
148 { align: "right", font: mid, fill: T_COLOR });
149 ui.text(String(world.match.score.ct), width / 2 + barW / 2 + 8 * scale, clockY,
150 { align: "left", font: mid, fill: CT_COLOR });
144151
145152 // --- health, armour, money
146 ctx.textAlign = "left";
147 ctx.font = big;
148 ctx.fillStyle = view.health > 30 ? "#f3f5f8" : "#ff6b5b";
149 ctx.fillText(String(Math.max(0, Math.round(view.health))), pad, height - pad);
150 ctx.font = small;
151 ctx.fillStyle = "#9fd0ff";
152 ctx.fillText(
153 `${view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour"}`,
153 ui.text(String(Math.max(0, Math.round(view.health))), pad, height - pad,
154 { align: "left", font: big, fill: view.health > 30 ? WHITE : RED });
155 ui.text(
156 view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour",
154157 pad, height - pad - Math.round(30 * scale),
158 { align: "left", font: small, fill: BLUE },
155159 );
156 ctx.fillStyle = "#7ddc8a";
157 ctx.font = mid;
158 ctx.fillText(`$${view.money}`, pad, pad + Math.round(18 * scale));
159 if (view.team === "ct" && view.defuseKit) {
160 ctx.fillStyle = "#9fd0ff";
161 ctx.font = small;
162 ctx.fillText("defuse kit", pad, pad + Math.round(38 * scale));
163 }
160 ui.text(`$${view.money}`, pad, pad + Math.round(18 * scale),
161 { align: "left", font: mid, fill: "#7ddc8a" });
162 if (view.team === "ct" && view.defuseKit)
163 ui.text("defuse kit", pad, pad + Math.round(38 * scale), { align: "left", font: small, fill: BLUE });
164164
165165 // --- ammo and weapon
166 ctx.textAlign = "right";
167 ctx.font = big;
168 ctx.fillStyle = "#f3f5f8";
169166 const ammo = view.ammo[weapon.id];
170167 const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
171 ctx.fillText(ammoText, width - pad, height - pad);
172 ctx.font = small;
173 ctx.fillStyle = "#c8ccd4";
174 ctx.fillText(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale));
175 if (world.time < view.reloadUntil) {
176 ctx.fillStyle = "#f0c060";
177 ctx.fillText("reloading", width - pad, height - pad - Math.round(50 * scale));
178 }
168 ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE });
169 ui.text(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale),
170 { align: "right", font: small, fill: GREY });
171 if (world.time < view.reloadUntil)
172 ui.text("reloading", width - pad, height - pad - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" });
179173
180174 // --- who is left
181 ctx.textAlign = "center";
182 ctx.font = small;
183175 const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length;
184176 const aliveCT = world.players.filter((p) => p.team === "ct" && p.alive).length;
185 ctx.fillStyle = T_COLOR;
186 ctx.fillText(`T ${aliveT}`, width / 2 - Math.round(60 * scale), Math.round(62 * scale));
187 ctx.fillStyle = CT_COLOR;
188 ctx.fillText(`CT ${aliveCT}`, width / 2 + Math.round(60 * scale), Math.round(62 * scale));
177 const leftY = Math.round(62 * scale);
178 ui.text(`T ${aliveT}`, width / 2 - Math.round(60 * scale), leftY, { align: "center", font: small, fill: T_COLOR });
179 ui.text(`CT ${aliveCT}`, width / 2 + Math.round(60 * scale), leftY, { align: "center", font: small, fill: CT_COLOR });
189180
190181 // --- kill feed
191 ctx.textAlign = "right";
192 ctx.font = small;
193182 world.feed.slice(-5).forEach((entry, i) => {
194183 const age = world.time - entry.at;
195184 if (age > 8) return;
196 const y = pad + Math.round((20 + i * 22) * scale);
197 ctx.globalAlpha = clamp(8 - age, 0, 1);
198 ctx.fillStyle = entry.killerTeam === "t" ? T_COLOR : CT_COLOR;
199185 const text = `${entry.killer} ${WEAPONS[entry.weapon].name}${entry.headshot ? " ⌖" : ""} ${entry.victim}`;
200 ctx.fillText(text, width - pad, y);
201 ctx.globalAlpha = 1;
186 ui.text(text, width - pad, pad + Math.round((20 + i * 22) * scale), {
187 align: "right", font: small, fill: entry.killerTeam === "t" ? T_COLOR : CT_COLOR, alpha: 8 - age,
188 });
202189 });
203190
204191 // --- what the round is saying
205192 const message = world.messages[world.messages.length - 1];
206 if (message !== undefined && world.time - message.at < 4) {
207 ctx.textAlign = "center";
208 ctx.font = mid;
209 ctx.fillStyle = "rgba(255,255,255,0.92)";
210 ctx.fillText(message.text, width / 2, height * 0.22);
211 }
193 if (message !== undefined && world.time - message.at < 4)
194 ui.text(message.text, width / 2, height * 0.22, { align: "center", font: mid, fill: "rgba(255,255,255,0.92)" });
212195
213196 // --- planting or defusing
214197 const progress = useProgress(world, view);
215198 if (progress !== null) {
216199 const w = Math.round(260 * scale), h = Math.round(14 * scale);
217200 const x = width / 2 - w / 2, y = height * 0.62;
218 panel(x - 2, y - 2, w + 4, h + 4);
219 ctx.fillStyle = progress.label.startsWith("Def") ? "#6fa8e8" : "#e2b04a";
220 ctx.fillRect(x, y, w * progress.fraction, h);
221 ctx.font = small;
222 ctx.fillStyle = "#fff";
223 ctx.textAlign = "center";
224 ctx.fillText(progress.label, width / 2, y - 8 * scale);
201 ui.panel(x - 2, y - 2, w + 4, h + 4);
202 ui.bar(x, y, w, h, progress.fraction, progress.label.startsWith("Def") ? CT_COLOR : T_COLOR);
203 ui.text(progress.label, width / 2, y - 8 * scale, { align: "center", font: small, fill: "#fff" });
225204 }
226205
227206 // --- dead: whose eyes this is, and how to move to the next pair
228207 if (!world.me.alive && world.match.phase !== "match_over") {
229 ctx.textAlign = "center";
230208 if (view === world.me) {
231209 // Nobody left to watch: your own body is the only camera in the round.
232 ctx.font = big;
233 ctx.fillStyle = "rgba(255,255,255,0.85)";
234 ctx.fillText(DEAD, width / 2, height * 0.4);
210 ui.text(DEAD, width / 2, height * 0.4, { align: "center", font: big, fill: "rgba(255,255,255,0.85)" });
235211 } else {
236212 // Above the bottom row, so it never lands on the health or the ammo —
237213 // at 360 pixels wide those are a thumb's width from the middle.
238214 const w = Math.round(420 * scale), h = Math.round(34 * scale);
239215 const y = height - pad - Math.round(64 * scale);
240 panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h);
241 ctx.font = mid;
242 ctx.fillStyle = view.team === "t" ? T_COLOR : CT_COLOR;
243 ctx.fillText(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale));
244 ctx.font = small;
245 ctx.fillStyle = "#c8ccd4";
246 ctx.fillText(SWITCH, width / 2, y);
216 ui.panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h);
217 ui.text(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale),
218 { align: "center", font: mid, fill: view.team === "t" ? T_COLOR : CT_COLOR });
219 ui.text(SWITCH, width / 2, y, { align: "center", font: small, fill: GREY });
247220 }
248221 }
249222
250 if (showScoreboard) drawScoreboard(ctx, world, width, height, scale);
223 if (showScoreboard) drawScoreboard(ctx, ui, world);
251224 }
252225
253 function drawScoreboard(
254 ctx: Paint,
255 world: World,
256 width: number,
257 height: number,
258 scale: number,
259 ): void {
226 function drawScoreboard(ctx: Paint, ui: Overlay, world: World): void {
227 const { width, height, scale } = ui;
260228 const w = Math.min(width - 40, Math.round(760 * scale));
261229 const rowH = Math.round(26 * scale);
262230 const h = rowH * (world.players.length + 4);
263231 const x = width / 2 - w / 2, y = height / 2 - h / 2;
264 ctx.fillStyle = "rgba(10, 12, 16, 0.88)";
265 ctx.fillRect(x, y, w, h);
266 ctx.font = `${Math.round(15 * scale)}px system-ui, sans-serif`;
267 ctx.textAlign = "left";
268 ctx.fillStyle = "#c8ccd4";
269 ctx.fillText("player", x + 16, y + rowH);
270 ctx.textAlign = "right";
271 for (const [i, label] of ["kills", "deaths", "hs", "damage", "money"].entries())
272 ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH);
232 const right = x + w - 16, stride = Math.round(90 * scale);
233 ui.panel(x, y, w, h, "rgba(10, 12, 16, 0.88)");
234 ui.text("player", x + 16, y + rowH, { align: "left", font: ui.font(15), fill: GREY });
235 // The headings sit on the row they head, so there is no lead over a value.
236 ui.columns(FIGURES, NOTHING, { x: right, y: y + rowH, lead: 0 }, stride);
273237
274238 const order = [...world.players].sort((a, b) => b.kills - a.kills || b.damage - a.damage);
275239 order.forEach((p, row) => {
276240 const ty = y + rowH * (row + 2.4);
277 ctx.textAlign = "left";
278 ctx.fillStyle = p === world.me ? "#ffffff" : p.team === "t" ? T_COLOR : CT_COLOR;
279 ctx.globalAlpha = p.alive ? 1 : 0.45;
280 ctx.fillText(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.bot ? "" : " (you)"}`, x + 16, ty);
281 ctx.textAlign = "right";
282 for (const [i, value] of [p.kills, p.deaths, p.headshots, Math.round(p.damage), `$${p.money}`].entries())
283 ctx.fillText(String(value), x + w - 16 - i * Math.round(90 * scale), ty);
241 // A dead player's whole row is dimmed — the name and the figures both — so
242 // the fade is set around them rather than passed to each line.
243 const dim = p.alive ? 1 : 0.45;
244 ui.text(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.bot ? "" : " (you)"}`, x + 16, ty, {
245 align: "left", fill: p === world.me ? "#ffffff" : p.team === "t" ? T_COLOR : CT_COLOR, alpha: dim,
246 });
247 ctx.globalAlpha = dim;
248 ui.columns(NOTHING, [
249 String(p.kills), String(p.deaths), String(p.headshots), String(Math.round(p.damage)), `$${p.money}`,
250 ], { x: right, y: ty }, stride);
284251 ctx.globalAlpha = 1;
285252 });
286253 }
src/games.ts +10
@@ -186,6 +186,16 @@ export type Game = {
186186 * The range's numbers here are #121's moving plates and #125's painter at once:
187187 * both landed a range bundle and neither swept the other's, so static/ held two
188188 * and every reader of it failed until a plain `bun run build` resolved them.
189 * #18 moved the shooter's HUD onto the engine's `overlay`, and the line above
190 * that said it "takes the same bytes back out of the shooter" was wrong: the
191 * app grew 483 raw / 498 gzipped, to 79,937 / 37,136. A tool set is bundled
192 * whole — the same thing that cost the range 973 bytes in #49 — so the shooter
193 * now ships `columns`, `clock` and the pad arithmetic it does use next to the
194 * closures it deleted, and what it deleted was small: a `panel` of two lines, a
195 * `clock` of two and three template strings. The bytes bought one copy of the
196 * furniture instead of two, and the app fits 82,000 / 38,000 with 2,063 raw and
197 * 864 gzipped to spare, so the ceiling did not move. Neither other bundle
198 * changed a byte: nothing outside `src/client/hud.ts` did.
189199 */
190200 export const GAMES: readonly Game[] = [
191201 {
static/app.024b05a3b5.js added +58

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

View file
static/app.c59820edf9.js deleted −58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs