Public: anyone can read this; the crew can change it.
forked from trevato/cs
Take updates from trevato/cs #393
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/390-take-updates-from-trevato-cs → main 3 commits fork
14 files
changed +397
−18
README.md +21 −6
| @@ -93,7 +93,17 @@ yet. | ||
| 93 | 93 | ## What is real and what is not |
| 94 | 94 | |
| 95 | 95 | - **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), | |
| 97 | 107 | CS damage with range falloff, armour and hitgroups, deterministic spray patterns, |
| 98 | 108 | two thrown grenades — an HE, and a flashbang that blinds by how much of it you |
| 99 | 109 | could see (the line, the angle and the distance, for at most 1.6 seconds) — |
| @@ -1069,7 +1079,10 @@ never checked itself. | ||
| 1069 | 1079 | crosshair that opens as far as the gun is inaccurate, the hit marker — four |
| 1070 | 1080 | ticks, red for a headshot, and for the hit that finished them (a grenade's |
| 1071 | 1081 | 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 | |
| 1073 | 1086 | feed fading out over eight seconds, the scoreboard's order, the damage arc |
| 1074 | 1087 | that sits behind you when you are shot in the back and walks ninety degrees |
| 1075 | 1088 | when you turn ninety, and the whole |
| @@ -1774,8 +1787,10 @@ What is real and what is not, today: | ||
| 1774 | 1787 | is also kept, and your body is moved by it at once through the engine's own |
| 1775 | 1788 | mover on the game's own tuning and hull (`src/client/predict.ts`); each |
| 1776 | 1789 | 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 | |
| 1779 | 1794 | over 100 ms, a bigger one snaps. A test steps a real room on a quarter-second |
| 1780 | 1795 | round trip and holds the page's body to the server's within a hundredth of a |
| 1781 | 1796 | 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: | ||
| 1784 | 1799 | else's body is predicted either — the other nine are drawn where the last |
| 1785 | 1800 | snapshot put them, in 62 ms steps. Prediction is off while you are dead. |
| 1786 | 1801 | - 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 | |
| 1789 | 1804 | is no page that lists a room's people before you join, because a code is all |
| 1790 | 1805 | a stranger would need to read it. Rooms live in this process only — a deploy |
| 1791 | 1806 | ends them. |
src/client/combat.test.ts +25 −1
| @@ -4,7 +4,8 @@ import { | ||
| 4 | 4 | type Actor, |
| 5 | 5 | } from "./combat.ts"; |
| 6 | 6 | 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"; | |
| 8 | 9 | import { BULLET_RANGE, KNIFE_RANGE, WEAPONS } from "./weapons.ts"; |
| 9 | 10 | |
| 10 | 11 | const actor = (over: Partial<Actor> = {}): Actor => ({ |
| @@ -172,3 +173,26 @@ test("a rifle in the back is still a rifle", () => { | ||
| 172 | 173 | expect(behind.result.damage).toBe(ahead.result.damage); |
| 173 | 174 | expect(behind.result.killed).toBe(false); |
| 174 | 175 | }); |
| 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", () | ||
| 268 | 268 | expect(mark(10 - KILL_MARK_LIFE - 0.01, true).calls.some((c) => c.op === "stroke")).toBe(false); |
| 269 | 269 | }); |
| 270 | 270 | |
| 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 | ||
| 271 | 295 | // --- health, money and armour ---------------------------------------------- |
| 272 | 296 | |
| 273 | 297 | 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; | ||
| 74 | 74 | export const HIT_MARK_LIFE = 0.25; |
| 75 | 75 | export const KILL_MARK_LIFE = 0.45; |
| 76 | 76 | 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]]; | |
| 77 | 79 | |
| 78 | 80 | /** What a dead player is told, in the two cases there are. */ |
| 79 | 81 | const DEAD = "You are dead — watching the round out"; |
| @@ -284,6 +286,20 @@ export function drawHud( | ||
| 284 | 286 | if (effect === undefined || effect.kind !== "hitmark") continue; |
| 285 | 287 | const age = world.time - effect.at; |
| 286 | 288 | 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 | } | |
| 287 | 303 | ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)"; |
| 288 | 304 | // On the diagonals, so there is no pixel grid to land on and no point |
| 289 | 305 | // 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 { | ||
| 1494 | 1494 | if (effect.name === "radio" && !hears(current, effect.by, ears)) continue; |
| 1495 | 1495 | audio.play(effect.name, effect.pos, eyePos(ears), effect.by === ears.id, ears.yaw); |
| 1496 | 1496 | } |
| 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 | |
| 1498 | 1498 | } |
| 1499 | 1499 | lastSound = current.time; |
| 1500 | 1500 | } |
src/client/movement.test.ts +48 −1
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | |
| 16 | 16 | import { expect, test } from "bun:test"; |
| 17 | 17 | import { |
| 18 | accelerate, airAccelerate, eyeHeight, hullHeight, move, | |
| 18 | accelerate, airAccelerate, eyeHeight, hullHeight, move, stuck, | |
| 19 | 19 | type Body, type Box, type Hull, type MoveTuning, type Vec3, type Wish, |
| 20 | 20 | } from "../engine/index.ts"; |
| 21 | 21 | import { CS_HULL, CS_MOVE, fallDamage } from "./movement.ts"; |
| @@ -173,3 +173,50 @@ test("fall damage starts only after a long drop", () => { | ||
| 173 | 173 | expect(fallDamage(400)).toBe(0); |
| 174 | 174 | expect(fallDamage(1000)).toBeGreaterThan(30); |
| 175 | 175 | }); |
| 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"; | ||
| 7 | 7 | import { WEAPONS } from "./weapons.ts"; |
| 8 | 8 | import { buy } from "./economy.ts"; |
| 9 | 9 | 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, | |
| 11 | 11 | eyePos, insideSmoke, NO_INPUT, shoot, startRound, tag, TAG_FLOOR, TAG_PER_DAMAGE, TAG_RECOVERY, throwOne, |
| 12 | 12 | 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, | |
| 14 | 14 | } from "./world.ts"; |
| 15 | 15 | import { cloudRadius, type Cloud } from "./grenade.ts"; |
| 16 | 16 | import { HE, SMOKE } from "./weapons.ts"; |
| 17 | 17 | import { CS_HULL } from "./movement.ts"; |
| 18 | 18 | 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, | |
| 20 | 20 | } from "../engine/index.ts"; |
| 21 | 21 | |
| 22 | 22 | const TICK = 1 / 64; |
| @@ -975,3 +975,97 @@ test("a cause off the wire this build has no row for is shown as itself", () => | ||
| 975 | 975 | expect(causeName("ak47")).toBe(WEAPONS.ak47.name); |
| 976 | 976 | expect(causeName("zeus" as never)).toBe("zeus"); |
| 977 | 977 | }); |
| 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 = | ||
| 130 | 130 | | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; by: number } |
| 131 | 131 | // A hit of the person's own: `killed` is whether it was the one that |
| 132 | 132 | // 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 } | |
| 134 | 136 | /** |
| 135 | 137 | * Damage *you* took, and where it came from: the shooter's feet, or the |
| 136 | 138 | * 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 { | ||
| 621 | 623 | record(world, { kind: "impact", at: world.time, pos: hit.point, normal: hit.normal }); |
| 622 | 624 | } else if (hit.kind === "actor") { |
| 623 | 625 | 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 | } | |
| 625 | 634 | // The fifth argument is what makes a knife in someone's back a backstab: |
| 626 | 635 | // the shooter's own feet, because the facing rule is a bearing between two |
| 627 | 636 | // 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 { | ||
| 641 | 650 | return true; |
| 642 | 651 | } |
| 643 | 652 | |
| 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 | ||
| 644 | 672 | function onKill(world: World, killer: Player, victim: Player, weapon: Cause, headshot: boolean): void { |
| 645 | 673 | victim.alive = false; |
| 646 | 674 | 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 | ||
| 263 | 263 | profiles of one scale share it, and a profile at another scale would want it to |
| 264 | 264 | move with them. |
| 265 | 265 | |
| 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 | ||
| 266 | 275 | The pieces under `move` are exported because they are worth using and testing on |
| 267 | 276 | their own: `accelerate` adds along a wish direction up to a wish speed and never |
| 268 | 277 | 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 | ||
| 475 | 475 | expect(bounced).toBe(true); |
| 476 | 476 | expect(said.every((v) => v === 0)).toBe(true); |
| 477 | 477 | }); |
| 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( | ||
| 185 | 185 | 0, |
| 186 | 186 | 1, |
| 187 | 187 | ); |
| 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. | |
| 189 | 194 | const standing = next < body.duckAmount; |
| 190 | const probe: Vec3 = [body.pos[0], body.pos[1], body.pos[2]]; | |
| 191 | 195 | 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 | } | |
| 193 | 202 | body.ducking = body.duckAmount > 0.5; |
| 194 | 203 | |
| 195 | 204 | const yawRad = (wish.yaw * Math.PI) / 180; |
src/rooms/room.ts +4 −1
| @@ -380,7 +380,10 @@ export function stepRoom(room: Room): void { | ||
| 380 | 380 | * because the buffer is one room's, written through each time. |
| 381 | 381 | */ |
| 382 | 382 | 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 | ); | |
| 384 | 387 | member.sentAt = room.world.time; |
| 385 | 388 | return bytes; |
| 386 | 389 | } |
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 | ||
| 193 | 193 | expect(applySnapshot(mine, plain, 0)?.ack).toBe(null); |
| 194 | 194 | }); |
| 195 | 195 | |
| 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 | ||
| 196 | 217 | test("a control message off the wire is validated field by field", () => { |
| 197 | 218 | const hello = readDown(JSON.stringify({ |
| 198 | 219 | 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 = { | ||
| 230 | 230 | */ |
| 231 | 231 | export function writeSnapshot( |
| 232 | 232 | world: World, tick: number, since: number, into: Uint8Array, ack: number | null = null, |
| 233 | crouch: number | null = null, | |
| 233 | 234 | ): Uint8Array { |
| 234 | 235 | const c = new Cursor(into); |
| 235 | 236 | c.u8(SNAPSHOT_FRAME); |
| @@ -282,6 +283,12 @@ export function writeSnapshot( | ||
| 282 | 283 | // and a frame without it is read as one that carries no acknowledgement, |
| 283 | 284 | // which is a page that simply does not predict. |
| 284 | 285 | 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)); | |
| 285 | 292 | return into.subarray(0, c.at); |
| 286 | 293 | } |
| 287 | 294 | |
| @@ -463,6 +470,8 @@ export function applySnapshot(world: World, bytes: Uint8Array, seat: number): Sn | ||
| 463 | 470 | } |
| 464 | 471 | // Only a frame read to the end of its events is aligned on the trailer. |
| 465 | 472 | 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; | |
| 466 | 475 | return { tick, time, events: read, own, ack }; |
| 467 | 476 | } |
| 468 | 477 |