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

forked from trevato/cs

Take updates from trevato/cs #393

Merged opened this in answer to

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

Merged

torque/cs-engine:torque/390-take-updates-from-trevato-cs → main 3 commits fork

  • Merged. torque/390-take-updates-from-trevato-cs was merged into main as f73b0ff.

Review

Sign in to review this change.
14 files changed +397 −18
README.md +21 −6
@@ -93,7 +93,17 @@ yet.
9393 ## What is real and what is not
9494
9595 - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style
96 movement (ground friction, air acceleration, an 18-unit step, strafe jumping),
96 movement (ground friction, air acceleration, an 18-unit step, strafe jumping,
97 and the **crouch-jump**: a jump peaks at 57 units, 7 short of the 64-unit
98 crates the maps are full of, but duck in the air and the legs tuck up under
99 a head that stays put, so the feet clear 75 and you land on top — jump
100 without ducking and the crate is still in your way. The jump itself is no
101 higher, and standing back up in the air is refused while the legs have
102 nowhere to go, the same way it is under a low ceiling. It lives in the
103 engine's `move`, so the range's body tucks its legs too. **The bots do not
104 jump**, so a crate top is a place a bot will not follow you to: one sent
105 after something up there, the bomb included, walks to the nearest floor
106 beside it and waits),
97107 CS damage with range falloff, armour and hitgroups, deterministic spray patterns,
98108 two thrown grenades — an HE, and a flashbang that blinds by how much of it you
99109 could see (the line, the angle and the distance, for at most 1.6 seconds) —
@@ -1069,7 +1079,10 @@ never checked itself.
10691079 crosshair that opens as far as the gun is inaccurate, the hit marker — four
10701080 ticks, red for a headshot, and for the hit that finished them (a grenade's
10711081 included) a heavier X held 0.45 s instead of 0.25 s, so the fifth bullet does
1072 not look like the four before it — the plant bar, the kill
1082 not look like the four before it; a round a **teammate** stops is a plus in
1083 your side's colour instead, with no hit beep, a spark on their body and
1084 "Blocked by <name>" said once a burst, and there is still no friendly
1085 fire — the plant bar, the kill
10731086 feed fading out over eight seconds, the scoreboard's order, the damage arc
10741087 that sits behind you when you are shot in the back and walks ninety degrees
10751088 when you turn ninety, and the whole
@@ -1774,8 +1787,10 @@ What is real and what is not, today:
17741787 is also kept, and your body is moved by it at once through the engine's own
17751788 mover on the game's own tuning and hull (`src/client/predict.ts`); each
17761789 snapshot ends with the stamp of the last input the server applied to your
1777 seat, and your body is put back on the server's word with every newer input
1778 replayed on top. A correction under 32 units (a hull's width) is eased out
1790 seat, and after it your own crouch to a 65535th (the body table's byte is a
1791 255th, and since a crouch in the air lifts the feet, that byte's rounding
1792 would be 0.035 units on the position a replay starts from). Your body is put
1793 back on the server's word with every newer input replayed on top. A correction under 32 units (a hull's width) is eased out
17791794 over 100 ms, a bigger one snaps. A test steps a real room on a quarter-second
17801795 round trip and holds the page's body to the server's within a hundredth of a
17811796 unit at every tick; the replay costs about 0.06 ms a snapshot.
@@ -1784,8 +1799,8 @@ What is real and what is not, today:
17841799 else's body is predicted either — the other nine are drawn where the last
17851800 snapshot put them, in 62 ms steps. Prediction is off while you are dead.
17861801 - Not yet: other bodies are not interpolated between snapshots; a room has no
1787 hit marker or damage arrow (both are effects recorded for one screen and a
1788 snapshot goes to everybody). Who is in a room is seen from inside it: there
1802 hit marker, damage arrow or "Blocked by" mark (all are effects recorded for
1803 one screen and a snapshot goes to everybody). Who is in a room is seen from inside it: there
17891804 is no page that lists a room's people before you join, because a code is all
17901805 a stranger would need to read it. Rooms live in this process only — a deploy
17911806 ends them.
src/client/combat.test.ts +25 −1
@@ -4,7 +4,8 @@ import {
44 type Actor,
55 } from "./combat.ts";
66 import type { Box } from "./level.ts";
7 import type { Vec3 } from "../engine/index.ts";
7 import { hullHeight, move, type Body, type Vec3, type Wish } from "../engine/index.ts";
8 import { CS_HULL, CS_MOVE } from "./movement.ts";
89 import { BULLET_RANGE, KNIFE_RANGE, WEAPONS } from "./weapons.ts";
910
1011 const actor = (over: Partial<Actor> = {}): Actor => ({
@@ -172,3 +173,26 @@ test("a rifle in the back is still a rifle", () => {
172173 expect(behind.result.damage).toBe(ahead.result.damage);
173174 expect(behind.result.killed).toBe(false);
174175 });
176
177 // --- a crouch-jumper -------------------------------------------------------
178
179 test("a crouch-jumper's head is where a bullet finds it: the hitboxes lift with the feet", () => {
180 const floor: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 };
181 const body: Body = { pos: [500, 0, 0], vel: [0, 0, 0], onGround: true, ducking: false, duckAmount: 0 };
182 const still: Wish = { forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: 250 };
183 move(body, { ...still, jump: true }, 1 / 64, [floor], CS_MOVE, CS_HULL);
184 for (let i = 0; i < 20; i++) move(body, { ...still, duck: true }, 1 / 64, [floor], CS_MOVE, CS_HULL);
185 expect(body.duckAmount).toBe(1);
186 expect(body.onGround).toBe(false);
187 const target = actor({ pos: [...body.pos] as Vec3, duckAmount: body.duckAmount });
188 const top = body.pos[2] + hullHeight(body, CS_HULL);
189 const head = hitboxes(target).find((p) => p.group === "head") as { max: Vec3 };
190 expect(head.max[2]).toBeCloseTo(top, 6);
191 // A hand under the top of the hull is a headshot; where the head of a body
192 // crouched on the floor would be goes under the tucked feet.
193 const high = traceBullet([0, 0, top - 3], [1000, 0, top - 3], [], [target], 0);
194 expect(high.kind === "actor" && high.group).toBe("head");
195 const low = traceBullet([0, 0, CS_HULL.duck - 3], [1000, 0, CS_HULL.duck - 3], [], [target], 0);
196 expect(body.pos[2]).toBeGreaterThan(CS_HULL.duck);
197 expect(low.kind).toBe("none");
198 });
src/client/hud.test.ts +24
@@ -268,6 +268,30 @@ test("the hit that kills is an X, not a fifth tick, and it stays up longer", ()
268268 expect(mark(10 - KILL_MARK_LIFE - 0.01, true).calls.some((c) => c.op === "stroke")).toBe(false);
269269 });
270270
271 test("a round a teammate stopped is a plus in your side's colour, never a hit", () => {
272 const mark = (friendly: boolean, team: "t" | "ct" = "ct"): Call[] => draw(scene((w) => {
273 w.time = 10;
274 w.me.team = team;
275 w.effects = only({ kind: "hitmark", at: 9.9, headshot: false, killed: false, friendly });
276 })).calls;
277 const strokes = (calls: Call[]): Call[] => calls.filter((c) => c.op === "stroke");
278 // One stroke, like a hit's, but in the side's colour and not the hit's white.
279 expect(strokes(mark(false)).map((c) => c.stroke)).toEqual(["rgba(255,255,255,0.9)"]);
280 expect(strokes(mark(true)).map((c) => c.stroke)).toEqual(["#6fa8e8"]);
281 expect(strokes(mark(true, "t")).map((c) => c.stroke)).toEqual(["#e2b04a"]);
282 // Four ticks on the axes — a plus — where a hit's sit on the diagonals.
283 const centred = (calls: Call[]): number[][] => calls
284 .filter((c) => c.op === "lineTo")
285 .map((c) => c.args as number[])
286 .filter(([x, y]) => Math.abs((x as number) - WIDTH / 2) < 50 && Math.abs((y as number) - HEIGHT / 2) < 50);
287 const ends = centred(mark(true));
288 expect(ends.length).toBe(4);
289 for (const [x, y] of ends) expect(x === WIDTH / 2 || y === HEIGHT / 2).toBe(true);
290 const hits = centred(mark(false));
291 expect(hits.length).toBe(4);
292 for (const [x, y] of hits) expect(x !== WIDTH / 2 && y !== HEIGHT / 2).toBe(true);
293 });
294
271295 // --- health, money and armour ----------------------------------------------
272296
273297 test("health reads white while you are standing and red when you are nearly gone", () => {
src/client/hud.ts +16
@@ -74,6 +74,8 @@ export const NOT_READY_ALPHA = 0.35;
7474 export const HIT_MARK_LIFE = 0.25;
7575 export const KILL_MARK_LIFE = 0.45;
7676 const KILL_STROKES: [number, number][] = [[1, 1], [1, -1]];
77 /** A blocked round's mark: the hit marker's four ticks turned onto the axes. */
78 const PLUS_ARMS: [number, number][] = [[1, 0], [-1, 0], [0, 1], [0, -1]];
7779
7880 /** What a dead player is told, in the two cases there are. */
7981 const DEAD = "You are dead — watching the round out";
@@ -284,6 +286,20 @@ export function drawHud(
284286 if (effect === undefined || effect.kind !== "hitmark") continue;
285287 const age = world.time - effect.at;
286288 if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue;
289 // A round a teammate stopped is not a hit and does not look like one: four
290 // ticks on the axes, a plus rather than an X, in your side's colour.
291 if (effect.friendly === true) {
292 ctx.strokeStyle = world.me.team === "t" ? T_COLOR : CT_COLOR;
293 ctx.lineWidth = ui.px(2);
294 const r = ui.px(9 + age * 20);
295 ctx.beginPath();
296 for (const [dx, dy] of PLUS_ARMS) {
297 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
298 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
299 }
300 ctx.stroke();
301 continue;
302 }
287303 ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)";
288304 // On the diagonals, so there is no pixel grid to land on and no point
289305 // pretending there is — but it is still the same size on the glass at every
src/client/main.ts +1 −1
@@ -1494,7 +1494,7 @@ export function boot(host: Host): Frame {
14941494 if (effect.name === "radio" && !hears(current, effect.by, ears)) continue;
14951495 audio.play(effect.name, effect.pos, eyePos(ears), effect.by === ears.id, ears.yaw);
14961496 }
1497 else if (effect.kind === "hitmark") audio.hit(effect.headshot);
1497 else if (effect.kind === "hitmark" && effect.friendly !== true) audio.hit(effect.headshot); // a teammate's body is not a hit
14981498 }
14991499 lastSound = current.time;
15001500 }
src/client/movement.test.ts +48 −1
@@ -15,7 +15,7 @@
1515
1616 import { expect, test } from "bun:test";
1717 import {
18 accelerate, airAccelerate, eyeHeight, hullHeight, move,
18 accelerate, airAccelerate, eyeHeight, hullHeight, move, stuck,
1919 type Body, type Box, type Hull, type MoveTuning, type Vec3, type Wish,
2020 } from "../engine/index.ts";
2121 import { CS_HULL, CS_MOVE, fallDamage } from "./movement.ts";
@@ -173,3 +173,50 @@ test("fall damage starts only after a long drop", () => {
173173 expect(fallDamage(400)).toBe(0);
174174 expect(fallDamage(1000)).toBeGreaterThan(30);
175175 });
176
177 // --- the crouch-jump --------------------------------------------------------
178
179 /** A 64-unit crate — the height of both maps' jumpable `x` cells — 100 units ahead. */
180 const crate: Box = { min: [100, -200, 0], max: [400, 200, 64], mat: 2 };
181
182 /** A run-up from 200 units back, a jump at the origin, forward held until the feet land. */
183 const leap = (duck: boolean, boxes: Box[]): { b: Body; clipped: boolean } => {
184 const b = body([-200, 0, 0]);
185 let clipped = false;
186 while (b.pos[0] < 0) move(b, wish({ forward: 1 }), 1 / 64, boxes, CS_MOVE, CS_HULL);
187 move(b, wish({ forward: 1, jump: true }), 1 / 64, boxes, CS_MOVE, CS_HULL);
188 for (let i = 0; i < 128 && !b.onGround; i++) {
189 move(b, wish({ forward: 1, duck }), 1 / 64, boxes, CS_MOVE, CS_HULL);
190 if (stuck(b.pos, hullHeight(b, CS_HULL), boxes, CS_HULL)) clipped = true;
191 }
192 return { b, clipped };
193 };
194
195 test("a crouch-jump lands on a 64-unit crate: a 57-unit jump with 18 units of legs tucked is 75", () => {
196 const { b, clipped } = leap(true, [ground, crate]);
197 expect(b.onGround).toBe(true);
198 expect(b.pos[2]).toBe(64);
199 expect(b.pos[0]).toBeGreaterThan(crate.min[0]);
200 expect(clipped).toBe(false);
201 });
202
203 test("a plain jump does not: the crate is still cover for anyone who does not know the trick", () => {
204 const { b } = leap(false, [ground, crate]);
205 expect(b.onGround).toBe(true);
206 expect(b.pos[2]).toBe(0);
207 expect(b.pos[0]).toBeLessThan(crate.min[0]);
208 });
209
210 test("a crouch-jump into a 72-unit slot never leaves the body inside the wall", () => {
211 const sill: Box = { min: [100, -200, 0], max: [400, 200, 64], mat: 1 };
212 const lintel: Box = { min: [100, -200, 136], max: [400, 200, 400], mat: 1 };
213 const boxes = [ground, sill, lintel];
214 const { b, clipped } = leap(true, boxes);
215 expect(clipped).toBe(false);
216 expect(b.pos[2]).toBe(64);
217 // Letting go of duck in there stands up only as far as the slot allows.
218 for (let i = 0; i < 64; i++) {
219 move(b, wish(), 1 / 64, boxes, CS_MOVE, CS_HULL);
220 expect(stuck(b.pos, hullHeight(b, CS_HULL), boxes, CS_HULL)).toBe(false);
221 }
222 });
src/client/world.test.ts +97 −3
@@ -7,16 +7,16 @@ import { SHORT } from "./match.ts";
77 import { WEAPONS } from "./weapons.ts";
88 import { buy } from "./economy.ts";
99 import {
10 aimAt, applyInput, bodyCentre, canSee, causeName, createWorld, currentWeapon, deployTicks, EFFECT_CAPACITY,
10 aimAt, applyInput, BLOCKED_QUIET, bodyCentre, canSee, causeName, createWorld, currentWeapon, deployTicks, EFFECT_CAPACITY,
1111 eyePos, insideSmoke, NO_INPUT, shoot, startRound, tag, TAG_FLOOR, TAG_PER_DAMAGE, TAG_RECOVERY, throwOne,
1212 update, updateWith,
13 type FeedEntry, type Input, type Player, type Sound, type World,
13 type Effect, type FeedEntry, type Input, type Player, type Sound, type World,
1414 } from "./world.ts";
1515 import { cloudRadius, type Cloud } from "./grenade.ts";
1616 import { HE, SMOKE } from "./weapons.ts";
1717 import { CS_HULL } from "./movement.ts";
1818 import {
19 angleVectors, effectAt, eyeHeight, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3,
19 angleVectors, createEffects, effectAt, eyeHeight, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3,
2020 } from "../engine/index.ts";
2121
2222 const TICK = 1 / 64;
@@ -975,3 +975,97 @@ test("a cause off the wire this build has no row for is shown as itself", () =>
975975 expect(causeName("ak47")).toBe(WEAPONS.ak47.name);
976976 expect(causeName("zeus" as never)).toBe("zeus");
977977 });
978
979 // --- a round a teammate stops ------------------------------------------------
980
981 /**
982 * `from` with a rifle up, aimed at a teammate standing 200 units off, on a slab
983 * of their own and past the freeze; everybody else is down. `from` is the
984 * person unless a bot is picked.
985 */
986 function behindMate(seed: number, pick?: (w: World) => Player): { world: World; from: Player; mate: Player } {
987 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, seed);
988 while (world.match.phase === "freeze") update(world, TICK, NO_INPUT);
989 const from = pick === undefined ? world.me : pick(world);
990 const mate = world.players.find((p) => p.team === from.team && p !== from && p !== world.me) as Player;
991 only(world, [from, mate]);
992 world.level = { ...world.level, boxes: [{ min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 }] };
993 from.pos = [0, 0, 0];
994 from.vel = [0, 0, 0];
995 from.onGround = true;
996 mate.pos = [200, 0, 0];
997 mate.vel = [0, 0, 0];
998 from.primary = "ak47";
999 from.weapon = "ak47";
1000 from.ammo.ak47 = { mag: 30, reserve: 90 };
1001 from.nextShotAt = 0;
1002 from.reloadUntil = 0;
1003 from.deployUntil = 0;
1004 from.shotIndex = 0;
1005 const aim = aimAt(from, bodyCentre(mate));
1006 from.yaw = aim.yaw;
1007 from.pitch = aim.pitch;
1008 world.effects = createEffects<Effect>(EFFECT_CAPACITY);
1009 world.messages.length = 0;
1010 return { world, from, mate };
1011 }
1012
1013 const kinds = (world: World): string[] => {
1014 const out: string[] = [];
1015 for (let i = 0; i < world.effects.count; i++) out.push((effectAt(world.effects, i) as Effect).kind);
1016 return out;
1017 };
1018
1019 test("a round your teammate stops lands on them: a spark, a friendly mark, and their name", () => {
1020 const { world, mate } = behindMate(601);
1021 expect(shoot(world, world.me)).toBe(true);
1022 expect(kinds(world)).toEqual(["shot", "tracer", "impact", "hitmark"]);
1023 for (let i = 0; i < world.effects.count; i++) {
1024 const e = effectAt(world.effects, i);
1025 if (e?.kind === "hitmark") expect(e.friendly).toBe(true);
1026 }
1027 // And none of it counts as a hit on anyone.
1028 expect(marksOf(world)).toEqual([{ headshot: false, killed: false }]);
1029 expect(world.messages.map((m) => m.text)).toEqual([`Blocked by ${mate.name}`]);
1030 });
1031
1032 test("a blocked round still does nothing to anyone", () => {
1033 const { world, mate } = behindMate(603);
1034 const before = { health: mate.health, armor: mate.armor, tag: mate.tag };
1035 for (let i = 0; i < 5; i++) {
1036 world.me.nextShotAt = 0;
1037 expect(shoot(world, world.me)).toBe(true);
1038 }
1039 expect(mate.health).toBe(before.health);
1040 expect(mate.armor).toBe(before.armor);
1041 expect(mate.tag).toBe(before.tag);
1042 expect(world.me.damage).toBe(0);
1043 expect(kinds(world)).not.toContain("blood");
1044 expect(kinds(world)).not.toContain("hurt");
1045 });
1046
1047 test("a spray into a teammate's back is one line, not one per bullet", () => {
1048 const { world, mate } = behindMate(607);
1049 for (let i = 0; i < 10; i++) {
1050 world.me.nextShotAt = 0;
1051 world.time += 0.1;
1052 shoot(world, world.me);
1053 }
1054 expect(world.messages.length).toBe(1);
1055 expect(world.messages[0]?.at).toBe(world.time); // held up while the burst lasts
1056 // A fresh burst after a pause is news again.
1057 world.time += BLOCKED_QUIET + 0.1;
1058 world.me.nextShotAt = 0;
1059 world.me.shotIndex = 0; // the trigger let go: the spray starts over
1060 world.me.punch = [0, 0];
1061 shoot(world, world.me);
1062 expect(world.messages.map((m) => m.text)).toEqual([`Blocked by ${mate.name}`, `Blocked by ${mate.name}`]);
1063 });
1064
1065 test("a bot blocked by a bot records nothing beyond its shot and tracer", () => {
1066 const { world, from } = behindMate(611, (w) => w.players.find((p) => p.bot && p.team === "t") as Player);
1067 expect(from).not.toBe(world.me);
1068 expect(shoot(world, from)).toBe(true);
1069 expect(kinds(world)).toEqual(["shot", "tracer"]);
1070 expect(world.messages.length).toBe(0);
1071 });
src/client/world.ts +30 −2
@@ -130,7 +130,9 @@ export type Effect =
130130 | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; by: number }
131131 // A hit of the person's own: `killed` is whether it was the one that
132132 // finished them, so the fifth bullet does not look like the four before it.
133 | { kind: "hitmark"; at: number; headshot: boolean; killed: boolean }
133 // `friendly` is a round a teammate's body stopped: it did nothing, and is
134 // never drawn or heard as a hit on an enemy.
135 | { kind: "hitmark"; at: number; headshot: boolean; killed: boolean; friendly?: boolean }
134136 /**
135137 * Damage *you* took, and where it came from: the shooter's feet, or the
136138 * middle of the blast, at the moment it landed. The mirror image of a hit
@@ -621,7 +623,14 @@ export function shoot(world: World, p: Player): boolean {
621623 record(world, { kind: "impact", at: world.time, pos: hit.point, normal: hit.normal });
622624 } else if (hit.kind === "actor") {
623625 const victim = hit.actor as Player;
624 if (victim.team === p.team && victim !== p) return true; // no friendly fire in this match
626 // No friendly fire in this match: the teammate stops the round and takes
627 // nothing. What the person sees is that it stopped on a body — a spark, a
628 // mark of its own, one line naming who — and only the person: a bot's
629 // blocked shot records nothing, which keeps the arena's hot loop where it was.
630 if (victim.team === p.team && victim !== p) {
631 if (p === world.me) blocked(world, victim, hit.point, [-dir[0], -dir[1], -dir[2]]);
632 return true;
633 }
625634 // The fifth argument is what makes a knife in someone's back a backstab:
626635 // the shooter's own feet, because the facing rule is a bearing between two
627636 // people and not between an eye and a chest. It is the same position the
@@ -641,6 +650,25 @@ export function shoot(world: World, p: Player): boolean {
641650 return true;
642651 }
643652
653 /** How long a "Blocked by" line holds off saying the same thing again, in seconds: one line a burst. */
654 export const BLOCKED_QUIET = 1.5;
655
656 /**
657 * The person's round stopped on a teammate. The spark faces back along the
658 * shot, since a body has no surface normal to give. The line is said once a
659 * burst: while the same words are still fresh they are refreshed rather than
660 * repeated, so a spray into someone's back is one line that stays up until the
661 * spray stops.
662 */
663 function blocked(world: World, mate: Player, point: Vec3, normal: Vec3): void {
664 record(world, { kind: "impact", at: world.time, pos: point, normal });
665 record(world, { kind: "hitmark", at: world.time, headshot: false, killed: false, friendly: true });
666 const text = `Blocked by ${mate.name}`;
667 const last = world.messages[world.messages.length - 1];
668 if (last !== undefined && last.text === text && world.time - last.at < BLOCKED_QUIET) last.at = world.time;
669 else say(world, text);
670 }
671
644672 function onKill(world: World, killer: Player, victim: Player, weapon: Cause, headshot: boolean): void {
645673 victim.alive = false;
646674 victim.deaths += 1;
src/engine/GUIDE.md +9
@@ -263,6 +263,15 @@ couple of units of slack under the feet, and it is in the caller's units: two
263263 profiles of one scale share it, and a profile at another scale would want it to
264264 move with them.
265265
266 **A crouch in the air is the legs, not the jump.** On the ground the floor
267 holds the feet, so closing the crouch lowers the head. In the air nothing holds
268 them, so `move` lifts the feet by exactly the height the hull gave up and the
269 head stays where it was: the top of the hull peaks at `v²/2g + stand` either
270 way, and the feet clear `stand − duck` more — the lineage's crouch-jump, which
271 is what puts a ledge just over the apex in reach. Opening the crouch in the air
272 pushes the legs back down, and that is refused while there is no room under
273 them, the same way standing up is refused under a ceiling.
274
266275 The pieces under `move` are exported because they are worth using and testing on
267276 their own: `accelerate` adds along a wish direction up to a wish speed and never
268277 past it, `airAccelerate` is the same with the tuning's air cap over the wish
src/engine/physics.test.ts +80
@@ -475,3 +475,83 @@ test("a bounce is not a landing: a body that leaves the floor again reports noth
475475 expect(bounced).toBe(true);
476476 expect(said.every((v) => v === 0)).toBe(true);
477477 });
478
479 // --- the crouch-jump, under both profiles -----------------------------------
480
481 /**
482 * A jump straight up with duck held from the jump on or not, and forward held
483 * only once the body is on its way down, towards a ledge whose face is one unit
484 * past the hull. The ledge's top is half the crouch above the jump's apex —
485 * walker 75 + 24/2 = 87 u, drifter 337.5 + 44/2 = 359.5 u — so standing feet
486 * never reach it and tucked ones clear it by the other half.
487 */
488 function leap(tuning: MoveTuning, hull: Hull, duck: boolean): { b: Body; ledge: Box; clipped: boolean } {
489 const top = tuning.jumpVelocity ** 2 / (2 * tuning.gravity) + (hull.stand - hull.duck) / 2;
490 const ledge: Box = { min: [hull.radius + 1, -400, 0], max: [4000, 400, top], mat: 1 };
491 const boxes = [FLOOR, ledge];
492 const b = body();
493 let clipped = false;
494 move(b, wish(tuning, { jump: true, duck }), TICK, boxes, tuning, hull);
495 for (let i = 0; i < 1024 && !b.onGround; i++) {
496 move(b, wish(tuning, { duck, forward: b.vel[2] < 0 ? 1 : 0 }), TICK, boxes, tuning, hull);
497 if (stuck(b.pos, hullHeight(b, hull), boxes, hull)) clipped = true;
498 }
499 return { b, ledge, clipped };
500 }
501
502 test("a crouch in the air lifts the feet by stand − duck, so a ledge over the apex is in reach", () => {
503 for (const { name, tuning, hull } of PROFILES) {
504 const tucked = leap(tuning, hull, true);
505 expect(tucked.b.onGround, `${name} lands`).toBe(true);
506 expect(tucked.b.pos[2], `${name} lands on the ledge`).toBe(tucked.ledge.max[2]);
507 expect(tucked.clipped, `${name} never inside a box`).toBe(false);
508 const plain = leap(tuning, hull, false);
509 expect(plain.b.pos[2], `${name} without the crouch lands on the floor`).toBe(0);
510 expect(plain.b.pos[0], `${name} stays short of the face`).toBeLessThanOrEqual(plain.ledge.min[0] - hull.radius);
511 }
512 });
513
514 test("the crouch-jump is the legs, not the jump: the top of the hull peaks at the same height", () => {
515 for (const { name, tuning, hull } of PROFILES) {
516 const head = (duck: boolean): number => {
517 const b = body();
518 move(b, wish(tuning, { jump: true }), TICK, [FLOOR], tuning, hull);
519 let best = 0;
520 while (!b.onGround) {
521 move(b, wish(tuning, { duck }), TICK, [FLOOR], tuning, hull);
522 best = Math.max(best, b.pos[2] + hullHeight(b, hull));
523 }
524 return best;
525 };
526 // v²/2g + stand either way: the head does not care what the legs did.
527 const expected = tuning.jumpVelocity ** 2 / (2 * tuning.gravity) + hull.stand;
528 expect(head(false), `${name} standing`).toBeCloseTo(expected, 6);
529 expect(head(true), `${name} tucked`).toBeCloseTo(expected, 6);
530 }
531 });
532
533 test("on the ground a crouch lowers the head and never the feet", () => {
534 for (const { name, tuning, hull } of PROFILES) {
535 const b = body();
536 run(b, wish(tuning, { duck: true }), 2, [FLOOR], tuning, hull);
537 expect(b.duckAmount, name).toBe(1);
538 expect(b.pos[2], name).toBe(0);
539 }
540 });
541
542 test("standing up in the air is refused while the legs would go through the floor", () => {
543 for (const { name, tuning, hull } of PROFILES) {
544 // Crouched, falling from a quarter of the crouch up: stretching out at once
545 // would put the feet three quarters of it under the floor.
546 const b = body([0, 0, (hull.stand - hull.duck) / 4]);
547 b.onGround = false;
548 b.duckAmount = 1;
549 for (let i = 0; i < 256; i++) {
550 move(b, wish(tuning), TICK, [FLOOR], tuning, hull);
551 expect(stuck(b.pos, hullHeight(b, hull), [FLOOR], hull), name).toBe(false);
552 }
553 expect(b.onGround, name).toBe(true);
554 expect(b.pos[2], name).toBe(0);
555 expect(b.duckAmount, `${name} stands once on the floor`).toBe(0);
556 }
557 });
src/engine/physics.ts +12 −3
@@ -185,11 +185,20 @@ export function move(
185185 0,
186186 1,
187187 );
188 // Standing up is refused while something is over your head.
188 // On the ground the floor holds the feet and a crouch lowers the head; in the
189 // air nothing holds them, so a crouch pulls the legs up under a head that
190 // stays put. That is the crouch-jump: the jump is no higher, but the feet
191 // clear `stand - duck` more. Standing up is refused while something is over
192 // your head — or, in the air, under your feet. Ducking never is: the new
193 // hull is inside the old one.
189194 const standing = next < body.duckAmount;
190 const probe: Vec3 = [body.pos[0], body.pos[1], body.pos[2]];
191195 const nextHeight = hull.stand + (hull.duck - hull.stand) * next;
192 if (!standing || !stuck(probe, nextHeight, boxes, hull)) body.duckAmount = next;
196 const lift = body.onGround ? 0 : hullHeight(body, hull) - nextHeight;
197 const probe: Vec3 = [body.pos[0], body.pos[1], body.pos[2] + lift];
198 if (!standing || !stuck(probe, nextHeight, boxes, hull)) {
199 body.duckAmount = next;
200 body.pos[2] += lift;
201 }
193202 body.ducking = body.duckAmount > 0.5;
194203
195204 const yawRad = (wish.yaw * Math.PI) / 180;
src/rooms/room.ts +4 −1
@@ -380,7 +380,10 @@ export function stepRoom(room: Room): void {
380380 * because the buffer is one room's, written through each time.
381381 */
382382 export function snapshotFor(room: Room, member: Member): Uint8Array {
383 const bytes = writeSnapshot(room.world, room.tick, member.sentAt, room.frame, room.applied.get(member.seat) ?? 0);
383 const bytes = writeSnapshot(
384 room.world, room.tick, member.sentAt, room.frame, room.applied.get(member.seat) ?? 0,
385 room.world.players[member.seat]?.duckAmount ?? null,
386 );
384387 member.sentAt = room.world.time;
385388 return bytes;
386389 }
src/rooms/wire.test.ts +21
@@ -193,6 +193,27 @@ test("a snapshot ends with the input it answers, and one without it answers noth
193193 expect(applySnapshot(mine, plain, 0)?.ack).toBe(null);
194194 });
195195
196 test("after the ack, the member's own crouch to a 65535th, and nothing before it moves", () => {
197 const r = room();
198 const body = r.world.players[0] as Player;
199 body.duckAmount = 0.3337; // between two 255ths: 85/255 = 0.33333, 86/255 = 0.33725
200 const acked = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX), 4242).slice();
201 const crouched = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX), 4242, body.duckAmount).slice();
202 expect(crouched.byteLength).toBe(acked.byteLength + 2);
203 expect([...crouched.subarray(0, acked.byteLength)]).toEqual([...acked]);
204 // A byte of crouch is 18/510 of a unit on feet a crouch-jump lifted; two are
205 // under a ten-thousandth of one.
206 const byByte = watcher(r);
207 applySnapshot(byByte, acked, 0);
208 expect(Math.abs((byByte.players[0] as Player).duckAmount - 0.3337)).toBeGreaterThan(1e-4);
209 const byTrailer = watcher(r);
210 expect(applySnapshot(byTrailer, crouched, 0)?.ack).toBe(4242);
211 expect((byTrailer.players[0] as Player).duckAmount).toBeCloseTo(0.3337, 4);
212 // A frame with no ack carries no crouch, even when one is handed in.
213 const plain = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX), null, 0.5);
214 expect(plain.byteLength).toBe(acked.byteLength - 4);
215 });
216
196217 test("a control message off the wire is validated field by field", () => {
197218 const hello = readDown(JSON.stringify({
198219 k: "hello", code: "ABCDEF", seat: 3, map: "de_forge", seed: 9, rules: "short", tick: 10,
src/rooms/wire.ts +9
@@ -230,6 +230,7 @@ export type Snapshot = {
230230 */
231231 export function writeSnapshot(
232232 world: World, tick: number, since: number, into: Uint8Array, ack: number | null = null,
233 crouch: number | null = null,
233234 ): Uint8Array {
234235 const c = new Cursor(into);
235236 c.u8(SNAPSHOT_FRAME);
@@ -282,6 +283,12 @@ export function writeSnapshot(
282283 // and a frame without it is read as one that carries no acknowledgement,
283284 // which is a page that simply does not predict.
284285 if (ack !== null) c.u32(ack);
286 // And after it, the member's own crouch to a 65535th rather than the body
287 // table's 255th. A crouch in the air lifts the feet by the height the hull
288 // gives up, so a byte's rounding is 18 / 510 of a unit on the position a page
289 // replays from — three times what `src/client/predict.test.ts` allows. Only a frame that
290 // acknowledges an input carries it: nothing else replays.
291 if (ack !== null && crouch !== null) c.u16(clamp(Math.round(crouch * 65535), 0, 65535));
285292 return into.subarray(0, c.at);
286293 }
287294
@@ -463,6 +470,8 @@ export function applySnapshot(world: World, bytes: Uint8Array, seat: number): Sn
463470 }
464471 // Only a frame read to the end of its events is aligned on the trailer.
465472 const ack = read === events && c.has(4) ? c.ru32() : null;
473 const me = world.players[seat];
474 if (ack !== null && c.has(2) && me !== undefined) me.duckAmount = c.ru16() / 65535;
466475 return { tick, time, events: read, own, ack };
467476 }
468477

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs