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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/18-the-readout-becomes-engine-a-2d-overlay → main 2 commits fork
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 | ||
| 203 | 203 | 2D over the scene: a scale and a pad off the canvas width, scaled fonts, |
| 204 | 204 | panels, aligned and faded text, a crosshair, a bar, right-aligned columns and |
| 205 | 205 | 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 | |
| 207 | 208 | it is kept in: `createScene` builds both buffers whole at the two capacities a |
| 208 | 209 | game picks, `resetScene` puts the counts back at the top of a frame, |
| 209 | 210 | `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 | ||
| 306 | 307 | (the rules), |
| 307 | 308 | `world` (the simulation), |
| 308 | 309 | `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 | |
| 311 | 318 | tested: the WebGL2 is the engine's now, and this game has no renderer of its |
| 312 | 319 | own. The level is handed in rather than imported: `createWorld(level, …)` |
| 313 | 320 | 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 4 | * |
| 5 | 5 | * It says what a player reads off the screen — the number that goes red, the |
| 6 | 6 | * clock that stops, the crosshair that opens, the feed that fades — and not how |
| @@ -9,14 +9,13 @@ | ||
| 9 | 9 | * not a change a player sees, and a pin that fails on one is a pin someone |
| 10 | 10 | * deletes rather than uses. |
| 11 | 11 | * |
| 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. | |
| 20 | 19 | * |
| 21 | 20 | * No canvas, no document, no browser. |
| 22 | 21 | */ |
| @@ -131,11 +130,22 @@ function line(paint: Recorder, text: string): Call { | ||
| 131 | 130 | |
| 132 | 131 | // --- the crosshair --------------------------------------------------------- |
| 133 | 132 | |
| 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 | ||
| 134 | 147 | /** 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; | |
| 139 | 149 | |
| 140 | 150 | const spreadOf = (world: World, moving: number): number => |
| 141 | 151 | inaccuracy(currentWeapon(world.me), { |
| @@ -151,8 +161,7 @@ test("the crosshair opens exactly as far as the gun is inaccurate, and is gone w | ||
| 151 | 161 | // At a standstill it is the gun's own spread and nothing else. |
| 152 | 162 | expect(gapOf(rest)).toBeCloseTo(4 + spreadOf(still, 0) * 14, 10); |
| 153 | 163 | // 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); | |
| 156 | 165 | |
| 157 | 166 | // Running wide open: any speed at or over the walk cap counts as fully moving. |
| 158 | 167 | 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 | ||
| 561 | 570 | expect(phone.calls[0]?.op).toBe("clearRect"); |
| 562 | 571 | expect(phone.calls[0]?.args).toEqual([0, 0, 360, 640]); |
| 563 | 572 | }); |
| 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 @@ | ||
| 6 | 6 | * What it draws onto is a `Paint` — the seventeen members of a 2D context it |
| 7 | 7 | * actually uses, and not a whole `CanvasRenderingContext2D`. A browser's |
| 8 | 8 | * 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. | |
| 11 | 18 | */ |
| 12 | 19 | |
| 13 | import { clamp } from "../engine/index.ts"; | |
| 20 | import { clamp, overlay, type Overlay, type Paint as Surface } from "../engine/index.ts"; | |
| 14 | 21 | import { CS_MOVE } from "./movement.ts"; |
| 15 | 22 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 16 | 23 | import { currentWeapon, useProgress, type Player, type World } from "./world.ts"; |
| 17 | 24 | |
| 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 & { | |
| 20 | 32 | fillStyle: string; |
| 21 | 33 | strokeStyle: string; |
| 22 | lineWidth: number; | |
| 23 | 34 | font: string; |
| 24 | 35 | textAlign: string; |
| 25 | 36 | 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; | |
| 37 | 37 | }; |
| 38 | 38 | |
| 39 | 39 | const T_COLOR = "#e2b04a"; |
| 40 | 40 | 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)"; | |
| 41 | 46 | |
| 42 | 47 | /** What a dead player is told, in the two cases there are. */ |
| 43 | 48 | const DEAD = "You are dead — watching the round out"; |
| 44 | 49 | const WATCHING = "Watching"; |
| 45 | 50 | const SWITCH = "fire — next player · aim — the one before"; |
| 46 | 51 | |
| 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 | } | |
| 51 | 73 | |
| 52 | 74 | /** |
| 53 | 75 | * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or |
| @@ -66,10 +88,11 @@ export function drawHud( | ||
| 66 | 88 | showScoreboard: boolean, |
| 67 | 89 | view: Player = world.me, |
| 68 | 90 | ): void { |
| 91 | const ui = toolsFor(ctx, width, height); | |
| 69 | 92 | ctx.clearRect(0, 0, width, height); |
| 70 | 93 | ctx.textBaseline = "alphabetic"; |
| 71 | 94 | const weapon = currentWeapon(view); |
| 72 | const pad = Math.max(12, Math.round(width * 0.02)); | |
| 95 | const pad = ui.pad; | |
| 73 | 96 | |
| 74 | 97 | // --- crosshair, opening up exactly as much as the gun is inaccurate |
| 75 | 98 | if (view.alive) { |
| @@ -80,16 +103,7 @@ export function drawHud( | ||
| 80 | 103 | ducking: view.duckAmount > 0.5, |
| 81 | 104 | scoped: view.scoped, |
| 82 | 105 | }); |
| 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); | |
| 93 | 107 | if (view.scoped) { |
| 94 | 108 | ctx.fillStyle = "rgba(0,0,0,0.55)"; |
| 95 | 109 | ctx.beginPath(); |
| @@ -99,7 +113,8 @@ export function drawHud( | ||
| 99 | 113 | } |
| 100 | 114 | } |
| 101 | 115 | |
| 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. | |
| 103 | 118 | for (const effect of world.effects) { |
| 104 | 119 | if (effect.kind !== "hitmark") continue; |
| 105 | 120 | const age = world.time - effect.at; |
| @@ -115,172 +130,124 @@ export function drawHud( | ||
| 115 | 130 | ctx.stroke(); |
| 116 | 131 | } |
| 117 | 132 | |
| 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); | |
| 127 | 137 | |
| 128 | 138 | // --- round clock and score |
| 129 | 139 | 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)); | |
| 133 | 141 | 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 }); | |
| 144 | 151 | |
| 145 | 152 | // --- 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", | |
| 154 | 157 | pad, height - pad - Math.round(30 * scale), |
| 158 | { align: "left", font: small, fill: BLUE }, | |
| 155 | 159 | ); |
| 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 }); | |
| 164 | 164 | |
| 165 | 165 | // --- ammo and weapon |
| 166 | ctx.textAlign = "right"; | |
| 167 | ctx.font = big; | |
| 168 | ctx.fillStyle = "#f3f5f8"; | |
| 169 | 166 | const ammo = view.ammo[weapon.id]; |
| 170 | 167 | 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" }); | |
| 179 | 173 | |
| 180 | 174 | // --- who is left |
| 181 | ctx.textAlign = "center"; | |
| 182 | ctx.font = small; | |
| 183 | 175 | const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length; |
| 184 | 176 | 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 }); | |
| 189 | 180 | |
| 190 | 181 | // --- kill feed |
| 191 | ctx.textAlign = "right"; | |
| 192 | ctx.font = small; | |
| 193 | 182 | world.feed.slice(-5).forEach((entry, i) => { |
| 194 | 183 | const age = world.time - entry.at; |
| 195 | 184 | 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; | |
| 199 | 185 | 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 | }); | |
| 202 | 189 | }); |
| 203 | 190 | |
| 204 | 191 | // --- what the round is saying |
| 205 | 192 | 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)" }); | |
| 212 | 195 | |
| 213 | 196 | // --- planting or defusing |
| 214 | 197 | const progress = useProgress(world, view); |
| 215 | 198 | if (progress !== null) { |
| 216 | 199 | const w = Math.round(260 * scale), h = Math.round(14 * scale); |
| 217 | 200 | 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" }); | |
| 225 | 204 | } |
| 226 | 205 | |
| 227 | 206 | // --- dead: whose eyes this is, and how to move to the next pair |
| 228 | 207 | if (!world.me.alive && world.match.phase !== "match_over") { |
| 229 | ctx.textAlign = "center"; | |
| 230 | 208 | if (view === world.me) { |
| 231 | 209 | // 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)" }); | |
| 235 | 211 | } else { |
| 236 | 212 | // Above the bottom row, so it never lands on the health or the ammo — |
| 237 | 213 | // at 360 pixels wide those are a thumb's width from the middle. |
| 238 | 214 | const w = Math.round(420 * scale), h = Math.round(34 * scale); |
| 239 | 215 | 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 }); | |
| 247 | 220 | } |
| 248 | 221 | } |
| 249 | 222 | |
| 250 | if (showScoreboard) drawScoreboard(ctx, world, width, height, scale); | |
| 223 | if (showScoreboard) drawScoreboard(ctx, ui, world); | |
| 251 | 224 | } |
| 252 | 225 | |
| 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; | |
| 260 | 228 | const w = Math.min(width - 40, Math.round(760 * scale)); |
| 261 | 229 | const rowH = Math.round(26 * scale); |
| 262 | 230 | const h = rowH * (world.players.length + 4); |
| 263 | 231 | 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); | |
| 273 | 237 | |
| 274 | 238 | const order = [...world.players].sort((a, b) => b.kills - a.kills || b.damage - a.damage); |
| 275 | 239 | order.forEach((p, row) => { |
| 276 | 240 | 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); | |
| 284 | 251 | ctx.globalAlpha = 1; |
| 285 | 252 | }); |
| 286 | 253 | } |
src/games.ts +10
| @@ -186,6 +186,16 @@ export type Game = { | ||
| 186 | 186 | * The range's numbers here are #121's moving plates and #125's painter at once: |
| 187 | 187 | * both landed a range bundle and neither swept the other's, so static/ held two |
| 188 | 188 | * 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. | |
| 189 | 199 | */ |
| 190 | 200 | export const GAMES: readonly Game[] = [ |
| 191 | 201 | { |
static/app.024b05a3b5.js added +58
Diff not shown — a line 62,697 characters wide (limit 2,000). +58
View filestatic/app.c59820edf9.js deleted −58
Diff not shown — a line 63,335 characters wide (limit 2,000). −58
View file
new above
offline