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

Closed opened this
Closed without merging

vesper/cs-engine:vesper/204-the-hud-makes-room-for-thumbs-health-and → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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
531531 put an element full screen — an iPhone will not — never shows that button rather
532532 than showing one that does nothing.
533533
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
534542 **The whole mouse plays, not just its two big buttons.** The wheel jumps either
535543 way it is turned — counted in notches, so one click of a wheel is one press
536544 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 @@
2121
2222 import { expect, test } from "bun:test";
2323 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";
2525 import { recorder, type Call, type Recorder } from "./paint.test-helper.ts";
2626 import { DE_FORGE } from "./map.ts";
2727 import { SHORT } from "./match.ts";
@@ -86,12 +86,14 @@ function draw(
8686 world: World,
8787 options: {
8888 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;
8991 } = {},
9092 ): Recorder {
9193 const paint = recorder();
9294 drawHud(
9395 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,
9597 );
9698 return paint;
9799 }
@@ -722,6 +724,122 @@ test("the same canvas turned sideways is drawn at the new size, not the one befo
722724 expect(bar.args[2]).toBe(Math.round(240 * 0.7));
723725 });
724726
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
725843 // --- flashed ---------------------------------------------------------------
726844
727845 /** 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 & {
5959 */
6060 const RADAR = 180;
6161
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
6269 /**
6370 * What is left of the ammo counter and the weapon's name while the gun is
6471 * 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 {
205212 */
206213 export const FOG = 0.9;
207214
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
208227 /**
209228 * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or
210229 * the player you are watching once you are dead — and it is what every number
@@ -218,6 +237,11 @@ export const FOG = 0.9;
218237 * pixels — and `dpr` is how many of those go to a CSS pixel, so that the
219238 * crosshair is the same size on the glass and exact on the grid at 125% and
220239 * 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.
221245 */
222246 export function drawHud(
223247 ctx: Paint,
@@ -228,12 +252,14 @@ export function drawHud(
228252 view: Player = world.me,
229253 dpr = 1,
230254 motion: Motion = DEFAULT_MOTION,
255 thumbs = 0,
231256 ): void {
232257 const ui = toolsFor(ctx, width, height, dpr);
233258 ctx.clearRect(0, 0, width, height);
234259 ctx.textBaseline = "alphabetic";
235260 const weapon = currentWeapon(view);
236261 const pad = ui.pad;
262 const foot = footOf(height, pad, thumbs);
237263 // Damage is something a body takes, so the indicator is drawn only from the
238264 // eyes of the body that took it: yours, while you are standing. A spectator
239265 // is not being shot at, and a corpse is not turning to face anything.
@@ -388,15 +414,20 @@ export function drawHud(
388414 { align: "left", font: mid, fill: CT_COLOR });
389415
390416 // --- 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,
392418 { align: "left", font: big, fill: view.health > 30 ? WHITE : RED });
393419 ui.text(
394420 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),
396422 { align: "left", font: small, fill: BLUE },
397423 );
398424 // --- 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);
400431 drawMinimap(ctx, ui, world, view, pad, pad, radar);
401432 const moneyY = pad + radar + Math.round(22 * scale);
402433 ui.text(`$${view.money}`, pad, moneyY, { align: "left", font: mid, fill: "#7ddc8a" });
@@ -411,7 +442,7 @@ export function drawHud(
411442 const ready = world.time < view.deployUntil ? NOT_READY_ALPHA : 1;
412443 const ammo = view.ammo[weapon.id];
413444 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 });
415446 // What is in your hands, and then what is in your pockets: the grenades you
416447 // bought and the bomb, each named only while you are carrying it.
417448 ui.text(
@@ -420,7 +451,7 @@ export function drawHud(
420451 (view.pocket.flash > 0 ? " · flash" : "") +
421452 (view.pocket.smoke > 0 ? " · smoke" : "") +
422453 (view.hasBomb ? " · bomb" : ""),
423 width - pad, height - pad - Math.round(30 * scale),
454 width - pad, foot - Math.round(30 * scale),
424455 { align: "right", font: small, fill: GREY, alpha: ready },
425456 );
426457 // What the gun is busy doing, in the one line there is room for. The two
@@ -429,7 +460,7 @@ export function drawHud(
429460 // reading and not a choice between two things happening at once.
430461 const busy = world.time < view.deployUntil ? "switching" : world.time < view.reloadUntil ? "reloading" : null;
431462 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" });
433464
434465 // --- who is left
435466 const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length;
@@ -472,7 +503,7 @@ export function drawHud(
472503 // Above the bottom row, so it never lands on the health or the ammo —
473504 // at 360 pixels wide those are a thumb's width from the middle.
474505 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);
476507 ui.panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h);
477508 ui.text(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale),
478509 { align: "center", font: mid, fill: teamColor(view.team) });
src/client/main.test.ts +26 −1
@@ -47,7 +47,7 @@ import { WEAPONS } from "./weapons.ts";
4747 import { matchOf } from "../matchid.ts";
4848 import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
4949 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";
5151 import { parseSituation } from "../plan/situation.ts";
5252 import { createWorld, type World } from "./world.ts";
5353
@@ -916,6 +916,31 @@ test("a phone gets the touch pads, a mouse does not", () => {
916916 expect(playing().by("touch").hidden).toBe(true);
917917 });
918918
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
919944 test("one POST per round scored, in the shape the server accepts", async () => {
920945 const stage = playing();
921946 stage.step(FRAME);
src/client/main.ts +33 −6
@@ -190,6 +190,12 @@ export type Panel = Listens & {
190190 appendChild(child: unknown): void;
191191 };
192192
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
193199 /** A field in the menu: everything a panel is, and what was typed into it. */
194200 export type Field = Panel & { value: string };
195201
@@ -377,7 +383,11 @@ export function boot(host: Host): Frame {
377383 // reader hears instead.
378384 const buyShelf = by("buyshelf");
379385 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;
381391 const stats = by("stats");
382392 // The one line about whether what you played is on that board. It is a live
383393 // 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 {
721731 element.hidden = !on;
722732 };
723733
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
724748 // The menu says what it is doing while the atlas is painted, and Play cannot
725749 // be pressed until there is something to play with: the textures, and the
726750 // 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 {
12511275 // Whatever the last match ended on: Play again is a match, not a match with
12521276 // the one before it still on the screen.
12531277 show(summaryPanel, false);
1254 show(touchPanel, win.matchMedia("(pointer: coarse)").matches);
1278 showTouch(coarse());
12551279 audio.resume();
12561280 void takePointer();
12571281 host.storage.setItem("cs.name", name);
@@ -1314,7 +1338,7 @@ export function boot(host: Host): Frame {
13141338 show(menu, false);
13151339 show(pause, false);
13161340 show(summaryPanel, false);
1317 show(touchPanel, win.matchMedia("(pointer: coarse)").matches);
1341 showTouch(coarse());
13181342 audio.resume();
13191343 // The same ask as a match of your own: raw counts first, and the answer read
13201344 // — a room is exactly where a silently refused lock would be worst, because
@@ -1425,7 +1449,7 @@ export function boot(host: Host): Frame {
14251449 show(pause, false);
14261450 show(summaryPanel, false);
14271451 show(menu, true);
1428 show(touchPanel, false);
1452 showTouch(false);
14291453 // The menu is back, so the line before Play is about the next match again —
14301454 // which on `generated` is a map nobody has drawn yet. The one just played
14311455 // is still named under the stage, and its name is still a link.
@@ -1461,7 +1485,7 @@ export function boot(host: Host): Frame {
14611485 sayMatch();
14621486 show(pause, false);
14631487 show(buyPanel, false);
1464 show(touchPanel, false);
1488 showTouch(false);
14651489 show(summaryPanel, true);
14661490 againButton.focus();
14671491 }
@@ -1707,7 +1731,10 @@ export function boot(host: Host): Frame {
17071731 hudCanvas.width = w;
17081732 hudCanvas.height = h;
17091733 }
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);
17111738 by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`;
17121739 }
17131740 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);
7272 */
7373 export const WEIGHTS_URL = "/static/weights.0123456789.json";
7474
75 /** How tall the touch panel is, in CSS pixels, when it is shown: what Chromium measures. */
76 export const TOUCH_HEIGHT = 106;
77
7578 /** The id of the meta that names where a round is planned, as the shell writes it. */
7679 export const PLAN_ID = "botplan";
7780
@@ -237,6 +240,13 @@ export function fakeStage(
237240 // The link the shell renders in the head, with the content-hashed URL on it.
238241 // `unlinked: true` leaves it blank: a page that named no weights at all.
239242 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 });
240250 // And the meta the shell renders for an endpoint, when this page has one.
241251 if (options.planEndpoint !== undefined) page.by(PLAN_ID).setAttribute("content", options.planEndpoint);
242252 // 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; }
104104 that sized it has to be put back, including the phone's 4:3 below. */
105105 #stage:fullscreen { width:100%; height:100%; max-width:none; max-height:none; aspect-ratio:auto;
106106 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; }
108114 #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; }
111117 /* The sentence a browser with no script reads, and the only thing on the page
112118 that is styled for a reader who will never see the game: at the top of main,
113119 directly over the stage whose one live control is dead without a script. It

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs