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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/67-transient-effects-become-engine-a-capped → main 2 commits fork

  • Merged. vesper/67-transient-effects-become-engine-a-capped was merged into main as 698db5b.

Review

Sign in to review this change.
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
227227 game picks, `resetScene` puts the counts back at the top of a frame,
228228 `nextInstance` hands out the next pooled instance and `pushLine` writes one
229229 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
231238 atlas and named meshes uploaded once at construction, and one `Scene` of
232239 instances and lines drawn per frame, each instance a mesh name with a place, a
233240 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
280287 `FullscreenHost`, `FullscreenTarget` and `FullscreenOwner`; from `overlay` `overlay`, `Overlay` and the `Paint`
281288 a 2D game draws onto; from `scene` the frame and its pool — `createScene`,
282289 `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
284293 `RendererAssets`, which draws that frame; and from `digest` `QUANTUM`,
285294 `quantise`, `CHECKPOINT_CHARS`, `fold`, `compare`, `Fold`, `Folded`, `Pin`
286295 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
330339 reaches only what `traceWorld` says it can see),
331340 `match`
332341 (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),
334345 `bots`, `observe`, `action`, `weights`, `models`, `materials`,
335346 `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round
336347 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";
2525 import { digest, TICK, withoutEntropy } from "./replay.ts";
2626 import {
2727 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,
2929 } 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 }
3040
3141 const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 };
3242 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", () =
198208 expect(world.me.grenades).toBe(0);
199209 expect(world.grenades[0]?.owner).toBe(world.me.id);
200210 // 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);
202212 });
203213
204214 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
248258
249259 expect(victim.health).toBeLessThan(healthBefore);
250260 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);
254264 // 83 hit points at the feet of a body with no vest is not a kill on its own,
255265 // so a kill is only asserted when the blast actually finished them.
256266 if (!victim.alive) {
src/client/hud.test.ts +17 −6
@@ -25,7 +25,18 @@ import { drawHud, type Paint } from "./hud.ts";
2525 import { DE_FORGE } from "./map.ts";
2626 import { SHORT } from "./match.ts";
2727 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 }
2940
3041 // --- a paint that remembers ------------------------------------------------
3142
@@ -85,7 +96,7 @@ function scene(change: (world: World) => void = () => {}): World {
8596 players,
8697 me: players[0] as Player,
8798 bomb: { ...BASE.bomb, defuser: null },
88 effects: [],
99 effects: createEffects<Effect>(EFFECT_CAPACITY),
89100 feed: [],
90101 messages: [],
91102 };
@@ -183,7 +194,7 @@ test("the crosshair opens exactly as far as the gun is inaccurate, and is gone w
183194 test("a hit is marked white, a headshot red, and both are gone a quarter second later", () => {
184195 const hit = draw(scene((w) => {
185196 w.time = 10;
186 w.effects = [{ kind: "hitmark", at: 9.9, headshot: false }];
197 w.effects = only({ kind: "hitmark", at: 9.9, headshot: false });
187198 }));
188199 const body = hit.calls.filter((c) => c.op === "stroke");
189200 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
191202
192203 const head = draw(scene((w) => {
193204 w.time = 10;
194 w.effects = [{ kind: "hitmark", at: 9.9, headshot: true }];
205 w.effects = only({ kind: "hitmark", at: 9.9, headshot: true });
195206 }));
196207 expect(head.calls.filter((c) => c.op === "stroke")[1]?.stroke).toBe("rgba(255,90,90,0.95)");
197208
198209 const old = draw(scene((w) => {
199210 w.time = 10;
200 w.effects = [{ kind: "hitmark", at: 9.7, headshot: true }];
211 w.effects = only({ kind: "hitmark", at: 9.7, headshot: true });
201212 }));
202213 expect(old.calls.filter((c) => c.op === "stroke").length).toBe(1);
203214 });
@@ -519,7 +530,7 @@ const busyWorld = (): World => scene((w) => {
519530 w.bomb.state = "planted";
520531 w.bomb.defuser = w.me;
521532 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 });
523534 w.feed = [kill(99), kill(97, { killerTeam: "ct" }), kill(94, { headshot: true })];
524535 w.messages = [{ at: 99.5, text: "The bomb has been planted at B." }];
525536 for (const p of w.players) p.kills = p.id;
src/client/hud.ts +4 −3
@@ -17,7 +17,7 @@
1717 * what colour a team is, which phase has a clock, and the words.
1818 */
1919
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";
2121 import { CS_MOVE } from "./movement.ts";
2222 import { inaccuracy, WEAPONS } from "./weapons.ts";
2323 import { currentWeapon, useProgress, type Player, type World } from "./world.ts";
@@ -115,8 +115,9 @@ export function drawHud(
115115
116116 // --- hit markers. Four arms like the crosshair's, but on the diagonals and
117117 // 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;
120121 const age = world.time - effect.at;
121122 if (age > 0.25) continue;
122123 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";
2525 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
2626 import { drawHud, type Paint } from "./hud.ts";
2727 import {
28 attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, painter,
28 attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, effectAt,
29 painter,
2930 type Button, type Camera, type Listens, type Mesh,
3031 } from "../engine/index.ts";
3132 import { generateLevel, seedFrom } from "./generate.ts";
@@ -393,8 +394,9 @@ export function boot(host: Host): Frame {
393394 * a round heard from your own corpse is the other half of a broken spectate.
394395 */
395396 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;
398400 if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears));
399401 else if (effect.kind === "sound") audio.grenade(effect.name, effect.pos, eyePos(ears));
400402 else if (effect.kind === "hitmark") audio.hit(effect.headshot);
src/client/scene.test.ts +29 −13
@@ -13,12 +13,21 @@
1313 import { expect, test } from "bun:test";
1414 import { existsSync, readFileSync } from "node:fs";
1515 import { join } from "node:path";
16 import type { Camera } from "../engine/index.ts";
16 import { pushEffect, resetEffects, type Camera } from "../engine/index.ts";
1717 import { CS_HULL } from "./movement.ts";
1818 import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts";
1919 import { DE_FORGE } from "./map.ts";
2020 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 };
2231
2332 const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 });
2433
@@ -26,7 +35,7 @@ const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90
2635 function world(): World {
2736 const w = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 7);
2837 w.time = 100;
29 w.effects.length = 0;
38 resetEffects(w.effects);
3039 return w;
3140 }
3241
@@ -116,13 +125,13 @@ test("a tracer 40 ms old is two vertices and one 120 ms old is none", () => {
116125 const tracer = (at: number): Effect => ({ kind: "tracer", from: [0, 0, 0], to: [100, 0, 0], at });
117126 const scene = createScene();
118127
119 w.effects.push(tracer(w.time - 0.04));
128 add(w, tracer(w.time - 0.04));
120129 buildScene(w, camera(), scene);
121130 expect(scene.lineCount).toBe(2);
122131 // Half way through its 0.08 s, so half faded: the blue of the near end.
123132 expect(scene.lines[5]).toBeCloseTo(0.3, 6);
124133
125 w.effects[0] = tracer(w.time - 0.12);
134 only(w, tracer(w.time - 0.12));
126135 buildScene(w, camera(), scene);
127136 expect(scene.lineCount).toBe(0);
128137 });
@@ -131,17 +140,17 @@ test("an impact is three sparks for a quarter of a second, and blood two for 0.3
131140 const w = world();
132141 const scene = createScene();
133142
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] });
135144 buildScene(w, camera(), scene);
136145 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] });
138147 buildScene(w, camera(), scene);
139148 expect(scene.lineCount).toBe(0);
140149
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] });
142151 buildScene(w, camera(), scene);
143152 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] });
145154 buildScene(w, camera(), scene);
146155 expect(scene.lineCount).toBe(0);
147156 });
@@ -175,14 +184,21 @@ test("a planted bomb adds the beam and blinks; a carried one adds nothing", () =
175184
176185 test("a flood of effects stops at the line capacity and writes nothing past it", () => {
177186 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);
180193 const scene = buildScene(w, camera(), createScene());
181194 expect(scene.lineCount).toBe(LINE_CAPACITY);
182195 expect(scene.lineCount).toBe(4096);
183196 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);
186202 });
187203
188204 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 @@
1818 */
1919
2020 import {
21 clamp, createScene as createPooledScene, hullHeight, nextInstance, pushLine, resetScene,
21 clamp, createScene as createPooledScene, effectAt, hullHeight, nextInstance, pushLine, resetScene,
2222 type Camera, type Scene as Frame, type Vec3,
2323 } from "../engine/index.ts";
2424 import { CS_HULL } from "./movement.ts";
@@ -153,7 +153,11 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
153153 place(into, "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE);
154154
155155 // --- 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;
157161 const age = world.time - effect.at;
158162 if (effect.kind === "tracer" && age < 0.08) {
159163 const fade = 1 - age / 0.08;
src/client/world.ts +53 −16
@@ -18,8 +18,9 @@ import {
1818 } from "./grenade.ts";
1919 import { siteAt, type Level } from "./level.ts";
2020 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,
2324 } from "../engine/index.ts";
2425 import * as rules from "./match.ts";
2526 import { CS_HULL, CS_MOVE } from "./movement.ts";
@@ -80,6 +81,28 @@ export type Effect =
8081 | { kind: "blast"; at: number; pos: Vec3 }
8182 | { kind: "sound"; at: number; pos: Vec3; name: GrenadeSound };
8283
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
83106 export type FeedEntry = { at: number; killer: string; victim: string; weapon: WeaponId; headshot: boolean; killerTeam: Team };
84107
85108 export type Input = {
@@ -112,7 +135,12 @@ export type World = {
112135 me: Player;
113136 time: number;
114137 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>;
116144 /** Every grenade in the air or on the floor right now, oldest first. */
117145 grenades: Grenade[];
118146 feed: FeedEntry[];
@@ -185,13 +213,21 @@ export function createWorld(
185213 const world: World = {
186214 match, players, me, time: 0,
187215 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: [],
189217 random, level, bombPlanted: false, pendingBuy: false,
190218 };
191219 startRound(world);
192220 return world;
193221 }
194222
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
195231 export const teammates = (world: World, team: Team): Player[] => world.players.filter((p) => p.team === team);
196232 export const alive = (world: World, team: Team): Player[] =>
197233 world.players.filter((p) => p.team === team && p.alive);
@@ -391,18 +427,18 @@ export function shoot(world: World, p: Player): boolean {
391427 const dir = angleVectors(yaw, pitch).forward;
392428 const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range];
393429 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 });
396432
397433 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 });
399435 } else if (hit.kind === "actor") {
400436 const victim = hit.actor as Player;
401437 if (victim.team === p.team && victim !== p) return true; // no friendly fire in this match
402438 const result = applyHit(victim, w, hit.group, hit.distance);
403439 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 });
406442 if (result.killed) onKill(world, p, victim, w.id, result.headshot);
407443 }
408444 return true;
@@ -440,12 +476,12 @@ export function throwOne(world: World, p: Player): boolean {
440476 const from = eyePos(p);
441477 const dir = angleVectors(p.yaw, p.pitch - THROW_LIFT).forward;
442478 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" });
444480 return true;
445481 }
446482
447483 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 });
449485 };
450486
451487 /**
@@ -455,16 +491,16 @@ const heard = (world: World, pos: Vec3, name: GrenadeSound): void => {
455491 */
456492 function detonate(world: World, g: Grenade): void {
457493 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 });
459495 heard(world, centre, "blast");
460496 const thrower = world.players.find((p) => p.id === g.owner);
461497 for (const caught of blastHits(g, world.players, world.level.boxes)) {
462498 const victim = caught.actor as Player;
463499 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] });
465501 if (thrower === undefined) continue;
466502 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 });
468504 if (result.killed) onKill(world, thrower, victim, "he", false);
469505 }
470506 }
@@ -631,8 +667,9 @@ export function update(world: World, dt: number, input: Input): void {
631667 // ended the tick rather than where they started it.
632668 updateGrenades(world, dt);
633669 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);
636673 }
637674
638675 /** 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
416416 beside these — the shooter keeps a walk cycle and one view model object there —
417417 and the renderer never sees it.
418418
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
419457 ### renderer
420458
421459 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", (
287287 * fullscreen — `Fullscreen`, `FullscreenHost`, `FullscreenOwner`,
288288 * `FullscreenTarget`, `createFullscreen`.
289289 *
290 * effects — `Effects`, `createEffects`, `pushEffect`, `expireEffects`,
291 * `effectAt`, `resetEffects`.
292 *
290293 * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one
291294 * copy of the surface, read twice, rather than two that drift.
292295 */
293296 export const SURFACE = [
294297 "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",
296300 "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold",
297301 "Folded", "Frames", "Fullscreen", "FullscreenHost", "FullscreenOwner", "FullscreenTarget",
298302 "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish",
@@ -303,16 +307,19 @@ export const SURFACE = [
303307 "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors",
304308 "applyGrads", "attachButtons", "attenuate",
305309 "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare",
306 "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createFullscreen",
310 "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createEffects",
311 "createFullscreen",
307312 "createLoop", "createPolicy", "createRenderer", "createRollout", "createScene",
308313 "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",
310316 "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside",
311317 "length", "length2d", "lerp",
312318 "logitCount", "mat4", "mergeSolid", "modelMatrix", "move", "mul", "multiply", "newActivations",
313319 "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",
316323 "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf",
317324 "zeroGrads", "zeroPolicy",
318325 ];
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_$]*$/;
6161 * this file ignores without needing to be told about it.
6262 */
6363 const NOT_THE_ENGINES = new Set([
64 "AudioContext", "Float32Array", "Int32Array", "TEXTURE_2D_ARRAY", "addEventListener",
64 "AudioContext", "Float32Array", "Float64Array", "Int32Array", "TEXTURE_2D_ARRAY",
65 "addEventListener",
6566 "cancelAnimationFrame", "de_forge", "fullscreenElement", "pointerLockElement",
6667 "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv",
6768 ]);
src/engine/index.ts +10
@@ -70,6 +70,16 @@ export type { Overlay, Paint } from "./overlay.ts";
7070 export { createScene, nextInstance, pushLine, resetScene } from "./scene.ts";
7171 export type { Camera, Instance, Scene, SceneCapacity } from "./scene.ts";
7272
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
7383 /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */
7484 export { createRenderer } from "./renderer.ts";
7585 export type { Renderer, RendererAssets } from "./renderer.ts";
src/frames.test.ts +21 −8
@@ -13,17 +13,21 @@
1313 * the line buffer at the end are the same objects — and the same length — as at
1414 * the start. Then each is asked for more than its pool holds, and refuses.
1515 *
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 *
1620 * Identity is the whole assertion. A pool that quietly grew, a buffer that was
1721 * replaced with a bigger one, or a scene object rebuilt inside `buildScene`
1822 * would all pass every test about what a frame looks like and fail here.
1923 */
2024
2125 import { expect, test } from "bun:test";
22 import type { Camera } from "./engine/index.ts";
26 import { pushEffect, resetEffects, type Camera } from "./engine/index.ts";
2327 import { INSTANCE_CAPACITY, LINE_CAPACITY, buildScene, createScene } from "./client/scene.ts";
2428 import { DE_FORGE } from "./client/map.ts";
2529 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";
2731 import {
2832 INSTANCE_CAPACITY as RANGE_INSTANCES, LINE_CAPACITY as RANGE_LINES,
2933 buildRangeScene, createRangeScene,
@@ -34,11 +38,14 @@ const FRAMES = 200;
3438
3539 const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 });
3640
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
3744 /** A match with `crowd` extra bodies standing in it, all of them drawable. */
3845 function crowded(crowd: number): World {
3946 const world = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 7);
4047 world.time = 100;
41 world.effects.length = 0;
48 resetEffects(world.effects);
4249 const bot = world.players.find((p) => p !== world.me) as Player;
4350 for (let i = 0; i < crowd; i++) world.players.push({ ...bot, id: 100 + i });
4451 return world;
@@ -68,18 +75,22 @@ test("two hundred frames of the shooter leave the same pool and the same buffer"
6875 const pool = scene.instances;
6976 const objects = [...pool];
7077 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;
7182
7283 let busiest = 0;
7384 for (let frame = 0; frame < FRAMES; frame++) {
7485 // Alternating: a frame with every effect this game draws, then a bare one.
75 world.effects.length = 0;
86 resetEffects(world.effects);
7687 if (frame % 2 === 0) {
7788 world.bomb.state = "planted";
7889 world.bomb.pos = [500, 600, 20];
7990 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] });
8394 }
8495 } else {
8596 world.bomb.state = "carried";
@@ -98,6 +109,8 @@ test("two hundred frames of the shooter leave the same pool and the same buffer"
98109 expect(busiest).toBeGreaterThan(4000);
99110 expect(busiest).toBeLessThanOrEqual(LINE_CAPACITY);
100111 expect(scene.lineCount).toBe(0);
112 expect(world.effects.events).toBe(slots);
113 expect(world.effects.times).toBe(stamps);
101114 });
102115
103116 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", ()
133146
134147 // And a flood of effects fills the line buffer without replacing it.
135148 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 });
137150 const lines = scene.lines;
138151 buildScene(world, camera(), scene);
139152 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", () => {
126126 test("the arena leaves no render effects behind to grow without bound", () => {
127127 const arena = createArena(2468, stillFoe);
128128 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);
130130 expect(arena.world.messages.length).toBe(0);
131131 expect(arena.world.match.phase).toBe("live");
132132 });
src/rl/arena.ts +3 −3
@@ -16,7 +16,7 @@ import { decode, toInput, type Intent } from "../client/action.ts";
1616 import { makeBrain, botThink } from "../client/bots.ts";
1717 import { openCells, type Level } from "../client/level.ts";
1818 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";
2020 import { observe, OBS_SIZE, type Memory } from "../client/observe.ts";
2121 import type { Input, Player, World } from "../client/world.ts";
2222 import { aimAt, applyInput, bodyCentre, canSee, createWorld, NO_INPUT } from "../client/world.ts";
@@ -167,7 +167,7 @@ export function reset(arena: Arena): void {
167167 const { world, learner, foe, random } = arena;
168168 world.time = 0;
169169 world.match.phase = "live";
170 world.effects.length = 0;
170 resetEffects(world.effects);
171171 world.feed.length = 0;
172172 world.messages.length = 0;
173173 arena.ticks = 0;
@@ -272,7 +272,7 @@ export function stepInput(arena: Arena, input: Input, dt = TICK): StepResult {
272272 if (foe.alive) applyInput(world, foe, foeInput, dt);
273273 // A kill runs the round rules; the arena has no rounds, so put them back.
274274 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);
276276 if (world.messages.length > 0) world.messages.length = 0;
277277 arena.ticks += 1;
278278 arena.memory.sinceSeen += dt;
static/app.2047dd3508.js added +58

Diff not shown — a line 66,805 characters wide (limit 2,000). +58

View file
static/app.d5f3ab2fca.js deleted −58

Diff not shown — a line 66,771 characters wide (limit 2,000). −58

View file
static/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 file
static/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

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs