Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
Transient effects become engine: a capped ring of timestamped events, pruned without allocating #166
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/67-transient-effects-become-engine-a-capped → main 2 commits fork
21 files
changed +625
−130
README.md +14 −3
| @@ -227,7 +227,14 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 227 | 227 | game picks, `resetScene` puts the counts back at the top of a frame, |
| 228 | 228 | `nextInstance` hands out the next pooled instance and `pushLine` writes one |
| 229 | 229 | line of two vertices — and at a capacity a frame draws less rather than |
| 230 | growing, the same policy in both games) and `renderer` (the WebGL2: two programs, an | |
| 230 | growing, the same policy in both games), `effects` (a capped ring of | |
| 231 | timestamped events, which is what a game remembers of what just happened: | |
| 232 | `createEffects` builds the slots and the stamps whole, `pushEffect` writes | |
| 233 | one, `effectAt` reads them back in the order they were pushed, `expireEffects` | |
| 234 | drops everything older than a lifetime by compacting what survived, and | |
| 235 | `resetEffects` empties it — nothing allocates after the first call, a full | |
| 236 | buffer drops its oldest rather than growing, and the event type is the | |
| 237 | game's, so it knows no tracer, no blood and no weapon) and `renderer` (the WebGL2: two programs, an | |
| 231 | 238 | atlas and named meshes uploaded once at construction, and one `Scene` of |
| 232 | 239 | instances and lines drawn per frame, each instance a mesh name with a place, a |
| 233 | 240 | yaw, a size and a vertical squash — so one uploaded mesh draws at any size, |
| @@ -280,7 +287,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 280 | 287 | `FullscreenHost`, `FullscreenTarget` and `FullscreenOwner`; from `overlay` `overlay`, `Overlay` and the `Paint` |
| 281 | 288 | a 2D game draws onto; from `scene` the frame and its pool — `createScene`, |
| 282 | 289 | `resetScene`, `nextInstance`, `pushLine`, `Scene`, `SceneCapacity`, `Camera` |
| 283 | and `Instance`; from `renderer` `createRenderer` with `Renderer` and | |
| 290 | and `Instance`; from `effects` the ring of what just happened — | |
| 291 | `createEffects`, `pushEffect`, `effectAt`, `expireEffects`, `resetEffects` and | |
| 292 | `Effects`; from `renderer` `createRenderer` with `Renderer` and | |
| 284 | 293 | `RendererAssets`, which draws that frame; and from `digest` `QUANTUM`, |
| 285 | 294 | `quantise`, `CHECKPOINT_CHARS`, `fold`, `compare`, `Fold`, `Folded`, `Pin` |
| 286 | 295 | and `Complaint`, which both games' pinned runs are folded by. |
| @@ -330,7 +339,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 330 | 339 | reaches only what `traceWorld` says it can see), |
| 331 | 340 | `match` |
| 332 | 341 | (the rules), |
| 333 | `world` (the simulation), | |
| 342 | `world` (the simulation, and the two seconds of what just happened it keeps in | |
| 343 | the engine's ring: `EFFECT_CAPACITY` events at most, each stamped, pruned at | |
| 344 | the end of every tick by `EFFECT_LIFE` without allocating), | |
| 334 | 345 | `bots`, `observe`, `action`, `weights`, `models`, `materials`, |
| 335 | 346 | `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round |
| 336 | 347 | clock, the kill feed and the scoreboard — every one of them drawn through the |
src/client/grenade.test.ts +15 −5
| @@ -25,8 +25,18 @@ import type { Box, Vec3 } from "../engine/index.ts"; | ||
| 25 | 25 | import { digest, TICK, withoutEntropy } from "./replay.ts"; |
| 26 | 26 | import { |
| 27 | 27 | buy, createWorld, eyePos, MAX_GRENADES, NO_INPUT, throwOne, update, |
| 28 | type Input, type Player, type World, | |
| 28 | type Effect, type Input, type Player, type World, | |
| 29 | 29 | } from "./world.ts"; |
| 30 | import { effectAt } from "../engine/index.ts"; | |
| 31 | ||
| 32 | /** Did anything the world remembers look like this? The buffer is read by index. */ | |
| 33 | function heard(world: World, is: (e: Effect) => boolean): boolean { | |
| 34 | for (let i = 0; i < world.effects.count; i++) { | |
| 35 | const effect = effectAt(world.effects, i); | |
| 36 | if (effect !== undefined && is(effect)) return true; | |
| 37 | } | |
| 38 | return false; | |
| 39 | } | |
| 30 | 40 | |
| 31 | 41 | const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 }; |
| 32 | 42 | const EAST_WALL: Box = { min: [300, -4000, 0], max: [364, 4000, 400], mat: 0 }; |
| @@ -198,7 +208,7 @@ test("the throw key throws one, once a press, and never during the freeze", () = | ||
| 198 | 208 | expect(world.me.grenades).toBe(0); |
| 199 | 209 | expect(world.grenades[0]?.owner).toBe(world.me.id); |
| 200 | 210 | // And the throw was heard where the hand was. |
| 201 | expect(world.effects.some((e) => e.kind === "sound" && e.name === "throw")).toBe(true); | |
| 211 | expect(heard(world, (e) => e.kind === "sound" && e.name === "throw")).toBe(true); | |
| 202 | 212 | }); |
| 203 | 213 | |
| 204 | 214 | test("the fuse is the same number of ticks whatever the frame rate", () => { |
| @@ -248,9 +258,9 @@ test("a blast that hurts someone marks the hit, and one that kills names the gre | ||
| 248 | 258 | |
| 249 | 259 | expect(victim.health).toBeLessThan(healthBefore); |
| 250 | 260 | expect(world.me.damage).toBeGreaterThan(damageBefore); |
| 251 | expect(world.effects.some((e) => e.kind === "hitmark")).toBe(true); | |
| 252 | expect(world.effects.some((e) => e.kind === "blast")).toBe(true); | |
| 253 | expect(world.effects.some((e) => e.kind === "sound" && e.name === "blast")).toBe(true); | |
| 261 | expect(heard(world, (e) => e.kind === "hitmark")).toBe(true); | |
| 262 | expect(heard(world, (e) => e.kind === "blast")).toBe(true); | |
| 263 | expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(true); | |
| 254 | 264 | // 83 hit points at the feet of a body with no vest is not a kill on its own, |
| 255 | 265 | // so a kill is only asserted when the blast actually finished them. |
| 256 | 266 | if (!victim.alive) { |
src/client/hud.test.ts +17 −6
| @@ -25,7 +25,18 @@ import { drawHud, type Paint } from "./hud.ts"; | ||
| 25 | 25 | import { DE_FORGE } from "./map.ts"; |
| 26 | 26 | import { SHORT } from "./match.ts"; |
| 27 | 27 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 28 | import { createWorld, currentWeapon, type Ammo, type Player, type World } from "./world.ts"; | |
| 28 | import { | |
| 29 | createWorld, currentWeapon, EFFECT_CAPACITY, | |
| 30 | type Ammo, type Effect, type Player, type World, | |
| 31 | } from "./world.ts"; | |
| 32 | import { createEffects, pushEffect } from "../engine/index.ts"; | |
| 33 | ||
| 34 | /** The one effect this frame is about, in a buffer of its own. */ | |
| 35 | function only(effect: Effect): ReturnType<typeof createEffects<Effect>> { | |
| 36 | const effects = createEffects<Effect>(EFFECT_CAPACITY); | |
| 37 | pushEffect(effects, effect, effect.at); | |
| 38 | return effects; | |
| 39 | } | |
| 29 | 40 | |
| 30 | 41 | // --- a paint that remembers ------------------------------------------------ |
| 31 | 42 | |
| @@ -85,7 +96,7 @@ function scene(change: (world: World) => void = () => {}): World { | ||
| 85 | 96 | players, |
| 86 | 97 | me: players[0] as Player, |
| 87 | 98 | bomb: { ...BASE.bomb, defuser: null }, |
| 88 | effects: [], | |
| 99 | effects: createEffects<Effect>(EFFECT_CAPACITY), | |
| 89 | 100 | feed: [], |
| 90 | 101 | messages: [], |
| 91 | 102 | }; |
| @@ -183,7 +194,7 @@ test("the crosshair opens exactly as far as the gun is inaccurate, and is gone w | ||
| 183 | 194 | test("a hit is marked white, a headshot red, and both are gone a quarter second later", () => { |
| 184 | 195 | const hit = draw(scene((w) => { |
| 185 | 196 | w.time = 10; |
| 186 | w.effects = [{ kind: "hitmark", at: 9.9, headshot: false }]; | |
| 197 | w.effects = only({ kind: "hitmark", at: 9.9, headshot: false }); | |
| 187 | 198 | })); |
| 188 | 199 | const body = hit.calls.filter((c) => c.op === "stroke"); |
| 189 | 200 | expect(body.length).toBe(2); // the crosshair, then the marker |
| @@ -191,13 +202,13 @@ test("a hit is marked white, a headshot red, and both are gone a quarter second | ||
| 191 | 202 | |
| 192 | 203 | const head = draw(scene((w) => { |
| 193 | 204 | w.time = 10; |
| 194 | w.effects = [{ kind: "hitmark", at: 9.9, headshot: true }]; | |
| 205 | w.effects = only({ kind: "hitmark", at: 9.9, headshot: true }); | |
| 195 | 206 | })); |
| 196 | 207 | expect(head.calls.filter((c) => c.op === "stroke")[1]?.stroke).toBe("rgba(255,90,90,0.95)"); |
| 197 | 208 | |
| 198 | 209 | const old = draw(scene((w) => { |
| 199 | 210 | w.time = 10; |
| 200 | w.effects = [{ kind: "hitmark", at: 9.7, headshot: true }]; | |
| 211 | w.effects = only({ kind: "hitmark", at: 9.7, headshot: true }); | |
| 201 | 212 | })); |
| 202 | 213 | expect(old.calls.filter((c) => c.op === "stroke").length).toBe(1); |
| 203 | 214 | }); |
| @@ -519,7 +530,7 @@ const busyWorld = (): World => scene((w) => { | ||
| 519 | 530 | w.bomb.state = "planted"; |
| 520 | 531 | w.bomb.defuser = w.me; |
| 521 | 532 | w.bomb.defuseProgress = 1; |
| 522 | w.effects = [{ kind: "hitmark", at: 99.95, headshot: true }]; | |
| 533 | w.effects = only({ kind: "hitmark", at: 99.95, headshot: true }); | |
| 523 | 534 | w.feed = [kill(99), kill(97, { killerTeam: "ct" }), kill(94, { headshot: true })]; |
| 524 | 535 | w.messages = [{ at: 99.5, text: "The bomb has been planted at B." }]; |
| 525 | 536 | for (const p of w.players) p.kills = p.id; |
src/client/hud.ts +4 −3
| @@ -17,7 +17,7 @@ | ||
| 17 | 17 | * what colour a team is, which phase has a clock, and the words. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { clamp, overlay, type Overlay, type Paint as Surface } from "../engine/index.ts"; | |
| 20 | import { clamp, effectAt, overlay, type Overlay, type Paint as Surface } from "../engine/index.ts"; | |
| 21 | 21 | import { CS_MOVE } from "./movement.ts"; |
| 22 | 22 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 23 | 23 | import { currentWeapon, useProgress, type Player, type World } from "./world.ts"; |
| @@ -115,8 +115,9 @@ export function drawHud( | ||
| 115 | 115 | |
| 116 | 116 | // --- hit markers. Four arms like the crosshair's, but on the diagonals and |
| 117 | 117 | // growing as the mark ages, which is not a shape the engine hands out. |
| 118 | for (const effect of world.effects) { | |
| 119 | if (effect.kind !== "hitmark") continue; | |
| 118 | for (let i = 0; i < world.effects.count; i++) { | |
| 119 | const effect = effectAt(world.effects, i); | |
| 120 | if (effect === undefined || effect.kind !== "hitmark") continue; | |
| 120 | 121 | const age = world.time - effect.at; |
| 121 | 122 | if (age > 0.25) continue; |
| 122 | 123 | ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)"; |
src/client/main.ts +5 −3
| @@ -25,7 +25,8 @@ import { createAudio, type Audio } from "./audio.ts"; | ||
| 25 | 25 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts"; |
| 26 | 26 | import { drawHud, type Paint } from "./hud.ts"; |
| 27 | 27 | import { |
| 28 | attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, painter, | |
| 28 | attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, effectAt, | |
| 29 | painter, | |
| 29 | 30 | type Button, type Camera, type Listens, type Mesh, |
| 30 | 31 | } from "../engine/index.ts"; |
| 31 | 32 | import { generateLevel, seedFrom } from "./generate.ts"; |
| @@ -393,8 +394,9 @@ export function boot(host: Host): Frame { | ||
| 393 | 394 | * a round heard from your own corpse is the other half of a broken spectate. |
| 394 | 395 | */ |
| 395 | 396 | function playSounds(current: World, ears: Player): void { |
| 396 | for (const effect of current.effects) { | |
| 397 | if (effect.at <= lastSound) continue; | |
| 397 | for (let i = 0; i < current.effects.count; i++) { | |
| 398 | const effect = effectAt(current.effects, i); | |
| 399 | if (effect === undefined || effect.at <= lastSound) continue; | |
| 398 | 400 | if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears)); |
| 399 | 401 | else if (effect.kind === "sound") audio.grenade(effect.name, effect.pos, eyePos(ears)); |
| 400 | 402 | else if (effect.kind === "hitmark") audio.hit(effect.headshot); |
src/client/scene.test.ts +29 −13
| @@ -13,12 +13,21 @@ | ||
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | 14 | import { existsSync, readFileSync } from "node:fs"; |
| 15 | 15 | import { join } from "node:path"; |
| 16 | import type { Camera } from "../engine/index.ts"; | |
| 16 | import { pushEffect, resetEffects, type Camera } from "../engine/index.ts"; | |
| 17 | 17 | import { CS_HULL } from "./movement.ts"; |
| 18 | 18 | import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts"; |
| 19 | 19 | import { DE_FORGE } from "./map.ts"; |
| 20 | 20 | import { SHORT } from "./match.ts"; |
| 21 | import { createWorld, type Effect, type Player, type World } from "./world.ts"; | |
| 21 | import { createWorld, EFFECT_CAPACITY, type Effect, type Player, type World } from "./world.ts"; | |
| 22 | ||
| 23 | /** One more thing that happened, stamped with the time it says it happened at. */ | |
| 24 | const add = (w: World, effect: Effect): void => pushEffect(w.effects, effect, effect.at); | |
| 25 | ||
| 26 | /** That, and nothing else: what the frame under test is about. */ | |
| 27 | const only = (w: World, effect: Effect): void => { | |
| 28 | resetEffects(w.effects); | |
| 29 | add(w, effect); | |
| 30 | }; | |
| 22 | 31 | |
| 23 | 32 | const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 }); |
| 24 | 33 | |
| @@ -26,7 +35,7 @@ const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 | ||
| 26 | 35 | function world(): World { |
| 27 | 36 | const w = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 7); |
| 28 | 37 | w.time = 100; |
| 29 | w.effects.length = 0; | |
| 38 | resetEffects(w.effects); | |
| 30 | 39 | return w; |
| 31 | 40 | } |
| 32 | 41 | |
| @@ -116,13 +125,13 @@ test("a tracer 40 ms old is two vertices and one 120 ms old is none", () => { | ||
| 116 | 125 | const tracer = (at: number): Effect => ({ kind: "tracer", from: [0, 0, 0], to: [100, 0, 0], at }); |
| 117 | 126 | const scene = createScene(); |
| 118 | 127 | |
| 119 | w.effects.push(tracer(w.time - 0.04)); | |
| 128 | add(w, tracer(w.time - 0.04)); | |
| 120 | 129 | buildScene(w, camera(), scene); |
| 121 | 130 | expect(scene.lineCount).toBe(2); |
| 122 | 131 | // Half way through its 0.08 s, so half faded: the blue of the near end. |
| 123 | 132 | expect(scene.lines[5]).toBeCloseTo(0.3, 6); |
| 124 | 133 | |
| 125 | w.effects[0] = tracer(w.time - 0.12); | |
| 134 | only(w, tracer(w.time - 0.12)); | |
| 126 | 135 | buildScene(w, camera(), scene); |
| 127 | 136 | expect(scene.lineCount).toBe(0); |
| 128 | 137 | }); |
| @@ -131,17 +140,17 @@ test("an impact is three sparks for a quarter of a second, and blood two for 0.3 | ||
| 131 | 140 | const w = world(); |
| 132 | 141 | const scene = createScene(); |
| 133 | 142 | |
| 134 | w.effects.push({ kind: "impact", at: w.time - 0.2, pos: [1, 2, 3], normal: [0, 0, 1] }); | |
| 143 | add(w, { kind: "impact", at: w.time - 0.2, pos: [1, 2, 3], normal: [0, 0, 1] }); | |
| 135 | 144 | buildScene(w, camera(), scene); |
| 136 | 145 | expect(scene.lineCount).toBe(6); |
| 137 | w.effects[0] = { kind: "impact", at: w.time - 0.26, pos: [1, 2, 3], normal: [0, 0, 1] }; | |
| 146 | only(w, { kind: "impact", at: w.time - 0.26, pos: [1, 2, 3], normal: [0, 0, 1] }); | |
| 138 | 147 | buildScene(w, camera(), scene); |
| 139 | 148 | expect(scene.lineCount).toBe(0); |
| 140 | 149 | |
| 141 | w.effects[0] = { kind: "blood", at: w.time - 0.29, pos: [1, 2, 3] }; | |
| 150 | only(w, { kind: "blood", at: w.time - 0.29, pos: [1, 2, 3] }); | |
| 142 | 151 | buildScene(w, camera(), scene); |
| 143 | 152 | expect(scene.lineCount).toBe(2); |
| 144 | w.effects[0] = { kind: "blood", at: w.time - 0.31, pos: [1, 2, 3] }; | |
| 153 | only(w, { kind: "blood", at: w.time - 0.31, pos: [1, 2, 3] }); | |
| 145 | 154 | buildScene(w, camera(), scene); |
| 146 | 155 | expect(scene.lineCount).toBe(0); |
| 147 | 156 | }); |
| @@ -175,14 +184,21 @@ test("a planted bomb adds the beam and blinks; a carried one adds nothing", () = | ||
| 175 | 184 | |
| 176 | 185 | test("a flood of effects stops at the line capacity and writes nothing past it", () => { |
| 177 | 186 | const w = world(); |
| 178 | for (let i = 0; i < 5000; i++) | |
| 179 | w.effects.push({ kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: w.time - 0.01 }); | |
| 187 | const flood = 5000; | |
| 188 | for (let i = 0; i < flood; i++) | |
| 189 | add(w, { kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: w.time - 0.01 }); | |
| 190 | // The effect buffer capped first and kept the newest of them; the line budget | |
| 191 | // then ran out half way through those. | |
| 192 | expect(w.effects.count).toBe(EFFECT_CAPACITY); | |
| 180 | 193 | const scene = buildScene(w, camera(), createScene()); |
| 181 | 194 | expect(scene.lineCount).toBe(LINE_CAPACITY); |
| 182 | 195 | expect(scene.lineCount).toBe(4096); |
| 183 | 196 | expect(scene.lines.length).toBe(LINE_CAPACITY * 6); |
| 184 | // The last vertex written is the cap's, and the buffer never grew. | |
| 185 | expect(scene.lines[LINE_CAPACITY * 6 - 6]).toBe(LINE_CAPACITY / 2 - 1); | |
| 197 | // The last vertex written is the cap's, and the buffer never grew: the | |
| 198 | // oldest tracer still alive, plus the 2048 lines that fitted. | |
| 199 | const oldest = flood - EFFECT_CAPACITY; | |
| 200 | expect(scene.lines[LINE_CAPACITY * 6 - 6]).toBe(oldest + LINE_CAPACITY / 2 - 1); | |
| 201 | expect(scene.lines[LINE_CAPACITY * 6 - 6]).toBe(2951); | |
| 186 | 202 | }); |
| 187 | 203 | |
| 188 | 204 | test("the view model is the weapon's kind, and its kick decays over 0.12 s", () => { |
src/client/scene.ts +6 −2
| @@ -18,7 +18,7 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | 20 | import { |
| 21 | clamp, createScene as createPooledScene, hullHeight, nextInstance, pushLine, resetScene, | |
| 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 | 24 | import { CS_HULL } from "./movement.ts"; |
| @@ -153,7 +153,11 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play | ||
| 153 | 153 | place(into, "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE); |
| 154 | 154 | |
| 155 | 155 | // --- tracers, sparks and the planted bomb's beam, all as lines |
| 156 | for (const effect of world.effects) { | |
| 156 | // Read by index, oldest first: the effects are the engine's ring and a | |
| 157 | // counted loop over it is the reader that allocates nothing. | |
| 158 | for (let i = 0; i < world.effects.count; i++) { | |
| 159 | const effect = effectAt(world.effects, i); | |
| 160 | if (effect === undefined) continue; | |
| 157 | 161 | const age = world.time - effect.at; |
| 158 | 162 | if (effect.kind === "tracer" && age < 0.08) { |
| 159 | 163 | const fade = 1 - age / 0.08; |
src/client/world.ts +53 −16
| @@ -18,8 +18,9 @@ import { | ||
| 18 | 18 | } from "./grenade.ts"; |
| 19 | 19 | import { siteAt, type Level } from "./level.ts"; |
| 20 | 20 | import { |
| 21 | angleVectors, angleDelta, clamp, dist, eyeHeight, hullHeight, move, normalize, rng, sub, visible, | |
| 22 | type Body, type Vec3, type Wish, | |
| 21 | angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move, | |
| 22 | normalize, pushEffect, rng, sub, visible, | |
| 23 | type Body, type Effects, type Vec3, type Wish, | |
| 23 | 24 | } from "../engine/index.ts"; |
| 24 | 25 | import * as rules from "./match.ts"; |
| 25 | 26 | import { CS_HULL, CS_MOVE } from "./movement.ts"; |
| @@ -80,6 +81,28 @@ export type Effect = | ||
| 80 | 81 | | { kind: "blast"; at: number; pos: Vec3 } |
| 81 | 82 | | { kind: "sound"; at: number; pos: Vec3; name: GrenadeSound }; |
| 82 | 83 | |
| 84 | /** | |
| 85 | * How long anything that happened is remembered for, in seconds. Every kind of | |
| 86 | * effect is kept this long and each decides for itself how much of that it is | |
| 87 | * drawn for — a tracer 80 ms, blood 300 — which is why there is one lifetime | |
| 88 | * here and not seven. An effect exactly this old is gone, which is the | |
| 89 | * comparison this prune has always made. | |
| 90 | */ | |
| 91 | export const EFFECT_LIFE = 2; | |
| 92 | ||
| 93 | /** | |
| 94 | * How many effects those two seconds may hold. A 5v5 with every player holding | |
| 95 | * the trigger down peaks at 138 live at once, counted by stepping a round; the | |
| 96 | * arithmetic bound is higher — a hundred shots a second, up to four events each | |
| 97 | * — so call the loudest two seconds this game can have eight hundred. 4096 is | |
| 98 | * well past both on purpose: it is more than `scene.ts` can draw in one frame | |
| 99 | * anyway, so what limits a busy frame is the line budget there and never how | |
| 100 | * much of what happened is still remembered. Past it the oldest event is | |
| 101 | * dropped rather than the buffer grown — what a frame is about to draw is the | |
| 102 | * newest — and 4096 slots and their stamps are 64 KB, made once. | |
| 103 | */ | |
| 104 | export const EFFECT_CAPACITY = 4096; | |
| 105 | ||
| 83 | 106 | export type FeedEntry = { at: number; killer: string; victim: string; weapon: WeaponId; headshot: boolean; killerTeam: Team }; |
| 84 | 107 | |
| 85 | 108 | export type Input = { |
| @@ -112,7 +135,12 @@ export type World = { | ||
| 112 | 135 | me: Player; |
| 113 | 136 | time: number; |
| 114 | 137 | bomb: Bomb; |
| 115 | effects: Effect[]; | |
| 138 | /** | |
| 139 | * Everything that just happened, oldest first: the engine's ring at | |
| 140 | * `EFFECT_CAPACITY`, pruned by `EFFECT_LIFE` at the end of every tick. The | |
| 141 | * frame and the HUD read it by index and draw what is young enough. | |
| 142 | */ | |
| 143 | effects: Effects<Effect>; | |
| 116 | 144 | /** Every grenade in the air or on the floor right now, oldest first. */ |
| 117 | 145 | grenades: Grenade[]; |
| 118 | 146 | feed: FeedEntry[]; |
| @@ -185,13 +213,21 @@ export function createWorld( | ||
| 185 | 213 | const world: World = { |
| 186 | 214 | match, players, me, time: 0, |
| 187 | 215 | bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null }, |
| 188 | effects: [], grenades: [], feed: [], messages: [], | |
| 216 | effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [], | |
| 189 | 217 | random, level, bombPlanted: false, pendingBuy: false, |
| 190 | 218 | }; |
| 191 | 219 | startRound(world); |
| 192 | 220 | return world; |
| 193 | 221 | } |
| 194 | 222 | |
| 223 | /** | |
| 224 | * One thing that just happened, into the ring. The buffer takes the stamp | |
| 225 | * separately because it knows nothing of an `Effect`, and handing it `e.at` | |
| 226 | * here is what keeps the two the same number: the frame reads the one in the | |
| 227 | * event, and the prune reads the one beside it. | |
| 228 | */ | |
| 229 | const record = (world: World, e: Effect): void => pushEffect(world.effects, e, e.at); | |
| 230 | ||
| 195 | 231 | export const teammates = (world: World, team: Team): Player[] => world.players.filter((p) => p.team === team); |
| 196 | 232 | export const alive = (world: World, team: Team): Player[] => |
| 197 | 233 | world.players.filter((p) => p.team === team && p.alive); |
| @@ -391,18 +427,18 @@ export function shoot(world: World, p: Player): boolean { | ||
| 391 | 427 | const dir = angleVectors(yaw, pitch).forward; |
| 392 | 428 | const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range]; |
| 393 | 429 | const hit = traceBullet(from, to, world.level.boxes, world.players, p.id); |
| 394 | world.effects.push({ kind: "shot", at: world.time, pos: from, weapon: w.id, mine: p === world.me }); | |
| 395 | if (w.kind !== "knife") world.effects.push({ kind: "tracer", at: world.time, from, to: hit.kind === "none" ? to : hit.point }); | |
| 430 | record(world, { kind: "shot", at: world.time, pos: from, weapon: w.id, mine: p === world.me }); | |
| 431 | if (w.kind !== "knife") record(world, { kind: "tracer", at: world.time, from, to: hit.kind === "none" ? to : hit.point }); | |
| 396 | 432 | |
| 397 | 433 | if (hit.kind === "world") { |
| 398 | world.effects.push({ kind: "impact", at: world.time, pos: hit.point, normal: hit.normal }); | |
| 434 | record(world, { kind: "impact", at: world.time, pos: hit.point, normal: hit.normal }); | |
| 399 | 435 | } else if (hit.kind === "actor") { |
| 400 | 436 | const victim = hit.actor as Player; |
| 401 | 437 | if (victim.team === p.team && victim !== p) return true; // no friendly fire in this match |
| 402 | 438 | const result = applyHit(victim, w, hit.group, hit.distance); |
| 403 | 439 | p.damage += result.damage; |
| 404 | world.effects.push({ kind: "blood", at: world.time, pos: hit.point }); | |
| 405 | if (p === world.me) world.effects.push({ kind: "hitmark", at: world.time, headshot: result.headshot }); | |
| 440 | record(world, { kind: "blood", at: world.time, pos: hit.point }); | |
| 441 | if (p === world.me) record(world, { kind: "hitmark", at: world.time, headshot: result.headshot }); | |
| 406 | 442 | if (result.killed) onKill(world, p, victim, w.id, result.headshot); |
| 407 | 443 | } |
| 408 | 444 | return true; |
| @@ -440,12 +476,12 @@ export function throwOne(world: World, p: Player): boolean { | ||
| 440 | 476 | const from = eyePos(p); |
| 441 | 477 | const dir = angleVectors(p.yaw, p.pitch - THROW_LIFT).forward; |
| 442 | 478 | world.grenades.push(throwGrenade(p.id, p.team, from, dir, world.level.boxes)); |
| 443 | world.effects.push({ kind: "sound", at: world.time, pos: from, name: "throw" }); | |
| 479 | record(world, { kind: "sound", at: world.time, pos: from, name: "throw" }); | |
| 444 | 480 | return true; |
| 445 | 481 | } |
| 446 | 482 | |
| 447 | 483 | const heard = (world: World, pos: Vec3, name: GrenadeSound): void => { |
| 448 | world.effects.push({ kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name }); | |
| 484 | record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name }); | |
| 449 | 485 | }; |
| 450 | 486 | |
| 451 | 487 | /** |
| @@ -455,16 +491,16 @@ const heard = (world: World, pos: Vec3, name: GrenadeSound): void => { | ||
| 455 | 491 | */ |
| 456 | 492 | function detonate(world: World, g: Grenade): void { |
| 457 | 493 | const centre = blastCentre(g); |
| 458 | world.effects.push({ kind: "blast", at: world.time, pos: centre }); | |
| 494 | record(world, { kind: "blast", at: world.time, pos: centre }); | |
| 459 | 495 | heard(world, centre, "blast"); |
| 460 | 496 | const thrower = world.players.find((p) => p.id === g.owner); |
| 461 | 497 | for (const caught of blastHits(g, world.players, world.level.boxes)) { |
| 462 | 498 | const victim = caught.actor as Player; |
| 463 | 499 | const result = applyDamage(victim, caught.health, caught.armor); |
| 464 | world.effects.push({ kind: "blood", at: world.time, pos: [victim.pos[0], victim.pos[1], victim.pos[2] + 40] }); | |
| 500 | record(world, { kind: "blood", at: world.time, pos: [victim.pos[0], victim.pos[1], victim.pos[2] + 40] }); | |
| 465 | 501 | if (thrower === undefined) continue; |
| 466 | 502 | thrower.damage += result.damage; |
| 467 | if (thrower === world.me) world.effects.push({ kind: "hitmark", at: world.time, headshot: false }); | |
| 503 | if (thrower === world.me) record(world, { kind: "hitmark", at: world.time, headshot: false }); | |
| 468 | 504 | if (result.killed) onKill(world, thrower, victim, "he", false); |
| 469 | 505 | } |
| 470 | 506 | } |
| @@ -631,8 +667,9 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 631 | 667 | // ended the tick rather than where they started it. |
| 632 | 668 | updateGrenades(world, dt); |
| 633 | 669 | checkRoundEnd(world); |
| 634 | const cutoff = world.time - 2; | |
| 635 | if (world.effects.length > 0) world.effects = world.effects.filter((e) => e.at > cutoff); | |
| 670 | // What happened more than `EFFECT_LIFE` ago, dropped in place: the ring is | |
| 671 | // the engine's, so a busy tick rewrites indices instead of building an array. | |
| 672 | expireEffects(world.effects, world.time, EFFECT_LIFE); | |
| 636 | 673 | } |
| 637 | 674 | |
| 638 | 675 | /** Is `target` visible to `from`, eye to eye? Used by bots and by the HUD. */ |
src/engine/GUIDE.md +38
| @@ -416,6 +416,44 @@ gun in your hands. A game's own memory of the last frame hangs off its own type | ||
| 416 | 416 | beside these — the shooter keeps a walk cycle and one view model object there — |
| 417 | 417 | and the renderer never sees it. |
| 418 | 418 | |
| 419 | ### effects | |
| 420 | ||
| 421 | What just happened, kept for a moment. A shot leaves a tracer for 80 ms, a | |
| 422 | bullet leaves sparks for a quarter of a second, a grenade leaves a sound: each | |
| 423 | is a thing the game remembers, stamps with its own clock and throws away when it | |
| 424 | is old. Both games here keep such a list, and what they kept it in was an array | |
| 425 | pruned with `Array.prototype.filter`, which allocates a new array on every tick | |
| 426 | anything happened. | |
| 427 | ||
| 428 | `createEffects` takes one number — how many events the loudest two seconds the | |
| 429 | game can have will hold — and builds both stores whole: a slot per event and a | |
| 430 | `Float64Array` of stamps. After that nothing allocates. `pushEffect` writes one | |
| 431 | slot, keeping the object the game handed it rather than a copy; `Effects.count` | |
| 432 | is how many are live and `effectAt` reads them by index in the order they were | |
| 433 | pushed, oldest first, which is a counted loop and not an iterator; | |
| 434 | `expireEffects` drops everything older than a lifetime by compacting the | |
| 435 | survivors towards the head, writing indices rather than building a list; and | |
| 436 | `resetEffects` empties it — a round that restarts, an episode that resets — | |
| 437 | without touching the stores it was built with. | |
| 438 | ||
| 439 | **What happens at the cap is the scene's policy pointed the other way: a full | |
| 440 | buffer drops its oldest rather than growing.** The newest event is the one the | |
| 441 | next frame is about to draw, so the newest is the one that is kept. And the | |
| 442 | comparison is exact: `expireEffects` keeps an event whose stamp is strictly | |
| 443 | after `now - ttl`, so one exactly that old is gone — the shooter's replays are | |
| 444 | pinned to the tick an effect dies on. | |
| 445 | ||
| 446 | The event type is the game's. This knows no kind, no colour, no team and no | |
| 447 | weapon, and it has no ids, no priorities and no callbacks in it: what an event | |
| 448 | means, and how long each kind of one is drawn for, stays in the game — the | |
| 449 | shooter keeps everything for two seconds in `src/client/world.ts` and decides | |
| 450 | per kind by age in `src/client/scene.ts`. | |
| 451 | ||
| 452 | What an `Effects` holds is then four fields, of which the functions above are | |
| 453 | the only writers: `Effects.capacity`, the slots and the stamps built at it, | |
| 454 | `Effects.count` live events, and `Effects.head`, the slot the oldest of them is | |
| 455 | in. A game reads the first two of those and leaves the ring arithmetic here. | |
| 456 | ||
| 419 | 457 | ### renderer |
| 420 | 458 | |
| 421 | 459 | The 3D under the scene, and the largest thing behind the door. `createRenderer` |
src/engine/boundary.test.ts +12 −5
| @@ -287,12 +287,16 @@ test("the example imports the door and nothing else, which is what it is for", ( | ||
| 287 | 287 | * fullscreen — `Fullscreen`, `FullscreenHost`, `FullscreenOwner`, |
| 288 | 288 | * `FullscreenTarget`, `createFullscreen`. |
| 289 | 289 | * |
| 290 | * effects — `Effects`, `createEffects`, `pushEffect`, `expireEffects`, | |
| 291 | * `effectAt`, `resetEffects`. | |
| 292 | * | |
| 290 | 293 | * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one |
| 291 | 294 | * copy of the surface, read twice, rather than two that drift. |
| 292 | 295 | */ |
| 293 | 296 | export const SURFACE = [ |
| 294 | 297 | "Activations", "Adam", "Body", "Box", "Builder", "Burst", "Button", "ButtonOptions", |
| 295 | "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "FakeCanvas", | |
| 298 | "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "Effects", | |
| 299 | "FakeCanvas", | |
| 296 | 300 | "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold", |
| 297 | 301 | "Folded", "Frames", "Fullscreen", "FullscreenHost", "FullscreenOwner", "FullscreenTarget", |
| 298 | 302 | "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish", |
| @@ -303,16 +307,19 @@ export const SURFACE = [ | ||
| 303 | 307 | "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors", |
| 304 | 308 | "applyGrads", "attachButtons", "attenuate", |
| 305 | 309 | "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare", |
| 306 | "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createFullscreen", | |
| 310 | "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createEffects", | |
| 311 | "createFullscreen", | |
| 307 | 312 | "createLoop", "createPolicy", "createRenderer", "createRollout", "createScene", |
| 308 | 313 | "createSoundDevice", |
| 309 | "cross", "deg", "dist", "dist2d", "dot", "eyeHeight", "fakePage", "fbm", "findPath", "finish", | |
| 314 | "cross", "deg", "dist", "dist2d", "dot", "effectAt", "expireEffects", "eyeHeight", "fakePage", | |
| 315 | "fbm", "findPath", "finish", | |
| 310 | 316 | "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside", |
| 311 | 317 | "length", "length2d", "lerp", |
| 312 | 318 | "logitCount", "mat4", "mergeSolid", "modelMatrix", "move", "mul", "multiply", "newActivations", |
| 313 | 319 | "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter", |
| 314 | "paramCount", "perspective", "pushLine", "quadMesh", "quantise", "rad", "rayBox", "region", | |
| 315 | "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "stuck", "sub", | |
| 320 | "paramCount", "perspective", "pushEffect", "pushLine", "quadMesh", "quantise", "rad", "rayBox", | |
| 321 | "region", "resetEffects", "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", | |
| 322 | "softmax", "stuck", "sub", | |
| 316 | 323 | "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf", |
| 317 | 324 | "zeroGrads", "zeroPolicy", |
| 318 | 325 | ]; |
src/engine/effects.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | /** | |
| 2 | * The buffer, checked as a buffer. Every test here is about a ring of stamped | |
| 3 | * events — what is live at a time, what order they come back in, what happens | |
| 4 | * at the cap and what the stores are — and not one of them names a tracer, a | |
| 5 | * kind or a colour, because this module knows none. The event type below is a | |
| 6 | * string and a letter, which is enough to read an order off. | |
| 7 | * | |
| 8 | * The rule it exists to hold is the one a game cannot check for itself: after | |
| 9 | * `createEffects`, nothing allocates. So both stores are captured and compared | |
| 10 | * by identity across two full frames of pushes and expiries, and the cap is | |
| 11 | * flooded with five thousand events. | |
| 12 | */ | |
| 13 | ||
| 14 | import { expect, test } from "bun:test"; | |
| 15 | import { createEffects, effectAt, expireEffects, pushEffect, resetEffects } from "./effects.ts"; | |
| 16 | import type { Effects } from "./effects.ts"; | |
| 17 | ||
| 18 | /** Whatever a game puts in it: here, a letter, so an order reads back. */ | |
| 19 | type Mark = { name: string }; | |
| 20 | ||
| 21 | const mark = (name: string): Mark => ({ name }); | |
| 22 | ||
| 23 | /** The live events, oldest first, as their names — the only reader below. */ | |
| 24 | function names(fx: Effects<Mark>): string[] { | |
| 25 | const out: string[] = []; | |
| 26 | for (let i = 0; i < fx.count; i++) { | |
| 27 | const event = effectAt(fx, i); | |
| 28 | if (event !== undefined) out.push(event.name); | |
| 29 | } | |
| 30 | return out; | |
| 31 | } | |
| 32 | ||
| 33 | test("a buffer is built whole: every slot and every stamp, before the first push", () => { | |
| 34 | const fx = createEffects<Mark>(4); | |
| 35 | expect(fx.capacity).toBe(4); | |
| 36 | expect(fx.events.length).toBe(4); | |
| 37 | expect(fx.times.length).toBe(4); | |
| 38 | expect(fx.count).toBe(0); | |
| 39 | expect(fx.head).toBe(0); | |
| 40 | for (const slot of fx.events) expect(slot).toBeUndefined(); | |
| 41 | }); | |
| 42 | ||
| 43 | test("a capacity that is not a whole number above zero is refused with the number in it", () => { | |
| 44 | for (const capacity of [0, -1, 2.5, Number.NaN]) | |
| 45 | expect(() => createEffects<Mark>(capacity)).toThrow(/whole number above zero/); | |
| 46 | expect(() => createEffects<Mark>(1.5)).toThrow(/not 1.5/); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a buffer nobody has pushed to iterates zero times", () => { | |
| 50 | const fx = createEffects<Mark>(8); | |
| 51 | let seen = 0; | |
| 52 | for (let i = 0; i < fx.count; i++) seen += 1; | |
| 53 | expect(seen).toBe(0); | |
| 54 | expect(names(fx)).toEqual([]); | |
| 55 | expect(effectAt(fx, 0)).toBeUndefined(); | |
| 56 | expect(effectAt(fx, -1)).toBeUndefined(); | |
| 57 | }); | |
| 58 | ||
| 59 | test("an event pushed at 1 with a quarter-second life is there at 1.2 and gone at 1.3", () => { | |
| 60 | const fx = createEffects<Mark>(8); | |
| 61 | pushEffect(fx, mark("a"), 1); | |
| 62 | expireEffects(fx, 1.2, 0.25); | |
| 63 | expect(names(fx)).toEqual(["a"]); | |
| 64 | expireEffects(fx, 1.3, 0.25); | |
| 65 | expect(names(fx)).toEqual([]); | |
| 66 | expect(fx.count).toBe(0); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the comparison is strictly after the cutoff: an event exactly its life old is gone", () => { | |
| 70 | const fx = createEffects<Mark>(8); | |
| 71 | pushEffect(fx, mark("exact"), 1); | |
| 72 | expireEffects(fx, 1.25, 0.25); // 1 is the cutoff itself, not after it | |
| 73 | expect(names(fx)).toEqual([]); | |
| 74 | ||
| 75 | const other = createEffects<Mark>(8); | |
| 76 | pushEffect(other, mark("just after"), 1.000001); | |
| 77 | expireEffects(other, 1.25, 0.25); | |
| 78 | expect(names(other)).toEqual(["just after"]); | |
| 79 | }); | |
| 80 | ||
| 81 | test("an expiry at a time before anything was pushed removes nothing", () => { | |
| 82 | const fx = createEffects<Mark>(8); | |
| 83 | pushEffect(fx, mark("a"), 10); | |
| 84 | pushEffect(fx, mark("b"), 11); | |
| 85 | expireEffects(fx, 5, 2); | |
| 86 | expect(names(fx)).toEqual(["a", "b"]); | |
| 87 | // And so does an expiry on a buffer that has nothing in it. | |
| 88 | const empty = createEffects<Mark>(8); | |
| 89 | expireEffects(empty, 100, 2); | |
| 90 | expect(empty.count).toBe(0); | |
| 91 | }); | |
| 92 | ||
| 93 | test("order is preserved across an expiry that drops from the middle", () => { | |
| 94 | const fx = createEffects<Mark>(8); | |
| 95 | // Stamps out of order on purpose: the buffer sorts nothing, it walks. | |
| 96 | pushEffect(fx, mark("a"), 10); | |
| 97 | pushEffect(fx, mark("b"), 1); | |
| 98 | pushEffect(fx, mark("c"), 10); | |
| 99 | pushEffect(fx, mark("d"), 1); | |
| 100 | pushEffect(fx, mark("e"), 10); | |
| 101 | expireEffects(fx, 11, 2); | |
| 102 | expect(names(fx)).toEqual(["a", "c", "e"]); | |
| 103 | // What the survivors moved out of holds nothing: the buffer let go of them. | |
| 104 | expect(fx.events.filter((slot) => slot !== undefined).length).toBe(3); | |
| 105 | // And the ring carries on from where it was. | |
| 106 | pushEffect(fx, mark("f"), 11); | |
| 107 | expect(names(fx)).toEqual(["a", "c", "e", "f"]); | |
| 108 | }); | |
| 109 | ||
| 110 | test("the cap drops the oldest and keeps the newest", () => { | |
| 111 | const fx = createEffects<Mark>(3); | |
| 112 | for (const name of ["a", "b", "c"]) pushEffect(fx, mark(name), 1); | |
| 113 | expect(names(fx)).toEqual(["a", "b", "c"]); | |
| 114 | pushEffect(fx, mark("d"), 2); | |
| 115 | expect(fx.count).toBe(3); | |
| 116 | expect(names(fx)).toEqual(["b", "c", "d"]); | |
| 117 | pushEffect(fx, mark("e"), 3); | |
| 118 | expect(names(fx)).toEqual(["c", "d", "e"]); | |
| 119 | // The stamps came along with them: "c" and "d" die before "e" does. | |
| 120 | expireEffects(fx, 3, 1.5); | |
| 121 | expect(names(fx)).toEqual(["d", "e"]); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a flood of five thousand events stops at the cap and never grows the stores", () => { | |
| 125 | const fx = createEffects<Mark>(256); | |
| 126 | const slots = fx.events; | |
| 127 | const times = fx.times; | |
| 128 | for (let i = 0; i < 5000; i++) pushEffect(fx, mark(`${i}`), i); | |
| 129 | expect(fx.count).toBe(256); | |
| 130 | expect(fx.events).toBe(slots); | |
| 131 | expect(fx.events.length).toBe(256); | |
| 132 | expect(fx.times).toBe(times); | |
| 133 | expect(fx.times.length).toBe(256); | |
| 134 | // The last 256 pushed, in the order they were pushed. | |
| 135 | expect(names(fx)[0]).toBe("4744"); | |
| 136 | expect(names(fx)[255]).toBe("4999"); | |
| 137 | expect(names(fx).length).toBe(256); | |
| 138 | }); | |
| 139 | ||
| 140 | test("two full frames of pushes and expiries leave the same two stores", () => { | |
| 141 | const fx = createEffects<Mark>(64); | |
| 142 | const slots = fx.events; | |
| 143 | const times = fx.times; | |
| 144 | const pushed: Mark[] = []; | |
| 145 | for (let i = 0; i < 64; i++) pushed.push(mark(`${i}`)); | |
| 146 | ||
| 147 | let now = 0; | |
| 148 | for (let frame = 0; frame < 2; frame++) { | |
| 149 | for (const event of pushed) pushEffect(fx, event, now); | |
| 150 | now += 1; | |
| 151 | expireEffects(fx, now, 0.5); | |
| 152 | expect(fx.count).toBe(0); | |
| 153 | } | |
| 154 | expect(fx.events).toBe(slots); | |
| 155 | expect(fx.events.length).toBe(64); | |
| 156 | expect(fx.times).toBe(times); | |
| 157 | expect(fx.times.length).toBe(64); | |
| 158 | }); | |
| 159 | ||
| 160 | test("what comes out is the object that went in, not a copy of it", () => { | |
| 161 | const fx = createEffects<Mark>(4); | |
| 162 | const one = mark("one"); | |
| 163 | pushEffect(fx, one, 1); | |
| 164 | expect(effectAt(fx, 0)).toBe(one); | |
| 165 | // A game that writes to it has written to what the next frame reads. | |
| 166 | one.name = "still one"; | |
| 167 | expect(effectAt(fx, 0)?.name).toBe("still one"); | |
| 168 | }); | |
| 169 | ||
| 170 | test("a reset empties the buffer and keeps the stores it was built with", () => { | |
| 171 | const fx = createEffects<Mark>(4); | |
| 172 | const slots = fx.events; | |
| 173 | const times = fx.times; | |
| 174 | for (const name of ["a", "b", "c", "d", "e"]) pushEffect(fx, mark(name), 1); | |
| 175 | resetEffects(fx); | |
| 176 | expect(fx.count).toBe(0); | |
| 177 | expect(fx.head).toBe(0); | |
| 178 | expect(names(fx)).toEqual([]); | |
| 179 | for (const slot of fx.events) expect(slot).toBeUndefined(); | |
| 180 | expect(fx.events).toBe(slots); | |
| 181 | expect(fx.times).toBe(times); | |
| 182 | // And it is a buffer again, from the front. | |
| 183 | pushEffect(fx, mark("f"), 2); | |
| 184 | expect(names(fx)).toEqual(["f"]); | |
| 185 | }); |
src/engine/effects.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | /** | |
| 2 | * Things that just happened, kept for a moment: a capped ring of events, each | |
| 3 | * stamped with the time the game says it happened at, iterated in the order | |
| 4 | * they were pushed and thrown away by age. Both games keep one — the shooter's | |
| 5 | * tracers, sparks, blood, hit marks and grenade sounds; the range's tracer — | |
| 6 | * and what they kept it in was a growing array pruned with `filter`, which | |
| 7 | * allocates a new array on every tick anything happened. | |
| 8 | * | |
| 9 | * **Nothing here is allocated after `createEffects`.** The two stores are built | |
| 10 | * whole at the capacity: one array of slots for the game's own events and one | |
| 11 | * `Float64Array` of their timestamps. Pushing writes a slot; pushing past the | |
| 12 | * cap overwrites the oldest rather than growing; expiring compacts the live | |
| 13 | * events towards the head, writing indices, and never builds a list of what | |
| 14 | * survived. An event comes back out of `effectAt` as the object the game | |
| 15 | * pushed, not a copy of it. | |
| 16 | * | |
| 17 | * **What happens at the cap is what happens at a scene's capacity, pointed the | |
| 18 | * other way: a buffer that is full drops its oldest rather than growing.** The | |
| 19 | * newest event is the one a frame is about to draw, so the newest is the one | |
| 20 | * that is kept. Pick the capacity for the loudest two seconds the game can | |
| 21 | * have — the shooter's is at the top of `src/client/world.ts` with the | |
| 22 | * arithmetic that chose it — and read a drop as a number that wants raising. | |
| 23 | * | |
| 24 | * **`expireEffects(fx, now, ttl)` keeps an event whose stamp is strictly after | |
| 25 | * `now - ttl`**, so one exactly `ttl` old is gone. That is the comparison the | |
| 26 | * shooter's `filter` made — `e.at > cutoff` — kept to the letter, because the | |
| 27 | * tick an effect dies on is a thing its replays are pinned to. A lifetime is | |
| 28 | * one number for the whole buffer rather than one per event: a game that draws | |
| 29 | * a tracer for 80 ms and blood for 300 keeps them all for the longest of those | |
| 30 | * and decides what to draw by age in the frame, which is what both games here | |
| 31 | * already did. | |
| 32 | * | |
| 33 | * It knows no game: no kind, no colour, no team, no weapon, and no id, | |
| 34 | * priority or callback. The event type is the game's own, and what one means is | |
| 35 | * the game's business. | |
| 36 | * | |
| 37 | * Reading one is an indexed loop, which is the shape that allocates nothing: | |
| 38 | * | |
| 39 | * for (let i = 0; i < fx.count; i++) { | |
| 40 | * const event = effectAt(fx, i); | |
| 41 | * if (event === undefined) continue; | |
| 42 | * … | |
| 43 | * } | |
| 44 | */ | |
| 45 | ||
| 46 | /** | |
| 47 | * A capped ring of timestamped events. `count` of them are live, the oldest at | |
| 48 | * `head`, and the two stores are the same objects for the life of the buffer — | |
| 49 | * only the functions below write any of these four fields. | |
| 50 | */ | |
| 51 | export type Effects<T> = { | |
| 52 | /** How many events fit. Pushing past this drops the oldest. */ | |
| 53 | readonly capacity: number; | |
| 54 | /** The slots, `capacity` of them, made once. A slot no event is in is `undefined`. */ | |
| 55 | readonly events: (T | undefined)[]; | |
| 56 | /** The stamp of the event in the slot beside it. */ | |
| 57 | readonly times: Float64Array; | |
| 58 | /** How many events are live: `effectAt` reads `0 … count - 1`. */ | |
| 59 | count: number; | |
| 60 | /** The slot the oldest live event is in. */ | |
| 61 | head: number; | |
| 62 | }; | |
| 63 | ||
| 64 | /** Why this capacity is not one, or null when it is. */ | |
| 65 | function wrongCapacity(capacity: number): string | null { | |
| 66 | if (!Number.isInteger(capacity) || capacity <= 0) | |
| 67 | return `effects capacity: must be a whole number above zero, not ${capacity}`; | |
| 68 | return null; | |
| 69 | } | |
| 70 | ||
| 71 | /** An empty buffer with every slot it will ever have. */ | |
| 72 | export function createEffects<T>(capacity: number): Effects<T> { | |
| 73 | const wrong = wrongCapacity(capacity); | |
| 74 | if (wrong !== null) throw new Error(wrong); | |
| 75 | return { | |
| 76 | capacity, | |
| 77 | events: new Array<T | undefined>(capacity).fill(undefined), | |
| 78 | times: new Float64Array(capacity), | |
| 79 | count: 0, | |
| 80 | head: 0, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * One event, stamped. The buffer keeps the reference it was handed rather than | |
| 86 | * a copy, so a game that mutates an event afterwards has mutated what the next | |
| 87 | * frame reads. Full, the oldest event is dropped to make room. | |
| 88 | */ | |
| 89 | export function pushEffect<T>(fx: Effects<T>, event: T, at: number): void { | |
| 90 | if (fx.count < fx.capacity) { | |
| 91 | const slot = (fx.head + fx.count) % fx.capacity; | |
| 92 | fx.events[slot] = event; | |
| 93 | fx.times[slot] = at; | |
| 94 | fx.count += 1; | |
| 95 | return; | |
| 96 | } | |
| 97 | // Full: the oldest slot is the one the newest event goes in, and the head | |
| 98 | // steps past it. Nothing moves and nothing is allocated. | |
| 99 | fx.events[fx.head] = event; | |
| 100 | fx.times[fx.head] = at; | |
| 101 | fx.head = (fx.head + 1) % fx.capacity; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Drop every event older than `ttl` at `now`, keeping the rest in the order | |
| 106 | * they were pushed. A survivor moves towards the head — never past an event | |
| 107 | * still to be read — so this is a walk with two indices and no list. | |
| 108 | */ | |
| 109 | export function expireEffects<T>(fx: Effects<T>, now: number, ttl: number): void { | |
| 110 | if (fx.count === 0) return; | |
| 111 | const cutoff = now - ttl; | |
| 112 | let kept = 0; | |
| 113 | for (let i = 0; i < fx.count; i++) { | |
| 114 | const from = (fx.head + i) % fx.capacity; | |
| 115 | if ((fx.times[from] as number) <= cutoff) continue; | |
| 116 | if (kept !== i) { | |
| 117 | const to = (fx.head + kept) % fx.capacity; | |
| 118 | fx.events[to] = fx.events[from]; | |
| 119 | fx.times[to] = fx.times[from] as number; | |
| 120 | } | |
| 121 | kept += 1; | |
| 122 | } | |
| 123 | // The slots the survivors left behind let go of the game's objects, which is | |
| 124 | // a write of `undefined` and not an allocation. | |
| 125 | for (let i = kept; i < fx.count; i++) fx.events[(fx.head + i) % fx.capacity] = undefined; | |
| 126 | fx.count = kept; | |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * The `i`th live event in push order — `0` is the oldest — or `undefined` past | |
| 131 | * the end. What comes back is the game's own object. | |
| 132 | */ | |
| 133 | export function effectAt<T>(fx: Effects<T>, i: number): T | undefined { | |
| 134 | if (i < 0 || i >= fx.count) return undefined; | |
| 135 | return fx.events[(fx.head + i) % fx.capacity]; | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Every event gone, the buffer itself untouched: the stores, and their length, | |
| 140 | * are the ones `createEffects` made. A round that ends or an episode that | |
| 141 | * resets starts from here. It costs a walk of the live events rather than of | |
| 142 | * the capacity, because a slot no event is in is already empty — which is the | |
| 143 | * invariant `pushEffect` and `expireEffects` keep between them. | |
| 144 | */ | |
| 145 | export function resetEffects<T>(fx: Effects<T>): void { | |
| 146 | for (let i = 0; i < fx.count; i++) fx.events[(fx.head + i) % fx.capacity] = undefined; | |
| 147 | fx.count = 0; | |
| 148 | fx.head = 0; | |
| 149 | } |
src/engine/guide.test.ts +2 −1
| @@ -61,7 +61,8 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; | ||
| 61 | 61 | * this file ignores without needing to be told about it. |
| 62 | 62 | */ |
| 63 | 63 | const NOT_THE_ENGINES = new Set([ |
| 64 | "AudioContext", "Float32Array", "Int32Array", "TEXTURE_2D_ARRAY", "addEventListener", | |
| 64 | "AudioContext", "Float32Array", "Float64Array", "Int32Array", "TEXTURE_2D_ARRAY", | |
| 65 | "addEventListener", | |
| 65 | 66 | "cancelAnimationFrame", "de_forge", "fullscreenElement", "pointerLockElement", |
| 66 | 67 | "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv", |
| 67 | 68 | ]); |
src/engine/index.ts +10
| @@ -70,6 +70,16 @@ export type { Overlay, Paint } from "./overlay.ts"; | ||
| 70 | 70 | export { createScene, nextInstance, pushLine, resetScene } from "./scene.ts"; |
| 71 | 71 | export type { Camera, Instance, Scene, SceneCapacity } from "./scene.ts"; |
| 72 | 72 | |
| 73 | /** | |
| 74 | * What just happened, kept for a moment: a capped ring of timestamped events, | |
| 75 | * pushed in, iterated in order and dropped by age. The event type is the game's | |
| 76 | * own — this knows no kind, no colour and no weapon — and like the scene's pool | |
| 77 | * it allocates nothing after it is made: full, it drops its oldest rather than | |
| 78 | * growing, and an expiry rewrites indices rather than building a list. | |
| 79 | */ | |
| 80 | export { createEffects, effectAt, expireEffects, pushEffect, resetEffects } from "./effects.ts"; | |
| 81 | export type { Effects } from "./effects.ts"; | |
| 82 | ||
| 73 | 83 | /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */ |
| 74 | 84 | export { createRenderer } from "./renderer.ts"; |
| 75 | 85 | export type { Renderer, RendererAssets } from "./renderer.ts"; |
src/frames.test.ts +21 −8
| @@ -13,17 +13,21 @@ | ||
| 13 | 13 | * the line buffer at the end are the same objects — and the same length — as at |
| 14 | 14 | * the start. Then each is asked for more than its pool holds, and refuses. |
| 15 | 15 | * |
| 16 | * The shooter's effects are read the same way: the ring it keeps what just | |
| 17 | * happened in is the engine's since #67, so its two stores are captured beside | |
| 18 | * the scene's and compared at the end of the two hundred frames. | |
| 19 | * | |
| 16 | 20 | * Identity is the whole assertion. A pool that quietly grew, a buffer that was |
| 17 | 21 | * replaced with a bigger one, or a scene object rebuilt inside `buildScene` |
| 18 | 22 | * would all pass every test about what a frame looks like and fail here. |
| 19 | 23 | */ |
| 20 | 24 | |
| 21 | 25 | import { expect, test } from "bun:test"; |
| 22 | import type { Camera } from "./engine/index.ts"; | |
| 26 | import { pushEffect, resetEffects, type Camera } from "./engine/index.ts"; | |
| 23 | 27 | import { INSTANCE_CAPACITY, LINE_CAPACITY, buildScene, createScene } from "./client/scene.ts"; |
| 24 | 28 | import { DE_FORGE } from "./client/map.ts"; |
| 25 | 29 | import { SHORT } from "./client/match.ts"; |
| 26 | import { createWorld, type Player, type World } from "./client/world.ts"; | |
| 30 | import { createWorld, type Effect, type Player, type World } from "./client/world.ts"; | |
| 27 | 31 | import { |
| 28 | 32 | INSTANCE_CAPACITY as RANGE_INSTANCES, LINE_CAPACITY as RANGE_LINES, |
| 29 | 33 | buildRangeScene, createRangeScene, |
| @@ -34,11 +38,14 @@ const FRAMES = 200; | ||
| 34 | 38 | |
| 35 | 39 | const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 }); |
| 36 | 40 | |
| 41 | /** One more thing that just happened, into the ring the world keeps. */ | |
| 42 | const happen = (world: World, effect: Effect): void => pushEffect(world.effects, effect, effect.at); | |
| 43 | ||
| 37 | 44 | /** A match with `crowd` extra bodies standing in it, all of them drawable. */ |
| 38 | 45 | function crowded(crowd: number): World { |
| 39 | 46 | const world = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 7); |
| 40 | 47 | world.time = 100; |
| 41 | world.effects.length = 0; | |
| 48 | resetEffects(world.effects); | |
| 42 | 49 | const bot = world.players.find((p) => p !== world.me) as Player; |
| 43 | 50 | for (let i = 0; i < crowd; i++) world.players.push({ ...bot, id: 100 + i }); |
| 44 | 51 | return world; |
| @@ -68,18 +75,22 @@ test("two hundred frames of the shooter leave the same pool and the same buffer" | ||
| 68 | 75 | const pool = scene.instances; |
| 69 | 76 | const objects = [...pool]; |
| 70 | 77 | const lines = scene.lines; |
| 78 | // The other buffer a busy frame touches: what just happened, which is the | |
| 79 | // engine's ring since #67 and is not rebuilt by a tick either. | |
| 80 | const slots = world.effects.events; | |
| 81 | const stamps = world.effects.times; | |
| 71 | 82 | |
| 72 | 83 | let busiest = 0; |
| 73 | 84 | for (let frame = 0; frame < FRAMES; frame++) { |
| 74 | 85 | // Alternating: a frame with every effect this game draws, then a bare one. |
| 75 | world.effects.length = 0; | |
| 86 | resetEffects(world.effects); | |
| 76 | 87 | if (frame % 2 === 0) { |
| 77 | 88 | world.bomb.state = "planted"; |
| 78 | 89 | world.bomb.pos = [500, 600, 20]; |
| 79 | 90 | for (let i = 0; i < 400; i++) { |
| 80 | world.effects.push({ kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: world.time - 0.01 }); | |
| 81 | world.effects.push({ kind: "impact", at: world.time - 0.1, pos: [i, 2, 3], normal: [0, 0, 1] }); | |
| 82 | world.effects.push({ kind: "blood", at: world.time - 0.1, pos: [i, 2, 3] }); | |
| 91 | happen(world, { kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: world.time - 0.01 }); | |
| 92 | happen(world, { kind: "impact", at: world.time - 0.1, pos: [i, 2, 3], normal: [0, 0, 1] }); | |
| 93 | happen(world, { kind: "blood", at: world.time - 0.1, pos: [i, 2, 3] }); | |
| 83 | 94 | } |
| 84 | 95 | } else { |
| 85 | 96 | world.bomb.state = "carried"; |
| @@ -98,6 +109,8 @@ test("two hundred frames of the shooter leave the same pool and the same buffer" | ||
| 98 | 109 | expect(busiest).toBeGreaterThan(4000); |
| 99 | 110 | expect(busiest).toBeLessThanOrEqual(LINE_CAPACITY); |
| 100 | 111 | expect(scene.lineCount).toBe(0); |
| 112 | expect(world.effects.events).toBe(slots); | |
| 113 | expect(world.effects.times).toBe(stamps); | |
| 101 | 114 | }); |
| 102 | 115 | |
| 103 | 116 | test("two hundred frames of the range leave the same pool and the same buffer", () => { |
| @@ -133,7 +146,7 @@ test("a frame that wants more than its pool holds is refused, in both games", () | ||
| 133 | 146 | |
| 134 | 147 | // And a flood of effects fills the line buffer without replacing it. |
| 135 | 148 | for (let i = 0; i < 5000; i++) |
| 136 | world.effects.push({ kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: world.time - 0.01 }); | |
| 149 | happen(world, { kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: world.time - 0.01 }); | |
| 137 | 150 | const lines = scene.lines; |
| 138 | 151 | buildScene(world, camera(), scene); |
| 139 | 152 | expect(scene.lines).toBe(lines); |
src/rl/arena.test.ts +1 −1
| @@ -126,7 +126,7 @@ test("both sides can be observed, and a reset puts the duel back", () => { | ||
| 126 | 126 | test("the arena leaves no render effects behind to grow without bound", () => { |
| 127 | 127 | const arena = createArena(2468, stillFoe); |
| 128 | 128 | for (let i = 0; i < 50; i++) step(arena, { ...STILL, fire: true }); |
| 129 | expect(arena.world.effects.length).toBe(0); | |
| 129 | expect(arena.world.effects.count).toBe(0); | |
| 130 | 130 | expect(arena.world.messages.length).toBe(0); |
| 131 | 131 | expect(arena.world.match.phase).toBe("live"); |
| 132 | 132 | }); |
src/rl/arena.ts +3 −3
| @@ -16,7 +16,7 @@ import { decode, toInput, type Intent } from "../client/action.ts"; | ||
| 16 | 16 | import { makeBrain, botThink } from "../client/bots.ts"; |
| 17 | 17 | import { openCells, type Level } from "../client/level.ts"; |
| 18 | 18 | import { DE_FORGE } from "../client/map.ts"; |
| 19 | import { angleDelta, clamp, dist, rng, type Vec3 } from "../engine/index.ts"; | |
| 19 | import { angleDelta, clamp, dist, resetEffects, rng, type Vec3 } from "../engine/index.ts"; | |
| 20 | 20 | import { observe, OBS_SIZE, type Memory } from "../client/observe.ts"; |
| 21 | 21 | import type { Input, Player, World } from "../client/world.ts"; |
| 22 | 22 | import { aimAt, applyInput, bodyCentre, canSee, createWorld, NO_INPUT } from "../client/world.ts"; |
| @@ -167,7 +167,7 @@ export function reset(arena: Arena): void { | ||
| 167 | 167 | const { world, learner, foe, random } = arena; |
| 168 | 168 | world.time = 0; |
| 169 | 169 | world.match.phase = "live"; |
| 170 | world.effects.length = 0; | |
| 170 | resetEffects(world.effects); | |
| 171 | 171 | world.feed.length = 0; |
| 172 | 172 | world.messages.length = 0; |
| 173 | 173 | arena.ticks = 0; |
| @@ -272,7 +272,7 @@ export function stepInput(arena: Arena, input: Input, dt = TICK): StepResult { | ||
| 272 | 272 | if (foe.alive) applyInput(world, foe, foeInput, dt); |
| 273 | 273 | // A kill runs the round rules; the arena has no rounds, so put them back. |
| 274 | 274 | if (world.match.phase !== "live") world.match.phase = "live"; |
| 275 | if (world.effects.length > 0) world.effects.length = 0; | |
| 275 | if (world.effects.count > 0) resetEffects(world.effects); | |
| 276 | 276 | if (world.messages.length > 0) world.messages.length = 0; |
| 277 | 277 | arena.ticks += 1; |
| 278 | 278 | arena.memory.sinceSeen += dt; |
static/app.2047dd3508.js added +58
Diff not shown — a line 66,805 characters wide (limit 2,000). +58
View filestatic/app.d5f3ab2fca.js deleted −58
Diff not shown — a line 66,771 characters wide (limit 2,000). −58
View filestatic/example.d8198a26c0.js → static/example.f335e943f5.js renamed +1 −1
Diff not shown — a line 9,725 characters wide (limit 2,000). +1 −1
View filestatic/range.27ebabc9ad.js → static/range.9ac9632ff0.js renamed +2 −2
Diff not shown — a line 18,622 characters wide (limit 2,000). +2 −2
View file
new above
offline