Public: anyone can read this; the crew can change it.

forked from trevato/cs

Take updates from trevato/cs #361

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/359-take-updates-from-trevato-cs → main 4 commits fork

  • Merged. torque/359-take-updates-from-trevato-cs was merged into main as 1ecc709.

Review

Sign in to review this change.
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 —
415415 time**: the carry speed changes on the tick you press the key, so tapping
416416 <kbd>3</kbd> runs at the knife's 250 u/s straight away, but the weapon you come
417417 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
419423 sightline is a decision, and the sums are in `src/client/weapons.ts` —
420424 <kbd>Tab</kbd> shows
421425 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
443447 `/range` the thumb pair reloads and switches drill and the middle button is left
444448 to the browser. A button no game binds keeps whatever meaning the browser gave it.
445449
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
446467 **Windows is asked for the mouse the way Windows needs.** `Controls.lock()` asks
447468 for `unadjustedMovement`, because without it Chromium on Windows hands the page
448469 a `movementX` with the OS's pointer speed and "enhance pointer precision"
@@ -899,7 +920,10 @@ never checked itself.
899920 the engine's ring: `EFFECT_CAPACITY` events at most, each stamped, pruned at
900921 the end of every tick by `EFFECT_LIFE` without allocating),
901922 `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
903927 clock, the kill feed, the scoreboard and the red arc and flash that say you
904928 were hit and from where — every one of them drawn through the
905929 engine's `overlay` since #18, so what is left in the file is this game's
@@ -984,7 +1008,10 @@ never checked itself.
9841008 with a recorder of its own that writes down the brush as well as the call, so
9851009 what is pinned is what a player reads: the health that goes red, the ammo, the
9861010 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
9881015 feed fading out over eight seconds, the scoreboard's order, the damage arc
9891016 that sits behind you when you are shot in the back and walks ninety degrees
9901017 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
263263 expect(victim.health).toBeLessThan(healthBefore);
264264 expect(world.me.damage).toBeGreaterThan(damageBefore);
265265 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);
266268 expect(heard(world, (e) => e.kind === "blast")).toBe(true);
267269 expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(true);
268270 // 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 @@
2020 */
2121
2222 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";
2425 import { recorder, type Call, type Recorder } from "./paint.test-helper.ts";
2526 import { DE_FORGE } from "./map.ts";
2627 import { SHORT } from "./match.ts";
@@ -84,13 +85,13 @@ const HEIGHT = 720;
8485 function draw(
8586 world: World,
8687 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;
8889 } = {},
8990 ): Recorder {
9091 const paint = recorder();
9192 drawHud(
9293 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,
9495 );
9596 return paint;
9697 }
@@ -224,7 +225,7 @@ test("a crosshair is the same size on the glass however dense the screen is", ()
224225 test("a hit is marked white, a headshot red, and both are gone a quarter second later", () => {
225226 const hit = draw(scene((w) => {
226227 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 });
228229 }));
229230 // One stroke, and it is the marker: the crosshair is filled, not stroked.
230231 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
234235
235236 const head = draw(scene((w) => {
236237 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 });
238239 }));
239240 expect(head.calls.filter((c) => c.op === "stroke")[0]?.stroke).toBe("rgba(255,90,90,0.95)");
240241
241242 const old = draw(scene((w) => {
242243 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 });
244245 }));
245246 // A quarter second later the marker is gone and nothing is stroked at all —
246247 // 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
248249 expect(aimed(old)).toBe(true);
249250 });
250251
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
251271 // --- health, money and armour ----------------------------------------------
252272
253273 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",
321341 expect(says(both, "switching")).toBe(true);
322342 expect(says(both, "reloading")).toBe(false);
323343
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
324360 // Carrying the bomb is written beside the gun's name.
325361 const carrier = draw(scene((w) => { w.me.team = "t"; w.me.hasBomb = true; }));
326362 expect(says(carrier, `${WEAPONS.usp.name} · bomb`)).toBe(true);
@@ -578,7 +614,7 @@ const busyWorld = (): World => scene((w) => {
578614 w.bomb.defuser = w.me;
579615 w.bomb.defuseProgress = 1;
580616 w.effects = these(
581 { kind: "hitmark", at: 99.95, headshot: true },
617 { kind: "hitmark", at: 99.95, headshot: true, killed: false },
582618 // Somebody is shooting back, which is a wash and an arc on the same frame.
583619 { kind: "hurt", at: 99.9, from: [w.me.pos[0] - 400, w.me.pos[1] + 250, 0], damage: 27 },
584620 );
@@ -767,6 +803,19 @@ test("the arc points at whoever hit you, and turning takes it with you", () => {
767803 expect(right[0]?.radius).toBeCloseTo(HEIGHT * 0.28, 6);
768804 });
769805
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
770819 test("an arc fades over its life and is gone at the end of it, and two attackers are two arcs", () => {
771820 const fresh = arcs(draw(shotAt([-500, 0], 0, 27, 0)));
772821 expect(fresh[0]?.alpha).toBeCloseTo(0.85, 3);
src/client/hud.ts +40 −6
@@ -30,6 +30,7 @@ import {
3030 type Overlay, type Paint as Surface, type Vec3,
3131 } from "../engine/index.ts";
3232 import { drawMinimap } from "./minimap.ts";
33 import { DEFAULT_MOTION, type Motion } from "./motion.ts";
3334 import { CS_MOVE } from "./movement.ts";
3435 import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts";
3536 import { inaccuracy, WEAPONS } from "./weapons.ts";
@@ -58,6 +59,22 @@ export type Paint = Surface & {
5859 */
5960 const RADAR = 180;
6061
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
6178 /** What a dead player is told, in the two cases there are. */
6279 const DEAD = "You are dead — watching the round out";
6380 const WATCHING = "Watching";
@@ -208,6 +225,7 @@ export function drawHud(
208225 showScoreboard: boolean,
209226 view: Player = world.me,
210227 dpr = 1,
228 motion: Motion = DEFAULT_MOTION,
211229 ): void {
212230 const ui = toolsFor(ctx, width, height, dpr);
213231 ctx.clearRect(0, 0, width, height);
@@ -265,12 +283,12 @@ export function drawHud(
265283 const effect = effectAt(world.effects, i);
266284 if (effect === undefined || effect.kind !== "hitmark") continue;
267285 const age = world.time - effect.at;
268 if (age > 0.25) continue;
286 if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue;
269287 ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)";
270288 // On the diagonals, so there is no pixel grid to land on and no point
271289 // pretending there is — but it is still the same size on the glass at every
272290 // 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);
274292 const r = ui.px(9 + age * 20);
275293 ctx.beginPath();
276294 const arms: [number, number][] = [[1, 1], [-1, -1], [1, -1], [-1, 1]];
@@ -278,6 +296,13 @@ export function drawHud(
278296 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
279297 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
280298 }
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 }
281306 ctx.stroke();
282307 }
283308
@@ -313,7 +338,11 @@ export function drawHud(
313338 const age = world.time - effect.at;
314339 if (age < 0 || age >= HURT_LIFE) continue;
315340 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)})`;
317346 ctx.beginPath();
318347 ctx.arc(width / 2, height / 2, radius, mid - HURT_HALF, mid + HURT_HALF);
319348 ctx.stroke();
@@ -355,10 +384,15 @@ export function drawHud(
355384 if (view.team === "ct" && view.defuseKit)
356385 ui.text("defuse kit", pad, moneyY + Math.round(20 * scale), { align: "left", font: small, fill: BLUE });
357386
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;
359393 const ammo = view.ammo[weapon.id];
360394 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 });
362396 // What is in your hands, and then what is in your pockets: the grenades you
363397 // bought and the bomb, each named only while you are carrying it.
364398 ui.text(
@@ -368,7 +402,7 @@ export function drawHud(
368402 (view.pocket.smoke > 0 ? " · smoke" : "") +
369403 (view.hasBomb ? " · bomb" : ""),
370404 width - pad, height - pad - Math.round(30 * scale),
371 { align: "right", font: small, fill: GREY },
405 { align: "right", font: small, fill: GREY, alpha: ready },
372406 );
373407 // What the gun is busy doing, in the one line there is room for. The two
374408 // 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
103103 expect(stage.frames.length).toBe(1);
104104 });
105105
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
106111 test("the atlas is painted a layer a frame, and the menu says so while it is", () => {
107112 const stage = start(fakeStage());
108113 // 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";
8888 import type { Level } from "./level.ts";
8989 import { FOV, fovFor, lookAt } from "./look.ts";
9090 import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts";
91 import { motionFor, parseMotion, REDUCED_MOTION_QUERY, type Motion } from "./motion.ts";
9192 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
9293 import { meshFor } from "./models.ts";
9394 import { parsePacked } from "./packed.ts";
@@ -368,6 +369,12 @@ export function boot(host: Host): Frame {
368369 const lengthField = by<Field>("length");
369370 const sideField = by<Field>("side");
370371 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);
371378 const mapField = by<Field>("map");
372379 // The id a person pasted, the line that answers them, and the three places
373380 // the match being played says what it is: the section under the stage (the
@@ -1182,6 +1189,7 @@ export function boot(host: Host): Frame {
11821189 postedFinal = false;
11831190 loop.reset();
11841191 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
1192 motion = parseMotion(motionField.value);
11851193 running = true;
11861194 lastSound = 0;
11871195 show(menu, false);
@@ -1231,6 +1239,7 @@ export function boot(host: Host): Frame {
12311239 postedFinal = true; // a room's result is the server's to write, never this page's
12321240 loop.reset();
12331241 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
1242 motion = parseMotion(motionField.value);
12341243 running = true;
12351244 lastSound = 0;
12361245 show(menu, false);
@@ -1565,7 +1574,7 @@ export function boot(host: Host): Frame {
15651574 }
15661575 if (world.match.phase === "match_over" && running) endMatch(world);
15671576
1568 renderer.draw(buildScene(world, camera, frameScene, view), elapsed);
1577 renderer.draw(buildScene(world, camera, frameScene, view, motion), elapsed);
15691578
15701579 // The same backing store the renderer gives the scene canvas underneath —
15711580 // 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 {
15761585 hudCanvas.width = w;
15771586 hudCanvas.height = h;
15781587 }
1579 drawHud(hudCtx, world, w, h, scoreboard, view, win.devicePixelRatio);
1588 drawHud(hudCtx, world, w, h, scoreboard, view, win.devicePixelRatio, motion);
15801589 by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`;
15811590 }
15821591 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";
1515 import { join } from "node:path";
1616 import { pushEffect, resetEffects, type Camera } from "../engine/index.ts";
1717 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";
1919 import { DE_FORGE } from "./map.ts";
2020 import { SHORT } from "./match.ts";
2121 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", () =>
279279 expect(source).toContain('from "../engine/index.ts"');
280280 expect(source).toContain("export type Scene = Frame &");
281281 });
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 {
2525 } from "../engine/index.ts";
2626 import { cloudRadius } from "./grenade.ts";
2727 import { CLOUD_MESH_RADIUS } from "./models.ts";
28 import { DEFAULT_MOTION, type Motion } from "./motion.ts";
2829 import { CS_HULL, NADE_HULL } from "./movement.ts";
2930 import { FLASH, HE, WEAPONS } from "./weapons.ts";
3031 import type { Player, World } from "./world.ts";
3132
3233 export type Tint = [number, number, number];
3334
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
3456 /** The gun in your hands: which one, and where it sits in view space. */
3557 export type ViewModel = { mesh: string; offset: Vec3 };
3658
@@ -207,7 +229,9 @@ function shade(
207229 * that is left out because the camera is inside its head, and the gun that is
208230 * drawn in view space because it is the one those hands are holding.
209231 */
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 {
211235 into.camera.pos[0] = camera.pos[0];
212236 into.camera.pos[1] = camera.pos[1];
213237 into.camera.pos[2] = camera.pos[2];
@@ -329,20 +353,26 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
329353 );
330354 }
331355
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
333358 if (view.alive && !view.scoped) {
334359 // Simulated seconds, not frame seconds: a match that restarts goes back to
335360 // zero and a paused frame moves nothing, so the clamp holds both ends.
336361 const dt = clamp(world.time - into.keep.time, 0, 0.1);
337362 const speed = Math.hypot(view.vel[0], view.vel[1]);
338363 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;
341370 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);
342372 const held = into.keep.viewModel;
343373 held.mesh = WEAPONS[view.weapon]?.kind ?? "rifle";
344374 held.offset[0] = bobX;
345 held.offset[1] = bobY - kick * 0.12;
375 held.offset[1] = bobY - kick * 0.12 - RAISE_DROP * rise * rise;
346376 held.offset[2] = kick * 0.35;
347377 into.viewModel = held;
348378 } else {
src/client/stage.test-helper.ts +3 −2
@@ -220,7 +220,7 @@ export type FakeStage = {
220220 * contract between the page and this file, so `src/server.test.ts` checks the
221221 * served page against this same list rather than a second copy of it.
222222 */
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"];
224224
225225 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
226226 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
@@ -251,7 +251,7 @@ const GLOBALS = ["document", "window", "localStorage", "fetch", "location"];
251251 */
252252 export function fakeStage(
253253 options: {
254 webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean;
254 webgl?: boolean; coarse?: boolean; reducedMotion?: boolean; fullscreen?: boolean; keyboardLock?: boolean;
255255 /**
256256 * What this page's weights are: the committed ones, a deploy that lost them
257257 * (`missing`), or bytes that are not a policy (`junk`).
@@ -283,6 +283,7 @@ export function fakeStage(
283283 hidden: HIDDEN,
284284 actions: PAGE_ACTIONS,
285285 coarse: options.coarse,
286 reducedMotion: options.reducedMotion,
286287 fullscreen: options.fullscreen,
287288 keyboardLock: options.keyboardLock,
288289 });
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", () => {
310310 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 29);
311311 run(world, SHORT.freezeTime + 0.2);
312312 // 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 };
314314 pushEffect(world.effects, marker, marker.at);
315315 // Ten bodies running for the loudest second and nine tenths the ring can
316316 // 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
624624 for (let i = 0; i < world.effects.count; i++)
625625 if (effectAt(world.effects, i)?.kind === "hitmark") marks += 1;
626626 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 }]);
627630 });
628631
629632 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", () =>
769772 if (effectAt(world.effects, i)?.kind === "hitmark") marks += 1;
770773 }
771774 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 }]);
772777 });
773778
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
774789 test("a rifle in someone's back does what a rifle did before the knife learned this", () => {
775790 const world = alone(407);
776791 const enemy = world.players.find((p) => p.team === "t") as Player;
src/client/world.ts +7 −3
@@ -120,7 +120,9 @@ export type Effect =
120120 * id it is derived from can (`src/rooms/wire.ts`).
121121 */
122122 | { 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 }
124126 /**
125127 * Damage *you* took, and where it came from: the shooter's feet, or the
126128 * 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 {
578580 // muzzle or the point on your chest the bullet met: an arrow on the screen
579581 // is a bearing to a person, so it is read off the person.
580582 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 });
582585 if (result.killed) onKill(world, p, victim, w.id, result.headshot);
583586 }
584587 return true;
@@ -652,7 +655,8 @@ function explode(world: World, g: Grenade, centre: Vec3): void {
652655 hurt(world, victim, centre, result.damage);
653656 if (thrower === undefined) continue;
654657 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 });
656660 if (result.killed) onKill(world, thrower, victim, "he", false);
657661 }
658662 }
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
294294 test("a phone is a page that says so, and nothing else about it changes", () => {
295295 expect(fakePage().window.matchMedia("(pointer: coarse)").matches).toBe(false);
296296 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);
297301 expect(fakePage().window.devicePixelRatio).toBe(1);
298302 expect(fakePage().document.readyState).toBe("complete");
299303 });
src/engine/page.ts +10 −2
@@ -330,8 +330,10 @@ export type PageOptions = {
330330 actions?: readonly string[];
331331 /** Give the canvases a 2D recorder. False is a browser that has no 2D context. */
332332 paint?: boolean;
333 /** What `matchMedia` answers. True is a phone. */
333 /** What `matchMedia("(pointer: coarse)")` answers. True is a phone. */
334334 coarse?: boolean;
335 /** What `matchMedia("(prefers-reduced-motion: reduce)")` answers. */
336 reducedMotion?: boolean;
335337 /**
336338 * The `<meta name=…>` elements this page was served with, by name — the
337339 * server's own words about the deployment, which `pages/shell.ts` writes and
@@ -476,7 +478,13 @@ export function fakePage(options: PageOptions = {}): FakePage {
476478 devicePixelRatio: 1,
477479 requestAnimationFrame: (callback) => frames.push(callback),
478480 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 }),
480488 performance: { now: () => clock },
481489 };
482490
src/pages/game.test.ts +9
@@ -91,6 +91,15 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => {
9191 expect(html).toContain(`<button type="button" class="primary" id="play" disabled>Play</button>`);
9292 });
9393
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
94103 test("a recent match names the map it was played on, and the name is a link to it", () => {
95104 const home = db();
96105 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";
2020 import { dailyOn, type Daily, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
2121 import { dayBoard, displayName, headshotShare, liveness, type Liveness, ratio, recentMatches, standings } from "../db.ts";
2222 import { COMPETITIVE, SHORT } from "../client/match.ts";
23 import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts";
2324 import { CS_MOVE } from "../client/movement.ts";
2425 import { NOTHING_POSTED } from "../client/pending.ts";
2526 import { FLASH, HE, SMOKE } from "../client/weapons.ts";
@@ -342,6 +343,8 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
342343 asked.map === "generated" ? " selected" : ""
343344 }>generated — a new one every match</option></select></label>
344345 <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>
345348 </div>
346349 <div class="row">
347350 <label>Match id<input id="matchid" maxlength="${LONGEST_PASTE}" placeholder="paste an id or a link to play it again"

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs