Public: anyone can read this; the crew can change it.
forked from trevato/cs
The HUD makes room for thumbs: health and ammo stop sitting under the touch buttons #404
vesper/cs-engine:vesper/204-the-hud-makes-room-for-thumbs-health-and → main 1 commit fork
7 files
changed +244
−19
README.md +8
| @@ -531,6 +531,14 @@ phone, where there is no keyboard and no pointer to lock. A browser that will no | ||
| 531 | 531 | put an element full screen — an iPhone will not — never shows that button rather |
| 532 | 532 | than showing one that does nothing. |
| 533 | 533 | |
| 534 | The buttons are two pads, each a grid three wide, so six buttons are two rows of | |
| 535 | 44px at any width: 106 CSS pixels at the foot of the stage. The HUD stands its | |
| 536 | bottom row — health, armour, ammo, the weapon, a spectator's panel — on top of | |
| 537 | that panel instead of under it. The page reads the height once when it shows or | |
| 538 | hides the pads, and again on a resize, never in a frame (`footOf` in | |
| 539 | `src/client/hud.ts`). With a mouse there are no pads and the layout is exactly | |
| 540 | what it was before. | |
| 541 | ||
| 534 | 542 | **The whole mouse plays, not just its two big buttons.** The wheel jumps either |
| 535 | 543 | way it is turned — counted in notches, so one click of a wheel is one press |
| 536 | 544 | whether the browser sends a hundred pixels, three lines or a page, and a |
src/client/hud.test.ts +120 −2
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | |
| 22 | 22 | import { expect, test } from "bun:test"; |
| 23 | 23 | import type { Motion } from "./motion.ts"; |
| 24 | import { drawHud, HIT_MARK_LIFE, HURT_LIFE, KILL_MARK_LIFE, NOT_READY_ALPHA, painAlpha, whiteout } from "./hud.ts"; | |
| 24 | import { drawHud, footOf, HIT_MARK_LIFE, HURT_LIFE, KILL_MARK_LIFE, NOT_READY_ALPHA, painAlpha, whiteout } from "./hud.ts"; | |
| 25 | 25 | import { recorder, type Call, type Recorder } from "./paint.test-helper.ts"; |
| 26 | 26 | import { DE_FORGE } from "./map.ts"; |
| 27 | 27 | import { SHORT } from "./match.ts"; |
| @@ -86,12 +86,14 @@ function draw( | ||
| 86 | 86 | world: World, |
| 87 | 87 | options: { |
| 88 | 88 | width?: number; height?: number; scoreboard?: boolean; view?: Player; dpr?: number; motion?: Motion; |
| 89 | /** Device pixels of touch pads over the foot of the canvas. */ | |
| 90 | thumbs?: number; | |
| 89 | 91 | } = {}, |
| 90 | 92 | ): Recorder { |
| 91 | 93 | const paint = recorder(); |
| 92 | 94 | drawHud( |
| 93 | 95 | paint, world, options.width ?? WIDTH, options.height ?? HEIGHT, |
| 94 | options.scoreboard ?? false, options.view ?? world.me, options.dpr ?? 1, options.motion, | |
| 96 | options.scoreboard ?? false, options.view ?? world.me, options.dpr ?? 1, options.motion, options.thumbs, | |
| 95 | 97 | ); |
| 96 | 98 | return paint; |
| 97 | 99 | } |
| @@ -722,6 +724,122 @@ test("the same canvas turned sideways is drawn at the new size, not the one befo | ||
| 722 | 724 | expect(bar.args[2]).toBe(Math.round(240 * 0.7)); |
| 723 | 725 | }); |
| 724 | 726 | |
| 727 | // --- room for thumbs -------------------------------------------------------- | |
| 728 | ||
| 729 | /** | |
| 730 | * The touch panel, as Chromium lays out `src/pages/shell.ts`'s grid: two rows | |
| 731 | * of 44px buttons and the panel's padding, 106 CSS pixels at every width. | |
| 732 | */ | |
| 733 | const PADS = 106; | |
| 734 | ||
| 735 | /** The lines of the bottom row in `busyWorld`: health, armour, ammo, weapon, and what the gun is doing. */ | |
| 736 | const BOTTOM = ["24", "armour 100 + helmet", "12 / 24", "USP-S", "reloading"]; | |
| 737 | ||
| 738 | /** Where each of those lines was written, by its words. */ | |
| 739 | const rowOf = (paint: Recorder): Record<string, [number, number]> => | |
| 740 | Object.fromEntries(BOTTOM.map((text) => { | |
| 741 | const at = line(paint, text).args; | |
| 742 | return [text, [Number(at[1]), Number(at[2])]]; | |
| 743 | })); | |
| 744 | ||
| 745 | test("with a mouse and keyboard the bottom row is where it has always been, to the pixel", () => { | |
| 746 | // Pinned as numbers on purpose, against the rule at the top of this file: | |
| 747 | // "move the HUD up always" is the wrong fix for #204 and passes every test | |
| 748 | // about what a phone sees. It fails this one. | |
| 749 | const world = busyWorld(); | |
| 750 | expect(rowOf(draw(world))).toEqual({ | |
| 751 | "24": [26, 694], "armour 100 + helmet": [26, 664], | |
| 752 | "12 / 24": [1254, 694], "USP-S": [1254, 664], "reloading": [1254, 644], | |
| 753 | }); | |
| 754 | // And no pads is the same frame as a caller that never heard of them, call | |
| 755 | // for call — the radar included, which only gives up room to real pads. | |
| 756 | for (const [width, height, dpr] of [[1280, 720, 1], [1920, 1080, 1], [2560, 1440, 2], [360, 640, 1]] as const) { | |
| 757 | const before = draw(world, { width, height, dpr }); | |
| 758 | const after = draw(world, { width, height, dpr, thumbs: 0 }); | |
| 759 | expect(after.calls).toEqual(before.calls); | |
| 760 | } | |
| 761 | expect(footOf(720, 26, 0)).toBe(694); | |
| 762 | }); | |
| 763 | ||
| 764 | /** | |
| 765 | * The two phones #204 names, held upright, as the HUD canvas is on them: the | |
| 766 | * stage is the page's width less its margins at 4:3 — 328×246 CSS pixels at | |
| 767 | * 360×640 and 358×269 at 390×844 — at two and at three device pixels to the | |
| 768 | * CSS pixel. Both, because the overlay's ratio stops at 2: on a 3× screen the | |
| 769 | * letters are smaller on the glass, and the 2× one is where the room runs out. | |
| 770 | */ | |
| 771 | const PHONES = [ | |
| 772 | { name: "360×640 @2", width: 328 * 2, height: 246 * 2, dpr: 2 }, | |
| 773 | { name: "360×640 @3", width: 328 * 3, height: 246 * 3, dpr: 3 }, | |
| 774 | { name: "390×844 @2", width: 358 * 2, height: 269 * 2, dpr: 2 }, | |
| 775 | { name: "390×844 @3", width: 358 * 3, height: 269 * 3, dpr: 3 }, | |
| 776 | ] as const; | |
| 777 | ||
| 778 | test("with the pads up, every line of the bottom row stands above them, and nothing else moves", () => { | |
| 779 | const world = busyWorld(); | |
| 780 | for (const phone of PHONES) { | |
| 781 | const { width, height, dpr } = phone; | |
| 782 | const thumbs = PADS * dpr; | |
| 783 | const padLine = height - thumbs; | |
| 784 | const bare = draw(world, { width, height, dpr }); | |
| 785 | const up = draw(world, { width, height, dpr, thumbs }); | |
| 786 | ||
| 787 | // Every one of them over the pad line, at the same x it had. | |
| 788 | const low = rowOf(bare), high = rowOf(up); | |
| 789 | for (const text of BOTTOM) { | |
| 790 | expect([phone.name, text, (high[text] as [number, number])[1] <= padLine]).toEqual([phone.name, text, true]); | |
| 791 | expect((high[text] as [number, number])[0]).toBe((low[text] as [number, number])[0]); | |
| 792 | } | |
| 793 | // Health and ammo stand on it, not somewhere above it: the room over the | |
| 794 | // pads on an upright phone is the whole of the HUD, and none is wasted. | |
| 795 | expect(high["24"]?.[1]).toBe(padLine); | |
| 796 | expect(high["12 / 24"]?.[1]).toBe(padLine); | |
| 797 | ||
| 798 | // Nothing is drawn twice or left out: the same calls, in the same order. | |
| 799 | expect(up.calls.map((c) => c.op)).toEqual(bare.calls.map((c) => c.op)); | |
| 800 | expect(words(up)).toEqual(words(bare)); | |
| 801 | // The top of the screen stays put — the clock, the score and who is left. | |
| 802 | const top = (paint: Recorder): Call[] => | |
| 803 | readable(paint).filter((c) => ["0:37", "9", "12"].includes(String(c.args[0])) || /^C?T \d$/.test(String(c.args[0]))); | |
| 804 | expect(top(up).length).toBe(5); | |
| 805 | expect(top(up).map((c) => c.args)).toEqual(top(bare).map((c) => c.args)); | |
| 806 | } | |
| 807 | }); | |
| 808 | ||
| 809 | test("with the pads up the money column still ends above the armour line", () => { | |
| 810 | // At 360×640 the room over the pads is 140 CSS pixels, and the column under | |
| 811 | // the radar — money, then the defuse kit — used to run into the armour line | |
| 812 | // that came up to meet it. The square gives up what the column needs. | |
| 813 | const world = busyWorld(); | |
| 814 | for (const phone of PHONES) { | |
| 815 | const { width, height, dpr } = phone; | |
| 816 | const up = draw(world, { width, height, dpr, thumbs: PADS * dpr }); | |
| 817 | const kit = Number(line(up, "defuse kit").args[2]); | |
| 818 | const armour = line(up, "armour 100 + helmet"); | |
| 819 | const letters = Number(/^(\d+)px/.exec(armour.font)?.[1]); | |
| 820 | expect(kit).toBeLessThan(Number(armour.args[2]) - letters); | |
| 821 | } | |
| 822 | // At 390×844 there was room already, and the square is the size it was. | |
| 823 | const [, , tall] = PHONES; | |
| 824 | const square = (paint: Recorder): unknown => paint.calls.find((c) => c.op === "fillRect" && c.args[0] === c.args[1])?.args; | |
| 825 | expect(square(draw(world, { ...tall, thumbs: PADS * tall.dpr }))).toEqual(square(draw(world, tall))); | |
| 826 | }); | |
| 827 | ||
| 828 | test("a spectator's panel comes up with the bottom row, and stays over it", () => { | |
| 829 | const world = scene((w) => { w.me.alive = false; w.match.phase = "live"; }); | |
| 830 | const mate = world.players.find((p) => p !== world.me && p.team === world.me.team && p.alive) as Player; | |
| 831 | const { width, height, dpr } = PHONES[3]; | |
| 832 | const thumbs = PADS * dpr; | |
| 833 | const up = draw(world, { width, height, dpr, view: mate, thumbs }); | |
| 834 | const bare = draw(world, { width, height, dpr, view: mate }); | |
| 835 | const switchAt = (paint: Recorder): number => Number(line(paint, "fire — next player · aim — the one before").args[2]); | |
| 836 | expect(switchAt(up)).toBeLessThan(Number(line(up, String(mate.health)).args[2])); | |
| 837 | // By exactly as much as the health under it came up: one foot for the whole row. | |
| 838 | const healthAt = (paint: Recorder): number => Number(line(paint, String(mate.health)).args[2]); | |
| 839 | expect(switchAt(bare) - switchAt(up)).toBe(healthAt(bare) - healthAt(up)); | |
| 840 | expect(healthAt(up)).toBe(height - thumbs); | |
| 841 | }); | |
| 842 | ||
| 725 | 843 | // --- flashed --------------------------------------------------------------- |
| 726 | 844 | |
| 727 | 845 | /** Every full-screen wash this frame drew, with the brush that drew it. */ |
src/client/hud.ts +38 −7
| @@ -59,6 +59,13 @@ export type Paint = Surface & { | ||
| 59 | 59 | */ |
| 60 | 60 | const RADAR = 180; |
| 61 | 61 | |
| 62 | /** | |
| 63 | * What stands between the foot of the radar and the bottom row, before the | |
| 64 | * scale: the money's line (22), the defuse kit's (20), and the armour line's | |
| 65 | * 30 over the foot with the height of its own letters (11) and a little air. | |
| 66 | */ | |
| 67 | const STACK = 90; | |
| 68 | ||
| 62 | 69 | /** |
| 63 | 70 | * What is left of the ammo counter and the weapon's name while the gun is |
| 64 | 71 | * still coming up: a click that does nothing for the 1.25 s an AWP takes |
| @@ -205,6 +212,18 @@ export function hurtAngle(yaw: number, from: Vec3, at: Vec3): number { | ||
| 205 | 212 | */ |
| 206 | 213 | export const FOG = 0.9; |
| 207 | 214 | |
| 215 | /** | |
| 216 | * Where the bottom row stands: the baseline health and ammo are written on, | |
| 217 | * `thumbs` device pixels of touch pads or not. With no pads it is the margin | |
| 218 | * every other edge has, exactly as it always was; with them it is the top of | |
| 219 | * the panel they sit in, so the numbers you need are over your hands rather | |
| 220 | * than under them. The panel carries its own padding above the buttons, which | |
| 221 | * is the gap — no second margin on top of it, because on a phone held upright | |
| 222 | * the room above the pads is the whole of the HUD. | |
| 223 | */ | |
| 224 | export const footOf = (height: number, pad: number, thumbs: number): number => | |
| 225 | height - Math.max(pad, Math.round(thumbs)); | |
| 226 | ||
| 208 | 227 | /** |
| 209 | 228 | * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or |
| 210 | 229 | * the player you are watching once you are dead — and it is what every number |
| @@ -218,6 +237,11 @@ export const FOG = 0.9; | ||
| 218 | 237 | * pixels — and `dpr` is how many of those go to a CSS pixel, so that the |
| 219 | 238 | * crosshair is the same size on the glass and exact on the grid at 125% and |
| 220 | 239 | * 150% as well as at 100%. Left out it is 1, which is what every test draws at. |
| 240 | * | |
| 241 | * `thumbs` is how much of the foot of the canvas the touch pads cover, in the | |
| 242 | * same device pixels — measured by the page when it shows or hides them, never | |
| 243 | * here and never per frame. Left out it is 0: a mouse and keyboard, and the | |
| 244 | * layout is the one it has always been. | |
| 221 | 245 | */ |
| 222 | 246 | export function drawHud( |
| 223 | 247 | ctx: Paint, |
| @@ -228,12 +252,14 @@ export function drawHud( | ||
| 228 | 252 | view: Player = world.me, |
| 229 | 253 | dpr = 1, |
| 230 | 254 | motion: Motion = DEFAULT_MOTION, |
| 255 | thumbs = 0, | |
| 231 | 256 | ): void { |
| 232 | 257 | const ui = toolsFor(ctx, width, height, dpr); |
| 233 | 258 | ctx.clearRect(0, 0, width, height); |
| 234 | 259 | ctx.textBaseline = "alphabetic"; |
| 235 | 260 | const weapon = currentWeapon(view); |
| 236 | 261 | const pad = ui.pad; |
| 262 | const foot = footOf(height, pad, thumbs); | |
| 237 | 263 | // Damage is something a body takes, so the indicator is drawn only from the |
| 238 | 264 | // eyes of the body that took it: yours, while you are standing. A spectator |
| 239 | 265 | // is not being shot at, and a corpse is not turning to face anything. |
| @@ -388,15 +414,20 @@ export function drawHud( | ||
| 388 | 414 | { align: "left", font: mid, fill: CT_COLOR }); |
| 389 | 415 | |
| 390 | 416 | // --- health, armour, money |
| 391 | ui.text(String(Math.max(0, Math.round(view.health))), pad, height - pad, | |
| 417 | ui.text(String(Math.max(0, Math.round(view.health))), pad, foot, | |
| 392 | 418 | { align: "left", font: big, fill: view.health > 30 ? WHITE : RED }); |
| 393 | 419 | ui.text( |
| 394 | 420 | view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour", |
| 395 | pad, height - pad - Math.round(30 * scale), | |
| 421 | pad, foot - Math.round(30 * scale), | |
| 396 | 422 | { align: "left", font: small, fill: BLUE }, |
| 397 | 423 | ); |
| 398 | 424 | // --- the radar, top left, and the money under it rather than over it |
| 399 | const radar = Math.min(Math.round(RADAR * scale), Math.round(Math.min(width, height) * 0.3)); | |
| 425 | // With the pads up the bottom row has come up to meet the money column, so | |
| 426 | // the square also gives up whatever the column needs to end above the armour | |
| 427 | // line: `STACK` of it. On a 360-pixel phone held upright that is nine pixels | |
| 428 | // of map; without the pads it is nothing, and the square is what it was. | |
| 429 | const room = thumbs > 0 ? foot - pad - Math.round(STACK * scale) : Infinity; | |
| 430 | const radar = Math.min(Math.round(RADAR * scale), Math.round(Math.min(width, height) * 0.3), room); | |
| 400 | 431 | drawMinimap(ctx, ui, world, view, pad, pad, radar); |
| 401 | 432 | const moneyY = pad + radar + Math.round(22 * scale); |
| 402 | 433 | ui.text(`$${view.money}`, pad, moneyY, { align: "left", font: mid, fill: "#7ddc8a" }); |
| @@ -411,7 +442,7 @@ export function drawHud( | ||
| 411 | 442 | const ready = world.time < view.deployUntil ? NOT_READY_ALPHA : 1; |
| 412 | 443 | const ammo = view.ammo[weapon.id]; |
| 413 | 444 | const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`; |
| 414 | ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE, alpha: ready }); | |
| 445 | ui.text(ammoText, width - pad, foot, { align: "right", font: big, fill: WHITE, alpha: ready }); | |
| 415 | 446 | // What is in your hands, and then what is in your pockets: the grenades you |
| 416 | 447 | // bought and the bomb, each named only while you are carrying it. |
| 417 | 448 | ui.text( |
| @@ -420,7 +451,7 @@ export function drawHud( | ||
| 420 | 451 | (view.pocket.flash > 0 ? " · flash" : "") + |
| 421 | 452 | (view.pocket.smoke > 0 ? " · smoke" : "") + |
| 422 | 453 | (view.hasBomb ? " · bomb" : ""), |
| 423 | width - pad, height - pad - Math.round(30 * scale), | |
| 454 | width - pad, foot - Math.round(30 * scale), | |
| 424 | 455 | { align: "right", font: small, fill: GREY, alpha: ready }, |
| 425 | 456 | ); |
| 426 | 457 | // What the gun is busy doing, in the one line there is room for. The two |
| @@ -429,7 +460,7 @@ export function drawHud( | ||
| 429 | 460 | // reading and not a choice between two things happening at once. |
| 430 | 461 | const busy = world.time < view.deployUntil ? "switching" : world.time < view.reloadUntil ? "reloading" : null; |
| 431 | 462 | if (busy !== null) |
| 432 | ui.text(busy, width - pad, height - pad - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" }); | |
| 463 | ui.text(busy, width - pad, foot - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" }); | |
| 433 | 464 | |
| 434 | 465 | // --- who is left |
| 435 | 466 | const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length; |
| @@ -472,7 +503,7 @@ export function drawHud( | ||
| 472 | 503 | // Above the bottom row, so it never lands on the health or the ammo — |
| 473 | 504 | // at 360 pixels wide those are a thumb's width from the middle. |
| 474 | 505 | const w = Math.round(420 * scale), h = Math.round(34 * scale); |
| 475 | const y = height - pad - Math.round(64 * scale); | |
| 506 | const y = foot - Math.round(64 * scale); | |
| 476 | 507 | ui.panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h); |
| 477 | 508 | ui.text(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale), |
| 478 | 509 | { align: "center", font: mid, fill: teamColor(view.team) }); |
src/client/main.test.ts +26 −1
| @@ -47,7 +47,7 @@ import { WEAPONS } from "./weapons.ts"; | ||
| 47 | 47 | import { matchOf } from "../matchid.ts"; |
| 48 | 48 | import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts"; |
| 49 | 49 | import { parseResult, type Result } from "../result.ts"; |
| 50 | import { DRIVER, fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts"; | |
| 50 | import { DRIVER, fakeStage, ORIGIN, TOUCH_HEIGHT, type FakeStage } from "./stage.test-helper.ts"; | |
| 51 | 51 | import { parseSituation } from "../plan/situation.ts"; |
| 52 | 52 | import { createWorld, type World } from "./world.ts"; |
| 53 | 53 | |
| @@ -916,6 +916,31 @@ test("a phone gets the touch pads, a mouse does not", () => { | ||
| 916 | 916 | expect(playing().by("touch").hidden).toBe(true); |
| 917 | 917 | }); |
| 918 | 918 | |
| 919 | test("a phone's HUD stands its bottom row on the pads, read when they are shown and not every frame", () => { | |
| 920 | // The HUD canvas is 800×450 CSS pixels at a ratio of 1 on this page, so a | |
| 921 | // CSS pixel of pad is a pixel of canvas and the pad line is 450 - 106. | |
| 922 | const health = (stage: FakeStage): number => { | |
| 923 | const call = stage.hud.paint.calls.filter((c) => c[0] === "fillText" && c[1] === "100").at(-1); | |
| 924 | return Number(call?.[3]); | |
| 925 | }; | |
| 926 | const phone = playing({ coarse: true }); | |
| 927 | // How often anything asks the panel how tall it is, from here on. | |
| 928 | let reads = 0; | |
| 929 | const touch = phone.by("touch"); | |
| 930 | Object.defineProperty(touch, "offsetHeight", { get: () => (reads++, touch.hidden ? 0 : TOUCH_HEIGHT) }); | |
| 931 | for (let i = 0; i < 30; i++) phone.step(FRAME); | |
| 932 | expect(health(phone)).toBe(450 - TOUCH_HEIGHT); | |
| 933 | expect(reads).toBe(0); | |
| 934 | // A rotation is a resize, and a resize is the one other time it is read. | |
| 935 | phone.window.fire("resize", {}); | |
| 936 | expect(reads).toBe(1); | |
| 937 | ||
| 938 | // A mouse: no pads, and the bottom row sits on the margin it always had. | |
| 939 | const desk = playing(); | |
| 940 | for (let i = 0; i < 3; i++) desk.step(FRAME); | |
| 941 | expect(health(desk)).toBe(450 - 16); | |
| 942 | }); | |
| 943 | ||
| 919 | 944 | test("one POST per round scored, in the shape the server accepts", async () => { |
| 920 | 945 | const stage = playing(); |
| 921 | 946 | stage.step(FRAME); |
src/client/main.ts +33 −6
| @@ -190,6 +190,12 @@ export type Panel = Listens & { | ||
| 190 | 190 | appendChild(child: unknown): void; |
| 191 | 191 | }; |
| 192 | 192 | |
| 193 | /** | |
| 194 | * The touch panel: a panel with a height. A browser's `offsetHeight` is it in | |
| 195 | * CSS pixels, and 0 while the panel is hidden, which is the no-pads answer. | |
| 196 | */ | |
| 197 | export type Pads = Panel & { readonly offsetHeight: number }; | |
| 198 | ||
| 193 | 199 | /** A field in the menu: everything a panel is, and what was typed into it. */ |
| 194 | 200 | export type Field = Panel & { value: string }; |
| 195 | 201 | |
| @@ -377,7 +383,11 @@ export function boot(host: Host): Frame { | ||
| 377 | 383 | // reader hears instead. |
| 378 | 384 | const buyShelf = by("buyshelf"); |
| 379 | 385 | const buySays = by("buysays"); |
| 380 | const touchPanel = by("touch"); | |
| 386 | const touchPanel = by<Pads>("touch"); | |
| 387 | // How much of the foot of the stage the pads cover, in CSS pixels: read when | |
| 388 | // they are shown or hidden, and on a resize, and never in a frame. The HUD | |
| 389 | // stands its bottom row on top of it; see `footOf` in `hud.ts`. | |
| 390 | let thumbs = 0; | |
| 381 | 391 | const stats = by("stats"); |
| 382 | 392 | // The one line about whether what you played is on that board. It is a live |
| 383 | 393 | // region in the page, so what is written here is announced and not only drawn. |
| @@ -721,6 +731,20 @@ export function boot(host: Host): Frame { | ||
| 721 | 731 | element.hidden = !on; |
| 722 | 732 | }; |
| 723 | 733 | |
| 734 | /** | |
| 735 | * Show the touch pads or hide them, and read how tall they are while the | |
| 736 | * page's layout is the one that has just been asked for. The panel's rows are | |
| 737 | * a fixed grid (`src/pages/shell.ts`), so its height moves only with a | |
| 738 | * resize, and that is the other place it is read. | |
| 739 | */ | |
| 740 | const showTouch = (on: boolean): void => { | |
| 741 | show(touchPanel, on); | |
| 742 | thumbs = touchPanel.offsetHeight; | |
| 743 | }; | |
| 744 | win.addEventListener("resize", () => { | |
| 745 | thumbs = touchPanel.offsetHeight; | |
| 746 | }); | |
| 747 | ||
| 724 | 748 | // The menu says what it is doing while the atlas is painted, and Play cannot |
| 725 | 749 | // be pressed until there is something to play with: the textures, and the |
| 726 | 750 | // policy the bots think with. Both are undone when the later of the two lands. |
| @@ -1251,7 +1275,7 @@ export function boot(host: Host): Frame { | ||
| 1251 | 1275 | // Whatever the last match ended on: Play again is a match, not a match with |
| 1252 | 1276 | // the one before it still on the screen. |
| 1253 | 1277 | show(summaryPanel, false); |
| 1254 | show(touchPanel, win.matchMedia("(pointer: coarse)").matches); | |
| 1278 | showTouch(coarse()); | |
| 1255 | 1279 | audio.resume(); |
| 1256 | 1280 | void takePointer(); |
| 1257 | 1281 | host.storage.setItem("cs.name", name); |
| @@ -1314,7 +1338,7 @@ export function boot(host: Host): Frame { | ||
| 1314 | 1338 | show(menu, false); |
| 1315 | 1339 | show(pause, false); |
| 1316 | 1340 | show(summaryPanel, false); |
| 1317 | show(touchPanel, win.matchMedia("(pointer: coarse)").matches); | |
| 1341 | showTouch(coarse()); | |
| 1318 | 1342 | audio.resume(); |
| 1319 | 1343 | // The same ask as a match of your own: raw counts first, and the answer read |
| 1320 | 1344 | // — a room is exactly where a silently refused lock would be worst, because |
| @@ -1425,7 +1449,7 @@ export function boot(host: Host): Frame { | ||
| 1425 | 1449 | show(pause, false); |
| 1426 | 1450 | show(summaryPanel, false); |
| 1427 | 1451 | show(menu, true); |
| 1428 | show(touchPanel, false); | |
| 1452 | showTouch(false); | |
| 1429 | 1453 | // The menu is back, so the line before Play is about the next match again — |
| 1430 | 1454 | // which on `generated` is a map nobody has drawn yet. The one just played |
| 1431 | 1455 | // is still named under the stage, and its name is still a link. |
| @@ -1461,7 +1485,7 @@ export function boot(host: Host): Frame { | ||
| 1461 | 1485 | sayMatch(); |
| 1462 | 1486 | show(pause, false); |
| 1463 | 1487 | show(buyPanel, false); |
| 1464 | show(touchPanel, false); | |
| 1488 | showTouch(false); | |
| 1465 | 1489 | show(summaryPanel, true); |
| 1466 | 1490 | againButton.focus(); |
| 1467 | 1491 | } |
| @@ -1707,7 +1731,10 @@ export function boot(host: Host): Frame { | ||
| 1707 | 1731 | hudCanvas.width = w; |
| 1708 | 1732 | hudCanvas.height = h; |
| 1709 | 1733 | } |
| 1710 | drawHud(hudCtx, world, w, h, scoreboard, view, win.devicePixelRatio, motion); | |
| 1734 | // The pads' height is in CSS pixels and the HUD draws in the backing | |
| 1735 | // store's, so it goes over at the same ratio the canvas itself did. | |
| 1736 | const covered = hudCanvas.clientHeight > 0 ? (thumbs * h) / hudCanvas.clientHeight : 0; | |
| 1737 | drawHud(hudCtx, world, w, h, scoreboard, view, win.devicePixelRatio, motion, covered); | |
| 1711 | 1738 | by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`; |
| 1712 | 1739 | } |
| 1713 | 1740 | loop.start(frame); |
src/client/stage.test-helper.ts +10
| @@ -72,6 +72,9 @@ export const WALL = Date.UTC(2026, 4, 17, 12, 0, 0); | ||
| 72 | 72 | */ |
| 73 | 73 | export const WEIGHTS_URL = "/static/weights.0123456789.json"; |
| 74 | 74 | |
| 75 | /** How tall the touch panel is, in CSS pixels, when it is shown: what Chromium measures. */ | |
| 76 | export const TOUCH_HEIGHT = 106; | |
| 77 | ||
| 75 | 78 | /** The id of the meta that names where a round is planned, as the shell writes it. */ |
| 76 | 79 | export const PLAN_ID = "botplan"; |
| 77 | 80 | |
| @@ -237,6 +240,13 @@ export function fakeStage( | ||
| 237 | 240 | // The link the shell renders in the head, with the content-hashed URL on it. |
| 238 | 241 | // `unlinked: true` leaves it blank: a page that named no weights at all. |
| 239 | 242 | if (options.unlinked !== true) page.by("weights").setAttribute("href", WEIGHTS_URL); |
| 243 | // The touch panel is as tall as a browser lays the shell's grid out — two rows | |
| 244 | // of 44px buttons and the panel's padding, 106 CSS pixels at every width — | |
| 245 | // and, like a browser's, nothing at all while it is hidden. | |
| 246 | const touch = page.by("touch"); | |
| 247 | Object.defineProperty(touch, "offsetHeight", { | |
| 248 | get: () => (touch.hidden ? 0 : TOUCH_HEIGHT), configurable: true, | |
| 249 | }); | |
| 240 | 250 | // And the meta the shell renders for an endpoint, when this page has one. |
| 241 | 251 | if (options.planEndpoint !== undefined) page.by(PLAN_ID).setAttribute("content", options.planEndpoint); |
| 242 | 252 | // The same switch on both sides of the seam: `webgl: false` is a browser with |
src/pages/shell.ts +9 −3
| @@ -104,10 +104,16 @@ button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; } | ||
| 104 | 104 | that sized it has to be put back, including the phone's 4:3 below. */ |
| 105 | 105 | #stage:fullscreen { width:100%; height:100%; max-width:none; max-height:none; aspect-ratio:auto; |
| 106 | 106 | margin:0; border:0; border-radius:0; } |
| 107 | #touch { position:absolute; inset:auto 0 0 0; display:flex; justify-content:space-between; padding:.6rem; pointer-events:none; } | |
| 107 | /* The pads are a grid three buttons wide, so their height is a number of rows | |
| 108 | and not whatever a wrap made of the width: six buttons are two rows of 44px | |
| 109 | on a 360px phone and on a desktop alike, and the HUD reads that height to | |
| 110 | stand its bottom row on (see src/client/hud.ts). A flex wrap at 360px was | |
| 111 | four rows — taller than the stage it sat on. */ | |
| 112 | #touch { position:absolute; inset:auto 0 0 0; display:flex; justify-content:space-between; align-items:flex-end; | |
| 113 | padding:.4rem; pointer-events:none; } | |
| 108 | 114 | #touch[hidden] { display:none; } |
| 109 | #touch .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; } | |
| 110 | #touch button { opacity:.72; background:#0f131acc; } | |
| 115 | #touch .pad { display:grid; grid-template-columns:repeat(3,auto); gap:.3rem; pointer-events:auto; } | |
| 116 | #touch button { padding:.2rem .3rem; font-size:.78rem; opacity:.72; background:#0f131acc; } | |
| 111 | 117 | /* The sentence a browser with no script reads, and the only thing on the page |
| 112 | 118 | that is styled for a reader who will never see the game: at the top of main, |
| 113 | 119 | directly over the stage whose one live control is dead without a script. It |