Public: anyone can read this; the crew can change it.
forked from trevato/cs
Smoke that actually blocks sight: one cloud, in the simulation as well as on the screen #254
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/165-smoke-that-actually-blocks-sight-one-clo → main 1 commit fork
29 files
changed +957
−128
scripts/build.test.ts +1 −1
| @@ -41,7 +41,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 41 | 41 | |
| 42 | 42 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 43 | 43 | expect(ENTRIES).toEqual([ |
| 44 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 98_000, gzip: 39_000 } }, | |
| 44 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 103_000, gzip: 41_000 } }, | |
| 45 | 45 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } }, |
| 46 | 46 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } }, |
| 47 | 47 | ]); |
src/budget.test.ts +37
| @@ -188,6 +188,43 @@ test("a 64 Hz tick with ten bodies stays well inside the frame budget", () => { | ||
| 188 | 188 | expect(alive).toBe(10); |
| 189 | 189 | }, PATIENCE); |
| 190 | 190 | |
| 191 | /** | |
| 192 | * The same tick with smoke standing in the round: ten clouds, one per body, | |
| 193 | * which is the most this game can have at once and far more than a round ever | |
| 194 | * does. It is measured against the *same* ceiling as the plain tick, because | |
| 195 | * what #165 promised is that a cloud does not buy a sight test a second price: | |
| 196 | * `canSee` traces the level's fifty-odd boxes first, as it always has, and then | |
| 197 | * runs `segmentSphere` over the clouds — a dot product and at most one square | |
| 198 | * root each, with no allocation — and in a round nobody smoked the loop is a | |
| 199 | * length check on an empty array and costs nothing measurable at all. | |
| 200 | * | |
| 201 | * Measured on the machine that wrote this, idle: a plain tick 0.102 ms, this | |
| 202 | * one 0.107 ms. Ten clouds cost about **0.005 ms a tick** — three hundredths of | |
| 203 | * one per cent of the 15.625 ms a frame has, and a twentieth of what a tick | |
| 204 | * already cost — and both are nearly ten times under the 1.0 ms ceiling, which | |
| 205 | * did not move for any of this. | |
| 206 | */ | |
| 207 | test("a tick with ten clouds of smoke standing is still well inside the budget", () => { | |
| 208 | useShipped(); | |
| 209 | let clouds = 0; | |
| 210 | const each = measure("a 64 Hz tick with ten clouds standing", TICK_CEILING, () => { | |
| 211 | const world = liveRound(7); | |
| 212 | // One cloud on each body, opened a moment ago so every one of them is at | |
| 213 | // its full radius for the whole batch rather than growing out of nothing. | |
| 214 | for (const p of world.players) | |
| 215 | world.clouds.push({ pos: [p.pos[0], p.pos[1], p.pos[2] + 58], born: world.time - 2, owner: p.id }); | |
| 216 | const batch = timed(TICKS, () => { | |
| 217 | for (let i = 0; i < TICKS; i++) update(world, TICK, NO_INPUT); | |
| 218 | }); | |
| 219 | clouds = world.clouds.length; | |
| 220 | return batch; | |
| 221 | }); | |
| 222 | ||
| 223 | expect(each).toBeLessThan(TICK_CEILING); | |
| 224 | // The ticks that were measured really did have ten clouds standing in them. | |
| 225 | expect(clouds).toBe(10); | |
| 226 | }, PATIENCE); | |
| 227 | ||
| 191 | 228 | /** |
| 192 | 229 | * The guard's own guard. A ceiling nothing can reach is a green light wired to |
| 193 | 230 | * nothing: a loop the JIT lifted out, a clock read twice in a row, a batch that |
src/client/audio.ts +12
| @@ -198,6 +198,18 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio { | ||
| 198 | 198 | device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08, pan }); |
| 199 | 199 | return; |
| 200 | 200 | } |
| 201 | if (sound === "smoke") { | |
| 202 | // The canister opening: no bang at all. A long, soft, wide hiss that | |
| 203 | // fades away rather than cracks — it is the one grenade noise in this | |
| 204 | // game that is a *process* and not an event, and it is quiet, because | |
| 205 | // what a smoke costs you is what you can see and never what you hear. | |
| 206 | // It carries like a footstep does and not like a blast: a cloud across | |
| 207 | // the map is not information anybody should get for free. | |
| 208 | const soft = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE); | |
| 209 | device.noiseBurst({ frequency: 3200, gain: 0.2 * soft, length: 1.4, q: 0.5, rate: 0.9, pan }); | |
| 210 | device.tone({ frequency: 420, slideTo: 210, gain: 0.1 * soft, length: 0.9, pan }); | |
| 211 | return; | |
| 212 | } | |
| 201 | 213 | if (sound === "flash") { |
| 202 | 214 | // The flash: the opposite noise to the blast in every way. A bright, |
| 203 | 215 | // tight crack with no body under it and a ring above it that climbs |
src/client/binding.test.ts +2 −1
| @@ -19,12 +19,13 @@ test("the bindings are the ones the keyboard has always had", () => { | ||
| 19 | 19 | expect(CS_KEYS.KeyB).toBe("buy"); |
| 20 | 20 | expect(CS_KEYS.KeyG).toBe("grenade"); |
| 21 | 21 | expect(CS_KEYS.KeyT).toBe("flash"); |
| 22 | expect(CS_KEYS.KeyV).toBe("smoke"); | |
| 22 | 23 | expect(CS_KEYS.Tab).toBe("scoreboard"); |
| 23 | 24 | expect(CS_KEYS.Digit1).toBe("slot1"); |
| 24 | 25 | expect(CS_KEYS.Digit3).toBe("slot3"); |
| 25 | 26 | expect(CS_MOUSE[0]).toBe("fire"); |
| 26 | 27 | expect(CS_MOUSE[2]).toBe("scope"); |
| 27 | expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash"]); | |
| 28 | expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash", "smoke"]); | |
| 28 | 29 | // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks |
| 29 | 30 | // the browser for the screen, and nothing about an `Input` changes for it. |
| 30 | 31 | expect(CS_KEYS.KeyF).toBe("fullscreen"); |
src/client/binding.ts +8 −5
| @@ -13,7 +13,8 @@ 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", KeyT: "flash", KeyB: "buy", Tab: "scoreboard", | |
| 16 | KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyV: "smoke", | |
| 17 | KeyB: "buy", Tab: "scoreboard", | |
| 17 | 18 | Digit1: "slot1", Digit2: "slot2", Digit3: "slot3", |
| 18 | 19 | // Neither of these is a thing the world knows about: `main.ts` hears them on |
| 19 | 20 | // `onAction`, and `readIntent` below never sees either. F asks the browser for |
| @@ -28,7 +29,7 @@ export const CS_KEYS: Record<string, string> = { | ||
| 28 | 29 | export const CS_MOUSE: Record<number, string> = { 0: "fire", 2: "scope" }; |
| 29 | 30 | |
| 30 | 31 | /** On-screen buttons that are a tap, not a hold. */ |
| 31 | export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash"] as const; | |
| 32 | export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash", "smoke"] as const; | |
| 32 | 33 | |
| 33 | 34 | export type Intent = { |
| 34 | 35 | input: Input; |
| @@ -55,9 +56,11 @@ export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: num | ||
| 55 | 56 | use: down("use"), |
| 56 | 57 | fire: down("fire"), |
| 57 | 58 | scope: down("scope"), |
| 58 | // Two pockets, two keys. Both held is the flash, because the one you just | |
| 59 | // reached for is the one you meant; releasing it throws the HE still held. | |
| 60 | throwing: down("flash") ? "flash" : down("grenade") ? "he" : null, | |
| 59 | // Three pockets, three keys, read newest-reach-first: the one nearest the | |
| 60 | // front of this list wins while more than one is held, because the one you | |
| 61 | // just reached for is the one you meant, and releasing it throws whichever | |
| 62 | // of the others is still down. | |
| 63 | throwing: down("smoke") ? "smoke" : down("flash") ? "flash" : down("grenade") ? "he" : null, | |
| 61 | 64 | switchTo: null, |
| 62 | 65 | }; |
| 63 | 66 | return { |
src/client/bots.ts +81 −1
| @@ -19,6 +19,13 @@ | ||
| 19 | 19 | * — the arena's, the trainer's — behave exactly as they did before, down to the |
| 20 | 20 | * coin flip, because `usePlan` was never called on them. |
| 21 | 21 | * |
| 22 | * Smoke is handled in the same layer, and deliberately not in the policy. A bot | |
| 23 | * cannot see through a cloud because `canSee` says so, which is one change in | |
| 24 | * one function and covers the shooting; what it cannot answer is the walking, | |
| 25 | * so `skirt` below bends the next step of a path around a cloud rather than | |
| 26 | * into it. Both are scripted, both are outside the network, and the shipped | |
| 27 | * weights are the same eight kilobytes they were. | |
| 28 | * | |
| 22 | 29 | * The weights are not in here and are not imported: they arrive as data. In a |
| 23 | 30 | * browser `src/client/main.ts` downloads `weights.json` as this game's own |
| 24 | 31 | * asset and calls `usePolicy` before Play is pressable; in Bun |
| @@ -38,6 +45,7 @@ import { CS_HULL } from "./movement.ts"; | ||
| 38 | 45 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| 39 | 46 | import { SHAPE } from "./packed.ts"; |
| 40 | 47 | import { ease, grip, gripRate, sprayPull } from "./spray.ts"; |
| 48 | import { cloudRadius } from "./grenade.ts"; | |
| 41 | 49 | import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts"; |
| 42 | 50 | import { buy } from "./economy.ts"; |
| 43 | 51 | import type { Input, Player, World } from "./world.ts"; |
| @@ -430,6 +438,74 @@ function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null { | ||
| 430 | 438 | return best; |
| 431 | 439 | } |
| 432 | 440 | |
| 441 | // --- smoke ---------------------------------------------------------------- | |
| 442 | ||
| 443 | /** | |
| 444 | * How far outside a cloud a bot aims to walk, in units. A hull's width, so it | |
| 445 | * skirts the fringe of one rather than clipping through it — and so that a | |
| 446 | * diversion is a visible step around something and not a shuffle along its | |
| 447 | * edge. | |
| 448 | */ | |
| 449 | const SMOKE_CLEARANCE = CS_HULL.radius * 2; | |
| 450 | ||
| 451 | /** | |
| 452 | * Where this bot should walk instead of straight at `step`, given the clouds | |
| 453 | * standing in the round. | |
| 454 | * | |
| 455 | * A cloud is not a wall — a body walks through one exactly as it walks through | |
| 456 | * air, and it should, because walking through smoke is half of what smoke is | |
| 457 | * for — but it *is* a wall to this bot's eyes: `canSee` refuses a line through | |
| 458 | * one, so a bot that strolls into a cloud arrives blind at whatever is waiting | |
| 459 | * on the other side and has given away the fight it was walking to. So the | |
| 460 | * scripted layer treats it as something to go around, and the policy is told | |
| 461 | * nothing: the shipped weights, `OBS_SIZE` and every head are untouched by all | |
| 462 | * of this. | |
| 463 | * | |
| 464 | * Two cases and no third. Standing in one, the shortest way out is straight | |
| 465 | * away from the middle, and that is where the bot walks. Heading into one, the | |
| 466 | * step is pushed sideways to whichever edge is nearer — the tangent, plus the | |
| 467 | * clearance — so the path bends around the cloud and rejoins itself. A cloud | |
| 468 | * behind the bot, beside the walk, or past the end of this step is left alone, | |
| 469 | * which is every cloud on most ticks; a round nobody smoked never reaches here | |
| 470 | * at all, because the caller checks the list is empty first. | |
| 471 | * | |
| 472 | * The step comes back unchanged when there is nothing to do, so the common case | |
| 473 | * allocates nothing. | |
| 474 | */ | |
| 475 | export function skirt(world: World, bot: Player, step: Vec3): Vec3 { | |
| 476 | for (const cloud of world.clouds) { | |
| 477 | const radius = cloudRadius(cloud, world.time); | |
| 478 | if (radius <= 0) continue; | |
| 479 | const keep = radius + SMOKE_CLEARANCE; | |
| 480 | const cx = cloud.pos[0] - bot.pos[0]; | |
| 481 | const cy = cloud.pos[1] - bot.pos[1]; | |
| 482 | const here = Math.hypot(cx, cy); | |
| 483 | if (here < keep) { | |
| 484 | // Inside it, or in its fringe: out, by the shortest line there is. Dead | |
| 485 | // in the middle there is no such line, so the bot keeps walking rather | |
| 486 | // than picking a direction out of nothing. | |
| 487 | if (here < 1e-6) continue; | |
| 488 | return [bot.pos[0] - (cx / here) * keep, bot.pos[1] - (cy / here) * keep, step[2]]; | |
| 489 | } | |
| 490 | const dx = step[0] - bot.pos[0]; | |
| 491 | const dy = step[1] - bot.pos[1]; | |
| 492 | const len = Math.hypot(dx, dy); | |
| 493 | if (len < 1e-6) continue; | |
| 494 | const ux = dx / len, uy = dy / len; | |
| 495 | // How far along the walk the cloud sits, and how far off to one side. | |
| 496 | const along = cx * ux + cy * uy; | |
| 497 | if (along <= 0 || along > len + keep) continue; | |
| 498 | const off = cx * -uy + cy * ux; | |
| 499 | if (Math.abs(off) >= keep) continue; | |
| 500 | // Around the near edge: the perpendicular, away from the side the cloud is | |
| 501 | // on, by just enough to put the walk on the tangent. | |
| 502 | const side = off >= 0 ? -1 : 1; | |
| 503 | const push = (keep - Math.abs(off)) * side; | |
| 504 | return [step[0] + -uy * push, step[1] + ux * push, step[2]]; | |
| 505 | } | |
| 506 | return step; | |
| 507 | } | |
| 508 | ||
| 433 | 509 | /** An enemy shooting nearby is a direction to look, even through a wall. */ |
| 434 | 510 | function listen(world: World, bot: Player, brain: BotBrain): void { |
| 435 | 511 | for (const other of world.players) { |
| @@ -659,7 +735,11 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 659 | 735 | const next = brain.path[brain.step]; |
| 660 | 736 | if (next !== undefined) { |
| 661 | 737 | if (dist([bot.pos[0], bot.pos[1], 0], [next[0], next[1], 0]) < 48) brain.step += 1; |
| 662 | const aim = aimAt(bot, [next[0], next[1], bot.pos[2] + 48]); | |
| 738 | // The path is A*'s and it knows nothing about smoke, which is right: the | |
| 739 | // cells are still walkable and a cloud is gone in fifteen seconds. What | |
| 740 | // bends is the *step*, here and only while there is a cloud to bend it. | |
| 741 | const walk = world.clouds.length === 0 ? next : skirt(world, bot, next); | |
| 742 | const aim = aimAt(bot, [walk[0], walk[1], bot.pos[2] + 48]); | |
| 663 | 743 | const look = brain.suspect !== null ? aimAt(bot, brain.suspect) : { yaw: aim.yaw, pitch: 0 }; |
| 664 | 744 | turnTowards(bot, { yaw: look.yaw, pitch: clamp(look.pitch, -20, 20) }, brain.aimSpeed * 0.5, dt); |
| 665 | 745 | const off = angleDelta(bot.yaw, aim.yaw); |
src/client/economy.test.ts +1 −1
| @@ -21,7 +21,7 @@ const buyer = (over: Partial<Buyer> = {}): Buyer => ({ | ||
| 21 | 21 | armor: 0, helmet: false, defuseKit: false, |
| 22 | 22 | weapon: "usp", primary: null, secondary: "usp", |
| 23 | 23 | ammo: { knife: { mag: 0, reserve: 0 }, usp: emptyAmmo("usp") }, |
| 24 | pocket: { he: 0, flash: 0 }, | |
| 24 | pocket: { he: 0, flash: 0, smoke: 0 }, | |
| 25 | 25 | ...over, |
| 26 | 26 | }); |
| 27 | 27 |
src/client/economy.ts +5 −4
| @@ -25,11 +25,12 @@ export const emptyAmmo = (id: WeaponId): Ammo => | ||
| 25 | 25 | ({ mag: WEAPONS[id].magazine, reserve: WEAPONS[id].reserve }); |
| 26 | 26 | |
| 27 | 27 | /** |
| 28 | * How many of each thrown weapon a player may carry into a round. One HE and | |
| 29 | * one flashbang — two grenades, bought separately in the freeze, each refused | |
| 30 | * once the pocket holds its limit, and both gone with the body that held them. | |
| 28 | * How many of each thrown weapon a player may carry into a round. One HE, one | |
| 29 | * flashbang and one smoke — three grenades, bought separately in the freeze, | |
| 30 | * each refused once the pocket holds its limit, and all gone with the body that | |
| 31 | * held them. | |
| 31 | 32 | */ |
| 32 | export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1 }; | |
| 33 | export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1, smoke: 1 }; | |
| 33 | 34 | |
| 34 | 35 | export type BuyItem = WeaponId | "armor" | "helmet" | "kit"; |
| 35 | 36 |
src/client/generate.test.ts +6 −1
| @@ -370,7 +370,12 @@ test("a bot walks a map nobody wrote, on that map's own cells", () => { | ||
| 370 | 370 | * field list still comes to the digest this line held before, |
| 371 | 371 | * 48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae. |
| 372 | 372 | */ |
| 373 | const GENERATED_ROUND = "d7bd4969d00345feff718f37a6ed13ff6967f8884ca716bc639bff63f085d82e"; | |
| 373 | // Re-pinned for #165, which put a smoke grenade in the game: the hash moved | |
| 374 | // and the play did not. A pocket is three counts now rather than two, a | |
| 375 | // grenade's kind is an index into three rather than a flag, and every tick | |
| 376 | // carries however many clouds are standing — and nothing in this run throws | |
| 377 | // anything, so `clouds` is empty on all 640 ticks of it. | |
| 378 | const GENERATED_ROUND = "619ce3102805d11dc40e8d545267e99034bfb3bdab2961202703423a87f6e500"; | |
| 374 | 379 | |
| 375 | 380 | test("a seeded round on a generated map plays exactly as it played", () => { |
| 376 | 381 | const level = generateLevel(5); |
src/client/grenade.test.ts +2 −2
| @@ -458,7 +458,7 @@ test("a flash is bought alongside the HE, one of each, and thrown on its own key | ||
| 458 | 458 | expect(buy(world, world.me, "he")).toBe(true); |
| 459 | 459 | expect(buy(world, world.me, "flash")).toBe(true); |
| 460 | 460 | // Carried alongside, not instead of: both are in the pocket at once. |
| 461 | expect(world.me.pocket).toEqual({ he: CARRY.he, flash: CARRY.flash }); | |
| 461 | expect(world.me.pocket).toEqual({ he: CARRY.he, flash: CARRY.flash, smoke: 0 }); | |
| 462 | 462 | expect(world.me.money).toBe(5000 - HE.price - FLASH.price); |
| 463 | 463 | // One of each a round: the second of either is refused and costs nothing. |
| 464 | 464 | expect(buy(world, world.me, "flash")).toBe(false); |
| @@ -472,7 +472,7 @@ test("a flash is bought alongside the HE, one of each, and thrown on its own key | ||
| 472 | 472 | for (let t = 0; t < 32; t++) update(world, TICK, { ...NO_INPUT, throwing: "flash" }); |
| 473 | 473 | expect(world.grenades.length).toBe(1); |
| 474 | 474 | expect(world.grenades[0]?.weapon).toBe("flash"); |
| 475 | expect(world.me.pocket).toEqual({ he: 1, flash: 0 }); | |
| 475 | expect(world.me.pocket).toEqual({ he: 1, flash: 0, smoke: 0 }); | |
| 476 | 476 | }); |
| 477 | 477 | |
| 478 | 478 | /** Everyone but `p` put out of the round, so only the flash can change anything. */ |
src/client/grenade.ts +131 −15
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | /** |
| 2 | 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 | * out. Three kinds are thrown today — the HE, the flashbang and the smoke — and | |
| 4 | * the only thing in this file that knows which is `Grenade.weapon`: the flight, | |
| 5 | * the bounce, the fuse and the sounds are one piece of machinery all three use, | |
| 6 | * and what arrives at the far end is `blastHits` for one, `flashHits` for | |
| 7 | * another and `cloudFrom` for the third. A fourth kind is a row in `weapons.ts` | |
| 8 | * and a function here. | |
| 8 | 9 | * |
| 9 | 10 | * There is no solver in this file. A grenade in flight is a `Body` like a |
| 10 | 11 | * player is, and it is moved by the same `move` in `src/engine/physics.ts` — |
| @@ -17,11 +18,17 @@ | ||
| 17 | 18 | * 64 Hz `world.ts` steps at — so it is the same number of ticks on a phone as |
| 18 | 19 | * on a desktop, and the same number in a replay as in a match. |
| 19 | 20 | * |
| 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. | |
| 21 | * Neither of the first two reaches anything it cannot see. `blastHits` and | |
| 22 | * `flashHits` both trace from the middle of the detonation through the engine's | |
| 23 | * own `visible`, so a wall, a crate or a corner takes all of it: a grenade | |
| 24 | * cannot kill through a wall here and a flash cannot blind through one, which | |
| 25 | * are the properties the two tests of that name hold. | |
| 26 | * | |
| 27 | * The smoke is the odd one and it is odd in the other direction: it reaches | |
| 28 | * nobody at all, and what it leaves behind outlives it. `cloudFrom` turns a | |
| 29 | * spent canister into a `Cloud` — a ball with a clock — which the world keeps | |
| 30 | * and `canSee` in `src/client/world.ts` traces against. Nothing about a cloud | |
| 31 | * is drawing: it is the same ball on the screen and in the simulation. | |
| 25 | 32 | */ |
| 26 | 33 | |
| 27 | 34 | import { |
| @@ -30,7 +37,10 @@ import { | ||
| 30 | 37 | } from "../engine/index.ts"; |
| 31 | 38 | import { hitboxes, type Actor } from "./combat.ts"; |
| 32 | 39 | import { CS_HULL, NADE_HULL, NADE_MOVE } from "./movement.ts"; |
| 33 | import { blastDamage, blindSeconds, fuseOf, WEAPONS, throughArmor, type ThrownId } from "./weapons.ts"; | |
| 40 | import { | |
| 41 | blastDamage, blindSeconds, fuseOf, SMOKE, WEAPONS, throughArmor, | |
| 42 | type Smoke, type ThrownId, | |
| 43 | } from "./weapons.ts"; | |
| 34 | 44 | |
| 35 | 45 | /** A grenade in the world: a body, which kind it is, whose it is, and how long it has left. */ |
| 36 | 46 | export type Grenade = Body & { |
| @@ -66,11 +76,11 @@ const HAND = 18; | ||
| 66 | 76 | |
| 67 | 77 | /** |
| 68 | 78 | * 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. | |
| 79 | * two belong to every thrown thing; the rest are each a kind's own — the HE's | |
| 80 | * `blast`, the flashbang's `flash`, the canister's `smoke` — which is why this | |
| 81 | * is one widening type and not a second one per grenade. | |
| 72 | 82 | */ |
| 73 | export type GrenadeSound = "throw" | "bounce" | "blast" | "flash"; | |
| 83 | export type GrenadeSound = "throw" | "bounce" | "blast" | "flash" | "smoke"; | |
| 74 | 84 | |
| 75 | 85 | /** |
| 76 | 86 | * Below this the knock of a bounce is not worth a sound: a grenade rolling to |
| @@ -132,6 +142,44 @@ export function stepGrenade(g: Grenade, dt: number, boxes: Box[]): boolean { | ||
| 132 | 142 | ); |
| 133 | 143 | } |
| 134 | 144 | |
| 145 | /** | |
| 146 | * How slowly a grenade has to be moving, in u/s, to count as having stopped. | |
| 147 | * A shell settling on concrete never quite reaches zero — the solver leaves it | |
| 148 | * a fraction of a unit a second — so "at rest" is a small number and not one. | |
| 149 | */ | |
| 150 | const REST_SPEED = 20; | |
| 151 | ||
| 152 | /** | |
| 153 | * How many seconds past its fuse a smoke will wait for the floor before it | |
| 154 | * opens anyway. A canister thrown off a ledge, wedged in a corner or bouncing | |
| 155 | * down a long stair is still a grenade somebody threw, and one that never opens | |
| 156 | * is worse than one that opens in the air. Two seconds is longer than any throw | |
| 157 | * on any map this game generates takes to settle. | |
| 158 | */ | |
| 159 | const REST_PATIENCE = 2; | |
| 160 | ||
| 161 | /** Is this grenade lying still enough for a cloud to grow where it stopped? */ | |
| 162 | export const atRest = (g: Grenade): boolean => | |
| 163 | g.onGround && Math.hypot(g.vel[0], g.vel[1], g.vel[2]) < REST_SPEED; | |
| 164 | ||
| 165 | /** | |
| 166 | * Has this one's fuse run out — has it done whatever it does yet? | |
| 167 | * | |
| 168 | * For the HE and the flashbang this is the comparison it has always been: zero | |
| 169 | * on the fuse is the tick it goes off, in the air or on the floor, because a | |
| 170 | * blast and a flash happen wherever the thing happens to be. | |
| 171 | * | |
| 172 | * A smoke is different in the one way that matters to a round: a cloud grows | |
| 173 | * where the canister *stopped*, so a fuse that runs out in mid-flight is held | |
| 174 | * — the fuse keeps burning negative, which is what makes the wait a number the | |
| 175 | * digest can see — until the thing is on the floor and still, or until | |
| 176 | * `REST_PATIENCE` seconds have gone by and it opens where it is. | |
| 177 | */ | |
| 178 | export function spent(g: Grenade): boolean { | |
| 179 | if (g.weapon !== "smoke") return g.fuse <= 0; | |
| 180 | return g.fuse <= -REST_PATIENCE || (g.fuse <= 0 && atRest(g)); | |
| 181 | } | |
| 182 | ||
| 135 | 183 | /** The middle of the explosion: the middle of the grenade, not its feet. */ |
| 136 | 184 | export const blastCentre = (g: Grenade): Vec3 => [g.pos[0], g.pos[1], g.pos[2] + NADE_HULL.stand / 2]; |
| 137 | 185 | |
| @@ -238,3 +286,71 @@ export function flashHits(g: Grenade, actors: Looker[], boxes: Box[]): Blinded[] | ||
| 238 | 286 | } |
| 239 | 287 | return blinded; |
| 240 | 288 | } |
| 289 | ||
| 290 | // --- the cloud ------------------------------------------------------------ | |
| 291 | ||
| 292 | /** | |
| 293 | * A cloud of smoke standing in the world: where its middle is, when it opened, | |
| 294 | * and whose canister it was. Everything else about it — how wide it is right | |
| 295 | * now, whether it is still there at all — is arithmetic on `born` and the | |
| 296 | * world's clock, which is why there is no radius on this record and no tick of | |
| 297 | * a cloud anywhere in the game. A cloud is not stepped; it is *read*. | |
| 298 | */ | |
| 299 | export type Cloud = { | |
| 300 | pos: Vec3; | |
| 301 | /** The simulated second the canister opened. */ | |
| 302 | born: number; | |
| 303 | /** The player id that threw it. Nothing reads it yet; the feed will. */ | |
| 304 | owner: number; | |
| 305 | }; | |
| 306 | ||
| 307 | /** | |
| 308 | * How high above the canister the middle of the ball sits, in units. A smoke | |
| 309 | * lying on the floor with its middle at its own height would be a ball half | |
| 310 | * buried in concrete and a hole a crouching body could see under; lifting it | |
| 311 | * most of a standing hull puts the bottom of the ball on the floor and the top | |
| 312 | * of it over a head, which is the wall a smoke is supposed to be. | |
| 313 | */ | |
| 314 | const CLOUD_LIFT = 58; | |
| 315 | ||
| 316 | /** The cloud a spent canister leaves behind, opening at `time`. */ | |
| 317 | export const cloudFrom = (g: Grenade, time: number): Cloud => ({ | |
| 318 | pos: [g.pos[0], g.pos[1], g.pos[2] + CLOUD_LIFT], | |
| 319 | born: time, | |
| 320 | owner: g.owner, | |
| 321 | }); | |
| 322 | ||
| 323 | /** | |
| 324 | * How wide this cloud is at `time`, in units, and zero once it is gone — | |
| 325 | * | |
| 326 | * r(age) = radius · age / grow age < grow | |
| 327 | * = radius grow ≤ age < life − fade | |
| 328 | * = radius · (life − age) / fade life − fade ≤ age < life | |
| 329 | * = 0 age < 0 or age ≥ life | |
| 330 | * | |
| 331 | * One number, read by three callers that must never disagree: the sight test in | |
| 332 | * `world.ts`, the ball `scene.ts` places, and the wash `hud.ts` puts over the | |
| 333 | * screen when your eye is inside it. A cloud that blocked a bot's line for a | |
| 334 | * second longer than it was drawn would be exactly the bug this shape rules | |
| 335 | * out. | |
| 336 | * | |
| 337 | * The default is the one smoke grenade in the table. It is an argument because | |
| 338 | * a test wants to say "a cloud this wide" without reaching for the weapon. | |
| 339 | */ | |
| 340 | /** | |
| 341 | * Is this cloud over? Asked by the world's housekeeping rather than by the | |
| 342 | * radius above, because the two differ at exactly one instant and it is the | |
| 343 | * instant that matters: a cloud is *born* at radius zero and is not gone, and a | |
| 344 | * prune written against the radius would drop every cloud on the tick it opened. | |
| 345 | */ | |
| 346 | export const cloudGone = (cloud: Cloud, time: number, smoke: Smoke = SMOKE.smoke): boolean => | |
| 347 | time - cloud.born >= smoke.life; | |
| 348 | ||
| 349 | export function cloudRadius(cloud: Cloud, time: number, smoke: Smoke = SMOKE.smoke): number { | |
| 350 | const age = time - cloud.born; | |
| 351 | if (age < 0 || age >= smoke.life) return 0; | |
| 352 | if (age < smoke.grow) return smoke.radius * (age / smoke.grow); | |
| 353 | const left = smoke.life - age; | |
| 354 | if (left < smoke.fade) return smoke.radius * (left / smoke.fade); | |
| 355 | return smoke.radius; | |
| 356 | } |
src/client/hud.test.ts +2 −2
| @@ -616,12 +616,12 @@ test("the pocket line names each grenade you are carrying, and both at once", () | ||
| 616 | 616 | const none = draw(scene()); |
| 617 | 617 | expect(words(none).some((t) => t.includes("HE"))).toBe(false); |
| 618 | 618 | const both = draw(scene((w) => { |
| 619 | (w.me as Player).pocket = { he: 1, flash: 1 }; | |
| 619 | (w.me as Player).pocket = { he: 1, flash: 1, smoke: 0 }; | |
| 620 | 620 | })); |
| 621 | 621 | const named = words(both).find((t) => t.includes("HE")) ?? ""; |
| 622 | 622 | expect(named).toContain("HE"); |
| 623 | 623 | expect(named).toContain("flash"); |
| 624 | const only = draw(scene((w) => { (w.me as Player).pocket = { he: 0, flash: 1 }; })); | |
| 624 | const only = draw(scene((w) => { (w.me as Player).pocket = { he: 0, flash: 1, smoke: 0 }; })); | |
| 625 | 625 | const line = words(only).find((t) => t.includes("flash")) ?? ""; |
| 626 | 626 | expect(line).not.toContain("HE"); |
| 627 | 627 | }); |
src/client/hud.ts +28 −1
| @@ -30,7 +30,7 @@ import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from | ||
| 30 | 30 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 31 | 31 | import { useProgress } from "./bomb.ts"; |
| 32 | 32 | import { scoreLines } from "./summary.ts"; |
| 33 | import { currentWeapon, type Player, type World } from "./world.ts"; | |
| 33 | import { currentWeapon, eyePos, insideSmoke, type Player, type World } from "./world.ts"; | |
| 34 | 34 | |
| 35 | 35 | /** |
| 36 | 36 | * Everything this module asks of the thing it draws on. Nothing else: it is the |
| @@ -100,6 +100,22 @@ export const whiteout = (blind: number): number => PEAK * Math.min(1, blind / WH | ||
| 100 | 100 | const PEAK = 0.92; |
| 101 | 101 | const WHITEOUT_FADE = 0.9; |
| 102 | 102 | |
| 103 | /** | |
| 104 | * How much grey is over the world when the eye is inside a cloud of smoke. | |
| 105 | * | |
| 106 | * It is a constant and not a ramp, on purpose: the simulation's answer inside a | |
| 107 | * cloud is a flat *no* — `canSee` refuses a line that meets the ball at all, so | |
| 108 | * a bot one unit inside one is as blind as a bot in the middle — and a wash | |
| 109 | * that faded with depth would be the screen disagreeing with the round. Like | |
| 110 | * the white-out above it stops short of 1, so the crosshair and the world are a | |
| 111 | * ghost behind it rather than gone; unlike the white-out it does not fade, | |
| 112 | * because a cloud does not fade while you are standing in it. | |
| 113 | * | |
| 114 | * From outside, a cloud is a ball `scene.ts` places and this draws nothing at | |
| 115 | * all. The two are the same fact from the two sides of one surface. | |
| 116 | */ | |
| 117 | export const FOG = 0.9; | |
| 118 | ||
| 103 | 119 | /** |
| 104 | 120 | * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or |
| 105 | 121 | * the player you are watching once you are dead — and it is what every number |
| @@ -171,6 +187,16 @@ export function drawHud( | ||
| 171 | 187 | ctx.fillRect(0, 0, width, height); |
| 172 | 188 | } |
| 173 | 189 | |
| 190 | // --- and in the smoke: the same idea from inside a cloud instead of outside | |
| 191 | // one. The ball `scene.ts` draws is opaque and its far faces are culled, so | |
| 192 | // an eye within it would otherwise see straight through the thing that is | |
| 193 | // blocking every sightline in the simulation. Which eye is the view's, so a | |
| 194 | // spectator sees the fog the body they are watching is standing in. | |
| 195 | if (view.alive && insideSmoke(world, eyePos(view))) { | |
| 196 | ctx.fillStyle = `rgba(196,200,208,${FOG})`; | |
| 197 | ctx.fillRect(0, 0, width, height); | |
| 198 | } | |
| 199 | ||
| 174 | 200 | const scale = ui.scale; |
| 175 | 201 | const big = ui.font(30); |
| 176 | 202 | const mid = ui.font(19); |
| @@ -216,6 +242,7 @@ export function drawHud( | ||
| 216 | 242 | weapon.name + |
| 217 | 243 | (view.pocket.he > 0 ? " · HE" : "") + |
| 218 | 244 | (view.pocket.flash > 0 ? " · flash" : "") + |
| 245 | (view.pocket.smoke > 0 ? " · smoke" : "") + | |
| 219 | 246 | (view.hasBomb ? " · bomb" : ""), |
| 220 | 247 | width - pad, height - pad - Math.round(30 * scale), |
| 221 | 248 | { align: "right", font: small, fill: GREY }, |
src/client/main.test.ts +2 −1
| @@ -958,7 +958,8 @@ test("every mesh name a scene of this game can place is handed in at constructio | ||
| 958 | 958 | // table misses is an exception on the frame a player first holds that gun. |
| 959 | 959 | const meshes = gameMeshes(); |
| 960 | 960 | expect(Object.keys(meshes).sort()).toEqual([ |
| 961 | "bomb", "flash", "grenade", "knife", "level", "pistol", "player", "rifle", "smg", "sniper", | |
| 961 | "bomb", "cloud", "flash", "grenade", "knife", "level", "pistol", "player", "rifle", "smg", | |
| 962 | "smoke", "sniper", | |
| 962 | 963 | ]); |
| 963 | 964 | // Every kind a weapon can be, and the kind `scene.ts` falls back to. |
| 964 | 965 | for (const weapon of Object.values(WEAPONS)) expect(meshes[weapon.kind]).toBeDefined(); |
src/client/main.ts +4 −2
| @@ -1171,8 +1171,10 @@ export function boot(host: Host): Frame { | ||
| 1171 | 1171 | export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> { |
| 1172 | 1172 | const kinds = new Set(Object.values(WEAPONS).map((weapon) => weapon.kind)); |
| 1173 | 1173 | // `grenade` arrives as a weapon kind and is the HE's body in the world; the |
| 1174 | // flashbang shares that kind and needs its own shell, so it is named here. | |
| 1175 | const names = ["level", "player", "bomb", "flash", ...kinds]; | |
| 1174 | // flashbang and the smoke share that kind and need their own shells, so they | |
| 1175 | // are named here — and so is `cloud`, which is no weapon at all but the ball | |
| 1176 | // a spent smoke leaves standing. | |
| 1177 | const names = ["level", "player", "bomb", "flash", "smoke", "cloud", ...kinds]; | |
| 1176 | 1178 | return Object.fromEntries(names.map((name) => [name, meshFor(name, level)])); |
| 1177 | 1179 | } |
| 1178 | 1180 |
src/client/models.ts +68 −3
| @@ -58,11 +58,19 @@ export function bombMesh(): Mesh { | ||
| 58 | 58 | * |
| 59 | 59 | * The flashbang is the same shell in a different shape: taller, narrower and |
| 60 | 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. | |
| 61 | * at a glance. The smoke is the squat one — a fat canister with a vent collar | |
| 62 | * — which is CS's shape too. All three are the one recipe because all three are | |
| 63 | * one thrown thing. | |
| 62 | 64 | */ |
| 63 | export function grenadeMesh(kind: "he" | "flash" = "he"): Mesh { | |
| 65 | export function grenadeMesh(kind: "he" | "flash" | "smoke" = "he"): Mesh { | |
| 64 | 66 | const b = builder(); |
| 65 | 67 | const SHELL = 7, BAND = 8; |
| 68 | if (kind === "smoke") { | |
| 69 | cube(b, 0, 0, 3.5, 7, 7, 8, SHELL); // the fat canister | |
| 70 | cube(b, 0, 0, 7, 8, 8, 2, BAND); // the vent collar at its top | |
| 71 | cube(b, 0, 0, 8.5, 2.5, 2.5, 2, BAND); // the spoon on top | |
| 72 | return finish(b); | |
| 73 | } | |
| 66 | 74 | if (kind === "flash") { |
| 67 | 75 | cube(b, 0, 0, 3, 5, 5, 10, SHELL); // the canister |
| 68 | 76 | cube(b, 0, 0, 1, 6, 6, 2, BAND); // the band around its foot |
| @@ -76,6 +84,60 @@ export function grenadeMesh(kind: "he" | "flash" = "he"): Mesh { | ||
| 76 | 84 | return finish(b); |
| 77 | 85 | } |
| 78 | 86 | |
| 87 | /** | |
| 88 | * The radius the cloud mesh below is built at, in units. A mesh is uploaded | |
| 89 | * once and a cloud grows, so the ball is built at one size and the scene sets | |
| 90 | * `size` to the radius it wants over this one — which also stretches the | |
| 91 | * texture as it grows, which is what billowing looks like from outside. | |
| 92 | */ | |
| 93 | export const CLOUD_MESH_RADIUS = 64; | |
| 94 | ||
| 95 | /** | |
| 96 | * The lumps a cloud is built of, as [x, y, z, radius] in units of the ball's | |
| 97 | * own radius. Nine of them: one filling the middle and eight around it at a | |
| 98 | * little under the full radius, in two staggered rings so the silhouette is | |
| 99 | * bumpy from every side and from above. Written down rather than generated | |
| 100 | * because it is nine rows and a reader can see the shape. | |
| 101 | */ | |
| 102 | const PUFFS: [number, number, number, number][] = [ | |
| 103 | [0, 0, 0, 0.78], | |
| 104 | [0.52, 0.18, 0.1, 0.52], | |
| 105 | [-0.46, 0.34, -0.06, 0.55], | |
| 106 | [0.14, -0.55, 0.12, 0.5], | |
| 107 | [-0.2, -0.44, -0.2, 0.47], | |
| 108 | [0.3, 0.42, -0.34, 0.45], | |
| 109 | [-0.38, -0.12, 0.42, 0.46], | |
| 110 | [0.08, 0.2, 0.56, 0.44], | |
| 111 | [-0.1, 0.5, 0.3, 0.42], | |
| 112 | ]; | |
| 113 | ||
| 114 | /** | |
| 115 | * A cloud of smoke: nine overlapping cubes about the origin, at | |
| 116 | * `CLOUD_MESH_RADIUS`, on the sand layer. It is the *same ball* the sight test | |
| 117 | * in `src/client/world.ts` traces against — one mesh, opaque, drawn in the pass | |
| 118 | * that was already there, with no blending and no sorting — which is the whole | |
| 119 | * argument for drawing it this way: what you cannot see past on the screen and | |
| 120 | * what a bot cannot see past in the simulation are the same sphere, to the unit. | |
| 121 | * | |
| 122 | * Opaque means the inside of one is not drawn at all — the far faces are culled | |
| 123 | * — so being inside a cloud is drawn by `src/client/hud.ts` instead, as a wash | |
| 124 | * over the world. Outside it is a solid mass; inside it is fog. Two drawings of | |
| 125 | * one fact, and neither of them a new pass. | |
| 126 | * | |
| 127 | * There is no tenth material for it: the sand layer is smooth fbm noise with no | |
| 128 | * seam and no straight line in it, and the tint `scene.ts` places it with turns | |
| 129 | * that sand into a cool pale grey. The lighting the renderer already does — a | |
| 130 | * hemisphere ambient plus one sun — is what makes the lumps read as lumps. | |
| 131 | */ | |
| 132 | export function cloudMesh(): Mesh { | |
| 133 | const b = builder(); | |
| 134 | const SAND = 0; | |
| 135 | const r = CLOUD_MESH_RADIUS; | |
| 136 | for (const [x, y, z, size] of PUFFS) | |
| 137 | cube(b, x * r, y * r, z * r, size * 2 * r, size * 2 * r, size * 2 * r, SAND); | |
| 138 | return finish(b); | |
| 139 | } | |
| 140 | ||
| 79 | 141 | /** |
| 80 | 142 | * A first-person weapon, in view space: X right, Y up, Z back, so the barrel |
| 81 | 143 | * runs down -Z and the model needs no rotation at all. |
| @@ -100,7 +162,8 @@ export function viewModelMesh(kind: string): Mesh { | ||
| 100 | 162 | |
| 101 | 163 | /** |
| 102 | 164 | * 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 | |
| 165 | * `flash`, `smoke`, `cloud`, or a weapon kind for the gun in your hands. | |
| 166 | * `main.ts` builds one of each at start-up and | |
| 104 | 167 | * hands them to the engine's renderer, which keeps them by name and never |
| 105 | 168 | * learns what a name is made of — that is this file, and this is the one line a |
| 106 | 169 | * second game's model table would replace. |
| @@ -114,5 +177,7 @@ export function meshFor(name: string, level: Level): Mesh { | ||
| 114 | 177 | if (name === "bomb") return bombMesh(); |
| 115 | 178 | if (name === "grenade") return grenadeMesh(); |
| 116 | 179 | if (name === "flash") return grenadeMesh("flash"); |
| 180 | if (name === "smoke") return grenadeMesh("smoke"); | |
| 181 | if (name === "cloud") return cloudMesh(); | |
| 117 | 182 | return viewModelMesh(name); |
| 118 | 183 | } |
src/client/replay.test.ts +126 −37
| @@ -29,11 +29,13 @@ import { DE_FORGE } from "./map.ts"; | ||
| 29 | 29 | import { SHORT } from "./match.ts"; |
| 30 | 30 | import { CHECKPOINT_CHARS } from "../engine/index.ts"; |
| 31 | 31 | import { |
| 32 | compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND, | |
| 32 | compare, digest, fold, reader, snapshot, SMOKE_SCRIPT, TICK, TICKS_PER_SECOND, | |
| 33 | 33 | withoutEntropy, type Pin, type Run, |
| 34 | 34 | } from "./replay.ts"; |
| 35 | 35 | import { colOf, rowOf, walkable, type Level } from "./level.ts"; |
| 36 | import { createWorld, type Player } from "./world.ts"; | |
| 36 | import { createWorld, update, type Player } from "./world.ts"; | |
| 37 | import { cloudRadius } from "./grenade.ts"; | |
| 38 | import { SMOKE } from "./weapons.ts"; | |
| 37 | 39 | import { createArena, reset, stepInput } from "../rl/arena.ts"; |
| 38 | 40 | import { useShipped } from "./shipped.ts"; |
| 39 | 41 | |
| @@ -49,7 +51,18 @@ const SECONDS = 40; | ||
| 49 | 51 | const TICKS = SECONDS * TICKS_PER_SECOND; |
| 50 | 52 | |
| 51 | 53 | /** |
| 52 | * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for #185, | |
| 54 | * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for #165, | |
| 55 | * which put a smoke grenade in the game, and for the reason #164 and #142 were | |
| 56 | * re-pinned before it: what moved is the *hash*, not the play. A pocket is | |
| 57 | * three counts now rather than two, a live grenade's kind is an index into | |
| 58 | * three rather than a flag, and a tick carries however many clouds are standing | |
| 59 | * with the radius of each — so every second of every run differs from the | |
| 60 | * numbers before it. Nothing in this script throws anything and the bots buy no | |
| 61 | * grenades, so `clouds` is empty on all 2,560 ticks of this run and `smoke` is | |
| 62 | * zero in all ten pockets. The run *with* a smoke in it is SMOKE_ROUND below, | |
| 63 | * and that one is new rather than moved. | |
| 64 | * | |
| 65 | * Before that it was re-pinned for #185, | |
| 53 | 66 | * and this time the *play* moved, on purpose: a bot whose gun has nothing left |
| 54 | 67 | * in it now puts it away and takes out the next thing that can fire. The first |
| 55 | 68 | * twenty-three seconds are the same to the bit — the pistol round is fought |
| @@ -90,18 +103,18 @@ const TICKS = SECONDS * TICKS_PER_SECOND; | ||
| 90 | 103 | * anything: the bots buy no grenades and the scripted person never presses |
| 91 | 104 | * either key, so no flash goes off and no `blind` is ever non-zero here. |
| 92 | 105 | */ |
| 93 | const ROUND = "7a7f4535278c1daa96c1adf3fe72bffeb957a332b08e469c160304b3e936b359"; | |
| 106 | const ROUND = "2baf12fbafbff25007a9147511d1307ee9f4d5cf7d0b2473d7dae4fea876490e"; | |
| 94 | 107 | const ROUND_SECONDS = [ |
| 95 | "5978706f2efd", "aa6aa9a5a3c9", "f822e864db93", "6f1cf4de1926", | |
| 96 | "25923b9cb132", "83b06bc2430e", "b97f8d621af5", "c1b94afedda0", | |
| 97 | "700c15a0f6c1", "32921cefb2ff", "8241df979380", "c815eb110a37", | |
| 98 | "95d7115059f4", "e131dfcfdb54", "a56027278fc7", "3e66ed95e89c", | |
| 99 | "df1d01273592", "392385270f63", "c41cf61d1f0f", "e5a9d02a9e68", | |
| 100 | "88d708808f5f", "b956000db9df", "c04cbb5e0444", "32c110c9cfad", | |
| 101 | "5aa46f32cacc", "7b21f3168603", "ec5690d1c569", "33df275c9fe8", | |
| 102 | "6552dd9625ab", "93098f77ddc6", "bcaa4f830efc", "8ddf93ee8995", | |
| 103 | "71fb5532c520", "a0e5a420db35", "778f27867fca", "3c9a93e518d4", | |
| 104 | "0ef3e79c0406", "0dc24683c2de", "e7f34118de6e", "d66ce8d2348e", | |
| 108 | "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5", | |
| 109 | "0fab6fec7329", "46cbc192435f", "db863794da65", "0e5d2d2d2685", | |
| 110 | "c693a6f1b5b1", "dd5ba820b82f", "6a9d536dc726", "3e3b6bf68300", | |
| 111 | "49e7ea12852b", "088f53f05a7b", "ff5cfd8a23ee", "79b70ab03724", | |
| 112 | "458b377ac8b0", "81c86e729615", "7db95c82ca95", "9ce89515f860", | |
| 113 | "74b44ef7c080", "1c8476381a49", "e4716444e2e0", "4b6a1819fbae", | |
| 114 | "13a62553b5fe", "ca237b750358", "d2bb12aba939", "86eb5bef3ba7", | |
| 115 | "ad7857be9d5b", "db763c785460", "8f761bbb7fd6", "7f701a5bf90f", | |
| 116 | "30e1a75cffa6", "2ed2a01fb154", "aebb3d0af576", "297ffec85d70", | |
| 117 | "357635b90451", "168bd830c746", "c8eaf7365264", "5b20351850e8", | |
| 105 | 118 | ]; |
| 106 | 119 | |
| 107 | 120 | /** |
| @@ -114,20 +127,65 @@ const ROUND_SECONDS = [ | ||
| 114 | 127 | * this code still folds to |
| 115 | 128 | * 37ea4cc90ecb55e2489ee1f219654b563a16e93893a6222c6e17f23f56b3c5d3. |
| 116 | 129 | */ |
| 117 | const DUEL = "bffdda30cf85fd8b2ea4840ad4b53e8041054411fcb2d6adcd7537be8f1ebbef"; | |
| 130 | const DUEL = "0a9868af8d274a54cb508e272550a536daa70da43320e410d71e0955603aabc1"; | |
| 118 | 131 | const DUEL_SECONDS = [ |
| 119 | "f3d6534f1590", "3f1e2479a841", "43b52fff16ef", "bd2c6c29eaf3", | |
| 120 | "0c965cb1929c", "7572a5087ec4", "c4b9c83c357b", "b352f5a86d06", | |
| 121 | "1e533cbaf327", "090b9d34147d", "a2f17103d7cf", "6831ee8d38c1", | |
| 122 | "258eb2af7c8b", "9bb124c5f5fd", "fdac71af56c4", "1ae133a994ad", | |
| 123 | "1d96558e6129", "989f1591cc59", "03836444fe3d", "2442e7241118", | |
| 124 | "4795750cc613", "8c40d6315858", "6da5618d9b4d", "217b93298e47", | |
| 125 | "49c8cc22abaa", "1f1706709ae2", "faa170572b54", "bd67caf0320a", | |
| 126 | "a89d510d047b", "645baf0eb839", "36eaedb9bb79", "ddc41eb233e9", | |
| 127 | "45377fd1cdea", "b0796f4fa162", "02afbd2180fd", "02bd15880c74", | |
| 128 | "4649a72e207a", "78940559b08f", "2144ea37bcab", "ebd6cf00bfa7", | |
| 132 | "5801e5552f24", "8c954aa18e59", "e0d4012395e3", "d728c9517bef", | |
| 133 | "90f577fd6976", "0581ff4913f0", "859b91e38680", "95ebb3020bff", | |
| 134 | "188efe0ff806", "40545e6cbbaa", "6a79b15e9ef7", "3d26abcb945d", | |
| 135 | "b476b2f78f25", "f239dbac03b0", "4b43967f5fbd", "8ea790547f96", | |
| 136 | "dbfe8a84389c", "bfaf7863d538", "5b4b77f9e81c", "fcb9d1c161df", | |
| 137 | "65e9a1323500", "526ef5ca58a8", "b31de7c52e5c", "1b6f694361a1", | |
| 138 | "943f03fe6d87", "f50bd6603e42", "2dd50a989a7c", "dd2d7e769b47", | |
| 139 | "2b347f3641c6", "ce95dda16049", "9ebf2b4b63ff", "ee506214e211", | |
| 140 | "4226cac848e4", "a846c73a59ad", "20351bc13ff8", "6ececa0d6d2d", | |
| 141 | "b2e941ea7c88", "41fe8a636cee", "b4b9ff99eecf", "89e11246d8a2", | |
| 129 | 142 | ]; |
| 130 | 143 | |
| 144 | /** | |
| 145 | * Thirty seconds of the same round with a smoke grenade thrown into it: seed 7 | |
| 146 | * on de_forge again, so the only difference between this and the pin above is | |
| 147 | * the canister. `SMOKE_SCRIPT` in `replay.ts` says what the person does; what | |
| 148 | * matters is the shape of what follows, and it is in the checkpoints. | |
| 149 | * | |
| 150 | * The first second is the freeze before either script has bought anything and | |
| 151 | * hashes identically to ROUND's; second 2 is where the two part, because the | |
| 152 | * two scripts buy different things. The canister is thrown at tick 640, its | |
| 153 | * fuse runs out a second and a half later and it opens once it has come to | |
| 154 | * rest; from there the ball grows over a second and a half, stands for eleven, | |
| 155 | * and thins away over two and a half, and the round plays on for six more | |
| 156 | * seconds with the sightline back — because a bot that cannot see through a | |
| 157 | * cloud picks another fight and walks another way, and one that can goes back | |
| 158 | * to picking the first. | |
| 159 | * | |
| 160 | * This is the pin the whole issue is about. The cloud's own arithmetic is | |
| 161 | * tested in `grenade.test.ts` and the sight test in `world.test.ts`; what only a | |
| 162 | * digest can hold is that a cloud standing in a round *changes the round*, tick | |
| 163 | * for tick, and goes on changing it the same way tomorrow. | |
| 164 | */ | |
| 165 | const SMOKE_SECONDS_LONG = 30; | |
| 166 | const SMOKE_TICKS = SMOKE_SECONDS_LONG * TICKS_PER_SECOND; | |
| 167 | const SMOKE_ROUND = "6484de2cd2ba8f6b9beba2620c1432f9701b64eea95b0710674cc9e2cf59b841"; | |
| 168 | const SMOKE_ROUND_SECONDS: readonly string[] = [ | |
| 169 | "c858433691b7", "ae8816aaf901", "28196227d006", "5dd7f1376bf8", | |
| 170 | "764ca02fa9f0", "5ffb3df0e281", "1bd6a3521cb1", "59fde5add12d", | |
| 171 | "6c43f1cba43b", "78791606a640", "37beb5da7147", "18f2f45184e2", | |
| 172 | "c83eb155baee", "c3d81a2d00f8", "961ca06c12b0", "3e98ea17a149", | |
| 173 | "de868179bf35", "6134bc3a91eb", "66705f6c4e85", "41500f852715", | |
| 174 | "82b63aa21f80", "7221bbca2d61", "4bf7ac3920dc", "d9f786de91cf", | |
| 175 | "a6197ae41785", "a6a828c675b7", "bf6377ccc22d", "d8a7a7d93b84", | |
| 176 | "64b65f8f4879", "cb3d1576cec1", | |
| 177 | ]; | |
| 178 | ||
| 179 | const SMOKE_PIN: Pin = { | |
| 180 | label: "round with a smoke in it", constant: "SMOKE_ROUND", | |
| 181 | digest: SMOKE_ROUND, seconds: SMOKE_ROUND_SECONDS, | |
| 182 | }; | |
| 183 | ||
| 184 | /** The smoked round, exactly as its pin was taken. */ | |
| 185 | const smokeRun = (): Run => | |
| 186 | withoutEntropy(() => | |
| 187 | digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), SMOKE_TICKS, SMOKE_SCRIPT)); | |
| 188 | ||
| 131 | 189 | const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUND, seconds: ROUND_SECONDS }; |
| 132 | 190 | const DUEL_PIN: Pin = { label: "arena duel", constant: "DUEL", digest: DUEL, seconds: DUEL_SECONDS }; |
| 133 | 191 | |
| @@ -161,6 +219,37 @@ test("a seeded ten-bot round plays exactly as it played", () => { | ||
| 161 | 219 | expect(run.seconds).toEqual(ROUND_SECONDS); |
| 162 | 220 | }); |
| 163 | 221 | |
| 222 | test("a seeded round with a smoke grenade thrown into it plays exactly as it played", () => { | |
| 223 | const run = smokeRun(); | |
| 224 | must(run, SMOKE_PIN); | |
| 225 | expect(run.digest).toBe(SMOKE_ROUND); | |
| 226 | expect(run.ticks).toBe(SMOKE_TICKS); | |
| 227 | expect(run.seconds).toEqual([...SMOKE_ROUND_SECONDS]); | |
| 228 | // The same seed on the same map: the first second, before either script has | |
| 229 | // bought anything, is the same second to the character. Everything after the | |
| 230 | // cloud opens is not, which is the whole claim — a cloud changes what a round | |
| 231 | // does, and goes on changing it the same way. | |
| 232 | expect(run.seconds[0]).toBe(ROUND_SECONDS[0]); | |
| 233 | expect(run.seconds[12]).not.toBe(ROUND_SECONDS[12]); | |
| 234 | }); | |
| 235 | ||
| 236 | /** A cloud really did stand in the pinned run, rather than the script missing. */ | |
| 237 | test("the smoked round has a cloud standing in it, and it is gone by the end", () => { | |
| 238 | const stood = withoutEntropy(() => { | |
| 239 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7); | |
| 240 | const next = reader(SMOKE_SCRIPT); | |
| 241 | let widest = 0; | |
| 242 | for (let tick = 0; tick < SMOKE_TICKS; tick++) { | |
| 243 | update(world, TICK, next(tick, world, world.me)); | |
| 244 | for (const cloud of world.clouds) widest = Math.max(widest, cloudRadius(cloud, world.time)); | |
| 245 | } | |
| 246 | return { widest, left: world.clouds.length, pocket: world.me.pocket.smoke }; | |
| 247 | }); | |
| 248 | expect(stood.widest).toBeCloseTo(SMOKE.smoke.radius, 3); | |
| 249 | expect(stood.left).toBe(0); | |
| 250 | expect(stood.pocket).toBe(0); | |
| 251 | }); | |
| 252 | ||
| 164 | 253 | test("a seeded duel in the training arena plays exactly as it played", () => { |
| 165 | 254 | const run = duelRun(); |
| 166 | 255 | must(run, DUEL_PIN); |
| @@ -228,26 +317,26 @@ const OPEN_MAP = "f5ec0f03c150cdbc92359df63271653c6a2be8c0f485bdf86d028f636eacd9 | ||
| 228 | 317 | const OPEN_MAP_BANDS: readonly string[] = [ |
| 229 | 318 | "f6fa28441414", "aab631e1a5fa", "ebc69b760b9d", "db19d9a43600", |
| 230 | 319 | ]; |
| 231 | const OPEN_ROUND = "b5d418adfbadc051285a06177f7e97ad15ce5ffde347cf8e51f47da76ea9e496"; | |
| 320 | const OPEN_ROUND = "0e66a5e70cb3a08e2223ae783e71eadcd2921ab72c24ade58ad273f46949c792"; | |
| 232 | 321 | const OPEN_ROUND_SECONDS: readonly string[] = [ |
| 233 | "1ad83b1cc8ed", "230f205bffb1", "9a22187ee262", "b1a1b9f147cb", | |
| 234 | "52bd60b8b4c1", "219b9ec017a2", "e143e86a3980", "3f54189e301c", | |
| 235 | "6499a8a1375d", "a87763d45101", "a012a6608bfe", "81b538e9769f", | |
| 236 | "bcd612a208ca", "a45c2ba991f2", "cc2cf4a19c6a", "0b095af1719b", | |
| 237 | "5fda5a176d18", "366a4f144a4e", "09fa64b30ca6", "066be5b8acf8", | |
| 322 | "6d0fbacbd279", "1f2bb687d697", "1e9a0750ec3a", "31e71073342a", | |
| 323 | "eb30f1715453", "95ae3acb2964", "818975909e82", "f1ee5d766f18", | |
| 324 | "bb0c65852bf7", "7b9793596110", "29feef4fb8f3", "137e09c5c485", | |
| 325 | "e82aa824dc98", "495ca8c2ef53", "c2b9abc2c229", "ef673bdf0f71", | |
| 326 | "271dff4a23ae", "a7b9a6f6d084", "0437a724cd8e", "ab60865f4146", | |
| 238 | 327 | ]; |
| 239 | 328 | |
| 240 | 329 | const TIGHT_MAP = "4bd818fd2e654f16fb41b6bd718951acbb22ac24572162a2be348e7edfcccc15"; |
| 241 | 330 | const TIGHT_MAP_BANDS: readonly string[] = [ |
| 242 | 331 | "1ca81af8ea02", "c4888f792a55", "d08dbb1ee415", "1efd9cc4a8e9", |
| 243 | 332 | ]; |
| 244 | const TIGHT_ROUND = "b283d2d6cf8b2c82d80e3d2f8e1b9bfb8f731dd70009310b12a930a056876223"; | |
| 333 | const TIGHT_ROUND = "794c2c6cbb53f2fb6290641e3b7d584c4ef77967c26bab3253550086e6be105d"; | |
| 245 | 334 | const TIGHT_ROUND_SECONDS: readonly string[] = [ |
| 246 | "1ad83b1cc8ed", "230f205bffb1", "9a22187ee262", "b1a1b9f147cb", | |
| 247 | "52bd60b8b4c1", "219b9ec017a2", "e143e86a3980", "8c22014cab10", | |
| 248 | "6707adad67e0", "79b78073cafe", "b8a200b895d9", "40375defc572", | |
| 249 | "6e4039ce2f67", "e7a50e9bb180", "0e56112c62be", "e2d9f7b0b01b", | |
| 250 | "5e358b1dce56", "04a9df6200ea", "a575c4789200", "a32602bcecdf", | |
| 335 | "6d0fbacbd279", "1f2bb687d697", "1e9a0750ec3a", "31e71073342a", | |
| 336 | "eb30f1715453", "95ae3acb2964", "818975909e82", "185485d36211", | |
| 337 | "24dcd109d121", "005682b4d399", "1d865b0a75a8", "2bf98b93a745", | |
| 338 | "e0785e7358eb", "29d2cda90977", "3a93a3ee310e", "ddf65d1e134e", | |
| 339 | "c914eacf7106", "88d6ad2428e3", "16d50610dbdb", "7e2e5a7f5f7d", | |
| 251 | 340 | ]; |
| 252 | 341 | |
| 253 | 342 | const OPEN: Generated = { |
src/client/replay.ts +72 −5
| @@ -10,9 +10,9 @@ | ||
| 10 | 10 | * and updates the constant in the same pull. |
| 11 | 11 | * |
| 12 | 12 | * What is hashed is the simulation and nothing else — PLAYER_FIELDS, |
| 13 | * GRENADE_FIELDS and MATCH_FIELDS below are the whole list. Effects, the kill feed and the | |
| 14 | * messages are presentation: they would make the digest fragile and would | |
| 15 | * catch nothing the state does not already catch. | |
| 13 | * GRENADE_FIELDS, CLOUD_FIELDS and MATCH_FIELDS below are the whole list. | |
| 14 | * Effects, the kill feed and the messages are presentation: they would make the | |
| 15 | * digest fragile and would catch nothing the state does not already catch. | |
| 16 | 16 | * |
| 17 | 17 | * The folding itself is `src/engine/digest.ts`, because the range needed the |
| 18 | 18 | * same instrument and may not import this file. What stays here is what is the |
| @@ -23,7 +23,8 @@ | ||
| 23 | 23 | import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts"; |
| 24 | 24 | import { buy, type BuyItem } from "./economy.ts"; |
| 25 | 25 | import { NO_INPUT, update, type Input, type Player, type World } from "./world.ts"; |
| 26 | import type { Grenade } from "./grenade.ts"; | |
| 26 | import { cloudRadius, type Cloud, type Grenade } from "./grenade.ts"; | |
| 27 | import type { ThrownId } from "./weapons.ts"; | |
| 27 | 28 | import type { MatchState, Phase } from "./match.ts"; |
| 28 | 29 | |
| 29 | 30 | export const TICK = 1 / 64; |
| @@ -57,6 +58,7 @@ export const PLAYER_FIELDS: { name: string; of: (p: Player) => number }[] = [ | ||
| 57 | 58 | // What is in each pocket: bought, thrown, and lost with the body that held it. |
| 58 | 59 | { name: "he", of: (p) => p.pocket.he }, |
| 59 | 60 | { name: "flash", of: (p) => p.pocket.flash }, |
| 61 | { name: "smoke", of: (p) => p.pocket.smoke }, | |
| 60 | 62 | // How long this body cannot see for. It is simulation, not presentation: the |
| 61 | 63 | // white-out a person gets and the shaky hands a bot gets are both read off it. |
| 62 | 64 | { name: "blind", of: (p) => p.blind }, |
| @@ -77,7 +79,28 @@ export const GRENADE_FIELDS: { name: string; of: (g: Grenade) => number }[] = [ | ||
| 77 | 79 | { name: "fuse", of: (g) => g.fuse }, |
| 78 | 80 | // Which kind it is — an HE and a flashbang fly identically and end very |
| 79 | 81 | // differently, so two otherwise equal ticks are not the same state. |
| 80 | { name: "kind", of: (g) => (g.weapon === "flash" ? 1 : 0) }, | |
| 82 | { name: "kind", of: (g) => KINDS.indexOf(g.weapon) }, | |
| 83 | ]; | |
| 84 | ||
| 85 | /** The order a grenade's kind is hashed in. Widened by a new thrown weapon. */ | |
| 86 | const KINDS: ThrownId[] = ["he", "flash", "smoke"]; | |
| 87 | ||
| 88 | /** | |
| 89 | * Everything hashed about a cloud of smoke standing in the round: where it is, | |
| 90 | * and how wide it is on this tick. The radius rather than the second it opened | |
| 91 | * because the radius is what the simulation *acts* on — `canSee` traces against | |
| 92 | * exactly this number — and because it moves every tick while the cloud grows | |
| 93 | * and again while it thins, so a cloud that opened a tick early or late is a | |
| 94 | * different run from the second it opened. | |
| 95 | * | |
| 96 | * Like the grenades, the count goes in first: a tick with one cloud standing | |
| 97 | * and a tick with none are different states and hash differently. | |
| 98 | */ | |
| 99 | export const CLOUD_FIELDS: { name: string; of: (c: Cloud, world: World) => number }[] = [ | |
| 100 | { name: "pos.x", of: (c) => c.pos[0] }, | |
| 101 | { name: "pos.y", of: (c) => c.pos[1] }, | |
| 102 | { name: "pos.z", of: (c) => c.pos[2] }, | |
| 103 | { name: "radius", of: (c, world) => cloudRadius(c, world.time) }, | |
| 81 | 104 | ]; |
| 82 | 105 | |
| 83 | 106 | /** Everything hashed about the match. */ |
| @@ -94,6 +117,8 @@ export function snapshot(world: World): string { | ||
| 94 | 117 | for (const p of world.players) for (const field of PLAYER_FIELDS) parts.push(quantise(field.of(p))); |
| 95 | 118 | parts.push(quantise(world.grenades.length)); |
| 96 | 119 | for (const g of world.grenades) for (const field of GRENADE_FIELDS) parts.push(quantise(field.of(g))); |
| 120 | parts.push(quantise(world.clouds.length)); | |
| 121 | for (const c of world.clouds) for (const field of CLOUD_FIELDS) parts.push(quantise(field.of(c, world))); | |
| 97 | 122 | for (const field of MATCH_FIELDS) parts.push(quantise(field.of(world.match))); |
| 98 | 123 | return `${parts.join(",")}\n`; |
| 99 | 124 | } |
| @@ -150,6 +175,47 @@ export const SCRIPT: Beat[] = [ | ||
| 150 | 175 | { atTick: 2496, input: {} }, |
| 151 | 176 | ]; |
| 152 | 177 | |
| 178 | /** | |
| 179 | * Thirty seconds with a smoke grenade in them. The script above throws nothing | |
| 180 | * — the bots buy no grenades and the scripted person never presses a throw key | |
| 181 | * — so before this every pinned run in this repository had an empty `clouds` | |
| 182 | * list on every tick, and the one thing in the simulation that can take a | |
| 183 | * sightline away was pinned by nothing at all. | |
| 184 | * | |
| 185 | * This one buys a smoke in the freeze, walks out of spawn and throws it at | |
| 186 | * tick 640 — four seconds into the live round, which is early enough that the | |
| 187 | * cloud is standing while the two sides are still walking towards each other. | |
| 188 | * From there the fuse, the canister settling, the ball growing over a second | |
| 189 | * and a half, fifteen seconds of it standing between whoever is on either side | |
| 190 | * of it, and the two and a half seconds of it thinning away are all inside the | |
| 191 | * run: the last second of the cloud is second 24, and the six after it are the | |
| 192 | * round playing on with the sightline back. | |
| 193 | * | |
| 194 | * What it pins is not the cloud's arithmetic, which has its own tests. It is | |
| 195 | * that a cloud *changes the round* — a bot that cannot see through one picks | |
| 196 | * another fight, walks another way, and everything downstream of that moves | |
| 197 | * with it. | |
| 198 | */ | |
| 199 | export const SMOKE_SCRIPT: Beat[] = [ | |
| 200 | { atTick: 0, input: {} }, | |
| 201 | { atTick: 64, input: {}, buy: "smoke" }, // 300 of the 800 start money | |
| 202 | { atTick: 512, input: { forward: 1 } }, // the round goes live | |
| 203 | { atTick: 608, input: { forward: 1, side: 1 } }, | |
| 204 | // One press of the throw key, one canister out of the pocket. | |
| 205 | { atTick: 640, input: { forward: 1, throwing: "smoke" } }, | |
| 206 | { atTick: 648, input: { forward: 1 } }, | |
| 207 | { atTick: 832, input: { forward: 1, side: -1 } }, | |
| 208 | { atTick: 960, input: { fire: true } }, | |
| 209 | { atTick: 976, input: {} }, | |
| 210 | { atTick: 1088, input: { reload: true } }, | |
| 211 | { atTick: 1096, input: { forward: 1 } }, | |
| 212 | { atTick: 1280, input: { use: true } }, | |
| 213 | { atTick: 1408, input: { forward: 1, duck: true } }, | |
| 214 | { atTick: 1536, input: { forward: -1 } }, | |
| 215 | { atTick: 1792, input: { side: 1, walk: true } }, | |
| 216 | { atTick: 1920, input: {} }, | |
| 217 | ]; | |
| 218 | ||
| 153 | 219 | /** |
| 154 | 220 | * A cursor over the script: hand it a tick and it returns the input held at |
| 155 | 221 | * that tick, taking any buy on the way past. The input is a fresh object each |
| @@ -222,6 +288,7 @@ export function compare(run: Run, pin: Pin): string | null { | ||
| 222 | 288 | hashes: |
| 223 | 289 | `${PLAYER_FIELDS.map((f) => f.name).join(", ")} per player, ` + |
| 224 | 290 | `${GRENADE_FIELDS.map((f) => f.name).join(", ")} per live grenade, ` + |
| 291 | `${CLOUD_FIELDS.map((f) => f.name).join(", ")} per standing cloud, ` + | |
| 225 | 292 | `and ${MATCH_FIELDS.map((f) => f.name).join(", ")}`, |
| 226 | 293 | every: TICKS_PER_SECOND, |
| 227 | 294 | unit: "second", |
src/client/scene.ts +51 −7
| @@ -21,6 +21,8 @@ import { | ||
| 21 | 21 | clamp, createScene as createPooledScene, effectAt, hullHeight, nextInstance, pushLine, resetScene, |
| 22 | 22 | type Camera, type Scene as Frame, type Vec3, |
| 23 | 23 | } from "../engine/index.ts"; |
| 24 | import { cloudRadius } from "./grenade.ts"; | |
| 25 | import { CLOUD_MESH_RADIUS } from "./models.ts"; | |
| 24 | 26 | import { CS_HULL } from "./movement.ts"; |
| 25 | 27 | import { FLASH, HE, WEAPONS } from "./weapons.ts"; |
| 26 | 28 | import type { Player, World } from "./world.ts"; |
| @@ -51,12 +53,16 @@ export const LINE_CAPACITY = 4096; | ||
| 51 | 53 | |
| 52 | 54 | /** |
| 53 | 55 | * Instances one frame may place: the level, every other living player and the |
| 54 | * bomb, which is eleven in the 5v5 `main.ts` starts. The pool is built at this | |
| 55 | * size once and never grows, so the headroom is for a bigger match rather than | |
| 56 | * for a busy frame; past it a player is simply not drawn, which is the engine's | |
| 56 | * bomb, which is eleven in the 5v5 `main.ts` starts, plus whatever is thrown — | |
| 57 | * up to three grenades in the air per body, and up to one cloud of smoke per | |
| 58 | * body standing for fifteen seconds. Ten clouds and ten bodies is thirty-one | |
| 59 | * before a single grenade is in the air, which is what moved this number from | |
| 60 | * 32 to 48: a frame in which a whole side smoked a site at once was already | |
| 61 | * dropping instances at the old cap. The pool is built at this size once and | |
| 62 | * never grows; past it a thing is simply not drawn, which is the engine's | |
| 57 | 63 | * policy at a cap and the same one `LINE_CAPACITY` has always had. |
| 58 | 64 | */ |
| 59 | export const INSTANCE_CAPACITY = 32; | |
| 65 | export const INSTANCE_CAPACITY = 48; | |
| 60 | 66 | |
| 61 | 67 | /** How long a blast is drawn for, in seconds, and in how many spokes. */ |
| 62 | 68 | const BLAST_SECONDS = 0.45; |
| @@ -74,6 +80,18 @@ const TEAM_TINT: Record<string, Tint> = { | ||
| 74 | 80 | }; |
| 75 | 81 | |
| 76 | 82 | const WHITE: Tint = [1, 1, 1]; |
| 83 | ||
| 84 | /** | |
| 85 | * What a cloud is tinted. The mesh is painted with the sand layer — smooth fbm | |
| 86 | * noise, no seam and no straight line — and sand is (196, 174, 132), so these | |
| 87 | * three numbers are what turn it into a cool pale grey of about (210, 214, 224) | |
| 88 | * before the renderer's light touches it. Brighter than white on blue on | |
| 89 | * purpose: smoke lit from a sky is cooler than the ground it is standing on. | |
| 90 | */ | |
| 91 | const SMOKE_TINT: Tint = [1.07, 1.23, 1.7]; | |
| 92 | ||
| 93 | /** Which body a thrown thing is drawn as, by kind. */ | |
| 94 | const GRENADE_MESH: Record<string, string> = { he: "grenade", flash: "flash", smoke: "smoke" }; | |
| 77 | 95 | const ORIGIN: Vec3 = [0, 0, 0]; |
| 78 | 96 | |
| 79 | 97 | /** |
| @@ -108,14 +126,22 @@ export function createScene(): Scene { | ||
| 108 | 126 | }; |
| 109 | 127 | } |
| 110 | 128 | |
| 111 | /** One mesh placed, or nothing at all once the pool is spent. */ | |
| 112 | function place(scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number, tint: Tint): void { | |
| 129 | /** | |
| 130 | * One mesh placed, or nothing at all once the pool is spent. `size` is how big | |
| 131 | * the mesh stands beside the size it was built at, and is 1 for every model in | |
| 132 | * this game but the cloud, which is one ball uploaded once and drawn at | |
| 133 | * whatever radius it has grown to. | |
| 134 | */ | |
| 135 | function place( | |
| 136 | scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number, tint: Tint, size = 1, | |
| 137 | ): void { | |
| 113 | 138 | const into = nextInstance(scene); |
| 114 | 139 | if (into === null) return; |
| 115 | 140 | into.mesh = mesh; |
| 116 | 141 | into.pos[0] = pos[0]; into.pos[1] = pos[1]; into.pos[2] = pos[2]; |
| 117 | 142 | into.yaw = yaw; |
| 118 | 143 | into.scale = scale; |
| 144 | into.size = size; | |
| 119 | 145 | into.tint[0] = tint[0]; into.tint[1] = tint[1]; into.tint[2] = tint[2]; |
| 120 | 146 | } |
| 121 | 147 | |
| @@ -153,7 +179,22 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 153 | 179 | // the yaw is the simulated time, so a frame decides nothing and a paused |
| 154 | 180 | // one turns it no further. |
| 155 | 181 | for (const g of world.grenades) |
| 156 | place(into, g.weapon === "flash" ? "flash" : "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE); | |
| 182 | place(into, GRENADE_MESH[g.weapon] ?? "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE); | |
| 183 | // Every cloud standing, as the ball the simulation traces against: the same | |
| 184 | // centre and the same radius `canSee` reads, so what fills the screen and | |
| 185 | // what takes a sightline away are one object. It is opaque and it is drawn in | |
| 186 | // the pass that was already here — no blend, no sort, no second texture — and | |
| 187 | // being *inside* one is drawn by `hud.ts` instead, because the far faces of a | |
| 188 | // ball you are standing in are culled and there is nothing left to see. | |
| 189 | for (const cloud of world.clouds) { | |
| 190 | const radius = cloudRadius(cloud, world.time); | |
| 191 | if (radius <= 0) continue; | |
| 192 | // A yaw off its own position, so two clouds on one site are not one mesh | |
| 193 | // stamped twice. It is a fact about where it opened and never about the | |
| 194 | // frame, so a paused game turns it no further. | |
| 195 | place(into, "cloud", cloud.pos, (cloud.pos[0] + cloud.pos[1] * 3) % 360, 1, SMOKE_TINT, | |
| 196 | radius / CLOUD_MESH_RADIUS); | |
| 197 | } | |
| 157 | 198 | |
| 158 | 199 | // --- tracers, sparks and the planted bomb's beam, all as lines |
| 159 | 200 | // Read by index, oldest first: the effects are the engine's ring and a |
| @@ -191,6 +232,9 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 191 | 232 | // radius — which is why the reach is read off the weapon and not typed |
| 192 | 233 | // twice — but it never brightens the screen from out here: what it costs |
| 193 | 234 | // the person looking at it is `blind` in the simulation, and the HUD's. |
| 235 | // A smoke has no star: the cloud it leaves is the drawing, and a flare | |
| 236 | // of light where a canister opened would say the wrong thing entirely. | |
| 237 | if (effect.weapon === "smoke") continue; | |
| 194 | 238 | const flash = effect.weapon === "flash"; |
| 195 | 239 | const seconds = flash ? FLASH_SECONDS : BLAST_SECONDS; |
| 196 | 240 | if (age >= seconds) continue; |
src/client/weapons.test.ts +2 −2
| @@ -105,9 +105,9 @@ test("the flashbang is in the table with its own numbers, and no bullet ones", ( | ||
| 105 | 105 | expect(FLASH.magazine).toBe(0); |
| 106 | 106 | expect(FLASH.blast).toBeUndefined(); |
| 107 | 107 | expect(HE.flash).toBeUndefined(); |
| 108 | // Both are thrown, and nothing else in the table is. | |
| 108 | // All three are thrown, and nothing else in the table is. | |
| 109 | 109 | expect(Object.values(WEAPONS).filter((w) => isThrown(w.id)).map((w) => w.id).sort()) |
| 110 | .toEqual(["flash", "he"]); | |
| 110 | .toEqual(["flash", "he", "smoke"]); | |
| 111 | 111 | // Adding it did not move a gun's spray: the patterns are seeded by index and |
| 112 | 112 | // the thrown weapons are last, so the AK's tenth bullet is the AK's tenth. |
| 113 | 113 | expect(recoilOffset("ak47", 9)).toEqual(recoilOffset("ak47", 9)); |
src/client/weapons.ts +58 −11
| @@ -19,13 +19,15 @@ | ||
| 19 | 19 | import { clamp, rng } from "../engine/index.ts"; |
| 20 | 20 | |
| 21 | 21 | export type WeaponId = |
| 22 | | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he" | "flash"; | |
| 22 | | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | |
| 23 | | "he" | "flash" | "smoke"; | |
| 23 | 24 | |
| 24 | /** The weapons that are thrown rather than fired: the two kinds in a pocket. */ | |
| 25 | export type ThrownId = "he" | "flash"; | |
| 25 | /** The weapons that are thrown rather than fired: the three kinds in a pocket. */ | |
| 26 | export type ThrownId = "he" | "flash" | "smoke"; | |
| 26 | 27 | |
| 27 | 28 | /** 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"; | |
| 29 | export const isThrown = (id: WeaponId): id is ThrownId => | |
| 30 | id === "he" || id === "flash" || id === "smoke"; | |
| 29 | 31 | |
| 30 | 32 | /** |
| 31 | 33 | * What a thrown weapon does when its fuse runs out. Only the HE has one, |
| @@ -51,6 +53,27 @@ export type Flash = { | ||
| 51 | 53 | awayAngle: number; // degrees; at or past this it went off behind you and is nothing |
| 52 | 54 | }; |
| 53 | 55 | |
| 56 | /** | |
| 57 | * What a smoke grenade does when its fuse runs out: it takes *sight*, and not | |
| 58 | * from one body at one instant like a flash does but from everybody, for as | |
| 59 | * long as the cloud stands. These five numbers are the whole of it — no hit | |
| 60 | * point, no armour and nobody to credit. | |
| 61 | * | |
| 62 | * The cloud is a ball, and that is a decision rather than a shortcut: a ball is | |
| 63 | * the one shape a sight test can be answered against for a dot product and a | |
| 64 | * square root (`segmentSphere` in `src/engine/trace.ts`), and a sight test is | |
| 65 | * already on the hot path ten times a tick. What it costs is measured in | |
| 66 | * `src/budget.test.ts`. | |
| 67 | */ | |
| 68 | export type Smoke = { | |
| 69 | fuse: number; // seconds from leaving the hand to the canister opening | |
| 70 | radius: number; // units; the ball, once it has grown | |
| 71 | grow: number; // seconds from the pop to the full radius | |
| 72 | /** Seconds from the pop to nothing at all, the thinning below included. */ | |
| 73 | life: number; | |
| 74 | fade: number; // seconds of that life it spends shrinking back to nothing | |
| 75 | }; | |
| 76 | ||
| 54 | 77 | export type Weapon = { |
| 55 | 78 | id: WeaponId; |
| 56 | 79 | name: string; |
| @@ -75,6 +98,8 @@ export type Weapon = { | ||
| 75 | 98 | blast?: Blast; |
| 76 | 99 | /** Set on the flashbang, and on nothing else. */ |
| 77 | 100 | flash?: Flash; |
| 101 | /** Set on the smoke grenade, and on nothing else. */ | |
| 102 | smoke?: Smoke; | |
| 78 | 103 | }; |
| 79 | 104 | |
| 80 | 105 | /** The HE grenade, by the name everything that throws one calls it. */ |
| @@ -89,8 +114,28 @@ export const HE: Weapon & { blast: Blast } = { id: "he", name: "HE Grenade", kin | ||
| 89 | 114 | */ |
| 90 | 115 | 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 | 116 | |
| 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; | |
| 117 | /** | |
| 118 | * The smoke grenade. CS's $300, and the only thing in this table that changes | |
| 119 | * how a round is *played* rather than how a fight ends: a ball 180 units across | |
| 120 | * the radius — about three and a half metres, wide enough to cover a doorway or | |
| 121 | * the mouth of a site and not a whole room — standing for fifteen seconds, | |
| 122 | * which is a quarter of a round and long enough to take a sightline out of the | |
| 123 | * fight it was going to decide. | |
| 124 | * | |
| 125 | * It grows over a second and a half rather than appearing, and spends its last | |
| 126 | * two and a half shrinking back to nothing, so there is no tick at which a | |
| 127 | * sightline blinks shut or blinks open: what the screen shows and what | |
| 128 | * `canSee` answers are the same radius, read off the same clock. | |
| 129 | * | |
| 130 | * The fuse is the HE's 1.5 seconds, but it is not the whole story — a smoke | |
| 131 | * opens where the canister *stopped*, so `spent` in `src/client/grenade.ts` | |
| 132 | * holds it until the thing has come to rest. | |
| 133 | */ | |
| 134 | export const SMOKE: Weapon & { smoke: Smoke } = { id: "smoke", name: "Smoke 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, smoke: { fuse: 1.5, radius: 180, grow: 1.5, life: 15, fade: 2.5 } }; | |
| 135 | ||
| 136 | /** The fuse on a thrown weapon, in seconds: the one number all three kinds have. */ | |
| 137 | export const fuseOf = (weapon: Weapon): number => | |
| 138 | weapon.blast?.fuse ?? weapon.flash?.fuse ?? weapon.smoke?.fuse ?? 0; | |
| 94 | 139 | |
| 95 | 140 | const WEAPON_LIST: Weapon[] = [ |
| 96 | 141 | { 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 }, |
| @@ -104,15 +149,17 @@ const WEAPON_LIST: Weapon[] = [ | ||
| 104 | 149 | { 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 }, |
| 105 | 150 | { 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 }, |
| 106 | 151 | { 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 }, |
| 107 | // The thrown weapons. Their bullet numbers are the empty ones — neither is | |
| 108 | // ever held, fired or reloaded — and everything they do is in `blast` and | |
| 109 | // `flash`: CS's $300 HE, 98 hit points at the centre falling off over 350 | |
| 110 | // units on a 1.5-second fuse with armour stopping about half of it, and CS's | |
| 111 | // $200 flashbang, which takes sight instead. They are last in the list on | |
| 152 | // The thrown weapons. Their bullet numbers are the empty ones — none of them | |
| 153 | // is ever held, fired or reloaded — and everything they do is in `blast`, | |
| 154 | // `flash` and `smoke`: CS's $300 HE, 98 hit points at the centre falling off | |
| 155 | // over 350 units on a 1.5-second fuse with armour stopping about half of it; | |
| 156 | // CS's $200 flashbang, which takes sight for a moment; and CS's $300 smoke, | |
| 157 | // which takes a sightline for fifteen seconds. They are last in the list on | |
| 112 | 158 | // purpose: the spray patterns below are seeded by index, so a thrown weapon |
| 113 | 159 | // added here never moves a gun's pattern. |
| 114 | 160 | HE, |
| 115 | 161 | FLASH, |
| 162 | SMOKE, | |
| 116 | 163 | ]; |
| 117 | 164 | |
| 118 | 165 | export const WEAPONS: Record<WeaponId, Weapon> = Object.fromEntries( |
src/client/world.test.ts +75 −2
| @@ -7,10 +7,16 @@ import { SHORT } from "./match.ts"; | ||
| 7 | 7 | import { WEAPONS } from "./weapons.ts"; |
| 8 | 8 | import { buy } from "./economy.ts"; |
| 9 | 9 | import { |
| 10 | applyInput, createWorld, currentWeapon, EFFECT_CAPACITY, NO_INPUT, shoot, startRound, update, | |
| 10 | applyInput, canSee, createWorld, currentWeapon, EFFECT_CAPACITY, eyePos, insideSmoke, NO_INPUT, | |
| 11 | shoot, startRound, throwOne, update, | |
| 11 | 12 | type Input, type Player, type Sound, type World, |
| 12 | 13 | } from "./world.ts"; |
| 13 | import { effectAt, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3 } from "../engine/index.ts"; | |
| 14 | import { cloudRadius, type Cloud } from "./grenade.ts"; | |
| 15 | import { SMOKE } from "./weapons.ts"; | |
| 16 | import { CS_HULL } from "./movement.ts"; | |
| 17 | import { | |
| 18 | effectAt, eyeHeight, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3, | |
| 19 | } from "../engine/index.ts"; | |
| 14 | 20 | |
| 15 | 21 | const TICK = 1 / 64; |
| 16 | 22 | const run = (world: World, seconds: number, input: Input = NO_INPUT): void => { |
| @@ -324,3 +330,70 @@ test("a match plays itself to the end inside its own time", () => { | ||
| 324 | 330 | expect(world.match.matchWinner).not.toBe(null); |
| 325 | 331 | expect(Math.max(world.match.score.t, world.match.score.ct)).toBe(2); |
| 326 | 332 | }); |
| 333 | ||
| 334 | // --- smoke ---------------------------------------------------------------- | |
| 335 | ||
| 336 | /** | |
| 337 | * A cloud standing between two bodies, placed by hand rather than thrown: what | |
| 338 | * is under test here is the sight rule, and a throw is `grenade.test.ts`'s. | |
| 339 | */ | |
| 340 | const BOX = DE_FORGE; | |
| 341 | ||
| 342 | const smoked = (world: World, at: Vec3, agoSeconds = SMOKE.smoke.grow): void => { | |
| 343 | world.clouds.push({ pos: at, born: world.time - agoSeconds, owner: 0 }); | |
| 344 | }; | |
| 345 | ||
| 346 | test("a cloud takes a sightline away, and gives it back when it has thinned out", () => { | |
| 347 | const world = createWorld(BOX, SHORT, "me", "ct", 2, 5); | |
| 348 | const [a, b] = world.players as [Player, Player]; | |
| 349 | a.pos = [0, 0, 0]; | |
| 350 | b.pos = [400, 0, 0]; | |
| 351 | a.alive = b.alive = true; | |
| 352 | expect(canSee(world, a, b)).toBe(true); | |
| 353 | // Halfway between them, grown: neither can see the other, either way round. | |
| 354 | smoked(world, [200, 0, eyeHeight(a, CS_HULL)]); | |
| 355 | expect(canSee(world, a, b)).toBe(false); | |
| 356 | expect(canSee(world, b, a)).toBe(false); | |
| 357 | // And a body standing in one is blind in every direction, which is the whole | |
| 358 | // of why `segmentSphere` answers a start inside the ball with "yes". | |
| 359 | a.pos = [200, 0, 0]; | |
| 360 | expect(canSee(world, a, b)).toBe(false); | |
| 361 | expect(insideSmoke(world, eyePos(a))).toBe(true); | |
| 362 | // Past the end of its life it is nothing at all: the line is back. | |
| 363 | (world.clouds[0] as { born: number }).born = world.time - SMOKE.smoke.life; | |
| 364 | a.pos = [0, 0, 0]; | |
| 365 | expect(canSee(world, a, b)).toBe(true); | |
| 366 | }); | |
| 367 | ||
| 368 | test("a cloud beside the line, behind the eye or past the target is not in the way", () => { | |
| 369 | const world = createWorld(BOX, SHORT, "me", "ct", 2, 5); | |
| 370 | const [a, b] = world.players as [Player, Player]; | |
| 371 | a.pos = [0, 0, 0]; | |
| 372 | b.pos = [400, 0, 0]; | |
| 373 | const eye = eyeHeight(a, CS_HULL); | |
| 374 | for (const at of [[200, 400, eye], [-400, 0, eye], [900, 0, eye]] as Vec3[]) { | |
| 375 | world.clouds = []; | |
| 376 | smoked(world, at); | |
| 377 | expect(canSee(world, a, b)).toBe(true); | |
| 378 | } | |
| 379 | }); | |
| 380 | ||
| 381 | test("a smoke grenade waits to come to rest, then stands its stated life", () => { | |
| 382 | const world = createWorld(BOX, SHORT, "me", "ct", 2, 9); | |
| 383 | world.match.phase = "live"; | |
| 384 | const me = world.me; | |
| 385 | me.pocket.smoke = 1; | |
| 386 | expect(throwOne(world, me, "smoke")).toBe(true); | |
| 387 | // It is still a grenade while its fuse burns, and no cloud exists yet. | |
| 388 | for (let i = 0; i < Math.round(SMOKE.smoke.fuse * 64); i++) update(world, 1 / 64, NO_INPUT); | |
| 389 | expect(world.clouds.length).toBe(0); | |
| 390 | // It opens once it has settled, and the ball grows from nothing. | |
| 391 | for (let i = 0; i < 64 * 2; i++) update(world, 1 / 64, NO_INPUT); | |
| 392 | expect(world.grenades.length).toBe(0); | |
| 393 | expect(world.clouds.length).toBe(1); | |
| 394 | const cloud = world.clouds[0] as Cloud; | |
| 395 | expect(cloudRadius(cloud, cloud.born + SMOKE.smoke.grow)).toBeCloseTo(SMOKE.smoke.radius, 6); | |
| 396 | // And it is gone by the end of its life, list and all. | |
| 397 | for (let i = 0; i < 64 * (SMOKE.smoke.life + 1); i++) update(world, 1 / 64, NO_INPUT); | |
| 398 | expect(world.clouds.length).toBe(0); | |
| 399 | }); |
src/client/world.ts +99 −17
| @@ -8,7 +8,9 @@ | ||
| 8 | 8 | * the one thing in a round that is not a person: `src/client/grenade.ts` says |
| 9 | 9 | * how one flies and what its blast reaches, and what a blast *means* — the |
| 10 | 10 | * damage, the hit marker, the line in the feed — is here with every other kind |
| 11 | * of harm. | |
| 11 | * of harm. One of the three leaves something behind: a smoke grenade opens into | |
| 12 | * a `Cloud`, the world keeps the list of them, and `canSee` at the foot of this | |
| 13 | * file is where a cloud stops being a picture and becomes a wall. | |
| 12 | 14 | * |
| 13 | 15 | * The simulation runs at a fixed 64 ticks a second and knows nothing about |
| 14 | 16 | * WebGL, so it can be stepped in a test without a browser. |
| @@ -18,14 +20,15 @@ import { beepDue, dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./b | ||
| 18 | 20 | import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 19 | 21 | import { emptyAmmo, type Ammo } from "./economy.ts"; |
| 20 | 22 | import { |
| 21 | blastCentre, blastHits, flashHits, stepGrenade, throwGrenade, THROW_LIFT, | |
| 22 | type Grenade, type GrenadeSound, | |
| 23 | blastCentre, blastHits, cloudFrom, cloudGone, cloudRadius, flashHits, spent, stepGrenade, | |
| 24 | throwGrenade, THROW_LIFT, | |
| 25 | type Cloud, type Grenade, type GrenadeSound, | |
| 23 | 26 | } from "./grenade.ts"; |
| 24 | 27 | import type { Level } from "./level.ts"; |
| 25 | 28 | import type { RoundPlan } from "../plan/plan.ts"; |
| 26 | 29 | import { |
| 27 | 30 | angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move, |
| 28 | normalize, pushEffect, rng, sub, visible, | |
| 31 | normalize, pushEffect, rng, segmentSphere, sub, visible, | |
| 29 | 32 | type Body, type Effects, type Vec3, type Wish, |
| 30 | 33 | } from "../engine/index.ts"; |
| 31 | 34 | import * as rules from "./match.ts"; |
| @@ -53,7 +56,7 @@ export type Player = Actor & Body & { | ||
| 53 | 56 | /** |
| 54 | 57 | * What is in the pocket, by kind: at most `CARRY[kind]` of each, thrown one |
| 55 | 58 | * at a time. A pocket rather than a count because a flashbang is carried |
| 56 | * alongside the HE and not instead of it. | |
| 59 | * alongside the HE and the smoke, and not instead of either. | |
| 57 | 60 | */ |
| 58 | 61 | pocket: Record<ThrownId, number>; |
| 59 | 62 | /** |
| @@ -172,6 +175,15 @@ export type World = { | ||
| 172 | 175 | effects: Effects<Effect>; |
| 173 | 176 | /** Every grenade in the air or on the floor right now, oldest first. */ |
| 174 | 177 | grenades: Grenade[]; |
| 178 | /** | |
| 179 | * Every cloud of smoke standing right now, oldest first. It is simulation and | |
| 180 | * not decoration: `canSee` below traces against these, so a cloud in this | |
| 181 | * list is a wall to everybody's eyes and a cloud out of it is not. A round | |
| 182 | * nobody threw a smoke in keeps an empty array, and every loop over it is a | |
| 183 | * length check — which is what makes the sight test cost the same as it | |
| 184 | * always did in the rounds that are most of them. | |
| 185 | */ | |
| 186 | clouds: Cloud[]; | |
| 175 | 187 | feed: FeedEntry[]; |
| 176 | 188 | messages: { at: number; text: string }[]; |
| 177 | 189 | /** |
| @@ -213,7 +225,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 213 | 225 | money: 800, |
| 214 | 226 | weapon: secondary, primary: null, secondary, |
| 215 | 227 | ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) }, |
| 216 | defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0 }, blind: 0, | |
| 228 | defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0, smoke: 0 }, blind: 0, | |
| 217 | 229 | nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, |
| 218 | 230 | firedThisPress: false, threwThisPress: null, |
| 219 | 231 | punch: [0, 0], stride: 0, |
| @@ -261,7 +273,8 @@ export function createWorld( | ||
| 261 | 273 | const world: World = { |
| 262 | 274 | match, players, me, time: 0, |
| 263 | 275 | bomb: newBomb(), |
| 264 | effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [], plan: null, | |
| 276 | effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], clouds: [], | |
| 277 | feed: [], messages: [], plan: null, | |
| 265 | 278 | random, level, bombPlanted: false, pendingBuy: false, |
| 266 | 279 | }; |
| 267 | 280 | startRound(world); |
| @@ -354,7 +367,7 @@ export function startRound(world: World): void { | ||
| 354 | 367 | p.health = 100; |
| 355 | 368 | // A grenade survives the round its owner did, like their gun does; one they |
| 356 | 369 | // died holding is left on the floor with them. |
| 357 | if (!p.alive) p.pocket = { he: 0, flash: 0 }; | |
| 370 | if (!p.alive) p.pocket = { he: 0, flash: 0, smoke: 0 }; | |
| 358 | 371 | p.alive = true; |
| 359 | 372 | // A flash does not carry into the next round, however late it went off. |
| 360 | 373 | p.blind = 0; |
| @@ -379,6 +392,10 @@ export function startRound(world: World): void { | ||
| 379 | 392 | if (carrier !== undefined) world.bomb = giveBomb(carrier); |
| 380 | 393 | world.bombPlanted = false; |
| 381 | 394 | world.grenades = []; |
| 395 | // A cloud does not outlive the round it was thrown in, however late it | |
| 396 | // opened: fifteen seconds of smoke standing through the next freeze would be | |
| 397 | // one side starting a round blind for something nobody did. | |
| 398 | world.clouds = []; | |
| 382 | 399 | world.feed = []; |
| 383 | 400 | for (const p of world.players) if (p.bot) botBuy(world, p); |
| 384 | 401 | } |
| @@ -535,8 +552,11 @@ export function throwOne(world: World, p: Player, kind: ThrownId = "he"): boolea | ||
| 535 | 552 | function detonate(world: World, g: Grenade): void { |
| 536 | 553 | const centre = blastCentre(g); |
| 537 | 554 | record(world, { kind: "blast", at: world.time, pos: centre, weapon: g.weapon }); |
| 538 | heard(world, centre, g.weapon === "flash" ? "flash" : "blast", g.owner); | |
| 555 | heard(world, centre, g.weapon === "flash" ? "flash" : g.weapon === "smoke" ? "smoke" : "blast", g.owner); | |
| 539 | 556 | if (g.weapon === "flash") return blind(world, g); |
| 557 | // A smoke hurts nobody and reaches nobody. What it does is *stay*: the | |
| 558 | // canister is spent, and the cloud it left is a thing the round now has. | |
| 559 | if (g.weapon === "smoke") return void world.clouds.push(cloudFrom(g, world.time)); | |
| 540 | 560 | explode(world, g, centre); |
| 541 | 561 | } |
| 542 | 562 | |
| @@ -574,18 +594,38 @@ function blind(world: World, g: Grenade): void { | ||
| 574 | 594 | } |
| 575 | 595 | } |
| 576 | 596 | |
| 577 | /** Every live grenade, one tick older: moved, heard, and detonated on time. */ | |
| 597 | /** | |
| 598 | * Every live grenade, one tick older: moved, heard, and detonated on time. | |
| 599 | * | |
| 600 | * "On time" is `spent` in `grenade.ts` rather than a zero on the fuse, because | |
| 601 | * a smoke waits for the floor. The list is filtered by the same question, so a | |
| 602 | * canister still rolling with its fuse past zero stays in the round instead of | |
| 603 | * disappearing on the tick it was supposed to open. | |
| 604 | */ | |
| 578 | 605 | function updateGrenades(world: World, dt: number): void { |
| 579 | 606 | if (world.grenades.length === 0) return; |
| 580 | let spent = false; | |
| 607 | let any = false; | |
| 581 | 608 | for (const g of world.grenades) { |
| 582 | 609 | if (stepGrenade(g, dt, world.level.boxes)) heard(world, g.pos, "bounce", g.owner); |
| 583 | if (g.fuse <= 0) { | |
| 610 | if (spent(g)) { | |
| 584 | 611 | detonate(world, g); |
| 585 | spent = true; | |
| 612 | any = true; | |
| 586 | 613 | } |
| 587 | 614 | } |
| 588 | if (spent) world.grenades = world.grenades.filter((g) => g.fuse > 0); | |
| 615 | if (any) world.grenades = world.grenades.filter((g) => !spent(g)); | |
| 616 | } | |
| 617 | ||
| 618 | /** | |
| 619 | * Clouds that have thinned away to nothing, dropped. A cloud at zero radius | |
| 620 | * blocks nothing and is drawn as nothing — `cloudRadius` answers the same | |
| 621 | * question every reader asks — so this is housekeeping and not a rule: what it | |
| 622 | * buys is that the loop in `canSee` stays short in a round where ten smokes | |
| 623 | * have been thrown over two minutes. | |
| 624 | */ | |
| 625 | function expireClouds(world: World): void { | |
| 626 | if (world.clouds.length === 0) return; | |
| 627 | if (world.clouds.some((cloud) => cloudGone(cloud, world.time))) | |
| 628 | world.clouds = world.clouds.filter((cloud) => !cloudGone(cloud, world.time)); | |
| 589 | 629 | } |
| 590 | 630 | |
| 591 | 631 | // --- the bomb ------------------------------------------------------------- |
| @@ -693,7 +733,7 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 693 | 733 | p.armor = 0; |
| 694 | 734 | p.helmet = false; |
| 695 | 735 | p.defuseKit = false; |
| 696 | p.pocket = { he: 0, flash: 0 }; | |
| 736 | p.pocket = { he: 0, flash: 0, smoke: 0 }; | |
| 697 | 737 | p.secondary = p.team === "t" ? "glock" : "usp"; |
| 698 | 738 | p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) }; |
| 699 | 739 | p.weapon = p.secondary; |
| @@ -719,6 +759,10 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 719 | 759 | // After everyone has moved, so a grenade is solved against where people |
| 720 | 760 | // ended the tick rather than where they started it. |
| 721 | 761 | updateGrenades(world, dt); |
| 762 | // And the clouds the spent ones left, once they have thinned to nothing. A | |
| 763 | // cloud born on this very tick survives it: it is at zero radius and not at | |
| 764 | // the end of its life, which is the one instant the two differ at. | |
| 765 | expireClouds(world); | |
| 722 | 766 | // Once a tick, and not once per player: `bombTick` runs ten times and the |
| 723 | 767 | // fuse is one clock. |
| 724 | 768 | bombBeep(world, dt); |
| @@ -728,11 +772,49 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 728 | 772 | expireEffects(world.effects, world.time, EFFECT_LIFE); |
| 729 | 773 | } |
| 730 | 774 | |
| 731 | /** Is `target` visible to `from`, eye to eye? Used by bots and by the HUD. */ | |
| 775 | /** | |
| 776 | * Is `target` visible to `from`, eye to eye? The one question the whole game | |
| 777 | * asks about sight: the bots pick their fights with it, a bot deciding whether | |
| 778 | * it is safe to cut a wire asks it, and the observation the policy is handed | |
| 779 | * carries its answer. So this is the single place a cloud has to be accounted | |
| 780 | * for, and it is. | |
| 781 | * | |
| 782 | * Two walls, in the order that costs least. The level first — fifty-odd boxes | |
| 783 | * through the engine's `visible`, which is the test this has always been — and | |
| 784 | * then the clouds, which in most rounds is an empty array and a length check. | |
| 785 | * Neither is cheaper to skip: a wall is still a wall inside a smoke. | |
| 786 | * | |
| 787 | * A cloud is solid to sight and not translucent: a line that clips the edge of | |
| 788 | * the ball is blocked exactly as one through the middle is. That is the | |
| 789 | * approximation this issue was allowed to make and it is the one that keeps the | |
| 790 | * test cheap — `segmentSphere` is a dot product and at most one square root — | |
| 791 | * and it is also what makes a body *standing in* a cloud blind in every | |
| 792 | * direction, which is what a smoke is for. | |
| 793 | */ | |
| 732 | 794 | export function canSee(world: World, from: Player, target: Player): boolean { |
| 733 | return visible(eyePos(from), eyePos(target), world.level.boxes); | |
| 795 | const eye = eyePos(from); | |
| 796 | const at = eyePos(target); | |
| 797 | if (!visible(eye, at, world.level.boxes)) return false; | |
| 798 | return !throughSmoke(world, eye, at); | |
| 734 | 799 | } |
| 735 | 800 | |
| 801 | /** | |
| 802 | * Does the line from `eye` to `at` pass through any cloud standing right now? | |
| 803 | * Separate from `canSee` because a cloud is a fact about a *line* and not about | |
| 804 | * two players: the HUD asks it about the camera, and a test asks it about two | |
| 805 | * points nobody is standing on. | |
| 806 | */ | |
| 807 | export function throughSmoke(world: World, eye: Vec3, at: Vec3): boolean { | |
| 808 | for (const cloud of world.clouds) { | |
| 809 | if (segmentSphere(eye, at, cloud.pos, cloudRadius(cloud, world.time))) return true; | |
| 810 | } | |
| 811 | return false; | |
| 812 | } | |
| 813 | ||
| 814 | /** Is this point inside a cloud — is an eye here in the fog? */ | |
| 815 | export const insideSmoke = (world: World, at: Vec3): boolean => | |
| 816 | world.clouds.some((cloud) => dist(at, cloud.pos) < cloudRadius(cloud, world.time)); | |
| 817 | ||
| 736 | 818 | export const facing = (p: Player, target: Vec3): number => { |
| 737 | 819 | const to = sub(target, eyePos(p)); |
| 738 | 820 | const yaw = (Math.atan2(to[1], to[0]) * 180) / Math.PI; |
src/engine/GUIDE.md +6
| @@ -176,6 +176,12 @@ index is carried so the thing you hit knows what it is made of — and a `Hit` | ||
| 176 | 176 | carries the fraction along the segment, the point, the normal and the thing that |
| 177 | 177 | was hit, which is whatever you put in the list. |
| 178 | 178 | |
| 179 | And one shape that is not a box: `segmentSphere` answers whether a segment meets | |
| 180 | a ball, for a dot product and at most one square root, with no allocation and no | |
| 181 | hit record — it is in the way or it is not. It is what a cloud of smoke is made | |
| 182 | of in the shooter, where it sits on the hot path of "can A see B" ten times a | |
| 183 | tick, and it is the reason that question is still cheap with smoke in the round. | |
| 184 | ||
| 179 | 185 | A shot in the example is one ray and one box, and it is the whole of hitscan: |
| 180 | 186 | `src/range/targets.ts` does the same against a list with `traceWorld`. A miss is |
| 181 | 187 | still a shot the player saw leave the barrel, so the tracer is written before the |
src/engine/index.ts +1 −1
| @@ -19,7 +19,7 @@ export { | ||
| 19 | 19 | export type { Mat4, Vec3 } from "./math.ts"; |
| 20 | 20 | |
| 21 | 21 | /** Rays against axis-aligned boxes, and the `Box` they are traced against. */ |
| 22 | export { rayBox, segmentLength, traceWorld, visible } from "./trace.ts"; | |
| 22 | export { rayBox, segmentLength, segmentSphere, traceWorld, visible } from "./trace.ts"; | |
| 23 | 23 | export type { Box, Hit } from "./trace.ts"; |
| 24 | 24 | |
| 25 | 25 | /** |
src/engine/surface.json +2 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "version": "0.1.0", | |
| 2 | "version": "0.2.0", | |
| 3 | 3 | "names": [ |
| 4 | 4 | "Activations", |
| 5 | 5 | "Adam", |
| @@ -161,6 +161,7 @@ | ||
| 161 | 161 | "sample", |
| 162 | 162 | "scale", |
| 163 | 163 | "segmentLength", |
| 164 | "segmentSphere", | |
| 164 | 165 | "softmax", |
| 165 | 166 | "stuck", |
| 166 | 167 | "sub", |
src/engine/trace.ts +50 −2
| @@ -1,6 +1,13 @@ | ||
| 1 | 1 | /** |
| 2 | * Rays against boxes. Bullets are hitscan, sight is a ray, and both ask the | |
| 3 | * same question: what is the first thing between here and there. | |
| 2 | * Rays against boxes, and a segment against a sphere. Bullets are hitscan, | |
| 3 | * sight is a ray, and both ask the same question: what is the first thing | |
| 4 | * between here and there. | |
| 5 | * | |
| 6 | * A box is what a level is built of and a sphere is what a cloud is: the | |
| 7 | * shooter's smoke grenade is a ball nothing can see through, and a ball is the | |
| 8 | * one shape a segment can be tested against for the price of a dot product and | |
| 9 | * at most one square root — which is why it is here rather than approximated | |
| 10 | * out of boxes. | |
| 4 | 11 | */ |
| 5 | 12 | |
| 6 | 13 | import { sub, length, type Vec3 } from "./math.ts"; |
| @@ -62,4 +69,45 @@ export function traceWorld(from: Vec3, to: Vec3, boxes: Box[]): Hit | null { | ||
| 62 | 69 | export const visible = (from: Vec3, to: Vec3, boxes: Box[]): boolean => |
| 63 | 70 | traceWorld(from, to, boxes) === null; |
| 64 | 71 | |
| 72 | /** | |
| 73 | * Does the segment `from → to` meet the ball of radius `radius` about `centre`? | |
| 74 | * A segment and not a ray: a sphere behind you or past the far end is not in | |
| 75 | * the way, and both of those are cases a plain quadratic answers wrongly. | |
| 76 | * | |
| 77 | * The arithmetic, with `d = to − from` and `f = from − centre`: | |
| 78 | * | |
| 79 | * |f + t·d|² = r² → (d·d)t² + 2(f·d)t + (f·f − r²) = 0 | |
| 80 | * | |
| 81 | * `c = f·f − r²` at or below zero is a start already inside the ball, which is | |
| 82 | * a meeting whatever the roots say — and it is the case that is asked most, so | |
| 83 | * it is answered first and without the square root. Otherwise the ball is | |
| 84 | * entered at the smaller root, and the segment meets it exactly when that root | |
| 85 | * lies in [0, 1]: `t < 0` is a ball behind the eye and `t > 1` one past the | |
| 86 | * target. A degenerate segment — both ends the same point — has `a = 0`, and | |
| 87 | * the `c ≤ 0` answer above has already covered the only way it can meet. | |
| 88 | * | |
| 89 | * Nothing is allocated and nothing is returned but a boolean: what is wanted of | |
| 90 | * a cloud is whether it is in the way, never where it was entered. | |
| 91 | */ | |
| 92 | export function segmentSphere(from: Vec3, to: Vec3, centre: Vec3, radius: number): boolean { | |
| 93 | if (radius <= 0) return false; | |
| 94 | const fx = (from[0] as number) - (centre[0] as number); | |
| 95 | const fy = (from[1] as number) - (centre[1] as number); | |
| 96 | const fz = (from[2] as number) - (centre[2] as number); | |
| 97 | const c = fx * fx + fy * fy + fz * fz - radius * radius; | |
| 98 | if (c <= 0) return true; | |
| 99 | const dx = (to[0] as number) - (from[0] as number); | |
| 100 | const dy = (to[1] as number) - (from[1] as number); | |
| 101 | const dz = (to[2] as number) - (from[2] as number); | |
| 102 | const a = dx * dx + dy * dy + dz * dz; | |
| 103 | if (a <= 0) return false; | |
| 104 | const half = fx * dx + fy * dy + fz * dz; | |
| 105 | // The ball is wholly behind the eye: the near root cannot be positive. | |
| 106 | if (half >= 0) return false; | |
| 107 | const disc = half * half - a * c; | |
| 108 | if (disc < 0) return false; | |
| 109 | const t = (-half - Math.sqrt(disc)) / a; | |
| 110 | return t <= 1; | |
| 111 | } | |
| 112 | ||
| 65 | 113 | export const segmentLength = (from: Vec3, to: Vec3): number => length(sub(to, from)); |
src/games.ts +17 −1
| @@ -449,6 +449,22 @@ export type Game = { | ||
| 449 | 449 | * raw and 2,873 gzipped to spare, so no ceiling moved. Neither other bundle |
| 450 | 450 | * changed a byte: nothing outside `src/client/` and `src/pages/game.ts` did. |
| 451 | 451 | * Measured 2026-09-21. |
| 452 | * #165 put the third grenade in the game — a smoke, which is the first thing | |
| 453 | * here that changes what a bot can *see*: a canister that waits until it has | |
| 454 | * come to rest, a `Cloud` the world keeps, `segmentSphere` behind the engine's | |
| 455 | * door, the ball `canSee` traces against, the lumpy mesh it is drawn as, the | |
| 456 | * wash the HUD puts over an eye inside one, the step `bots.ts` bends around | |
| 457 | * one, a sound, a key and a row in the weapon table. The app grew 3,067 raw / | |
| 458 | * 1,107 gzipped bytes for all of it: 95,598 → 98,665 raw and 37,788 → 38,895 | |
| 459 | * gzipped. That is 665 bytes past the raw half of the 98,000 / 39,000 #214 | |
| 460 | * brought *down*, and the gzipped half had 105 left, which is not headroom | |
| 461 | * anybody can land a feature in — so the ceiling is 103,000 / 41,000 here, | |
| 462 | * today's size plus about four per cent of raw and five of gzip, on the same | |
| 463 | * reasoning #164, #185 and #148 raised the last three. Neither other bundle | |
| 464 | * moved a byte: the range is 34,810 raw / 14,568 gzipped, the same numbers | |
| 465 | * #216 measured, and nothing outside `src/client/`, `src/engine/trace.ts` and | |
| 466 | * `src/pages/game.ts` changed — the range and the example ship no world, no | |
| 467 | * weapons and no cloud. Measured 2026-09-21. | |
| 452 | 468 | */ |
| 453 | 469 | export const GAMES: readonly Game[] = [ |
| 454 | 470 | { |
| @@ -456,7 +472,7 @@ export const GAMES: readonly Game[] = [ | ||
| 456 | 472 | href: "/", |
| 457 | 473 | label: "cs", |
| 458 | 474 | entry: "src/client/main.ts", |
| 459 | ceiling: { raw: 98_000, gzip: 39_000 }, | |
| 475 | ceiling: { raw: 103_000, gzip: 41_000 }, | |
| 460 | 476 | assets: [ |
| 461 | 477 | { |
| 462 | 478 | name: "weights", |
src/pages/game.ts +8 −2
| @@ -274,7 +274,7 @@ const menu = (visitor: Who, asked: Asked): string => ` | ||
| 274 | 274 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> |
| 275 | 275 | <p class="hint" id="prep">Preparing textures…</p> |
| 276 | 276 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 277 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · | |
| 277 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · <kbd>V</kbd> throw a smoke · | |
| 278 | 278 | <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · |
| 279 | 279 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 280 | 280 | <kbd>Esc</kbd> or <kbd>P</kbd> pause</p> |
| @@ -420,6 +420,7 @@ export function gamePage( | ||
| 420 | 420 | { action: "reload", label: "reload" }, |
| 421 | 421 | { action: "grenade", label: "nade" }, |
| 422 | 422 | { action: "flash", label: "flash" }, |
| 423 | { action: "smoke", label: "smoke" }, | |
| 423 | 424 | { action: "buy", label: "buy" }, |
| 424 | 425 | { action: "fire", label: "fire" }, |
| 425 | 426 | ], |
| @@ -447,7 +448,12 @@ export function gamePage( | ||
| 447 | 448 | the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see — |
| 448 | 449 | and a flashbang thrown the same way, which takes sight rather than health: up to 1.6 seconds |
| 449 | 450 | of it, by the angle you were looking at it from and how far away it went off, and none at all |
| 450 | through a wall or over your shoulder. | |
| 451 | through a wall or over your shoulder, and a smoke grenade, which is the one that changes how | |
| 452 | a round is played: it opens where the canister came to rest and stands a ball 180 units | |
| 453 | across for fifteen seconds, growing and thinning on the round's own clock. It blocks sight in | |
| 454 | the simulation and not only on the screen — the one function that answers "can this body see | |
| 455 | that one" traces the ball, so a bot behind smoke is as blind as you are and will not shoot | |
| 456 | what it cannot see, and it walks around a cloud rather than into one. | |
| 451 | 457 | Everything you see is generated — the map, the textures, the models and the sounds — so the |
| 452 | 458 | whole game is one small download and no asset files.</p> |
| 453 | 459 | ${policyHtml(card)} |
new above
offline