Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
mini map. especially on procedural maps i need to know where im going #179
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/176-mini-map-especially-on-procedural-maps-i → main 1 commit fork
11 files
changed +651
−121
README.md +26 −3
| @@ -114,6 +114,16 @@ phone, where there is no keyboard and no pointer to lock. A browser that will no | ||
| 114 | 114 | put an element full screen — an iPhone will not — never shows that button rather |
| 115 | 115 | than showing one that does nothing. |
| 116 | 116 | |
| 117 | The radar sits in the top corner: the level from above, north up and never | |
| 118 | turning, with the rooms in grey, the two bombsites lettered A and B, your side | |
| 119 | in your side's colour and a white wedge for where you are and which way you are | |
| 120 | looking. An enemy appears on it while somebody alive on your side can see them | |
| 121 | and goes the moment nobody can — there is no memory of where one was a second | |
| 122 | ago. The bomb is on it too: yellow in the hands of whoever is carrying it, on | |
| 123 | the floor for the terrorists to come back for, and blinking red where it was | |
| 124 | planted, for both sides. It is the only thing on the screen that says where you | |
| 125 | are on a map generated for that one match, which is why #176 built it. | |
| 126 | ||
| 117 | 127 | Killed, you watch the rest of the round out of a teammate's eyes: fire moves |
| 118 | 128 | to the next player on your side and aim moves back one, the camera is *placed* |
| 119 | 129 | on whoever you picked — asking for someone else is a cut, never a flight across |
| @@ -348,9 +358,16 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 348 | 358 | `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round |
| 349 | 359 | clock, the kill feed and the scoreboard — every one of them drawn through the |
| 350 | 360 | engine's `overlay` since #18, so what is left in the file is this game's |
| 351 | layout, its team colours, which phases have a clock and its words, and the | |
| 361 | layout, which phases have a clock and its words, and the | |
| 352 | 362 | scale, the scrim, the fonts, the fade, the bar, the columns and the `m:ss` are |
| 353 | the same code the range's panel is drawn with), `spectate` (whose eyes a frame | |
| 363 | the same code the range's panel is drawn with), | |
| 364 | `minimap` (the radar in the corner: the level from above, merged into a few | |
| 365 | dozen rectangles once per map by the engine's greedy meshing and replayed | |
| 366 | every frame, north up, with your wedge, your side, the enemies your side can | |
| 367 | see right now and the bomb — it knows no alphabet, so floor is whatever the | |
| 368 | level calls passable and a crate is a dark block in a room), | |
| 369 | `palette` (what colour a side is, in the one place both surfaces read it), | |
| 370 | `spectate` (whose eyes a frame | |
| 354 | 371 | is drawn from once you are dead, and the camera that eases onto them), |
| 355 | 372 | `audio`, `main`. Everything in here is headless and |
| 356 | 373 | tested: the WebGL2 is the engine's now, and this game has no renderer of its |
| @@ -405,7 +422,13 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 405 | 422 | clock that stops between rounds and reddens once the bomb is down, the |
| 406 | 423 | crosshair that opens as far as the gun is inaccurate, the plant bar, the kill |
| 407 | 424 | feed fading out over eight seconds, the scoreboard's order, and the whole |
| 408 | readout fitting a 360px phone and a 1920px desk alike. | |
| 425 | readout fitting a 360px phone and a 1920px desk alike. That recorder is | |
| 426 | `paint.test-helper.ts` now, because `minimap.test.ts` reads the radar the same | |
| 427 | way: on a map the colour *is* the meaning, so the brush is half the assertion | |
| 428 | — the north-west cell of the grid lands in the north-west corner of the | |
| 429 | square, the wedge points where you look, an enemy is a red dot exactly while | |
| 430 | somebody alive on your side can see them, and the planted bomb blinks where | |
| 431 | it lies. | |
| 409 | 432 | - `src/range/` — the second game, at `/range`, and the proof that the engine is a |
| 410 | 433 | package: a module in here may import the engine through its door, `src/pages/shell.ts` |
| 411 | 434 | and its own siblings, and nothing else. **It is a game with no renderer under |
src/client/hud.test.ts +7 −43
| @@ -10,18 +10,18 @@ | ||
| 10 | 10 | * deletes rather than uses. |
| 11 | 11 | * |
| 12 | 12 | * The `Paint` it draws onto is the HUD's — the engine's surface with the styles |
| 13 | * narrowed to the strings this game writes into them, which is why a recorder | |
| 14 | * declared as it hands a `string` back when the brush is read. The recorder is | |
| 15 | * this file's own because it writes down the brush as well as the call: the | |
| 16 | * colour a line was drawn in is half of what is pinned here, and neither of the | |
| 17 | * two recorders already in the repository (`stage.test-helper.ts`, | |
| 18 | * `overlay.test.ts`) keeps it. | |
| 13 | * narrowed to the strings this game writes into them, which is why the recorder | |
| 14 | * hands a `string` back when the brush is read. The recorder writes down the | |
| 15 | * brush as well as the call, because the colour a line was drawn in is half of | |
| 16 | * what is pinned here; it lived in this file until the radar wanted the same | |
| 17 | * one, and it is `paint.test-helper.ts` now. | |
| 19 | 18 | * |
| 20 | 19 | * No canvas, no document, no browser. |
| 21 | 20 | */ |
| 22 | 21 | |
| 23 | 22 | import { expect, test } from "bun:test"; |
| 24 | import { drawHud, type Paint } from "./hud.ts"; | |
| 23 | import { drawHud } from "./hud.ts"; | |
| 24 | import { recorder, type Call, type Recorder } from "./paint.test-helper.ts"; | |
| 25 | 25 | import { DE_FORGE } from "./map.ts"; |
| 26 | 26 | import { SHORT } from "./match.ts"; |
| 27 | 27 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| @@ -38,42 +38,6 @@ function only(effect: Effect): ReturnType<typeof createEffects<Effect>> { | ||
| 38 | 38 | return effects; |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | // --- a paint that remembers ------------------------------------------------ | |
| 42 | ||
| 43 | /** One call the HUD made, and the state of the brush that made it. */ | |
| 44 | type Call = { | |
| 45 | op: string; | |
| 46 | args: readonly unknown[]; | |
| 47 | fill: string; | |
| 48 | stroke: string; | |
| 49 | font: string; | |
| 50 | align: string; | |
| 51 | alpha: number; | |
| 52 | }; | |
| 53 | ||
| 54 | type Recorder = Paint & { calls: Call[] }; | |
| 55 | ||
| 56 | function recorder(): Recorder { | |
| 57 | const calls: Call[] = []; | |
| 58 | const note = (op: string) => (...args: unknown[]): void => void calls.push({ | |
| 59 | op, | |
| 60 | args, | |
| 61 | fill: paint.fillStyle, | |
| 62 | stroke: paint.strokeStyle, | |
| 63 | font: paint.font, | |
| 64 | align: paint.textAlign, | |
| 65 | alpha: paint.globalAlpha, | |
| 66 | }); | |
| 67 | const paint: Recorder = { | |
| 68 | calls, | |
| 69 | fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1, | |
| 70 | clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"), | |
| 71 | beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"), | |
| 72 | arc: note("arc"), fill: note("fill"), stroke: note("stroke"), | |
| 73 | }; | |
| 74 | return paint; | |
| 75 | } | |
| 76 | ||
| 77 | 41 | // --- the world this suite draws -------------------------------------------- |
| 78 | 42 | |
| 79 | 43 | /** |
src/client/hud.ts +26 −16
| @@ -1,7 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The HUD, drawn on a 2D canvas over the scene: crosshair, health, ammo, the |
| 3 | * round clock, the kill feed and the scoreboard. It reads the world and draws; | |
| 4 | * it never changes anything. | |
| 3 | * round clock, the kill feed, the scoreboard and the radar. It reads the world | |
| 4 | * and draws; it never changes anything. | |
| 5 | * | |
| 6 | * The radar is `src/client/minimap.ts` — the level from above is a picture of | |
| 7 | * its own and everything about drawing one is in there. What stays here is what | |
| 8 | * stays here for every other thing on the screen: where the square sits and how | |
| 9 | * big it is, which is the one question this file answers. | |
| 5 | 10 | * |
| 6 | 11 | * What it draws onto is a `Paint` — the seventeen members of a 2D context it |
| 7 | 12 | * actually uses, and not a whole `CanvasRenderingContext2D`. A browser's |
| @@ -14,11 +19,14 @@ | ||
| 14 | 19 | * crosshair, a progress bar, a row of right-aligned columns and the `m:ss` |
| 15 | 20 | * clock — the same tools `src/range/readout.ts` draws its panel with. What is |
| 16 | 21 | * left in this file is Counter-Strike's and nothing else: where a thing sits, |
| 17 | * what colour a team is, which phase has a clock, and the words. | |
| 22 | * which phase has a clock, and the words. What colour a side is drawn in is | |
| 23 | * `src/client/palette.ts`, because the radar draws the same ten players. | |
| 18 | 24 | */ |
| 19 | 25 | |
| 20 | 26 | import { clamp, effectAt, overlay, type Overlay, type Paint as Surface } from "../engine/index.ts"; |
| 27 | import { drawMinimap } from "./minimap.ts"; | |
| 21 | 28 | import { CS_MOVE } from "./movement.ts"; |
| 29 | import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts"; | |
| 22 | 30 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 23 | 31 | import { currentWeapon, useProgress, type Player, type World } from "./world.ts"; |
| 24 | 32 | |
| @@ -36,13 +44,12 @@ export type Paint = Surface & { | ||
| 36 | 44 | textBaseline: string; |
| 37 | 45 | }; |
| 38 | 46 | |
| 39 | const T_COLOR = "#e2b04a"; | |
| 40 | const CT_COLOR = "#6fa8e8"; | |
| 41 | const WHITE = "#f3f5f8"; | |
| 42 | const RED = "#ff6b5b"; | |
| 43 | const BLUE = "#9fd0ff"; | |
| 44 | const GREY = "#c8ccd4"; | |
| 45 | const CROSSHAIR = "rgba(120, 255, 140, 0.9)"; | |
| 47 | /** | |
| 48 | * The radar's square, before the scale: 180 of them on a 1280-wide canvas. It | |
| 49 | * is also capped at a third of the shorter side, so a short landscape phone | |
| 50 | * does not get a map with the clock sitting on it. | |
| 51 | */ | |
| 52 | const RADAR = 180; | |
| 46 | 53 | |
| 47 | 54 | /** What a dead player is told, in the two cases there are. */ |
| 48 | 55 | const DEAD = "You are dead — watching the round out"; |
| @@ -158,10 +165,13 @@ export function drawHud( | ||
| 158 | 165 | pad, height - pad - Math.round(30 * scale), |
| 159 | 166 | { align: "left", font: small, fill: BLUE }, |
| 160 | 167 | ); |
| 161 | ui.text(`$${view.money}`, pad, pad + Math.round(18 * scale), | |
| 162 | { align: "left", font: mid, fill: "#7ddc8a" }); | |
| 168 | // --- the radar, top left, and the money under it rather than over it | |
| 169 | const radar = Math.min(Math.round(RADAR * scale), Math.round(Math.min(width, height) * 0.3)); | |
| 170 | drawMinimap(ctx, ui, world, view, pad, pad, radar); | |
| 171 | const moneyY = pad + radar + Math.round(22 * scale); | |
| 172 | ui.text(`$${view.money}`, pad, moneyY, { align: "left", font: mid, fill: "#7ddc8a" }); | |
| 163 | 173 | if (view.team === "ct" && view.defuseKit) |
| 164 | ui.text("defuse kit", pad, pad + Math.round(38 * scale), { align: "left", font: small, fill: BLUE }); | |
| 174 | ui.text("defuse kit", pad, moneyY + Math.round(20 * scale), { align: "left", font: small, fill: BLUE }); | |
| 165 | 175 | |
| 166 | 176 | // --- ammo and weapon |
| 167 | 177 | const ammo = view.ammo[weapon.id]; |
| @@ -190,7 +200,7 @@ export function drawHud( | ||
| 190 | 200 | if (age > 8) return; |
| 191 | 201 | const text = `${entry.killer} ${WEAPONS[entry.weapon].name}${entry.headshot ? " ⌖" : ""} ${entry.victim}`; |
| 192 | 202 | ui.text(text, width - pad, pad + Math.round((20 + i * 22) * scale), { |
| 193 | align: "right", font: small, fill: entry.killerTeam === "t" ? T_COLOR : CT_COLOR, alpha: 8 - age, | |
| 203 | align: "right", font: small, fill: teamColor(entry.killerTeam), alpha: 8 - age, | |
| 194 | 204 | }); |
| 195 | 205 | }); |
| 196 | 206 | |
| @@ -221,7 +231,7 @@ export function drawHud( | ||
| 221 | 231 | const y = height - pad - Math.round(64 * scale); |
| 222 | 232 | ui.panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h); |
| 223 | 233 | ui.text(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale), |
| 224 | { align: "center", font: mid, fill: view.team === "t" ? T_COLOR : CT_COLOR }); | |
| 234 | { align: "center", font: mid, fill: teamColor(view.team) }); | |
| 225 | 235 | ui.text(SWITCH, width / 2, y, { align: "center", font: small, fill: GREY }); |
| 226 | 236 | } |
| 227 | 237 | } |
| @@ -248,7 +258,7 @@ function drawScoreboard(ctx: Paint, ui: Overlay, world: World): void { | ||
| 248 | 258 | // the fade is set around them rather than passed to each line. |
| 249 | 259 | const dim = p.alive ? 1 : 0.45; |
| 250 | 260 | ui.text(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.bot ? "" : " (you)"}`, x + 16, ty, { |
| 251 | align: "left", fill: p === world.me ? "#ffffff" : p.team === "t" ? T_COLOR : CT_COLOR, alpha: dim, | |
| 261 | align: "left", fill: p === world.me ? "#ffffff" : teamColor(p.team), alpha: dim, | |
| 252 | 262 | }); |
| 253 | 263 | ctx.globalAlpha = dim; |
| 254 | 264 | ui.columns(NOTHING, [ |
src/client/minimap.test.ts added +258
| @@ -0,0 +1,258 @@ | ||
| 1 | /** | |
| 2 | * What a player reads off the radar, and nothing about how `minimap.ts` is | |
| 3 | * arranged to draw it: that the map is the level and fits its square, that | |
| 4 | * north is up, that the wedge points where you look, that an enemy is on it | |
| 5 | * exactly while your side can see them, and that the bomb is where the bomb is. | |
| 6 | * | |
| 7 | * The one thing pinned to the pixel is the projection — a map whose corners are | |
| 8 | * not the level's corners is a map that lies about where you are — and it is | |
| 9 | * pinned as a property (the north-west cell lands in the north-west corner), | |
| 10 | * never as a number somebody would have to re-derive after a retune. | |
| 11 | * | |
| 12 | * It draws onto `paint.test-helper.ts`'s recorder, which keeps the brush as | |
| 13 | * well as the call, because on a radar the colour *is* the meaning: yellow is | |
| 14 | * your side, red is theirs, and a rectangle the colour of the floor is a room. | |
| 15 | * | |
| 16 | * No canvas, no document, no browser. | |
| 17 | */ | |
| 18 | ||
| 19 | import { expect, test } from "bun:test"; | |
| 20 | import { overlay } from "../engine/index.ts"; | |
| 21 | import { generateLevel } from "./generate.ts"; | |
| 22 | import { drawHud } from "./hud.ts"; | |
| 23 | import { worldOf, type Level } from "./level.ts"; | |
| 24 | import { DE_FORGE } from "./map.ts"; | |
| 25 | import { SHORT } from "./match.ts"; | |
| 26 | import { drawMinimap, planOf } from "./minimap.ts"; | |
| 27 | import { recorder, type Call, type Recorder } from "./paint.test-helper.ts"; | |
| 28 | import { canSee, createWorld, type Player, type World } from "./world.ts"; | |
| 29 | ||
| 30 | /** The square the radar is asked for, somewhere that is not the origin. */ | |
| 31 | const X = 20; | |
| 32 | const Y = 30; | |
| 33 | const SIDE = 200; | |
| 34 | ||
| 35 | /** The colours this file reads back. They are `minimap.ts`'s, written out once. */ | |
| 36 | const FLOOR = "rgba(146, 156, 174, 0.5)"; | |
| 37 | const BACKDROP = "rgba(8, 10, 14, 0.62)"; | |
| 38 | const T_COLOR = "#e2b04a"; | |
| 39 | const CT_COLOR = "#6fa8e8"; | |
| 40 | const ENEMY = "#ff6b5b"; | |
| 41 | const BOMB_ON = "#ff4a3d"; | |
| 42 | const BOMB_OFF = "rgba(255, 74, 61, 0.35)"; | |
| 43 | const BOMB_LOOSE = "#ffd166"; | |
| 44 | ||
| 45 | /** A round on de_forge with `size` players a side, laid out by `startRound`. */ | |
| 46 | const round = (team: "t" | "ct", size = 5, level: Level = DE_FORGE): World => | |
| 47 | createWorld(level, SHORT, "you", team, size, 64); | |
| 48 | ||
| 49 | function draw(world: World, view: Player = world.me): Recorder { | |
| 50 | const paint = recorder(); | |
| 51 | drawMinimap(paint, overlay(paint, 1280, 720), world, view, X, Y, SIDE); | |
| 52 | return paint; | |
| 53 | } | |
| 54 | ||
| 55 | const ops = (paint: Recorder, op: string): Call[] => paint.calls.filter((c) => c.op === op); | |
| 56 | /** Every dot on the map: a player, a halo under one, and nothing else is round. */ | |
| 57 | const dots = (paint: Recorder): Call[] => ops(paint, "arc"); | |
| 58 | const inColor = (calls: Call[], color: string): Call[] => calls.filter((c) => c.fill === color); | |
| 59 | const at = (call: Call): [number, number] => [call.args[0] as number, call.args[1] as number]; | |
| 60 | ||
| 61 | /** Everyone on a side, in the order the world holds them. */ | |
| 62 | const side = (world: World, team: "t" | "ct"): Player[] => world.players.filter((p) => p.team === team); | |
| 63 | ||
| 64 | test("the map is the level, drawn inside the square it was given and nowhere else", () => { | |
| 65 | const paint = draw(round("ct")); | |
| 66 | ||
| 67 | // The backdrop is the square, to the pixel: what is under it is the scene. | |
| 68 | const backdrop = inColor(ops(paint, "fillRect"), BACKDROP); | |
| 69 | expect(backdrop.length).toBe(1); | |
| 70 | expect(backdrop[0]?.args).toEqual([X, Y, SIDE, SIDE]); | |
| 71 | ||
| 72 | // Every room on it, and every mark, is inside that square. | |
| 73 | for (const rect of ops(paint, "fillRect")) { | |
| 74 | const [x, y] = at(rect); | |
| 75 | expect(x).toBeGreaterThanOrEqual(X); | |
| 76 | expect(y).toBeGreaterThanOrEqual(Y); | |
| 77 | expect(x + (rect.args[2] as number)).toBeLessThanOrEqual(X + SIDE); | |
| 78 | expect(y + (rect.args[3] as number)).toBeLessThanOrEqual(Y + SIDE); | |
| 79 | } | |
| 80 | for (const dot of dots(paint)) { | |
| 81 | const [x, y] = at(dot); | |
| 82 | expect(x).toBeGreaterThan(X); | |
| 83 | expect(x).toBeLessThan(X + SIDE); | |
| 84 | expect(y).toBeGreaterThan(Y); | |
| 85 | expect(y).toBeLessThan(Y + SIDE); | |
| 86 | } | |
| 87 | ||
| 88 | // There are rooms on it, and the two bombsites are lettered. | |
| 89 | expect(inColor(ops(paint, "fillRect"), FLOOR).length).toBeGreaterThan(10); | |
| 90 | expect(ops(paint, "fillText").map((c) => c.args[0])).toEqual(["A", "B"]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("north is up, west is left, and the whole grid fits the square", () => { | |
| 94 | const world = round("ct", 5); | |
| 95 | const [mate, far] = side(world, "ct").filter((p) => p !== world.me); | |
| 96 | const { width, height } = world.level.grid; | |
| 97 | // The two opposite corner cells of the grid, which is the widest claim the | |
| 98 | // projection can be asked to keep. | |
| 99 | (mate as Player).pos = worldOf(world.level, 0, 0); | |
| 100 | (far as Player).pos = worldOf(world.level, width - 1, height - 1); | |
| 101 | const marks = inColor(dots(draw(world)), CT_COLOR).map(at); | |
| 102 | ||
| 103 | const cell = SIDE / width; | |
| 104 | const northWest = marks.find(([x, y]) => x < X + cell && y < Y + cell); | |
| 105 | const southEast = marks.find(([x, y]) => x > X + SIDE - cell && y > Y + SIDE - cell); | |
| 106 | expect(northWest).toBeDefined(); | |
| 107 | expect(southEast).toBeDefined(); | |
| 108 | }); | |
| 109 | ||
| 110 | test("you are a wedge pointing wherever you are looking", () => { | |
| 111 | const world = round("ct", 1); | |
| 112 | world.me.pos = [0, 0, 0]; // the middle of the map is the middle of the square | |
| 113 | const middle: [number, number] = [X + SIDE / 2, Y + SIDE / 2]; | |
| 114 | ||
| 115 | // The wedge is the one path on the radar: its `moveTo` is the tip. | |
| 116 | const tipAt = (yaw: number): [number, number] => { | |
| 117 | world.me.yaw = yaw; | |
| 118 | const path = ops(draw(world), "moveTo"); | |
| 119 | expect(path.length).toBe(1); | |
| 120 | return at(path[0] as Call); | |
| 121 | }; | |
| 122 | ||
| 123 | const east = tipAt(0); | |
| 124 | expect(east[0]).toBeGreaterThan(middle[0]); | |
| 125 | expect(east[1]).toBeCloseTo(middle[1], 5); | |
| 126 | ||
| 127 | // Yaw rises north, and on a screen north is the smaller y. | |
| 128 | const north = tipAt(90); | |
| 129 | expect(north[1]).toBeLessThan(middle[1]); | |
| 130 | expect(north[0]).toBeCloseTo(middle[0], 5); | |
| 131 | ||
| 132 | expect(tipAt(180)[0]).toBeLessThan(middle[0]); | |
| 133 | expect(tipAt(-90)[1]).toBeGreaterThan(middle[1]); | |
| 134 | ||
| 135 | // Dead with nobody left to watch, there is no wedge: a corpse faces nowhere. | |
| 136 | world.me.alive = false; | |
| 137 | expect(ops(draw(world), "moveTo").length).toBe(0); | |
| 138 | }); | |
| 139 | ||
| 140 | test("an enemy is on the radar while your side can see them, and off it when nobody can", () => { | |
| 141 | const world = round("ct", 1); | |
| 142 | const enemy = side(world, "t")[0] as Player; | |
| 143 | const level = world.level; | |
| 144 | ||
| 145 | // Two cells apart down mid, with nothing in between. | |
| 146 | world.me.pos = worldOf(level, 22, 20); | |
| 147 | enemy.pos = worldOf(level, 22, 18); | |
| 148 | expect(canSee(world, world.me, enemy)).toBe(true); | |
| 149 | expect(inColor(dots(draw(world)), ENEMY).length).toBe(1); | |
| 150 | ||
| 151 | // The same enemy inside B site, with half the map between them. | |
| 152 | enemy.pos = worldOf(level, 5, 5); | |
| 153 | expect(canSee(world, world.me, enemy)).toBe(false); | |
| 154 | expect(inColor(dots(draw(world)), ENEMY).length).toBe(0); | |
| 155 | ||
| 156 | // Dead, you see nothing for your side: the sight is a living teammate's. | |
| 157 | enemy.pos = worldOf(level, 22, 18); | |
| 158 | world.me.alive = false; | |
| 159 | expect(inColor(dots(draw(world)), ENEMY).length).toBe(0); | |
| 160 | ||
| 161 | // And a dead enemy is not a dot, however plainly you can see the body. | |
| 162 | world.me.alive = true; | |
| 163 | enemy.alive = false; | |
| 164 | expect(inColor(dots(draw(world)), ENEMY).length).toBe(0); | |
| 165 | }); | |
| 166 | ||
| 167 | test("your side is always on it, wherever they are standing", () => { | |
| 168 | const world = round("ct", 5); | |
| 169 | const mates = side(world, "ct").filter((p) => p !== world.me); | |
| 170 | // Scattered over the map, most of them out of sight of everybody. | |
| 171 | mates.forEach((mate, i) => { mate.pos = worldOf(world.level, 4 + i * 9, 5 + i * 8); }); | |
| 172 | expect(inColor(dots(draw(world)), CT_COLOR).length).toBe(mates.length); | |
| 173 | // A dead teammate is off it, like a dead enemy. | |
| 174 | (mates[0] as Player).alive = false; | |
| 175 | expect(inColor(dots(draw(world)), CT_COLOR).length).toBe(mates.length - 1); | |
| 176 | }); | |
| 177 | ||
| 178 | test("the bomb: planted, everybody sees it blink; on the floor, only the terrorists", () => { | |
| 179 | const world = round("ct", 5); | |
| 180 | world.bomb.state = "planted"; | |
| 181 | world.bomb.pos = world.level.sites.A; | |
| 182 | world.bomb.site = "A"; | |
| 183 | ||
| 184 | // It blinks twice a second, and both halves of the blink are the same mark. | |
| 185 | world.time = 0.2; | |
| 186 | const on = inColor(ops(draw(world), "fillRect"), BOMB_ON); | |
| 187 | world.time = 0.7; | |
| 188 | const off = inColor(ops(draw(world), "fillRect"), BOMB_OFF); | |
| 189 | expect(on.length).toBe(1); | |
| 190 | expect(off.length).toBe(1); | |
| 191 | expect(at(on[0] as Call)).toEqual(at(off[0] as Call)); | |
| 192 | ||
| 193 | // A dropped bomb is a terrorist's business: their radar has it, the CTs' does not. | |
| 194 | world.bomb.state = "dropped"; | |
| 195 | world.bomb.pos = worldOf(world.level, 22, 30); | |
| 196 | expect(inColor(ops(draw(world), "fillRect"), BOMB_LOOSE).length).toBe(0); | |
| 197 | const carrier = side(world, "t")[0] as Player; | |
| 198 | expect(inColor(ops(draw(world, carrier), "fillRect"), BOMB_LOOSE).length).toBe(1); | |
| 199 | }); | |
| 200 | ||
| 201 | test("whoever is carrying it wears a halo, on either side", () => { | |
| 202 | const world = round("t", 5); | |
| 203 | const carrier = world.players.find((p) => p.hasBomb) as Player; | |
| 204 | expect(carrier).toBeDefined(); | |
| 205 | expect(carrier.team).toBe("t"); | |
| 206 | // `startRound` hands the bomb to a terrorist: this round that is a teammate. | |
| 207 | const haloes = inColor(dots(draw(world)), BOMB_LOOSE); | |
| 208 | expect(haloes.length).toBe(1); | |
| 209 | expect(inColor(dots(draw(world)), T_COLOR).length).toBe(side(world, "t").length - 1); | |
| 210 | ||
| 211 | // And it moves with them: give it to the player behind the camera instead. | |
| 212 | carrier.hasBomb = false; | |
| 213 | world.me.hasBomb = true; | |
| 214 | const mine = inColor(dots(draw(world)), BOMB_LOOSE); | |
| 215 | expect(mine.length).toBe(1); | |
| 216 | expect(at(mine[0] as Call)).not.toEqual(at(haloes[0] as Call)); | |
| 217 | }); | |
| 218 | ||
| 219 | test("a level's rectangles are built once, and they are exactly its floor", () => { | |
| 220 | const plan = planOf(DE_FORGE); | |
| 221 | expect(planOf(DE_FORGE)).toBe(plan); | |
| 222 | expect(plan.span).toBe(DE_FORGE.grid.width * DE_FORGE.grid.cell); | |
| 223 | ||
| 224 | // Every walkable cell is drawn exactly once: the merged rectangles cover the | |
| 225 | // floor and nothing that is not floor. | |
| 226 | const { grid } = DE_FORGE; | |
| 227 | const area = plan.floor.reduce((a, b) => a + (b.max[0] - b.min[0]) * (b.max[1] - b.min[1]), 0); | |
| 228 | expect(area).toBe(grid.cells.filter(DE_FORGE.passable).length * grid.cell * grid.cell); | |
| 229 | // And it is a few dozen rectangles rather than the 1,936 cells they came from. | |
| 230 | expect(plan.floor.length).toBeLessThan(grid.cells.length / 8); | |
| 231 | ||
| 232 | // A map nobody wrote is a map like any other: its own plan, its own size. | |
| 233 | const generated = generateLevel(12345); | |
| 234 | expect(planOf(generated)).not.toBe(plan); | |
| 235 | expect(planOf(generated).span).toBe(generated.grid.width * generated.grid.cell); | |
| 236 | expect(planOf(generated).sites.map((s) => s.letter)).toEqual(["A", "B"]); | |
| 237 | expect(draw(round("ct", 5, generated)).calls.length).toBeGreaterThan(20); | |
| 238 | }); | |
| 239 | ||
| 240 | test("the HUD puts it in the top corner and keeps the money out from under it", () => { | |
| 241 | const world = round("ct", 5); | |
| 242 | const paint = recorder(); | |
| 243 | drawHud(paint, world, 1280, 720, false, world.me); | |
| 244 | ||
| 245 | const backdrop = inColor(ops(paint, "fillRect"), BACKDROP)[0] as Call; | |
| 246 | const [x, y] = at(backdrop); | |
| 247 | const square = backdrop.args[2] as number; | |
| 248 | expect(backdrop.args[3]).toBe(square); | |
| 249 | // Up in the corner, and small enough to leave the round clock alone. | |
| 250 | expect(x).toBeLessThan(1280 / 4); | |
| 251 | expect(y).toBeLessThan(720 / 4); | |
| 252 | expect(square).toBeLessThan(720 / 3); | |
| 253 | ||
| 254 | // The money used to sit where the map is now, and it does not any more. | |
| 255 | // `fillText` is (text, x, y): the baseline is the third argument. | |
| 256 | const money = ops(paint, "fillText").find((c) => String(c.args[0]).startsWith("$")) as Call; | |
| 257 | expect(money.args[2] as number).toBeGreaterThan(y + square); | |
| 258 | }); |
src/client/minimap.ts added +191
| @@ -0,0 +1,191 @@ | ||
| 1 | /** | |
| 2 | * The radar: the level seen from above, in a square in the corner of the HUD. | |
| 3 | * A generated map is a map nobody has ever walked, so this is the only thing on | |
| 4 | * the screen that says where the rooms are, which way the site you are pushing | |
| 5 | * lies, and where the bomb is now. | |
| 6 | * | |
| 7 | * It reads the level and the world and draws; like the rest of the HUD it | |
| 8 | * changes nothing. North is up and the square never turns: a map that spins | |
| 9 | * under you tells you which way you face, which the wedge already does, and | |
| 10 | * costs you the one thing worth having — a picture of the place that is the | |
| 11 | * same picture every time you glance at it. | |
| 12 | * | |
| 13 | * It knows no alphabet. What is floor is the level's own `passable`, what is a | |
| 14 | * bombsite is its `siteCells`, and everything else stays backdrop — which is | |
| 15 | * why a crate shows up as a dark block inside a room without this file ever | |
| 16 | * having heard of the letter `x`. The rectangles are the engine's greedy | |
| 17 | * meshing, the same call `cells.ts` builds the geometry with, so a 40×40 map is | |
| 18 | * a few dozen `fillRect`s a frame rather than sixteen hundred; they depend on | |
| 19 | * nothing but the level, so they are built once per level and replayed. | |
| 20 | * | |
| 21 | * Every mark on it is filled and none of it is stroked. That is not a style: on | |
| 22 | * this HUD a stroke is the crosshair or a hit marker, and a radar that stroked | |
| 23 | * a ring would be a third thing answering to the same brush. | |
| 24 | * | |
| 25 | * What it will not do is tell you something the round has not: an enemy is on | |
| 26 | * the radar while somebody alive on your side can see them, and off it the | |
| 27 | * moment nobody can. There is no memory of where one was a second ago, which is | |
| 28 | * deliberate — a dot that lingers is a dot that lies. | |
| 29 | */ | |
| 30 | ||
| 31 | import { mergeSolid, type Box, type Overlay, type Paint, type Vec3 } from "../engine/index.ts"; | |
| 32 | import type { Level } from "./level.ts"; | |
| 33 | import { RED, teamColor, WHITE } from "./palette.ts"; | |
| 34 | import { canSee, type Player, type World } from "./world.ts"; | |
| 35 | ||
| 36 | /** The dark the map is drawn on: enough to read a floor against a bright scene. */ | |
| 37 | const BACKDROP = "rgba(8, 10, 14, 0.62)"; | |
| 38 | const FLOOR = "rgba(146, 156, 174, 0.5)"; | |
| 39 | const SITE = "rgba(226, 176, 74, 0.42)"; | |
| 40 | const LABEL = "rgba(255, 255, 255, 0.72)"; | |
| 41 | /** The bomb, planted: it blinks between these two twice a second, like the beep. */ | |
| 42 | const BOMB_ON = "#ff4a3d"; | |
| 43 | const BOMB_OFF = "rgba(255, 74, 61, 0.35)"; | |
| 44 | /** The bomb on the floor, and in a terrorist's hands: the same yellow both times. */ | |
| 45 | const BOMB_LOOSE = "#ffd166"; | |
| 46 | ||
| 47 | const TAU = Math.PI * 2; | |
| 48 | ||
| 49 | /** | |
| 50 | * Where a level's rectangles are, in world units. Everything in here is a | |
| 51 | * function of the level and of nothing else — not of the size of the square, not | |
| 52 | * of the frame — so it is built on the first frame of a match and replayed on | |
| 53 | * every frame after it. | |
| 54 | */ | |
| 55 | export type Plan = { | |
| 56 | /** World units along the longer side: what the square is fitted to. */ | |
| 57 | readonly span: number; | |
| 58 | /** Everything a player can stand on, merged into as few rectangles as the shape allows. */ | |
| 59 | readonly floor: readonly Box[]; | |
| 60 | /** The two bombsites, drawn over the floor and lettered. */ | |
| 61 | readonly sites: readonly { readonly letter: "A" | "B"; readonly boxes: readonly Box[] }[]; | |
| 62 | }; | |
| 63 | ||
| 64 | /** | |
| 65 | * The plan of this level, built once. A map is played for a whole match and a | |
| 66 | * generated one is a new value every match, so the cache is weak: a level the | |
| 67 | * game has finished with takes its rectangles with it. | |
| 68 | */ | |
| 69 | const plans = new WeakMap<Level, Plan>(); | |
| 70 | ||
| 71 | export function planOf(level: Level): Plan { | |
| 72 | const found = plans.get(level); | |
| 73 | if (found !== undefined) return found; | |
| 74 | const { grid } = level; | |
| 75 | const plan: Plan = { | |
| 76 | span: Math.max(grid.width, grid.height) * grid.cell, | |
| 77 | floor: mergeSolid(grid, level.passable, 0, 1, 0), | |
| 78 | sites: (["A", "B"] as const).map((letter) => ({ | |
| 79 | letter, | |
| 80 | boxes: mergeSolid(grid, (cell) => cell === level.siteCells[letter], 0, 1, 0), | |
| 81 | })), | |
| 82 | }; | |
| 83 | plans.set(level, plan); | |
| 84 | return plan; | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 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 | */ | |
| 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; | |
| 105 | const plan = planOf(level); | |
| 106 | // The map is centred on the world's origin and so is the square. | |
| 107 | const k = side / plan.span; | |
| 108 | const midX = x + side / 2; | |
| 109 | const midY = y + side / 2; | |
| 110 | const atX = (wx: number): number => midX + wx * k; | |
| 111 | // Rows run south and the world runs north, so the map is drawn upside down on | |
| 112 | // purpose: north is up. | |
| 113 | const atY = (wy: number): number => midY - wy * k; | |
| 114 | ||
| 115 | ui.panel(x, y, side, side, BACKDROP); | |
| 116 | // Edges are rounded rather than sizes, so two merged rectangles that shared a | |
| 117 | // cell edge in the world still share a pixel edge here and leave no seam. | |
| 118 | const block = (box: Box, fill: string): void => { | |
| 119 | const left = Math.round(atX(box.min[0])); | |
| 120 | const top = Math.round(atY(box.max[1])); | |
| 121 | ui.panel(left, top, Math.round(atX(box.max[0])) - left, Math.round(atY(box.min[1])) - top, fill); | |
| 122 | }; | |
| 123 | for (const box of plan.floor) block(box, FLOOR); | |
| 124 | for (const site of plan.sites) for (const box of site.boxes) block(box, SITE); | |
| 125 | ||
| 126 | // The HUD's small font, to the number: the overlay's scale has three sizes on | |
| 127 | // it and a map that invented a fourth would be the only thing on the screen | |
| 128 | // written in it. | |
| 129 | const font = ui.font(15); | |
| 130 | for (const site of plan.sites) { | |
| 131 | const at = level.sites[site.letter]; | |
| 132 | ui.text(site.letter, atX(at[0]), atY(at[1]) + 5 * ui.scale, { align: "center", font, fill: LABEL }); | |
| 133 | } | |
| 134 | ||
| 135 | const dot = (at: Vec3, radius: number, fill: string): void => { | |
| 136 | ctx.fillStyle = fill; | |
| 137 | ctx.beginPath(); | |
| 138 | ctx.arc(atX(at[0]), atY(at[1]), radius, 0, TAU); | |
| 139 | ctx.fill(); | |
| 140 | }; | |
| 141 | const mark = (at: Vec3, half: number, fill: string): void => { | |
| 142 | ui.panel(Math.round(atX(at[0]) - half), Math.round(atY(at[1]) - half), half * 2, half * 2, fill); | |
| 143 | }; | |
| 144 | ||
| 145 | // --- the bomb. Planted, it is everybody's business and blinks where it lies; | |
| 146 | // on the floor it is the terrorists' to pick up and only they are shown it. | |
| 147 | const bomb = world.bomb; | |
| 148 | const radius = Math.max(2, 3 * ui.scale); | |
| 149 | if (bomb.state === "planted") mark(bomb.pos, radius + 1, world.time % 1 < 0.5 ? BOMB_ON : BOMB_OFF); | |
| 150 | else if (bomb.state === "dropped" && view.team === "t") mark(bomb.pos, radius, BOMB_LOOSE); | |
| 151 | ||
| 152 | // --- the players. Your side always; the other side while somebody alive on | |
| 153 | // yours can see them, which is asked of the level's own geometry. | |
| 154 | const mates = world.players.filter((p) => p.alive && p.team === view.team); | |
| 155 | for (const player of world.players) { | |
| 156 | if (!player.alive || player === view) continue; | |
| 157 | const mine = player.team === view.team; | |
| 158 | if (!mine && !mates.some((mate) => canSee(world, mate, player))) continue; | |
| 159 | // Who is carrying it, on either side: a halo under the dot, drawn first so | |
| 160 | // the dot still says which side they are on. | |
| 161 | if (player.hasBomb) dot(player.pos, radius + 2, BOMB_LOOSE); | |
| 162 | dot(player.pos, radius, mine ? teamColor(player.team) : RED); | |
| 163 | } | |
| 164 | ||
| 165 | // --- you, last and on top: a wedge pointing wherever you are looking. Dead | |
| 166 | // with nobody left to watch, the view is your own body and there is no wedge: | |
| 167 | // a corpse is not facing anywhere. | |
| 168 | if (view.alive) { | |
| 169 | if (view.hasBomb) dot(view.pos, radius + 4, BOMB_LOOSE); | |
| 170 | wedge(ctx, atX(view.pos[0]), atY(view.pos[1]), view.yaw, radius + 3, WHITE); | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * An arrowhead at (`px`, `py`) pointing along `yaw`. Yaw is degrees about the | |
| 176 | * world's Z, zero east and rising north; the screen's Y runs the other way, | |
| 177 | * which is the one minus sign in here. | |
| 178 | */ | |
| 179 | function wedge(ctx: Paint, px: number, py: number, yaw: number, radius: number, color: string): void { | |
| 180 | const a = (yaw * Math.PI) / 180; | |
| 181 | const point = (turn: number, reach: number): void => { | |
| 182 | ctx.lineTo(px + Math.cos(a + turn) * radius * reach, py - Math.sin(a + turn) * radius * reach); | |
| 183 | }; | |
| 184 | ctx.fillStyle = color; | |
| 185 | ctx.beginPath(); | |
| 186 | ctx.moveTo(px + Math.cos(a) * radius, py - Math.sin(a) * radius); | |
| 187 | point(2.4, 0.9); | |
| 188 | point(Math.PI, 0.25); | |
| 189 | point(-2.4, 0.9); | |
| 190 | ctx.fill(); | |
| 191 | } |
src/client/paint.test-helper.ts added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | /** | |
| 2 | * A paint that remembers: the HUD's surface, recording every call *and the | |
| 3 | * brush that made it*, because on this game's 2D the colour a thing was drawn | |
| 4 | * in is half of what a test is about — a health number goes red, a teammate is | |
| 5 | * yellow, an enemy is not. | |
| 6 | * | |
| 7 | * It was `hud.test.ts`'s own until the radar arrived and `minimap.test.ts` | |
| 8 | * wanted the same recorder; a second copy of it is a copy that drifts. The two | |
| 9 | * recorders already in the repository are no use to either: `overlay.test.ts`'s | |
| 10 | * and `stage.test-helper.ts`'s write down the call and throw the brush away. | |
| 11 | * | |
| 12 | * It ships in no bundle — nothing under `src/client/` imports it but tests — | |
| 13 | * and it touches no global: no canvas, no document, no browser. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { Paint } from "./hud.ts"; | |
| 17 | ||
| 18 | /** One call a surface was given, and the state of the brush that made it. */ | |
| 19 | export type Call = { | |
| 20 | op: string; | |
| 21 | args: readonly unknown[]; | |
| 22 | fill: string; | |
| 23 | stroke: string; | |
| 24 | font: string; | |
| 25 | align: string; | |
| 26 | alpha: number; | |
| 27 | }; | |
| 28 | ||
| 29 | export type Recorder = Paint & { calls: Call[] }; | |
| 30 | ||
| 31 | export function recorder(): Recorder { | |
| 32 | const calls: Call[] = []; | |
| 33 | const note = (op: string) => (...args: unknown[]): void => void calls.push({ | |
| 34 | op, | |
| 35 | args, | |
| 36 | fill: paint.fillStyle, | |
| 37 | stroke: paint.strokeStyle, | |
| 38 | font: paint.font, | |
| 39 | align: paint.textAlign, | |
| 40 | alpha: paint.globalAlpha, | |
| 41 | }); | |
| 42 | const paint: Recorder = { | |
| 43 | calls, | |
| 44 | fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1, | |
| 45 | clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"), | |
| 46 | beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"), | |
| 47 | arc: note("arc"), fill: note("fill"), stroke: note("stroke"), | |
| 48 | }; | |
| 49 | return paint; | |
| 50 | } |
src/client/palette.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | /** | |
| 2 | * The colours this game's 2D is drawn in, and the one rule about them: a side | |
| 3 | * is always the same colour, wherever a side is drawn. | |
| 4 | * | |
| 5 | * It is a file of its own because there are two surfaces reading it now. The | |
| 6 | * HUD had the palette to itself until `minimap.ts` drew the same ten players | |
| 7 | * from above, and a second copy of `#e2b04a` is a copy that goes stale the day | |
| 8 | * somebody retunes one of them. Nothing here knows what is drawn or where — it | |
| 9 | * is a list of strings and one lookup. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Team } from "./match.ts"; | |
| 13 | ||
| 14 | export const T_COLOR = "#e2b04a"; | |
| 15 | export const CT_COLOR = "#6fa8e8"; | |
| 16 | export const WHITE = "#f3f5f8"; | |
| 17 | export const RED = "#ff6b5b"; | |
| 18 | export const BLUE = "#9fd0ff"; | |
| 19 | export const GREY = "#c8ccd4"; | |
| 20 | export const CROSSHAIR = "rgba(120, 255, 140, 0.9)"; | |
| 21 | ||
| 22 | /** The colour a side is drawn in, on the HUD and on the radar alike. */ | |
| 23 | export const teamColor = (team: Team): string => (team === "t" ? T_COLOR : CT_COLOR); |
src/games.ts +10
| @@ -225,6 +225,16 @@ export type Game = { | ||
| 225 | 225 | * the solver's two new branches ship with the rest of it. Neither other bundle |
| 226 | 226 | * moved a byte for this pull: nothing outside `src/range/` changed here. |
| 227 | 227 | * Measured 2026-09-20 with #142, #152 and #158 merged in. |
| 228 | * #176 gave the shooter a radar — `src/client/minimap.ts`, the level from above | |
| 229 | * in the corner of the HUD — and the app grew 1,875 raw / 935 gzipped bytes for | |
| 230 | * it: the module itself, the palette the HUD and the map now share, and the | |
| 231 | * engine's `mergeSolid` called a second way (it was already bundled, for the | |
| 232 | * geometry, so what shipped here is the radar's own drawing and nothing else). | |
| 233 | * Measured 2026-09-20 after #176: app 86,909 raw / 39,626 gzipped, which fits | |
| 234 | * the 88,000 / 40,500 #142 set with 1,091 raw and 874 gzipped bytes to spare, so | |
| 235 | * the ceiling did not move — but like the range's after #88 that is the thinnest | |
| 236 | * headroom on this page, and the next thing the shooter gains will want it | |
| 237 | * raised. Neither other bundle moved a byte: nothing outside `src/client/` did. | |
| 228 | 238 | */ |
| 229 | 239 | export const GAMES: readonly Game[] = [ |
| 230 | 240 | { |
src/pages/game.ts +2 −1
| @@ -70,7 +70,8 @@ const SIGN_IN = "/__forge/signin?to=%2F"; | ||
| 70 | 70 | const menu = (visitor: Who): string => ` |
| 71 | 71 | <h2>Defuse or detonate</h2> |
| 72 | 72 | <p>Five against five on <b>de_forge</b>, or on a map generated for the match. Bomb rounds, |
| 73 | CS movement and economy, bots that push the sites.</p> | |
| 73 | CS movement and economy, bots that push the sites, and a radar in the corner so a map | |
| 74 | nobody has ever walked is still a place you can find your way around.</p> | |
| 74 | 75 | <p class="hint">${visitor.role === "anon" |
| 75 | 76 | ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>. |
| 76 | 77 | <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.` |
static/app.9a24651e0a.js added +58
Diff not shown — a line 68,704 characters wide (limit 2,000). +58
View filestatic/app.e248e1964e.js deleted −58
Diff not shown — a line 66,829 characters wide (limit 2,000). −58
View file
new above
offline