Public: anyone can read this; the crew can change it.
forked from trevato/cs
Take updates from trevato/cs #361
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/359-take-updates-from-trevato-cs → main 4 commits fork
17 files
changed +342
−32
README.md +30 −3
| @@ -415,7 +415,11 @@ trigger and the weapon keys are off the player while your feet still work — | ||
| 415 | 415 | time**: the carry speed changes on the tick you press the key, so tapping |
| 416 | 416 | <kbd>3</kbd> runs at the knife's 250 u/s straight away, but the weapon you come |
| 417 | 417 | back to cannot fire, scope or reload until it is up (a pistol 0.7 s, a rifle or |
| 418 | the knife 1.0 s, the AWP 1.25 s, counted in whole ticks). Knife-running a | |
| 418 | the knife 1.0 s, the AWP 1.25 s, counted in whole ticks). You can watch it: the | |
| 419 | gun comes up from below over exactly that draw and is level on the tick the | |
| 420 | trigger answers, and the ammo counter and the weapon's name stay dim until then | |
| 421 | (`raiseLeft` in `src/client/scene.ts`, read off the same `deployUntil` the | |
| 422 | simulation keeps). Knife-running a | |
| 419 | 423 | sightline is a decision, and the sums are in `src/client/weapons.ts` — |
| 420 | 424 | <kbd>Tab</kbd> shows |
| 421 | 425 | the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and |
| @@ -443,6 +447,23 @@ prevented, so a thumb mid-round cannot leave the page with the match on it. At | ||
| 443 | 447 | `/range` the thumb pair reloads and switches drill and the middle button is left |
| 444 | 448 | to the browser. A button no game binds keeps whatever meaning the browser gave it. |
| 445 | 449 | |
| 450 | **Motion you did not ask for, turned down — and nothing else.** A **Motion** | |
| 451 | select sits beside Sensitivity in the menu. It starts wherever the machine | |
| 452 | already asks: the page reads `(prefers-reduced-motion: reduce)` through | |
| 453 | `matchMedia` once at boot and starts at **Reduced** if the system says so; | |
| 454 | **Full** is the game exactly as it has always drawn. The player can move it | |
| 455 | either way, and a match reads it as it starts, the way it reads the | |
| 456 | sensitivity. **Reduced** turns down exactly two things, both decoration: the | |
| 457 | walk's sway on the gun in your hands (it goes to zero, the gun a player | |
| 458 | standing still already holds), and the fade on the red arc that says where a | |
| 459 | hit came from, which holds its strength for its life and then goes. Everything | |
| 460 | that is the game is untouched — the gun's kick when you fire, its raise on a | |
| 461 | switch, the crosshair opening with your spread, the pain flash, the tracers, | |
| 462 | the blood and the kill feed are identical on both settings. A view that does | |
| 463 | not kick is an aim advantage, not an accessibility feature. `src/client/motion.ts` | |
| 464 | holds the two values and the line between them; `scene` and `hud` take the one | |
| 465 | value with the frame. | |
| 466 | ||
| 446 | 467 | **Windows is asked for the mouse the way Windows needs.** `Controls.lock()` asks |
| 447 | 468 | for `unadjustedMovement`, because without it Chromium on Windows hands the page |
| 448 | 469 | a `movementX` with the OS's pointer speed and "enhance pointer precision" |
| @@ -899,7 +920,10 @@ never checked itself. | ||
| 899 | 920 | the engine's ring: `EFFECT_CAPACITY` events at most, each stamped, pruned at |
| 900 | 921 | the end of every tick by `EFFECT_LIFE` without allocating), |
| 901 | 922 | `bots`, `cover`, `observe`, `action`, `weights`, `models`, `materials`, |
| 902 | `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round | |
| 923 | `binding`, | |
| 924 | `motion` (whether the decoration moves: two values, the media query that | |
| 925 | only sets where the menu starts, and the line between decoration and the game), | |
| 926 | `scene`, `hud` (the crosshair, the health, the ammo, the round | |
| 903 | 927 | clock, the kill feed, the scoreboard and the red arc and flash that say you |
| 904 | 928 | were hit and from where — every one of them drawn through the |
| 905 | 929 | engine's `overlay` since #18, so what is left in the file is this game's |
| @@ -984,7 +1008,10 @@ never checked itself. | ||
| 984 | 1008 | with a recorder of its own that writes down the brush as well as the call, so |
| 985 | 1009 | what is pinned is what a player reads: the health that goes red, the ammo, the |
| 986 | 1010 | clock that stops between rounds and reddens once the bomb is down, the |
| 987 | crosshair that opens as far as the gun is inaccurate, the plant bar, the kill | |
| 1011 | crosshair that opens as far as the gun is inaccurate, the hit marker — four | |
| 1012 | ticks, red for a headshot, and for the hit that finished them (a grenade's | |
| 1013 | included) a heavier X held 0.45 s instead of 0.25 s, so the fifth bullet does | |
| 1014 | not look like the four before it — the plant bar, the kill | |
| 988 | 1015 | feed fading out over eight seconds, the scoreboard's order, the damage arc |
| 989 | 1016 | that sits behind you when you are shot in the back and walks ninety degrees |
| 990 | 1017 | when you turn ninety, and the whole |
src/client/grenade.test.ts +2
| @@ -263,6 +263,8 @@ test("a blast that hurts someone marks the hit, and one that kills names the gre | ||
| 263 | 263 | expect(victim.health).toBeLessThan(healthBefore); |
| 264 | 264 | expect(world.me.damage).toBeGreaterThan(damageBefore); |
| 265 | 265 | expect(heard(world, (e) => e.kind === "hitmark")).toBe(true); |
| 266 | // The mark says whether the blast finished them, as a bullet's does. | |
| 267 | expect(heard(world, (e) => e.kind === "hitmark" && e.killed === !victim.alive)).toBe(true); | |
| 266 | 268 | expect(heard(world, (e) => e.kind === "blast")).toBe(true); |
| 267 | 269 | expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(true); |
| 268 | 270 | // 83 hit points at the feet of a body with no vest is not a kill on its own, |
src/client/hud.test.ts +56 −7
| @@ -20,7 +20,8 @@ | ||
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | 22 | import { expect, test } from "bun:test"; |
| 23 | import { drawHud, HURT_LIFE, painAlpha, whiteout } from "./hud.ts"; | |
| 23 | import type { Motion } from "./motion.ts"; | |
| 24 | import { drawHud, HIT_MARK_LIFE, HURT_LIFE, KILL_MARK_LIFE, NOT_READY_ALPHA, painAlpha, whiteout } from "./hud.ts"; | |
| 24 | 25 | import { recorder, type Call, type Recorder } from "./paint.test-helper.ts"; |
| 25 | 26 | import { DE_FORGE } from "./map.ts"; |
| 26 | 27 | import { SHORT } from "./match.ts"; |
| @@ -84,13 +85,13 @@ const HEIGHT = 720; | ||
| 84 | 85 | function draw( |
| 85 | 86 | world: World, |
| 86 | 87 | options: { |
| 87 | width?: number; height?: number; scoreboard?: boolean; view?: Player; dpr?: number; | |
| 88 | width?: number; height?: number; scoreboard?: boolean; view?: Player; dpr?: number; motion?: Motion; | |
| 88 | 89 | } = {}, |
| 89 | 90 | ): Recorder { |
| 90 | 91 | const paint = recorder(); |
| 91 | 92 | drawHud( |
| 92 | 93 | paint, world, options.width ?? WIDTH, options.height ?? HEIGHT, |
| 93 | options.scoreboard ?? false, options.view ?? world.me, options.dpr ?? 1, | |
| 94 | options.scoreboard ?? false, options.view ?? world.me, options.dpr ?? 1, options.motion, | |
| 94 | 95 | ); |
| 95 | 96 | return paint; |
| 96 | 97 | } |
| @@ -224,7 +225,7 @@ test("a crosshair is the same size on the glass however dense the screen is", () | ||
| 224 | 225 | test("a hit is marked white, a headshot red, and both are gone a quarter second later", () => { |
| 225 | 226 | const hit = draw(scene((w) => { |
| 226 | 227 | w.time = 10; |
| 227 | w.effects = only({ kind: "hitmark", at: 9.9, headshot: false }); | |
| 228 | w.effects = only({ kind: "hitmark", at: 9.9, headshot: false, killed: false }); | |
| 228 | 229 | })); |
| 229 | 230 | // One stroke, and it is the marker: the crosshair is filled, not stroked. |
| 230 | 231 | const body = hit.calls.filter((c) => c.op === "stroke"); |
| @@ -234,13 +235,13 @@ test("a hit is marked white, a headshot red, and both are gone a quarter second | ||
| 234 | 235 | |
| 235 | 236 | const head = draw(scene((w) => { |
| 236 | 237 | w.time = 10; |
| 237 | w.effects = only({ kind: "hitmark", at: 9.9, headshot: true }); | |
| 238 | w.effects = only({ kind: "hitmark", at: 9.9, headshot: true, killed: false }); | |
| 238 | 239 | })); |
| 239 | 240 | expect(head.calls.filter((c) => c.op === "stroke")[0]?.stroke).toBe("rgba(255,90,90,0.95)"); |
| 240 | 241 | |
| 241 | 242 | const old = draw(scene((w) => { |
| 242 | 243 | w.time = 10; |
| 243 | w.effects = only({ kind: "hitmark", at: 9.7, headshot: true }); | |
| 244 | w.effects = only({ kind: "hitmark", at: 9.7, headshot: true, killed: false }); | |
| 244 | 245 | })); |
| 245 | 246 | // A quarter second later the marker is gone and nothing is stroked at all — |
| 246 | 247 | // the crosshair, which is still drawn, is four fills. |
| @@ -248,6 +249,25 @@ test("a hit is marked white, a headshot red, and both are gone a quarter second | ||
| 248 | 249 | expect(aimed(old)).toBe(true); |
| 249 | 250 | }); |
| 250 | 251 | |
| 252 | test("the hit that kills is an X, not a fifth tick, and it stays up longer", () => { | |
| 253 | const mark = (at: number, killed: boolean): Recorder => draw(scene((w) => { | |
| 254 | w.time = 10; | |
| 255 | w.effects = only({ kind: "hitmark", at, headshot: false, killed }); | |
| 256 | })); | |
| 257 | const strokes = (paint: Recorder): number => paint.calls.filter((c) => c.op === "lineTo").length; | |
| 258 | const hit = mark(9.9, false); | |
| 259 | const kill = mark(9.9, true); | |
| 260 | // The same four ticks, and for a kill two more strokes through the middle. | |
| 261 | expect(strokes(kill) - strokes(hit)).toBe(2); | |
| 262 | expect(strokes(hit) - strokes(mark(9, false))).toBe(4); | |
| 263 | // Past a hit's quarter second a kill's mark is still up; past its own | |
| 264 | // KILL_MARK_LIFE it is gone too. | |
| 265 | const later = 10 - (HIT_MARK_LIFE + KILL_MARK_LIFE) / 2; | |
| 266 | expect(mark(later, false).calls.some((c) => c.op === "stroke")).toBe(false); | |
| 267 | expect(mark(later, true).calls.some((c) => c.op === "stroke")).toBe(true); | |
| 268 | expect(mark(10 - KILL_MARK_LIFE - 0.01, true).calls.some((c) => c.op === "stroke")).toBe(false); | |
| 269 | }); | |
| 270 | ||
| 251 | 271 | // --- health, money and armour ---------------------------------------------- |
| 252 | 272 | |
| 253 | 273 | test("health reads white while you are standing and red when you are nearly gone", () => { |
| @@ -321,6 +341,22 @@ test("ammo reads mag / reserve, the knife reads a dash, and reloading says so", | ||
| 321 | 341 | expect(says(both, "switching")).toBe(true); |
| 322 | 342 | expect(says(both, "reloading")).toBe(false); |
| 323 | 343 | |
| 344 | // While it is on its way up the counter and the name are dimmed, and the | |
| 345 | // word that explains it is not; the tick it is up they are whole again. | |
| 346 | const drawing = (until: number): Recorder => draw(scene((w) => { | |
| 347 | w.time = 10; | |
| 348 | w.me.weapon = "usp"; | |
| 349 | w.me.ammo.usp = { mag: 12, reserve: 24 }; | |
| 350 | w.me.deployUntil = until; | |
| 351 | })); | |
| 352 | const rising = drawing(10.5); | |
| 353 | expect(line(rising, "12 / 24").alpha).toBeCloseTo(NOT_READY_ALPHA, 10); | |
| 354 | expect(line(rising, WEAPONS.usp.name).alpha).toBeCloseTo(NOT_READY_ALPHA, 10); | |
| 355 | expect(line(rising, "switching").alpha).toBe(1); | |
| 356 | const up = drawing(10); | |
| 357 | expect(line(up, "12 / 24").alpha).toBe(1); | |
| 358 | expect(line(up, WEAPONS.usp.name).alpha).toBe(1); | |
| 359 | ||
| 324 | 360 | // Carrying the bomb is written beside the gun's name. |
| 325 | 361 | const carrier = draw(scene((w) => { w.me.team = "t"; w.me.hasBomb = true; })); |
| 326 | 362 | expect(says(carrier, `${WEAPONS.usp.name} · bomb`)).toBe(true); |
| @@ -578,7 +614,7 @@ const busyWorld = (): World => scene((w) => { | ||
| 578 | 614 | w.bomb.defuser = w.me; |
| 579 | 615 | w.bomb.defuseProgress = 1; |
| 580 | 616 | w.effects = these( |
| 581 | { kind: "hitmark", at: 99.95, headshot: true }, | |
| 617 | { kind: "hitmark", at: 99.95, headshot: true, killed: false }, | |
| 582 | 618 | // Somebody is shooting back, which is a wash and an arc on the same frame. |
| 583 | 619 | { kind: "hurt", at: 99.9, from: [w.me.pos[0] - 400, w.me.pos[1] + 250, 0], damage: 27 }, |
| 584 | 620 | ); |
| @@ -767,6 +803,19 @@ test("the arc points at whoever hit you, and turning takes it with you", () => { | ||
| 767 | 803 | expect(right[0]?.radius).toBeCloseTo(HEIGHT * 0.28, 6); |
| 768 | 804 | }); |
| 769 | 805 | |
| 806 | test("under reduced motion an arc holds its strength for its life and then goes", () => { | |
| 807 | const steady = (ago: number) => | |
| 808 | arcs(draw(shotAt([-500, 0], 0, 27, ago), { motion: "reduced" })); | |
| 809 | expect(steady(0)[0]?.alpha).toBeCloseTo(0.85, 3); | |
| 810 | // Where the full setting has faded to half, this one has not moved. | |
| 811 | expect(steady(HURT_LIFE / 2)[0]?.alpha).toBeCloseTo(0.85, 3); | |
| 812 | expect(steady(HURT_LIFE - 0.01)[0]?.alpha).toBeCloseTo(0.85, 3); | |
| 813 | // Its life is the same life: gone on the same tick. | |
| 814 | expect(steady(HURT_LIFE)).toEqual([]); | |
| 815 | // And the bearing is the bearing — only the fade was decoration. | |
| 816 | expect(behindness(steady(0.1)[0]?.at ?? 0)).toBeCloseTo(180, 6); | |
| 817 | }); | |
| 818 | ||
| 770 | 819 | test("an arc fades over its life and is gone at the end of it, and two attackers are two arcs", () => { |
| 771 | 820 | const fresh = arcs(draw(shotAt([-500, 0], 0, 27, 0))); |
| 772 | 821 | expect(fresh[0]?.alpha).toBeCloseTo(0.85, 3); |
src/client/hud.ts +40 −6
| @@ -30,6 +30,7 @@ import { | ||
| 30 | 30 | type Overlay, type Paint as Surface, type Vec3, |
| 31 | 31 | } from "../engine/index.ts"; |
| 32 | 32 | import { drawMinimap } from "./minimap.ts"; |
| 33 | import { DEFAULT_MOTION, type Motion } from "./motion.ts"; | |
| 33 | 34 | import { CS_MOVE } from "./movement.ts"; |
| 34 | 35 | import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts"; |
| 35 | 36 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| @@ -58,6 +59,22 @@ export type Paint = Surface & { | ||
| 58 | 59 | */ |
| 59 | 60 | const RADAR = 180; |
| 60 | 61 | |
| 62 | /** | |
| 63 | * What is left of the ammo counter and the weapon's name while the gun is | |
| 64 | * still coming up: a click that does nothing for the 1.25 s an AWP takes | |
| 65 | * should not leave anyone guessing why. | |
| 66 | */ | |
| 67 | export const NOT_READY_ALPHA = 0.35; | |
| 68 | ||
| 69 | /** | |
| 70 | * How long a hit marker lives, and how much longer the one for a kill does: | |
| 71 | * long enough to register as its own thing at a glance, well inside the time | |
| 72 | * the effect ring keeps an effect for. | |
| 73 | */ | |
| 74 | export const HIT_MARK_LIFE = 0.25; | |
| 75 | export const KILL_MARK_LIFE = 0.45; | |
| 76 | const KILL_STROKES: [number, number][] = [[1, 1], [1, -1]]; | |
| 77 | ||
| 61 | 78 | /** What a dead player is told, in the two cases there are. */ |
| 62 | 79 | const DEAD = "You are dead — watching the round out"; |
| 63 | 80 | const WATCHING = "Watching"; |
| @@ -208,6 +225,7 @@ export function drawHud( | ||
| 208 | 225 | showScoreboard: boolean, |
| 209 | 226 | view: Player = world.me, |
| 210 | 227 | dpr = 1, |
| 228 | motion: Motion = DEFAULT_MOTION, | |
| 211 | 229 | ): void { |
| 212 | 230 | const ui = toolsFor(ctx, width, height, dpr); |
| 213 | 231 | ctx.clearRect(0, 0, width, height); |
| @@ -265,12 +283,12 @@ export function drawHud( | ||
| 265 | 283 | const effect = effectAt(world.effects, i); |
| 266 | 284 | if (effect === undefined || effect.kind !== "hitmark") continue; |
| 267 | 285 | const age = world.time - effect.at; |
| 268 | if (age > 0.25) continue; | |
| 286 | if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue; | |
| 269 | 287 | ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)"; |
| 270 | 288 | // On the diagonals, so there is no pixel grid to land on and no point |
| 271 | 289 | // pretending there is — but it is still the same size on the glass at every |
| 272 | 290 | // ratio, which is the half of this a dense screen used to lose. |
| 273 | ctx.lineWidth = ui.px(2); | |
| 291 | ctx.lineWidth = ui.px(effect.killed ? 3 : 2); | |
| 274 | 292 | const r = ui.px(9 + age * 20); |
| 275 | 293 | ctx.beginPath(); |
| 276 | 294 | const arms: [number, number][] = [[1, 1], [-1, -1], [1, -1], [-1, 1]]; |
| @@ -278,6 +296,13 @@ export function drawHud( | ||
| 278 | 296 | ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55); |
| 279 | 297 | ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r); |
| 280 | 298 | } |
| 299 | // The hit that finished them closes the four ticks into an X through the | |
| 300 | // middle: a different shape, not a shade of the same one. | |
| 301 | if (effect.killed) | |
| 302 | for (const [dx, dy] of KILL_STROKES) { | |
| 303 | ctx.moveTo(width / 2 - dx * r * 0.45, height / 2 - dy * r * 0.45); | |
| 304 | ctx.lineTo(width / 2 + dx * r * 0.45, height / 2 + dy * r * 0.45); | |
| 305 | } | |
| 281 | 306 | ctx.stroke(); |
| 282 | 307 | } |
| 283 | 308 | |
| @@ -313,7 +338,11 @@ export function drawHud( | ||
| 313 | 338 | const age = world.time - effect.at; |
| 314 | 339 | if (age < 0 || age >= HURT_LIFE) continue; |
| 315 | 340 | const mid = hurtAngle(view.yaw, effect.from, view.pos); |
| 316 | ctx.strokeStyle = `rgba(${HURT},${(HURT_ALPHA * (1 - age / HURT_LIFE)).toFixed(3)})`; | |
| 341 | // Under reduced motion the arc holds its strength and then goes: where | |
| 342 | // it came from is what it says, and a slide to nothing is only how it | |
| 343 | // leaves. The pain flash above is left alone — how hard is information. | |
| 344 | const fade = motion === "reduced" ? 1 : 1 - age / HURT_LIFE; | |
| 345 | ctx.strokeStyle = `rgba(${HURT},${(HURT_ALPHA * fade).toFixed(3)})`; | |
| 317 | 346 | ctx.beginPath(); |
| 318 | 347 | ctx.arc(width / 2, height / 2, radius, mid - HURT_HALF, mid + HURT_HALF); |
| 319 | 348 | ctx.stroke(); |
| @@ -355,10 +384,15 @@ export function drawHud( | ||
| 355 | 384 | if (view.team === "ct" && view.defuseKit) |
| 356 | 385 | ui.text("defuse kit", pad, moneyY + Math.round(20 * scale), { align: "left", font: small, fill: BLUE }); |
| 357 | 386 | |
| 358 | // --- ammo and weapon | |
| 387 | // --- ammo and weapon, dimmed while the gun is still on its way up: the | |
| 388 | // simulation refuses the trigger until `deployUntil`, and the gun rising in | |
| 389 | // the middle of the screen ends on that same tick, so the two cues end | |
| 390 | // together. The busy line below keeps its full strength — it says in words | |
| 391 | // what the dimming means. | |
| 392 | const ready = world.time < view.deployUntil ? NOT_READY_ALPHA : 1; | |
| 359 | 393 | const ammo = view.ammo[weapon.id]; |
| 360 | 394 | const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`; |
| 361 | ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE }); | |
| 395 | ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE, alpha: ready }); | |
| 362 | 396 | // What is in your hands, and then what is in your pockets: the grenades you |
| 363 | 397 | // bought and the bomb, each named only while you are carrying it. |
| 364 | 398 | ui.text( |
| @@ -368,7 +402,7 @@ export function drawHud( | ||
| 368 | 402 | (view.pocket.smoke > 0 ? " · smoke" : "") + |
| 369 | 403 | (view.hasBomb ? " · bomb" : ""), |
| 370 | 404 | width - pad, height - pad - Math.round(30 * scale), |
| 371 | { align: "right", font: small, fill: GREY }, | |
| 405 | { align: "right", font: small, fill: GREY, alpha: ready }, | |
| 372 | 406 | ); |
| 373 | 407 | // What the gun is busy doing, in the one line there is room for. The two |
| 374 | 408 | // cannot both be true — a switch cancels the reload it interrupted and a |
src/client/main.test.ts +5
| @@ -103,6 +103,11 @@ test("it boots on the page's ids, asks for a frame, and draws nothing until a ma | ||
| 103 | 103 | expect(stage.frames.length).toBe(1); |
| 104 | 104 | }); |
| 105 | 105 | |
| 106 | test("the menu's Motion starts where the system's prefers-reduced-motion is", () => { | |
| 107 | expect(start(fakeStage()).by("motion").value).toBe("full"); | |
| 108 | expect(start(fakeStage({ reducedMotion: true })).by("motion").value).toBe("reduced"); | |
| 109 | }); | |
| 110 | ||
| 106 | 111 | test("the atlas is painted a layer a frame, and the menu says so while it is", () => { |
| 107 | 112 | const stage = start(fakeStage()); |
| 108 | 113 | // Nothing was painted inside `boot`: the frame was asked for, the page has it |
src/client/main.ts +11 −2
| @@ -88,6 +88,7 @@ import { DE_FORGE } from "./map.ts"; | ||
| 88 | 88 | import type { Level } from "./level.ts"; |
| 89 | 89 | import { FOV, fovFor, lookAt } from "./look.ts"; |
| 90 | 90 | import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts"; |
| 91 | import { motionFor, parseMotion, REDUCED_MOTION_QUERY, type Motion } from "./motion.ts"; | |
| 91 | 92 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 92 | 93 | import { meshFor } from "./models.ts"; |
| 93 | 94 | import { parsePacked } from "./packed.ts"; |
| @@ -368,6 +369,12 @@ export function boot(host: Host): Frame { | ||
| 368 | 369 | const lengthField = by<Field>("length"); |
| 369 | 370 | const sideField = by<Field>("side"); |
| 370 | 371 | const sensField = by<Field>("sens"); |
| 372 | // The menu's Motion control starts where the system's own setting is, asked | |
| 373 | // once here; the player can move it either way, and a match reads it as it | |
| 374 | // starts, the way it reads the sensitivity. | |
| 375 | const motionField = by<Field>("motion"); | |
| 376 | motionField.value = motionFor(win.matchMedia(REDUCED_MOTION_QUERY).matches); | |
| 377 | let motion: Motion = parseMotion(motionField.value); | |
| 371 | 378 | const mapField = by<Field>("map"); |
| 372 | 379 | // The id a person pasted, the line that answers them, and the three places |
| 373 | 380 | // the match being played says what it is: the section under the stage (the |
| @@ -1182,6 +1189,7 @@ export function boot(host: Host): Frame { | ||
| 1182 | 1189 | postedFinal = false; |
| 1183 | 1190 | loop.reset(); |
| 1184 | 1191 | controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12); |
| 1192 | motion = parseMotion(motionField.value); | |
| 1185 | 1193 | running = true; |
| 1186 | 1194 | lastSound = 0; |
| 1187 | 1195 | show(menu, false); |
| @@ -1231,6 +1239,7 @@ export function boot(host: Host): Frame { | ||
| 1231 | 1239 | postedFinal = true; // a room's result is the server's to write, never this page's |
| 1232 | 1240 | loop.reset(); |
| 1233 | 1241 | controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12); |
| 1242 | motion = parseMotion(motionField.value); | |
| 1234 | 1243 | running = true; |
| 1235 | 1244 | lastSound = 0; |
| 1236 | 1245 | show(menu, false); |
| @@ -1565,7 +1574,7 @@ export function boot(host: Host): Frame { | ||
| 1565 | 1574 | } |
| 1566 | 1575 | if (world.match.phase === "match_over" && running) endMatch(world); |
| 1567 | 1576 | |
| 1568 | renderer.draw(buildScene(world, camera, frameScene, view), elapsed); | |
| 1577 | renderer.draw(buildScene(world, camera, frameScene, view, motion), elapsed); | |
| 1569 | 1578 | |
| 1570 | 1579 | // The same backing store the renderer gives the scene canvas underneath — |
| 1571 | 1580 | // one function, so the HUD's pixels and the world's are the same pixels — |
| @@ -1576,7 +1585,7 @@ export function boot(host: Host): Frame { | ||
| 1576 | 1585 | hudCanvas.width = w; |
| 1577 | 1586 | hudCanvas.height = h; |
| 1578 | 1587 | } |
| 1579 | drawHud(hudCtx, world, w, h, scoreboard, view, win.devicePixelRatio); | |
| 1588 | drawHud(hudCtx, world, w, h, scoreboard, view, win.devicePixelRatio, motion); | |
| 1580 | 1589 | by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`; |
| 1581 | 1590 | } |
| 1582 | 1591 | loop.start(frame); |
src/client/motion.test.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS, motionFor, parseMotion } from "./motion.ts"; | |
| 3 | ||
| 4 | test("the system's answer is where the control starts, and full is what saying nothing gets", () => { | |
| 5 | expect(motionFor(true)).toBe("reduced"); | |
| 6 | expect(motionFor(false)).toBe("full"); | |
| 7 | expect(DEFAULT_MOTION).toBe("full"); | |
| 8 | }); | |
| 9 | ||
| 10 | test("a control's value becomes a motion, and anything else is the default", () => { | |
| 11 | for (const m of MOTIONS) expect(parseMotion(m)).toBe(m); | |
| 12 | for (const junk of ["", "REDUCED", "none", null, undefined, 1, {}]) expect(parseMotion(junk)).toBe(DEFAULT_MOTION); | |
| 13 | expect(Object.keys(MOTION_LABELS).sort()).toEqual([...MOTIONS].sort()); | |
| 14 | }); |
src/client/motion.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * Motion: whether the parts of the view that are only decoration move. | |
| 3 | * | |
| 4 | * Two values, and one line between them. `reduced` turns down motion that | |
| 5 | * carries nothing a player could act on — the gun swaying as you walk, a | |
| 6 | * damage arc sliding out under the crosshair — and touches nothing that is | |
| 7 | * the game: the recoil kick, the raise of a gun that cannot fire yet, the | |
| 8 | * crosshair opening with the spread, the pain flash that says how hard you | |
| 9 | * were hit, the tracers, the blood and the kill feed are the same on both | |
| 10 | * settings. A view that does not kick is an aim advantage, not an | |
| 11 | * accessibility feature, and a setting that quietly made the game easier to | |
| 12 | * play would be a worse thing to offer than none at all. | |
| 13 | * | |
| 14 | * What the system's `prefers-reduced-motion` answers is where the menu's | |
| 15 | * control starts, and nothing more: the player can move it either way. | |
| 16 | */ | |
| 17 | ||
| 18 | export const MOTIONS = ["full", "reduced"] as const; | |
| 19 | export type Motion = (typeof MOTIONS)[number]; | |
| 20 | ||
| 21 | /** What the game has always drawn, and what a browser that says nothing gets. */ | |
| 22 | export const DEFAULT_MOTION: Motion = "full"; | |
| 23 | ||
| 24 | /** What each value is called on the menu's control. */ | |
| 25 | export const MOTION_LABELS: Record<Motion, string> = { full: "Full", reduced: "Reduced" }; | |
| 26 | ||
| 27 | export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; | |
| 28 | ||
| 29 | /** The answer to the media query, as the motion the menu starts at. */ | |
| 30 | export const motionFor = (prefersReduced: boolean): Motion => (prefersReduced ? "reduced" : "full"); | |
| 31 | ||
| 32 | /** The boundary: whatever a control says, a motion — and anything else is the default. */ | |
| 33 | export const parseMotion = (value: unknown): Motion => | |
| 34 | typeof value === "string" && (MOTIONS as readonly string[]).includes(value) ? (value as Motion) : DEFAULT_MOTION; |
src/client/scene.test.ts +63 −1
| @@ -15,7 +15,7 @@ import { existsSync, readFileSync } from "node:fs"; | ||
| 15 | 15 | import { join } from "node:path"; |
| 16 | 16 | import { pushEffect, resetEffects, type Camera } from "../engine/index.ts"; |
| 17 | 17 | import { CS_HULL } from "./movement.ts"; |
| 18 | import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts"; | |
| 18 | import { buildScene, createScene, LINE_CAPACITY, RAISE_DROP, raiseLeft, SKY } from "./scene.ts"; | |
| 19 | 19 | import { DE_FORGE } from "./map.ts"; |
| 20 | 20 | import { SHORT } from "./match.ts"; |
| 21 | 21 | import { createWorld, EFFECT_CAPACITY, type Effect, type Player, type World } from "./world.ts"; |
| @@ -279,3 +279,65 @@ test("this game has no renderer of its own: the frame is the door's type", () => | ||
| 279 | 279 | expect(source).toContain('from "../engine/index.ts"'); |
| 280 | 280 | expect(source).toContain("export type Scene = Frame &"); |
| 281 | 281 | }); |
| 282 | ||
| 283 | test("a gun switched to comes up from below over its own deploy, and is level when it can fire", () => { | |
| 284 | const w = world(); | |
| 285 | const scene = createScene(); | |
| 286 | buildScene(w, camera(), scene); | |
| 287 | const level = scene.viewModel?.offset[1] ?? NaN; | |
| 288 | ||
| 289 | // An AWP drawn this instant: all 1.25 s owed, the whole drop, squared. | |
| 290 | w.me.primary = "awp"; | |
| 291 | w.me.weapon = "awp"; | |
| 292 | w.me.deployUntil = w.time + 1.25; | |
| 293 | buildScene(w, camera(), scene); | |
| 294 | expect(scene.viewModel?.offset[1]).toBeCloseTo(level - RAISE_DROP, 9); | |
| 295 | ||
| 296 | // Halfway through its draw a quarter of the drop is left: (0.5)² = 0.25. | |
| 297 | w.time += 0.625; | |
| 298 | buildScene(w, camera(), scene); | |
| 299 | expect(scene.viewModel?.offset[1]).toBeCloseTo(level - RAISE_DROP * 0.25, 9); | |
| 300 | ||
| 301 | // The tick the trigger answers, the gun is level. | |
| 302 | w.time = w.me.deployUntil; | |
| 303 | buildScene(w, camera(), scene); | |
| 304 | expect(scene.viewModel?.offset[1]).toBeCloseTo(level, 9); | |
| 305 | }); | |
| 306 | ||
| 307 | test("the raise is read off the shot clock alone", () => { | |
| 308 | expect(raiseLeft(10, 11.25, 1.25)).toBe(1); | |
| 309 | expect(raiseLeft(10.625, 11.25, 1.25)).toBeCloseTo(0.5, 12); | |
| 310 | expect(raiseLeft(11.25, 11.25, 1.25)).toBe(0); | |
| 311 | // A round start zeroes the clock: nothing is owed and nothing rises. | |
| 312 | expect(raiseLeft(0, 0, 1.25)).toBe(0); | |
| 313 | // A clock whole ticks past the draw never pushes the gun below the floor. | |
| 314 | expect(raiseLeft(10, 11.3, 1.25)).toBe(1); | |
| 315 | }); | |
| 316 | ||
| 317 | test("reduced motion stills the walk's sway on the gun, and leaves the kick and the raise alone", () => { | |
| 318 | const w = world(); | |
| 319 | w.me.vel = [250, 0, 0]; | |
| 320 | const full = createScene(); | |
| 321 | const still = createScene(); | |
| 322 | // A few frames of walking, so the walk cycle is somewhere with a sway in it. | |
| 323 | for (let i = 0; i < 5; i++) { | |
| 324 | w.time += 0.05; | |
| 325 | buildScene(w, camera(), full); | |
| 326 | buildScene(w, camera(), still, w.me, "reduced"); | |
| 327 | } | |
| 328 | expect(Math.abs(full.viewModel?.offset[0] ?? 0)).toBeGreaterThan(0.001); | |
| 329 | expect(still.viewModel?.offset[0]).toBe(0); | |
| 330 | expect(still.viewModel?.offset[1]).toBe(0); | |
| 331 | ||
| 332 | // A shot this instant and a gun just drawn: both are things that happened, | |
| 333 | // and a reduced view kicks and raises exactly as a full one standing still. | |
| 334 | w.me.vel = [0, 0, 0]; | |
| 335 | w.me.lastShotAt = w.time; | |
| 336 | w.me.deployUntil = w.time + 0.35; | |
| 337 | const standing = createScene(); | |
| 338 | buildScene(w, camera(), standing); | |
| 339 | buildScene(w, camera(), still, w.me, "reduced"); | |
| 340 | expect(still.viewModel?.offset[1]).toBeCloseTo(standing.viewModel?.offset[1] ?? NaN, 12); | |
| 341 | expect(still.viewModel?.offset[2]).toBeCloseTo(standing.viewModel?.offset[2] ?? NaN, 12); | |
| 342 | expect(still.viewModel?.offset[2]).toBeGreaterThan(0); | |
| 343 | }); |
src/client/scene.ts +35 −5
| @@ -25,12 +25,34 @@ import { | ||
| 25 | 25 | } from "../engine/index.ts"; |
| 26 | 26 | import { cloudRadius } from "./grenade.ts"; |
| 27 | 27 | import { CLOUD_MESH_RADIUS } from "./models.ts"; |
| 28 | import { DEFAULT_MOTION, type Motion } from "./motion.ts"; | |
| 28 | 29 | import { CS_HULL, NADE_HULL } from "./movement.ts"; |
| 29 | 30 | import { FLASH, HE, WEAPONS } from "./weapons.ts"; |
| 30 | 31 | import type { Player, World } from "./world.ts"; |
| 31 | 32 | |
| 32 | 33 | export type Tint = [number, number, number]; |
| 33 | 34 | |
| 35 | /** | |
| 36 | * How far below level a gun on its way up sits, in the view model's own units | |
| 37 | * (the walk bob is 0.04 of them): far enough to be off the bottom of the | |
| 38 | * screen the frame it is drawn. | |
| 39 | */ | |
| 40 | export const RAISE_DROP = 0.7; | |
| 41 | ||
| 42 | /** | |
| 43 | * How much of a raise is left, 1 the frame the gun is drawn and 0 when it is | |
| 44 | * level. It is read off `deployUntil`, the clock `world.ts` already refuses the | |
| 45 | * trigger on, so *the gun is up* and *the gun can shoot* are one moment and not | |
| 46 | * two: an AWP takes its 1.25 s to come up, a pistol 0.7 s. Nothing here keeps a | |
| 47 | * clock of its own, so a switch no frame was drawn of is still read right, a | |
| 48 | * paused match raises nothing, and a round start — which zeroes `deployUntil` | |
| 49 | * rather than deploying — begins with the gun already up. | |
| 50 | */ | |
| 51 | export function raiseLeft(time: number, deployUntil: number, deployTime: number): number { | |
| 52 | if (time >= deployUntil || deployTime <= 0) return 0; | |
| 53 | return Math.min(1, (deployUntil - time) / deployTime); | |
| 54 | } | |
| 55 | ||
| 34 | 56 | /** The gun in your hands: which one, and where it sits in view space. */ |
| 35 | 57 | export type ViewModel = { mesh: string; offset: Vec3 }; |
| 36 | 58 | |
| @@ -207,7 +229,9 @@ function shade( | ||
| 207 | 229 | * that is left out because the camera is inside its head, and the gun that is |
| 208 | 230 | * drawn in view space because it is the one those hands are holding. |
| 209 | 231 | */ |
| 210 | export function buildScene(world: World, camera: Camera, into: Scene, view: Player = world.me): Scene { | |
| 232 | export function buildScene( | |
| 233 | world: World, camera: Camera, into: Scene, view: Player = world.me, motion: Motion = DEFAULT_MOTION, | |
| 234 | ): Scene { | |
| 211 | 235 | into.camera.pos[0] = camera.pos[0]; |
| 212 | 236 | into.camera.pos[1] = camera.pos[1]; |
| 213 | 237 | into.camera.pos[2] = camera.pos[2]; |
| @@ -329,20 +353,26 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 329 | 353 | ); |
| 330 | 354 | } |
| 331 | 355 | |
| 332 | // --- the gun in these hands: a walk cycle it sways on, a kick that decays | |
| 356 | // --- the gun in these hands: a walk cycle it sways on, a kick that decays, | |
| 357 | // and a raise from below for as long as the trigger is still refused | |
| 333 | 358 | if (view.alive && !view.scoped) { |
| 334 | 359 | // Simulated seconds, not frame seconds: a match that restarts goes back to |
| 335 | 360 | // zero and a paused frame moves nothing, so the clamp holds both ends. |
| 336 | 361 | const dt = clamp(world.time - into.keep.time, 0, 0.1); |
| 337 | 362 | const speed = Math.hypot(view.vel[0], view.vel[1]); |
| 338 | 363 | into.keep.bobPhase += dt * (4 + speed * 0.02); |
| 339 | const bobX = Math.sin(into.keep.bobPhase) * Math.min(speed / 250, 1) * 0.05; | |
| 340 | const bobY = Math.abs(Math.cos(into.keep.bobPhase)) * Math.min(speed / 250, 1) * 0.04; | |
| 364 | // The walk's sway says nothing a player can act on, so reduced motion | |
| 365 | // takes it to exactly zero — the gun a player standing still already | |
| 366 | // holds. The kick and the raise below are things that happened, and stay. | |
| 367 | const sway = motion === "reduced" ? 0 : Math.min(speed / 250, 1); | |
| 368 | const bobX = Math.sin(into.keep.bobPhase) * sway * 0.05; | |
| 369 | const bobY = Math.abs(Math.cos(into.keep.bobPhase)) * sway * 0.04; | |
| 341 | 370 | const kick = Math.max(0, 0.12 - (world.time - view.lastShotAt)) * 2.2; |
| 371 | const rise = raiseLeft(world.time, view.deployUntil, WEAPONS[view.weapon]?.deployTime ?? 0); | |
| 342 | 372 | const held = into.keep.viewModel; |
| 343 | 373 | held.mesh = WEAPONS[view.weapon]?.kind ?? "rifle"; |
| 344 | 374 | held.offset[0] = bobX; |
| 345 | held.offset[1] = bobY - kick * 0.12; | |
| 375 | held.offset[1] = bobY - kick * 0.12 - RAISE_DROP * rise * rise; | |
| 346 | 376 | held.offset[2] = kick * 0.35; |
| 347 | 377 | into.viewModel = held; |
| 348 | 378 | } else { |
src/client/stage.test-helper.ts +3 −2
| @@ -220,7 +220,7 @@ export type FakeStage = { | ||
| 220 | 220 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 221 | 221 | * served page against this same list rather than a second copy of it. |
| 222 | 222 | */ |
| 223 | export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "joinroom", "makeroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"]; | |
| 223 | export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "motion", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "joinroom", "makeroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"]; | |
| 224 | 224 | |
| 225 | 225 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 226 | 226 | const HIDDEN = ["pause", "summary", "buy", "touch", "full"]; |
| @@ -251,7 +251,7 @@ const GLOBALS = ["document", "window", "localStorage", "fetch", "location"]; | ||
| 251 | 251 | */ |
| 252 | 252 | export function fakeStage( |
| 253 | 253 | options: { |
| 254 | webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean; | |
| 254 | webgl?: boolean; coarse?: boolean; reducedMotion?: boolean; fullscreen?: boolean; keyboardLock?: boolean; | |
| 255 | 255 | /** |
| 256 | 256 | * What this page's weights are: the committed ones, a deploy that lost them |
| 257 | 257 | * (`missing`), or bytes that are not a policy (`junk`). |
| @@ -283,6 +283,7 @@ export function fakeStage( | ||
| 283 | 283 | hidden: HIDDEN, |
| 284 | 284 | actions: PAGE_ACTIONS, |
| 285 | 285 | coarse: options.coarse, |
| 286 | reducedMotion: options.reducedMotion, | |
| 286 | 287 | fullscreen: options.fullscreen, |
| 287 | 288 | keyboardLock: options.keyboardLock, |
| 288 | 289 | }); |
src/client/world.test.ts +16 −1
| @@ -310,7 +310,7 @@ test("a burst of footsteps cannot push a hit marker out of the ring", () => { | ||
| 310 | 310 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 29); |
| 311 | 311 | run(world, SHORT.freezeTime + 0.2); |
| 312 | 312 | // The marker for a hit that just landed, pushed the way the world pushes one. |
| 313 | const marker = { kind: "hitmark" as const, at: world.time, headshot: true }; | |
| 313 | const marker = { kind: "hitmark" as const, at: world.time, headshot: true, killed: false }; | |
| 314 | 314 | pushEffect(world.effects, marker, marker.at); |
| 315 | 315 | // Ten bodies running for the loudest second and nine tenths the ring can |
| 316 | 316 | // hold — EFFECT_LIFE is two, so nothing here has expired yet. |
| @@ -624,6 +624,9 @@ test("damage a bot takes is nobody's screen: only the person's hits are remember | ||
| 624 | 624 | for (let i = 0; i < world.effects.count; i++) |
| 625 | 625 | if (effectAt(world.effects, i)?.kind === "hitmark") marks += 1; |
| 626 | 626 | expect(marks).toBe(1); |
| 627 | // They are still standing, so the mark is a hit's and not a kill's. | |
| 628 | expect(enemy.alive).toBe(true); | |
| 629 | expect(marksOf(world)).toEqual([{ headshot: expect.any(Boolean), killed: false }]); | |
| 627 | 630 | }); |
| 628 | 631 | |
| 629 | 632 | test("a blast you are caught in came from the blast, and not from whoever threw it", () => { |
| @@ -769,8 +772,20 @@ test("you stabbing them in the back is the same rule, and marks the hit", () => | ||
| 769 | 772 | if (effectAt(world.effects, i)?.kind === "hitmark") marks += 1; |
| 770 | 773 | } |
| 771 | 774 | expect(marks).toBe(1); |
| 775 | // The stab that finished them says so: the HUD draws this one as an X. | |
| 776 | expect(marksOf(world)).toEqual([{ headshot: false, killed: true }]); | |
| 772 | 777 | }); |
| 773 | 778 | |
| 779 | /** Every hit marker the person has been shown, as what it says. */ | |
| 780 | function marksOf(world: World): { headshot: boolean; killed: boolean }[] { | |
| 781 | const out: { headshot: boolean; killed: boolean }[] = []; | |
| 782 | for (let i = 0; i < world.effects.count; i++) { | |
| 783 | const e = effectAt(world.effects, i); | |
| 784 | if (e?.kind === "hitmark") out.push({ headshot: e.headshot, killed: e.killed }); | |
| 785 | } | |
| 786 | return out; | |
| 787 | } | |
| 788 | ||
| 774 | 789 | test("a rifle in someone's back does what a rifle did before the knife learned this", () => { |
| 775 | 790 | const world = alone(407); |
| 776 | 791 | const enemy = world.players.find((p) => p.team === "t") as Player; |
src/client/world.ts +7 −3
| @@ -120,7 +120,9 @@ export type Effect = | ||
| 120 | 120 | * id it is derived from can (`src/rooms/wire.ts`). |
| 121 | 121 | */ |
| 122 | 122 | | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; by: number } |
| 123 | | { kind: "hitmark"; at: number; headshot: boolean } | |
| 123 | // A hit of the person's own: `killed` is whether it was the one that | |
| 124 | // finished them, so the fifth bullet does not look like the four before it. | |
| 125 | | { kind: "hitmark"; at: number; headshot: boolean; killed: boolean } | |
| 124 | 126 | /** |
| 125 | 127 | * Damage *you* took, and where it came from: the shooter's feet, or the |
| 126 | 128 | * middle of the blast, at the moment it landed. The mirror image of a hit |
| @@ -578,7 +580,8 @@ export function shoot(world: World, p: Player): boolean { | ||
| 578 | 580 | // muzzle or the point on your chest the bullet met: an arrow on the screen |
| 579 | 581 | // is a bearing to a person, so it is read off the person. |
| 580 | 582 | hurt(world, victim, p.pos, result.damage); |
| 581 | if (p === world.me) record(world, { kind: "hitmark", at: world.time, headshot: result.headshot }); | |
| 583 | if (p === world.me) | |
| 584 | record(world, { kind: "hitmark", at: world.time, headshot: result.headshot, killed: result.killed }); | |
| 582 | 585 | if (result.killed) onKill(world, p, victim, w.id, result.headshot); |
| 583 | 586 | } |
| 584 | 587 | return true; |
| @@ -652,7 +655,8 @@ function explode(world: World, g: Grenade, centre: Vec3): void { | ||
| 652 | 655 | hurt(world, victim, centre, result.damage); |
| 653 | 656 | if (thrower === undefined) continue; |
| 654 | 657 | thrower.damage += result.damage; |
| 655 | if (thrower === world.me) record(world, { kind: "hitmark", at: world.time, headshot: false }); | |
| 658 | if (thrower === world.me) | |
| 659 | record(world, { kind: "hitmark", at: world.time, headshot: false, killed: result.killed }); | |
| 656 | 660 | if (result.killed) onKill(world, thrower, victim, "he", false); |
| 657 | 661 | } |
| 658 | 662 | } |
src/engine/page.test.ts +4
| @@ -294,6 +294,10 @@ test("a frame waits in the queue until the test runs it, and the clock is the te | ||
| 294 | 294 | test("a phone is a page that says so, and nothing else about it changes", () => { |
| 295 | 295 | expect(fakePage().window.matchMedia("(pointer: coarse)").matches).toBe(false); |
| 296 | 296 | expect(fakePage({ coarse: true }).window.matchMedia("(pointer: coarse)").matches).toBe(true); |
| 297 | // Each query answers for itself: a phone has not asked for reduced motion. | |
| 298 | expect(fakePage({ coarse: true }).window.matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(false); | |
| 299 | expect(fakePage({ reducedMotion: true }).window.matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true); | |
| 300 | expect(fakePage({ reducedMotion: true }).window.matchMedia("(pointer: coarse)").matches).toBe(false); | |
| 297 | 301 | expect(fakePage().window.devicePixelRatio).toBe(1); |
| 298 | 302 | expect(fakePage().document.readyState).toBe("complete"); |
| 299 | 303 | }); |
src/engine/page.ts +10 −2
| @@ -330,8 +330,10 @@ export type PageOptions = { | ||
| 330 | 330 | actions?: readonly string[]; |
| 331 | 331 | /** Give the canvases a 2D recorder. False is a browser that has no 2D context. */ |
| 332 | 332 | paint?: boolean; |
| 333 | /** What `matchMedia` answers. True is a phone. */ | |
| 333 | /** What `matchMedia("(pointer: coarse)")` answers. True is a phone. */ | |
| 334 | 334 | coarse?: boolean; |
| 335 | /** What `matchMedia("(prefers-reduced-motion: reduce)")` answers. */ | |
| 336 | reducedMotion?: boolean; | |
| 335 | 337 | /** |
| 336 | 338 | * The `<meta name=…>` elements this page was served with, by name — the |
| 337 | 339 | * server's own words about the deployment, which `pages/shell.ts` writes and |
| @@ -476,7 +478,13 @@ export function fakePage(options: PageOptions = {}): FakePage { | ||
| 476 | 478 | devicePixelRatio: 1, |
| 477 | 479 | requestAnimationFrame: (callback) => frames.push(callback), |
| 478 | 480 | cancelAnimationFrame: () => void frames.splice(0, frames.length), |
| 479 | matchMedia: (_query) => ({ matches: options.coarse === true }), | |
| 481 | // Each query is answered for itself, and one this page was not told | |
| 482 | // about is a browser that does not match it. | |
| 483 | matchMedia: (query) => ({ | |
| 484 | matches: query === "(pointer: coarse)" ? options.coarse === true | |
| 485 | : query === "(prefers-reduced-motion: reduce)" ? options.reducedMotion === true | |
| 486 | : false, | |
| 487 | }), | |
| 480 | 488 | performance: { now: () => clock }, |
| 481 | 489 | }; |
| 482 | 490 |
src/pages/game.test.ts +9
| @@ -91,6 +91,15 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => { | ||
| 91 | 91 | expect(html).toContain(`<button type="button" class="primary" id="play" disabled>Play</button>`); |
| 92 | 92 | }); |
| 93 | 93 | |
| 94 | test("the menu has a Motion control, its first option the game's default for the client to move", () => { | |
| 95 | // No `selected`: a select picks its first option, and the page's one | |
| 96 | // `selected` word stays the map's. | |
| 97 | const html = gamePage(db(), options); | |
| 98 | expect(html).toContain( | |
| 99 | `<label>Motion<select id="motion"><option value="full">Full</option><option value="reduced">Reduced</option></select></label>`, | |
| 100 | ); | |
| 101 | }); | |
| 102 | ||
| 94 | 103 | test("a recent match names the map it was played on, and the name is a link to it", () => { |
| 95 | 104 | const home = db(); |
| 96 | 105 | const player: Player = { userId: "u-1", session: "s1", name: "kestrel" }; |
src/pages/game.ts +3
| @@ -20,6 +20,7 @@ import type { Database } from "bun:sqlite"; | ||
| 20 | 20 | import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts"; |
| 21 | 21 | import { dayBoard, displayName, headshotShare, liveness, type Liveness, ratio, recentMatches, standings } from "../db.ts"; |
| 22 | 22 | import { COMPETITIVE, SHORT } from "../client/match.ts"; |
| 23 | import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts"; | |
| 23 | 24 | import { CS_MOVE } from "../client/movement.ts"; |
| 24 | 25 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 25 | 26 | import { FLASH, HE, SMOKE } from "../client/weapons.ts"; |
| @@ -342,6 +343,8 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 342 | 343 | asked.map === "generated" ? " selected" : "" |
| 343 | 344 | }>generated — a new one every match</option></select></label> |
| 344 | 345 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 346 | <label>Motion<select id="motion">${[DEFAULT_MOTION, ...MOTIONS.filter((m) => m !== DEFAULT_MOTION)].map((m) => | |
| 347 | `<option value="${m}">${escape(MOTION_LABELS[m])}</option>`).join("")}</select></label> | |
| 345 | 348 | </div> |
| 346 | 349 | <div class="row"> |
| 347 | 350 | <label>Match id<input id="matchid" maxlength="${LONGEST_PASTE}" placeholder="paste an id or a link to play it again" |
new above
offline