Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The second grenade is a flash: it blinds what can see it, and it proves one utility was not a special case #172
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/164-the-second-grenade-is-a-flash-it-blinds → main 2 commits fork
26 files
changed +975
−228
README.md +4 −1
| @@ -31,6 +31,8 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 31 | 31 | - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style |
| 32 | 32 | movement (ground friction, air acceleration, an 18-unit step, strafe jumping), |
| 33 | 33 | CS damage with range falloff, armour and hitgroups, deterministic spray patterns, |
| 34 | two thrown grenades — an HE, and a flashbang that blinds by how much of it you | |
| 35 | could see (the line, the angle and the distance, for at most 1.6 seconds) — | |
| 34 | 36 | a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and |
| 35 | 37 | loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`. |
| 36 | 38 | - **Two kinds of map.** `de_forge` is drawn by hand, cell by cell; the other |
| @@ -103,7 +105,8 @@ whatever the name field said, and a result posted without one is a **guest**. | ||
| 103 | 105 | |
| 104 | 106 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
| 105 | 107 | <kbd>Shift</kbd> walk, mouse aims and fires, <kbd>R</kbd> reload, |
| 106 | <kbd>G</kbd> throws the HE grenade in your pocket, if you bought one, | |
| 108 | <kbd>G</kbd> throws the HE grenade in your pocket and <kbd>T</kbd> the | |
| 109 | flashbang, if you bought them, | |
| 107 | 110 | <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze |
| 108 | 111 | time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows |
| 109 | 112 | the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and |
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: 88_000, gzip: 40_500 } }, | |
| 40 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 92_000, gzip: 42_000 } }, | |
| 41 | 41 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } }, |
| 42 | 42 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } }, |
| 43 | 43 | ]); |
src/client/action.ts +3 −2
| @@ -65,8 +65,9 @@ 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 | // A bot throws nothing: it buys neither grenade, and `null` here is the | |
| 69 | // field's own "neither", which `applyInput` reads as no press at all. | |
| 70 | throwing: null, | |
| 70 | 71 | switchTo, |
| 71 | 72 | }; |
| 72 | 73 | } |
src/client/audio.ts +17 −1
| @@ -11,7 +11,12 @@ import { WEAPONS, type WeaponId } from "./weapons.ts"; | ||
| 11 | 11 | |
| 12 | 12 | export type Audio = { |
| 13 | 13 | shot(weapon: WeaponId, at: Vec3, listener: Vec3): void; |
| 14 | /** The three noises one grenade makes: leaving the hand, landing, going off. */ | |
| 14 | /** | |
| 15 | * The noises a grenade makes: leaving the hand, landing, and going off as | |
| 16 | * whichever kind it was — `blast` for the HE, `flash` for the flashbang. One | |
| 17 | * path widened by a name rather than a second voice, because the first two | |
| 18 | * are the same noise whatever is in the shell. | |
| 19 | */ | |
| 15 | 20 | grenade(sound: GrenadeSound, at: Vec3, listener: Vec3): void; |
| 16 | 21 | hit(headshot: boolean): void; |
| 17 | 22 | beep(pitch: number, gain?: number): void; |
| @@ -89,6 +94,17 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio { | ||
| 89 | 94 | device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08 }); |
| 90 | 95 | return; |
| 91 | 96 | } |
| 97 | if (sound === "flash") { | |
| 98 | // The flash: the opposite noise to the blast in every way. A bright, | |
| 99 | // tight crack with no body under it and a ring above it that climbs | |
| 100 | // rather than falls — what a very loud, very short bang leaves in your | |
| 101 | // ears. It carries as far as the blast does, and it matters over a | |
| 102 | // wider circle than one, because so does the light. | |
| 103 | const bright = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE); | |
| 104 | device.noiseBurst({ frequency: 5200, gain: 0.55 * bright, length: 0.28, q: 1.2, rate: 1.5 }); | |
| 105 | device.tone({ frequency: 2600, slideTo: 4200, gain: 0.3 * bright, length: 0.5 }); | |
| 106 | return; | |
| 107 | } | |
| 92 | 108 | // The blast: a wide, low body of noise with a thump under it that falls |
| 93 | 109 | // an octave and a half as it goes. |
| 94 | 110 | const volume = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE); |
src/client/binding.test.ts +2 −1
| @@ -18,12 +18,13 @@ test("the bindings are the ones the keyboard has always had", () => { | ||
| 18 | 18 | expect(CS_KEYS.KeyE).toBe("use"); |
| 19 | 19 | expect(CS_KEYS.KeyB).toBe("buy"); |
| 20 | 20 | expect(CS_KEYS.KeyG).toBe("grenade"); |
| 21 | expect(CS_KEYS.KeyT).toBe("flash"); | |
| 21 | 22 | expect(CS_KEYS.Tab).toBe("scoreboard"); |
| 22 | 23 | expect(CS_KEYS.Digit1).toBe("slot1"); |
| 23 | 24 | expect(CS_KEYS.Digit3).toBe("slot3"); |
| 24 | 25 | expect(CS_MOUSE[0]).toBe("fire"); |
| 25 | 26 | expect(CS_MOUSE[2]).toBe("scope"); |
| 26 | expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade"]); | |
| 27 | expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash"]); | |
| 27 | 28 | // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks |
| 28 | 29 | // the browser for the screen, and nothing about an `Input` changes for it. |
| 29 | 30 | expect(CS_KEYS.KeyF).toBe("fullscreen"); |
src/client/binding.ts +5 −3
| @@ -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", KeyG: "grenade", KeyB: "buy", Tab: "scoreboard", | |
| 16 | KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", 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", "grenade"] as const; | |
| 27 | export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash"] as const; | |
| 28 | 28 | |
| 29 | 29 | export type Intent = { |
| 30 | 30 | input: Input; |
| @@ -51,7 +51,9 @@ 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 | // Two pockets, two keys. Both held is the flash, because the one you just | |
| 55 | // reached for is the one you meant; releasing it throws the HE still held. | |
| 56 | throwing: down("flash") ? "flash" : down("grenade") ? "he" : null, | |
| 55 | 57 | switchTo: null, |
| 56 | 58 | }; |
| 57 | 59 | return { |
src/client/bots.test.ts +63 −1
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { applyIntent, botThink, CARD, makeBrain, policy, think } from "./bots.ts"; | |
| 2 | import { applyIntent, blindness, botThink, CARD, flashed, makeBrain, policy, think } from "./bots.ts"; | |
| 3 | import { OBS_SIZE } from "./observe.ts"; | |
| 4 | import { FLASH } from "./weapons.ts"; | |
| 3 | 5 | import { DE_FORGE } from "./map.ts"; |
| 4 | 6 | import { COMPETITIVE } from "./match.ts"; |
| 5 | 7 | import { aimAt, bodyCentre, createWorld, type Player } from "./world.ts"; |
| @@ -89,3 +91,63 @@ test("the card describes a real training run", () => { | ||
| 89 | 91 | expect(CARD.winRate).toBeLessThanOrEqual(100); |
| 90 | 92 | expect(CARD.trainedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); |
| 91 | 93 | }); |
| 94 | ||
| 95 | test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, blinder", () => { | |
| 96 | const { world, me, bot } = duel(); | |
| 97 | const brain = bot.brain!; | |
| 98 | const clear = flashed(bot, brain); | |
| 99 | // Unflashed it is exactly the skill it always was — the same numbers, to the | |
| 100 | // bit, which is why every pinned run in this repository is untouched by this. | |
| 101 | expect(blindness(bot)).toBe(0); | |
| 102 | expect(clear.aimSpeed).toBe(brain.aimSpeed); | |
| 103 | expect(clear.aimError).toBe(brain.aimError); | |
| 104 | expect(clear.reaction).toBe(brain.reaction); | |
| 105 | ||
| 106 | bot.blind = FLASH.flash.maxBlind; | |
| 107 | const hit = flashed(bot, brain); | |
| 108 | expect(blindness(bot)).toBe(1); | |
| 109 | expect(hit.aimSpeed).toBeCloseTo(brain.aimSpeed * 0.2, 6); | |
| 110 | expect(hit.aimError).toBeCloseTo(brain.aimError * 7, 6); | |
| 111 | expect(hit.reaction).toBeCloseTo(brain.reaction * 4, 6); | |
| 112 | expect(hit.sight).toBeLessThan(clear.sight); | |
| 113 | // Half way through the flash it is half of each: the number in the world is | |
| 114 | // the only thing that decides, and it is the same one the HUD paints. | |
| 115 | bot.blind = FLASH.flash.maxBlind / 2; | |
| 116 | expect(flashed(bot, brain).aimSpeed).toBeCloseTo(brain.aimSpeed * 0.6, 6); | |
| 117 | ||
| 118 | // Its head is untouched: the observation, the heads and the weights know | |
| 119 | // nothing about any of this. | |
| 120 | expect(OBS_SIZE).toBe(32); | |
| 121 | expect(brain.obs.length).toBe(OBS_SIZE); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a flashed bot turns slower and shoots later than the same bot unflashed", () => { | |
| 125 | const turn = (blind: number): number => { | |
| 126 | const { world, me, bot } = duel(); | |
| 127 | bot.blind = blind; | |
| 128 | bot.yaw = 90; // ninety degrees off the target | |
| 129 | let fired = 0; | |
| 130 | for (let t = 0; t < 64; t++) { | |
| 131 | const input = botThink(world, bot, 1 / 64); | |
| 132 | world.time += 1 / 64; | |
| 133 | if (input.fire) fired += 1; | |
| 134 | } | |
| 135 | return Math.abs(bot.yaw - 90) - fired * 1e-6; | |
| 136 | }; | |
| 137 | // A second of the same duel, from the same seed, with and without a flash on | |
| 138 | // it: the blinded one has come nowhere near as far round towards the shot. | |
| 139 | expect(turn(FLASH.flash.maxBlind)).toBeLessThan(turn(0)); | |
| 140 | ||
| 141 | // And a bot blinded at the far end of the map does not pick a fight it cannot | |
| 142 | // see: its sight shrinks with the blindness. | |
| 143 | const blinded = duel(); | |
| 144 | blinded.bot.blind = FLASH.flash.maxBlind; | |
| 145 | botThink(blinded.world, blinded.bot, 1 / 64); | |
| 146 | expect(flashed(blinded.bot, blinded.bot.brain!).sight).toBeLessThan(500); | |
| 147 | expect(blinded.bot.brain!.targetId).toBe(null); | |
| 148 | // The same bot, the same room, the same enemy five hundred units away, with | |
| 149 | // its eyes working: it picks the fight it just could not see. | |
| 150 | const seeing = duel(); | |
| 151 | botThink(seeing.world, seeing.bot, 1 / 64); | |
| 152 | expect(seeing.bot.brain!.targetId).toBe(seeing.me.id); | |
| 153 | }); |
src/client/bots.ts +49 −9
| @@ -19,7 +19,7 @@ import { | ||
| 19 | 19 | import { colOf, findPath, rowOf, siteAt, walkable, type Level } from "./level.ts"; |
| 20 | 20 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| 21 | 21 | import { CARD, PACKED } from "./weights.ts"; |
| 22 | import { WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 22 | import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 23 | 23 | import type { Input, Player, World } from "./world.ts"; |
| 24 | 24 | import { aimAt, bodyCentre, buy, canSee, NO_INPUT } from "./world.ts"; |
| 25 | 25 | |
| @@ -93,6 +93,41 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 93 | 93 | }; |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | // --- blinded -------------------------------------------------------------- | |
| 97 | ||
| 98 | /** | |
| 99 | * How badly this bot is flashed, 0 to 1: the seconds of blindness it has left | |
| 100 | * over the ceiling a flash can hand out. A bot does not see a white screen — | |
| 101 | * there is no screen — so what a flash costs it is written here, in the same | |
| 102 | * skill layer its reaction and its shaky hands already live in. | |
| 103 | * | |
| 104 | * Not flashed, this is exactly 0 and every number below is multiplied by | |
| 105 | * exactly 1, which is why an unflashed bot plays the same game to the bit and | |
| 106 | * the training arena's digest does not move for any of this. | |
| 107 | */ | |
| 108 | export const blindness = (bot: Player): number => clamp(bot.blind / FLASH.flash.maxBlind, 0, 1); | |
| 109 | ||
| 110 | /** | |
| 111 | * What a flash leaves a bot with. Fully blind it turns at a fifth of its usual | |
| 112 | * speed, its hands wander seven times as far off centre, it takes four times as | |
| 113 | * long to decide to shoot at all, and it only notices an enemy inside a | |
| 114 | * twelfth of its usual sight — which is roughly what a person flashed in the | |
| 115 | * face does, and is the whole of the disadvantage. Its policy is untouched: | |
| 116 | * `OBS_SIZE`, the heads and the shipped weights know nothing about this. | |
| 117 | */ | |
| 118 | export function flashed(bot: Player, brain: BotBrain): { aimSpeed: number; aimError: number; reaction: number; sight: number } { | |
| 119 | const b = blindness(bot); | |
| 120 | return { | |
| 121 | aimSpeed: brain.aimSpeed * (1 - 0.8 * b), | |
| 122 | aimError: brain.aimError * (1 + 6 * b), | |
| 123 | reaction: brain.reaction * (1 + 3 * b), | |
| 124 | sight: SIGHT * (1 - 0.92 * b), | |
| 125 | }; | |
| 126 | } | |
| 127 | ||
| 128 | /** How far a bot notices an enemy at all, in units, with its eyes working. */ | |
| 129 | const SIGHT = 4000; | |
| 130 | ||
| 96 | 131 | /** What a bot spends its money on, roughly in the order a person would. */ |
| 97 | 132 | export function botBuy(world: World, bot: Player): void { |
| 98 | 133 | const brain = brainOf(world, bot); |
| @@ -145,10 +180,13 @@ const nearby = (level: Level, brain: BotBrain, at: Vec3, spread: number): Vec3 = | ||
| 145 | 180 | function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null { |
| 146 | 181 | let best: Player | null = null; |
| 147 | 182 | let bestScore = Infinity; |
| 183 | // Flashed, a bot picks its fights out of a much smaller room: this is the | |
| 184 | // "choice" half of what a flash costs it, and the aim below is the other. | |
| 185 | const sight = flashed(bot, brain).sight; | |
| 148 | 186 | for (const other of world.players) { |
| 149 | 187 | if (!other.alive || other.team === bot.team) continue; |
| 150 | 188 | const d = dist(bot.pos, other.pos); |
| 151 | if (d > 4000) continue; | |
| 189 | if (d > sight) continue; | |
| 152 | 190 | if (!canSee(world, bot, other)) continue; |
| 153 | 191 | const aim = aimAt(bot, bodyCentre(other)); |
| 154 | 192 | const off = Math.abs(angleDelta(bot.yaw, aim.yaw)); |
| @@ -179,12 +217,13 @@ function listen(world: World, bot: Player, brain: BotBrain): void { | ||
| 179 | 217 | export function scriptedFight( |
| 180 | 218 | world: World, bot: Player, brain: BotBrain, target: Player, input: Input, dt: number, |
| 181 | 219 | ): Input { |
| 220 | const hands = flashed(bot, brain); | |
| 182 | 221 | const spraying = bot.shotIndex > 0 && world.time - bot.lastShotAt < 0.3; |
| 183 | const wobble = brain.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15; | |
| 222 | const wobble = hands.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15; | |
| 184 | 223 | const aim = aimAt(bot, bodyCentre(target)); |
| 185 | turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, brain.aimSpeed, dt); | |
| 186 | const ready = world.time - brain.seenAt > brain.reaction; | |
| 187 | const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + brain.aimError; | |
| 224 | turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, hands.aimSpeed, dt); | |
| 225 | const ready = world.time - brain.seenAt > hands.reaction; | |
| 226 | const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + hands.aimError; | |
| 188 | 227 | const ammo = bot.ammo[bot.weapon]; |
| 189 | 228 | if (ammo !== undefined && ammo.mag === 0) input.reload = true; |
| 190 | 229 | if (ready && onTarget && world.time > brain.restUntil) { |
| @@ -236,11 +275,12 @@ export function think(world: World, bot: Player, brain: BotBrain, target: Player | ||
| 236 | 275 | * is a bot you can beat; the same policy with steady hands is not. |
| 237 | 276 | */ |
| 238 | 277 | export function applyIntent(world: World, bot: Player, brain: BotBrain, intent: Intent, dt: number): void { |
| 278 | const hands = flashed(bot, brain); | |
| 239 | 279 | if (world.time > brain.jitterUntil) { |
| 240 | brain.jitter = (brain.random() * 2 - 1) * brain.aimError; | |
| 280 | brain.jitter = (brain.random() * 2 - 1) * hands.aimError; | |
| 241 | 281 | brain.jitterUntil = world.time + 0.25 + brain.random() * 0.35; |
| 242 | 282 | } |
| 243 | const limit = brain.aimSpeed; | |
| 283 | const limit = hands.aimSpeed; | |
| 244 | 284 | bot.yaw += clamp(intent.yawRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied); |
| 245 | 285 | bot.pitch = clamp( |
| 246 | 286 | bot.pitch + clamp(intent.pitchRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied) * 0.4, |
| @@ -303,7 +343,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 303 | 343 | // that is one shot and then nothing, so the trigger is released between |
| 304 | 344 | // decisions and each decision is worth one pull. |
| 305 | 345 | const held = WEAPONS[bot.weapon]?.automatic !== false || brain.hold === SKIP - 1; |
| 306 | input.fire = intent.fire && held && world.time - brain.seenAt > brain.reaction; | |
| 346 | input.fire = intent.fire && held && world.time - brain.seenAt > flashed(bot, brain).reaction; | |
| 307 | 347 | input.forward = intent.forward; |
| 308 | 348 | input.side = intent.side; |
| 309 | 349 | input.walk = intent.walk; |
src/client/generate.test.ts +8 −5
| @@ -215,12 +215,15 @@ test("a bot walks a map nobody wrote, on that map's own cells", () => { | ||
| 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 | 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. | |
| 218 | * Re-pinned for #164 with the other two: the snapshot in `replay.ts` now | |
| 219 | * carries both pockets, how long each body is blinded for and which kind each | |
| 220 | * live grenade is, so every digest in this repository moved with it. The map | |
| 221 | * and the play are untouched — the assertions under this one still find the | |
| 222 | * same spawns on the same cells, and the same ten seconds folded with the old | |
| 223 | * field list still comes to the digest this line held before, | |
| 224 | * 48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae. | |
| 222 | 225 | */ |
| 223 | const GENERATED_ROUND = "48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae"; | |
| 226 | const GENERATED_ROUND = "d7bd4969d00345feff718f37a6ed13ff6967f8884ca716bc639bff63f085d82e"; | |
| 224 | 227 | |
| 225 | 228 | test("a seeded round on a generated map plays exactly as it played", () => { |
| 226 | 229 | const level = generateLevel(5); |
src/client/grenade.test.ts +229 −19
| @@ -17,17 +17,20 @@ | ||
| 17 | 17 | import { expect, test } from "bun:test"; |
| 18 | 18 | import { DE_FORGE } from "./map.ts"; |
| 19 | 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"; | |
| 20 | import { | |
| 21 | blastCentre, blastHits, flashHits, MIN_BLIND, stepGrenade, throwGrenade, THROW_LIFT, THROW_SPEED, | |
| 22 | viewAngle, type Looker, | |
| 23 | } from "./grenade.ts"; | |
| 24 | import { CS_HULL, NADE_HULL, NADE_MOVE } from "./movement.ts"; | |
| 25 | import { blastDamage, blindSeconds, FLASH, HE, WEAPONS } from "./weapons.ts"; | |
| 23 | 26 | import type { Actor } from "./combat.ts"; |
| 24 | 27 | import type { Box, Vec3 } from "../engine/index.ts"; |
| 25 | 28 | import { digest, TICK, withoutEntropy } from "./replay.ts"; |
| 26 | 29 | import { |
| 27 | buy, createWorld, eyePos, MAX_GRENADES, NO_INPUT, throwOne, update, | |
| 30 | buy, CARRY, createWorld, eyePos, NO_INPUT, throwOne, update, | |
| 28 | 31 | type Effect, type Input, type Player, type World, |
| 29 | 32 | } from "./world.ts"; |
| 30 | import { effectAt } from "../engine/index.ts"; | |
| 33 | import { effectAt, eyeHeight } from "../engine/index.ts"; | |
| 31 | 34 | |
| 32 | 35 | /** Did anything the world remembers look like this? The buffer is read by index. */ |
| 33 | 36 | function heard(world: World, is: (e: Effect) => boolean): boolean { |
| @@ -62,7 +65,7 @@ test("the grenade is in the buy menu with CS's numbers, and carries a blast", () | ||
| 62 | 65 | }); |
| 63 | 66 | |
| 64 | 67 | test("a grenade thrown level flies where the arithmetic says", () => { |
| 65 | const g = throwGrenade(0, "ct", [0, 0, 64], EAST, [FLOOR]); | |
| 68 | const g = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR]); | |
| 66 | 69 | // It leaves the hand eighteen units ahead of the eye, at 750 u/s. |
| 67 | 70 | expect(g.pos).toEqual([18, 0, 64]); |
| 68 | 71 | expect(g.vel).toEqual([THROW_SPEED, 0, 0]); |
| @@ -80,7 +83,7 @@ test("a grenade thrown level flies where the arithmetic says", () => { | ||
| 80 | 83 | }); |
| 81 | 84 | |
| 82 | 85 | test("a grenade thrown at a wall comes back with 0.45 of what it arrived with", () => { |
| 83 | const g = throwGrenade(0, "ct", [0, 0, 64], EAST, [FLOOR, EAST_WALL]); | |
| 86 | const g = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR, EAST_WALL]); | |
| 84 | 87 | let bounces = 0; |
| 85 | 88 | let after = 0; |
| 86 | 89 | for (let n = 0; n < 64 && bounces === 0; n++) { |
| @@ -97,7 +100,7 @@ test("a grenade thrown at a wall comes back with 0.45 of what it arrived with", | ||
| 97 | 100 | }); |
| 98 | 101 | |
| 99 | 102 | test("a grenade dropped on the floor bounces, then comes to rest on it", () => { |
| 100 | const g = throwGrenade(0, "ct", [0, 0, 200], [0, 0, -1], [FLOOR]); | |
| 103 | const g = throwGrenade(0, "ct", "he", [0, 0, 200], [0, 0, -1], [FLOOR]); | |
| 101 | 104 | let bounces = 0; |
| 102 | 105 | for (let n = 0; n < 64 * 6; n++) if (stepGrenade(g, TICK, [FLOOR])) bounces += 1; |
| 103 | 106 | expect(bounces).toBeGreaterThan(0); |
| @@ -119,7 +122,7 @@ test("the blast is 98 at the centre, 49 at half the radius, and armour halves it | ||
| 119 | 122 | // The grenade at the feet of a body standing at the origin: the centre of |
| 120 | 123 | // the blast is 2 units up and the middle of the chest is 54, so 52 units is |
| 121 | 124 | // as close as a standing body gets — 98 · (1 − 52/350) = 83.4. |
| 122 | const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 125 | const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 123 | 126 | const at = (x: number, over: Partial<Actor> = {}): { health: number; armor: number } => { |
| 124 | 127 | const hits = blastHits(g, [actor([x, 0, 0], over)], []); |
| 125 | 128 | return { health: hits[0]?.health ?? 0, armor: hits[0]?.armor ?? 0 }; |
| @@ -145,7 +148,7 @@ test("the blast is 98 at the centre, 49 at half the radius, and armour halves it | ||
| 145 | 148 | }); |
| 146 | 149 | |
| 147 | 150 | test("a grenade behind a crate hurts nobody on the far side", () => { |
| 148 | const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 151 | const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 149 | 152 | const behind = actor([200, 0, 0]); |
| 150 | 153 | // In the open, 200 units is well inside the radius and it hurts. |
| 151 | 154 | expect(blastHits(g, [behind], [FLOOR])[0]?.health).toBeGreaterThan(0); |
| @@ -157,7 +160,7 @@ test("a grenade behind a crate hurts nobody on the far side", () => { | ||
| 157 | 160 | }); |
| 158 | 161 | |
| 159 | 162 | test("the blast does not reach the thrower's own side", () => { |
| 160 | const g = throwGrenade(0, "ct", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 163 | const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 161 | 164 | expect(blastHits(g, [actor([0, 0, 0], { team: "ct" })], [FLOOR])).toEqual([]); |
| 162 | 165 | expect(blastHits(g, [actor([0, 0, 0], { team: "t" })], [FLOOR]).length).toBe(1); |
| 163 | 166 | }); |
| @@ -181,7 +184,7 @@ test("a grenade is bought like anything else, one at a time, and goes in a pocke | ||
| 181 | 184 | world.me.money = 5000; |
| 182 | 185 | const before = world.me.weapon; |
| 183 | 186 | expect(buy(world, world.me, "he")).toBe(true); |
| 184 | expect(world.me.grenades).toBe(MAX_GRENADES); | |
| 187 | expect(world.me.pocket.he).toBe(CARRY.he); | |
| 185 | 188 | expect(world.me.money).toBe(5000 - HE.price); |
| 186 | 189 | // It did not become the gun in your hands and it did not displace one. |
| 187 | 190 | expect(world.me.weapon).toBe(before); |
| @@ -195,17 +198,17 @@ test("the throw key throws one, once a press, and never during the freeze", () = | ||
| 195 | 198 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 12); |
| 196 | 199 | world.me.money = 5000; |
| 197 | 200 | buy(world, world.me, "he"); |
| 198 | const held: Input = { ...NO_INPUT, throwGrenade: true }; | |
| 201 | const held: Input = { ...NO_INPUT, throwing: "he" }; | |
| 199 | 202 | // Frozen: the key does nothing and the grenade stays in the pocket. |
| 200 | 203 | update(world, TICK, held); |
| 201 | 204 | expect(world.grenades.length).toBe(0); |
| 202 | expect(world.me.grenades).toBe(1); | |
| 205 | expect(world.me.pocket.he).toBe(1); | |
| 203 | 206 | |
| 204 | 207 | for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT); |
| 205 | 208 | // Live, and held down for half a second: exactly one leaves the hand. |
| 206 | 209 | for (let t = 0; t < 32; t++) update(world, TICK, held); |
| 207 | 210 | expect(world.grenades.length).toBe(1); |
| 208 | expect(world.me.grenades).toBe(0); | |
| 211 | expect(world.me.pocket.he).toBe(0); | |
| 209 | 212 | expect(world.grenades[0]?.owner).toBe(world.me.id); |
| 210 | 213 | // And the throw was heard where the hand was. |
| 211 | 214 | expect(heard(world, (e) => e.kind === "sound" && e.name === "throw")).toBe(true); |
| @@ -311,7 +314,7 @@ test("a round with a grenade in it is still bit-for-bit reproducible", () => { | ||
| 311 | 314 | // A script that buys one in the freeze and throws it the moment it is live. |
| 312 | 315 | const script = [ |
| 313 | 316 | { atTick: 0, input: {}, buy: "he" as const }, |
| 314 | { atTick: 5 * TICKS_PER_SECOND, input: { throwGrenade: true } }, | |
| 317 | { atTick: 5 * TICKS_PER_SECOND, input: { throwing: "he" as const } }, | |
| 315 | 318 | { atTick: 5 * TICKS_PER_SECOND + 8, input: {} }, |
| 316 | 319 | ]; |
| 317 | 320 | const run = (): ReturnType<typeof digest> => |
| @@ -332,12 +335,219 @@ test("a round with a grenade in it is still bit-for-bit reproducible", () => { | ||
| 332 | 335 | |
| 333 | 336 | test("an input that never sets the throw plays exactly as it did before", () => { |
| 334 | 337 | // The whole of what a bot had to learn for this feature: nothing. |
| 335 | expect(NO_INPUT.throwGrenade).toBe(false); | |
| 338 | expect(NO_INPUT.throwing).toBe(null); | |
| 336 | 339 | const world = ready(51); |
| 337 | world.me.grenades = 1; | |
| 340 | world.me.pocket.he = 1; | |
| 338 | 341 | const before = eyePos(world.me); |
| 339 | 342 | for (let t = 0; t < 64; t++) update(world, TICK, NO_INPUT); |
| 340 | 343 | expect(world.grenades.length).toBe(0); |
| 341 | expect(world.me.grenades).toBe(1); | |
| 344 | expect(world.me.pocket.he).toBe(1); | |
| 342 | 345 | expect(before.length).toBe(3); |
| 343 | 346 | }); |
| 347 | ||
| 348 | // --- the flash ------------------------------------------------------------ | |
| 349 | ||
| 350 | /** A body at `pos` looking along `yaw`/`pitch`: what a flash needs to see. */ | |
| 351 | const looker = (pos: Vec3, over: Partial<Looker> = {}): Looker => ({ | |
| 352 | ...actor(pos), pitch: 0, ...over, | |
| 353 | }); | |
| 354 | ||
| 355 | /** The height a standing body's eye is at: what a flash is measured to. */ | |
| 356 | const EYE = eyeHeight({ duckAmount: 0 }, CS_HULL); | |
| 357 | ||
| 358 | /** | |
| 359 | * A flashbang lying still with its centre at `where`, out of nobody's hand. | |
| 360 | * The tests below put it at eye height so the distances are the round numbers | |
| 361 | * they are written as rather than a hypotenuse nobody can check by hand. | |
| 362 | */ | |
| 363 | const lit = (where: Vec3): ReturnType<typeof throwGrenade> => | |
| 364 | throwGrenade(0, "ct", "flash", [where[0], where[1], where[2] - NADE_HULL.stand / 2], [0, 0, 0], [FLOOR]); | |
| 365 | ||
| 366 | test("a flashbang is a grenade like the other one: same flight, its own fuse", () => { | |
| 367 | const he = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR]); | |
| 368 | const flash = throwGrenade(0, "ct", "flash", [0, 0, 64], EAST, [FLOOR]); | |
| 369 | expect(flash.weapon).toBe("flash"); | |
| 370 | expect(flash.fuse).toBe(FLASH.flash.fuse); | |
| 371 | expect(he.fuse).toBe(HE.blast.fuse); | |
| 372 | // The same body through the same solver: sixteen ticks apart they are still | |
| 373 | // in exactly the same place, because nothing about the flight is the kind's. | |
| 374 | for (let n = 0; n < 16; n++) { | |
| 375 | stepGrenade(he, TICK, [FLOOR]); | |
| 376 | stepGrenade(flash, TICK, [FLOOR]); | |
| 377 | } | |
| 378 | expect(flash.pos).toEqual(he.pos); | |
| 379 | expect(flash.vel).toEqual(he.vel); | |
| 380 | // And it does no damage to anybody, however close they stand. | |
| 381 | expect(blastHits(flash, [actor([0, 0, 0])], [FLOOR])).toEqual([]); | |
| 382 | // While the HE blinds nobody: the two ends are the only thing that differs. | |
| 383 | expect(flashHits(he, [looker([0, 0, 0])], [FLOOR])).toEqual([]); | |
| 384 | }); | |
| 385 | ||
| 386 | test("a flash blinds by how much of it you could see, and not at all with no line", () => { | |
| 387 | const g = lit([0, 0, EYE]); | |
| 388 | const seconds = (l: Looker, boxes: Box[] = [FLOOR]): number => | |
| 389 | flashHits(g, [l], boxes)[0]?.seconds ?? 0; | |
| 390 | const max = FLASH.flash.maxBlind; | |
| 391 | ||
| 392 | // Standing 300 units east, looking west straight at it: 1.6 · 1 · (1 − 300/1200). | |
| 393 | const staring = looker([300, 0, 0], { yaw: 180 }); | |
| 394 | expect(seconds(staring)).toBeCloseTo(max * 0.75, 6); | |
| 395 | ||
| 396 | // Looking away works: the same body turned round sees none of it. | |
| 397 | expect(seconds(looker([300, 0, 0], { yaw: 0 }))).toBe(0); | |
| 398 | // And half turned away — 90° off, which is 45/105 of the way from 30 to 135. | |
| 399 | expect(seconds(looker([300, 0, 0], { yaw: 90 }))).toBeCloseTo(max * (45 / 105) * 0.75, 6); | |
| 400 | ||
| 401 | // A wall works: `traceWorld` decides whether there was a line at all, and a | |
| 402 | // crate between the two takes every bit of it however squarely they looked. | |
| 403 | const crate: Box = { min: [100, -80, 0], max: [140, 80, 120], mat: 0 }; | |
| 404 | expect(seconds(staring, [FLOOR, crate])).toBe(0); | |
| 405 | // The man beside the crate, with nothing in the way, is blinded as before. | |
| 406 | expect(seconds(looker([300, 300, 0], { yaw: 225 }), [FLOOR, crate])).toBeGreaterThan(0); | |
| 407 | ||
| 408 | // Being behind it works: a flash at your back, with your view down the far | |
| 409 | // way, is over your shoulder and costs nothing however close it went off. | |
| 410 | expect(seconds(looker([-300, 0, 0], { yaw: 180 }))).toBe(0); | |
| 411 | expect(seconds(looker([-20, 0, 0], { yaw: 180 }))).toBe(0); | |
| 412 | ||
| 413 | // Being behind *someone* is not cover: a body is not a wall, and the trace | |
| 414 | // is against the world's boxes exactly like the blast's is. | |
| 415 | const shield = looker([150, 0, 0], { yaw: 180 }); | |
| 416 | expect(flashHits(g, [shield, staring], [FLOOR]).length).toBe(2); | |
| 417 | ||
| 418 | // Past the radius it is a light in another room. | |
| 419 | expect(seconds(looker([1300, 0, 0], { yaw: 180 }))).toBe(0); | |
| 420 | // And a flicker is not a blinding: anything under MIN_BLIND is no entry. | |
| 421 | expect(blindSeconds(FLASH.flash, 0, 1150)).toBeLessThan(MIN_BLIND); | |
| 422 | expect(flashHits(g, [looker([1150, 0, 0], { yaw: 180 })], [FLOOR])).toEqual([]); | |
| 423 | }); | |
| 424 | ||
| 425 | test("a flash does not care whose side you are on, and the dead keep their sight", () => { | |
| 426 | const g = lit([0, 0, EYE]); | |
| 427 | const mine = looker([200, 0, 0], { yaw: 180, team: "ct" }); // the thrower's own side | |
| 428 | const theirs = looker([200, 0, 0], { yaw: 180, team: "t" }); | |
| 429 | const dead = looker([200, 0, 0], { yaw: 180, team: "t", alive: false }); | |
| 430 | expect(flashHits(g, [mine], [FLOOR]).length).toBe(1); | |
| 431 | expect(flashHits(g, [theirs], [FLOOR]).length).toBe(1); | |
| 432 | expect(flashHits(g, [dead], [FLOOR])).toEqual([]); | |
| 433 | // The same two under the HE: it spares the thrower's team and hurts the other. | |
| 434 | const he = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]); | |
| 435 | expect(blastHits(he, [mine], [FLOOR])).toEqual([]); | |
| 436 | expect(blastHits(he, [theirs], [FLOOR]).length).toBe(1); | |
| 437 | }); | |
| 438 | ||
| 439 | test("the angle a flash is judged at is the whole view, not the yaw", () => { | |
| 440 | const overhead: Vec3 = [0, 0, EYE + 200]; | |
| 441 | const g = lit(overhead); | |
| 442 | const below = looker([0, 0, 0]); | |
| 443 | // It went off straight overhead: looking up is looking at it, and looking | |
| 444 | // level is ninety degrees off it whichever way the body is turned. | |
| 445 | expect(viewAngle({ ...below, pitch: -90 }, overhead)).toBeCloseTo(0, 6); | |
| 446 | expect(viewAngle({ ...below, pitch: 0 }, overhead)).toBeCloseTo(90, 6); | |
| 447 | expect(viewAngle({ ...below, pitch: 0, yaw: 90 }, overhead)).toBeCloseTo(90, 6); | |
| 448 | const up = flashHits(g, [{ ...below, pitch: -90 }], [FLOOR])[0]?.seconds ?? 0; | |
| 449 | const level = flashHits(g, [{ ...below, pitch: 0 }], [FLOOR])[0]?.seconds ?? 0; | |
| 450 | expect(up).toBeGreaterThan(level); | |
| 451 | expect(up).toBeCloseTo(FLASH.flash.maxBlind * (1 - 200 / FLASH.flash.radius), 6); | |
| 452 | }); | |
| 453 | ||
| 454 | test("a flash is bought alongside the HE, one of each, and thrown on its own key", () => { | |
| 455 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 13); | |
| 456 | world.me.money = 5000; | |
| 457 | expect(buy(world, world.me, "he")).toBe(true); | |
| 458 | expect(buy(world, world.me, "flash")).toBe(true); | |
| 459 | // Carried alongside, not instead of: both are in the pocket at once. | |
| 460 | expect(world.me.pocket).toEqual({ he: CARRY.he, flash: CARRY.flash }); | |
| 461 | expect(world.me.money).toBe(5000 - HE.price - FLASH.price); | |
| 462 | // One of each a round: the second of either is refused and costs nothing. | |
| 463 | expect(buy(world, world.me, "flash")).toBe(false); | |
| 464 | expect(buy(world, world.me, "he")).toBe(false); | |
| 465 | expect(world.me.money).toBe(5000 - HE.price - FLASH.price); | |
| 466 | // And neither went into your hands. | |
| 467 | expect(world.me.weapon).toBe(world.me.secondary); | |
| 468 | ||
| 469 | for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT); | |
| 470 | // The flash key throws the flash and leaves the HE where it was. | |
| 471 | for (let t = 0; t < 32; t++) update(world, TICK, { ...NO_INPUT, throwing: "flash" }); | |
| 472 | expect(world.grenades.length).toBe(1); | |
| 473 | expect(world.grenades[0]?.weapon).toBe("flash"); | |
| 474 | expect(world.me.pocket).toEqual({ he: 1, flash: 0 }); | |
| 475 | }); | |
| 476 | ||
| 477 | /** Everyone but `p` put out of the round, so only the flash can change anything. */ | |
| 478 | const alone = (world: World, p: Player): void => { | |
| 479 | for (const other of world.players) if (other !== p) other.alive = false; | |
| 480 | }; | |
| 481 | ||
| 482 | test("a flash that goes off blinds everyone looking at it, and the clock burns it down", () => { | |
| 483 | // One a side, and both bodies held still, alive and whole for the second and | |
| 484 | // a half the fuse burns: what is under test is who the flash reaches. | |
| 485 | const world = ready(37, 1); | |
| 486 | const me = world.me; | |
| 487 | const victim = world.players.find((p) => p.team === "t" && p.alive) as Player; | |
| 488 | // Standing next to each other in the open, looking at one another, with the | |
| 489 | // flash between them: what is under test is the blinding, not the throw. | |
| 490 | victim.pos = [me.pos[0] + 64, me.pos[1], me.pos[2]]; | |
| 491 | victim.yaw = 180; | |
| 492 | me.yaw = 0; | |
| 493 | const where: Vec3 = [me.pos[0] + 32, me.pos[1], me.pos[2] + EYE]; | |
| 494 | me.pocket.flash = 1; | |
| 495 | expect(throwOne(world, me, "flash")).toBe(true); | |
| 496 | for (let t = 0; t < 3 * TICKS_PER_SECOND && world.grenades.length > 0; t++) { | |
| 497 | for (const g of world.grenades) { | |
| 498 | g.pos = [where[0], where[1], where[2]]; | |
| 499 | g.vel = [0, 0, 0]; | |
| 500 | } | |
| 501 | victim.pos = [me.pos[0] + 64, me.pos[1], me.pos[2]]; | |
| 502 | victim.yaw = 180; | |
| 503 | victim.health = 100; | |
| 504 | me.health = 100; | |
| 505 | me.yaw = 0; | |
| 506 | update(world, TICK, NO_INPUT); | |
| 507 | } | |
| 508 | // Both of them were looking at it, so both are blinded: light is not on a side. | |
| 509 | expect(victim.blind).toBeGreaterThan(0); | |
| 510 | expect(me.blind).toBeGreaterThan(0); | |
| 511 | expect(me.blind).toBeLessThanOrEqual(FLASH.flash.maxBlind); | |
| 512 | // It was heard as a flash, not as a blast, and it drew its own pop. | |
| 513 | expect(heard(world, (e) => e.kind === "sound" && e.name === "flash")).toBe(true); | |
| 514 | expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(false); | |
| 515 | expect(heard(world, (e) => e.kind === "blast" && e.weapon === "flash")).toBe(true); | |
| 516 | // Nobody was hurt and nothing went in the feed: a flash is a chance, not a kill. | |
| 517 | expect(victim.health).toBe(100); | |
| 518 | expect(world.feed.length).toBe(0); | |
| 519 | ||
| 520 | // And it burns down on the simulation's clock, a tick at a time, to nothing. | |
| 521 | const left = me.blind; | |
| 522 | update(world, TICK, NO_INPUT); | |
| 523 | expect(me.blind).toBeCloseTo(left - TICK, 10); | |
| 524 | for (let t = 0; t < 3 * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT); | |
| 525 | expect(me.blind).toBe(0); | |
| 526 | expect(world.match.phase === "live" || world.match.phase === "over").toBe(true); | |
| 527 | }); | |
| 528 | ||
| 529 | test("two flashes in the face are not twice the blindness: neither passes the ceiling", () => { | |
| 530 | const world = ready(39, 1); | |
| 531 | const me = world.me; | |
| 532 | alone(world, me); | |
| 533 | me.yaw = 0; // looking straight at where each of them goes off, forty units out | |
| 534 | const max = FLASH.flash.maxBlind; | |
| 535 | /** Go off at the eye, and step until it is gone. */ | |
| 536 | const pop = (): void => { | |
| 537 | const g = throwGrenade( | |
| 538 | me.id, me.team, "flash", [me.pos[0] + 40, me.pos[1], me.pos[2] + EYE - NADE_HULL.stand / 2], [0, 0, 0], [], | |
| 539 | ); | |
| 540 | g.fuse = TICK; | |
| 541 | world.grenades.push(g); | |
| 542 | while (world.grenades.length > 0) update(world, TICK, NO_INPUT); | |
| 543 | }; | |
| 544 | pop(); | |
| 545 | const first = me.blind; | |
| 546 | expect(first).toBeGreaterThan(max * 0.9); | |
| 547 | expect(first).toBeLessThanOrEqual(max); | |
| 548 | // A second one on top of the first: the longer of the two, never the sum. | |
| 549 | pop(); | |
| 550 | expect(me.blind).toBeLessThanOrEqual(max); | |
| 551 | expect(me.blind).toBeGreaterThan(max * 0.9); | |
| 552 | expect(me.blind).toBeLessThan(first * 2); | |
| 553 | }); |
src/client/grenade.ts +100 −18
| @@ -1,5 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | * The thrown grenade: a body, a fuse and a blast. | |
| 2 | * The thrown grenade: a body, a fuse, and whatever it does when the fuse runs | |
| 3 | * out. Two kinds are thrown today — the HE and the flashbang — and the only | |
| 4 | * thing in this file that knows which is `Grenade.weapon`: the flight, the | |
| 5 | * bounce, the fuse and the sounds are one piece of machinery both use, and what | |
| 6 | * arrives at the far end is `blastHits` for one and `flashHits` for the other. | |
| 7 | * A third kind is a row in `weapons.ts` and a function here. | |
| 3 | 8 | * |
| 4 | 9 | * There is no solver in this file. A grenade in flight is a `Body` like a |
| 5 | 10 | * player is, and it is moved by the same `move` in `src/engine/physics.ts` — |
| @@ -12,20 +17,25 @@ | ||
| 12 | 17 | * 64 Hz `world.ts` steps at — so it is the same number of ticks on a phone as |
| 13 | 18 | * on a desktop, and the same number in a replay as in a match. |
| 14 | 19 | * |
| 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 | * Neither kind reaches anything it cannot see. `blastHits` and `flashHits` both | |
| 21 | * trace from the middle of the detonation through the engine's own `visible`, | |
| 22 | * so a wall, a crate or a corner takes all of it: a grenade cannot kill through | |
| 23 | * a wall here and a flash cannot blind through one, which are the properties | |
| 24 | * the two tests of that name hold. | |
| 20 | 25 | */ |
| 21 | 26 | |
| 22 | import { dist, move, stuck, visible, type Body, type Box, type Vec3, type Wish } from "../engine/index.ts"; | |
| 27 | import { | |
| 28 | angleVectors, clamp, dist, dot, eyeHeight, move, normalize, stuck, sub, visible, | |
| 29 | type Body, type Box, type Vec3, type Wish, | |
| 30 | } from "../engine/index.ts"; | |
| 23 | 31 | import { hitboxes, type Actor } from "./combat.ts"; |
| 24 | import { NADE_HULL, NADE_MOVE } from "./movement.ts"; | |
| 25 | import { blastDamage, HE, throughArmor } from "./weapons.ts"; | |
| 32 | import { CS_HULL, NADE_HULL, NADE_MOVE } from "./movement.ts"; | |
| 33 | import { blastDamage, blindSeconds, fuseOf, WEAPONS, throughArmor, type ThrownId } from "./weapons.ts"; | |
| 26 | 34 | |
| 27 | /** A grenade in the world: a body, whose it is, and how long it has left. */ | |
| 35 | /** A grenade in the world: a body, which kind it is, whose it is, and how long it has left. */ | |
| 28 | 36 | export type Grenade = Body & { |
| 37 | /** Which thrown weapon this is — what happens at zero on the fuse. */ | |
| 38 | weapon: ThrownId; | |
| 29 | 39 | /** The player id that threw it — who the kill is credited to. */ |
| 30 | 40 | owner: number; |
| 31 | 41 | team: "t" | "ct"; |
| @@ -54,8 +64,13 @@ export const THROW_LIFT = 10; | ||
| 54 | 64 | /** How far in front of the eye it appears, in units: out of the hand, not the face. */ |
| 55 | 65 | const HAND = 18; |
| 56 | 66 | |
| 57 | /** The sounds a grenade makes, in the order one round of it happens. */ | |
| 58 | export type GrenadeSound = "throw" | "bounce" | "blast"; | |
| 67 | /** | |
| 68 | * The sounds a grenade makes, in the order one round of it happens. The first | |
| 69 | * two belong to every thrown thing; the last of them is the kind's own — the | |
| 70 | * HE's `blast`, the flashbang's `flash` — which is why this is one widening | |
| 71 | * type and not a second one per grenade. | |
| 72 | */ | |
| 73 | export type GrenadeSound = "throw" | "bounce" | "blast" | "flash"; | |
| 59 | 74 | |
| 60 | 75 | /** |
| 61 | 76 | * Below this the knock of a bounce is not worth a sound: a grenade rolling to |
| @@ -65,14 +80,17 @@ export type GrenadeSound = "throw" | "bounce" | "blast"; | ||
| 65 | 80 | const HEARD = 90; |
| 66 | 81 | |
| 67 | 82 | /** |
| 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. | |
| 83 | * A grenade of kind `weapon` leaving a hand at `from` along `dir`. It appears a | |
| 84 | * little ahead of the eye so it is not thrown from inside the thrower's own head | |
| 85 | * — unless that would put it inside a wall, in which case it starts at the eye, | |
| 86 | * because a grenade that spawns inside geometry is a grenade that falls through | |
| 87 | * the map. The fuse is the kind's own and comes from the weapon table; nothing | |
| 88 | * about the flight does, which is why both kinds fly identically. | |
| 72 | 89 | */ |
| 73 | 90 | export function throwGrenade( |
| 74 | 91 | owner: number, |
| 75 | 92 | team: "t" | "ct", |
| 93 | weapon: ThrownId, | |
| 76 | 94 | from: Vec3, |
| 77 | 95 | dir: Vec3, |
| 78 | 96 | boxes: Box[], |
| @@ -90,9 +108,10 @@ export function throwGrenade( | ||
| 90 | 108 | onGround: false, |
| 91 | 109 | ducking: false, |
| 92 | 110 | duckAmount: 0, |
| 111 | weapon, | |
| 93 | 112 | owner, |
| 94 | 113 | team, |
| 95 | fuse: HE.blast.fuse, | |
| 114 | fuse: fuseOf(WEAPONS[weapon]), | |
| 96 | 115 | }; |
| 97 | 116 | } |
| 98 | 117 | |
| @@ -142,7 +161,8 @@ export type Caught = { actor: Actor; distance: number; health: number; armor: nu | ||
| 142 | 161 | * not hurt the person who threw it. |
| 143 | 162 | */ |
| 144 | 163 | export function blastHits(g: Grenade, actors: Actor[], boxes: Box[]): Caught[] { |
| 145 | const blast = HE.blast; | |
| 164 | const blast = WEAPONS[g.weapon].blast; | |
| 165 | if (blast === undefined) return []; | |
| 146 | 166 | const centre = blastCentre(g); |
| 147 | 167 | const caught: Caught[] = []; |
| 148 | 168 | for (const actor of actors) { |
| @@ -156,3 +176,65 @@ export function blastHits(g: Grenade, actors: Actor[], boxes: Box[]): Caught[] { | ||
| 156 | 176 | } |
| 157 | 177 | return caught; |
| 158 | 178 | } |
| 179 | ||
| 180 | // --- the flash ------------------------------------------------------------ | |
| 181 | ||
| 182 | /** What a flash needs on top of a body: which way the eyes were pointing. */ | |
| 183 | export type Looker = Actor & { pitch: number }; | |
| 184 | ||
| 185 | /** The eye a flash is seen with — the same height a bullet leaves from. */ | |
| 186 | const eyeOf = (a: Looker): Vec3 => [a.pos[0], a.pos[1], a.pos[2] + eyeHeight(a, CS_HULL)]; | |
| 187 | ||
| 188 | /** | |
| 189 | * Degrees between where `a` was looking and `at`. The whole view direction, not | |
| 190 | * just the yaw: a flash at your feet is one you are looking away from, and a | |
| 191 | * player who was staring at the floor should keep their sight. | |
| 192 | */ | |
| 193 | export function viewAngle(a: Looker, at: Vec3): number { | |
| 194 | const to = sub(at, eyeOf(a)); | |
| 195 | if (dist(at, eyeOf(a)) < 1e-6) return 0; | |
| 196 | const look = angleVectors(a.yaw, a.pitch).forward; | |
| 197 | return (Math.acos(clamp(dot(look, normalize(to)), -1, 1)) * 180) / Math.PI; | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * Below this a flash is a flicker, not a blinding: a tenth of a second is six | |
| 202 | * ticks, less than a frame of white, and not worth taking anybody's screen for. | |
| 203 | */ | |
| 204 | export const MIN_BLIND = 0.1; | |
| 205 | ||
| 206 | /** One body a flash reached, and for how long it took their sight. */ | |
| 207 | export type Blinded = { actor: Looker; seconds: number; angle: number; distance: number }; | |
| 208 | ||
| 209 | /** | |
| 210 | * Who this flash blinds and for how long. Three questions in this order, and | |
| 211 | * the cheap ones first: was there a line at all (the engine's `visible`, eye to | |
| 212 | * pop — a wall takes all of it), how far away was it, and how much of the view | |
| 213 | * it went off in. `blindSeconds` in `weapons.ts` is the curve; this is the | |
| 214 | * geometry it is fed. | |
| 215 | * | |
| 216 | * Unlike the blast, a flash does not care whose side you are on. It is light in | |
| 217 | * a room: it blinds the thrower's team, the thrower, and the enemy alike, which | |
| 218 | * is CS's rule and the only one that makes throwing one a decision. | |
| 219 | * | |
| 220 | * Anything under `MIN_BLIND` is dropped rather than returned as a flicker | |
| 221 | * nobody can act on. | |
| 222 | */ | |
| 223 | export function flashHits(g: Grenade, actors: Looker[], boxes: Box[]): Blinded[] { | |
| 224 | const flash = WEAPONS[g.weapon].flash; | |
| 225 | if (flash === undefined) return []; | |
| 226 | const centre = blastCentre(g); | |
| 227 | const blinded: Blinded[] = []; | |
| 228 | for (const actor of actors) { | |
| 229 | if (!actor.alive) continue; | |
| 230 | const eye = eyeOf(actor); | |
| 231 | const distance = dist(centre, eye); | |
| 232 | if (distance >= flash.radius) continue; | |
| 233 | if (!visible(centre, eye, boxes)) continue; | |
| 234 | const angle = viewAngle(actor, centre); | |
| 235 | const seconds = blindSeconds(flash, angle, distance); | |
| 236 | if (seconds < MIN_BLIND) continue; | |
| 237 | blinded.push({ actor, seconds, angle, distance }); | |
| 238 | } | |
| 239 | return blinded; | |
| 240 | } |
src/client/hud.test.ts +57 −1
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | 22 | import { expect, test } from "bun:test"; |
| 23 | import { drawHud } from "./hud.ts"; | |
| 23 | import { drawHud, whiteout } from "./hud.ts"; | |
| 24 | 24 | import { recorder, type Call, type Recorder } from "./paint.test-helper.ts"; |
| 25 | 25 | import { DE_FORGE } from "./map.ts"; |
| 26 | 26 | import { SHORT } from "./match.ts"; |
| @@ -566,3 +566,59 @@ test("the same canvas turned sideways is drawn at the new size, not the one befo | ||
| 566 | 566 | const bar = after.find((c) => c.op === "fillRect") as Call; |
| 567 | 567 | expect(bar.args[2]).toBe(Math.round(240 * 0.7)); |
| 568 | 568 | }); |
| 569 | ||
| 570 | // --- flashed --------------------------------------------------------------- | |
| 571 | ||
| 572 | /** Every full-screen wash this frame drew, with the brush that drew it. */ | |
| 573 | const washes = (paint: Recorder, width = WIDTH, height = HEIGHT): Call[] => | |
| 574 | paint.calls.filter((c) => c.op === "fillRect" && c.args[2] === width && c.args[3] === height); | |
| 575 | ||
| 576 | /** The opacity of an `rgba(...)` fill, or 0 for anything else. */ | |
| 577 | const opacity = (fill: string): number => Number(/rgba\([^)]*,([^,)]+)\)$/.exec(fill)?.[1] ?? 0); | |
| 578 | ||
| 579 | test("a flashed player gets white over the world, and the crosshair back before it ends", () => { | |
| 580 | const max = 1.6; | |
| 581 | // Unflashed there is no wash at all: this costs a frame nothing. | |
| 582 | expect(washes(draw(scene()))).toEqual([]); | |
| 583 | ||
| 584 | // The white is read off the simulation's own number, in seconds. | |
| 585 | const full = washes(draw(scene((w) => { (w.me as Player).blind = max; }))); | |
| 586 | expect(full.length).toBe(1); | |
| 587 | expect(opacity(full[0]?.fill ?? "")).toBeCloseTo(whiteout(max), 6); | |
| 588 | // It never quite reaches one: the world is always a ghost behind it. | |
| 589 | expect(whiteout(max)).toBeLessThan(1); | |
| 590 | expect(whiteout(max)).toBeGreaterThan(0.85); | |
| 591 | ||
| 592 | // It fades, and it is gone with the blindness. | |
| 593 | expect(whiteout(0.45)).toBeLessThan(whiteout(0.9)); | |
| 594 | expect(whiteout(0.9)).toBe(whiteout(max)); // held at full while there is time left | |
| 595 | expect(washes(draw(scene((w) => { (w.me as Player).blind = 0; })))).toEqual([]); | |
| 596 | ||
| 597 | // The crosshair is drawn *under* the wash and is readable again for the last | |
| 598 | // half second of any flash: at 0.45 s left the white is under a half. | |
| 599 | expect(whiteout(0.45)).toBeLessThan(0.5); | |
| 600 | const half = draw(scene((w) => { (w.me as Player).blind = 0.45; })); | |
| 601 | const wash = washes(half)[0] as Call; | |
| 602 | const crosshair = half.calls.findIndex((c) => c.op === "stroke"); | |
| 603 | expect(crosshair).toBeGreaterThanOrEqual(0); | |
| 604 | expect(half.calls.indexOf(wash)).toBeGreaterThan(crosshair); | |
| 605 | ||
| 606 | // And the numbers go on top of it: a flashed player still reads their health. | |
| 607 | const flashed = draw(scene((w) => { (w.me as Player).blind = max; })); | |
| 608 | const health = flashed.calls.findIndex((c) => c.op === "fillText" && c.args[0] === "100"); | |
| 609 | expect(health).toBeGreaterThan(flashed.calls.indexOf(washes(flashed)[0] as Call)); | |
| 610 | }); | |
| 611 | ||
| 612 | test("the pocket line names each grenade you are carrying, and both at once", () => { | |
| 613 | const none = draw(scene()); | |
| 614 | expect(words(none).some((t) => t.includes("HE"))).toBe(false); | |
| 615 | const both = draw(scene((w) => { | |
| 616 | (w.me as Player).pocket = { he: 1, flash: 1 }; | |
| 617 | })); | |
| 618 | const named = words(both).find((t) => t.includes("HE")) ?? ""; | |
| 619 | expect(named).toContain("HE"); | |
| 620 | expect(named).toContain("flash"); | |
| 621 | const only = draw(scene((w) => { (w.me as Player).pocket = { he: 0, flash: 1 }; })); | |
| 622 | const line = words(only).find((t) => t.includes("flash")) ?? ""; | |
| 623 | expect(line).not.toContain("HE"); | |
| 624 | }); |
src/client/hud.ts +36 −3
| @@ -78,6 +78,26 @@ function toolsFor(ctx: Paint, width: number, height: number): Overlay { | ||
| 78 | 78 | return tools; |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | /** | |
| 82 | * The white-out, as opacity: how much white is over the world with `blind` | |
| 83 | * seconds of a flash left to run — | |
| 84 | * | |
| 85 | * alpha(b) = PEAK · min(1, b / WHITEOUT_FADE) | |
| 86 | * | |
| 87 | * Two deliberate limits, because a full-screen white-out is the easiest thing | |
| 88 | * in this game to make unplayable. It never reaches 1, so the world is always a | |
| 89 | * ghost behind it rather than gone. And it falls to nothing over the last | |
| 90 | * `WHITEOUT_FADE` seconds, so the crosshair under it is readable again once | |
| 91 | * alpha drops under about a half — at `b = 0.49 s` — whatever the flash was | |
| 92 | * worth. The longest a flash can be is `FLASH.maxBlind`, 1.6 seconds, of which | |
| 93 | * at most the first 0.7 is full white. | |
| 94 | */ | |
| 95 | export const whiteout = (blind: number): number => PEAK * Math.min(1, blind / WHITEOUT_FADE); | |
| 96 | ||
| 97 | /** The most white there ever is, and the seconds it takes to clear. */ | |
| 98 | const PEAK = 0.92; | |
| 99 | const WHITEOUT_FADE = 0.9; | |
| 100 | ||
| 81 | 101 | /** |
| 82 | 102 | * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or |
| 83 | 103 | * the player you are watching once you are dead — and it is what every number |
| @@ -138,6 +158,16 @@ export function drawHud( | ||
| 138 | 158 | ctx.stroke(); |
| 139 | 159 | } |
| 140 | 160 | |
| 161 | // --- flashed: white over the world, and nothing else. The blindness itself | |
| 162 | // is the simulation's — `view.blind`, in seconds, burned down at 64 Hz — so | |
| 163 | // this is only how it looks and a bot flashed by the same grenade suffers the | |
| 164 | // same number. It goes on after the crosshair and before the numbers: it | |
| 165 | // dims the crosshair as it should and never hides the clock or your health. | |
| 166 | if (view.blind > 0) { | |
| 167 | ctx.fillStyle = `rgba(255,255,255,${whiteout(view.blind).toFixed(3)})`; | |
| 168 | ctx.fillRect(0, 0, width, height); | |
| 169 | } | |
| 170 | ||
| 141 | 171 | const scale = ui.scale; |
| 142 | 172 | const big = ui.font(30); |
| 143 | 173 | const mid = ui.font(19); |
| @@ -177,10 +207,13 @@ export function drawHud( | ||
| 177 | 207 | const ammo = view.ammo[weapon.id]; |
| 178 | 208 | const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`; |
| 179 | 209 | ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE }); |
| 180 | // What is in your hands, and then what is in your pockets: the HE you bought | |
| 181 | // and the bomb, each named only while you are carrying it. | |
| 210 | // What is in your hands, and then what is in your pockets: the grenades you | |
| 211 | // bought and the bomb, each named only while you are carrying it. | |
| 182 | 212 | ui.text( |
| 183 | weapon.name + (view.grenades > 0 ? " · HE" : "") + (view.hasBomb ? " · bomb" : ""), | |
| 213 | weapon.name + | |
| 214 | (view.pocket.he > 0 ? " · HE" : "") + | |
| 215 | (view.pocket.flash > 0 ? " · flash" : "") + | |
| 216 | (view.hasBomb ? " · bomb" : ""), | |
| 184 | 217 | width - pad, height - pad - Math.round(30 * scale), |
| 185 | 218 | { align: "right", font: small, fill: GREY }, |
| 186 | 219 | ); |
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", "grenade", "knife", "level", "pistol", "player", "rifle", "smg", "sniper", | |
| 462 | "bomb", "flash", "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 +3 −1
| @@ -484,7 +484,9 @@ export function boot(host: Host): Frame { | ||
| 484 | 484 | */ |
| 485 | 485 | export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> { |
| 486 | 486 | const kinds = new Set(Object.values(WEAPONS).map((weapon) => weapon.kind)); |
| 487 | const names = ["level", "player", "bomb", ...kinds]; | |
| 487 | // `grenade` arrives as a weapon kind and is the HE's body in the world; the | |
| 488 | // flashbang shares that kind and needs its own shell, so it is named here. | |
| 489 | const names = ["level", "player", "bomb", "flash", ...kinds]; | |
| 488 | 490 | return Object.fromEntries(names.map((name) => [name, meshFor(name, level)])); |
| 489 | 491 | } |
| 490 | 492 |
src/client/models.ts +15 −3
| @@ -55,10 +55,21 @@ export function bombMesh(): Mesh { | ||
| 55 | 55 | * middle, built about its own origin so it needs no rotation to be drawn in |
| 56 | 56 | * flight or at rest. Eight units across — twice the four-unit hull the solver |
| 57 | 57 | * moves it with, so it looks like an object and not like a pixel. |
| 58 | * | |
| 59 | * The flashbang is the same shell in a different shape: taller, narrower and | |
| 60 | * banded top and bottom, the way CS's is, so the two are told apart in the air | |
| 61 | * at a glance. Both are the one recipe because both are one thrown thing. | |
| 58 | 62 | */ |
| 59 | export function grenadeMesh(): Mesh { | |
| 63 | export function grenadeMesh(kind: "he" | "flash" = "he"): Mesh { | |
| 60 | 64 | const b = builder(); |
| 61 | 65 | const SHELL = 7, BAND = 8; |
| 66 | if (kind === "flash") { | |
| 67 | cube(b, 0, 0, 3, 5, 5, 10, SHELL); // the canister | |
| 68 | cube(b, 0, 0, 1, 6, 6, 2, BAND); // the band around its foot | |
| 69 | cube(b, 0, 0, 6, 6, 6, 2, BAND); // and the one around its shoulder | |
| 70 | cube(b, 0, 0, 8.5, 2.5, 2.5, 2, BAND); // the spoon on top | |
| 71 | return finish(b); | |
| 72 | } | |
| 62 | 73 | cube(b, 0, 0, 2, 7, 7, 7, SHELL); |
| 63 | 74 | cube(b, 0, 0, 2, 8, 8, 3, BAND); |
| 64 | 75 | cube(b, 0, 0, 6, 2.5, 2.5, 2, BAND); // the spoon on top |
| @@ -88,8 +99,8 @@ export function viewModelMesh(kind: string): Mesh { | ||
| 88 | 99 | } |
| 89 | 100 | |
| 90 | 101 | /** |
| 91 | * A mesh by the name a scene calls it: `level`, `player`, `bomb`, `grenade`, or a weapon | |
| 92 | * kind for the gun in your hands. `main.ts` builds one of each at start-up and | |
| 102 | * A mesh by the name a scene calls it: `level`, `player`, `bomb`, `grenade`, | |
| 103 | * `flash`, or a weapon kind for the gun in your hands. `main.ts` builds one of each at start-up and | |
| 93 | 104 | * hands them to the engine's renderer, which keeps them by name and never |
| 94 | 105 | * learns what a name is made of — that is this file, and this is the one line a |
| 95 | 106 | * second game's model table would replace. |
| @@ -102,5 +113,6 @@ export function meshFor(name: string, level: Level): Mesh { | ||
| 102 | 113 | if (name === "player") return playerMesh(); |
| 103 | 114 | if (name === "bomb") return bombMesh(); |
| 104 | 115 | if (name === "grenade") return grenadeMesh(); |
| 116 | if (name === "flash") return grenadeMesh("flash"); | |
| 105 | 117 | return viewModelMesh(name); |
| 106 | 118 | } |
src/client/replay.test.ts +40 −36
| @@ -34,51 +34,55 @@ 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. 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. | |
| 37 | * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for #164, | |
| 38 | * which put a second grenade in the game, and for exactly the reason #142 was | |
| 39 | * re-pinned before it: what moved is the *hash*, not the play. A pocket is two | |
| 40 | * counts now rather than one, every body carries the seconds of a flash it | |
| 41 | * cannot see through, and a live grenade carries which kind it is — five fields | |
| 42 | * where there were two — so every second of every run differs from the numbers | |
| 43 | * before it. The same forty seconds folded with the *old* field list on this | |
| 44 | * code still comes to the old digest, | |
| 45 | * da283f931bb75db712cb9b526edf2a03215f6e77b2f5c0038f3d60a0e5b918b7, which is | |
| 46 | * how this branch knows nobody's footing changed. Nothing in this script throws | |
| 47 | * anything: the bots buy no grenades and the scripted person never presses | |
| 48 | * either key, so no flash goes off and no `blind` is ever non-zero here. | |
| 47 | 49 | */ |
| 48 | const ROUND = "da283f931bb75db712cb9b526edf2a03215f6e77b2f5c0038f3d60a0e5b918b7"; | |
| 50 | const ROUND = "ca91ea4cb43109a961fa2b63d44bc8d27de4e426b79016341ee3e5e0e6a7043e"; | |
| 49 | 51 | const ROUND_SECONDS = [ |
| 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", | |
| 52 | "5978706f2efd", "aa6aa9a5a3c9", "f822e864db93", "6f1cf4de1926", | |
| 53 | "25923b9cb132", "83b06bc2430e", "b97f8d621af5", "c1b94afedda0", | |
| 54 | "700c15a0f6c1", "8c0ad0d13658", "d7aad60e3ff3", "d4e5187b49b4", | |
| 55 | "60730fe44cc8", "a4c501cdf06e", "e23f99650039", "8f0fd8311650", | |
| 56 | "3e2a50a35374", "0eaec9fa5595", "a4da64d03f4f", "a741df777b49", | |
| 57 | "a9e376a00a92", "97551fd34e2a", "1342599e6fe1", "b3805e3f7b45", | |
| 58 | "54fedea960e5", "867ccb00fbfd", "853c878b3121", "f723b6926421", | |
| 59 | "adca551d5dcd", "dc6e57a96c4b", "5612a3feff88", "302de76251c8", | |
| 60 | "237a92008fdd", "108e5db94381", "ab4ecb1715fb", "7cbeb300896f", | |
| 61 | "2afdbc98a409", "0db10ea3eb14", "069dfe802cf2", "fca970b88b16", | |
| 60 | 62 | ]; |
| 61 | 63 | |
| 62 | 64 | /** |
| 63 | 65 | * Forty seconds of duels in `src/rl/arena.ts`, seed 4242, against the |
| 64 | * scripted opponent, resetting each time one of them dies. Re-pinned for #142 | |
| 66 | * scripted opponent, resetting each time one of them dies. Re-pinned for #164 | |
| 65 | 67 | * 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. | |
| 68 | * `toInput` never sets the throw, nothing in it is ever blinded, and every | |
| 69 | * number a flash scales in `bots.ts` is multiplied by exactly one while | |
| 70 | * `blind` is zero — so the duel itself is untouched, and the old field list on | |
| 71 | * this code still folds to | |
| 72 | * 37ea4cc90ecb55e2489ee1f219654b563a16e93893a6222c6e17f23f56b3c5d3. | |
| 69 | 73 | */ |
| 70 | const DUEL = "37ea4cc90ecb55e2489ee1f219654b563a16e93893a6222c6e17f23f56b3c5d3"; | |
| 74 | const DUEL = "bffdda30cf85fd8b2ea4840ad4b53e8041054411fcb2d6adcd7537be8f1ebbef"; | |
| 71 | 75 | const DUEL_SECONDS = [ |
| 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", | |
| 76 | "f3d6534f1590", "3f1e2479a841", "43b52fff16ef", "bd2c6c29eaf3", | |
| 77 | "0c965cb1929c", "7572a5087ec4", "c4b9c83c357b", "b352f5a86d06", | |
| 78 | "1e533cbaf327", "090b9d34147d", "a2f17103d7cf", "6831ee8d38c1", | |
| 79 | "258eb2af7c8b", "9bb124c5f5fd", "fdac71af56c4", "1ae133a994ad", | |
| 80 | "1d96558e6129", "989f1591cc59", "03836444fe3d", "2442e7241118", | |
| 81 | "4795750cc613", "8c40d6315858", "6da5618d9b4d", "217b93298e47", | |
| 82 | "49c8cc22abaa", "1f1706709ae2", "faa170572b54", "bd67caf0320a", | |
| 83 | "a89d510d047b", "645baf0eb839", "36eaedb9bb79", "ddc41eb233e9", | |
| 84 | "45377fd1cdea", "b0796f4fa162", "02afbd2180fd", "02bd15880c74", | |
| 85 | "4649a72e207a", "78940559b08f", "2144ea37bcab", "ebd6cf00bfa7", | |
| 82 | 86 | ]; |
| 83 | 87 | |
| 84 | 88 | const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUND, seconds: ROUND_SECONDS }; |
src/client/replay.ts +9 −2
| @@ -53,8 +53,12 @@ export const PLAYER_FIELDS: { name: string; of: (p: Player) => number }[] = [ | ||
| 53 | 53 | { name: "duckAmount", of: (p) => p.duckAmount }, |
| 54 | 54 | // -1 for a weapon that has no magazine (the knife), so a switch still shows. |
| 55 | 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 }, | |
| 56 | // What is in each pocket: bought, thrown, and lost with the body that held it. | |
| 57 | { name: "he", of: (p) => p.pocket.he }, | |
| 58 | { name: "flash", of: (p) => p.pocket.flash }, | |
| 59 | // How long this body cannot see for. It is simulation, not presentation: the | |
| 60 | // white-out a person gets and the shaky hands a bot gets are both read off it. | |
| 61 | { name: "blind", of: (p) => p.blind }, | |
| 58 | 62 | ]; |
| 59 | 63 | |
| 60 | 64 | /** |
| @@ -70,6 +74,9 @@ export const GRENADE_FIELDS: { name: string; of: (g: Grenade) => number }[] = [ | ||
| 70 | 74 | { name: "vel.y", of: (g) => g.vel[1] }, |
| 71 | 75 | { name: "vel.z", of: (g) => g.vel[2] }, |
| 72 | 76 | { name: "fuse", of: (g) => g.fuse }, |
| 77 | // Which kind it is — an HE and a flashbang fly identically and end very | |
| 78 | // differently, so two otherwise equal ticks are not the same state. | |
| 79 | { name: "kind", of: (g) => (g.weapon === "flash" ? 1 : 0) }, | |
| 73 | 80 | ]; |
| 74 | 81 | |
| 75 | 82 | /** Everything hashed about the match. */ |
src/client/scene.ts +18 −7
| @@ -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 { HE, WEAPONS } from "./weapons.ts"; | |
| 25 | import { FLASH, HE, WEAPONS } from "./weapons.ts"; | |
| 26 | 26 | import type { Player, World } from "./world.ts"; |
| 27 | 27 | |
| 28 | 28 | export type Tint = [number, number, number]; |
| @@ -60,6 +60,9 @@ export const INSTANCE_CAPACITY = 32; | ||
| 60 | 60 | |
| 61 | 61 | /** How long a blast is drawn for, in seconds, and in how many spokes. */ |
| 62 | 62 | const BLAST_SECONDS = 0.45; |
| 63 | ||
| 64 | /** A flash is a shorter, brighter thing than a blast: it is over in a blink. */ | |
| 65 | const FLASH_SECONDS = 0.2; | |
| 63 | 66 | const BLAST_SPOKES = 16; |
| 64 | 67 | |
| 65 | 68 | /** The horizon, and the colour distance fades into. */ |
| @@ -150,7 +153,7 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 150 | 153 | // the yaw is the simulated time, so a frame decides nothing and a paused |
| 151 | 154 | // one turns it no further. |
| 152 | 155 | for (const g of world.grenades) |
| 153 | place(into, "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE); | |
| 156 | place(into, g.weapon === "flash" ? "flash" : "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE); | |
| 154 | 157 | |
| 155 | 158 | // --- tracers, sparks and the planted bomb's beam, all as lines |
| 156 | 159 | // Read by index, oldest first: the effects are the engine's ring and a |
| @@ -182,10 +185,17 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 182 | 185 | ); |
| 183 | 186 | } |
| 184 | 187 | } else if (effect.kind === "blast" && age < BLAST_SECONDS) { |
| 185 | // The blast, drawn as what it is: a star of light that runs out to the | |
| 186 | // radius the damage actually reaches and fades as it goes. | |
| 187 | const t = age / BLAST_SECONDS; | |
| 188 | const k = HE.blast.radius * Math.min(1, t * 2.2); | |
| 188 | // A grenade going off, drawn as what it is: a star of light that runs out | |
| 189 | // to the radius that kind actually reaches and fades as it goes. The | |
| 190 | // flash is the shorter, whiter, faster one and it is drawn to its own | |
| 191 | // radius — which is why the reach is read off the weapon and not typed | |
| 192 | // twice — but it never brightens the screen from out here: what it costs | |
| 193 | // the person looking at it is `blind` in the simulation, and the HUD's. | |
| 194 | const flash = effect.weapon === "flash"; | |
| 195 | const seconds = flash ? FLASH_SECONDS : BLAST_SECONDS; | |
| 196 | if (age >= seconds) continue; | |
| 197 | const t = age / seconds; | |
| 198 | const k = (flash ? FLASH.flash.radius * 0.25 : HE.blast.radius) * Math.min(1, t * 2.2); | |
| 189 | 199 | const fade = 1 - t; |
| 190 | 200 | for (let s = 0; s < BLAST_SPOKES; s++) { |
| 191 | 201 | const a = (s / BLAST_SPOKES) * Math.PI * 2; |
| @@ -194,7 +204,8 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 194 | 204 | into, |
| 195 | 205 | effect.pos, |
| 196 | 206 | at(TO, effect.pos[0] + Math.cos(a) * k, effect.pos[1] + Math.sin(a) * k, effect.pos[2] + lift * k), |
| 197 | colour(NEAR, 1, 0.95, 0.8 * fade), colour(FAR, 1, 0.5 * fade, 0.1 * fade), | |
| 207 | flash ? colour(NEAR, 1, 1, 1) : colour(NEAR, 1, 0.95, 0.8 * fade), | |
| 208 | flash ? colour(FAR, fade, fade, fade) : colour(FAR, 1, 0.5 * fade, 0.1 * fade), | |
| 198 | 209 | ); |
| 199 | 210 | } |
| 200 | 211 | } else if (effect.kind === "blood" && age < 0.3) { |
src/client/weapons.test.ts +57 −1
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { buyable, computeDamage, inaccuracy, recoilOffset, WEAPONS } from "./weapons.ts"; | |
| 2 | import { | |
| 3 | blindSeconds, buyable, computeDamage, FLASH, HE, inaccuracy, isThrown, recoilOffset, WEAPONS, | |
| 4 | } from "./weapons.ts"; | |
| 3 | 5 | |
| 4 | 6 | test("an AK chest shot through armour is survivable, a headshot is not", () => { |
| 5 | 7 | const ak = WEAPONS.ak47; |
| @@ -62,3 +64,57 @@ test("the buy menu only offers what your side carries", () => { | ||
| 62 | 64 | expect(ct).not.toContain("ak47"); |
| 63 | 65 | expect(buyable("t").map((w) => w.id)).toContain("ak47"); |
| 64 | 66 | }); |
| 67 | ||
| 68 | // --- the flashbang -------------------------------------------------------- | |
| 69 | ||
| 70 | test("the flashbang is in the table with its own numbers, and no bullet ones", () => { | |
| 71 | expect(WEAPONS.flash).toBe(FLASH); | |
| 72 | expect(FLASH.price).toBe(200); // dollars | |
| 73 | expect(FLASH.kind).toBe("grenade"); | |
| 74 | expect(FLASH.flash).toEqual({ | |
| 75 | fuse: 1.6, // seconds | |
| 76 | radius: 1200, // units | |
| 77 | maxBlind: 1.6, // seconds, the ceiling | |
| 78 | fullAngle: 30, // degrees | |
| 79 | awayAngle: 135, // degrees | |
| 80 | }); | |
| 81 | // It hurts nobody: no damage, no falloff, no magazine, and no blast at all. | |
| 82 | expect(FLASH.damage).toBe(0); | |
| 83 | expect(FLASH.magazine).toBe(0); | |
| 84 | expect(FLASH.blast).toBeUndefined(); | |
| 85 | expect(HE.flash).toBeUndefined(); | |
| 86 | // Both are thrown, and nothing else in the table is. | |
| 87 | expect(Object.values(WEAPONS).filter((w) => isThrown(w.id)).map((w) => w.id).sort()) | |
| 88 | .toEqual(["flash", "he"]); | |
| 89 | // Adding it did not move a gun's spray: the patterns are seeded by index and | |
| 90 | // the thrown weapons are last, so the AK's tenth bullet is the AK's tenth. | |
| 91 | expect(recoilOffset("ak47", 9)).toEqual(recoilOffset("ak47", 9)); | |
| 92 | expect(recoilOffset("flash", 3)).toEqual([0, 0]); | |
| 93 | }); | |
| 94 | ||
| 95 | test("the blind curve is the one in the comment, at three angles and two distances", () => { | |
| 96 | const f = FLASH.flash; | |
| 97 | const at = (angle: number, distance: number): number => blindSeconds(f, angle, distance); | |
| 98 | // In the face, at the centre: the ceiling, and it is 1.6 seconds and not three. | |
| 99 | expect(at(0, 0)).toBeCloseTo(1.6, 10); | |
| 100 | expect(f.maxBlind).toBe(1.6); | |
| 101 | // Three angles, at the centre. 30° is still the whole of it — the cone where | |
| 102 | // it fills your view. 82.5° is exactly half way from 30 to 135, so half. | |
| 103 | // 135° is over your shoulder: nothing, however close it went off. | |
| 104 | expect(at(30, 0)).toBeCloseTo(1.6, 10); | |
| 105 | expect(at(82.5, 0)).toBeCloseTo(0.8, 10); | |
| 106 | expect(at(135, 0)).toBe(0); | |
| 107 | expect(at(180, 0)).toBe(0); | |
| 108 | // The same three at 600 units — half the radius, so half of each again. | |
| 109 | expect(at(0, 600)).toBeCloseTo(0.8, 10); | |
| 110 | expect(at(82.5, 600)).toBeCloseTo(0.4, 10); | |
| 111 | expect(at(135, 600)).toBe(0); | |
| 112 | // And at the radius and past it there is nothing to see. | |
| 113 | expect(at(0, 1200)).toBe(0); | |
| 114 | expect(at(0, 2000)).toBe(0); | |
| 115 | // Between the two angles it is a straight line: 60° is 75/105 of the way. | |
| 116 | expect(at(60, 0)).toBeCloseTo(1.6 * (75 / 105), 10); | |
| 117 | // And it never runs backwards: further and wider is always less. | |
| 118 | expect(at(45, 300)).toBeGreaterThan(at(60, 300)); | |
| 119 | expect(at(45, 300)).toBeGreaterThan(at(45, 400)); | |
| 120 | }); |
src/client/weapons.ts +88 −13
| @@ -7,19 +7,28 @@ | ||
| 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 | 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`. | |
| 10 | * Eight of the ten are guns. The other two are thrown: the HE grenade and the | |
| 11 | * flashbang, which are in the same table because they are bought in the same | |
| 12 | * menu and named in the same kill feed, and which carry a `blast` or a `flash` | |
| 13 | * instead of a magazine. How one flies is `src/client/grenade.ts`; what it does | |
| 14 | * when the fuse runs out is a `Blast` or a `Flash` below and nothing else, which | |
| 15 | * is what lets a third thrown thing be a row in this table rather than a branch | |
| 16 | * in the world. | |
| 14 | 17 | */ |
| 15 | 18 | |
| 16 | import { rng } from "../engine/index.ts"; | |
| 19 | import { clamp, rng } from "../engine/index.ts"; | |
| 17 | 20 | |
| 18 | 21 | export type WeaponId = |
| 19 | | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he"; | |
| 22 | | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he" | "flash"; | |
| 23 | ||
| 24 | /** The weapons that are thrown rather than fired: the two kinds in a pocket. */ | |
| 25 | export type ThrownId = "he" | "flash"; | |
| 26 | ||
| 27 | /** Is this a weapon somebody throws? Narrows the id as well as answering. */ | |
| 28 | export const isThrown = (id: WeaponId): id is ThrownId => id === "he" || id === "flash"; | |
| 20 | 29 | |
| 21 | 30 | /** |
| 22 | * What a thrown weapon does when its fuse runs out. Only the grenade has one, | |
| 31 | * What a thrown weapon does when its fuse runs out. Only the HE has one, | |
| 23 | 32 | * and everything about the explosion a simulation needs is these four numbers. |
| 24 | 33 | */ |
| 25 | 34 | export type Blast = { |
| @@ -29,6 +38,19 @@ export type Blast = { | ||
| 29 | 38 | armorPenetration: number; // 0..1, the share that reaches health through armour |
| 30 | 39 | }; |
| 31 | 40 | |
| 41 | /** | |
| 42 | * What a flashbang does when its fuse runs out: it takes sight, not health, and | |
| 43 | * these five numbers are the whole of it. Nothing here is a hit point and | |
| 44 | * nothing here is armour — a vest does not help you look away. | |
| 45 | */ | |
| 46 | export type Flash = { | |
| 47 | fuse: number; // seconds from leaving the hand to the pop | |
| 48 | radius: number; // units; at and past this it does nothing at all | |
| 49 | maxBlind: number; // seconds, dead centre and looking straight at it: the ceiling | |
| 50 | fullAngle: number; // degrees; inside this cone of the view it is the whole screen | |
| 51 | awayAngle: number; // degrees; at or past this it went off behind you and is nothing | |
| 52 | }; | |
| 53 | ||
| 32 | 54 | export type Weapon = { |
| 33 | 55 | id: WeaponId; |
| 34 | 56 | name: string; |
| @@ -49,13 +71,27 @@ export type Weapon = { | ||
| 49 | 71 | recoil: number; // degrees of climb per shot, scaled by the pattern |
| 50 | 72 | zoom?: number; // scoped field of view |
| 51 | 73 | automatic: boolean; |
| 52 | /** Set on a thrown weapon, and on nothing else. */ | |
| 74 | /** Set on the HE, and on nothing else. */ | |
| 53 | 75 | blast?: Blast; |
| 76 | /** Set on the flashbang, and on nothing else. */ | |
| 77 | flash?: Flash; | |
| 54 | 78 | }; |
| 55 | 79 | |
| 56 | 80 | /** The HE grenade, by the name everything that throws one calls it. */ |
| 57 | 81 | 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 | 82 | |
| 83 | /** | |
| 84 | * The flashbang. CS's $200, on a 1.6-second fuse, and it matters a great deal | |
| 85 | * further than the HE does — 1,200 units against 350 — because light carries | |
| 86 | * and shrapnel does not. `maxBlind` is the ceiling and it is deliberately | |
| 87 | * short: 1.6 seconds of white is a fight you lost, and three would be a tab you | |
| 88 | * closed. The two angles are the curve's, and `blindSeconds` below is the curve. | |
| 89 | */ | |
| 90 | export const FLASH: Weapon & { flash: Flash } = { id: "flash", name: "Flashbang", kind: "grenade", team: "both", price: 200, 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, flash: { fuse: 1.6, radius: 1200, maxBlind: 1.6, fullAngle: 30, awayAngle: 135 } }; | |
| 91 | ||
| 92 | /** The fuse on a thrown weapon, in seconds: the one number both kinds have. */ | |
| 93 | export const fuseOf = (weapon: Weapon): number => weapon.blast?.fuse ?? weapon.flash?.fuse ?? 0; | |
| 94 | ||
| 59 | 95 | const WEAPON_LIST: Weapon[] = [ |
| 60 | 96 | { 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 }, |
| 61 | 97 | { 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 }, |
| @@ -65,12 +101,15 @@ const WEAPON_LIST: Weapon[] = [ | ||
| 65 | 101 | { 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 }, |
| 66 | 102 | { 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 }, |
| 67 | 103 | { 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. | |
| 104 | // The thrown weapons. Their bullet numbers are the empty ones — neither is | |
| 105 | // ever held, fired or reloaded — and everything they do is in `blast` and | |
| 106 | // `flash`: CS's $300 HE, 98 hit points at the centre falling off over 350 | |
| 107 | // units on a 1.5-second fuse with armour stopping about half of it, and CS's | |
| 108 | // $200 flashbang, which takes sight instead. They are last in the list on | |
| 109 | // purpose: the spray patterns below are seeded by index, so a thrown weapon | |
| 110 | // added here never moves a gun's pattern. | |
| 73 | 111 | HE, |
| 112 | FLASH, | |
| 74 | 113 | ]; |
| 75 | 114 | |
| 76 | 115 | export const WEAPONS: Record<WeaponId, Weapon> = Object.fromEntries( |
| @@ -191,3 +230,39 @@ export function blastDamage(blast: Blast, distance: number): number { | ||
| 191 | 230 | if (distance >= blast.radius) return 0; |
| 192 | 231 | return blast.damage * (1 - distance / blast.radius); |
| 193 | 232 | } |
| 233 | ||
| 234 | /** | |
| 235 | * How long a flash blinds somebody for, in seconds: how much of it they could | |
| 236 | * see, and nothing else. `angle` is the degrees between where they were looking | |
| 237 | * and where it went off, `distance` the units between the eye and the pop — | |
| 238 | * | |
| 239 | * blind(θ, d) = maxBlind · look(θ) · reach(d) | |
| 240 | * | |
| 241 | * look(θ) = 1 θ ≤ fullAngle | |
| 242 | * = (awayAngle − θ) / (awayAngle − fullAngle) in between | |
| 243 | * = 0 θ ≥ awayAngle | |
| 244 | * | |
| 245 | * reach(d) = 1 − d / radius, clamped to [0, 1] zero at and past radius | |
| 246 | * | |
| 247 | * `reach` is the HE's own falloff shape (Source's `RadiusDamage`: a straight | |
| 248 | * line to nothing at the radius) because a flash fades with distance for the | |
| 249 | * same reason and there is no case for a second shape. `look` is this game's: | |
| 250 | * inside 30° the pop fills the view and costs the full time, and at 135° it is | |
| 251 | * over your shoulder and costs nothing, with a straight line between. | |
| 252 | * | |
| 253 | * At 1.6 s, 1,200 units, 30° and 135° that is: 1.6 s in the face; 0.8 s at 82.5° | |
| 254 | * or at 600 units; 0.4 s at both; and nothing at all at 135° however close it | |
| 255 | * went off, or at 1,200 units however squarely you were looking at it. | |
| 256 | * | |
| 257 | * Whether there was a line of sight at all is a different question, and not this | |
| 258 | * function's: `src/client/grenade.ts` asks the engine's `traceWorld` first, the | |
| 259 | * same way the blast does. | |
| 260 | */ | |
| 261 | export function blindSeconds(flash: Flash, angle: number, distance: number): number { | |
| 262 | const look = | |
| 263 | angle <= flash.fullAngle ? 1 : | |
| 264 | angle >= flash.awayAngle ? 0 : | |
| 265 | (flash.awayAngle - angle) / (flash.awayAngle - flash.fullAngle); | |
| 266 | const reach = clamp(1 - distance / flash.radius, 0, 1); | |
| 267 | return flash.maxBlind * look * reach; | |
| 268 | } |
src/client/world.ts +92 −38
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 15 | 15 | import { |
| 16 | blastCentre, blastHits, stepGrenade, throwGrenade, THROW_LIFT, | |
| 16 | blastCentre, blastHits, flashHits, stepGrenade, throwGrenade, THROW_LIFT, | |
| 17 | 17 | type Grenade, type GrenadeSound, |
| 18 | 18 | } from "./grenade.ts"; |
| 19 | 19 | import { siteAt, type Level } from "./level.ts"; |
| @@ -25,8 +25,8 @@ import { | ||
| 25 | 25 | import * as rules from "./match.ts"; |
| 26 | 26 | import { CS_HULL, CS_MOVE } from "./movement.ts"; |
| 27 | 27 | import { |
| 28 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset, | |
| 29 | type WeaponId, type Weapon, | |
| 28 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, isThrown, recoilOffset, | |
| 29 | type ThrownId, type WeaponId, type Weapon, | |
| 30 | 30 | } from "./weapons.ts"; |
| 31 | 31 | import { botBuy, botThink, type BotBrain } from "./bots.ts"; |
| 32 | 32 | |
| @@ -46,15 +46,27 @@ export type Player = Actor & Body & { | ||
| 46 | 46 | ammo: Record<string, Ammo>; |
| 47 | 47 | defuseKit: boolean; |
| 48 | 48 | hasBomb: boolean; |
| 49 | /** HE grenades in the pocket: at most `MAX_GRENADES`, thrown one at a time. */ | |
| 50 | grenades: number; | |
| 49 | /** | |
| 50 | * What is in the pocket, by kind: at most `CARRY[kind]` of each, thrown one | |
| 51 | * at a time. A pocket rather than a count because a flashbang is carried | |
| 52 | * alongside the HE and not instead of it. | |
| 53 | */ | |
| 54 | pocket: Record<ThrownId, number>; | |
| 55 | /** | |
| 56 | * Seconds of blindness left, from a flash this player could see. It burns | |
| 57 | * down on the simulation's clock like a fuse does, it is the same number for | |
| 58 | * a person and for a bot, and it is what the white-out and the bot's shaky | |
| 59 | * hands are both read off — so the two can never disagree about what happened. | |
| 60 | */ | |
| 61 | blind: number; | |
| 51 | 62 | nextShotAt: number; |
| 52 | 63 | reloadUntil: number; |
| 53 | 64 | shotIndex: number; |
| 54 | 65 | lastShotAt: number; |
| 55 | 66 | scoped: boolean; |
| 56 | 67 | firedThisPress: boolean; |
| 57 | threwThisPress: boolean; | |
| 68 | /** Which kind the throw key was last held on, so a press throws exactly one. */ | |
| 69 | threwThisPress: ThrownId | null; | |
| 58 | 70 | punch: [number, number]; // view kick, decaying |
| 59 | 71 | kills: number; |
| 60 | 72 | deaths: number; |
| @@ -78,7 +90,8 @@ export type Effect = | ||
| 78 | 90 | | { kind: "blood"; at: number; pos: Vec3 } |
| 79 | 91 | | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean } |
| 80 | 92 | | { kind: "hitmark"; at: number; headshot: boolean } |
| 81 | | { kind: "blast"; at: number; pos: Vec3 } | |
| 93 | /** A grenade going off, of whichever kind: the frame draws each its own way. */ | |
| 94 | | { kind: "blast"; at: number; pos: Vec3; weapon: ThrownId } | |
| 82 | 95 | | { kind: "sound"; at: number; pos: Vec3; name: GrenadeSound }; |
| 83 | 96 | |
| 84 | 97 | /** |
| @@ -116,17 +129,17 @@ export type Input = { | ||
| 116 | 129 | use: boolean; |
| 117 | 130 | scope: boolean; |
| 118 | 131 | /** |
| 119 | * Throw the grenade in your pocket. It is one more field, and a bot that | |
| 120 | * never sets it — every bot in this game, today — plays exactly as it did | |
| 121 | * before there were grenades. | |
| 132 | * Which kind of grenade to throw out of your pocket, or null for neither. It | |
| 133 | * is one more field, and a bot that never sets it — every bot in this game, | |
| 134 | * today — plays exactly as it did before there were grenades. | |
| 122 | 135 | */ |
| 123 | throwGrenade: boolean; | |
| 136 | throwing: ThrownId | null; | |
| 124 | 137 | switchTo: WeaponId | null; |
| 125 | 138 | }; |
| 126 | 139 | |
| 127 | 140 | export const NO_INPUT: Input = { |
| 128 | 141 | forward: 0, side: 0, jump: false, duck: false, walk: false, |
| 129 | fire: false, reload: false, use: false, scope: false, throwGrenade: false, switchTo: null, | |
| 142 | fire: false, reload: false, use: false, scope: false, throwing: null, switchTo: null, | |
| 130 | 143 | }; |
| 131 | 144 | |
| 132 | 145 | export type World = { |
| @@ -174,9 +187,9 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 174 | 187 | money: 800, |
| 175 | 188 | weapon: secondary, primary: null, secondary, |
| 176 | 189 | ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) }, |
| 177 | defuseKit: false, hasBomb: false, grenades: 0, | |
| 190 | defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0 }, blind: 0, | |
| 178 | 191 | nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, |
| 179 | firedThisPress: false, threwThisPress: false, | |
| 192 | firedThisPress: false, threwThisPress: null, | |
| 180 | 193 | punch: [0, 0], |
| 181 | 194 | kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, |
| 182 | 195 | }; |
| @@ -252,8 +265,10 @@ export function startRound(world: World): void { | ||
| 252 | 265 | p.health = 100; |
| 253 | 266 | // A grenade survives the round its owner did, like their gun does; one they |
| 254 | 267 | // died holding is left on the floor with them. |
| 255 | if (!p.alive) p.grenades = 0; | |
| 268 | if (!p.alive) p.pocket = { he: 0, flash: 0 }; | |
| 256 | 269 | p.alive = true; |
| 270 | // A flash does not carry into the next round, however late it went off. | |
| 271 | p.blind = 0; | |
| 257 | 272 | p.duckAmount = 0; |
| 258 | 273 | p.hasBomb = false; |
| 259 | 274 | p.shotIndex = 0; |
| @@ -323,8 +338,12 @@ export function priceOf(item: BuyItem, player: Player): number { | ||
| 323 | 338 | return WEAPONS[item]?.price ?? Infinity; |
| 324 | 339 | } |
| 325 | 340 | |
| 326 | /** How many HE grenades a player may carry. CS allows one, and so does this. */ | |
| 327 | export const MAX_GRENADES = 1; | |
| 341 | /** | |
| 342 | * How many of each thrown weapon a player may carry into a round. One HE and | |
| 343 | * one flashbang — two grenades, bought separately in the freeze, each refused | |
| 344 | * once the pocket holds its limit, and both gone with the body that held them. | |
| 345 | */ | |
| 346 | export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1 }; | |
| 328 | 347 | |
| 329 | 348 | export const canBuy = (world: World, player: Player): boolean => |
| 330 | 349 | world.match.phase === "freeze" && player.alive; |
| @@ -349,10 +368,11 @@ export function buy(world: World, player: Player, item: BuyItem): boolean { | ||
| 349 | 368 | if (weapon === undefined || weapon.kind === "knife") return false; |
| 350 | 369 | if (weapon.team !== "both" && weapon.team !== player.team) return false; |
| 351 | 370 | // A grenade goes in a pocket, not in your hands: it does not become the |
| 352 | // weapon you are holding and it does not displace the gun you bought. | |
| 353 | if (weapon.kind === "grenade") { | |
| 354 | if (player.grenades >= MAX_GRENADES) return false; | |
| 355 | player.grenades += 1; | |
| 371 | // weapon you are holding and it does not displace the gun you bought, and | |
| 372 | // one kind in there does not crowd out the other. | |
| 373 | if (isThrown(item)) { | |
| 374 | if (player.pocket[item] >= CARRY[item]) return false; | |
| 375 | player.pocket[item] += 1; | |
| 356 | 376 | player.money -= price; |
| 357 | 377 | return true; |
| 358 | 378 | } |
| @@ -464,18 +484,19 @@ function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId, | ||
| 464 | 484 | // --- grenades ------------------------------------------------------------- |
| 465 | 485 | |
| 466 | 486 | /** |
| 467 | * Throw the grenade in this player's pocket, if there is one and the round | |
| 468 | * will have it. It leaves the eye along the view, tilted `THROW_LIFT` degrees | |
| 469 | * up the way CS tilts one, and from there it belongs to `grenade.ts` and the | |
| 470 | * engine's solver — nothing here steers it. | |
| 487 | * Throw the grenade of kind `kind` in this player's pocket, if there is one and | |
| 488 | * the round will have it. It leaves the eye along the view, tilted `THROW_LIFT` | |
| 489 | * degrees up the way CS tilts one, and from there it belongs to `grenade.ts` | |
| 490 | * and the engine's solver — nothing here steers it, and nothing here knows | |
| 491 | * which kind it was beyond taking it out of the right pocket. | |
| 471 | 492 | */ |
| 472 | export function throwOne(world: World, p: Player): boolean { | |
| 473 | if (!p.alive || p.grenades <= 0) return false; | |
| 493 | export function throwOne(world: World, p: Player, kind: ThrownId = "he"): boolean { | |
| 494 | if (!p.alive || p.pocket[kind] <= 0) return false; | |
| 474 | 495 | if (world.match.phase === "freeze" || world.match.phase === "over") return false; |
| 475 | p.grenades -= 1; | |
| 496 | p.pocket[kind] -= 1; | |
| 476 | 497 | const from = eyePos(p); |
| 477 | 498 | const dir = angleVectors(p.yaw, p.pitch - THROW_LIFT).forward; |
| 478 | world.grenades.push(throwGrenade(p.id, p.team, from, dir, world.level.boxes)); | |
| 499 | world.grenades.push(throwGrenade(p.id, p.team, kind, from, dir, world.level.boxes)); | |
| 479 | 500 | record(world, { kind: "sound", at: world.time, pos: from, name: "throw" }); |
| 480 | 501 | return true; |
| 481 | 502 | } |
| @@ -484,15 +505,25 @@ const heard = (world: World, pos: Vec3, name: GrenadeSound): void => { | ||
| 484 | 505 | record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name }); |
| 485 | 506 | }; |
| 486 | 507 | |
| 508 | /** | |
| 509 | * A grenade whose fuse ran out: the pop everyone sees and hears, and then | |
| 510 | * whatever this kind of grenade does. The dispatch is the one place in the | |
| 511 | * world that knows there is more than one kind of thrown thing. | |
| 512 | */ | |
| 513 | function detonate(world: World, g: Grenade): void { | |
| 514 | const centre = blastCentre(g); | |
| 515 | record(world, { kind: "blast", at: world.time, pos: centre, weapon: g.weapon }); | |
| 516 | heard(world, centre, g.weapon === "flash" ? "flash" : "blast"); | |
| 517 | if (g.weapon === "flash") return blind(world, g); | |
| 518 | explode(world, g, centre); | |
| 519 | } | |
| 520 | ||
| 487 | 521 | /** |
| 488 | 522 | * What a blast does: damage by distance to everything it can see, through |
| 489 | 523 | * armour, credited to whoever threw it — a hit marker if that was you and a |
| 490 | 524 | * line in the feed with the grenade named if it killed. |
| 491 | 525 | */ |
| 492 | function detonate(world: World, g: Grenade): void { | |
| 493 | const centre = blastCentre(g); | |
| 494 | record(world, { kind: "blast", at: world.time, pos: centre }); | |
| 495 | heard(world, centre, "blast"); | |
| 526 | function explode(world: World, g: Grenade, centre: Vec3): void { | |
| 496 | 527 | const thrower = world.players.find((p) => p.id === g.owner); |
| 497 | 528 | for (const caught of blastHits(g, world.players, world.level.boxes)) { |
| 498 | 529 | const victim = caught.actor as Player; |
| @@ -505,6 +536,22 @@ function detonate(world: World, g: Grenade): void { | ||
| 505 | 536 | } |
| 506 | 537 | } |
| 507 | 538 | |
| 539 | /** | |
| 540 | * What a flash does: it takes sight, and only from those who had a line to it. | |
| 541 | * `flashHits` decides how long from the angle and the distance; the longer of | |
| 542 | * what is left and what just arrived wins, so two flashes are not four seconds | |
| 543 | * of white — a second one cannot make the first one worse than its own ceiling. | |
| 544 | * | |
| 545 | * Nobody is hurt, nothing is credited and there is no line in the feed: a flash | |
| 546 | * is not a kill, it is a chance at one. | |
| 547 | */ | |
| 548 | function blind(world: World, g: Grenade): void { | |
| 549 | for (const caught of flashHits(g, world.players, world.level.boxes)) { | |
| 550 | const victim = caught.actor as Player; | |
| 551 | victim.blind = Math.max(victim.blind, caught.seconds); | |
| 552 | } | |
| 553 | } | |
| 554 | ||
| 508 | 555 | /** Every live grenade, one tick older: moved, heard, and detonated on time. */ |
| 509 | 556 | function updateGrenades(world: World, dt: number): void { |
| 510 | 557 | if (world.grenades.length === 0) return; |
| @@ -612,10 +659,12 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 612 | 659 | } |
| 613 | 660 | } |
| 614 | 661 | if (!input.fire) p.firedThisPress = false; |
| 615 | // One throw a press, like a semi-automatic trigger: holding the key down | |
| 616 | // does not empty a pocket that only ever holds one anyway. | |
| 617 | if (input.throwGrenade && !p.threwThisPress) throwOne(world, p); | |
| 618 | p.threwThisPress = input.throwGrenade; | |
| 662 | // One throw a press, like a semi-automatic trigger: holding a key down does | |
| 663 | // not empty a pocket that only ever holds one of that kind anyway. Asking for | |
| 664 | // the other kind while the first is held is a new press and throws it, which | |
| 665 | // is the only sensible reading of two keys for two pockets. | |
| 666 | if (input.throwing !== null && input.throwing !== p.threwThisPress) throwOne(world, p, input.throwing); | |
| 667 | p.threwThisPress = input.throwing; | |
| 619 | 668 | if (world.time - p.lastShotAt > 0.35) p.shotIndex = 0; |
| 620 | 669 | p.punch = [p.punch[0] * Math.pow(0.02, dt), p.punch[1] * Math.pow(0.02, dt)]; |
| 621 | 670 | updateBomb(world, p, input, dt); |
| @@ -645,7 +694,7 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 645 | 694 | p.armor = 0; |
| 646 | 695 | p.helmet = false; |
| 647 | 696 | p.defuseKit = false; |
| 648 | p.grenades = 0; | |
| 697 | p.pocket = { he: 0, flash: 0 }; | |
| 649 | 698 | p.secondary = p.team === "t" ? "glock" : "usp"; |
| 650 | 699 | p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) }; |
| 651 | 700 | p.weapon = p.secondary; |
| @@ -658,6 +707,11 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 658 | 707 | } |
| 659 | 708 | if (world.match.phase === "freeze" && before === "over") startRound(world); |
| 660 | 709 | |
| 710 | // A flash burns down on the same clock its fuse did — the same number of | |
| 711 | // ticks on a phone as on a desktop, and the same in a replay as in a match — | |
| 712 | // and it does so for everybody, standing or dead, before anyone acts on it. | |
| 713 | for (const p of world.players) if (p.blind > 0) p.blind = Math.max(0, p.blind - dt); | |
| 714 | ||
| 661 | 715 | applyInput(world, world.me, world.me.alive ? input : NO_INPUT, dt); |
| 662 | 716 | for (const p of world.players) { |
| 663 | 717 | if (!p.bot || !p.alive) continue; |
src/games.ts +14 −1
| @@ -235,6 +235,19 @@ export type Game = { | ||
| 235 | 235 | * the ceiling did not move — but like the range's after #88 that is the thinnest |
| 236 | 236 | * headroom on this page, and the next thing the shooter gains will want it |
| 237 | 237 | * raised. Neither other bundle moved a byte: nothing outside `src/client/` did. |
| 238 | * #164 put the second grenade in the game — a flashbang through the machinery | |
| 239 | * #142 built, which is one `Grenade.weapon`, one `Flash` row in the weapon | |
| 240 | * table, `blindSeconds` and `flashHits`, a `blind` on every body, the white-out | |
| 241 | * in the HUD, the flashed bot in `bots.ts`, a mesh, a sound and a key. The app | |
| 242 | * grew 2,361 raw / 742 gzipped bytes for all of it, measured on top of #176: | |
| 243 | * 86,909 → 89,270 raw and 39,626 → 40,368 gzipped. That is past the raw half of | |
| 244 | * the 88,000 / 40,500 #142 set — the line above about that being the thinnest | |
| 245 | * headroom on this page was right, and this is the pull that wanted it raised — | |
| 246 | * so the ceiling is 92,000 / 42,000 here, today's size plus about three per | |
| 247 | * cent of raw and four of gzip, which is room for an honest feature and not | |
| 248 | * room for a stray import of something large. Neither other bundle moved a | |
| 249 | * byte: nothing outside `src/client/` and `src/pages/game.ts` changed here, and | |
| 250 | * neither of them imports either. Measured 2026-09-20, with #176 merged in. | |
| 238 | 251 | */ |
| 239 | 252 | export const GAMES: readonly Game[] = [ |
| 240 | 253 | { |
| @@ -242,7 +255,7 @@ export const GAMES: readonly Game[] = [ | ||
| 242 | 255 | href: "/", |
| 243 | 256 | label: "cs", |
| 244 | 257 | entry: "src/client/main.ts", |
| 245 | ceiling: { raw: 88_000, gzip: 40_500 }, | |
| 258 | ceiling: { raw: 92_000, gzip: 42_000 }, | |
| 246 | 259 | blurb: |
| 247 | 260 | "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.", |
| 248 | 261 | }, |
src/pages/game.ts +6 −2
| @@ -91,7 +91,7 @@ const menu = (visitor: Who): string => ` | ||
| 91 | 91 | to your site is the walk to theirs.</p> |
| 92 | 92 | <p class="hint" id="prep">Preparing textures…</p> |
| 93 | 93 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 94 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw a grenade · | |
| 94 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · | |
| 95 | 95 | <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · |
| 96 | 96 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 97 | 97 | <kbd>Esc</kbd> pause</p> |
| @@ -124,6 +124,7 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions | ||
| 124 | 124 | [ |
| 125 | 125 | { action: "reload", label: "reload" }, |
| 126 | 126 | { action: "grenade", label: "nade" }, |
| 127 | { action: "flash", label: "flash" }, | |
| 127 | 128 | { action: "buy", label: "buy" }, |
| 128 | 129 | { action: "fire", label: "fire" }, |
| 129 | 130 | ], |
| @@ -142,7 +143,10 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions | ||
| 142 | 143 | (friction, air acceleration, 18-unit steps, strafe jumping), CS damage with armour and |
| 143 | 144 | hitgroups, fixed spray patterns you can learn, a round clock, a bomb, the buy economy and an |
| 144 | 145 | HE grenade that bounces off the walls on the same solver the players walk on — 98 damage at |
| 145 | the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see. | |
| 146 | the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see — | |
| 147 | and a flashbang thrown the same way, which takes sight rather than health: up to 1.6 seconds | |
| 148 | of it, by the angle you were looking at it from and how far away it went off, and none at all | |
| 149 | through a wall or over your shoulder. | |
| 146 | 150 | Everything you see is generated — the map, the textures, the models and the sounds — so the |
| 147 | 151 | whole game is one small download and no asset files.</p> |
| 148 | 152 | <p>The bots path to a site and plant or defuse on their own, but they do not aim by rule: |
static/app.467be451f6.js added +58
Diff not shown — a line 71,064 characters wide (limit 2,000). +58
View filestatic/app.9a24651e0a.js deleted −58
Diff not shown — a line 68,704 characters wide (limit 2,000). −58
View file
new above
offline