Public: anyone can read this; the crew can change it.
forked from trevato/cs
See the map before you play it: a plan of the layout on the menu, drawn from the level itself #218
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/149-see-the-map-before-you-play-it-a-plan-of → main 1 commit fork
11 files
changed +583
−93
README.md +16
| @@ -239,6 +239,19 @@ the floor for the terrorists to come back for, and blinking red where it was | ||
| 239 | 239 | planted, for both sides. It is the only thing on the screen that says where you |
| 240 | 240 | are on a map generated for that one match, which is why #176 built it. |
| 241 | 241 | |
| 242 | The same drawing is on the menu, before you press Play: a plan of the map the | |
| 243 | button would start — the rooms, the two bombsites lettered, and the two spawns | |
| 244 | in the sides' own colours, which is the one thing a round never shows you. | |
| 245 | Picking **de_forge** shows de_forge, picking **generated** shows the map this | |
| 246 | match's id carves, and typing a name shows that exact map. It is the radar's | |
| 247 | `drawPlan` given a whole level rather than a second drawing of the same data, | |
| 248 | and it is drawn once per choice rather than once a frame: a choice has to stand | |
| 249 | still for 200 ms before the level behind it is carved, so typing `gen_14u5j6g` | |
| 250 | a character at a time costs one map and not ten, and nothing is drawn at all | |
| 251 | while a match is running. Beside it is the same map in words — the size of the | |
| 252 | grid, and where the sites and the spawns are by the compass — so the canvas is | |
| 253 | hidden from the accessibility tree instead of being announced as nothing. | |
| 254 | ||
| 242 | 255 | Killed, you watch the rest of the round out of a teammate's eyes: fire moves |
| 243 | 256 | to the next player on your side and aim moves back one, the camera is *placed* |
| 244 | 257 | on whoever you picked — asking for someone else is a cut, never a flight across |
| @@ -549,6 +562,9 @@ never checked itself. | ||
| 549 | 562 | every frame, north up, with your wedge, your side, the enemies your side can |
| 550 | 563 | see right now and the bomb — it knows no alphabet, so floor is whatever the |
| 551 | 564 | level calls passable and a crate is a dark block in a room), |
| 565 | `preview` (the menu's plan of the map Play would start: `minimap`'s own | |
| 566 | `drawPlan` fitted to a small square, with both sides' spawns on it and the | |
| 567 | sentence a screen reader is given instead of the picture), | |
| 552 | 568 | `palette` (what colour a side is, in the one place both surfaces read it), |
| 553 | 569 | `spectate` (whose eyes a frame |
| 554 | 570 | is drawn from once you are dead, and the camera that eases onto them), |
src/client/main.test.ts +101
| @@ -30,6 +30,8 @@ import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame | ||
| 30 | 30 | import { DE_FORGE } from "./map.ts"; |
| 31 | 31 | import { SHORT } from "./match.ts"; |
| 32 | 32 | import { LAYERS, materialTextures } from "./materials.ts"; |
| 33 | import { recorder } from "./paint.test-helper.ts"; | |
| 34 | import { drawPreview, previewText } from "./preview.ts"; | |
| 33 | 35 | import { |
| 34 | 36 | FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, REFUSED, RETRYING, SAVED, waitAfter, |
| 35 | 37 | } from "./pending.ts"; |
| @@ -910,6 +912,105 @@ test("copy this map is a link first and a clipboard second", async () => { | ||
| 910 | 912 | expect(stage.copied.length).toBe(2); |
| 911 | 913 | }); |
| 912 | 914 | |
| 915 | // --- the map you can see before you play it ------------------------------- | |
| 916 | ||
| 917 | /** | |
| 918 | * How long a choice has to stand still before the plan of it is drawn, in | |
| 919 | * frames of this test's clock. It is `PLAN_WAIT` in `main.ts` — 200 ms — and it | |
| 920 | * is written as a count of frames because that is the only clock a test has. | |
| 921 | */ | |
| 922 | const SETTLE = Math.ceil(200 / FRAME) + 1; | |
| 923 | ||
| 924 | /** The stub canvas's size, which is what a browser reports before any CSS. */ | |
| 925 | const PLAN_WIDTH = 800; | |
| 926 | const PLAN_HEIGHT = 450; | |
| 927 | ||
| 928 | /** Every 2D call the menu's plan canvas has been given, oldest first. */ | |
| 929 | const planCalls = (stage: FakeStage): [string, ...unknown[]][] => stage.plan.paint.calls; | |
| 930 | ||
| 931 | /** What `drawPreview` puts on a canvas this size, for a level this test names. */ | |
| 932 | function planFor(level: Level): [string, ...unknown[]][] { | |
| 933 | const paint = recorder(); | |
| 934 | drawPreview(paint, level, PLAN_WIDTH, PLAN_HEIGHT); | |
| 935 | return paint.calls.map((call) => [call.op, ...call.args] as [string, ...unknown[]]); | |
| 936 | } | |
| 937 | ||
| 938 | test("the menu draws the map Play would start, once the choice has stood still", () => { | |
| 939 | const stage = painted(start(fakeStage())); | |
| 940 | // Nothing is drawn while the atlas is being painted: the menu is answerable | |
| 941 | // and the frames belong to the browser. | |
| 942 | expect(planCalls(stage).length).toBe(0); | |
| 943 | expect(stage.by("planalt").textContent).toBe(""); | |
| 944 | ||
| 945 | // One frame past the wait and the plan of de_forge is on the canvas, at the | |
| 946 | // size the browser is giving us of it, with the sentence beside it. | |
| 947 | for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME); | |
| 948 | expect([stage.plan.width, stage.plan.height]).toEqual([PLAN_WIDTH, PLAN_HEIGHT]); | |
| 949 | expect(planCalls(stage)).toEqual(planFor(DE_FORGE)); | |
| 950 | expect(stage.by("planalt").textContent).toBe(previewText(DE_FORGE)); | |
| 951 | ||
| 952 | // Once per choice and not once a frame: the frames after it draw nothing. | |
| 953 | const drawn = planCalls(stage).length; | |
| 954 | for (let frame = 0; frame < 10; frame++) stage.step(FRAME); | |
| 955 | expect(planCalls(stage).length).toBe(drawn); | |
| 956 | ||
| 957 | // A new map every match: the plan is the map that id carves, which is the map | |
| 958 | // the menu just named and the map Play is about to start. | |
| 959 | stage.by("map").value = "generated"; | |
| 960 | stage.by("map").fire("change"); | |
| 961 | const level = generateLevel(seedFrom(stage.ids[0] as string)); | |
| 962 | expect(stage.by("willplay").textContent).toBe(level.name); | |
| 963 | for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME); | |
| 964 | expect(planCalls(stage).slice(drawn)).toEqual(planFor(level)); | |
| 965 | expect(stage.by("planalt").textContent).toBe(previewText(level)); | |
| 966 | ||
| 967 | // And it is that map that is played, with no second id drawn for it. | |
| 968 | stage.by("play").fire("click"); | |
| 969 | expect(worldOf(stage)?.level.name).toBe(level.name); | |
| 970 | expect(stage.ids.length).toBe(1); | |
| 971 | }); | |
| 972 | ||
| 973 | test("a name typed a character at a time costs one map, and none while a match runs", () => { | |
| 974 | const stage = painted(start(fakeStage())); | |
| 975 | for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME); | |
| 976 | const drawn = planCalls(stage).length; | |
| 977 | ||
| 978 | // Typing: a keystroke a frame, which is faster than the wait, so no map is | |
| 979 | // carved for any of the prefixes — `gen_1` is a map, and not the one meant. | |
| 980 | for (const upto of [5, 6, 7, 8, 9, 10, 11]) { | |
| 981 | stage.by("mapseed").value = MAP.slice(0, upto); | |
| 982 | stage.by("mapseed").fire("input"); | |
| 983 | stage.step(FRAME); | |
| 984 | } | |
| 985 | expect(planCalls(stage).length).toBe(drawn); | |
| 986 | ||
| 987 | // Hands off the keyboard, and the map they named is on the screen. | |
| 988 | for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME); | |
| 989 | const named = generateLevel(seedOf(MAP) as number); | |
| 990 | expect(planCalls(stage).slice(drawn)).toEqual(planFor(named)); | |
| 991 | ||
| 992 | // A name that is not one is de_forge on the plan too, like everywhere else. | |
| 993 | const typed = planCalls(stage).length; | |
| 994 | stage.by("mapseed").value = "gen_0ya"; | |
| 995 | stage.by("mapseed").fire("input"); | |
| 996 | for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME); | |
| 997 | expect(planCalls(stage).slice(typed)).toEqual(planFor(DE_FORGE)); | |
| 998 | ||
| 999 | // Nothing is drawn while a match is running: the menu is not on the screen | |
| 1000 | // and the frame belongs to the round. | |
| 1001 | const before = planCalls(stage).length; | |
| 1002 | stage.by("mapseed").value = MAP; | |
| 1003 | stage.by("mapseed").fire("input"); | |
| 1004 | stage.by("play").fire("click"); | |
| 1005 | for (let frame = 0; frame < SETTLE + 10; frame++) stage.step(FRAME); | |
| 1006 | expect(planCalls(stage).length).toBe(before); | |
| 1007 | ||
| 1008 | // Back at the menu it is drawn again, for the map the next Play would start. | |
| 1009 | stage.by("quit").fire("click"); | |
| 1010 | for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME); | |
| 1011 | expect(planCalls(stage).slice(before)).toEqual(planFor(named)); | |
| 1012 | }); | |
| 1013 | ||
| 913 | 1014 | /** |
| 914 | 1015 | * A match played from one id: the page hands that id out instead of a fresh |
| 915 | 1016 | * one, Play is pressed, and `frames` frames are stepped. The id is the only |
src/client/main.ts +77 −9
| @@ -18,7 +18,10 @@ | ||
| 18 | 18 | * with. The menu names the map Play is about to give you *before* Play, which |
| 19 | 19 | * is why the id for a match that wants a new map is drawn when the menu asks |
| 20 | 20 | * rather than when the button is pressed, and the link beside that name is the |
| 21 | * map by name: press it and you get that layout, not another new one. | |
| 21 | * map by name: press it and you get that layout, not another new one. Beside | |
| 22 | * that name is a plan of the map itself, drawn through `preview.ts` once per | |
| 23 | * choice — never per frame and never during a match — so what a player picks is | |
| 24 | * a place and not a number. | |
| 22 | 25 | * |
| 23 | 26 | * A round that did not land is not lost quietly. The result of every round is |
| 24 | 27 | * posted as it is scored, and a post that threw or came back anything but 200 |
| @@ -63,6 +66,7 @@ import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; | ||
| 63 | 66 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 64 | 67 | import { meshFor } from "./models.ts"; |
| 65 | 68 | import { nextStep, type Answer } from "./pending.ts"; |
| 69 | import { drawPreview, previewText } from "./preview.ts"; | |
| 66 | 70 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 67 | 71 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 68 | 72 | import { buyable, WEAPONS } from "./weapons.ts"; |
| @@ -71,6 +75,16 @@ import { createWorld, eyePos, update, type Player, type World } from "./world.ts | ||
| 71 | 75 | |
| 72 | 76 | const TICK = 1 / 64; |
| 73 | 77 | |
| 78 | /** | |
| 79 | * How still the Map controls have to be before the map they name is carved and | |
| 80 | * drawn, in milliseconds. It is a debounce rather than a blur handler because | |
| 81 | * the choice is three controls and a person moves between them — a blur is not | |
| 82 | * the moment a choice was made. 200 ms is longer than the gap between two | |
| 83 | * keystrokes of a name being typed and shorter than a person noticing, so | |
| 84 | * `gen_14u5j6g` costs one map and the picture is there when they look up. | |
| 85 | */ | |
| 86 | const PLAN_WAIT = 200; | |
| 87 | ||
| 74 | 88 | /** |
| 75 | 89 | * What the menu says when the field holds something that is not a match id. |
| 76 | 90 | * A typo is not an error page and not a refusal to play: the match starts, it |
| @@ -107,19 +121,21 @@ export type Pressable = Listens & { type: string; textContent: string | null; di | ||
| 107 | 121 | /** The box the canvases sit in: what goes full screen, when a browser will. */ |
| 108 | 122 | export type Box = Panel & { requestFullscreen?(): unknown }; |
| 109 | 123 | |
| 110 | /** A button that says which of two states it is in — the full screen one does. */ | |
| 111 | export type Toggle = Panel & { setAttribute(name: string, value: string): void }; | |
| 112 | ||
| 113 | /** A canvas on the stage: the scene is drawn into, the HUD is painted on. */ | |
| 114 | export type Stage = Panel & { | |
| 124 | /** A canvas this page only ever draws on: the plan of the map, under the menu. */ | |
| 125 | export type Sheet = Panel & { | |
| 115 | 126 | readonly clientWidth: number; |
| 116 | 127 | readonly clientHeight: number; |
| 117 | 128 | width: number; |
| 118 | 129 | height: number; |
| 119 | 130 | getContext(kind: "2d"): Paint | null; |
| 120 | requestPointerLock(): unknown; | |
| 121 | 131 | }; |
| 122 | 132 | |
| 133 | /** A button that says which of two states it is in — the full screen one does. */ | |
| 134 | export type Toggle = Panel & { setAttribute(name: string, value: string): void }; | |
| 135 | ||
| 136 | /** A canvas on the stage: the scene is drawn into, the HUD is painted on. */ | |
| 137 | export type Stage = Sheet & { requestPointerLock(): unknown }; | |
| 138 | ||
| 123 | 139 | export type PageDocument = Listens & { |
| 124 | 140 | readonly pointerLockElement: unknown; |
| 125 | 141 | /** Whose element is on the screen right now — the engine's `createFullscreen` reads it. */ |
| @@ -262,8 +278,11 @@ export function boot(host: Host): Frame { | ||
| 262 | 278 | // The Map control's other half: one exact map, by name. It wins over the |
| 263 | 279 | // select, because a person who typed a name meant that map. |
| 264 | 280 | const seedField = by<Field>("mapseed"); |
| 265 | // The name of the map Play is about to give you, and the link that keeps it. | |
| 281 | // The name of the map Play is about to give you, the plan of it, the line | |
| 282 | // that says in words what the plan says in pixels, and the link that keeps it. | |
| 266 | 283 | const willPlay = by("willplay"); |
| 284 | const planCanvas = by<Sheet>("plan"); | |
| 285 | const planAlt = by("planalt"); | |
| 267 | 286 | const copyMap = by<Toggle>("copymap"); |
| 268 | 287 | const copyLabel = copyMap.textContent; |
| 269 | 288 | const prep = by("prep"); |
| @@ -282,6 +301,7 @@ export function boot(host: Host): Frame { | ||
| 282 | 301 | // principle have been called before the check ran. |
| 283 | 302 | const build = maybeBuild; |
| 284 | 303 | const hudCtx = hudCanvas.getContext("2d") as Paint; |
| 304 | const planCtx = planCanvas.getContext("2d") as Paint; | |
| 285 | 305 | const audio = host.createAudio(); |
| 286 | 306 | const controls = createControls( |
| 287 | 307 | { surface: sceneCanvas, window: win, document: doc }, |
| @@ -373,6 +393,17 @@ export function boot(host: Host): Frame { | ||
| 373 | 393 | let drawn: string | null = null; |
| 374 | 394 | /** The URL the copy control is holding, which is the one it puts on the clipboard. */ |
| 375 | 395 | let keeping = mapLink("de_forge"); |
| 396 | /** | |
| 397 | * When the plan of the chosen map is due to be drawn, on the frame clock, or | |
| 398 | * null when what is on the canvas is already the map Play would start. | |
| 399 | * Carving a level is about six milliseconds and the Map name field fires on | |
| 400 | * every keystroke, so a choice has to stand still for `PLAN_WAIT` before it | |
| 401 | * costs anything: `gen_14u5j6g` typed a character at a time is one map drawn, | |
| 402 | * not ten. The drawing itself is not the price — it is three hundredths of a | |
| 403 | * millisecond, because the rectangles are merged once per level. Scheduled here and spent in the frame loop, which is the only clock | |
| 404 | * this file has and the only place a slow frame can be seen. | |
| 405 | */ | |
| 406 | let planAt: number | null = null; | |
| 376 | 407 | // The map the renderer is holding the geometry of. It starts as the one the |
| 377 | 408 | // meshes were built from, and a match on another map swaps that one mesh. |
| 378 | 409 | let shown: Level = DE_FORGE; |
| @@ -622,6 +653,35 @@ export function boot(host: Host): Frame { | ||
| 622 | 653 | copyMap.setAttribute("href", keeping); |
| 623 | 654 | copyMap.textContent = copyLabel; |
| 624 | 655 | onMap.textContent = badName() ? NOT_A_MAP : aboutMap; |
| 656 | // The picture of it is not drawn here: naming a map is a string and drawing | |
| 657 | // one is a level, and this runs on every keystroke. See `PLAN_WAIT`. | |
| 658 | planAt = win.performance.now() + PLAN_WAIT; | |
| 659 | } | |
| 660 | ||
| 661 | /** | |
| 662 | * The plan of the map Play would start, drawn on the small canvas beside its | |
| 663 | * name, and the sentence beside that for anyone who is not looking at it. The | |
| 664 | * level is chosen exactly the way `start` below chooses it — same fields, same | |
| 665 | * seed, same `levelOfChoice` — so the picture is the map, not a map like it. | |
| 666 | * | |
| 667 | * `generated` is the only choice that needs the match's id, so it is the only | |
| 668 | * one that draws one. Picking de_forge or typing a name hands out no id here, | |
| 669 | * exactly as naming that map on the line above hands out none. | |
| 670 | */ | |
| 671 | function drawMapPlan(): void { | |
| 672 | const choice = chosenMap(); | |
| 673 | const level = levelOfChoice(choice, choice === "generated" ? seedFrom(nextMatch()) : 0); | |
| 674 | // Device pixels, like the HUD's canvas: the CSS box is a fixed square and | |
| 675 | // this is what the browser is actually giving us of it. | |
| 676 | const dpr = Math.min(win.devicePixelRatio || 1, 2); | |
| 677 | const w = Math.round(planCanvas.clientWidth * dpr); | |
| 678 | const h = Math.round(planCanvas.clientHeight * dpr); | |
| 679 | if (planCanvas.width !== w || planCanvas.height !== h) { | |
| 680 | planCanvas.width = w; | |
| 681 | planCanvas.height = h; | |
| 682 | } | |
| 683 | drawPreview(planCtx, level, w, h); | |
| 684 | planAlt.textContent = previewText(level); | |
| 625 | 685 | } |
| 626 | 686 | |
| 627 | 687 | for (const field of [mapField, seedField, idField]) |
| @@ -779,7 +839,15 @@ export function boot(host: Host): Frame { | ||
| 779 | 839 | // The round that has not landed, tried again on the frame its backoff runs |
| 780 | 840 | // out — including after the match is over, when this is the only frame left |
| 781 | 841 | // that runs. It is four comparisons and no work at all in the common case. |
| 782 | send(win.performance.now()); | |
| 842 | const now = win.performance.now(); | |
| 843 | send(now); | |
| 844 | // The plan of the map the menu is offering, once the choice has stood still | |
| 845 | // — and never while a match is running, where the frame belongs to the round | |
| 846 | // and the menu is not on the screen to look at anyway. | |
| 847 | if (planAt !== null && !running && now >= planAt) { | |
| 848 | planAt = null; | |
| 849 | drawMapPlan(); | |
| 850 | } | |
| 783 | 851 | if (world === null || !running) return; |
| 784 | 852 | |
| 785 | 853 | const me = world.me; |
src/client/minimap.ts +47 −16
| @@ -26,6 +26,12 @@ | ||
| 26 | 26 | * the radar while somebody alive on your side can see them, and off it the |
| 27 | 27 | * moment nobody can. There is no memory of where one was a second ago, which is |
| 28 | 28 | * deliberate — a dot that lingers is a dot that lies. |
| 29 | * | |
| 30 | * Half of it is not about a round at all: a level fitted to a square, its floor, | |
| 31 | * its two sites and the letters on them. That half is `drawPlan`, and the menu | |
| 32 | * draws the map you are about to play with it — see `src/client/preview.ts`. It | |
| 33 | * hands back the projection it used, because a caller that wants to put anything | |
| 34 | * else on the square needs exactly the arithmetic that put the rooms there. | |
| 29 | 35 | */ |
| 30 | 36 | |
| 31 | 37 | import { mergeSolid, type Box, type Overlay, type Paint, type Vec3 } from "../engine/index.ts"; |
| @@ -85,23 +91,26 @@ export function planOf(level: Level): Plan { | ||
| 85 | 91 | } |
| 86 | 92 | |
| 87 | 93 | /** |
| 88 | * Draw the radar into the square at (`x`, `y`) of side `side`. `view` is whose | |
| 89 | * eyes this frame is drawn from — you, or the player you are watching once you | |
| 90 | * are dead — which decides which side is yours and who counts as an enemy. | |
| 91 | * | |
| 92 | * Where the square sits is the HUD's business, like every other position on the | |
| 93 | * screen; what goes in it is this file's. | |
| 94 | * Where a point of the world lands on the square a plan was drawn in. It is two | |
| 95 | * functions rather than a matrix because that is all the projection is: one | |
| 96 | * scale, one centre, and the one minus sign that puts north up. | |
| 94 | 97 | */ |
| 95 | export function drawMinimap( | |
| 96 | ctx: Paint, | |
| 97 | ui: Overlay, | |
| 98 | world: World, | |
| 99 | view: Player, | |
| 100 | x: number, | |
| 101 | y: number, | |
| 102 | side: number, | |
| 103 | ): void { | |
| 104 | const level = world.level; | |
| 98 | export type Projection = { | |
| 99 | readonly atX: (worldX: number) => number; | |
| 100 | readonly atY: (worldY: number) => number; | |
| 101 | }; | |
| 102 | ||
| 103 | /** | |
| 104 | * The level itself, flat in the square at (`x`, `y`) of side `side`: the | |
| 105 | * backdrop, every cell a player can stand on, the two bombsites over them and | |
| 106 | * the letters they answer to. Nothing in here is about a round — no players, no | |
| 107 | * bomb, no time — so the radar draws it every frame and the menu draws it once | |
| 108 | * per choice, off the same rectangles. | |
| 109 | * | |
| 110 | * Hands back the projection it used, so whatever the caller puts on the square | |
| 111 | * next lands where this put the rooms. | |
| 112 | */ | |
| 113 | export function drawPlan(ui: Overlay, level: Level, x: number, y: number, side: number): Projection { | |
| 105 | 114 | const plan = planOf(level); |
| 106 | 115 | // The map is centred on the world's origin and so is the square. |
| 107 | 116 | const k = side / plan.span; |
| @@ -131,6 +140,28 @@ export function drawMinimap( | ||
| 131 | 140 | const at = level.sites[site.letter]; |
| 132 | 141 | ui.text(site.letter, atX(at[0]), atY(at[1]) + 5 * ui.scale, { align: "center", font, fill: LABEL }); |
| 133 | 142 | } |
| 143 | return { atX, atY }; | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Draw the radar into the square at (`x`, `y`) of side `side`. `view` is whose | |
| 148 | * eyes this frame is drawn from — you, or the player you are watching once you | |
| 149 | * are dead — which decides which side is yours and who counts as an enemy. | |
| 150 | * | |
| 151 | * Where the square sits is the HUD's business, like every other position on the | |
| 152 | * screen; what goes in it is this file's. | |
| 153 | */ | |
| 154 | export function drawMinimap( | |
| 155 | ctx: Paint, | |
| 156 | ui: Overlay, | |
| 157 | world: World, | |
| 158 | view: Player, | |
| 159 | x: number, | |
| 160 | y: number, | |
| 161 | side: number, | |
| 162 | ): void { | |
| 163 | // The level, and only the level: everything below is the round on top of it. | |
| 164 | const { atX, atY } = drawPlan(ui, world.level, x, y, side); | |
| 134 | 165 | |
| 135 | 166 | const dot = (at: Vec3, radius: number, fill: string): void => { |
| 136 | 167 | ctx.fillStyle = fill; |
src/client/preview.test.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 2 | * What a player sees on the menu before they press Play: a whole level in a | |
| 3 | * square, its two bombsites lettered and its two spawns marked in the colours | |
| 4 | * the sides are drawn in everywhere else. | |
| 5 | * | |
| 6 | * It reads frames off `paint.test-helper.ts`'s recorder the way `hud.test.ts` | |
| 7 | * and `minimap.test.ts` do — the brush as well as the call, because the colour | |
| 8 | * of a rectangle is what says whose spawn it is — and it pins the claims that | |
| 9 | * are about the picture rather than about the arrangement: everything inside | |
| 10 | * the box, the box square whatever shape the canvas is, north up, and the | |
| 11 | * sentence beside it made of the level and not of a template. | |
| 12 | * | |
| 13 | * No canvas, no document, no browser. | |
| 14 | */ | |
| 15 | ||
| 16 | import { expect, test } from "bun:test"; | |
| 17 | import { generateLevel } from "./generate.ts"; | |
| 18 | import type { Level } from "./level.ts"; | |
| 19 | import { DE_FORGE } from "./map.ts"; | |
| 20 | import { CT_COLOR, T_COLOR } from "./palette.ts"; | |
| 21 | import { compass, drawPreview, previewText } from "./preview.ts"; | |
| 22 | import { recorder, type Call, type Recorder } from "./paint.test-helper.ts"; | |
| 23 | ||
| 24 | /** The colours this file reads back, which are `minimap.ts`'s own. */ | |
| 25 | const BACKDROP = "rgba(8, 10, 14, 0.62)"; | |
| 26 | const FLOOR = "rgba(146, 156, 174, 0.5)"; | |
| 27 | ||
| 28 | /** A map nobody wrote, so nothing below is about de_forge in particular. */ | |
| 29 | const GENERATED = generateLevel(12345); | |
| 30 | ||
| 31 | const draw = (level: Level, width = 192, height = 192): Recorder => { | |
| 32 | const paint = recorder(); | |
| 33 | drawPreview(paint, level, width, height); | |
| 34 | return paint; | |
| 35 | }; | |
| 36 | ||
| 37 | const ops = (paint: Recorder, op: string): Call[] => paint.calls.filter((c) => c.op === op); | |
| 38 | const inColor = (calls: Call[], color: string): Call[] => calls.filter((c) => c.fill === color); | |
| 39 | const box = (call: Call): number[] => call.args.slice(0, 4).map(Number); | |
| 40 | ||
| 41 | test("the whole level is in the square: its rooms, its two sites, its two spawns", () => { | |
| 42 | for (const level of [DE_FORGE, GENERATED]) { | |
| 43 | const paint = draw(level); | |
| 44 | const rects = ops(paint, "fillRect"); | |
| 45 | ||
| 46 | // The backdrop is the square and the square is the canvas. | |
| 47 | const backdrop = inColor(rects, BACKDROP); | |
| 48 | expect(backdrop.length).toBe(1); | |
| 49 | expect(box(backdrop[0] as Call)).toEqual([0, 0, 192, 192]); | |
| 50 | ||
| 51 | // Rooms, and the two sites lettered in reading order. | |
| 52 | expect(inColor(rects, FLOOR).length).toBeGreaterThan(10); | |
| 53 | expect(ops(paint, "fillText").map((c) => c.args[0])).toEqual(["A", "B"]); | |
| 54 | ||
| 55 | // Both spawns, one mark per cell a side starts a round on — and they are | |
| 56 | // the sides' own colours, so a player reads which end is theirs. | |
| 57 | expect(inColor(rects, T_COLOR).length).toBe(level.spawns.t.length); | |
| 58 | expect(inColor(rects, CT_COLOR).length).toBe(level.spawns.ct.length); | |
| 59 | ||
| 60 | // Nothing is drawn outside the box, in either direction. | |
| 61 | for (const rect of rects) { | |
| 62 | const [x, y, w, h] = box(rect) as [number, number, number, number]; | |
| 63 | expect(x).toBeGreaterThanOrEqual(0); | |
| 64 | expect(y).toBeGreaterThanOrEqual(0); | |
| 65 | expect(x + w).toBeLessThanOrEqual(192); | |
| 66 | expect(y + h).toBeLessThanOrEqual(192); | |
| 67 | } | |
| 68 | for (const text of ops(paint, "fillText")) { | |
| 69 | expect(text.args[1] as number).toBeGreaterThan(0); | |
| 70 | expect(text.args[1] as number).toBeLessThan(192); | |
| 71 | expect(text.args[2] as number).toBeGreaterThan(0); | |
| 72 | expect(text.args[2] as number).toBeLessThan(192); | |
| 73 | } | |
| 74 | } | |
| 75 | }); | |
| 76 | ||
| 77 | test("north is up and the sides are at opposite ends of it", () => { | |
| 78 | const paint = draw(GENERATED); | |
| 79 | const middle = (calls: Call[]): number => | |
| 80 | calls.reduce((sum, c) => sum + (c.args[1] as number), 0) / calls.length; | |
| 81 | // The terrorists spawn in the south of this map and the counter-terrorists in | |
| 82 | // the north, so on the square the T marks are the lower ones. | |
| 83 | const t = middle(inColor(ops(paint, "fillRect"), T_COLOR)); | |
| 84 | const ct = middle(inColor(ops(paint, "fillRect"), CT_COLOR)); | |
| 85 | expect(GENERATED.spawns.t[0]?.[1]).toBeLessThan(0); | |
| 86 | expect(t).toBeGreaterThan(ct); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a canvas that is not square still gets a square map, centred", () => { | |
| 90 | // The stub page's canvas is 800 by 450, which is what a browser hands back | |
| 91 | // before any CSS has been applied to it. | |
| 92 | const paint = draw(DE_FORGE, 800, 450); | |
| 93 | const backdrop = inColor(ops(paint, "fillRect"), BACKDROP)[0] as Call; | |
| 94 | expect(box(backdrop)).toEqual([175, 0, 450, 450]); | |
| 95 | // It is cleared first: the map before it was a different shape. | |
| 96 | expect(paint.calls[0]?.op).toBe("clearRect"); | |
| 97 | expect(box(paint.calls[0] as Call)).toEqual([0, 0, 800, 450]); | |
| 98 | // And a canvas with no pixels in it is nothing drawn rather than a divide by | |
| 99 | // zero: a browser hands one of those back for a hidden element. | |
| 100 | expect(draw(DE_FORGE, 0, 0).calls.length).toBe(1); | |
| 101 | }); | |
| 102 | ||
| 103 | test("the line beside it is the level, in words", () => { | |
| 104 | const line = previewText(GENERATED); | |
| 105 | const { grid } = GENERATED; | |
| 106 | expect(line).toContain(`${grid.width}×${grid.height} cells`); | |
| 107 | expect(line).toContain("bombsite A north-east"); | |
| 108 | expect(line).toContain("B south-west"); | |
| 109 | // Where each side starts, which is the one thing on the plan a round never | |
| 110 | // shows you: this map spawns the terrorists south and the CTs north. | |
| 111 | expect(line).toContain("spawns south and north"); | |
| 112 | // It does not repeat the map's name — the name is the element above it. | |
| 113 | expect(line).not.toContain(GENERATED.name); | |
| 114 | ||
| 115 | // de_forge is a different map and gets a different sentence. | |
| 116 | expect(previewText(DE_FORGE)).not.toBe(line); | |
| 117 | expect(previewText(DE_FORGE)).toContain("44×44 cells"); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a compass word is dropped when a thing is not really off centre", () => { | |
| 121 | const SPAN = 1000; | |
| 122 | expect(compass([400, 400, 0], SPAN)).toBe("north-east"); | |
| 123 | expect(compass([-400, -400, 0], SPAN)).toBe("south-west"); | |
| 124 | expect(compass([0, 400, 0], SPAN)).toBe("north"); | |
| 125 | expect(compass([-400, 0, 0], SPAN)).toBe("west"); | |
| 126 | expect(compass([0, 0, 0], SPAN)).toBe("the middle"); | |
| 127 | // Inside a twelfth of the map is the middle of it, not faintly north of it. | |
| 128 | expect(compass([10, -20, 0], SPAN)).toBe("the middle"); | |
| 129 | }); |
src/client/preview.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * The plan of the map on the menu: the layout Play is about to start, before | |
| 3 | * Play. Choosing a generated map used to be choosing a number — the menu named | |
| 4 | * it, and the name told you nothing about the place — so this is the level | |
| 5 | * itself, drawn small beside that name. | |
| 6 | * | |
| 7 | * It is the radar's drawing given a whole level instead of a corner of one: | |
| 8 | * `drawPlan` in `./minimap.ts` is the projection and the merged floor | |
| 9 | * rectangles, and what is added here is the one thing a round has that a menu | |
| 10 | * does not — the two spawns, which nobody can see from inside a match and | |
| 11 | * everybody wants to see before one. | |
| 12 | * | |
| 13 | * `previewText` is what a screen reader gets instead of the picture: the size of | |
| 14 | * the grid and where the sites and the spawns are, in compass words off the | |
| 15 | * level's own coordinates. The canvas itself is hidden from the accessibility | |
| 16 | * tree by the page — a canvas announced is a canvas announced as nothing. | |
| 17 | * | |
| 18 | * Nothing here is a browser: it takes a `Paint` and two numbers, which is a real | |
| 19 | * 2D context in a page and a recorder in a test. | |
| 20 | */ | |
| 21 | ||
| 22 | import { overlay, type Overlay, type Paint, type Vec3 } from "../engine/index.ts"; | |
| 23 | import type { Level } from "./level.ts"; | |
| 24 | import { drawPlan, type Projection } from "./minimap.ts"; | |
| 25 | import type { Team } from "./match.ts"; | |
| 26 | import { teamColor } from "./palette.ts"; | |
| 27 | ||
| 28 | /** | |
| 29 | * How far off centre a thing has to be before it is called north or east, as a | |
| 30 | * share of the map's longer side. Under it the word is dropped: a site in the | |
| 31 | * middle of a map is in the middle of it, not faintly north of it. | |
| 32 | */ | |
| 33 | const OFF_CENTRE = 0.08; | |
| 34 | ||
| 35 | /** The middle of a cloud of points — the five cells a side spawns on. */ | |
| 36 | function middleOf(points: readonly Vec3[]): Vec3 { | |
| 37 | if (points.length === 0) return [0, 0, 0]; | |
| 38 | let x = 0; | |
| 39 | let y = 0; | |
| 40 | for (const point of points) { | |
| 41 | x += point[0]; | |
| 42 | y += point[1]; | |
| 43 | } | |
| 44 | return [x / points.length, y / points.length, 0]; | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Where a point is on a map whose longer side is `span`, in the words a person | |
| 49 | * would use: `north-east`, `south`, or `the middle` when it is neither. | |
| 50 | */ | |
| 51 | export function compass(at: Vec3, span: number): string { | |
| 52 | const edge = span * OFF_CENTRE; | |
| 53 | const northSouth = at[1] > edge ? "north" : at[1] < -edge ? "south" : ""; | |
| 54 | const eastWest = at[0] > edge ? "east" : at[0] < -edge ? "west" : ""; | |
| 55 | if (northSouth === "" && eastWest === "") return "the middle"; | |
| 56 | return northSouth === "" || eastWest === "" ? northSouth + eastWest : `${northSouth}-${eastWest}`; | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * The line beside the picture: what this map is, in one sentence made of the | |
| 61 | * level and nothing else. It does not repeat the map's name — the name is the | |
| 62 | * element above it on the page — and it stays short because it sits in a column | |
| 63 | * beside a 96-pixel square on a 360-pixel screen. | |
| 64 | */ | |
| 65 | export function previewText(level: Level): string { | |
| 66 | const { grid, sites, spawns } = level; | |
| 67 | const span = Math.max(grid.width, grid.height) * grid.cell; | |
| 68 | const where = (at: Vec3): string => compass(at, span); | |
| 69 | return `${grid.width}×${grid.height} cells · bombsite A ${where(sites.A)}, B ${where(sites.B)} · ` + | |
| 70 | `spawns ${where(middleOf(spawns.t))} and ${where(middleOf(spawns.ct))}`; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * One side's spawn cells, as squares in that side's colour. A square is the | |
| 75 | * cell it stands on and not a dot of its own: a side spawns on thirty of them | |
| 76 | * and thirty dots big enough to see would be a blot over the rooms behind them. | |
| 77 | */ | |
| 78 | function drawSpawns(ui: Overlay, at: Projection, points: readonly Vec3[], half: number, team: Team): void { | |
| 79 | const fill = teamColor(team); | |
| 80 | for (const point of points) | |
| 81 | ui.panel(Math.round(at.atX(point[0])) - half, Math.round(at.atY(point[1])) - half, half * 2, half * 2, fill); | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * The whole preview, into a canvas `width` by `height` device pixels: the level | |
| 86 | * in the largest square that fits, centred, with both sides' spawns on it. The | |
| 87 | * square is what keeps a map a map on a canvas a browser rounded to something | |
| 88 | * else — a plan stretched to a 16:9 box is a plan that lies about a corner. | |
| 89 | * | |
| 90 | * It clears first, because it is drawn once per choice rather than once a frame | |
| 91 | * and the map before it was a different shape. | |
| 92 | */ | |
| 93 | export function drawPreview(ctx: Paint, level: Level, width: number, height: number): void { | |
| 94 | ctx.clearRect(0, 0, width, height); | |
| 95 | const side = Math.min(width, height); | |
| 96 | if (side <= 0) return; | |
| 97 | const x = Math.round((width - side) / 2); | |
| 98 | const y = Math.round((height - side) / 2); | |
| 99 | const ui = overlay(ctx, side, side); | |
| 100 | const at = drawPlan(ui, level, x, y, side); | |
| 101 | // One cell of this map, in pixels of this square, and never under a pixel. | |
| 102 | const half = Math.max(1, Math.round(side / Math.max(level.grid.width, level.grid.height) / 2)); | |
| 103 | drawSpawns(ui, at, level.spawns.t, half, "t"); | |
| 104 | drawSpawns(ui, at, level.spawns.ct, half, "ct"); | |
| 105 | } |
src/client/stage.test-helper.ts +7 −4
| @@ -112,6 +112,8 @@ export type FakeStage = { | ||
| 112 | 112 | keyboard: FakeKeyboard; |
| 113 | 113 | scene: StageCanvas; |
| 114 | 114 | hud: StageCanvas; |
| 115 | /** The menu's small canvas: the plan of the map Play would start. */ | |
| 116 | plan: StageCanvas; | |
| 115 | 117 | by(id: string): FakeElement; |
| 116 | 118 | /** The `[data-action]` buttons the page carries, in the page's order. */ |
| 117 | 119 | buttons: FakeElement[]; |
| @@ -162,7 +164,7 @@ export type FakeStage = { | ||
| 162 | 164 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 163 | 165 | * served page against this same list rather than a second copy of it. |
| 164 | 166 | */ |
| 165 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"]; | |
| 167 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"]; | |
| 166 | 168 | |
| 167 | 169 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 168 | 170 | const HIDDEN = ["pause", "buy", "touch", "full"]; |
| @@ -196,15 +198,15 @@ export function fakeStage( | ||
| 196 | 198 | ): FakeStage { |
| 197 | 199 | const page = fakePage({ |
| 198 | 200 | ids: PAGE_IDS, |
| 199 | canvases: ["scene", "hud"], | |
| 201 | canvases: ["scene", "hud", "plan"], | |
| 200 | 202 | hidden: HIDDEN, |
| 201 | 203 | actions: PAGE_ACTIONS, |
| 202 | 204 | coarse: options.coarse, |
| 203 | 205 | fullscreen: options.fullscreen, |
| 204 | 206 | keyboardLock: options.keyboardLock, |
| 205 | 207 | }); |
| 206 | // Asked for with `paint` left on, so both canvases have their 2D recorder. | |
| 207 | const [scene, hud] = page.canvases as [StageCanvas, StageCanvas]; | |
| 208 | // Asked for with `paint` left on, so every canvas has its 2D recorder. | |
| 209 | const [scene, hud, plan] = page.canvases as [StageCanvas, StageCanvas, StageCanvas]; | |
| 208 | 210 | // The same switch on both sides of the seam: `webgl: false` is a browser with |
| 209 | 211 | // no context to give, whether the frame asks this page's `host` for a renderer |
| 210 | 212 | // or — as the committed bundle does — asks the canvas itself. |
| @@ -282,6 +284,7 @@ export function fakeStage( | ||
| 282 | 284 | keyboard: page.keyboard, |
| 283 | 285 | scene, |
| 284 | 286 | hud, |
| 287 | plan, | |
| 285 | 288 | buttons: page.buttons, |
| 286 | 289 | frames: page.frames, |
| 287 | 290 | posts, draws, replaced, atlases, sounds, store, ids, copied, |
src/games.ts +11
| @@ -330,6 +330,17 @@ export type Game = { | ||
| 330 | 330 | * cannot shorten. Neither other bundle moved a byte: `?map=` is the shooter's |
| 331 | 331 | * parameter and the other two games neither read it nor import any of this. |
| 332 | 332 | * Measured 2026-09-21. |
| 333 | * #149 drew that map on the menu — `src/client/preview.ts`, which is the | |
| 334 | * radar's own `drawPlan` given a whole level, the two spawns on top of it and | |
| 335 | * the sentence a screen reader gets instead — and the app grew 1,368 raw / 536 | |
| 336 | * gzipped bytes for it: 96,588 → 97,956 raw and 43,537 → 44,073 gzipped. Almost | |
| 337 | * none of that is drawing: the projection and the merged floor rectangles were | |
| 338 | * already in the bundle for the radar, and what is new is the debounce in the | |
| 339 | * frame loop, the spawn marks, and the compass words, which a minifier cannot | |
| 340 | * shorten. It fits the 100,000 / 45,000 #148 set with 2,044 raw and 927 gzipped | |
| 341 | * bytes to spare, so the ceiling is held — and it is still the thinnest headroom | |
| 342 | * on this page. Neither other bundle moved a byte: nothing outside | |
| 343 | * `src/client/` and `src/pages/game.ts` changed. Measured 2026-09-21. | |
| 333 | 344 | */ |
| 334 | 345 | export const GAMES: readonly Game[] = [ |
| 335 | 346 | { |
src/pages/game.ts +32 −6
| @@ -95,11 +95,21 @@ a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7 | ||
| 95 | 95 | background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; } |
| 96 | 96 | a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:underline; } |
| 97 | 97 | #thismatch p { margin:.35rem 0; } |
| 98 | /* The name of the map Play is about to give you, and the link that keeps it. | |
| 99 | It wraps rather than scrolls: at 360px the name is one line and the link is | |
| 100 | the next, and both are still 44px to press. */ | |
| 98 | /* The plan of the map Play is about to give you, the name it answers to and the | |
| 99 | link that keeps it: the picture on the left, the words on the right. It wraps | |
| 100 | rather than scrolls, and at 360px the square and the column still fit side by | |
| 101 | side — 96 + 8 + 224 inside the overlay's padding. */ | |
| 101 | 102 | #willmap { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; justify-content:center; |
| 102 | margin:.35rem 0; } | |
| 103 | margin:.35rem 0; width:100%; max-width:34rem; } | |
| 104 | /* A fixed square, sized in CSS rather than by the canvas's own attributes, so | |
| 105 | the space it takes is the same before it is drawn as after. */ | |
| 106 | #plan { flex:none; width:6rem; height:6rem; background:#0f131a; border:1px solid var(--line); | |
| 107 | border-radius:.35rem; } | |
| 108 | #willtext { flex:1 1 13rem; min-width:0; max-width:22rem; text-align:left; } | |
| 109 | #willtext p { margin:.1rem 0; } | |
| 110 | /* Two lines' worth, whether it is holding the sentence the server rendered or | |
| 111 | the one the browser measured off the level: the line changes, the box does not. */ | |
| 112 | #planalt { min-height:2.5em; } | |
| 103 | 113 | #willplay { user-select:all; } |
| 104 | 114 | `; |
| 105 | 115 | |
| @@ -112,6 +122,16 @@ export const BAD_MAP = "That link's map name is not one — this is de_forge ins | ||
| 112 | 122 | /** What the line before Play says when the map is one a match has yet to draw. */ |
| 113 | 123 | export const UNDRAWN = "a new one every match"; |
| 114 | 124 | |
| 125 | /** | |
| 126 | * What stands in for the plan of the map until the browser has drawn one. The | |
| 127 | * picture is a canvas and the sentence beside it is measured off the level, so | |
| 128 | * neither exists in the first paint: a server that carved a map to describe it | |
| 129 | * would be carving one per request. This is the true sentence for that moment, | |
| 130 | * the length of the one that replaces it, and `src/client/main.ts` writes the | |
| 131 | * real one in as soon as there is a map to describe. | |
| 132 | */ | |
| 133 | export const UNPLANNED = "A plan of it is drawn here as soon as the game has booted."; | |
| 134 | ||
| 115 | 135 | /** The link that plays this match again: the id it names and the map it was on. */ |
| 116 | 136 | export const shareHref = (asked: Asked): string => |
| 117 | 137 | asked.match === null ? "/" : `/?match=${asked.match}&map=${asked.map}`; |
| @@ -179,8 +199,14 @@ const menu = (visitor: Who, asked: Asked): string => ` | ||
| 179 | 199 | cover, and the same map for both sides — it is the layout turned half a circle, so the walk |
| 180 | 200 | to your site is the walk to theirs. A name in this field is that exact map, on any machine; |
| 181 | 201 | empty, the Map control above decides.`}</p> |
| 182 | <p id="willmap">Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b> | |
| 183 | <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a></p> | |
| 202 | <div id="willmap"> | |
| 203 | <canvas id="plan" width="192" height="192" aria-hidden="true"></canvas> | |
| 204 | <div id="willtext"> | |
| 205 | <p>Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b></p> | |
| 206 | <p class="hint" id="planalt">${escape(UNPLANNED)}</p> | |
| 207 | <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a> | |
| 208 | </div> | |
| 209 | </div> | |
| 184 | 210 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> |
| 185 | 211 | <p class="hint" id="prep">Preparing textures…</p> |
| 186 | 212 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
static/app.9e515111bc.js deleted −58
Diff not shown — a line 77,687 characters wide (limit 2,000). −58
View filestatic/app.e2fe4849eb.js added +58
Diff not shown — a line 79,047 characters wide (limit 2,000). +58
View file
new above
offline