Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The first grenade: an HE you buy, throw and hear, thrown through the engine's own solver #157
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/142-the-first-grenade-an-he-you-buy-throw-an → main 2 commits fork
28 files
changed +1075
−134
README.md +11 −3
| @@ -102,6 +102,7 @@ whatever the name field said, and a result posted without one is a **guest**. | ||
| 102 | 102 | |
| 103 | 103 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
| 104 | 104 | <kbd>Shift</kbd> walk, mouse aims and fires, <kbd>R</kbd> reload, |
| 105 | <kbd>G</kbd> throws the HE grenade in your pocket, if you bought one, | |
| 105 | 106 | <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze |
| 106 | 107 | time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows |
| 107 | 108 | the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and |
| @@ -298,12 +299,19 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 298 | 299 | rooms, corridors, two sites, two spawns and cover, every cell stamped twice |
| 299 | 300 | so the map is its own opponent's map turned half a circle, and a gate that |
| 300 | 301 | refuses a layout a round cannot be played on), |
| 301 | `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL` | |
| 302 | and `fallDamage`, handed to the engine's mover on every call — the solver | |
| 302 | `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL`, | |
| 303 | `NADE_MOVE`, `NADE_HULL` and `fallDamage`, handed to the engine's mover on | |
| 304 | every call — the solver | |
| 303 | 305 | itself went behind the door in #123 and holds none of them, and |
| 304 | 306 | `movement.test.ts` is now only about whether this game still feels like this |
| 305 | 307 | game, the generality having moved to `src/engine/physics.test.ts`), `weapons`, |
| 306 | `combat`, `match` | |
| 308 | `combat`, | |
| 309 | `grenade` (the one thrown thing: a body the engine's own solver moves on the | |
| 310 | grenade's tuning — 0.4 of CS's gravity, 0.45 elasticity, its own four-unit | |
| 311 | hull — a fuse that burns on the simulation's clock, and a blast that falls | |
| 312 | off in a straight line from 98 at the centre to nothing at 350 units and | |
| 313 | reaches only what `traceWorld` says it can see), | |
| 314 | `match` | |
| 307 | 315 | (the rules), |
| 308 | 316 | `world` (the simulation), |
| 309 | 317 | `bots`, `observe`, `action`, `weights`, `models`, `materials`, |
scripts/build.test.ts +1 −1
| @@ -37,7 +37,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 37 | 37 | |
| 38 | 38 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 39 | 39 | expect(ENTRIES).toEqual([ |
| 40 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 82_000, gzip: 38_000 } }, | |
| 40 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 88_000, gzip: 40_500 } }, | |
| 41 | 41 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 33_000, gzip: 14_000 } }, |
| 42 | 42 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } }, |
| 43 | 43 | ]); |
src/client/action.ts +2
| @@ -65,6 +65,8 @@ export function toInput(intent: Intent, reload: boolean, switchTo: WeaponId | nu | ||
| 65 | 65 | reload, |
| 66 | 66 | use: false, |
| 67 | 67 | scope: false, |
| 68 | // A bot does not throw grenades yet — the issue after this one teaches it. | |
| 69 | throwGrenade: false, | |
| 68 | 70 | switchTo, |
| 69 | 71 | }; |
| 70 | 72 | } |
src/client/audio.ts +33
| @@ -6,10 +6,13 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { attenuate, createSoundDevice, dist, type SoundDevice, type Vec3 } from "../engine/index.ts"; |
| 9 | import type { GrenadeSound } from "./grenade.ts"; | |
| 9 | 10 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 10 | 11 | |
| 11 | 12 | export type Audio = { |
| 12 | 13 | shot(weapon: WeaponId, at: Vec3, listener: Vec3): void; |
| 14 | /** The three noises one grenade makes: leaving the hand, landing, going off. */ | |
| 15 | grenade(sound: GrenadeSound, at: Vec3, listener: Vec3): void; | |
| 13 | 16 | hit(headshot: boolean): void; |
| 14 | 17 | beep(pitch: number, gain?: number): void; |
| 15 | 18 | resume(): void; |
| @@ -22,12 +25,22 @@ const TONE: Record<string, { cut: number; thump: number; length: number }> = { | ||
| 22 | 25 | rifle: { cut: 1500, thump: 110, length: 0.24 }, |
| 23 | 26 | sniper: { cut: 1000, thump: 80, length: 0.5 }, |
| 24 | 27 | knife: { cut: 4200, thump: 400, length: 0.08 }, |
| 28 | // Never heard: a grenade is thrown rather than fired, and its three noises | |
| 29 | // are below. It is here so the table covers every kind a weapon can be. | |
| 30 | grenade: { cut: 2600, thump: 300, length: 0.1 }, | |
| 25 | 31 | }; |
| 26 | 32 | |
| 27 | 33 | /** Full volume within 120 units, and audible across the map after that. */ |
| 28 | 34 | const EARSHOT = 900; |
| 29 | 35 | const CLOSE = 120; |
| 30 | 36 | |
| 37 | /** | |
| 38 | * A blast carries much further than a gunshot and is loud over a much wider | |
| 39 | * circle: full volume out to 300 units and still worth hearing at 2,400. | |
| 40 | */ | |
| 41 | const BLAST_EARSHOT = 2400; | |
| 42 | const BLAST_CLOSE = 300; | |
| 43 | ||
| 31 | 44 | /** The shot a weapon makes: its filter colour, its thump and how long it rings. */ |
| 32 | 45 | export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } => |
| 33 | 46 | TONE[WEAPONS[weapon]?.kind ?? "rifle"] ?? (TONE.rifle as { cut: number; thump: number; length: number }); |
| @@ -62,6 +75,26 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio { | ||
| 62 | 75 | length: tone.length, |
| 63 | 76 | }); |
| 64 | 77 | }, |
| 78 | grenade(sound, at, listener) { | |
| 79 | const far = dist(at, listener); | |
| 80 | if (sound === "throw") { | |
| 81 | // A short upward whoosh of air: quiet, and only near you. | |
| 82 | device.noiseBurst({ frequency: 2400, gain: 0.14 * loudness(at, listener), length: 0.16, q: 0.7, rate: 1.4 }); | |
| 83 | return; | |
| 84 | } | |
| 85 | if (sound === "bounce") { | |
| 86 | // A hard tap: the shell against concrete, tight and short. | |
| 87 | const volume = loudness(at, listener); | |
| 88 | device.noiseBurst({ frequency: 1200, gain: 0.22 * volume, length: 0.07, q: 4, rate: 1 }); | |
| 89 | device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08 }); | |
| 90 | return; | |
| 91 | } | |
| 92 | // The blast: a wide, low body of noise with a thump under it that falls | |
| 93 | // an octave and a half as it goes. | |
| 94 | const volume = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE); | |
| 95 | device.noiseBurst({ frequency: 360, gain: 0.7 * volume, length: 1.1, q: 0.35, rate: 0.6 }); | |
| 96 | device.tone({ frequency: 130, slideTo: 42, gain: 0.55 * volume, length: 0.85 }); | |
| 97 | }, | |
| 65 | 98 | hit(headshot) { |
| 66 | 99 | audio.beep(headshot ? 1400 : 900, 0.08); |
| 67 | 100 | }, |
src/client/binding.test.ts +2 −1
| @@ -17,12 +17,13 @@ test("the bindings are the ones the keyboard has always had", () => { | ||
| 17 | 17 | expect(CS_KEYS.KeyR).toBe("reload"); |
| 18 | 18 | expect(CS_KEYS.KeyE).toBe("use"); |
| 19 | 19 | expect(CS_KEYS.KeyB).toBe("buy"); |
| 20 | expect(CS_KEYS.KeyG).toBe("grenade"); | |
| 20 | 21 | expect(CS_KEYS.Tab).toBe("scoreboard"); |
| 21 | 22 | expect(CS_KEYS.Digit1).toBe("slot1"); |
| 22 | 23 | expect(CS_KEYS.Digit3).toBe("slot3"); |
| 23 | 24 | expect(CS_MOUSE[0]).toBe("fire"); |
| 24 | 25 | expect(CS_MOUSE[2]).toBe("scope"); |
| 25 | expect([...CS_TAP]).toEqual(["jump", "reload", "buy"]); | |
| 26 | expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade"]); | |
| 26 | 27 | // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks |
| 27 | 28 | // the browser for the screen, and nothing about an `Input` changes for it. |
| 28 | 29 | expect(CS_KEYS.KeyF).toBe("fullscreen"); |
src/client/binding.ts +3 −2
| @@ -13,7 +13,7 @@ export const CS_KEYS: Record<string, string> = { | ||
| 13 | 13 | KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right", |
| 14 | 14 | ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right", |
| 15 | 15 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", |
| 16 | KeyR: "reload", KeyE: "use", KeyB: "buy", Tab: "scoreboard", | |
| 16 | KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyB: "buy", Tab: "scoreboard", | |
| 17 | 17 | Digit1: "slot1", Digit2: "slot2", Digit3: "slot3", |
| 18 | 18 | // Not a thing the world knows about: `main.ts` hears it on `onAction` and |
| 19 | 19 | // asks the browser for the screen, which `readIntent` below never sees. |
| @@ -24,7 +24,7 @@ export const CS_KEYS: Record<string, string> = { | ||
| 24 | 24 | export const CS_MOUSE: Record<number, string> = { 0: "fire", 2: "scope" }; |
| 25 | 25 | |
| 26 | 26 | /** On-screen buttons that are a tap, not a hold. */ |
| 27 | export const CS_TAP = ["jump", "reload", "buy"] as const; | |
| 27 | export const CS_TAP = ["jump", "reload", "buy", "grenade"] as const; | |
| 28 | 28 | |
| 29 | 29 | export type Intent = { |
| 30 | 30 | input: Input; |
| @@ -51,6 +51,7 @@ export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: num | ||
| 51 | 51 | use: down("use"), |
| 52 | 52 | fire: down("fire"), |
| 53 | 53 | scope: down("scope"), |
| 54 | throwGrenade: down("grenade"), | |
| 54 | 55 | switchTo: null, |
| 55 | 56 | }; |
| 56 | 57 | return { |
src/client/combat.ts +22 −8
| @@ -81,14 +81,17 @@ export function traceBullet( | ||
| 81 | 81 | return { kind: "none", point: to }; |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | /** Apply a hit and say what it cost: health off, armour off, and whether it killed. */ | |
| 85 | export function applyHit( | |
| 84 | /** | |
| 85 | * Take `health` off a body and `armor` off its vest, and say what it cost and | |
| 86 | * whether it killed. Where the two numbers came from — a bullet's hitgroup and | |
| 87 | * range, or a blast's distance — is the caller's business; what a body does | |
| 88 | * about them is the same either way, and is this. | |
| 89 | */ | |
| 90 | export function applyDamage( | |
| 86 | 91 | target: Actor, |
| 87 | weapon: Weapon, | |
| 88 | group: Hitgroup, | |
| 89 | distance: number, | |
| 90 | ): { damage: number; killed: boolean; headshot: boolean } { | |
| 91 | const { health, armor } = computeDamage(weapon, distance, group, target.armor, target.helmet); | |
| 92 | health: number, | |
| 93 | armor: number, | |
| 94 | ): { damage: number; killed: boolean } { | |
| 92 | 95 | const dealt = Math.min(health, target.health); |
| 93 | 96 | target.armor = Math.max(0, target.armor - armor); |
| 94 | 97 | target.health -= health; |
| @@ -97,5 +100,16 @@ export function applyHit( | ||
| 97 | 100 | target.health = 0; |
| 98 | 101 | target.alive = false; |
| 99 | 102 | } |
| 100 | return { damage: dealt, killed, headshot: group === "head" }; | |
| 103 | return { damage: dealt, killed }; | |
| 104 | } | |
| 105 | ||
| 106 | /** Apply a hit and say what it cost: health off, armour off, and whether it killed. */ | |
| 107 | export function applyHit( | |
| 108 | target: Actor, | |
| 109 | weapon: Weapon, | |
| 110 | group: Hitgroup, | |
| 111 | distance: number, | |
| 112 | ): { damage: number; killed: boolean; headshot: boolean } { | |
| 113 | const { health, armor } = computeDamage(weapon, distance, group, target.armor, target.helmet); | |
| 114 | return { ...applyDamage(target, health, armor), headshot: group === "head" }; | |
| 101 | 115 | } |
src/client/generate.test.ts +6 −1
| @@ -214,8 +214,13 @@ test("a bot walks a map nobody wrote, on that map's own cells", () => { | ||
| 214 | 214 | * generator — a room more, a crate fewer — moves it, and that is the point: |
| 215 | 215 | * paste the new digest in the pull that tuned it and say which seed's map |
| 216 | 216 | * changed shape. A digest that moves for any other reason is a bug. |
| 217 | * | |
| 218 | * Re-pinned for #142 with the other two: the snapshot in `replay.ts` now | |
| 219 | * carries what is in each pocket and every live grenade, so every digest in | |
| 220 | * this repository moved with it. The map and the play are untouched — the | |
| 221 | * assertions under this one still find the same spawns on the same cells. | |
| 217 | 222 | */ |
| 218 | const GENERATED_ROUND = "95aedb752f57a576d996a90212f973a4233a2906c2625c12a89b3b2222ef6f2e"; | |
| 223 | const GENERATED_ROUND = "48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae"; | |
| 219 | 224 | |
| 220 | 225 | test("a seeded round on a generated map plays exactly as it played", () => { |
| 221 | 226 | const level = generateLevel(5); |
src/client/grenade.test.ts added +333
| @@ -0,0 +1,333 @@ | ||
| 1 | /** | |
| 2 | * The grenade, checked by hand. | |
| 3 | * | |
| 4 | * The flight is arithmetic and is asserted as arithmetic: the solver takes | |
| 5 | * both half-kicks of gravity before it moves a body down, which is | |
| 6 | * semi-implicit Euler, so after `n` ticks of `dt` a body thrown level from | |
| 7 | * `z0` at `v` is at | |
| 8 | * | |
| 9 | * x = v · n · dt z = z0 − g · dt² · n(n+1)/2 | |
| 10 | * | |
| 11 | * — half a tick of gravity below the continuous parabola, and exact to the | |
| 12 | * bit. Everything below is that formula, the bounce (0.45 of what it arrived | |
| 13 | * with, reversed), and the blast's own numbers: 98 in the lap, 49 at half the | |
| 14 | * radius, nothing at 350. | |
| 15 | */ | |
| 16 | ||
| 17 | import { expect, test } from "bun:test"; | |
| 18 | import { DE_FORGE } from "./map.ts"; | |
| 19 | import { SHORT } from "./match.ts"; | |
| 20 | import { blastCentre, blastHits, stepGrenade, throwGrenade, THROW_LIFT, THROW_SPEED } from "./grenade.ts"; | |
| 21 | import { NADE_HULL, NADE_MOVE } from "./movement.ts"; | |
| 22 | import { blastDamage, HE, WEAPONS } from "./weapons.ts"; | |
| 23 | import type { Actor } from "./combat.ts"; | |
| 24 | import type { Box, Vec3 } from "../engine/index.ts"; | |
| 25 | import { digest, TICK, withoutEntropy } from "./replay.ts"; | |
| 26 | import { | |
| 27 | buy, createWorld, eyePos, MAX_GRENADES, NO_INPUT, throwOne, update, | |
| 28 | type Input, type Player, type World, | |
| 29 | } from "./world.ts"; | |
| 30 | ||
| 31 | const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 }; | |
| 32 | const EAST_WALL: Box = { min: [300, -4000, 0], max: [364, 4000, 400], mat: 0 }; | |
| 33 | ||
| 34 | /** A body at `pos`, made the way a test can read. */ | |
| 35 | const actor = (pos: Vec3, over: Partial<Actor> = {}): Actor => ({ | |
| 36 | id: 9, team: "t", pos, yaw: 0, duckAmount: 0, health: 100, armor: 0, helmet: false, alive: true, | |
| 37 | ...over, | |
| 38 | }); | |
| 39 | ||
| 40 | const EAST: Vec3 = [1, 0, 0]; | |
| 41 | ||
| 42 | test("the grenade is in the buy menu with CS's numbers, and carries a blast", () => { | |
| 43 | expect(WEAPONS.he).toBe(HE); | |
| 44 | expect(HE.price).toBe(300); | |
| 45 | expect(HE.blast).toEqual({ damage: 98, radius: 350, fuse: 1.5, armorPenetration: 0.5 }); | |
| 46 | // Its own tuning, and not the player's: 0.4 of CS's gravity, a bounce, and | |
| 47 | // no step — a grenade does not climb an eighteen-unit ledge like a person. | |
| 48 | expect(NADE_MOVE.gravity).toBe(320); | |
| 49 | expect(NADE_MOVE.restitution).toBe(0.45); | |
| 50 | expect(NADE_MOVE.stepSize).toBe(0); | |
| 51 | expect(NADE_HULL.stand).toBe(4); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a grenade thrown level flies where the arithmetic says", () => { | |
| 55 | const g = throwGrenade(0, "ct", [0, 0, 64], EAST, [FLOOR]); | |
| 56 | // It leaves the hand eighteen units ahead of the eye, at 750 u/s. | |
| 57 | expect(g.pos).toEqual([18, 0, 64]); | |
| 58 | expect(g.vel).toEqual([THROW_SPEED, 0, 0]); | |
| 59 | const gravity = NADE_MOVE.gravity; | |
| 60 | for (let n = 1; n <= 16; n++) { | |
| 61 | stepGrenade(g, TICK, [FLOOR]); | |
| 62 | expect(g.pos[0]).toBeCloseTo(18 + THROW_SPEED * n * TICK, 6); | |
| 63 | expect(g.pos[2]).toBeCloseTo(64 - gravity * TICK * TICK * ((n * (n + 1)) / 2), 6); | |
| 64 | expect(g.fuse).toBeCloseTo(HE.blast.fuse - n * TICK, 10); | |
| 65 | } | |
| 66 | // A quarter of a second in: 187.5 units along, and 10.2 units of drop. | |
| 67 | const quarter = 16; | |
| 68 | expect(g.pos[0] - 18).toBeCloseTo(THROW_SPEED * quarter * TICK, 6); | |
| 69 | expect(64 - g.pos[2]).toBeCloseTo(gravity * TICK * TICK * ((quarter * (quarter + 1)) / 2), 6); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a grenade thrown at a wall comes back with 0.45 of what it arrived with", () => { | |
| 73 | const g = throwGrenade(0, "ct", [0, 0, 64], EAST, [FLOOR, EAST_WALL]); | |
| 74 | let bounces = 0; | |
| 75 | let after = 0; | |
| 76 | for (let n = 0; n < 64 && bounces === 0; n++) { | |
| 77 | if (stepGrenade(g, TICK, [FLOOR, EAST_WALL])) { | |
| 78 | bounces += 1; | |
| 79 | after = g.vel[0]; | |
| 80 | } | |
| 81 | } | |
| 82 | expect(bounces).toBe(1); | |
| 83 | // 750 out, 0.45 of it back: −337.5 u/s, and it is against the wall's face, | |
| 84 | // its own two-unit radius short of it. | |
| 85 | expect(after).toBeCloseTo(-THROW_SPEED * NADE_MOVE.restitution, 6); | |
| 86 | expect(g.pos[0]).toBeCloseTo(EAST_WALL.min[0] - NADE_HULL.radius, 6); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a grenade dropped on the floor bounces, then comes to rest on it", () => { | |
| 90 | const g = throwGrenade(0, "ct", [0, 0, 200], [0, 0, -1], [FLOOR]); | |
| 91 | let bounces = 0; | |
| 92 | for (let n = 0; n < 64 * 6; n++) if (stepGrenade(g, TICK, [FLOOR])) bounces += 1; | |
| 93 | expect(bounces).toBeGreaterThan(0); | |
| 94 | expect(g.onGround).toBe(true); | |
| 95 | expect(g.pos[2]).toBe(0); | |
| 96 | expect(Math.hypot(g.vel[0], g.vel[1], g.vel[2])).toBeLessThan(1); | |
| 97 | }); | |
| 98 | ||
| 99 | test("the blast falls off in a straight line to nothing at the radius", () => { | |
| 100 | const blast = HE.blast; | |
| 101 | expect(blastDamage(blast, 0)).toBeCloseTo(98, 10); | |
| 102 | expect(blastDamage(blast, blast.radius / 2)).toBeCloseTo(49, 10); | |
| 103 | expect(blastDamage(blast, blast.radius)).toBe(0); | |
| 104 | expect(blastDamage(blast, blast.radius + 1)).toBe(0); | |
| 105 | expect(blastDamage(blast, 87.5)).toBeCloseTo(73.5, 10); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the blast is 98 at the centre, 49 at half the radius, and armour halves it", () => { | |
| 109 | // The grenade at the feet of a body standing at the origin: the centre of | |
| 110 | // the blast is 2 units up and the middle of the chest is 54, so 52 units is | |
| 111 | // as close as a standing body gets — 98 · (1 − 52/350) = 83.4. | |
| 112 | const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 113 | const at = (x: number, over: Partial<Actor> = {}): { health: number; armor: number } => { | |
| 114 | const hits = blastHits(g, [actor([x, 0, 0], over)], []); | |
| 115 | return { health: hits[0]?.health ?? 0, armor: hits[0]?.armor ?? 0 }; | |
| 116 | }; | |
| 117 | const centre = blastCentre(g); | |
| 118 | const chest = 0.75 * 72; // the middle of the chest box, 62%..88% of a 72-unit hull | |
| 119 | const near = Math.hypot(0, chest - centre[2]); | |
| 120 | expect(at(0).health).toBe(Math.round(blastDamage(HE.blast, near))); | |
| 121 | expect(at(0).health).toBe(83); | |
| 122 | ||
| 123 | // Half the radius away along the floor: √(175² + 52²) = 182.6 units, which | |
| 124 | // is 98 · (1 − 182.6/350) = 46.9. | |
| 125 | const half = Math.hypot(175, chest - centre[2]); | |
| 126 | expect(at(175).health).toBe(Math.round(blastDamage(HE.blast, half))); | |
| 127 | expect(at(175).health).toBe(47); | |
| 128 | ||
| 129 | // Past it, nothing at all — no entry, not a zero. | |
| 130 | expect(blastHits(g, [actor([400, 0, 0])], [])).toEqual([]); | |
| 131 | ||
| 132 | // Armour takes half of what it stops, like every other source of damage: | |
| 133 | // 83.44 becomes 42 through the vest, and the vest wears 21 down. | |
| 134 | expect(at(0, { armor: 100 })).toEqual({ health: 42, armor: 21 }); | |
| 135 | }); | |
| 136 | ||
| 137 | test("a grenade behind a crate hurts nobody on the far side", () => { | |
| 138 | const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 139 | const behind = actor([200, 0, 0]); | |
| 140 | // In the open, 200 units is well inside the radius and it hurts. | |
| 141 | expect(blastHits(g, [behind], [FLOOR])[0]?.health).toBeGreaterThan(0); | |
| 142 | // A crate between the two takes all of it: `traceWorld` decides, not distance. | |
| 143 | const crate: Box = { min: [100, -80, 0], max: [140, 80, 90], mat: 0 }; | |
| 144 | expect(blastHits(g, [behind], [FLOOR, crate])).toEqual([]); | |
| 145 | // And the man standing beside the crate, with nothing in the way, still dies. | |
| 146 | expect(blastHits(g, [actor([200, 200, 0])], [FLOOR, crate])[0]?.health).toBeGreaterThan(0); | |
| 147 | }); | |
| 148 | ||
| 149 | test("the blast does not reach the thrower's own side", () => { | |
| 150 | const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 151 | expect(blastHits(g, [actor([0, 0, 0], { team: "ct" })], [FLOOR])).toEqual([]); | |
| 152 | expect(blastHits(g, [actor([0, 0, 0], { team: "t" })], [FLOOR]).length).toBe(1); | |
| 153 | }); | |
| 154 | ||
| 155 | // --- in a round ----------------------------------------------------------- | |
| 156 | ||
| 157 | const TICKS_PER_SECOND = 64; | |
| 158 | ||
| 159 | /** A world past the freeze, with `me` holding one grenade and nothing else to do. */ | |
| 160 | function ready(seed: number, teamSize = 5): World { | |
| 161 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", teamSize, seed); | |
| 162 | world.me.money = 5000; | |
| 163 | expect(buy(world, world.me, "he")).toBe(true); | |
| 164 | for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT); | |
| 165 | expect(world.match.phase).toBe("live"); | |
| 166 | return world; | |
| 167 | } | |
| 168 | ||
| 169 | test("a grenade is bought like anything else, one at a time, and goes in a pocket", () => { | |
| 170 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 11); | |
| 171 | world.me.money = 5000; | |
| 172 | const before = world.me.weapon; | |
| 173 | expect(buy(world, world.me, "he")).toBe(true); | |
| 174 | expect(world.me.grenades).toBe(MAX_GRENADES); | |
| 175 | expect(world.me.money).toBe(5000 - HE.price); | |
| 176 | // It did not become the gun in your hands and it did not displace one. | |
| 177 | expect(world.me.weapon).toBe(before); | |
| 178 | expect(world.me.primary).toBe(null); | |
| 179 | // One a round, CS's rule: the second is refused and costs nothing. | |
| 180 | expect(buy(world, world.me, "he")).toBe(false); | |
| 181 | expect(world.me.money).toBe(5000 - HE.price); | |
| 182 | }); | |
| 183 | ||
| 184 | test("the throw key throws one, once a press, and never during the freeze", () => { | |
| 185 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 12); | |
| 186 | world.me.money = 5000; | |
| 187 | buy(world, world.me, "he"); | |
| 188 | const held: Input = { ...NO_INPUT, throwGrenade: true }; | |
| 189 | // Frozen: the key does nothing and the grenade stays in the pocket. | |
| 190 | update(world, TICK, held); | |
| 191 | expect(world.grenades.length).toBe(0); | |
| 192 | expect(world.me.grenades).toBe(1); | |
| 193 | ||
| 194 | for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT); | |
| 195 | // Live, and held down for half a second: exactly one leaves the hand. | |
| 196 | for (let t = 0; t < 32; t++) update(world, TICK, held); | |
| 197 | expect(world.grenades.length).toBe(1); | |
| 198 | expect(world.me.grenades).toBe(0); | |
| 199 | expect(world.grenades[0]?.owner).toBe(world.me.id); | |
| 200 | // And the throw was heard where the hand was. | |
| 201 | expect(world.effects.some((e) => e.kind === "sound" && e.name === "throw")).toBe(true); | |
| 202 | }); | |
| 203 | ||
| 204 | test("the fuse is the same number of ticks whatever the frame rate", () => { | |
| 205 | /** Step `world` in frames of `perFrame` ticks; return the tick it went off on. */ | |
| 206 | const fuseTicks = (perFrame: number): number => { | |
| 207 | const world = ready(21); | |
| 208 | world.me.pitch = THROW_LIFT; // aim down by the lift: a dead level throw | |
| 209 | expect(throwOne(world, world.me)).toBe(true); | |
| 210 | let ticks = 0; | |
| 211 | while (world.grenades.length > 0 && ticks < 64 * 5) { | |
| 212 | for (let f = 0; f < perFrame; f++) { | |
| 213 | update(world, TICK, NO_INPUT); | |
| 214 | ticks += 1; | |
| 215 | } | |
| 216 | } | |
| 217 | return ticks; | |
| 218 | }; | |
| 219 | // 1.5 seconds of 64 Hz ticks is 96 of them, and a frame that batches four of | |
| 220 | // them at a time — a sixteen-frame-a-second phone — detonates on the same one. | |
| 221 | expect(fuseTicks(1)).toBe(Math.ceil(HE.blast.fuse * TICKS_PER_SECOND)); | |
| 222 | expect(fuseTicks(4)).toBe(fuseTicks(1)); | |
| 223 | }); | |
| 224 | ||
| 225 | test("a blast that hurts someone marks the hit, and one that kills names the grenade", () => { | |
| 226 | // One a side, so the only thing that can hurt anybody here is the grenade: | |
| 227 | // nobody on this player's team exists to shoot, and this player never fires. | |
| 228 | const world = ready(31, 1); | |
| 229 | const victim = world.players.find((p) => p.team === "t") as Player; | |
| 230 | const where: Vec3 = [victim.pos[0], victim.pos[1], victim.pos[2]]; | |
| 231 | victim.armor = 0; | |
| 232 | const kills = world.me.kills; | |
| 233 | expect(throwOne(world, world.me)).toBe(true); | |
| 234 | let healthBefore = victim.health; | |
| 235 | let damageBefore = world.me.damage; | |
| 236 | for (let t = 0; t < 2 * TICKS_PER_SECOND && world.grenades.length > 0; t++) { | |
| 237 | // Hold the victim still and the grenade at their feet: what is under test | |
| 238 | // is the blast, not whether a thrown one can be aimed at a moving bot. | |
| 239 | victim.pos = [where[0], where[1], where[2]]; | |
| 240 | for (const g of world.grenades) { | |
| 241 | g.pos = [where[0], where[1], where[2]]; | |
| 242 | g.vel = [0, 0, 0]; | |
| 243 | } | |
| 244 | healthBefore = victim.health; | |
| 245 | damageBefore = world.me.damage; | |
| 246 | update(world, TICK, NO_INPUT); | |
| 247 | } | |
| 248 | ||
| 249 | expect(victim.health).toBeLessThan(healthBefore); | |
| 250 | expect(world.me.damage).toBeGreaterThan(damageBefore); | |
| 251 | expect(world.effects.some((e) => e.kind === "hitmark")).toBe(true); | |
| 252 | expect(world.effects.some((e) => e.kind === "blast")).toBe(true); | |
| 253 | expect(world.effects.some((e) => e.kind === "sound" && e.name === "blast")).toBe(true); | |
| 254 | // 83 hit points at the feet of a body with no vest is not a kill on its own, | |
| 255 | // so a kill is only asserted when the blast actually finished them. | |
| 256 | if (!victim.alive) { | |
| 257 | expect(world.me.kills).toBe(kills + 1); | |
| 258 | const entry = world.feed[world.feed.length - 1]; | |
| 259 | expect(entry?.weapon).toBe("he"); | |
| 260 | expect(WEAPONS[entry?.weapon ?? "knife"].name).toBe("HE Grenade"); | |
| 261 | } | |
| 262 | }); | |
| 263 | ||
| 264 | test("a blast that finishes a hurt man puts the grenade in the feed", () => { | |
| 265 | const world = ready(33, 1); | |
| 266 | const victim = world.players.find((p) => p.team === "t") as Player; | |
| 267 | victim.health = 20; // one blast away, whatever the falloff says | |
| 268 | const where: Vec3 = [victim.pos[0], victim.pos[1], victim.pos[2]]; | |
| 269 | expect(throwOne(world, world.me)).toBe(true); | |
| 270 | for (let t = 0; t < 2 * TICKS_PER_SECOND && world.grenades.length > 0; t++) { | |
| 271 | victim.pos = [where[0], where[1], where[2]]; | |
| 272 | for (const g of world.grenades) { | |
| 273 | g.pos = [where[0], where[1], where[2]]; | |
| 274 | g.vel = [0, 0, 0]; | |
| 275 | } | |
| 276 | update(world, TICK, NO_INPUT); | |
| 277 | } | |
| 278 | expect(victim.alive).toBe(false); | |
| 279 | expect(world.me.kills).toBe(1); | |
| 280 | const entry = world.feed[world.feed.length - 1]; | |
| 281 | expect(entry?.weapon).toBe("he"); | |
| 282 | expect(entry?.killer).toBe(world.me.name); | |
| 283 | expect(WEAPONS.he.name).toBe("HE Grenade"); | |
| 284 | }); | |
| 285 | ||
| 286 | test("a grenade is drawn where the body is, and forgotten when it goes off", () => { | |
| 287 | const world = ready(41); | |
| 288 | expect(throwOne(world, world.me)).toBe(true); | |
| 289 | const g = world.grenades[0]; | |
| 290 | if (g === undefined) throw new Error("the throw made no grenade"); | |
| 291 | const start: Vec3 = [g.pos[0], g.pos[1], g.pos[2]]; | |
| 292 | for (let t = 0; t < 16; t++) update(world, TICK, NO_INPUT); | |
| 293 | expect(world.grenades.length).toBe(1); | |
| 294 | expect(g.pos).not.toEqual(start); | |
| 295 | for (let t = 0; t < 2 * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT); | |
| 296 | expect(world.grenades.length).toBe(0); | |
| 297 | }); | |
| 298 | ||
| 299 | test("a round with a grenade in it is still bit-for-bit reproducible", () => { | |
| 300 | const TICKS = 8 * TICKS_PER_SECOND; | |
| 301 | // A script that buys one in the freeze and throws it the moment it is live. | |
| 302 | const script = [ | |
| 303 | { atTick: 0, input: {}, buy: "he" as const }, | |
| 304 | { atTick: 5 * TICKS_PER_SECOND, input: { throwGrenade: true } }, | |
| 305 | { atTick: 5 * TICKS_PER_SECOND + 8, input: {} }, | |
| 306 | ]; | |
| 307 | const run = (): ReturnType<typeof digest> => | |
| 308 | withoutEntropy(() => { | |
| 309 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7); | |
| 310 | world.me.money = 5000; | |
| 311 | return digest(world, TICKS, script); | |
| 312 | }); | |
| 313 | const quiet = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), TICKS)); | |
| 314 | const a = run(); | |
| 315 | const b = run(); | |
| 316 | expect(a.digest).toBe(b.digest); | |
| 317 | expect(a.seconds).toEqual(b.seconds); | |
| 318 | // And it is a different run from the one where nobody threw anything: the | |
| 319 | // grenade is in the hash, so a thrown one cannot hide. | |
| 320 | expect(a.digest).not.toBe(quiet.digest); | |
| 321 | }); | |
| 322 | ||
| 323 | test("an input that never sets the throw plays exactly as it did before", () => { | |
| 324 | // The whole of what a bot had to learn for this feature: nothing. | |
| 325 | expect(NO_INPUT.throwGrenade).toBe(false); | |
| 326 | const world = ready(51); | |
| 327 | world.me.grenades = 1; | |
| 328 | const before = eyePos(world.me); | |
| 329 | for (let t = 0; t < 64; t++) update(world, TICK, NO_INPUT); | |
| 330 | expect(world.grenades.length).toBe(0); | |
| 331 | expect(world.me.grenades).toBe(1); | |
| 332 | expect(before.length).toBe(3); | |
| 333 | }); |
src/client/grenade.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * The thrown grenade: a body, a fuse and a blast. | |
| 3 | * | |
| 4 | * There is no solver in this file. A grenade in flight is a `Body` like a | |
| 5 | * player is, and it is moved by the same `move` in `src/engine/physics.ts` — | |
| 6 | * handed `NADE_MOVE` and `NADE_HULL` from `src/client/movement.ts` instead of | |
| 7 | * the player's pair, which is the whole of why it floats, bounces and comes to | |
| 8 | * rest instead of walking. It has its own four-unit hull on purpose: sharing | |
| 9 | * the player's would let it step eighteen units up a ledge like a person. | |
| 10 | * | |
| 11 | * The fuse burns on the simulation's clock — `dt` seconds a tick, at the fixed | |
| 12 | * 64 Hz `world.ts` steps at — so it is the same number of ticks on a phone as | |
| 13 | * on a desktop, and the same number in a replay as in a match. | |
| 14 | * | |
| 15 | * The blast reaches what it can see and nothing else. `blastHits` traces from | |
| 16 | * the middle of the explosion to the middle of each chest through the engine's | |
| 17 | * own `visible`, so a wall, a crate or a corner takes all of it: a grenade | |
| 18 | * cannot kill through a wall here, which is the property the test of that name | |
| 19 | * holds. | |
| 20 | */ | |
| 21 | ||
| 22 | import { dist, move, stuck, visible, type Body, type Box, type Vec3, type Wish } from "../engine/index.ts"; | |
| 23 | import { hitboxes, type Actor } from "./combat.ts"; | |
| 24 | import { NADE_HULL, NADE_MOVE } from "./movement.ts"; | |
| 25 | import { blastDamage, HE, throughArmor } from "./weapons.ts"; | |
| 26 | ||
| 27 | /** A grenade in the world: a body, whose it is, and how long it has left. */ | |
| 28 | export type Grenade = Body & { | |
| 29 | /** The player id that threw it — who the kill is credited to. */ | |
| 30 | owner: number; | |
| 31 | team: "t" | "ct"; | |
| 32 | /** Seconds left on the fuse. It detonates on the tick this reaches zero. */ | |
| 33 | fuse: number; | |
| 34 | }; | |
| 35 | ||
| 36 | /** | |
| 37 | * A grenade wishes for nothing: it has no legs. The same object every tick, | |
| 38 | * because it never changes and a frame allocates nothing. | |
| 39 | */ | |
| 40 | const STILL: Wish = { | |
| 41 | forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: 0, | |
| 42 | }; | |
| 43 | ||
| 44 | /** How fast it leaves the hand, in u/s. CS's full throw is 750. */ | |
| 45 | export const THROW_SPEED = 750; | |
| 46 | ||
| 47 | /** | |
| 48 | * Degrees above the crosshair a throw goes. CS tilts a throw up by ten so that | |
| 49 | * a grenade thrown at a wall across the room arrives at head height rather | |
| 50 | * than at your feet; aim ten degrees down and it flies dead level. | |
| 51 | */ | |
| 52 | export const THROW_LIFT = 10; | |
| 53 | ||
| 54 | /** How far in front of the eye it appears, in units: out of the hand, not the face. */ | |
| 55 | const HAND = 18; | |
| 56 | ||
| 57 | /** The sounds a grenade makes, in the order one round of it happens. */ | |
| 58 | export type GrenadeSound = "throw" | "bounce" | "blast"; | |
| 59 | ||
| 60 | /** | |
| 61 | * Below this the knock of a bounce is not worth a sound: a grenade rolling to | |
| 62 | * a stop taps the floor every tick and nobody wants to hear that. In u/s of | |
| 63 | * the component that reversed. | |
| 64 | */ | |
| 65 | const HEARD = 90; | |
| 66 | ||
| 67 | /** | |
| 68 | * A grenade leaving a hand at `from` along `dir`. It appears a little ahead of | |
| 69 | * the eye so it is not thrown from inside the thrower's own head — unless that | |
| 70 | * would put it inside a wall, in which case it starts at the eye, because a | |
| 71 | * grenade that spawns inside geometry is a grenade that falls through the map. | |
| 72 | */ | |
| 73 | export function throwGrenade( | |
| 74 | owner: number, | |
| 75 | team: "t" | "ct", | |
| 76 | from: Vec3, | |
| 77 | dir: Vec3, | |
| 78 | boxes: Box[], | |
| 79 | ): Grenade { | |
| 80 | const ahead: Vec3 = [ | |
| 81 | from[0] + dir[0] * HAND, | |
| 82 | from[1] + dir[1] * HAND, | |
| 83 | from[2] + dir[2] * HAND, | |
| 84 | ]; | |
| 85 | const clear = !stuck(ahead, NADE_HULL.stand, boxes, NADE_HULL); | |
| 86 | const pos: Vec3 = clear ? ahead : [from[0], from[1], from[2]]; | |
| 87 | return { | |
| 88 | pos, | |
| 89 | vel: [dir[0] * THROW_SPEED, dir[1] * THROW_SPEED, dir[2] * THROW_SPEED], | |
| 90 | onGround: false, | |
| 91 | ducking: false, | |
| 92 | duckAmount: 0, | |
| 93 | owner, | |
| 94 | team, | |
| 95 | fuse: HE.blast.fuse, | |
| 96 | }; | |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * One tick of a grenade: the engine's solver moves it, the fuse burns by `dt`. | |
| 101 | * True when it hit something hard enough to be heard — which is a component of | |
| 102 | * its velocity coming back the other way, since a bounce is the only thing | |
| 103 | * that can reverse one. | |
| 104 | */ | |
| 105 | export function stepGrenade(g: Grenade, dt: number, boxes: Box[]): boolean { | |
| 106 | const vx = g.vel[0], vy = g.vel[1], vz = g.vel[2]; | |
| 107 | move(g, STILL, dt, boxes, NADE_MOVE, NADE_HULL); | |
| 108 | g.fuse -= dt; | |
| 109 | return ( | |
| 110 | (vx * g.vel[0] < 0 && Math.abs(vx) > HEARD) || | |
| 111 | (vy * g.vel[1] < 0 && Math.abs(vy) > HEARD) || | |
| 112 | (vz * g.vel[2] < 0 && Math.abs(vz) > HEARD) | |
| 113 | ); | |
| 114 | } | |
| 115 | ||
| 116 | /** The middle of the explosion: the middle of the grenade, not its feet. */ | |
| 117 | export const blastCentre = (g: Grenade): Vec3 => [g.pos[0], g.pos[1], g.pos[2] + NADE_HULL.stand / 2]; | |
| 118 | ||
| 119 | /** What the blast is aimed at on a body: the middle of the chest box. */ | |
| 120 | function chestOf(actor: Actor): Vec3 { | |
| 121 | const parts = hitboxes(actor); | |
| 122 | const chest = parts.find((p) => p.group === "chest") ?? parts[0]; | |
| 123 | if (chest === undefined) return [actor.pos[0], actor.pos[1], actor.pos[2]]; | |
| 124 | return [ | |
| 125 | (chest.min[0] + chest.max[0]) / 2, | |
| 126 | (chest.min[1] + chest.max[1]) / 2, | |
| 127 | (chest.min[2] + chest.max[2]) / 2, | |
| 128 | ]; | |
| 129 | } | |
| 130 | ||
| 131 | /** One body the blast reached, and what it cost them. */ | |
| 132 | export type Caught = { actor: Actor; distance: number; health: number; armor: number }; | |
| 133 | ||
| 134 | /** | |
| 135 | * Who this blast reaches and for how much: the falloff in `weapons.ts` decides | |
| 136 | * the damage by distance, `throughArmor` takes the vest's share of it like | |
| 137 | * every other source of damage in this game, and the engine's `visible` | |
| 138 | * decides whether any of it arrives at all. | |
| 139 | * | |
| 140 | * The thrower's own side is left out — this match has no friendly fire, the | |
| 141 | * same rule a bullet is under in `world.ts` — which also means a grenade does | |
| 142 | * not hurt the person who threw it. | |
| 143 | */ | |
| 144 | export function blastHits(g: Grenade, actors: Actor[], boxes: Box[]): Caught[] { | |
| 145 | const blast = HE.blast; | |
| 146 | const centre = blastCentre(g); | |
| 147 | const caught: Caught[] = []; | |
| 148 | for (const actor of actors) { | |
| 149 | if (!actor.alive || actor.team === g.team) continue; | |
| 150 | const chest = chestOf(actor); | |
| 151 | const distance = dist(centre, chest); | |
| 152 | const raw = blastDamage(blast, distance); | |
| 153 | if (raw <= 0) continue; | |
| 154 | if (!visible(centre, chest, boxes)) continue; | |
| 155 | caught.push({ actor, distance, ...throughArmor(raw, actor.armor, blast.armorPenetration) }); | |
| 156 | } | |
| 157 | return caught; | |
| 158 | } |
src/client/hud.ts +7 −2
| @@ -166,8 +166,13 @@ export function drawHud( | ||
| 166 | 166 | const ammo = view.ammo[weapon.id]; |
| 167 | 167 | const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`; |
| 168 | 168 | ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE }); |
| 169 | ui.text(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale), | |
| 170 | { align: "right", font: small, fill: GREY }); | |
| 169 | // What is in your hands, and then what is in your pockets: the HE you bought | |
| 170 | // and the bomb, each named only while you are carrying it. | |
| 171 | ui.text( | |
| 172 | weapon.name + (view.grenades > 0 ? " · HE" : "") + (view.hasBomb ? " · bomb" : ""), | |
| 173 | width - pad, height - pad - Math.round(30 * scale), | |
| 174 | { align: "right", font: small, fill: GREY }, | |
| 175 | ); | |
| 171 | 176 | if (world.time < view.reloadUntil) |
| 172 | 177 | ui.text("reloading", width - pad, height - pad - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" }); |
| 173 | 178 |
src/client/main.test.ts +1 −1
| @@ -459,7 +459,7 @@ test("every mesh name a scene of this game can place is handed in at constructio | ||
| 459 | 459 | // table misses is an exception on the frame a player first holds that gun. |
| 460 | 460 | const meshes = gameMeshes(); |
| 461 | 461 | expect(Object.keys(meshes).sort()).toEqual([ |
| 462 | "bomb", "knife", "level", "pistol", "player", "rifle", "smg", "sniper", | |
| 462 | "bomb", "grenade", "knife", "level", "pistol", "player", "rifle", "smg", "sniper", | |
| 463 | 463 | ]); |
| 464 | 464 | // Every kind a weapon can be, and the kind `scene.ts` falls back to. |
| 465 | 465 | for (const weapon of Object.values(WEAPONS)) expect(meshes[weapon.kind]).toBeDefined(); |
src/client/main.ts +1
| @@ -396,6 +396,7 @@ export function boot(host: Host): Frame { | ||
| 396 | 396 | for (const effect of current.effects) { |
| 397 | 397 | if (effect.at <= lastSound) continue; |
| 398 | 398 | if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears)); |
| 399 | else if (effect.kind === "sound") audio.grenade(effect.name, effect.pos, eyePos(ears)); | |
| 399 | 400 | else if (effect.kind === "hitmark") audio.hit(effect.headshot); |
| 400 | 401 | } |
| 401 | 402 | lastSound = current.time; |
src/client/models.test.ts +19 −3
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The four recipes, pinned before `models.ts` is handed to an engine renderer | |
| 2 | * The five recipes, pinned before `models.ts` is handed to an engine renderer | |
| 3 | 3 | * that will not check any of this. What a renderer and the atlas actually |
| 4 | 4 | * require is asserted here — the attribute lengths agree, every index is inside |
| 5 | 5 | * the vertex count, every layer is a real layer of the array texture, nothing |
| @@ -20,7 +20,7 @@ import type { Mesh } from "../engine/index.ts"; | ||
| 20 | 20 | import type { Box } from "./level.ts"; |
| 21 | 21 | import { DE_FORGE } from "./map.ts"; |
| 22 | 22 | import { LAYERS } from "./materials.ts"; |
| 23 | import { bombMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts"; | |
| 23 | import { bombMesh, grenadeMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts"; | |
| 24 | 24 | import { CS_HULL } from "./movement.ts"; |
| 25 | 25 | import { WEAPONS } from "./weapons.ts"; |
| 26 | 26 | |
| @@ -153,20 +153,36 @@ test("a kind nobody carries falls back to a gun rather than throwing", () => { | ||
| 153 | 153 | expect(data(viewModelMesh(""))).toBe(data(fallback)); |
| 154 | 154 | }); |
| 155 | 155 | |
| 156 | test("meshFor names the four recipes and nothing else", () => { | |
| 156 | test("meshFor names the five recipes and nothing else", () => { | |
| 157 | 157 | expect(data(meshFor("level", DE_FORGE))).toBe(data(levelMesh(DE_FORGE.boxes))); |
| 158 | 158 | expect(data(meshFor("player", DE_FORGE))).toBe(data(playerMesh())); |
| 159 | 159 | expect(data(meshFor("bomb", DE_FORGE))).toBe(data(bombMesh())); |
| 160 | // `grenade` is a thing in the world rather than a gun in your hands, so it | |
| 161 | // is its own recipe and not a view model — it is the one weapon kind that | |
| 162 | // names a body a scene places out there. | |
| 163 | expect(data(meshFor("grenade", DE_FORGE))).toBe(data(grenadeMesh())); | |
| 160 | 164 | for (const kind of new Set(Object.values(WEAPONS).map((w) => w.kind))) { |
| 165 | if (kind === "grenade") continue; | |
| 161 | 166 | expect(data(meshFor(kind, DE_FORGE))).toBe(data(viewModelMesh(kind))); |
| 162 | 167 | } |
| 163 | 168 | }); |
| 164 | 169 | |
| 170 | test("a grenade is a small body, and a different one from the bomb", () => { | |
| 171 | const nade = grenadeMesh(); | |
| 172 | expect(wellFormed(nade).vertices).toBeGreaterThan(0); | |
| 173 | expect(data(nade)).not.toBe(data(bombMesh())); | |
| 174 | // Eight units across at its widest: twice the hull the solver moves it with, | |
| 175 | // and small enough to read as an object beside a 72-unit player. | |
| 176 | const xs = [...nade.position].filter((_, i) => i % 3 === 0); | |
| 177 | expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(8, 6); | |
| 178 | }); | |
| 179 | ||
| 165 | 180 | test("a recipe called twice gives equal data in a new object every time", () => { |
| 166 | 181 | const calls: (() => Mesh)[] = [ |
| 167 | 182 | () => levelMesh([oneBox]), |
| 168 | 183 | () => playerMesh(), |
| 169 | 184 | () => bombMesh(), |
| 185 | () => grenadeMesh(), | |
| 170 | 186 | () => viewModelMesh("rifle"), |
| 171 | 187 | ]; |
| 172 | 188 | for (const call of calls) { |
src/client/models.ts +17 −1
| @@ -50,6 +50,21 @@ export function bombMesh(): Mesh { | ||
| 50 | 50 | return finish(b); |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | /** | |
| 54 | * A grenade, in the world: a small dark body with a lighter band around its | |
| 55 | * middle, built about its own origin so it needs no rotation to be drawn in | |
| 56 | * flight or at rest. Eight units across — twice the four-unit hull the solver | |
| 57 | * moves it with, so it looks like an object and not like a pixel. | |
| 58 | */ | |
| 59 | export function grenadeMesh(): Mesh { | |
| 60 | const b = builder(); | |
| 61 | const SHELL = 7, BAND = 8; | |
| 62 | cube(b, 0, 0, 2, 7, 7, 7, SHELL); | |
| 63 | cube(b, 0, 0, 2, 8, 8, 3, BAND); | |
| 64 | cube(b, 0, 0, 6, 2.5, 2.5, 2, BAND); // the spoon on top | |
| 65 | return finish(b); | |
| 66 | } | |
| 67 | ||
| 53 | 68 | /** |
| 54 | 69 | * A first-person weapon, in view space: X right, Y up, Z back, so the barrel |
| 55 | 70 | * runs down -Z and the model needs no rotation at all. |
| @@ -73,7 +88,7 @@ export function viewModelMesh(kind: string): Mesh { | ||
| 73 | 88 | } |
| 74 | 89 | |
| 75 | 90 | /** |
| 76 | * A mesh by the name a scene calls it: `level`, `player`, `bomb`, or a weapon | |
| 91 | * A mesh by the name a scene calls it: `level`, `player`, `bomb`, `grenade`, or a weapon | |
| 77 | 92 | * kind for the gun in your hands. `main.ts` builds one of each at start-up and |
| 78 | 93 | * hands them to the engine's renderer, which keeps them by name and never |
| 79 | 94 | * learns what a name is made of — that is this file, and this is the one line a |
| @@ -86,5 +101,6 @@ export function meshFor(name: string, level: Level): Mesh { | ||
| 86 | 101 | if (name === "level") return levelMesh(level.boxes); |
| 87 | 102 | if (name === "player") return playerMesh(); |
| 88 | 103 | if (name === "bomb") return bombMesh(); |
| 104 | if (name === "grenade") return grenadeMesh(); | |
| 89 | 105 | return viewModelMesh(name); |
| 90 | 106 | } |
src/client/movement.ts +36
| @@ -29,8 +29,44 @@ export const CS_MOVE: MoveTuning = { | ||
| 29 | 29 | jumpVelocity: 301.993, |
| 30 | 30 | stepSize: 18, |
| 31 | 31 | duckRate: 5, |
| 32 | restitution: 0, // a person does not bounce off a wall; they slide along it | |
| 32 | 33 | }; |
| 33 | 34 | |
| 35 | /** | |
| 36 | * A thrown grenade is a body too, and this is the pair the same solver is | |
| 37 | * handed for one: CS:GO's grenade projectile falls at 0.4 of the world's | |
| 38 | * gravity and keeps 0.45 of the speed it meets a surface with | |
| 39 | * (`SetGravity( 0.4 )`, `SetElasticity( 0.45 )` in `hegrenade_projectile`), | |
| 40 | * which is why a nade floats a little and bounces twice before it settles. | |
| 41 | * | |
| 42 | * The friction is what brings it to rest once it is lying on the floor, and | |
| 43 | * the step size is zero on purpose: a grenade that shared the player's tuning | |
| 44 | * would climb an eighteen-unit ledge like a person walking up it. | |
| 45 | * | |
| 46 | * A grenade never wishes to move — `src/client/grenade.ts` hands the solver a | |
| 47 | * wish that is still on every tick — so the speeds and the crouch below are | |
| 48 | * the numbers in a `MoveTuning` that have no job here, and they are zero | |
| 49 | * rather than a pretend value nobody reads. | |
| 50 | */ | |
| 51 | export const NADE_MOVE: MoveTuning = { | |
| 52 | gravity: 800 * 0.4, // u/s², 0.4 of CS's gravity | |
| 53 | maxSpeed: 0, | |
| 54 | duckSpeed: 0, | |
| 55 | walkSpeed: 0, | |
| 56 | accelerate: 0, | |
| 57 | airAccelerate: 0, | |
| 58 | airSpeedCap: 0, | |
| 59 | friction: 4, // per second, once it is on the ground: it rolls, then stops | |
| 60 | stopSpeed: 30, // u/s under which friction takes the rest of it at once | |
| 61 | jumpVelocity: 0, | |
| 62 | stepSize: 0, | |
| 63 | duckRate: 0, | |
| 64 | restitution: 0.45, | |
| 65 | }; | |
| 66 | ||
| 67 | /** A grenade's own hull: a four-unit cube with no eyes, not a person's 32×32×72. */ | |
| 68 | export const NADE_HULL: Hull = { radius: 2, stand: 4, duck: 4, eyeStand: 2, eyeDuck: 2 }; | |
| 69 | ||
| 34 | 70 | /** |
| 35 | 71 | * Fall damage, CS-style: nothing under ~580 u/s, then it bites. This is a curve |
| 36 | 72 | * this game chose and not a consequence of the solver, which is why it is here |
src/client/replay.test.ts +37 −27
| @@ -34,41 +34,51 @@ const SECONDS = 40; | ||
| 34 | 34 | const TICKS = SECONDS * TICKS_PER_SECOND; |
| 35 | 35 | |
| 36 | 36 | /** |
| 37 | * Forty seconds of a five-a-side round on de_forge, seed 7. Computed on | |
| 38 | * 06a3c81 ("One page shell serves both games"), the commit this branch left | |
| 39 | * main at, before anything in this pull touched the tree. | |
| 37 | * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for | |
| 38 | * #142, which put a grenade in the game: what moved is the *hash*, not the | |
| 39 | * play. The snapshot now carries what is in each pocket and every live | |
| 40 | * grenade's body and fuse, so every second of every run differs from the | |
| 41 | * numbers before it — and the same forty seconds folded with the old field | |
| 42 | * list on this code still comes to the old digest, | |
| 43 | * 6e2532e264dedc1b0e627305cc8dc6adc7c9331467ba99ed85b8dbd00c5288a0, which is | |
| 44 | * how this branch knows nobody's footing changed. Nothing in this script | |
| 45 | * throws one: the bots do not buy grenades and the scripted person never | |
| 46 | * presses the key. | |
| 40 | 47 | */ |
| 41 | const ROUND = "6e2532e264dedc1b0e627305cc8dc6adc7c9331467ba99ed85b8dbd00c5288a0"; | |
| 48 | const ROUND = "da283f931bb75db712cb9b526edf2a03215f6e77b2f5c0038f3d60a0e5b918b7"; | |
| 42 | 49 | const ROUND_SECONDS = [ |
| 43 | "9e65233c0d48", "aa3a9181fd4e", "7c4010b39595", "f1f17242e53a", | |
| 44 | "f60445aeab1c", "fe6db21f4254", "6a9cb307907d", "0d04a4981ebb", | |
| 45 | "4f5b67b31f3a", "1143cc644fa5", "233dfe9b86ad", "a484e4d8c88a", | |
| 46 | "a8def47d1078", "edc047a03760", "041e88d4a21a", "7d1355c2c4e7", | |
| 47 | "5612f8126e48", "da03b727e727", "f11d1891e25d", "dd54b431fcf4", | |
| 48 | "fdd3cb238ea2", "44a94744ba5f", "51aafcbd7178", "87b3d8a4f92c", | |
| 49 | "1c7e703d3c31", "022fc11cab00", "b7da2d1b042c", "2aecf3498845", | |
| 50 | "65a47f51d197", "793d4cc2982f", "f7e5296e6308", "cefff31fc624", | |
| 51 | "ef1a5a248bc6", "d655548a346b", "fe852048d7cd", "d35a992a9295", | |
| 52 | "83ddecae01bf", "adb7eefdf1f4", "fd307b97235e", "0c623d45e867", | |
| 50 | "5b61fdcfd222", "59c85df8e999", "bae4c0a2cbdc", "343fa534be10", | |
| 51 | "e8589eb745a5", "30bd545dec3d", "2c703b733da0", "a9e24b3caf72", | |
| 52 | "185f9f1906fc", "4ce7203de7de", "d829688db383", "0c08923df153", | |
| 53 | "875524011970", "5ca4afe0f422", "4b2ef1d2dbcd", "032232a64550", | |
| 54 | "354c3e0df525", "1031fc49f939", "3bf4adca46af", "3da55a96f080", | |
| 55 | "6b2e493c9776", "184b9c1a17f3", "044a57e21e01", "d8b98952eaa0", | |
| 56 | "dab17116ca26", "7085bccf2aba", "224b3006a906", "5b4a3353cd1c", | |
| 57 | "55475c7735f5", "86da5b34ffc9", "8f961f47b849", "9217ef0cb1b7", | |
| 58 | "205d3c20bd95", "1ebf1980b791", "925eb8f6ae25", "764c3b4fb751", | |
| 59 | "ba6077a43a06", "628a19392265", "e4443444e72d", "58dd1da0f322", | |
| 53 | 60 | ]; |
| 54 | 61 | |
| 55 | 62 | /** |
| 56 | 63 | * Forty seconds of duels in `src/rl/arena.ts`, seed 4242, against the |
| 57 | * scripted opponent, resetting each time one of them dies. Computed on the | |
| 58 | * same commit, 06a3c81. | |
| 64 | * scripted opponent, resetting each time one of them dies. Re-pinned for #142 | |
| 65 | * for exactly the reason above and no other: the arena buys no grenades and | |
| 66 | * `toInput` never sets the throw, so the duel itself is untouched and the old | |
| 67 | * field list on this code still folds to | |
| 68 | * 5cdb7419bdd67eff8e59436620d6bf3c65169d3f0477114d16b634c6b1bc79b6. | |
| 59 | 69 | */ |
| 60 | const DUEL = "5cdb7419bdd67eff8e59436620d6bf3c65169d3f0477114d16b634c6b1bc79b6"; | |
| 70 | const DUEL = "37ea4cc90ecb55e2489ee1f219654b563a16e93893a6222c6e17f23f56b3c5d3"; | |
| 61 | 71 | const DUEL_SECONDS = [ |
| 62 | "4b602f465cc3", "55bd03b39724", "7ed7a3e9852c", "384c7656b58c", | |
| 63 | "d5a4fed56e37", "0c14ed3d20b0", "fb4abcb5a49a", "5cd40562c219", | |
| 64 | "21d1d1268ed9", "8a09b3475552", "8926d65beaf0", "60f7da994999", | |
| 65 | "4c87374025da", "7700baee5ede", "fe59251327c2", "85c8b4f5851d", | |
| 66 | "9ddcbb06870b", "ef4346bb917d", "701b38b7cc85", "7b0310352ead", | |
| 67 | "9a7977b11907", "368b58be4235", "e7a36f9aa08e", "214a8b8bff24", | |
| 68 | "0d20594c6069", "ba43b863d3cc", "3cfebac1c436", "c5f40350e06c", | |
| 69 | "a13f1ee2bf59", "657dd44ce153", "79e2aee250d0", "67c57043bc04", | |
| 70 | "2fa20867e8c7", "f25307c5968c", "e36ece4c4588", "14525727cbb2", | |
| 71 | "3e8043af606f", "5b20a5d42233", "955040d49339", "d2053b7c791c", | |
| 72 | "4c57d7bdcb7f", "f374d023c8e8", "74ee330614b4", "d8dde79a3ad4", | |
| 73 | "c70ef481676b", "fb9c0f703423", "50117aa42276", "5315650d699a", | |
| 74 | "2fdd772b5129", "05a0b2d22059", "dbf2a92b9c48", "218603e90510", | |
| 75 | "a1037674ca1d", "e624412df2fd", "76fdd5cf444e", "2786cf745579", | |
| 76 | "a591f5256ee9", "7d1f1738def2", "2aca39a01dc9", "8367c172a66e", | |
| 77 | "745adf940e76", "0d5939fa2fc4", "6a7277642008", "420709562891", | |
| 78 | "1bd36e889bb5", "46245dfc87e8", "813ad43a79ab", "6775f1835415", | |
| 79 | "3baaf6e14bcb", "14a0dd1d2f63", "9448b5334bcb", "62731f350fb4", | |
| 80 | "a71a4fb1ea25", "6a7209feb590", "a003206a0750", "9ae62ae7335e", | |
| 81 | "a08e0da54e22", "56f94459be4f", "5f9be2613a9f", "6bd3759350d2", | |
| 72 | 82 | ]; |
| 73 | 83 | |
| 74 | 84 | const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUND, seconds: ROUND_SECONDS }; |
src/client/replay.ts +24 −3
| @@ -9,8 +9,8 @@ | ||
| 9 | 9 | * moved behaviour; a deliberate change to the simulation moves it on purpose |
| 10 | 10 | * and updates the constant in the same pull. |
| 11 | 11 | * |
| 12 | * What is hashed is the simulation and nothing else — PLAYER_FIELDS and | |
| 13 | * MATCH_FIELDS below are the whole list. Effects, the kill feed and the | |
| 12 | * What is hashed is the simulation and nothing else — PLAYER_FIELDS, | |
| 13 | * GRENADE_FIELDS and MATCH_FIELDS below are the whole list. Effects, the kill feed and the | |
| 14 | 14 | * messages are presentation: they would make the digest fragile and would |
| 15 | 15 | * catch nothing the state does not already catch. |
| 16 | 16 | * |
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | |
| 23 | 23 | import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts"; |
| 24 | 24 | import { buy, NO_INPUT, update, type BuyItem, type Input, type Player, type World } from "./world.ts"; |
| 25 | import type { Grenade } from "./grenade.ts"; | |
| 25 | 26 | import type { MatchState, Phase } from "./match.ts"; |
| 26 | 27 | |
| 27 | 28 | export const TICK = 1 / 64; |
| @@ -52,6 +53,23 @@ export const PLAYER_FIELDS: { name: string; of: (p: Player) => number }[] = [ | ||
| 52 | 53 | { name: "duckAmount", of: (p) => p.duckAmount }, |
| 53 | 54 | // -1 for a weapon that has no magazine (the knife), so a switch still shows. |
| 54 | 55 | { name: "mag", of: (p) => p.ammo[p.weapon]?.mag ?? -1 }, |
| 56 | // What is in the pocket: bought, thrown, and lost with the body that held it. | |
| 57 | { name: "grenades", of: (p) => p.grenades }, | |
| 58 | ]; | |
| 59 | ||
| 60 | /** | |
| 61 | * Everything hashed about a grenade in the air. They come and go inside a | |
| 62 | * round, so the count goes in first: a tick with one grenade resting and a | |
| 63 | * tick with none are different states and hash differently. | |
| 64 | */ | |
| 65 | export const GRENADE_FIELDS: { name: string; of: (g: Grenade) => number }[] = [ | |
| 66 | { name: "pos.x", of: (g) => g.pos[0] }, | |
| 67 | { name: "pos.y", of: (g) => g.pos[1] }, | |
| 68 | { name: "pos.z", of: (g) => g.pos[2] }, | |
| 69 | { name: "vel.x", of: (g) => g.vel[0] }, | |
| 70 | { name: "vel.y", of: (g) => g.vel[1] }, | |
| 71 | { name: "vel.z", of: (g) => g.vel[2] }, | |
| 72 | { name: "fuse", of: (g) => g.fuse }, | |
| 55 | 73 | ]; |
| 56 | 74 | |
| 57 | 75 | /** Everything hashed about the match. */ |
| @@ -66,6 +84,8 @@ export const MATCH_FIELDS: { name: string; of: (m: MatchState) => number }[] = [ | ||
| 66 | 84 | export function snapshot(world: World): string { |
| 67 | 85 | const parts: string[] = []; |
| 68 | 86 | for (const p of world.players) for (const field of PLAYER_FIELDS) parts.push(quantise(field.of(p))); |
| 87 | parts.push(quantise(world.grenades.length)); | |
| 88 | for (const g of world.grenades) for (const field of GRENADE_FIELDS) parts.push(quantise(field.of(g))); | |
| 69 | 89 | for (const field of MATCH_FIELDS) parts.push(quantise(field.of(world.match))); |
| 70 | 90 | return `${parts.join(",")}\n`; |
| 71 | 91 | } |
| @@ -192,7 +212,8 @@ export function compare(run: Run, pin: Pin): string | null { | ||
| 192 | 212 | file: "src/client/replay.test.ts", |
| 193 | 213 | list: `${pin.constant}_SECONDS`, |
| 194 | 214 | hashes: |
| 195 | `${PLAYER_FIELDS.map((f) => f.name).join(", ")} per player ` + | |
| 215 | `${PLAYER_FIELDS.map((f) => f.name).join(", ")} per player, ` + | |
| 216 | `${GRENADE_FIELDS.map((f) => f.name).join(", ")} per live grenade, ` + | |
| 196 | 217 | `and ${MATCH_FIELDS.map((f) => f.name).join(", ")}`, |
| 197 | 218 | every: TICKS_PER_SECOND, |
| 198 | 219 | unit: "second", |
src/client/scene.ts +26 −1
| @@ -22,7 +22,7 @@ import { | ||
| 22 | 22 | type Camera, type Scene as Frame, type Vec3, |
| 23 | 23 | } from "../engine/index.ts"; |
| 24 | 24 | import { CS_HULL } from "./movement.ts"; |
| 25 | import { WEAPONS } from "./weapons.ts"; | |
| 25 | import { HE, WEAPONS } from "./weapons.ts"; | |
| 26 | 26 | import type { Player, World } from "./world.ts"; |
| 27 | 27 | |
| 28 | 28 | export type Tint = [number, number, number]; |
| @@ -58,6 +58,10 @@ export const LINE_CAPACITY = 4096; | ||
| 58 | 58 | */ |
| 59 | 59 | export const INSTANCE_CAPACITY = 32; |
| 60 | 60 | |
| 61 | /** How long a blast is drawn for, in seconds, and in how many spokes. */ | |
| 62 | const BLAST_SECONDS = 0.45; | |
| 63 | const BLAST_SPOKES = 16; | |
| 64 | ||
| 61 | 65 | /** The horizon, and the colour distance fades into. */ |
| 62 | 66 | export const SKY: Tint = [0.66, 0.73, 0.82]; |
| 63 | 67 | |
| @@ -142,6 +146,11 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 142 | 146 | } |
| 143 | 147 | if (world.bomb.state === "planted" || world.bomb.state === "dropped") |
| 144 | 148 | place(into, "bomb", world.bomb.pos, 0, 1, WHITE); |
| 149 | // Every grenade in the air or lying on the floor. It tumbles as it flies: | |
| 150 | // the yaw is the simulated time, so a frame decides nothing and a paused | |
| 151 | // one turns it no further. | |
| 152 | for (const g of world.grenades) | |
| 153 | place(into, "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE); | |
| 145 | 154 | |
| 146 | 155 | // --- tracers, sparks and the planted bomb's beam, all as lines |
| 147 | 156 | for (const effect of world.effects) { |
| @@ -168,6 +177,22 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 168 | 177 | colour(NEAR, 0.95, 0.9, 0.75), colour(FAR, 0.8, 0.7, 0.5), |
| 169 | 178 | ); |
| 170 | 179 | } |
| 180 | } else if (effect.kind === "blast" && age < BLAST_SECONDS) { | |
| 181 | // The blast, drawn as what it is: a star of light that runs out to the | |
| 182 | // radius the damage actually reaches and fades as it goes. | |
| 183 | const t = age / BLAST_SECONDS; | |
| 184 | const k = HE.blast.radius * Math.min(1, t * 2.2); | |
| 185 | const fade = 1 - t; | |
| 186 | for (let s = 0; s < BLAST_SPOKES; s++) { | |
| 187 | const a = (s / BLAST_SPOKES) * Math.PI * 2; | |
| 188 | const lift = Math.sin(s * 2.4) * 0.5; | |
| 189 | pushLine( | |
| 190 | into, | |
| 191 | effect.pos, | |
| 192 | at(TO, effect.pos[0] + Math.cos(a) * k, effect.pos[1] + Math.sin(a) * k, effect.pos[2] + lift * k), | |
| 193 | colour(NEAR, 1, 0.95, 0.8 * fade), colour(FAR, 1, 0.5 * fade, 0.1 * fade), | |
| 194 | ); | |
| 195 | } | |
| 171 | 196 | } else if (effect.kind === "blood" && age < 0.3) { |
| 172 | 197 | const k = 18 * (1 - age / 0.3); |
| 173 | 198 | pushLine( |
src/client/weapons.ts +69 −9
| @@ -6,17 +6,33 @@ | ||
| 6 | 6 | * The spray patterns are generated once from a per-weapon seed, not copied: |
| 7 | 7 | * they are deterministic, so the AK's first ten bullets are the same ten |
| 8 | 8 | * bullets every round, which is the property that makes spray control a skill. |
| 9 | * | |
| 10 | * Eight of the nine are guns. The ninth is the HE grenade, which is in the | |
| 11 | * same table because it is bought in the same menu and named in the same kill | |
| 12 | * feed, and which carries a `blast` instead of a magazine: how it flies and | |
| 13 | * what it does when the fuse runs out is `src/client/grenade.ts`. | |
| 9 | 14 | */ |
| 10 | 15 | |
| 11 | 16 | import { rng } from "../engine/index.ts"; |
| 12 | 17 | |
| 13 | 18 | export type WeaponId = |
| 14 | | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp"; | |
| 19 | | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he"; | |
| 20 | ||
| 21 | /** | |
| 22 | * What a thrown weapon does when its fuse runs out. Only the grenade has one, | |
| 23 | * and everything about the explosion a simulation needs is these four numbers. | |
| 24 | */ | |
| 25 | export type Blast = { | |
| 26 | damage: number; // hit points at the centre of the blast, unarmoured | |
| 27 | radius: number; // units; the damage falls to nothing at this distance | |
| 28 | fuse: number; // seconds from leaving the hand to the detonation | |
| 29 | armorPenetration: number; // 0..1, the share that reaches health through armour | |
| 30 | }; | |
| 15 | 31 | |
| 16 | 32 | export type Weapon = { |
| 17 | 33 | id: WeaponId; |
| 18 | 34 | name: string; |
| 19 | kind: "knife" | "pistol" | "smg" | "rifle" | "sniper"; | |
| 35 | kind: "knife" | "pistol" | "smg" | "rifle" | "sniper" | "grenade"; | |
| 20 | 36 | team: "t" | "ct" | "both"; |
| 21 | 37 | price: number; |
| 22 | 38 | damage: number; |
| @@ -33,8 +49,13 @@ export type Weapon = { | ||
| 33 | 49 | recoil: number; // degrees of climb per shot, scaled by the pattern |
| 34 | 50 | zoom?: number; // scoped field of view |
| 35 | 51 | automatic: boolean; |
| 52 | /** Set on a thrown weapon, and on nothing else. */ | |
| 53 | blast?: Blast; | |
| 36 | 54 | }; |
| 37 | 55 | |
| 56 | /** The HE grenade, by the name everything that throws one calls it. */ | |
| 57 | export const HE: Weapon & { blast: Blast } = { id: "he", name: "HE Grenade", kind: "grenade", team: "both", price: 300, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, blast: { damage: 98, radius: 350, fuse: 1.5, armorPenetration: 0.5 } }; | |
| 58 | ||
| 38 | 59 | const WEAPON_LIST: Weapon[] = [ |
| 39 | 60 | { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, armorPenetration: 0.85, rangeModifier: 1, rpm: 240, magazine: 0, reserve: 0, reloadTime: 0, killReward: 1500, carrySpeed: 250, spread: 0, moveSpread: 0, recoil: 0, automatic: true }, |
| 40 | 61 | { id: "glock", name: "Glock-18", kind: "pistol", team: "t", price: 200, damage: 30, armorPenetration: 0.47, rangeModifier: 0.75, rpm: 400, magazine: 20, reserve: 120, reloadTime: 2.1, killReward: 300, carrySpeed: 240, spread: 0.22, moveSpread: 1.6, recoil: 0.7, automatic: false }, |
| @@ -44,6 +65,12 @@ const WEAPON_LIST: Weapon[] = [ | ||
| 44 | 65 | { id: "ak47", name: "AK-47", kind: "rifle", team: "t", price: 2700, damage: 36, armorPenetration: 0.775, rangeModifier: 0.98, rpm: 600, magazine: 30, reserve: 90, reloadTime: 2.5, killReward: 300, carrySpeed: 215, spread: 0.16, moveSpread: 3.4, recoil: 1.5, automatic: true }, |
| 45 | 66 | { id: "m4a4", name: "M4A4", kind: "rifle", team: "ct", price: 3100, damage: 33, armorPenetration: 0.7, rangeModifier: 0.97, rpm: 666, magazine: 30, reserve: 90, reloadTime: 3.1, killReward: 300, carrySpeed: 225, spread: 0.15, moveSpread: 3.2, recoil: 1.25, automatic: true }, |
| 46 | 67 | { id: "awp", name: "AWP", kind: "sniper", team: "both", price: 4750, damage: 115, armorPenetration: 0.975, rangeModifier: 0.99, rpm: 41, magazine: 10, reserve: 30, reloadTime: 3.7, killReward: 100, carrySpeed: 200, spread: 0.05, moveSpread: 12, recoil: 3.5, zoom: 40, automatic: false }, |
| 68 | // The one thrown weapon. Its bullet numbers are the empty ones — it is never | |
| 69 | // held, never fired and never reloaded — and everything it does is in | |
| 70 | // `blast`: CS's $300 HE, 98 hit points at the centre falling off over 350 | |
| 71 | // units, on a 1.5-second fuse, with armour stopping about half of it. It is | |
| 72 | // last in the list on purpose: the spray patterns below are seeded by index. | |
| 73 | HE, | |
| 47 | 74 | ]; |
| 48 | 75 | |
| 49 | 76 | export const WEAPONS: Record<WeaponId, Weapon> = Object.fromEntries( |
| @@ -108,6 +135,27 @@ export function inaccuracy(weapon: Weapon, state: Accuracy): number { | ||
| 108 | 135 | export const HITGROUP = { head: 4, chest: 1, stomach: 1.25, arm: 1, leg: 0.75 } as const; |
| 109 | 136 | export type Hitgroup = keyof typeof HITGROUP; |
| 110 | 137 | |
| 138 | /** | |
| 139 | * What `damage` costs a body wearing `armor`, CS's way: the weapon's armour | |
| 140 | * penetration is the share that reaches health, and the vest takes half of what | |
| 141 | * it stopped — until it runs out, after which what is left goes straight | |
| 142 | * through. One function because a bullet and a blast divide it the same way, | |
| 143 | * and a body should not care which one arrived. | |
| 144 | */ | |
| 145 | export function throughArmor( | |
| 146 | damage: number, | |
| 147 | armor: number, | |
| 148 | penetration: number, | |
| 149 | ): { health: number; armor: number } { | |
| 150 | let health = damage * penetration; | |
| 151 | let armorDamage = (damage - health) * 0.5; | |
| 152 | if (armorDamage > armor) { | |
| 153 | armorDamage = armor; | |
| 154 | health = damage - armor * 2; | |
| 155 | } | |
| 156 | return { health: Math.max(0, Math.round(health)), armor: Math.round(armorDamage) }; | |
| 157 | } | |
| 158 | ||
| 111 | 159 | /** |
| 112 | 160 | * CS's damage sum: range falloff, then the hitgroup, then armour. Armour takes |
| 113 | 161 | * half of what it stops; a helmet is the only thing that helps a headshot. |
| @@ -123,11 +171,23 @@ export function computeDamage( | ||
| 123 | 171 | damage *= HITGROUP[group]; |
| 124 | 172 | const protectedHere = armor > 0 && (group !== "head" || helmet) && group !== "leg"; |
| 125 | 173 | if (!protectedHere) return { health: Math.round(damage), armor: 0 }; |
| 126 | let health = damage * weapon.armorPenetration; | |
| 127 | let armorDamage = (damage - health) * 0.5; | |
| 128 | if (armorDamage > armor) { | |
| 129 | armorDamage = armor; | |
| 130 | health = damage - armor * 2; | |
| 131 | } | |
| 132 | return { health: Math.max(0, Math.round(health)), armor: Math.round(armorDamage) }; | |
| 174 | return throughArmor(damage, armor, weapon.armorPenetration); | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * The blast, before armour: the full damage at the centre, falling off in a | |
| 179 | * straight line to nothing at the radius — | |
| 180 | * | |
| 181 | * damage(d) = damage · (1 − d / radius), zero at and past the radius | |
| 182 | * | |
| 183 | * which is Source's own `RadiusDamage` falloff (`falloff = damage / radius`, | |
| 184 | * subtracted per unit of distance). At 98 and 350 units that is 98 in the lap, | |
| 185 | * 49 at 175 units and nothing at all from 350 away. | |
| 186 | * | |
| 187 | * Whether the blast reaches at all is a different question, and not this | |
| 188 | * function's: `src/client/grenade.ts` asks the engine's `traceWorld` first. | |
| 189 | */ | |
| 190 | export function blastDamage(blast: Blast, distance: number): number { | |
| 191 | if (distance >= blast.radius) return 0; | |
| 192 | return blast.damage * (1 - distance / blast.radius); | |
| 133 | 193 | } |
src/client/world.ts +110 −8
| @@ -1,13 +1,21 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The world: ten players, one bomb, one round at a time. Everything that can |
| 3 | * happen in a round happens here — buying, moving, shooting, planting, | |
| 4 | * defusing, dying — and the match rules in match.ts decide what it means. | |
| 3 | * happen in a round happens here — buying, moving, shooting, throwing, | |
| 4 | * planting, defusing, dying — and the match rules in match.ts decide what it | |
| 5 | * means. A thrown grenade is the one thing in a round that is not a person: | |
| 6 | * `src/client/grenade.ts` says how one flies and what its blast reaches, and | |
| 7 | * what a blast *means* — the damage, the hit marker, the line in the feed — is | |
| 8 | * here with every other kind of harm. | |
| 5 | 9 | * |
| 6 | 10 | * The simulation runs at a fixed 64 ticks a second and knows nothing about |
| 7 | 11 | * WebGL, so it can be stepped in a test without a browser. |
| 8 | 12 | */ |
| 9 | 13 | |
| 10 | import { applyHit, traceBullet, type Actor } from "./combat.ts"; | |
| 14 | import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts"; | |
| 15 | import { | |
| 16 | blastCentre, blastHits, stepGrenade, throwGrenade, THROW_LIFT, | |
| 17 | type Grenade, type GrenadeSound, | |
| 18 | } from "./grenade.ts"; | |
| 11 | 19 | import { siteAt, type Level } from "./level.ts"; |
| 12 | 20 | import { |
| 13 | 21 | angleVectors, angleDelta, clamp, dist, eyeHeight, hullHeight, move, normalize, rng, sub, visible, |
| @@ -37,12 +45,15 @@ export type Player = Actor & Body & { | ||
| 37 | 45 | ammo: Record<string, Ammo>; |
| 38 | 46 | defuseKit: boolean; |
| 39 | 47 | hasBomb: boolean; |
| 48 | /** HE grenades in the pocket: at most `MAX_GRENADES`, thrown one at a time. */ | |
| 49 | grenades: number; | |
| 40 | 50 | nextShotAt: number; |
| 41 | 51 | reloadUntil: number; |
| 42 | 52 | shotIndex: number; |
| 43 | 53 | lastShotAt: number; |
| 44 | 54 | scoped: boolean; |
| 45 | 55 | firedThisPress: boolean; |
| 56 | threwThisPress: boolean; | |
| 46 | 57 | punch: [number, number]; // view kick, decaying |
| 47 | 58 | kills: number; |
| 48 | 59 | deaths: number; |
| @@ -66,7 +77,8 @@ export type Effect = | ||
| 66 | 77 | | { kind: "blood"; at: number; pos: Vec3 } |
| 67 | 78 | | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean } |
| 68 | 79 | | { kind: "hitmark"; at: number; headshot: boolean } |
| 69 | | { kind: "sound"; at: number; pos: Vec3; name: string }; | |
| 80 | | { kind: "blast"; at: number; pos: Vec3 } | |
| 81 | | { kind: "sound"; at: number; pos: Vec3; name: GrenadeSound }; | |
| 70 | 82 | |
| 71 | 83 | export type FeedEntry = { at: number; killer: string; victim: string; weapon: WeaponId; headshot: boolean; killerTeam: Team }; |
| 72 | 84 | |
| @@ -80,12 +92,18 @@ export type Input = { | ||
| 80 | 92 | reload: boolean; |
| 81 | 93 | use: boolean; |
| 82 | 94 | scope: boolean; |
| 95 | /** | |
| 96 | * Throw the grenade in your pocket. It is one more field, and a bot that | |
| 97 | * never sets it — every bot in this game, today — plays exactly as it did | |
| 98 | * before there were grenades. | |
| 99 | */ | |
| 100 | throwGrenade: boolean; | |
| 83 | 101 | switchTo: WeaponId | null; |
| 84 | 102 | }; |
| 85 | 103 | |
| 86 | 104 | export const NO_INPUT: Input = { |
| 87 | 105 | forward: 0, side: 0, jump: false, duck: false, walk: false, |
| 88 | fire: false, reload: false, use: false, scope: false, switchTo: null, | |
| 106 | fire: false, reload: false, use: false, scope: false, throwGrenade: false, switchTo: null, | |
| 89 | 107 | }; |
| 90 | 108 | |
| 91 | 109 | export type World = { |
| @@ -95,6 +113,8 @@ export type World = { | ||
| 95 | 113 | time: number; |
| 96 | 114 | bomb: Bomb; |
| 97 | 115 | effects: Effect[]; |
| 116 | /** Every grenade in the air or on the floor right now, oldest first. */ | |
| 117 | grenades: Grenade[]; | |
| 98 | 118 | feed: FeedEntry[]; |
| 99 | 119 | messages: { at: number; text: string }[]; |
| 100 | 120 | random: () => number; |
| @@ -126,8 +146,9 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 126 | 146 | money: 800, |
| 127 | 147 | weapon: secondary, primary: null, secondary, |
| 128 | 148 | ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) }, |
| 129 | defuseKit: false, hasBomb: false, | |
| 130 | nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false, | |
| 149 | defuseKit: false, hasBomb: false, grenades: 0, | |
| 150 | nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, | |
| 151 | firedThisPress: false, threwThisPress: false, | |
| 131 | 152 | punch: [0, 0], |
| 132 | 153 | kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, |
| 133 | 154 | }; |
| @@ -164,7 +185,7 @@ export function createWorld( | ||
| 164 | 185 | const world: World = { |
| 165 | 186 | match, players, me, time: 0, |
| 166 | 187 | bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null }, |
| 167 | effects: [], feed: [], messages: [], | |
| 188 | effects: [], grenades: [], feed: [], messages: [], | |
| 168 | 189 | random, level, bombPlanted: false, pendingBuy: false, |
| 169 | 190 | }; |
| 170 | 191 | startRound(world); |
| @@ -193,6 +214,9 @@ export function startRound(world: World): void { | ||
| 193 | 214 | p.pos = [at[0], at[1], 2]; |
| 194 | 215 | p.vel = [0, 0, 0]; |
| 195 | 216 | p.health = 100; |
| 217 | // A grenade survives the round its owner did, like their gun does; one they | |
| 218 | // died holding is left on the floor with them. | |
| 219 | if (!p.alive) p.grenades = 0; | |
| 196 | 220 | p.alive = true; |
| 197 | 221 | p.duckAmount = 0; |
| 198 | 222 | p.hasBomb = false; |
| @@ -216,6 +240,7 @@ export function startRound(world: World): void { | ||
| 216 | 240 | world.bomb = { state: "carried", pos: [...carrier.pos] as Vec3, site: null, plantProgress: 0, defuseProgress: 0, defuser: null }; |
| 217 | 241 | } |
| 218 | 242 | world.bombPlanted = false; |
| 243 | world.grenades = []; | |
| 219 | 244 | world.feed = []; |
| 220 | 245 | for (const p of world.players) if (p.bot) botBuy(world, p); |
| 221 | 246 | } |
| @@ -262,6 +287,9 @@ export function priceOf(item: BuyItem, player: Player): number { | ||
| 262 | 287 | return WEAPONS[item]?.price ?? Infinity; |
| 263 | 288 | } |
| 264 | 289 | |
| 290 | /** How many HE grenades a player may carry. CS allows one, and so does this. */ | |
| 291 | export const MAX_GRENADES = 1; | |
| 292 | ||
| 265 | 293 | export const canBuy = (world: World, player: Player): boolean => |
| 266 | 294 | world.match.phase === "freeze" && player.alive; |
| 267 | 295 | |
| @@ -284,6 +312,14 @@ export function buy(world: World, player: Player, item: BuyItem): boolean { | ||
| 284 | 312 | const weapon = WEAPONS[item]; |
| 285 | 313 | if (weapon === undefined || weapon.kind === "knife") return false; |
| 286 | 314 | if (weapon.team !== "both" && weapon.team !== player.team) return false; |
| 315 | // A grenade goes in a pocket, not in your hands: it does not become the | |
| 316 | // weapon you are holding and it does not displace the gun you bought. | |
| 317 | if (weapon.kind === "grenade") { | |
| 318 | if (player.grenades >= MAX_GRENADES) return false; | |
| 319 | player.grenades += 1; | |
| 320 | player.money -= price; | |
| 321 | return true; | |
| 322 | } | |
| 287 | 323 | if (weapon.kind === "pistol") player.secondary = item; |
| 288 | 324 | else player.primary = item; |
| 289 | 325 | player.ammo[item] = emptyAmmo(item); |
| @@ -389,6 +425,64 @@ function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId, | ||
| 389 | 425 | checkRoundEnd(world); |
| 390 | 426 | } |
| 391 | 427 | |
| 428 | // --- grenades ------------------------------------------------------------- | |
| 429 | ||
| 430 | /** | |
| 431 | * Throw the grenade in this player's pocket, if there is one and the round | |
| 432 | * will have it. It leaves the eye along the view, tilted `THROW_LIFT` degrees | |
| 433 | * up the way CS tilts one, and from there it belongs to `grenade.ts` and the | |
| 434 | * engine's solver — nothing here steers it. | |
| 435 | */ | |
| 436 | export function throwOne(world: World, p: Player): boolean { | |
| 437 | if (!p.alive || p.grenades <= 0) return false; | |
| 438 | if (world.match.phase === "freeze" || world.match.phase === "over") return false; | |
| 439 | p.grenades -= 1; | |
| 440 | const from = eyePos(p); | |
| 441 | const dir = angleVectors(p.yaw, p.pitch - THROW_LIFT).forward; | |
| 442 | world.grenades.push(throwGrenade(p.id, p.team, from, dir, world.level.boxes)); | |
| 443 | world.effects.push({ kind: "sound", at: world.time, pos: from, name: "throw" }); | |
| 444 | return true; | |
| 445 | } | |
| 446 | ||
| 447 | const heard = (world: World, pos: Vec3, name: GrenadeSound): void => { | |
| 448 | world.effects.push({ kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name }); | |
| 449 | }; | |
| 450 | ||
| 451 | /** | |
| 452 | * What a blast does: damage by distance to everything it can see, through | |
| 453 | * armour, credited to whoever threw it — a hit marker if that was you and a | |
| 454 | * line in the feed with the grenade named if it killed. | |
| 455 | */ | |
| 456 | function detonate(world: World, g: Grenade): void { | |
| 457 | const centre = blastCentre(g); | |
| 458 | world.effects.push({ kind: "blast", at: world.time, pos: centre }); | |
| 459 | heard(world, centre, "blast"); | |
| 460 | const thrower = world.players.find((p) => p.id === g.owner); | |
| 461 | for (const caught of blastHits(g, world.players, world.level.boxes)) { | |
| 462 | const victim = caught.actor as Player; | |
| 463 | const result = applyDamage(victim, caught.health, caught.armor); | |
| 464 | world.effects.push({ kind: "blood", at: world.time, pos: [victim.pos[0], victim.pos[1], victim.pos[2] + 40] }); | |
| 465 | if (thrower === undefined) continue; | |
| 466 | thrower.damage += result.damage; | |
| 467 | if (thrower === world.me) world.effects.push({ kind: "hitmark", at: world.time, headshot: false }); | |
| 468 | if (result.killed) onKill(world, thrower, victim, "he", false); | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | /** Every live grenade, one tick older: moved, heard, and detonated on time. */ | |
| 473 | function updateGrenades(world: World, dt: number): void { | |
| 474 | if (world.grenades.length === 0) return; | |
| 475 | let spent = false; | |
| 476 | for (const g of world.grenades) { | |
| 477 | if (stepGrenade(g, dt, world.level.boxes)) heard(world, g.pos, "bounce"); | |
| 478 | if (g.fuse <= 0) { | |
| 479 | detonate(world, g); | |
| 480 | spent = true; | |
| 481 | } | |
| 482 | } | |
| 483 | if (spent) world.grenades = world.grenades.filter((g) => g.fuse > 0); | |
| 484 | } | |
| 485 | ||
| 392 | 486 | // --- the bomb ------------------------------------------------------------- |
| 393 | 487 | |
| 394 | 488 | const PLANT_RANGE = 64; |
| @@ -482,6 +576,10 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 482 | 576 | } |
| 483 | 577 | } |
| 484 | 578 | if (!input.fire) p.firedThisPress = false; |
| 579 | // One throw a press, like a semi-automatic trigger: holding the key down | |
| 580 | // does not empty a pocket that only ever holds one anyway. | |
| 581 | if (input.throwGrenade && !p.threwThisPress) throwOne(world, p); | |
| 582 | p.threwThisPress = input.throwGrenade; | |
| 485 | 583 | if (world.time - p.lastShotAt > 0.35) p.shotIndex = 0; |
| 486 | 584 | p.punch = [p.punch[0] * Math.pow(0.02, dt), p.punch[1] * Math.pow(0.02, dt)]; |
| 487 | 585 | updateBomb(world, p, input, dt); |
| @@ -511,6 +609,7 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 511 | 609 | p.armor = 0; |
| 512 | 610 | p.helmet = false; |
| 513 | 611 | p.defuseKit = false; |
| 612 | p.grenades = 0; | |
| 514 | 613 | p.secondary = p.team === "t" ? "glock" : "usp"; |
| 515 | 614 | p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) }; |
| 516 | 615 | p.weapon = p.secondary; |
| @@ -528,6 +627,9 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 528 | 627 | if (!p.bot || !p.alive) continue; |
| 529 | 628 | applyInput(world, p, botThink(world, p, dt), dt); |
| 530 | 629 | } |
| 630 | // After everyone has moved, so a grenade is solved against where people | |
| 631 | // ended the tick rather than where they started it. | |
| 632 | updateGrenades(world, dt); | |
| 531 | 633 | checkRoundEnd(world); |
| 532 | 634 | const cutoff = world.time - 2; |
| 533 | 635 | if (world.effects.length > 0) world.effects = world.effects.filter((e) => e.at > cutoff); |
src/engine/physics.test.ts +58
| @@ -50,6 +50,7 @@ const WALKER: MoveTuning = { | ||
| 50 | 50 | jumpVelocity: 300, |
| 51 | 51 | stepSize: 20, |
| 52 | 52 | duckRate: 4, |
| 53 | restitution: 0, // a walker does not bounce: it stops dead and slides | |
| 53 | 54 | }; |
| 54 | 55 | |
| 55 | 56 | /** Half the gravity, a higher jump, a faster body, twice the step. */ |
| @@ -66,8 +67,13 @@ const DRIFTER: MoveTuning = { | ||
| 66 | 67 | jumpVelocity: 450, |
| 67 | 68 | stepSize: WALKER.stepSize * 2, |
| 68 | 69 | duckRate: 2, |
| 70 | restitution: 0, | |
| 69 | 71 | }; |
| 70 | 72 | |
| 73 | /** The third profile, and the only bouncy one: half of what it arrives with, | |
| 74 | * given back the other way, and no friction to argue with it afterwards. */ | |
| 75 | const RUBBER: MoveTuning = { ...WALKER, restitution: 0.5, friction: 0 }; | |
| 76 | ||
| 71 | 77 | /** A 40×40×64 hull. */ |
| 72 | 78 | const SHORT: Hull = { radius: 20, stand: 64, duck: 40, eyeStand: 56, eyeDuck: 34 }; |
| 73 | 79 | |
| @@ -366,3 +372,55 @@ test("turning while accelerating in the air gains speed a straight line cannot", | ||
| 366 | 372 | while (!straight.onGround) move(straight, wish(WALKER, { forward: 1 }), TICK, [FLOOR], WALKER, SHORT); |
| 367 | 373 | expect(Math.hypot(straight.vel[0], straight.vel[1])).toBeCloseTo(WALKER.maxSpeed, 6); |
| 368 | 374 | }); |
| 375 | ||
| 376 | // --- restitution ----------------------------------------------------------- | |
| 377 | ||
| 378 | test("restitution is the share a blocked axis gives back, and a walker has none", () => { | |
| 379 | // A tall wall and a body flying at it in mid-air, so neither friction nor the | |
| 380 | // ground snap has anything to say about the velocity under test. | |
| 381 | const wall: Box = { min: [300, -400, -64], max: [400, 400, 500], mat: 1 }; | |
| 382 | const into = (tuning: MoveTuning): { vel: number; pos: number } => { | |
| 383 | const b = body([200, 0, 200]); | |
| 384 | b.vel = [400, 0, 0]; | |
| 385 | b.onGround = false; | |
| 386 | for (let tick = 0; tick < 40; tick++) move(b, wish(tuning), TICK, [FLOOR, wall], tuning, SHORT); | |
| 387 | return { vel: b.vel[0], pos: b.pos[0] }; | |
| 388 | }; | |
| 389 | // A walker keeps pushing into the wall and slides along it: the velocity it | |
| 390 | // met the wall with is exactly the velocity it still has. Both profiles above | |
| 391 | // are walkers, and neither is moved by the arithmetic below. | |
| 392 | for (const { name, tuning } of PROFILES) { | |
| 393 | expect(tuning.restitution, name).toBe(0); | |
| 394 | expect(into(tuning).vel, name).toBe(400); | |
| 395 | expect(into(tuning).pos, name).toBeCloseTo(300 - SHORT.radius, 9); | |
| 396 | } | |
| 397 | // Half of it back, the other way, out of the same call and the same solver: | |
| 398 | // 400 × 0.5 = 200 u/s away from the wall, and the face is behind it now. | |
| 399 | expect(into(RUBBER).vel).toBe(-400 * RUBBER.restitution); | |
| 400 | expect(into(RUBBER).pos).toBeLessThan(300 - SHORT.radius); | |
| 401 | }); | |
| 402 | ||
| 403 | test("a bounce off the floor is the arrival speed reversed and scaled, and the contact tolerance ends it", () => { | |
| 404 | const b = body([0, 0, 200]); | |
| 405 | b.onGround = false; | |
| 406 | let arrived = 0, left = 0; | |
| 407 | for (let tick = 0; tick < 64 * 20; tick++) { | |
| 408 | const before = b.vel[2]; | |
| 409 | move(b, wish(RUBBER), TICK, [FLOOR], RUBBER, SHORT); | |
| 410 | if (arrived === 0 && before < 0 && b.vel[2] > 0) { arrived = before; left = b.vel[2]; } | |
| 411 | } | |
| 412 | // Exactly the share the tuning names, and the other way. The speed it met | |
| 413 | // the floor with is the speed it started the tick with less one whole tick | |
| 414 | // of gravity — the solver takes both half-kicks before it moves a body down | |
| 415 | // — so the bounce is −(v − g·dt) · restitution and nothing else. | |
| 416 | expect(arrived).toBeLessThan(0); | |
| 417 | const met = arrived - RUBBER.gravity * TICK; | |
| 418 | expect(left).toBeCloseTo(-met * RUBBER.restitution, 10); | |
| 419 | // And it ends up asleep on the floor rather than jittering above it: a body | |
| 420 | // that arrives slower than the solver's own contact tolerance lets it fall | |
| 421 | // is caught by the floor instead of bouncing off it, which is what brings a | |
| 422 | // bouncing thing to rest even where nothing takes its energy away. | |
| 423 | expect(b.onGround).toBe(true); | |
| 424 | expect(b.pos[2]).toBe(0); | |
| 425 | expect(b.vel[2]).toBe(0); | |
| 426 | }); |
src/engine/physics.ts +18 −1
| @@ -42,6 +42,14 @@ export type MoveTuning = { | ||
| 42 | 42 | stepSize: number; |
| 43 | 43 | /** How fast a crouch closes and opens, in duck fractions per second. */ |
| 44 | 44 | duckRate: number; |
| 45 | /** | |
| 46 | * The share of the speed a blocked axis gives back, reversed: 0.45 is a body | |
| 47 | * that keeps a bit less than half of what it arrived with, 0 is one that | |
| 48 | * bounces not at all. A walker's is 0 and the axis is left exactly as it was, | |
| 49 | * which is what sliding along a wall while still pushing into it is; anything | |
| 50 | * above 0 is reflected, which is what a thrown thing does. | |
| 51 | */ | |
| 52 | restitution: number; | |
| 45 | 53 | }; |
| 46 | 54 | |
| 47 | 55 | /** |
| @@ -217,8 +225,17 @@ export function move( | ||
| 217 | 225 | } |
| 218 | 226 | } |
| 219 | 227 | |
| 228 | // A bouncing body leaves a wall with `restitution` of the speed it met it | |
| 229 | // with, reversed. A walker has none and is left alone: zeroing the component | |
| 230 | // it is pushing into the wall with would be a change of feel, not of bounce. | |
| 231 | if (tuning.restitution > 0) { | |
| 232 | if (blockedX) body.vel[0] *= -tuning.restitution; | |
| 233 | if (blockedY) body.vel[1] *= -tuning.restitution; | |
| 234 | } | |
| 235 | ||
| 220 | 236 | if (!body.onGround) body.vel[2] -= tuning.gravity * dt * 0.5; |
| 221 | if (slideAxis(body.pos, 2, body.vel[2] * dt, height, boxes, hull)) body.vel[2] = 0; | |
| 237 | if (slideAxis(body.pos, 2, body.vel[2] * dt, height, boxes, hull)) | |
| 238 | body.vel[2] = tuning.restitution > 0 ? -body.vel[2] * tuning.restitution : 0; | |
| 222 | 239 | |
| 223 | 240 | // On the ground means "a floor within `CONTACT` units of the feet" — and |
| 224 | 241 | // landing snaps to it, so a player never rests a hair above what they touched. |
src/games.ts +16 −1
| @@ -196,6 +196,21 @@ export type Game = { | ||
| 196 | 196 | * furniture instead of two, and the app fits 82,000 / 38,000 with 2,063 raw and |
| 197 | 197 | * 864 gzipped to spare, so the ceiling did not move. Neither other bundle |
| 198 | 198 | * changed a byte: nothing outside `src/client/hud.ts` did. |
| 199 | * #142 put the first grenade in the game — `src/client/grenade.ts`, a thrown | |
| 200 | * body the engine's own solver moves, its mesh, its three sounds and the blast | |
| 201 | * that reaches only what it can see — and the app grew 4,199 raw / 1,404 | |
| 202 | * gzipped bytes for it on top of the HUD move above: the module itself, the | |
| 203 | * grenade in the weapon table, the pocket and the throw in `world.ts`, the | |
| 204 | * body and the star of light in `scene.ts`, the recipes in `audio.ts` and the | |
| 205 | * key in `binding.ts`. It did not fit 82,000/38,000 — it passed the gzip | |
| 206 | * ceiling by 578 bytes — and the ceiling is raised here to 88,000/40,500, | |
| 207 | * today's 84,136 raw / 38,540 gzipped plus about five per cent, which is room | |
| 208 | * for an honest feature and not room for a stray import of something large. | |
| 209 | * The range did not move a byte. The example is 9,735 raw again, the same | |
| 210 | * number to the byte, and differs only in which short names the minifier | |
| 211 | * handed out over a module graph that changed shape — it ships no grenade, no | |
| 212 | * world and no weapons at all. Measured 2026-09-20 after #142 was merged with | |
| 213 | * #152 and #158, on the bundles this branch commits. | |
| 199 | 214 | */ |
| 200 | 215 | export const GAMES: readonly Game[] = [ |
| 201 | 216 | { |
| @@ -203,7 +218,7 @@ export const GAMES: readonly Game[] = [ | ||
| 203 | 218 | href: "/", |
| 204 | 219 | label: "cs", |
| 205 | 220 | entry: "src/client/main.ts", |
| 206 | ceiling: { raw: 82_000, gzip: 38_000 }, | |
| 221 | ceiling: { raw: 88_000, gzip: 40_500 }, | |
| 207 | 222 | blurb: |
| 208 | 223 | "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.", |
| 209 | 224 | }, |
src/pages/game.ts +6 −2
| @@ -90,7 +90,8 @@ const menu = (visitor: Who): string => ` | ||
| 90 | 90 | to your site is the walk to theirs.</p> |
| 91 | 91 | <p class="hint" id="prep">Preparing textures…</p> |
| 92 | 92 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 93 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · | |
| 93 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw a grenade · | |
| 94 | <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · | |
| 94 | 95 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 95 | 96 | <kbd>Esc</kbd> pause</p> |
| 96 | 97 | <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player, |
| @@ -121,6 +122,7 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions | ||
| 121 | 122 | ], |
| 122 | 123 | [ |
| 123 | 124 | { action: "reload", label: "reload" }, |
| 125 | { action: "grenade", label: "nade" }, | |
| 124 | 126 | { action: "buy", label: "buy" }, |
| 125 | 127 | { action: "fire", label: "fire" }, |
| 126 | 128 | ], |
| @@ -137,7 +139,9 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions | ||
| 137 | 139 | <h2>What this is</h2> |
| 138 | 140 | <p>A defusal shooter written for the browser: WebGL2 drawn by hand, Source-style movement |
| 139 | 141 | (friction, air acceleration, 18-unit steps, strafe jumping), CS damage with armour and |
| 140 | hitgroups, fixed spray patterns you can learn, a round clock, a bomb and the buy economy. | |
| 142 | hitgroups, fixed spray patterns you can learn, a round clock, a bomb, the buy economy and an | |
| 143 | HE grenade that bounces off the walls on the same solver the players walk on — 98 damage at | |
| 144 | the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see. | |
| 141 | 145 | Everything you see is generated — the map, the textures, the models and the sounds — so the |
| 142 | 146 | whole game is one small download and no asset files.</p> |
| 143 | 147 | <p>The bots path to a site and plant or defuse on their own, but they do not aim by rule: |
static/app.024b05a3b5.js deleted −58
Diff not shown — a line 62,697 characters wide (limit 2,000). −58
View filestatic/app.d5f3ab2fca.js added +58
Diff not shown — a line 66,771 characters wide (limit 2,000). +58
View filestatic/example.f6197b684a.js → static/example.d8198a26c0.js renamed +1 −1
Diff not shown — a line 9,725 characters wide (limit 2,000). +1 −1
View file
new above
offline