@adatechnology/game-crowd-three 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1086 @@
1
+ // src/instances.ts
2
+ import { INSTANCE_POSES, walkGait } from "@adatechnology/game-characters";
3
+ import { MISSING, STATE } from "@adatechnology/game-crowd";
4
+
5
+ // src/data/render.json
6
+ var render_default = {
7
+ sources: {
8
+ agents: "jogo.js G 67098-67121 hordeRender: view cull (behind beyond 0.35\xB7d + 3 m once past \u221A30 m, nothing past 200 m), LOD [18, 55] m with 1.5 m of hysteresis, attack pose in at 1.6 m and out at 2.2 m, the pose by state, the bloater's swelling, the eye only with the skull and awake",
9
+ corpses: "jogo.js G 67122-67131 hordeRender: an agent's body on its variant at the near level within 18 m (else the middle), the fallen pose, the colour darkening 70% over the body's life, the last 60 s shrinking it; a body that was not an agent on the plain body batch",
10
+ civilians: "jogo.js G 70177-70184 npcRender: one baked body per survivor, a child 0.66 high and 0.75 wide, the bite showing as 0.3 of age",
11
+ batches: "jogo.js G 67079-67094 ZVAR, CB: each variant at 1, 0.5 and 0.28 of detail in batches of 160, 700 and 2600; the plain corpse batch at the middle body (BODY_LOD[1]) for 400; G 68469 PB: survivors baked at 0.8 in batches of 4"
12
+ },
13
+ numbers: {
14
+ lodDistances: [18, 55],
15
+ lodHysteresis: 1.5,
16
+ behindFrom: 30,
17
+ behindFactor: 0.35,
18
+ behindMargin: 3,
19
+ farCull: 200,
20
+ gaitMax: 1.4,
21
+ attackEnter: 2.56,
22
+ attackLeave: 4.84,
23
+ swell: 0.9,
24
+ swellPivot: 0.5,
25
+ ageBase: 0.75,
26
+ agePer: 0.08,
27
+ armBase: 1,
28
+ armPer: 0.4,
29
+ corpseNear: 18,
30
+ corpseFade: 60,
31
+ corpseDarken: 0.7,
32
+ corpseRise: 1.8,
33
+ corpseAgeBase: 0.8,
34
+ corpseAgeDark: 0.2,
35
+ childHeight: 0.66,
36
+ childBuild: 0.75,
37
+ bittenLook: 0.3,
38
+ civilianSkinStep: 0.25,
39
+ civilianBake: 0.8,
40
+ civilianBatch: 4
41
+ },
42
+ poses: {
43
+ dormant: "fallen",
44
+ airborne: "leap",
45
+ eating: "eat",
46
+ pileStep: "attack",
47
+ swing: "scream",
48
+ crawl: "crawl",
49
+ grab: "attack",
50
+ calling: "scream",
51
+ default: "walk",
52
+ corpse: "fallen",
53
+ civilian: "walk"
54
+ }
55
+ };
56
+
57
+ // src/instances.ts
58
+ var CROWD_RENDER_DEFAULTS = {
59
+ sources: render_default.sources,
60
+ numbers: render_default.numbers,
61
+ poses: render_default.poses
62
+ };
63
+ var poseIndex = (pose) => INSTANCE_POSES.indexOf(pose);
64
+ function createCrowdInstances(options) {
65
+ const N = { ...render_default.numbers, ...options.numbers };
66
+ const P = { ...render_default.poses, ...options.poses };
67
+ const pose = {
68
+ dormant: poseIndex(P.dormant),
69
+ airborne: poseIndex(P.airborne),
70
+ eating: poseIndex(P.eating),
71
+ pileStep: poseIndex(P.pileStep),
72
+ swing: poseIndex(P.swing),
73
+ crawl: poseIndex(P.crawl),
74
+ grab: poseIndex(P.grab),
75
+ calling: poseIndex(P.calling),
76
+ walk: poseIndex(P.default),
77
+ corpse: poseIndex(P.corpse),
78
+ civilian: poseIndex(P.civilian)
79
+ };
80
+ const [near, middle] = N.lodDistances;
81
+ const lodEdges = [near, middle];
82
+ const behindFrom = N.behindFrom;
83
+ const farSquared = N.farCull * N.farCull;
84
+ return {
85
+ numbers: N,
86
+ agents(Z, variants, frame, sink) {
87
+ const cx = frame.camera.x;
88
+ const cz = frame.camera.z;
89
+ const fx = -Math.sin(frame.viewYaw);
90
+ const fz = -Math.cos(frame.viewYaw);
91
+ const target = frame.target;
92
+ let written = 0;
93
+ for (let i = 0; i < Z.n; i++) {
94
+ const dx = Z.x[i] - cx;
95
+ const dz = Z.z[i] - cz;
96
+ const d2 = dx * dx + dz * dz;
97
+ if (d2 > behindFrom && dx * fx + dz * fz < -Math.sqrt(d2) * N.behindFactor - N.behindMargin) continue;
98
+ if (d2 > farSquared) continue;
99
+ if (frame.hidden?.(i)) continue;
100
+ let l = Z.lod[i];
101
+ const dd = Math.sqrt(d2);
102
+ while (l > 0 && dd < lodEdges[l - 1] - N.lodHysteresis) l--;
103
+ while (l < 2 && dd > lodEdges[l] + N.lodHysteresis) l++;
104
+ Z.lod[i] = l;
105
+ const sp = Z.renderSpeed[i];
106
+ const gait = Math.min(N.gaitMax, walkGait(sp, Z.height[i]));
107
+ const st = Z.state[i];
108
+ if (st === STATE.CHASE && target?.alive) {
109
+ const px = Z.x[i] - target.x;
110
+ const pz = Z.z[i] - target.z;
111
+ const p2 = px * px + pz * pz;
112
+ Z.attackPose[i] = p2 < (Z.attackPose[i] ? N.attackLeave : N.attackEnter) ? 1 : 0;
113
+ } else Z.attackPose[i] = 0;
114
+ const p = st === STATE.DORMANT ? pose.dormant : st === STATE.AIRBORNE ? pose.airborne : st === STATE.EATING ? pose.eating : st === STATE.PILE_STEP ? pose.pileStep : st === STATE.SWING ? pose.swing : Z.crawl[i] ? pose.crawl : st === STATE.GRAB || Z.attackPose[i] ? pose.grab : st === STATE.CALLING ? pose.calling : pose.walk;
115
+ const bb = st === STATE.SWELLING ? Z.build[i] * (1 + (N.swellPivot - Z.stateTimer[i]) * N.swell) : Z.build[i];
116
+ const eyeOn = !(Z.missing[i] & MISSING.SKULL);
117
+ const V = variants[Z.variant[i]];
118
+ sink(Z.variant[i], l, {
119
+ a: [Z.x[i], Z.z[i], Z.renderYaw[i], Z.phase[i]],
120
+ b: [Z.height[i] / V.s, bb / V.bulk, gait, p],
121
+ c: [Z.missing[i], N.ageBase + Z.age[i] * N.agePer, N.armBase + N.armPer * Z.arms[i], Z.skin[i]],
122
+ color: Z.color[i],
123
+ shade: 1,
124
+ eye: eyeOn && st !== STATE.DORMANT ? options.eyes[Z.type[i]] ?? null : null,
125
+ y: Z.y[i]
126
+ });
127
+ written++;
128
+ }
129
+ return written;
130
+ },
131
+ corpses(entries, variants, frame, life, sink, plain) {
132
+ const cx = frame.camera.x;
133
+ const cz = frame.camera.z;
134
+ for (const { age: t, data: C } of entries) {
135
+ const dark = Math.min(1, t / life);
136
+ const shade = 1 - dark * N.corpseDarken;
137
+ const fade = t > life - N.corpseFade ? (life - t) / N.corpseFade : 1;
138
+ const c = [C.missing, N.corpseAgeBase + dark * N.corpseAgeDark, 1, C.skin];
139
+ if (C.variant >= 0) {
140
+ const V = variants[C.variant];
141
+ const dx = C.x - cx;
142
+ const dz = C.z - cz;
143
+ const l = dx * dx + dz * dz < N.corpseNear * N.corpseNear ? 0 : 1;
144
+ sink(C.variant, l, { a: [C.x, C.z, C.yaw, C.seed], b: [C.height / V.s * fade, C.build / V.bulk, Math.min(1, t * N.corpseRise), pose.corpse], c, color: C.color, shade, eye: null, y: 0 });
145
+ continue;
146
+ }
147
+ plain({ a: [C.x, C.z, C.yaw, C.seed], b: [C.height * fade, C.build, Math.min(1, t * N.corpseRise), pose.corpse], c, color: C.color, shade, eye: null, y: 0 });
148
+ }
149
+ },
150
+ civilians(list, colorOf, sink) {
151
+ for (const n of list) {
152
+ if (n.state === "undead" || n.state === "gone" || n.state === "dead") continue;
153
+ const sp = Math.hypot(n.vx, n.vz);
154
+ const child = !!n.spec.child;
155
+ sink(n, {
156
+ a: [n.x, n.z, n.yaw, n.phase],
157
+ b: [child ? N.childHeight : 1, child ? N.childBuild : 1, Math.min(N.gaitMax, walkGait(sp, child ? N.childHeight : 1)), pose.civilian],
158
+ c: [0, n.biteTimer > 0 ? N.bittenLook : 0, 0, n.k * N.civilianSkinStep],
159
+ color: colorOf(n),
160
+ shade: 1,
161
+ eye: null,
162
+ y: 0
163
+ });
164
+ }
165
+ }
166
+ };
167
+ }
168
+
169
+ // src/renderer.ts
170
+ import { variantDecay, variantSeed } from "@adatechnology/game-characters";
171
+ import { LOD_BAKE, LOD_SEGMENTS, bakeBody, buildInstancedBody, createBodyBatch, createVariantBatches } from "@adatechnology/game-characters-three";
172
+ var now = () => typeof performance === "undefined" ? Date.now() : performance.now();
173
+ function createCrowdRenderer(three, options) {
174
+ const { crowd } = options;
175
+ const group = new three.Group();
176
+ group.name = "crowd";
177
+ const time = { value: 0 };
178
+ const eyes = crowd.pack.types.map((type) => type.eye ?? null);
179
+ const instances = createCrowdInstances({ eyes, ...options.numbers ? { numbers: options.numbers } : {}, ...options.poses ? { poses: options.poses } : {} });
180
+ const N = instances.numbers;
181
+ const variants = [];
182
+ const batches = [];
183
+ let plain = null;
184
+ const people = /* @__PURE__ */ new Map();
185
+ let bakeMs = 0;
186
+ const lodCounts = [0, 0, 0];
187
+ const shirt = new three.Color();
188
+ const eyeColors = /* @__PURE__ */ new Map();
189
+ const eyeOf = (hex) => {
190
+ let color = eyeColors.get(hex);
191
+ if (!color) eyeColors.set(hex, color = new three.Color(hex));
192
+ return color;
193
+ };
194
+ const push = (batch, instance) => {
195
+ if (!batch) return;
196
+ shirt.setHex(instance.color).multiplyScalar(instance.shade);
197
+ batch.push({ a: instance.a, b: instance.b, c: instance.c, color: shirt, ...instance.eye !== null ? { eye: eyeOf(instance.eye) } : {}, y: instance.y });
198
+ };
199
+ const typeOfVariant = [];
200
+ crowd.variantsOf.forEach((list, c) => list.forEach((g) => typeOfVariant[g] = c));
201
+ const baked = /* @__PURE__ */ new Set();
202
+ const ensurePlain = () => {
203
+ if (!plain) {
204
+ plain = createBodyBatch(three, options.plainBody ?? buildInstancedBody(three, { segments: LOD_SEGMENTS[1] }), { max: crowd.numbers.corpsesMax, castShadow: false, time });
205
+ group.add(plain.mesh);
206
+ }
207
+ return plain;
208
+ };
209
+ const bakeType = (c) => {
210
+ if (baked.has(c)) return;
211
+ baked.add(c);
212
+ const type = crowd.pack.types[c];
213
+ const name = options.typeName?.(type, c) ?? type.id;
214
+ const started = now();
215
+ crowd.variantsOf[c].forEach((g, v) => {
216
+ const decay = variantDecay(v);
217
+ const seed = variantSeed(c, v);
218
+ const bodies = LOD_BAKE.map((lod) => bakeBody(three, options.builders, { type: name, decay, seed }, lod));
219
+ const variant = { typeId: type.id, variant: v, seed, decay, s: bodies[0].s, bulk: bodies[0].bulk, verts: bodies.map((body) => body.verts), geometries: bodies.map((body) => body.geo) };
220
+ variants[g] = variant;
221
+ batches[g] = createVariantBatches(three, variant, time);
222
+ for (const batch of batches[g]) group.add(batch.mesh);
223
+ });
224
+ bakeMs += now() - started;
225
+ };
226
+ const bake = () => crowd.pack.types.forEach((_, c) => bakeType(c));
227
+ const bakeSeen = () => {
228
+ const Z = crowd.store;
229
+ for (let i = 0; i < Z.n; i++) if (!baked.has(Z.type[i])) bakeType(Z.type[i]);
230
+ for (const { data } of crowd.corpses.entries) if (data.variant >= 0 && !baked.has(typeOfVariant[data.variant])) bakeType(typeOfVariant[data.variant]);
231
+ };
232
+ const personBatch = (civilian) => {
233
+ const made = people.get(civilian.id);
234
+ if (made) return made;
235
+ const spec = options.civilians?.body(civilian);
236
+ if (!spec) return void 0;
237
+ const batch = createBodyBatch(three, bakeBody(three, options.builders, spec, N.civilianBake).geo, { max: N.civilianBatch, time });
238
+ people.set(civilian.id, batch);
239
+ group.add(batch.mesh);
240
+ return batch;
241
+ };
242
+ const all = () => [...batches.flatMap((set) => set ?? []), ...plain ? [plain] : [], ...people.values()];
243
+ return {
244
+ group,
245
+ instances,
246
+ bake,
247
+ update(dt, frame) {
248
+ bakeSeen();
249
+ time.value += dt;
250
+ for (const batch of all()) batch.begin();
251
+ instances.agents(crowd.store, variants, frame, (v, l, instance) => push(batches[v]?.[l], instance));
252
+ if (options.drawCorpses?.() !== false) instances.corpses(crowd.corpses.entries, variants, frame, crowd.numbers.corpseSeconds, (v, l, instance) => push(batches[v]?.[l], instance), (instance) => push(ensurePlain(), instance));
253
+ for (let l = 0; l < 3; l++) lodCounts[l] = batches.reduce((sum, set) => sum + (set?.[l]?.count ?? 0), 0);
254
+ const list = options.civilians?.list();
255
+ if (list) instances.civilians(list, options.civilians?.color ?? ((n) => n.spec.color ?? 8421504), (n, instance) => push(personBatch(n), instance));
256
+ for (const batch of all()) batch.end();
257
+ },
258
+ get lodCounts() {
259
+ return lodCounts;
260
+ },
261
+ get drawCalls() {
262
+ return all().filter((batch) => batch.count > 0).length;
263
+ },
264
+ get bakeMs() {
265
+ return bakeMs;
266
+ },
267
+ get variants() {
268
+ return variants;
269
+ },
270
+ dispose() {
271
+ for (const batch of all()) {
272
+ batch.mesh.geometry.dispose();
273
+ group.remove(batch.mesh);
274
+ }
275
+ variants.length = 0;
276
+ batches.length = 0;
277
+ baked.clear();
278
+ plain = null;
279
+ people.clear();
280
+ }
281
+ };
282
+ }
283
+
284
+ // src/herd-view.ts
285
+ function createHerdView(three, options) {
286
+ const group = new three.Group();
287
+ group.name = "herd";
288
+ const drawn = /* @__PURE__ */ new Map();
289
+ const sky = [];
290
+ const build = (E) => {
291
+ const species = options.species(E.spec.species);
292
+ if (!species) return null;
293
+ const rig = options.drawer.build(species);
294
+ rig.root.position.set(E.x, 0, E.z);
295
+ if (E.dead) options.drawer.setVisualState?.(rig, "dead");
296
+ group.add(rig.root);
297
+ drawn.set(E, rig);
298
+ if (E.spec.sky) sky.push(E);
299
+ return rig;
300
+ };
301
+ return {
302
+ group,
303
+ update(dt) {
304
+ for (const E of options.herd.list) if (!drawn.has(E)) build(E);
305
+ const time = options.herd.time;
306
+ for (const [E, rig] of drawn) {
307
+ if (E.spec.sky) continue;
308
+ rig.root.position.set(E.x, 0, E.z);
309
+ rig.root.rotation.y = E.yaw;
310
+ options.drawer.animate(rig, { time, delta: dt, clip: E.clip });
311
+ }
312
+ sky.forEach((E, i) => {
313
+ const rig = drawn.get(E);
314
+ const U = options.flock?.birds[i];
315
+ if (!rig || !U) return;
316
+ rig.root.position.set(U.x, U.y, U.z);
317
+ rig.root.rotation.set(0, U.yaw, U.roll);
318
+ rig.root.visible = U.visible;
319
+ if (!U.visible) return;
320
+ rig.phase = U.wing;
321
+ for (const w of rig.wings) {
322
+ w.shoulder.rotation.y = 0;
323
+ w.elbow.rotation.y = 0;
324
+ w.shoulder.rotation.z = w.side * (0.18 + Math.sin(U.wing * 0.4) * 0.05);
325
+ }
326
+ });
327
+ },
328
+ get count() {
329
+ return drawn.size;
330
+ },
331
+ clear() {
332
+ for (const rig of drawn.values()) group.remove(rig.root);
333
+ drawn.clear();
334
+ sky.length = 0;
335
+ }
336
+ };
337
+ }
338
+
339
+ // src/audio/bank.ts
340
+ import { DISTANCE_CURVES } from "@adatechnology/game-audio";
341
+ var scream = {
342
+ id: "scream_hoarse",
343
+ bus: "voices",
344
+ order: "scream",
345
+ duration: [1, 1.4],
346
+ layers: [
347
+ {
348
+ source: { kind: "noise", offset: "random" },
349
+ filters: {
350
+ mode: "parallel",
351
+ bank: [
352
+ { type: "bandpass", q: 6, frequency: { from: 700 * 1.15, to: 700 * 0.8, curve: "linear", over: "duration" }, level: 1 },
353
+ { type: "bandpass", q: 8, frequency: { from: 1150 * 1.15, to: 1150 * 0.8, curve: "linear", over: "duration" }, level: 0.7 },
354
+ { type: "bandpass", q: 10, frequency: { from: 2600 * 1.15, to: 2600 * 0.8, curve: "linear", over: "duration" }, level: 0.35 }
355
+ ]
356
+ },
357
+ modulators: [{ target: "gain", wave: "square", rate: [31, 40], depth: 0.35 }],
358
+ envelope: [
359
+ [0, 0, "set"],
360
+ [0.06, 0.9, "linear"],
361
+ ["0.6d", 0.9, "set"],
362
+ ["1d", 1e-3, "exp", "fixed"]
363
+ ]
364
+ }
365
+ ],
366
+ falloff: DISTANCE_CURVES.game_scream,
367
+ sources: ["jogo:L62128-62142"]
368
+ };
369
+ var groanLayer = (base) => ({
370
+ source: {
371
+ kind: "osc",
372
+ wave: "sawtooth",
373
+ frequency: typeof base === "number" ? { from: base, to: base * 0.7, curve: "linear", over: "duration" } : { from: base, to: { ...base, scale: 0.7 }, curve: "linear", over: "duration" }
374
+ },
375
+ filters: { mode: "serial", bank: [{ type: "bandpass", q: 3, frequency: { set: typeof base === "number" ? base * 3 : { ...base, scale: 3 } } }] },
376
+ envelope: [
377
+ [0, 0, "set"],
378
+ [0.15, 0.25, "linear"],
379
+ ["1d", 1e-3, "exp", "fixed"]
380
+ ]
381
+ });
382
+ var groan = {
383
+ id: "groan",
384
+ bus: "voices",
385
+ order: "groan",
386
+ vars: { base: [95, 135], dur: [0.9, 1.5] },
387
+ duration: { param: "dur" },
388
+ layers: [groanLayer({ param: "base" })],
389
+ falloff: DISTANCE_CURVES.game_voice,
390
+ sources: ["jogo:L62143-62155 gemido"]
391
+ };
392
+ var runnerGroan = {
393
+ id: "runner_groan",
394
+ bus: "voices",
395
+ order: "groan",
396
+ vars: { dur: [0.9, 1.5] },
397
+ duration: { param: "dur" },
398
+ layers: [groanLayer(300)],
399
+ falloff: DISTANCE_CURVES.game_voice,
400
+ sources: ["jogo:L62143-62155 corredor"]
401
+ };
402
+ var call = (id, options) => ({
403
+ id,
404
+ bus: "world",
405
+ order: "calls",
406
+ duration: options.seconds,
407
+ tail: 0.02,
408
+ keepSilent: true,
409
+ layers: [
410
+ {
411
+ repeat: { count: options.count, spacing: options.spacing },
412
+ source: { kind: "osc", wave: options.wave, frequency: { from: options.frequency, to: options.frequency * options.drop, curve: "exp", over: "duration" } },
413
+ filters: { mode: "serial", bank: [{ type: "bandpass", frequency: { set: options.frequency * 1.2 }, q: 3 }] },
414
+ envelope: [
415
+ [0, 1e-4, "set", "fixed"],
416
+ [0.015, 1, "exp"],
417
+ ["1d", 1e-4, "exp", "fixed"]
418
+ ]
419
+ }
420
+ ],
421
+ falloff: DISTANCE_CURVES.animals,
422
+ sources: ["jogo:L66625-66639"]
423
+ });
424
+ var CROWD_SOUND_BANK = {
425
+ id: "crowd",
426
+ sounds: [
427
+ scream,
428
+ groan,
429
+ runnerGroan,
430
+ call("dog_bark", { count: 2, spacing: 0.22, seconds: 0.12, wave: "square", frequency: 520, drop: 0.6 }),
431
+ call("lapwing_call", { count: 3, spacing: 0.14, seconds: 0.09, wave: "square", frequency: 2400, drop: 0.6 }),
432
+ call("hen_cluck", { count: 4, spacing: 0.14, seconds: 0.09, wave: "square", frequency: 900, drop: 0.6 }),
433
+ call("cow_moo", { count: 1, spacing: 0.14, seconds: 1.2, wave: "sawtooth", frequency: 140, drop: 0.8 })
434
+ ]
435
+ };
436
+ var HERD_SOUND_IDS = { bark: "dog_bark", lapwing: "lapwing_call", cluck: "hen_cluck", moo: "cow_moo" };
437
+
438
+ // src/bench/module.ts
439
+ import { HERD_ROLES, STATE as STATE2, bindInfection, carrionCenter, createCivilians, createCrowd, createFlock, createHerd, createRng } from "@adatechnology/game-crowd";
440
+ import { createPlayer } from "@adatechnology/game-player";
441
+
442
+ // src/bench/locale.ts
443
+ var EN = {
444
+ group: "Crowd",
445
+ name: "Crowd area",
446
+ mix: "Mixed (by weight)",
447
+ controls: {
448
+ count: "Agents",
449
+ type: "Type of the new ones",
450
+ night: "Night",
451
+ seed: "Seed",
452
+ spawn: "Add 100",
453
+ noise: "Noise here (80 m)",
454
+ hit: "Shoot the nearest in the head",
455
+ kill_all: "Kill all",
456
+ join: "Civilians join you",
457
+ flee: "Civilians run away",
458
+ herd: "Bring the animals",
459
+ materialize: "Bring 50 from the region",
460
+ push: "Push (break a grab)",
461
+ reset: "Start again"
462
+ },
463
+ hints: {
464
+ count: "The crowd grows or shrinks to this many, born 10 to 90 m from the area centre. Only the near ones think every frame (relevance bands).",
465
+ type: "The type of the agents added from now on; mixed draws them by the weights of the pack.",
466
+ night: "At night sight drops by 40%.",
467
+ noise: "A noise heard 80 m around you: the ones that hear it come.",
468
+ hit: "A headshot to the nearest agent: a body on the ground, blood through the room effects.",
469
+ join: "The civilians follow you; bitten, they turn after the bite time.",
470
+ flee: "The civilians run off along a path out of the area and are gone.",
471
+ herd: "A companion dog, hens, a lapwing, cows, a horse, a pig, a capybara, a carcass and the vultures over it.",
472
+ materialize: "The region model turns 50 of its numbers into agents near you; the far ones go back.",
473
+ push: "What holds you lets go after six pushes."
474
+ },
475
+ layers: { corpses: "Bodies on the ground", civilians: "Civilians", animals: "Animals" },
476
+ metrics: {
477
+ agents: "Agents",
478
+ drawn: "Drawn",
479
+ lod_near: "Near level",
480
+ lod_middle: "Middle level",
481
+ lod_far: "Far level",
482
+ draw_calls: "Draw calls",
483
+ bake_ms: "Bake",
484
+ active: "Thinking every frame",
485
+ kills: "Kills",
486
+ corpses: "Bodies",
487
+ civilians: "Civilians alive",
488
+ health: "Your health",
489
+ grabbed: "Grabbed",
490
+ message: "Last message"
491
+ },
492
+ scenarios: {
493
+ relevance: "Relevance: 500 agents, only the near ones every frame",
494
+ lod: "Level of detail with 1.5 m of hysteresis",
495
+ attack_pose: "Attack pose in at 1.6 m, out at 2.2 m",
496
+ corpse_darkens: "A body darkens and shrinks at the end of its life",
497
+ civilians_flee: "Civilians follow, then run away and are gone",
498
+ herd_calls: "The hen flees and clucks, the lapwing raises the alarm",
499
+ vultures: "The vultures circle the bodies",
500
+ infection: "Region numbers become agents and go back",
501
+ determinism: "The same seed, the same crowd"
502
+ },
503
+ messages: {
504
+ called: "A screamer called the crowd",
505
+ burst_hit: "Hit by a bloater burst",
506
+ swing_knock: "A brute knocked you down",
507
+ pounce_knock: "A leaper knocked you down",
508
+ migration: "A crowd is on the move",
509
+ door_broken: "A door gave way",
510
+ alarm: "A bird raises the alarm",
511
+ bark: "The dog is barking",
512
+ growl: "The dog is growling",
513
+ bitten: "A civilian was bitten",
514
+ turned: "A civilian turned",
515
+ died: "A civilian died"
516
+ },
517
+ yes: "yes",
518
+ no: "no"
519
+ };
520
+ var PT_BR = {
521
+ group: "Multid\xE3o",
522
+ name: "\xC1rea da multid\xE3o",
523
+ mix: "Misturado (pelo peso)",
524
+ controls: {
525
+ count: "Agentes",
526
+ type: "Tipo dos novos",
527
+ night: "Noite",
528
+ seed: "Semente",
529
+ spawn: "Mais 100",
530
+ noise: "Barulho aqui (80 m)",
531
+ hit: "Tiro na cabe\xE7a do mais perto",
532
+ kill_all: "Matar todos",
533
+ join: "Civis v\xEAm com voc\xEA",
534
+ flee: "Civis fogem",
535
+ herd: "Trazer os bichos",
536
+ materialize: "Trazer 50 da regi\xE3o",
537
+ push: "Empurrar (soltar-se)",
538
+ reset: "Recome\xE7ar"
539
+ },
540
+ hints: {
541
+ count: "A multid\xE3o cresce ou diminui at\xE9 esse n\xFAmero, nascendo de 10 a 90 m do centro da \xE1rea. S\xF3 os de perto pensam todo quadro (faixas de relev\xE2ncia).",
542
+ type: "O tipo dos agentes acrescentados daqui em diante; misturado sorteia pelos pesos do pacote.",
543
+ night: "\xC0 noite a vis\xE3o cai 40%.",
544
+ noise: "Um barulho ouvido a 80 m de voc\xEA: quem ouve vem.",
545
+ hit: "Um tiro na cabe\xE7a do agente mais perto: um corpo no ch\xE3o, sangue pelos efeitos da sala.",
546
+ join: "Os civis seguem voc\xEA; mordidos, viram depois do tempo da mordida.",
547
+ flee: "Os civis fogem por um caminho para fora da \xE1rea e somem.",
548
+ herd: "Um c\xE3o companheiro, galinhas, um quero-quero, vacas, um cavalo, um porco, uma capivara, uma carca\xE7a e os urubus sobre ela.",
549
+ materialize: "O modelo da regi\xE3o transforma 50 dos seus n\xFAmeros em agentes perto de voc\xEA; os de longe voltam.",
550
+ push: "Quem segura voc\xEA solta depois de seis empurr\xF5es."
551
+ },
552
+ layers: { corpses: "Corpos no ch\xE3o", civilians: "Civis", animals: "Bichos" },
553
+ metrics: {
554
+ agents: "Agentes",
555
+ drawn: "Desenhados",
556
+ lod_near: "N\xEDvel perto",
557
+ lod_middle: "N\xEDvel meio",
558
+ lod_far: "N\xEDvel longe",
559
+ draw_calls: "Chamadas de desenho",
560
+ bake_ms: "Cozimento",
561
+ active: "Pensando todo quadro",
562
+ kills: "Mortes",
563
+ corpses: "Corpos",
564
+ civilians: "Civis vivos",
565
+ health: "Sua vida",
566
+ grabbed: "Agarrado",
567
+ message: "\xDAltima mensagem"
568
+ },
569
+ scenarios: {
570
+ relevance: "Relev\xE2ncia: 500 agentes, s\xF3 os de perto todo quadro",
571
+ lod: "N\xEDvel de detalhe com 1,5 m de histerese",
572
+ attack_pose: "Pose de ataque entra a 1,6 m e sai a 2,2 m",
573
+ corpse_darkens: "O corpo escurece e encolhe no fim da vida",
574
+ civilians_flee: "Civis seguem, depois fogem e somem",
575
+ herd_calls: "A galinha foge e cacareja, o quero-quero d\xE1 o alarme",
576
+ vultures: "Os urubus circulam os corpos",
577
+ infection: "N\xFAmeros da regi\xE3o viram agentes e voltam",
578
+ determinism: "A mesma semente, a mesma multid\xE3o"
579
+ },
580
+ messages: {
581
+ called: "Um gritador chamou a multid\xE3o",
582
+ burst_hit: "Atingido pelo estouro de um inchado",
583
+ swing_knock: "Um brutamonte derrubou voc\xEA",
584
+ pounce_knock: "Um saltador derrubou voc\xEA",
585
+ migration: "Uma multid\xE3o est\xE1 migrando",
586
+ door_broken: "Uma porta cedeu",
587
+ alarm: "Um p\xE1ssaro d\xE1 o alarme",
588
+ bark: "O c\xE3o est\xE1 latindo",
589
+ growl: "O c\xE3o est\xE1 rosnando",
590
+ bitten: "Um civil foi mordido",
591
+ turned: "Um civil virou",
592
+ died: "Um civil morreu"
593
+ },
594
+ yes: "sim",
595
+ no: "n\xE3o"
596
+ };
597
+ var TEXT = { en: EN, "pt-BR": PT_BR };
598
+ var crowdBenchTextOf = (locale) => TEXT[locale] ?? PT_BR;
599
+
600
+ // src/bench/module.ts
601
+ var CROWD_AREA_ID = "crowd_area";
602
+ var CROWD_EFFECTS = {
603
+ blood: { effect: "blood_hit" },
604
+ limb: { effect: "dismember" },
605
+ cloud: { effect: "bloater_cloud" },
606
+ spark: { effect: "impact", param: "metal" },
607
+ dust: { effect: "impact", param: "concrete" }
608
+ };
609
+ var CAPACITY = 4e3;
610
+ var FRAME = 1 / 30;
611
+ var RING = [10, 90];
612
+ var LOTS = { home: { x: -14, z: -14, w: 8, d: 10, house: true, front: 1 }, shop: { x: 8, z: -14, w: 10, d: 10, front: 1 } };
613
+ var CIVILIANS = [
614
+ { id: "civilian_1", home: "home", color: 4090506 },
615
+ { id: "civilian_2", home: "home", color: 13672496, child: true },
616
+ { id: "civilian_3", home: "shop", color: 6969914 },
617
+ { id: "civilian_4", home: "shop", color: 9058906 }
618
+ ];
619
+ var WAY_OUT = [
620
+ [0, 4],
621
+ [30, 4],
622
+ [60, 20]
623
+ ];
624
+ var FARM = [
625
+ ["dog", 2, 3, { companion: true }],
626
+ ["cat", 12, -6],
627
+ ["chicken", -8, 4],
628
+ ["chicken", -8.5, 5],
629
+ ["chicken", -7.5, 4.6],
630
+ ["southern_lapwing", 8, 5],
631
+ ["cow", -20, -10],
632
+ ["cow", -24, -12],
633
+ ["horse", -26, -4],
634
+ ["pig", -14, -16],
635
+ ["capybara", 18, 14],
636
+ ["cow", -18, -24, { dead: true }],
637
+ ["vulture", 0, 0],
638
+ ["vulture", 0, 0],
639
+ ["vulture", 0, 0]
640
+ ];
641
+ var messageText = (text, id) => text.messages[id] ?? id;
642
+ function createCrowdTestModule(options) {
643
+ const locale = options.locale ?? "pt-BR";
644
+ const text = crowdBenchTextOf(locale);
645
+ const effects = options.effects ?? CROWD_EFFECTS;
646
+ const [cx, cz] = options.center ?? [0, -45];
647
+ const settings = { count: options.startCount ?? 0, type: "mix", night: false, seed: options.seed ?? 1 };
648
+ const layers = { corpses: true, civilians: true, animals: true };
649
+ let host = null;
650
+ let three = null;
651
+ let renderer = null;
652
+ let herdView = null;
653
+ let lastMessage = "";
654
+ let pending = {};
655
+ let last = null;
656
+ const offs = [];
657
+ const build = (seed) => {
658
+ const player = createPlayer({ seed });
659
+ const crowd = createCrowd({ pack: options.pack, seed, capacity: CAPACITY, target: player });
660
+ const civilians = createCivilians(crowd, { people: CIVILIANS.map((c) => ({ ...c })), buildings: LOTS });
661
+ const herd = createHerd(crowd, { view: () => ({ x: player.state.x, z: player.state.z }) });
662
+ const flock = createFlock({ count: 0, mode: "carrion" });
663
+ const model = options.infection?.create() ?? null;
664
+ const spot = createRng(seed + 7);
665
+ const binding = model ? bindInfection(crowd, model, { place: () => [cx + (spot() - 0.5) * 40, cz - 20 - spot() * 40], civilians, regionOf: () => options.infection.region }) : null;
666
+ const place = () => {
667
+ const a = spot() * Math.PI * 2;
668
+ const r = RING[0] + spot() * (RING[1] - RING[0]);
669
+ const type = settings.type === "mix" ? -1 : crowd.typeIndex(settings.type);
670
+ return crowd.spawn(cx + Math.cos(a) * r, cz + Math.sin(a) * r, type);
671
+ };
672
+ return { crowd, player, civilians, herd, flock, vultures: 0, binding, model, place };
673
+ };
674
+ let world = build(settings.seed);
675
+ const wire = () => {
676
+ for (const off of offs.splice(0)) off();
677
+ const { crowd, herd, civilians } = world;
678
+ offs.push(crowd.on("sound", (e) => void host?.audio?.play({ sound: e.sound, volume: e.volume, position: e.position })));
679
+ offs.push(herd.on("sound", (e) => void host?.audio?.play({ sound: HERD_SOUND_IDS[e.sound] ?? e.sound, volume: e.volume, position: e.position })));
680
+ offs.push(crowd.on("effect", (e) => playEffect(e)));
681
+ offs.push(crowd.on("message", (e) => void (lastMessage = messageText(text, e.id))));
682
+ offs.push(herd.on("message", (e) => void (lastMessage = messageText(text, e.id))));
683
+ offs.push(civilians.on("bitten", () => void (lastMessage = text.messages.bitten)));
684
+ offs.push(civilians.on("turned", () => void (lastMessage = text.messages.turned)));
685
+ offs.push(civilians.on("died", () => void (lastMessage = text.messages.died)));
686
+ offs.push(crowd.on("kill", (e) => host?.log(`crowd kill: ${e.type}`, { agent: e.agent, headshot: e.headshot })));
687
+ };
688
+ const playEffect = (e) => {
689
+ const map = effects[e.effect];
690
+ if (!map || !host?.effects) return;
691
+ host.effects.play({ effect: map.effect, point: e.point, ...map.param !== void 0 ? { param: map.param } : {}, ...e.amount !== void 0 ? { scale: Math.max(0.3, Math.min(2, e.amount / 30)) } : {} });
692
+ };
693
+ const fit = () => {
694
+ const { crowd } = world;
695
+ while (crowd.store.n < settings.count && world.place() >= 0) {
696
+ }
697
+ while (crowd.store.n > settings.count) crowd.remove(crowd.store.n - 1);
698
+ };
699
+ const bringHerd = () => {
700
+ const animals = options.animals;
701
+ if (!animals || world.herd.list.length) return;
702
+ for (const [id, dx, dz, extra] of FARM) {
703
+ const species = animals.species(id);
704
+ if (!species) continue;
705
+ world.herd.add({ ...HERD_ROLES[id], ...extra, species: id, x: cx + dx, z: cz + 30 + dz, walkHz: species.walkHz, runHz: species.runHz, size: animals.size(species) });
706
+ }
707
+ world.vultures = world.herd.list.filter((E) => E.spec.sky).length;
708
+ world.flock = createFlock({ count: world.vultures, mode: "carrion" });
709
+ herdView?.clear();
710
+ herdView = null;
711
+ };
712
+ const reset = () => {
713
+ world.binding?.unbind();
714
+ renderer?.dispose();
715
+ if (renderer) host?.scene?.remove(renderer.group);
716
+ renderer = null;
717
+ herdView?.clear();
718
+ if (herdView) host?.scene?.remove(herdView.group);
719
+ herdView = null;
720
+ world = build(settings.seed);
721
+ wire();
722
+ fit();
723
+ lastMessage = "";
724
+ };
725
+ const view = () => {
726
+ const camera = host?.camera;
727
+ if (!camera?.position) return null;
728
+ const Vector3 = host?.THREE?.Vector3;
729
+ if (camera.getWorldDirection && Vector3) {
730
+ const look = camera.getWorldDirection(new Vector3());
731
+ return { x: camera.position.x, z: camera.position.z, yaw: Math.atan2(-look.x, -look.z) };
732
+ }
733
+ return { x: camera.position.x, z: camera.position.z, yaw: camera.rotation?.y ?? 0 };
734
+ };
735
+ const ensureDrawing = () => {
736
+ if (!host || !three) return;
737
+ const scene = host.scene;
738
+ if (!renderer && options.builders) {
739
+ const bodies = options.civilianBodies ?? [];
740
+ renderer = createCrowdRenderer(three, {
741
+ crowd: world.crowd,
742
+ builders: options.builders(three),
743
+ ...options.typeName ? { typeName: options.typeName } : {},
744
+ drawCorpses: () => layers.corpses,
745
+ ...bodies.length ? { civilians: { list: () => layers.civilians ? world.civilians.list : [], body: (n) => bodies[n.k % bodies.length] } } : {}
746
+ });
747
+ scene.add(renderer.group);
748
+ }
749
+ if (!herdView && options.animals?.drawer && world.herd.list.length) {
750
+ herdView = createHerdView(three, { herd: world.herd, flock: world.flock, drawer: options.animals.drawer(three), species: options.animals.species });
751
+ scene.add(herdView.group);
752
+ }
753
+ };
754
+ const frame = (dt) => {
755
+ const { crowd, player, civilians, herd, flock } = world;
756
+ const here = view();
757
+ const speed = here && last && dt > 0 ? Math.hypot(here.x - last.x, here.z - last.z) / dt : 0;
758
+ if (here) {
759
+ player.state.x = here.x;
760
+ player.state.z = here.z;
761
+ }
762
+ player.update(dt, { forward: speed > 0.2 ? 1 : 0, run: speed > 3.4, yaw: here?.yaw ?? 0, pressed: pending }, { crowd: crowd.asPlayerCrowd() });
763
+ pending = {};
764
+ if (here) {
765
+ player.state.x = here.x;
766
+ player.state.z = here.z;
767
+ last = { x: here.x, z: here.z };
768
+ }
769
+ crowd.updateTrail(dt);
770
+ civilians.update(dt);
771
+ if (herd.list.length) {
772
+ herd.update(dt);
773
+ const carcass = herd.list.find((E) => E.dead);
774
+ flock.update(herd.time, dt, carrionCenter(crowd.corpses.entries.map((e) => e.data), carcass ? [carcass.x, carcass.z] : null), [player.state.x, player.state.z]);
775
+ }
776
+ crowd.update(dt, { viewYaw: here?.yaw ?? 0, nightFactor: settings.night ? 1 : 0 });
777
+ crowd.updateCorpses(dt);
778
+ ensureDrawing();
779
+ if (renderer && (crowd.store.n || crowd.corpses.count || civilians.list.length)) {
780
+ renderer.update(dt, { camera: { x: player.state.x, z: player.state.z }, viewYaw: here?.yaw ?? 0, target: { x: player.state.x, z: player.state.z, alive: player.state.alive } });
781
+ }
782
+ if (herdView) {
783
+ herdView.group.visible = layers.animals;
784
+ herdView.update(dt);
785
+ }
786
+ };
787
+ const hitNearest = () => {
788
+ const { crowd, player } = world;
789
+ const Z = crowd.store;
790
+ let best = -1;
791
+ let bd = Infinity;
792
+ for (let i = 0; i < Z.n; i++) {
793
+ const d = Math.hypot(Z.x[i] - player.state.x, Z.z[i] - player.state.z);
794
+ if (d < bd) [best, bd] = [i, d];
795
+ }
796
+ if (best >= 0) crowd.damage(best, { region: 0, amount: 200, distance: bd, cut: 0, caliber: null });
797
+ };
798
+ const onChange = (change) => {
799
+ if (change.kind === "layer") {
800
+ if (change.id in layers) layers[change.id] = change.on;
801
+ return;
802
+ }
803
+ if (change.kind === "action") {
804
+ const id2 = change.id;
805
+ if (id2 === "spawn") {
806
+ settings.count = Math.min(CAPACITY, world.crowd.store.n + 100);
807
+ host?.bench?.patch("count", { value: settings.count });
808
+ fit();
809
+ } else if (id2 === "noise") world.crowd.emitNoise(world.player.state.x, world.player.state.z, 80);
810
+ else if (id2 === "hit") hitNearest();
811
+ else if (id2 === "kill_all") for (let i = world.crowd.store.n - 1; i >= 0; i--) world.crowd.kill(i);
812
+ else if (id2 === "join") for (const n of world.civilians.list) world.civilians.join(n.id);
813
+ else if (id2 === "flee") for (const n of world.civilians.list) world.civilians.flee(n.id, WAY_OUT.map(([x, z]) => [cx + x, cz + 40 + z]));
814
+ else if (id2 === "herd") bringHerd();
815
+ else if (id2 === "materialize" && world.binding && options.infection) world.binding.materialize(options.infection.region, 50);
816
+ else if (id2 === "push") pending = { ...pending, push: true };
817
+ else if (id2 === "reset") reset();
818
+ return;
819
+ }
820
+ const { id, value } = change;
821
+ if (id === "count" && typeof value === "number") {
822
+ settings.count = value;
823
+ fit();
824
+ } else if (id === "type" && typeof value === "string") settings.type = value;
825
+ else if (id === "night" && typeof value === "boolean") settings.night = value;
826
+ else if (id === "seed" && typeof value === "number" && value !== settings.seed) {
827
+ settings.seed = value;
828
+ reset();
829
+ }
830
+ };
831
+ const typeOptions = () => [{ id: "mix", name: text.mix }, ...options.pack.types.map((type) => ({ id: type.id, name: options.typeLabel?.(type, locale) ?? type.id }))];
832
+ const controls = () => [
833
+ { kind: "number", id: "count", name: text.controls.count, hint: text.hints.count, min: 0, max: 3e3, step: 100, default: settings.count },
834
+ { kind: "choice", id: "type", name: text.controls.type, hint: text.hints.type, options: typeOptions(), default: "mix" },
835
+ { kind: "boolean", id: "night", name: text.controls.night, hint: text.hints.night, default: false },
836
+ { kind: "number", id: "seed", name: text.controls.seed, min: 1, max: 9999, step: 1, default: settings.seed },
837
+ { kind: "action", id: "spawn", name: text.controls.spawn },
838
+ { kind: "action", id: "noise", name: text.controls.noise, hint: text.hints.noise },
839
+ { kind: "action", id: "hit", name: text.controls.hit, hint: text.hints.hit },
840
+ { kind: "action", id: "kill_all", name: text.controls.kill_all },
841
+ { kind: "action", id: "join", name: text.controls.join, hint: text.hints.join },
842
+ { kind: "action", id: "flee", name: text.controls.flee, hint: text.hints.flee },
843
+ ...options.animals ? [{ kind: "action", id: "herd", name: text.controls.herd, hint: text.hints.herd }] : [],
844
+ ...options.infection ? [{ kind: "action", id: "materialize", name: text.controls.materialize, hint: text.hints.materialize }] : [],
845
+ { kind: "action", id: "push", name: text.controls.push, hint: text.hints.push },
846
+ { kind: "action", id: "reset", name: text.controls.reset }
847
+ ];
848
+ const benchLayers = () => [
849
+ { id: "corpses", name: text.layers.corpses },
850
+ { id: "civilians", name: text.layers.civilians },
851
+ { id: "animals", name: text.layers.animals }
852
+ ];
853
+ const metrics = () => [
854
+ { id: "agents", name: text.metrics.agents, digits: 0, max: CAPACITY, read: () => world.crowd.store.n },
855
+ { id: "drawn", name: text.metrics.drawn, digits: 0, read: () => renderer ? renderer.lodCounts[0] + renderer.lodCounts[1] + renderer.lodCounts[2] : 0 },
856
+ { id: "lod_near", name: text.metrics.lod_near, digits: 0, read: () => renderer?.lodCounts[0] ?? 0 },
857
+ { id: "lod_middle", name: text.metrics.lod_middle, digits: 0, read: () => renderer?.lodCounts[1] ?? 0 },
858
+ { id: "lod_far", name: text.metrics.lod_far, digits: 0, read: () => renderer?.lodCounts[2] ?? 0 },
859
+ { id: "draw_calls", name: text.metrics.draw_calls, digits: 0, read: () => renderer?.drawCalls ?? 0 },
860
+ { id: "bake_ms", name: text.metrics.bake_ms, unit: "ms", digits: 0, read: () => renderer?.bakeMs ?? 0 },
861
+ { id: "active", name: text.metrics.active, digits: 0, read: () => world.crowd.stats.bands[0] },
862
+ { id: "kills", name: text.metrics.kills, digits: 0, read: () => world.crowd.stats.kills },
863
+ { id: "bodies", name: text.metrics.corpses, digits: 0, read: () => world.crowd.corpses.count },
864
+ { id: "civilians_alive", name: text.metrics.civilians, digits: 0, read: () => world.civilians.list.filter((n) => n.state !== "dead" && n.state !== "undead" && n.state !== "gone").length },
865
+ { id: "health", name: text.metrics.health, digits: 0, read: () => Math.max(0, world.player.state.health) },
866
+ { id: "grabbed", name: text.metrics.grabbed, read: () => world.player.state.grabbedBy >= 0 ? text.yes : text.no },
867
+ { id: "message", name: text.metrics.message, read: () => lastMessage }
868
+ ];
869
+ const fresh = (seed = 1, capacity = 600) => {
870
+ const player = createPlayer({ seed });
871
+ return { crowd: createCrowd({ pack: options.pack, seed, capacity, flow: { clock: () => 0 }, target: player }), player };
872
+ };
873
+ const run = (crowd, seconds) => {
874
+ for (let t = 0; t < seconds; t += FRAME) {
875
+ crowd.update(FRAME);
876
+ crowd.updateCorpses(FRAME);
877
+ }
878
+ };
879
+ const eyes = options.pack.types.map((type) => type.eye ?? null);
880
+ const sizes = Array.from({ length: options.pack.types.reduce((sum, type) => sum + (type.variants ?? 1), 0) }, () => ({ s: 1.7, bulk: 1 }));
881
+ const drawOnce = (crowd, player) => {
882
+ const out = [];
883
+ createCrowdInstances({ eyes }).agents(crowd.store, sizes, { camera: { x: player.state.x, z: player.state.z }, viewYaw: 0, target: { x: player.state.x, z: player.state.z, alive: true } }, (_v, level, instance) => out.push({ level, pose: instance.b[3] }));
884
+ return out;
885
+ };
886
+ const scenarios = () => [
887
+ {
888
+ id: "crowd_relevance",
889
+ name: text.scenarios.relevance,
890
+ run: ({ expect }) => {
891
+ const { crowd } = fresh(3);
892
+ const spot = createRng(3);
893
+ for (let k = 0; k < 500; k++) crowd.spawn((spot() - 0.5) * 600, (spot() - 0.5) * 600);
894
+ run(crowd, 1);
895
+ const [near, , mid, far] = crowd.stats.bands;
896
+ expect(crowd.store.n === 500, "500 agents", { n: crowd.store.n });
897
+ expect(near < 50 && mid + far > 400, "few think every frame, most are far and slow", { bands: [...crowd.stats.bands] });
898
+ }
899
+ },
900
+ {
901
+ id: "crowd_lod",
902
+ name: text.scenarios.lod,
903
+ run: ({ expect }) => {
904
+ const { crowd, player } = fresh(1, 4);
905
+ player.state.x = 0;
906
+ player.state.z = 0;
907
+ const i = crowd.spawn(0, -17, 0);
908
+ const levels = [];
909
+ for (const z of [-17, -19, -19.6, -17.5, -16]) {
910
+ crowd.store.z[i] = z;
911
+ levels.push(drawOnce(crowd, player)[0].level);
912
+ }
913
+ expect(levels.join() === "0,0,1,1,0", "in at 19.5 m, back under 16.5 m", { levels });
914
+ }
915
+ },
916
+ {
917
+ id: "crowd_attack_pose",
918
+ name: text.scenarios.attack_pose,
919
+ run: ({ expect }) => {
920
+ const { crowd, player } = fresh(1, 4);
921
+ const i = crowd.spawn(0, -1.5, 0);
922
+ crowd.store.state[i] = STATE2.CHASE;
923
+ const poses = [];
924
+ for (const z of [-1.5, -2, -2.3, -1.9]) {
925
+ crowd.store.z[i] = z;
926
+ poses.push(drawOnce(crowd, player)[0].pose);
927
+ }
928
+ expect(poses.join() === "3,3,0,0", "the attack pose holds to 2.2 m and comes back under 1.6 m", { poses });
929
+ }
930
+ },
931
+ {
932
+ id: "crowd_corpse_darkens",
933
+ name: text.scenarios.corpse_darkens,
934
+ run: ({ expect }) => {
935
+ const { crowd, player } = fresh(1, 4);
936
+ crowd.kill(crowd.spawn(0, -5, 0));
937
+ const life = crowd.numbers.corpseSeconds;
938
+ crowd.updateCorpses(life / 2);
939
+ const shades = [];
940
+ const heights = [];
941
+ const take = (_v, _l, instance) => {
942
+ shades.push(instance.shade);
943
+ heights.push(instance.b[0]);
944
+ };
945
+ const instances = createCrowdInstances({ eyes });
946
+ const frameOf = { camera: { x: player.state.x, z: player.state.z }, viewYaw: 0 };
947
+ instances.corpses(crowd.corpses.entries, sizes, frameOf, life, take, (i) => take(-1, 1, i));
948
+ crowd.updateCorpses(life / 2 - 30);
949
+ instances.corpses(crowd.corpses.entries, sizes, frameOf, life, take, (i) => take(-1, 1, i));
950
+ expect(Math.abs(shades[0] - 0.65) < 1e-9, "half its life: 65% of the colour", { shades });
951
+ expect(Math.abs(heights[1] / heights[0] - 0.5) < 1e-6, "30 s before the end: half its height", { heights });
952
+ }
953
+ },
954
+ {
955
+ id: "crowd_civilians_flee",
956
+ name: text.scenarios.civilians_flee,
957
+ run: ({ expect }) => {
958
+ const { crowd, player } = fresh(2, 4);
959
+ player.state.x = 0;
960
+ player.state.z = 6;
961
+ const civilians = createCivilians(crowd, { people: [{ id: "c", home: "home" }], buildings: LOTS });
962
+ civilians.join("c");
963
+ for (let t = 0; t < 10; t += 0.05) civilians.update(0.05);
964
+ const c = civilians.get("c");
965
+ expect(Math.hypot(c.x - 0, c.z - 6) < 2.5, "it follows", { x: c.x, z: c.z });
966
+ civilians.flee("c", [[5, 6], [10, 6]]);
967
+ for (let t = 0; t < 20; t += 0.05) civilians.update(0.05);
968
+ expect(c.state === "gone", "it ran off and is gone", { state: c.state });
969
+ }
970
+ },
971
+ {
972
+ id: "crowd_herd_calls",
973
+ name: text.scenarios.herd_calls,
974
+ run: ({ expect }) => {
975
+ const { crowd } = fresh(4, 4);
976
+ const herd = createHerd(crowd);
977
+ const hen = herd.add({ ...HERD_ROLES.chicken, species: "chicken", x: 20, z: 0, walkHz: 2.2, runHz: 4, size: 0.4 });
978
+ herd.add({ ...HERD_ROLES.southern_lapwing, species: "southern_lapwing", x: 5, z: 0, walkHz: 2.5, runHz: 1.6, size: 0.35 });
979
+ crowd.spawn(21.5, 0, 0);
980
+ const sounds = [];
981
+ herd.on("sound", (e) => sounds.push(HERD_SOUND_IDS[e.sound] ?? e.sound));
982
+ for (let f = 0; f < 40; f++) herd.update(0.05);
983
+ expect(sounds.includes("hen_cluck") && sounds.includes("lapwing_call"), "the hen clucks, the lapwing calls", { sounds });
984
+ expect(hen.x < 20, "the hen ran", { x: hen.x });
985
+ }
986
+ },
987
+ {
988
+ id: "crowd_vultures",
989
+ name: text.scenarios.vultures,
990
+ run: ({ expect }) => {
991
+ const centre = carrionCenter([{ x: 0, z: 0 }, { x: 3, z: 0 }, { x: 0, z: 3 }], null);
992
+ const vultures = createFlock({ count: 3, mode: "carrion" });
993
+ vultures.update(10, 0.05, centre, [0, 0]);
994
+ const r = vultures.birds.map((U) => Math.hypot(U.x - centre[0], U.z - centre[1]));
995
+ expect(r.every((d, i) => Math.abs(d - (7 + i * 2)) < 1e-9), "each at its radius over the centre of the bodies", { r });
996
+ }
997
+ },
998
+ ...options.infection ? [
999
+ {
1000
+ id: "crowd_infection",
1001
+ name: text.scenarios.infection,
1002
+ run: ({ expect }) => {
1003
+ const infection = options.infection;
1004
+ const model = infection.create();
1005
+ const { crowd } = fresh(6, 64);
1006
+ const binding = bindInfection(crowd, model, { place: (_r, seed, k) => [seed % 100 - 50 + k * 0.01, 500 + k] });
1007
+ const before = infection.active?.(model);
1008
+ const born = binding.materialize(infection.region, 10);
1009
+ crowd.update(0.1);
1010
+ const back = binding.giveBack(infection.region);
1011
+ expect(born.length > 0 && back === born.length && crowd.store.n === 0, "brought out, given back", { born: born.length, back });
1012
+ if (before !== void 0) expect(infection.active(model) === before, "the region keeps its numbers", { before, after: infection.active(model) });
1013
+ binding.unbind();
1014
+ }
1015
+ }
1016
+ ] : [],
1017
+ {
1018
+ id: "crowd_determinism",
1019
+ name: text.scenarios.determinism,
1020
+ run: ({ expect }) => {
1021
+ const trace = (seed) => {
1022
+ const { crowd } = fresh(seed, 40);
1023
+ for (let k = 0; k < 30; k++) crowd.spawn(k % 6 * 3 - 8, -10 - Math.floor(k / 6) * 3);
1024
+ run(crowd, 3);
1025
+ return Array.from({ length: crowd.store.n }, (_, i) => `${crowd.store.x[i].toFixed(4)},${crowd.store.z[i].toFixed(4)}`).join(";");
1026
+ };
1027
+ expect(trace(9) === trace(9), "the same seed gives the same crowd");
1028
+ expect(trace(9) !== trace(10), "another seed gives another");
1029
+ }
1030
+ }
1031
+ ];
1032
+ wire();
1033
+ fit();
1034
+ const module = {
1035
+ id: CROWD_AREA_ID,
1036
+ name: text.name,
1037
+ group: text.group,
1038
+ get crowd() {
1039
+ return world.crowd;
1040
+ },
1041
+ get player() {
1042
+ return world.player;
1043
+ },
1044
+ get renderer() {
1045
+ return renderer;
1046
+ },
1047
+ items: () => [],
1048
+ area: () => ({ placements: [], view: { eye: [0, 1.7, 6], target: [cx, 1.2, cz] } }),
1049
+ controls: () => controls(),
1050
+ layers: () => benchLayers(),
1051
+ metrics: () => metrics(),
1052
+ scenarios: () => scenarios(),
1053
+ attach(lent) {
1054
+ host = lent;
1055
+ three = lent.THREE;
1056
+ last = null;
1057
+ lent.audio?.register?.(CROWD_SOUND_BANK);
1058
+ const off = lent.bench?.onChange(onChange);
1059
+ const offFrame = lent.onFrame(frame);
1060
+ return () => {
1061
+ off?.();
1062
+ offFrame();
1063
+ if (renderer) lent.scene.remove(renderer.group);
1064
+ if (herdView) lent.scene.remove(herdView.group);
1065
+ renderer?.dispose();
1066
+ renderer = null;
1067
+ herdView = null;
1068
+ host = null;
1069
+ };
1070
+ }
1071
+ };
1072
+ return module;
1073
+ }
1074
+ export {
1075
+ CROWD_AREA_ID,
1076
+ CROWD_EFFECTS,
1077
+ CROWD_RENDER_DEFAULTS,
1078
+ CROWD_SOUND_BANK,
1079
+ HERD_SOUND_IDS,
1080
+ createCrowdInstances,
1081
+ createCrowdRenderer,
1082
+ createCrowdTestModule,
1083
+ createHerdView,
1084
+ crowdBenchTextOf
1085
+ };
1086
+ //# sourceMappingURL=index.js.map