Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The HUD is pinned before it moves: src/client/hud.test.ts says what a player sees today #71
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/64-the-hud-is-pinned-before-it-moves-src-cl → main 1 commit fork
2 files
changed +512
−1
README.md +7 −1
| @@ -199,7 +199,13 @@ from the angle it subtends, and you turn on the spot. | ||
| 199 | 199 | a match, one POST per round scored and a browser with no WebGL2 are all |
| 200 | 200 | checked with no browser in the room. `hud` draws onto a `Paint` — the |
| 201 | 201 | seventeen members of a 2D context it uses — which is how that stub can be a |
| 202 | recorder and the page can still hand it a canvas. | |
| 202 | recorder and the page can still hand it a canvas. `hud.test.ts` drives it | |
| 203 | with a recorder of its own that writes down the brush as well as the call, so | |
| 204 | what is pinned is what a player reads: the health that goes red, the ammo, the | |
| 205 | clock that stops between rounds and reddens once the bomb is down, the | |
| 206 | crosshair that opens as far as the gun is inaccurate, the plant bar, the kill | |
| 207 | feed fading out over eight seconds, the scoreboard's order, and the whole | |
| 208 | readout fitting a 360px phone and a 1920px desk alike. | |
| 203 | 209 | - `src/range/` — the second game, at `/range`, and the proof that the engine is a |
| 204 | 210 | package: a module in here may import the engine through its door, `src/pages/shell.ts` |
| 205 | 211 | and its own siblings, and nothing else. **It is a game with no renderer under |
src/client/hud.test.ts added +505
| @@ -0,0 +1,505 @@ | ||
| 1 | /** | |
| 2 | * This test exists so #18 can move the HUD onto the engine's overlay and prove | |
| 3 | * it drew the same thing. | |
| 4 | * | |
| 5 | * It says what a player reads off the screen — the number that goes red, the | |
| 6 | * clock that stops, the crosshair that opens, the feed that fades — and not how | |
| 7 | * `hud.ts` is arranged to draw it. Nothing here asserts a coordinate that only | |
| 8 | * means something to the current layout: a panel that moves two pixels left is | |
| 9 | * not a change a player sees, and a pin that fails on one is a pin someone | |
| 10 | * deletes rather than uses. | |
| 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. | |
| 20 | * | |
| 21 | * No canvas, no document, no browser. | |
| 22 | */ | |
| 23 | ||
| 24 | import { expect, test } from "bun:test"; | |
| 25 | import { drawHud, type Paint } from "./hud.ts"; | |
| 26 | import { SHORT } from "./match.ts"; | |
| 27 | import { inaccuracy, WEAPONS } from "./weapons.ts"; | |
| 28 | import { createWorld, currentWeapon, type Ammo, type Player, type World } from "./world.ts"; | |
| 29 | ||
| 30 | // --- a paint that remembers ------------------------------------------------ | |
| 31 | ||
| 32 | /** One call the HUD made, and the state of the brush that made it. */ | |
| 33 | type Call = { | |
| 34 | op: string; | |
| 35 | args: readonly unknown[]; | |
| 36 | fill: string; | |
| 37 | stroke: string; | |
| 38 | font: string; | |
| 39 | align: string; | |
| 40 | alpha: number; | |
| 41 | }; | |
| 42 | ||
| 43 | type Recorder = Paint & { calls: Call[] }; | |
| 44 | ||
| 45 | function recorder(): Recorder { | |
| 46 | const calls: Call[] = []; | |
| 47 | const note = (op: string) => (...args: unknown[]): void => void calls.push({ | |
| 48 | op, | |
| 49 | args, | |
| 50 | fill: paint.fillStyle, | |
| 51 | stroke: paint.strokeStyle, | |
| 52 | font: paint.font, | |
| 53 | align: paint.textAlign, | |
| 54 | alpha: paint.globalAlpha, | |
| 55 | }); | |
| 56 | const paint: Recorder = { | |
| 57 | calls, | |
| 58 | fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1, | |
| 59 | clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"), | |
| 60 | beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"), | |
| 61 | arc: note("arc"), fill: note("fill"), stroke: note("stroke"), | |
| 62 | }; | |
| 63 | return paint; | |
| 64 | } | |
| 65 | ||
| 66 | // --- the world this suite draws -------------------------------------------- | |
| 67 | ||
| 68 | /** | |
| 69 | * One match, built once for the whole file: `createWorld` lays out ten players, | |
| 70 | * spawns, bots and a bomb, and doing that per test is seconds nobody needs. | |
| 71 | * Every test draws a *copy* of it instead, so a test that spends a magazine or | |
| 72 | * kills a bot does not reach the next one. | |
| 73 | */ | |
| 74 | const BASE = createWorld(SHORT, "you", "ct", 5, 64); | |
| 75 | ||
| 76 | const copyAmmo = (ammo: Record<string, Ammo>): Record<string, Ammo> => | |
| 77 | Object.fromEntries(Object.entries(ammo).map(([id, a]) => [id, { ...a }])); | |
| 78 | ||
| 79 | /** A copy of the base world, changed the one way this test is about. */ | |
| 80 | function scene(change: (world: World) => void = () => {}): World { | |
| 81 | const players = BASE.players.map((p) => ({ ...p, ammo: copyAmmo(p.ammo) })); | |
| 82 | const world: World = { | |
| 83 | ...BASE, | |
| 84 | match: { ...BASE.match, score: { ...BASE.match.score }, losses: { ...BASE.match.losses } }, | |
| 85 | players, | |
| 86 | me: players[0] as Player, | |
| 87 | bomb: { ...BASE.bomb, defuser: null }, | |
| 88 | effects: [], | |
| 89 | feed: [], | |
| 90 | messages: [], | |
| 91 | }; | |
| 92 | change(world); | |
| 93 | return world; | |
| 94 | } | |
| 95 | ||
| 96 | const WIDTH = 1280; | |
| 97 | const HEIGHT = 720; | |
| 98 | ||
| 99 | /** Draw one frame of the HUD onto a fresh recorder. */ | |
| 100 | function draw( | |
| 101 | world: World, | |
| 102 | options: { width?: number; height?: number; scoreboard?: boolean } = {}, | |
| 103 | ): Recorder { | |
| 104 | const paint = recorder(); | |
| 105 | drawHud(paint, world, options.width ?? WIDTH, options.height ?? HEIGHT, options.scoreboard ?? false); | |
| 106 | return paint; | |
| 107 | } | |
| 108 | ||
| 109 | /** Every line on the screen a player could actually read: alpha zero is not on it. */ | |
| 110 | const readable = (paint: Recorder): Call[] => | |
| 111 | paint.calls.filter((c) => c.op === "fillText" && c.alpha > 0); | |
| 112 | ||
| 113 | const words = (paint: Recorder): string[] => readable(paint).map((c) => String(c.args[0])); | |
| 114 | ||
| 115 | const says = (paint: Recorder, text: string): boolean => words(paint).includes(text); | |
| 116 | ||
| 117 | /** The one readable line that reads exactly this, and the brush that drew it. */ | |
| 118 | function line(paint: Recorder, text: string): Call { | |
| 119 | const hits = readable(paint).filter((c) => c.args[0] === text); | |
| 120 | expect(hits.length).toBe(1); | |
| 121 | return hits[0] as Call; | |
| 122 | } | |
| 123 | ||
| 124 | // --- the crosshair --------------------------------------------------------- | |
| 125 | ||
| 126 | /** How far the crosshair's first arm starts from the middle of the screen. */ | |
| 127 | const gapOf = (paint: Recorder, width = WIDTH): number => { | |
| 128 | const first = paint.calls.find((c) => c.op === "moveTo"); | |
| 129 | return Number(first?.args[0]) - width / 2; | |
| 130 | }; | |
| 131 | ||
| 132 | const spreadOf = (world: World, moving: number): number => | |
| 133 | inaccuracy(currentWeapon(world.me), { | |
| 134 | moving, | |
| 135 | inAir: !world.me.onGround, | |
| 136 | ducking: world.me.duckAmount > 0.5, | |
| 137 | scoped: world.me.scoped, | |
| 138 | }); | |
| 139 | ||
| 140 | test("the crosshair opens exactly as far as the gun is inaccurate, and is gone when you are", () => { | |
| 141 | const still = scene(); | |
| 142 | const rest = draw(still); | |
| 143 | // At a standstill it is the gun's own spread and nothing else. | |
| 144 | expect(gapOf(rest)).toBeCloseTo(4 + spreadOf(still, 0) * 14, 10); | |
| 145 | // Each arm is seven pixels long, from the gap outwards. | |
| 146 | const arm = rest.calls.find((c) => c.op === "lineTo"); | |
| 147 | expect(Number(arm?.args[0]) - WIDTH / 2).toBeCloseTo(gapOf(rest) + 7, 10); | |
| 148 | ||
| 149 | // Running wide open: any speed at or over the walk cap counts as fully moving. | |
| 150 | const running = scene((w) => { w.me.vel = [1000, 0, 0]; }); | |
| 151 | expect(gapOf(draw(running))).toBeCloseTo(4 + spreadOf(running, 1) * 14, 10); | |
| 152 | expect(gapOf(draw(running))).toBeGreaterThan(gapOf(rest)); | |
| 153 | ||
| 154 | // Scoped it draws tight, and darkens everything outside the sight. | |
| 155 | const scoped = draw(scene((w) => { w.me.scoped = true; })); | |
| 156 | expect(gapOf(scoped)).toBeLessThan(gapOf(rest)); | |
| 157 | expect(scoped.calls.some((c) => c.op === "arc")).toBe(true); | |
| 158 | expect(scoped.calls.find((c) => c.op === "fill")?.args[0]).toBe("evenodd"); | |
| 159 | ||
| 160 | // Dead players do not aim: no crosshair, no sight, nothing stroked at all. | |
| 161 | const dead = draw(scene((w) => { w.me.alive = false; })); | |
| 162 | expect(dead.calls.some((c) => c.op === "stroke")).toBe(false); | |
| 163 | expect(dead.calls.some((c) => c.op === "moveTo")).toBe(false); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a hit is marked white, a headshot red, and both are gone a quarter second later", () => { | |
| 167 | const hit = draw(scene((w) => { | |
| 168 | w.time = 10; | |
| 169 | w.effects = [{ kind: "hitmark", at: 9.9, headshot: false }]; | |
| 170 | })); | |
| 171 | const body = hit.calls.filter((c) => c.op === "stroke"); | |
| 172 | expect(body.length).toBe(2); // the crosshair, then the marker | |
| 173 | expect(body[1]?.stroke).toBe("rgba(255,255,255,0.9)"); | |
| 174 | ||
| 175 | const head = draw(scene((w) => { | |
| 176 | w.time = 10; | |
| 177 | w.effects = [{ kind: "hitmark", at: 9.9, headshot: true }]; | |
| 178 | })); | |
| 179 | expect(head.calls.filter((c) => c.op === "stroke")[1]?.stroke).toBe("rgba(255,90,90,0.95)"); | |
| 180 | ||
| 181 | const old = draw(scene((w) => { | |
| 182 | w.time = 10; | |
| 183 | w.effects = [{ kind: "hitmark", at: 9.7, headshot: true }]; | |
| 184 | })); | |
| 185 | expect(old.calls.filter((c) => c.op === "stroke").length).toBe(1); | |
| 186 | }); | |
| 187 | ||
| 188 | // --- health, money and armour ---------------------------------------------- | |
| 189 | ||
| 190 | test("health reads white while you are standing and red when you are nearly gone", () => { | |
| 191 | expect(line(draw(scene()), "100").fill).toBe("#f3f5f8"); | |
| 192 | expect(line(draw(scene((w) => { w.me.health = 31; })), "31").fill).toBe("#f3f5f8"); | |
| 193 | // Thirty itself is already the red one: the HUD asks `health > 30`. | |
| 194 | expect(line(draw(scene((w) => { w.me.health = 30; })), "30").fill).toBe("#ff6b5b"); | |
| 195 | expect(line(draw(scene((w) => { w.me.health = 9; })), "9").fill).toBe("#ff6b5b"); | |
| 196 | ||
| 197 | // It is a whole number, and never a negative one. | |
| 198 | expect(says(draw(scene((w) => { w.me.health = 45.6; })), "46")).toBe(true); | |
| 199 | expect(says(draw(scene((w) => { w.me.health = -12; })), "0")).toBe(true); | |
| 200 | }); | |
| 201 | ||
| 202 | test("the wallet, the armour and the kit say what you bought", () => { | |
| 203 | const poor = draw(scene()); | |
| 204 | expect(says(poor, "$800")).toBe(true); | |
| 205 | expect(says(poor, "no armour")).toBe(true); | |
| 206 | expect(says(poor, "defuse kit")).toBe(false); | |
| 207 | ||
| 208 | const kitted = draw(scene((w) => { | |
| 209 | w.me.money = 4250; | |
| 210 | w.me.armor = 100; | |
| 211 | w.me.helmet = true; | |
| 212 | w.me.defuseKit = true; | |
| 213 | })); | |
| 214 | expect(says(kitted, "$4250")).toBe(true); | |
| 215 | expect(says(kitted, "armour 100 + helmet")).toBe(true); | |
| 216 | expect(says(kitted, "defuse kit")).toBe(true); | |
| 217 | ||
| 218 | // A terrorist with a kit in the inventory is still a terrorist: no kit line. | |
| 219 | const t = draw(scene((w) => { w.me.team = "t"; w.me.defuseKit = true; })); | |
| 220 | expect(says(t, "defuse kit")).toBe(false); | |
| 221 | }); | |
| 222 | ||
| 223 | // --- ammo ------------------------------------------------------------------ | |
| 224 | ||
| 225 | test("ammo reads mag / reserve, the knife reads a dash, and reloading says so", () => { | |
| 226 | const loaded = draw(scene((w) => { | |
| 227 | w.me.weapon = "usp"; | |
| 228 | w.me.ammo.usp = { mag: 5, reserve: 20 }; | |
| 229 | })); | |
| 230 | expect(says(loaded, "5 / 20")).toBe(true); | |
| 231 | expect(says(loaded, WEAPONS.usp.name)).toBe(true); | |
| 232 | expect(says(loaded, "reloading")).toBe(false); | |
| 233 | ||
| 234 | const knife = draw(scene((w) => { w.me.weapon = "knife"; })); | |
| 235 | expect(says(knife, "—")).toBe(true); | |
| 236 | expect(says(knife, WEAPONS.knife.name)).toBe(true); | |
| 237 | ||
| 238 | // Mid-reload the magazine still reads empty, and the word is on the screen. | |
| 239 | const reloading = draw(scene((w) => { | |
| 240 | w.time = 10; | |
| 241 | w.me.reloadUntil = 10.5; | |
| 242 | w.me.ammo.usp = { mag: 0, reserve: 24 }; | |
| 243 | })); | |
| 244 | expect(says(reloading, "0 / 24")).toBe(true); | |
| 245 | expect(line(reloading, "reloading").fill).toBe("#f0c060"); | |
| 246 | ||
| 247 | // The instant it finishes the word goes. | |
| 248 | expect(says(draw(scene((w) => { w.time = 10; w.me.reloadUntil = 10; })), "reloading")).toBe(false); | |
| 249 | ||
| 250 | // Carrying the bomb is written beside the gun's name. | |
| 251 | const carrier = draw(scene((w) => { w.me.team = "t"; w.me.hasBomb = true; })); | |
| 252 | expect(says(carrier, `${WEAPONS.usp.name} · bomb`)).toBe(true); | |
| 253 | }); | |
| 254 | ||
| 255 | // --- the clock and the score ----------------------------------------------- | |
| 256 | ||
| 257 | test("the clock counts the phase down, goes red when the bomb is down, and stops between rounds", () => { | |
| 258 | const freeze = draw(scene((w) => { w.match.phase = "freeze"; w.match.timer = 8; })); | |
| 259 | expect(line(freeze, "0:08").fill).toBe("#f3f5f8"); | |
| 260 | ||
| 261 | const live = draw(scene((w) => { w.match.phase = "live"; w.match.timer = 95; })); | |
| 262 | expect(line(live, "1:35").fill).toBe("#f3f5f8"); | |
| 263 | ||
| 264 | const planted = draw(scene((w) => { w.match.phase = "planted"; w.match.timer = 40; })); | |
| 265 | expect(line(planted, "0:40").fill).toBe("#ff6b5b"); | |
| 266 | ||
| 267 | // Nothing is being counted between rounds or after the match: no clock to read. | |
| 268 | for (const phase of ["over", "match_over"] as const) { | |
| 269 | const paint = draw(scene((w) => { w.match.phase = phase; w.match.timer = 5; })); | |
| 270 | expect(says(paint, "--:--")).toBe(true); | |
| 271 | expect(says(paint, "0:05")).toBe(false); | |
| 272 | } | |
| 273 | }); | |
| 274 | ||
| 275 | test("the score sits either side of the clock, each in its team's colour, and so do the survivors", () => { | |
| 276 | const paint = draw(scene((w) => { | |
| 277 | w.match.score = { t: 7, ct: 11 }; | |
| 278 | for (const p of w.players) if (p.team === "t" && p !== w.me) p.alive = false; | |
| 279 | })); | |
| 280 | expect(line(paint, "7").fill).toBe("#e2b04a"); | |
| 281 | expect(line(paint, "11").fill).toBe("#6fa8e8"); | |
| 282 | // Five terrorists, all of them down; five counter-terrorists, all still up. | |
| 283 | expect(line(paint, "T 0").fill).toBe("#e2b04a"); | |
| 284 | expect(line(paint, "CT 5").fill).toBe("#6fa8e8"); | |
| 285 | }); | |
| 286 | ||
| 287 | // --- the kill feed --------------------------------------------------------- | |
| 288 | ||
| 289 | const kill = (at: number, over: Partial<World["feed"][number]> = {}): World["feed"][number] => ({ | |
| 290 | at, killer: "Kestrel", victim: "Pike", weapon: "ak47", headshot: false, killerTeam: "t", ...over, | |
| 291 | }); | |
| 292 | ||
| 293 | test("a kill is named with the gun that made it, fades for eight seconds and is then gone", () => { | |
| 294 | const fresh = draw(scene((w) => { w.time = 100; w.feed = [kill(100)]; })); | |
| 295 | const entry = line(fresh, `Kestrel ${WEAPONS.ak47.name} Pike`); | |
| 296 | expect(entry.alpha).toBe(1); | |
| 297 | expect(entry.fill).toBe("#e2b04a"); | |
| 298 | ||
| 299 | // Halfway out it is half there, and a CT's kill is the other colour. | |
| 300 | const fading = draw(scene((w) => { | |
| 301 | w.time = 100; | |
| 302 | w.feed = [kill(92.5, { killerTeam: "ct", headshot: true })]; | |
| 303 | })); | |
| 304 | const faded = line(fading, `Kestrel ${WEAPONS.ak47.name} ⌖ Pike`); | |
| 305 | expect(faded.alpha).toBeCloseTo(0.5, 10); | |
| 306 | expect(faded.fill).toBe("#6fa8e8"); | |
| 307 | ||
| 308 | // Eight seconds old is the end of it: nothing readable, and nothing at all after. | |
| 309 | const spent = draw(scene((w) => { w.time = 100; w.feed = [kill(92)]; })); | |
| 310 | expect(readable(spent).some((c) => String(c.args[0]).includes("Kestrel"))).toBe(false); | |
| 311 | const gone = draw(scene((w) => { w.time = 100; w.feed = [kill(91.9)]; })); | |
| 312 | expect(gone.calls.some((c) => c.op === "fillText" && String(c.args[0]).includes("Kestrel"))).toBe(false); | |
| 313 | ||
| 314 | // The feed shows the last five kills, newest last, oldest dropped. | |
| 315 | const busy = draw(scene((w) => { | |
| 316 | w.time = 100; | |
| 317 | w.feed = [1, 2, 3, 4, 5, 6].map((n) => kill(99, { victim: `Down ${n}` })); | |
| 318 | })); | |
| 319 | expect(words(busy).filter((s) => s.includes("Down")).length).toBe(5); | |
| 320 | expect(words(busy).some((s) => s.includes("Down 1"))).toBe(false); | |
| 321 | expect(words(busy).some((s) => s.includes("Down 6"))).toBe(true); | |
| 322 | }); | |
| 323 | ||
| 324 | test("the round's last word is on the screen for four seconds", () => { | |
| 325 | const said = draw(scene((w) => { | |
| 326 | w.time = 100; | |
| 327 | w.messages = [{ at: 99.9, text: "The bomb has been planted at A." }]; | |
| 328 | })); | |
| 329 | expect(says(said, "The bomb has been planted at A.")).toBe(true); | |
| 330 | ||
| 331 | const stale = draw(scene((w) => { | |
| 332 | w.time = 100; | |
| 333 | w.messages = [{ at: 96, text: "The bomb has been planted at A." }]; | |
| 334 | })); | |
| 335 | expect(says(stale, "The bomb has been planted at A.")).toBe(false); | |
| 336 | }); | |
| 337 | ||
| 338 | // --- planting and defusing ------------------------------------------------- | |
| 339 | ||
| 340 | /** The filled fraction of the use bar: the bar's width over the panel behind it. */ | |
| 341 | function useBar(paint: Recorder): { fraction: number; fill: string } { | |
| 342 | const rects = paint.calls.filter((c) => c.op === "fillRect"); | |
| 343 | const bar = rects[rects.length - 1] as Call; | |
| 344 | const panel = rects[rects.length - 2] as Call; | |
| 345 | return { fraction: Number(bar.args[2]) / (Number(panel.args[2]) - 4), fill: bar.fill }; | |
| 346 | } | |
| 347 | ||
| 348 | test("the use bar fills as far as the plant or the defuse has got", () => { | |
| 349 | const nothing = draw(scene()); | |
| 350 | expect(says(nothing, "Planting")).toBe(false); | |
| 351 | ||
| 352 | const planting = draw(scene((w) => { | |
| 353 | w.me.team = "t"; | |
| 354 | w.me.hasBomb = true; | |
| 355 | w.bomb.plantProgress = SHORT.plantTime / 2; | |
| 356 | })); | |
| 357 | expect(says(planting, "Planting")).toBe(true); | |
| 358 | expect(useBar(planting).fraction).toBeCloseTo(0.5, 10); | |
| 359 | expect(useBar(planting).fill).toBe("#e2b04a"); | |
| 360 | ||
| 361 | const defusing = draw(scene((w) => { | |
| 362 | w.bomb.state = "planted"; | |
| 363 | w.bomb.defuser = w.me; | |
| 364 | w.bomb.defuseProgress = SHORT.defuseTime * 0.25; | |
| 365 | })); | |
| 366 | expect(says(defusing, "Defusing")).toBe(true); | |
| 367 | expect(useBar(defusing).fraction).toBeCloseTo(0.25, 10); | |
| 368 | expect(useBar(defusing).fill).toBe("#6fa8e8"); | |
| 369 | ||
| 370 | // A kit is faster, and says so, and the bar is full when the wire is cut. | |
| 371 | const kit = draw(scene((w) => { | |
| 372 | w.me.defuseKit = true; | |
| 373 | w.bomb.state = "planted"; | |
| 374 | w.bomb.defuser = w.me; | |
| 375 | w.bomb.defuseProgress = SHORT.defuseTimeWithKit; | |
| 376 | })); | |
| 377 | expect(says(kit, "Defusing (kit)")).toBe(true); | |
| 378 | expect(useBar(kit).fraction).toBeCloseTo(1, 10); | |
| 379 | }); | |
| 380 | ||
| 381 | // --- dead, and the scoreboard ---------------------------------------------- | |
| 382 | ||
| 383 | const WATCHING = "You are dead — watching the round out"; | |
| 384 | ||
| 385 | test("a dead player is told they are watching, until the match itself is over", () => { | |
| 386 | expect(says(draw(scene()), WATCHING)).toBe(false); | |
| 387 | expect(says(draw(scene((w) => { w.me.alive = false; })), WATCHING)).toBe(true); | |
| 388 | expect(says(draw(scene((w) => { | |
| 389 | w.me.alive = false; | |
| 390 | w.match.phase = "match_over"; | |
| 391 | })), WATCHING)).toBe(false); | |
| 392 | }); | |
| 393 | ||
| 394 | test("the scoreboard ranks by kills, then by damage, marks you, and dims the dead", () => { | |
| 395 | const world = scene((w) => { | |
| 396 | const [me, second, third, fourth] = w.players as Player[]; | |
| 397 | me.kills = 2; me.deaths = 1; me.headshots = 1; me.damage = 310.4; me.money = 3200; | |
| 398 | second.kills = 5; second.damage = 700; | |
| 399 | third.kills = 2; third.damage = 480; // same kills as me, more damage: above me | |
| 400 | fourth.kills = 0; fourth.alive = false; | |
| 401 | }); | |
| 402 | const paint = draw(world, { scoreboard: true }); | |
| 403 | const rows = readable(paint).filter((c) => c.align === "left" && String(c.args[0]).includes(" ")); | |
| 404 | ||
| 405 | const names = rows.map((c) => String(c.args[0])); | |
| 406 | expect(names[0]).toContain(world.players[1]?.name); | |
| 407 | expect(names[1]).toContain(world.players[2]?.name); | |
| 408 | expect(names[2]).toContain("you"); | |
| 409 | ||
| 410 | // Your own row is the white one, and it is the only one that says so. | |
| 411 | const mine = rows[2] as Call; | |
| 412 | expect(mine.fill).toBe("#ffffff"); | |
| 413 | expect(names.filter((n) => n.includes("(you)")).length).toBe(1); | |
| 414 | expect(rows.filter((r) => r.fill === "#ffffff").length).toBe(1); | |
| 415 | ||
| 416 | // Every other row wears its team's colour, and a dead one is dimmed. | |
| 417 | expect(rows.filter((r) => r.fill === "#e2b04a" || r.fill === "#6fa8e8").length).toBe(9); | |
| 418 | const down = rows.find((r) => String(r.args[0]).includes(world.players[3]?.name ?? "")) as Call; | |
| 419 | expect(down.alpha).toBeCloseTo(0.45, 10); | |
| 420 | expect(mine.alpha).toBe(1); | |
| 421 | ||
| 422 | // The columns are there, and your figures are under them, rounded. | |
| 423 | expect(words(paint)).toContain("kills"); | |
| 424 | expect(words(paint)).toContain("damage"); | |
| 425 | const figures = readable(paint).filter((c) => c.align === "right"); | |
| 426 | expect(figures.map((c) => String(c.args[0]))).toContain("310"); | |
| 427 | expect(figures.map((c) => String(c.args[0]))).toContain("$3200"); | |
| 428 | ||
| 429 | // It is drawn over everything: its scrim comes after the health it covers. | |
| 430 | const scrim = paint.calls.findIndex((c) => c.fill === "rgba(10, 12, 16, 0.88)"); | |
| 431 | const health = paint.calls.findIndex((c) => c.op === "fillText" && c.args[0] === "100"); | |
| 432 | expect(scrim).toBeGreaterThan(health); | |
| 433 | ||
| 434 | // And nothing of it is on the screen when it is not being held open. | |
| 435 | expect(says(draw(world), "kills")).toBe(false); | |
| 436 | }); | |
| 437 | ||
| 438 | // --- both ends of the screen ----------------------------------------------- | |
| 439 | ||
| 440 | /** A frame with every panel on it at once, so a layout test has something to lay out. */ | |
| 441 | const busyWorld = (): World => scene((w) => { | |
| 442 | w.time = 100; | |
| 443 | w.match.phase = "planted"; | |
| 444 | w.match.timer = 37; | |
| 445 | w.match.score = { t: 9, ct: 12 }; | |
| 446 | w.me.health = 24; | |
| 447 | w.me.armor = 100; | |
| 448 | w.me.helmet = true; | |
| 449 | w.me.defuseKit = true; | |
| 450 | w.me.money = 12450; | |
| 451 | w.me.reloadUntil = 101; | |
| 452 | w.bomb.state = "planted"; | |
| 453 | w.bomb.defuser = w.me; | |
| 454 | w.bomb.defuseProgress = 1; | |
| 455 | w.effects = [{ kind: "hitmark", at: 99.95, headshot: true }]; | |
| 456 | w.feed = [kill(99), kill(97, { killerTeam: "ct" }), kill(94, { headshot: true })]; | |
| 457 | w.messages = [{ at: 99.5, text: "The bomb has been planted at B." }]; | |
| 458 | for (const p of w.players) p.kills = p.id; | |
| 459 | (w.players[4] as Player).alive = false; | |
| 460 | }); | |
| 461 | ||
| 462 | /** Every call that put something over the edge of the canvas. */ | |
| 463 | function offCanvas(paint: Recorder, width: number, height: number): Call[] { | |
| 464 | const on = (x: number, y: number): boolean => x >= 0 && y >= 0 && x <= width && y <= height; | |
| 465 | return paint.calls.filter((c) => { | |
| 466 | const [a, b, c3, d] = c.args.map(Number); | |
| 467 | if (c.op === "fillText") return !on(Number(c.args[1]), Number(c.args[2])); | |
| 468 | if (c.op === "moveTo" || c.op === "lineTo") return !on(a as number, b as number); | |
| 469 | if (c.op === "arc") return !on((a as number) - (c3 as number), (b as number) - (c3 as number)) || | |
| 470 | !on((a as number) + (c3 as number), (b as number) + (c3 as number)); | |
| 471 | if (c.op === "fillRect" || c.op === "rect" || c.op === "clearRect") | |
| 472 | return !on(a as number, b as number) || !on((a as number) + (c3 as number), (b as number) + (d as number)); | |
| 473 | return false; | |
| 474 | }); | |
| 475 | } | |
| 476 | ||
| 477 | const fontsUsed = (paint: Recorder): string[] => | |
| 478 | [...new Set(paint.calls.filter((c) => c.op === "fillText").map((c) => c.font))].sort(); | |
| 479 | ||
| 480 | test("the whole readout fits a phone and a desk, and says the same amount at both", () => { | |
| 481 | const world = busyWorld(); | |
| 482 | const phone = draw(world, { width: 360, height: 640, scoreboard: true }); | |
| 483 | const desk = draw(world, { width: 1920, height: 1080, scoreboard: true }); | |
| 484 | ||
| 485 | expect(offCanvas(phone, 360, 640)).toEqual([]); | |
| 486 | expect(offCanvas(desk, 1920, 1080)).toEqual([]); | |
| 487 | ||
| 488 | // The scale is `width / 1280`, clamped to 0.7 .. 1.4: 21/13/11 px on a phone, | |
| 489 | // 42/27/21 on a desk, and no other size on either. | |
| 490 | expect(fontsUsed(phone)).toEqual([ | |
| 491 | "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif", | |
| 492 | ]); | |
| 493 | expect(fontsUsed(desk)).toEqual([ | |
| 494 | "21px system-ui, sans-serif", "27px system-ui, sans-serif", "42px system-ui, sans-serif", | |
| 495 | ]); | |
| 496 | ||
| 497 | // The same world reads the same on both: every line of it, in the same order. | |
| 498 | expect(words(phone)).toEqual(words(desk)); | |
| 499 | expect(phone.calls.length).toBe(desk.calls.length); | |
| 500 | expect(phone.calls.map((c) => c.op)).toEqual(desk.calls.map((c) => c.op)); | |
| 501 | ||
| 502 | // And the frame starts by clearing the one it is drawn on, whatever its size. | |
| 503 | expect(phone.calls[0]?.op).toBe("clearRect"); | |
| 504 | expect(phone.calls[0]?.args).toEqual([0, 0, 360, 640]); | |
| 505 | }); |
new above
offline