PageSourceSearch

https://project-zombie.vercel.app/js/physics.js

js project-zombie.vercel.app collected 2026-10-03 07:32:45 UTC 25,595 bytes, 582 lines download raw bytes

1import * as THREE from 'three';
2import { GAME_CONFIG, getActiveMapSize, BOX_POSITION, getPackAPunchCostForWeapon } from './config.js';
3import { state } from './state.js';
4import { dialogue } from './dialogue.js';
5import { scene, playerGroup, camera, worldPropsGroup, resetGameplayCameraPresentation, boxGroup } from './scene.js';
6import { FirstPersonCamera } from './input.js';
7import {
8    createHitParticles,
9    createZombieDeathParticles,
10    spawnBloodSplat,
11    spawnBloodOnZombieHit,
12    spawnBloodOnZombieKill,
13    SPLAT_BLOOD_PALETTE,
14    updateBloodSplats
15} from './entities.js';
16import {
17    spawnZombieGoreChunks,
18    updateGoreChunks
19} from './zombie-gore-chunks.js';
20import { playSound, playHitSound } from './audio.js';
21import { die, tryEnterDownedOrDie } from './game.js';
22import { updateWeaponFeel, updateMuzzleFlash } from './weapon-feel.js';
23import { updateMuzzleBarrelFx } from './muzzle-fx.js';
24import { spawnDamageNumber, updateDamageNumbers } from './damage-numbers.js';
25import { spawnChainBoomFanfare, shotBoomThreshold, updateShotBooms } from './chain-boom.js';
26import { updateZombieAnimations, destroyZombieVisuals, separateActiveZombies } from './zombie-manager.js';
27import * as net from './network.js';
28import { getClipBrushBoxes, resolveGlbColliders, resolveClipBrushesXZ } from './map-file.js';
29import { getNearestDownedPlayerForRevive } from './remote-players.js';
30import { applyZombieTerrainColliders, getZombiePathSubsteps } from './zombie-manager.js';
31import {
32    getPointsMultiplier,
33    isInstaKillActive,
34    trySpawnSoloPowerupAt,
35    getMysteryBoxCostDisplay
36} from './powerup-drops.js';
37import { mapUsesPowerMechanic } from './power-system.js';
38
39const zombieLookTarget = new THREE.Vector3();
40const particleWorldPos = new THREE.Vector3();
41// Reused every frame by the PAP interaction loop to avoid per-frame allocation.
42const _papWorldPos = new THREE.Vector3();
43const _powerSwitchWorldPos = new THREE.Vector3();
44
45function buysNeedMapPowerLocked() {
46    const ps = GAME_CONFIG.powerSystem ?? {};
47    return ps.enabled !== false && mapUsesPowerMechanic() && !state.powerOn;
48}
49
50function isZombieHeadshot(projMesh, zombieMesh, zCfg) {
51    const relY = projMesh.position.y - zombieMesh.position.y;
52    const y0 = zCfg.headshotMinY ?? 1.38;
53    const y1 = zCfg.headshotMaxY ?? 2.02;
54    return relY >= y0 && relY <= y1;
55}
56
57export function updatePhysics(dt) {
58    if (!state.fpsCamera) {
59        // Previously this traversed the entire scene to build a Box3 per mesh for
60        // FirstPersonCamera. The resulting array was never read — collision runs
61        // through resolveGlbColliders / _resolveClipBrushes / getFloorYAt instead.
62        // The traversal was a ~5-15 ms one-off hitch on the first game frame.
63        state.fpsCamera = new FirstPersonCamera(camera);
64        state.fpsCamera.translation_.set(0, GAME_CONFIG.player.height, 0);
65        // Re-apply saved sensitivity now that the camera instance exists.
66        const _savedSens = parseFloat(localStorage.getItem('zbonk_mouseSensitivity') || '5');
67        const _factor    = _savedSens / 5;
68        state.fpsCamera.phiSpeed_   = 8 * _factor;
69        state.fpsCamera.thetaSpeed_ = 5 * _factor;
70        // `gamePhase` can be `menu` during the map intro — update() is skipped, so the
71        // previous session's camera aim would otherwise show through the composer.
72        if (state.gamePhase !== 'playing' && state.gamePhase !== 'upgrade') {
73            resetGameplayCameraPresentation();
74        }
75    }
76
77    const pausedPlaying =
78        state.isPaused &&
79        (state.gamePhase === 'playing' || state.gamePhase === 'upgrade');
80
81    // Camera first so viewmodel walk sway uses the same-frame `bobPhase_` / speed as the body bob.
82    if (!pausedPlaying) {
83        if (state.gamePhase === 'playing' || state.gamePhase === 'upgrade') {
84            if (state.gamePhase === 'playing' && state.spectating && GAME_CONFIG.player.multiplayer) {
85                state.fpsCamera.update(dt);
86                state.wallRunning = false;
87
88                // Clip brush collision (Radiant-style volumes placed in the editor)
89                const clipBoxesSpec = getClipBrushBoxes();
90                if (clipBoxesSpec.length > 0) {
91                    resolveClipBrushesXZ(state.fpsCamera.translation_, GAME_CONFIG.player.hitboxRadius, clipBoxesSpec);
92                }
93                // GLB prop collision
94                resolveGlbColliders(state.fpsCamera.translation_, GAME_CONFIG.player.hitboxRadius);
95
96                playerGroup.position.copy(state.fpsCamera.translation_);
97                playerGroup.position.y = 0;
98            } else {
99                state.fpsCamera.update(dt);
100                state.wallRunning = !!(state.fpsCamera && state.fpsCamera.wallRunSide_ > 0);
101
102                // When downed the player has zero velocity — skip collision resolution
103                // entirely. The prone Y (0.46) would cause the 3D BVH probe to intersect
104                // building-floor geometry and fire a spurious XZ push that ejects the
105                // player from the map.
106                if (!state.downed) {
107                    const clipBoxes = getClipBrushBoxes();
108                    if (clipBoxes.length > 0) {
109                        resolveClipBrushesXZ(state.fpsCamera.translation_, GAME_CONFIG.player.hitboxRadius, clipBoxes);
110                    }
111                    resolveGlbColliders(state.fpsCamera.translation_, GAME_CONFIG.player.hitboxRadius);
112                }
113
114                // Hard map-bounds clamp — prevents corner-fighting between sequential
115                // resolvers from slowly walking the player outside the world.
116                const mapHalfPlayer = getActiveMapSize() / 2 - GAME_CONFIG.player.hitboxRadius;
117                state.fpsCamera.translation_.x = Math.max(-mapHalfPlayer, Math.min(mapHalfPlayer, state.fpsCamera.translation_.x));
118                state.fpsCamera.translation_.z = Math.max(-mapHalfPlayer, Math.min(mapHalfPlayer, state.fpsCamera.translation_.z));
119
120                playerGroup.position.copy(state.fpsCamera.translation_);
121                playerGroup.position.y = 0;
122            }
123        }
124    }
125
126    updateWeaponFeel(dt);
127    updateMuzzleFlash(dt);
128    updateMuzzleBarrelFx(dt);
129
130    if (pausedPlaying) {
131        if (state.gamePhase === 'playing' || state.gamePhase === 'upgrade') {
132            state.interactTarget = null;
133            state.inBoxRange = false;
134        }
135        return;
136    }
137
138    const worldInteract =
139        state.gamePhase === 'playing' || state.gamePhase === 'upgrade';
140
141    // Ally revive wins over buyables when a downed teammate is in range (multiplayer).
142    const rng = GAME_CONFIG.interactRange ?? 3;
143    const px = playerGroup.position.x;
144    const pz = playerGroup.position.z;
145    let best = null;
146    let bestD = Infinity;
147
148    if (GAME_CONFIG.player.multiplayer && worldInteract && !state.downed && !state.spectating) {
149        const rr = GAME_CONFIG.player.mpReviveRange ?? 2.8;
150        const near = getNearestDownedPlayerForRevive(px, pz, rr);
151        if (near) {
152            best = { kind: 'revive', sessionId: near.sessionId };
153            bestD = near.dist;
154        }
155    }
156
157    if (!best && worldInteract) {
158        const considerXZ = (wx, wz, payload) => {
159            const d = Math.hypot(px - wx, pz - wz);
160            if (d < rng && d < bestD) {
161                bestD = d;
162                best = payload;
163            }
164        };
165
166        const powerLocksBuys = buysNeedMapPowerLocked();
167
168        // Mystery box is always accessible regardless of power state
169        {
170            const bx = boxGroup.position.x;
171            const bz = boxGroup.position.z;
172            if (state.boxPhase === 'waitingPickup') {
173                considerXZ(bx, bz, {
174                    kind: 'box_pickup',
175                    weaponName: (GAME_CONFIG.weapons[state.boxLandedWeapon]?.name ?? 'Weapon'),
176                    cost: 0,
177                });
178            } else if (state.boxPhase === 'idle') {
179                considerXZ(bx, bz, {
180                    kind: 'box',
181                    cost: getMysteryBoxCostDisplay()
182                });
183            }
184        }
185
186        if (!powerLocksBuys) {
187            for (const ch of worldPropsGroup.children) {
188                if (ch.userData.entityType !== 'perkMachine') continue;
189                const cfg = GAME_CONFIG.worldPerkMachines.find((p) => p.id === ch.userData.perkId);
190                if (!cfg) continue;
191                considerXZ(ch.position.x, ch.position.z, {
192                    kind:  'perk',
193                    id:    cfg.id,
194                    title: cfg.title,
195                    desc:  cfg.desc ?? '',
196                    cost:  cfg.cost,
197                });
198            }
199            for (const ch of worldPropsGroup.children) {
200                if (ch.userData?.entityType !== 'packAPunch') continue;
201                ch.getWorldPosition(_papWorldPos);
202                considerXZ(_papWorldPos.x, _papWorldPos.z, {
203                    kind: 'pap',
204                    cost: getPackAPunchCostForWeapon(state.currentWeapon, state)
205                });
206            }
207        }
208
209        if (powerLocksBuys) {
210            for (const ch of worldPropsGroup.children) {
211                if (ch.userData?.entityType !== 'powerSwitch') continue;
212                ch.getWorldPosition(_powerSwitchWorldPos);
213                considerXZ(_powerSwitchWorldPos.x, _powerSwitchWorldPos.z, { kind: 'powerSwitch' });
214            }
215        }
216    }
217    state.interactTarget = best;
218    state.inBoxRange = !state.downed && (best?.kind === 'box' || best?.kind === 'box_pickup');
219    if (state.downed || state.spectating) {
220        state.interactTarget = null;
221        state.inBoxRange = false;
222    }
223
224    // Projectiles
225    for (let i = state.projectiles.length - 1; i >= 0; i--) {
226        const p = state.projectiles[i];
227        p.mesh.position.addScaledVector(p.dir, p.speed * dt);
228        p.life -= dt;
229
230        let hit = false;
231        const mapHalf = getActiveMapSize() / 2;
232        if (p.mesh.position.y < 0 || Math.abs(p.mesh.position.x) > mapHalf || Math.abs(p.mesh.position.z) > mapHalf) {
233            hit = true;
234        }
235
236        if (!hit) {
237            const py = p.mesh.position.y;
238            let bestZ = null;
239            let bestJ = -1;
240            let bestDistSq = Infinity;
241            for (let j = state.activeZombies.length - 1; j >= 0; j--) {
242                const z = state.activeZombies[j];
243                if (p.hitZombies?.has(z)) continue;
244                const dx = p.mesh.position.x - z.mesh.position.x;
245                const dz = p.mesh.position.z - z.mesh.position.z;
246                const distSq = dx * dx + dz * dz;
247                const rad = z.radius + 0.2;
248                if (distSq < rad * rad && py > 0 && py < 2) {
249                    if (distSq < bestDistSq) {
250                        bestDistSq = distSq;
251                        bestZ = z;
252                        bestJ = j;
253                    }
254                }
255            }
256
257            if (bestZ) {
258                const z = bestZ;
259                const j = bestJ;
260                if (p.hitZombies) p.hitZombies.add(z);
261
262                const zCfg = GAME_CONFIG.zombie;
263                const plCfg = GAME_CONFIG.player;
264                const headshot = isZombieHeadshot(p.mesh, z.mesh, zCfg);
265                const criticalSodaBase = state.worldPerks?.criticalSoda
266                    ? (plCfg.critStrikeChance ?? 0.25)
267                    : 0;
268                const critBonus = state.upgradeCritChanceAdd ?? 0;
269                const critChance = Math.min(0.95, criticalSodaBase + critBonus);
270                const crit = critChance > 0 && Math.random() < critChance;
271                let dmg = p.damageBase ?? p.damage ?? 0;
272                if (crit) dmg *= plCfg.critDamageMult ?? 1.85;
273                if (headshot) {
274                    const hs =
275                        (zCfg.headshotDamageMult ?? 2.35) +
276                        (state.upgradeHeadshotMultAdd || 0);
277                    dmg *= Math.max(1, hs);
278                }
279                dmg = Math.max(1, Math.floor(dmg));
280                if (!GAME_CONFIG.player.multiplayer && isInstaKillActive()) {
281                    dmg = z.health;
282                }
283
284                if (GAME_CONFIG.player.multiplayer && z.networkId) {
285                    // ── Multiplayer: server is authoritative ───────────────────
286                    // Visual feedback is immediate (optimistic); the zombie mesh
287                    // disappears only when the server removes it from state.
288                    // Hits are coalesced per-frame and flushed once via
289                    // net.flushZombieHits() in main.js's gameLoop — drops late-
290                    // round message rate by ~10× when multiple players + pierce
291                    // are involved.
292                    net.queueZombieHit(z.networkId, dmg, headshot, crit);
293
294                    const bloodDropletColor =
295                        SPLAT_BLOOD_PALETTE[Math.floor(Math.random() * SPLAT_BLOOD_PALETTE.length)];
296                    createHitParticles(p.mesh.position, bloodDropletColor);
297                    spawnZombieGoreChunks(p.mesh.position.clone(), p.dir, { variant: 'hit' });
298                    spawnBloodOnZombieHit(z.mesh.position.x, z.mesh.position.z);
299                    playHitSound(headshot);
300
301                    const lo = new THREE.Vector3(
302                        (Math.random() - 0.5) * 0.45,
303                        headshot ? 1.52 + Math.random() * 0.28 : 1.05 + Math.random() * 0.35,
304                        (Math.random() - 0.5) * 0.45
305                    );
306                    // Can't know if it's a kill client-side; show hit number only
307                    spawnDamageNumber(dmg, {
308                        kill: false,
309                        followMesh: z.mesh,
310                        localOffset: lo,
311                        headshot,
312                        crit
313                    });
314
315                    if (p.shotTally && !p.shotTally.zombiesHit.has(z)) {
316                        p.shotTally.zombiesHit.add(z);
317                        const boomTh = shotBoomThreshold();
318                        if (p.shotTally.zombiesHit.size >= boomTh && !p.shotTally.boomShown) {
319                            p.shotTally.boomShown = true;
320                            spawnChainBoomFanfare({
321                                followMesh: z.mesh,
322                                localOffset: new THREE.Vector3(0, 1.58, 0)
323                            });
324                        }
325                    }
326                } else {
327                    // ── Solo: local authority ──────────────────────────────────
328                    z.health -= dmg;
329                    const ptMul = getPointsMultiplier();
330                    state.points += Math.floor(GAME_CONFIG.economy.pointsPerHit * ptMul);
331                    const bloodDropletColor =
332                        SPLAT_BLOOD_PALETTE[
333                            Math.floor(Math.random() * SPLAT_BLOOD_PALETTE.length)
334                        ];
335                    createHitParticles(p.mesh.position, bloodDropletColor);
336                    spawnZombieGoreChunks(p.mesh.position.clone(), p.dir, { variant: 'hit' });
337                    spawnBloodOnZombieHit(z.mesh.position.x, z.mesh.position.z);
338                    playHitSound(headshot);
339                    const willKill = z.health <= 0;
340                    const lo = new THREE.Vector3(
341                        (Math.random() - 0.5) * 0.45,
342                        headshot ? 1.52 + Math.random() * 0.28 : 1.05 + Math.random() * 0.35,
343                        (Math.random() - 0.5) * 0.45
344                    );
345                    const frozen = willKill
346                        ? new THREE.Vector3(
347                              z.mesh.position.x + lo.x,
348                              Math.min(1.95, z.mesh.position.y + lo.y),
349                              z.mesh.position.z + lo.z
350                          )
351                        : null;
352                    spawnDamageNumber(dmg, {
353                        kill: willKill,
354                        followMesh: willKill ? null : z.mesh,
355                        localOffset: lo,
356                        frozenWorld: frozen,
357                        headshot,
358                        crit
359                    });
360
361                    if (p.shotTally && !p.shotTally.zombiesHit.has(z)) {
362                        p.shotTally.zombiesHit.add(z);
363                        const boomTh = shotBoomThreshold();
364                        if (p.shotTally.zombiesHit.size >= boomTh && !p.shotTally.boomShown) {
365                            p.shotTally.boomShown = true;
366                            const boomY = z.mesh.position.y + 1.58;
367                            const boomAnchor = willKill
368                                ? {
369                                      frozenWorld: new THREE.Vector3(
370                                          z.mesh.position.x,
371                                          boomY,
372                                          z.mesh.position.z
373                                      )
374                                  }
375                                : {
376                                      followMesh: z.mesh,
377                                      localOffset: new THREE.Vector3(0, 1.58, 0)
378                                  };
379                            spawnChainBoomFanfare(boomAnchor);
380                        }
381                    }
382
383                    if (z.health <= 0) {
384                        dialogue.onKill();
385                        state.soloScoreboardKills++;
386                        state.points += Math.floor(GAME_CONFIG.economy.pointsPerKill * ptMul);
387                        state.health = Math.min(state.maxHealth, state.health + state.lifeSteal);
388                        spawnBloodOnZombieKill(z.mesh.position.x, z.mesh.position.z);
389                        createZombieDeathParticles(z.mesh.position);
390                        const burst = z.mesh.position.clone();
391                        burst.y += 0.82 + Math.random() * 0.32;
392                        const ang = Math.random() * Math.PI * 2;
393                        const killDir = new THREE.Vector3(
394                            Math.cos(ang) * 0.75,
395                            0.28 + Math.random() * 0.22,
396                            Math.sin(ang) * 0.75
397                        ).normalize();
398                        spawnZombieGoreChunks(burst, killDir, { variant: 'kill' });
399                        trySpawnSoloPowerupAt(z.mesh.position.x, z.mesh.position.z);
400                        destroyZombieVisuals(z);
401                        scene.remove(z.mesh);
402                        state.activeZombies.splice(j, 1);
403                    }
404                }
405
406                p.hitsOnZombies = (p.hitsOnZombies ?? 0) + 1;
407                const maxHits = p.maxPierceHits ?? 1;
408                hit = p.hitsOnZombies >= maxHits;
409            }
410        }
411
412        if (hit || p.life <= 0) {
413            scene.remove(p.mesh);
414            // Tracer materials are cached in createWeaponTracerMaterial — do not dispose.
415            state.projectiles.splice(i, 1);
416        }
417    }
418
419    // Particles (blood droplets leave a floor splat when they land)
420    const splatHalf = getActiveMapSize() / 2 - 0.35;
421    for (let i = state.particles.length - 1; i >= 0; i--) {
422        const p = state.particles[i];
423        p.mesh.position.addScaledVector(p.vel, dt);
424        p.vel.y -= 20 * dt;
425        p.life -= dt;
426
427        p.mesh.getWorldPosition(particleWorldPos);
428        const wx = particleWorldPos.x;
429        const wy = particleWorldPos.y;
430        const wz = particleWorldPos.z;
431
432        const detach = () => {
433            if (p.mesh.parent) p.mesh.parent.remove(p.mesh);
434            state.particles.splice(i, 1);
435        };
436
437        if (p.life <= 0) {
438            detach();
439            continue;
440        }
441
442        if (wy < 0.11) {
443            if (
444                p.leavesBloodSplat &&
445                Math.abs(wx) < splatHalf &&
446                Math.abs(wz) < splatHalf
447            ) {
448                spawnBloodSplat(wx, wz, p.splatColor);
449            }
450            detach();
451            continue;
452        }
453    }
454
455    updateGoreChunks(dt);
456
457    updateBloodSplats(dt);
458
459    // Update zombie animations (runs in both solo and multiplayer for smooth visuals)
460    updateZombieAnimations(dt);
461
462    // ── Solo-only: zombie movement, player collision, zombie damage ─────────
463    if (!GAME_CONFIG.player.multiplayer) {
464        const pHitR = GAME_CONFIG.player.hitboxRadius;
465        const attackPad = 0.2;
466        const pathSub = getZombiePathSubsteps();
467        const subInv = 1 / pathSub;
468
469        state.activeZombies.forEach(z => {
470            const px = playerGroup.position.x;
471            const pz = playerGroup.position.z;
472            const zx = z.mesh.position.x;
473            const zz = z.mesh.position.z;
474
475            let dirx = state.downed ? zx - px : px - zx;
476            let dirz = state.downed ? zz - pz : pz - zz;
477            let dist = Math.hypot(dirx, dirz);
478
479            if (dist > 0.1) {
480                dirx /= dist;
481                dirz /= dist;
482                zombieLookTarget.set(z.mesh.position.x + dirx, z.mesh.position.y, z.mesh.position.z + dirz);
483                z.mesh.lookAt(zombieLookTarget);
484                // Shorter sub-steps: re-resolve floor + clip + GLB per slice so
485                // zombies don’t tunnel past steep mesh or get stuck in one frame.
486                for (let s = 0; s < pathSub; s++) {
487                    z.mesh.position.x += dirx * z.speed * dt * subInv;
488                    z.mesh.position.z += dirz * z.speed * dt * subInv;
489                    applyZombieTerrainColliders(z);
490                }
491            } else if (state.downed) {
492                const ang = Math.random() * Math.PI * 2;
493                const c = Math.cos(ang);
494                const s = Math.sin(ang);
495                for (let k = 0; k < pathSub; k++) {
496                    z.mesh.position.x += c * z.speed * dt * subInv;
497                    z.mesh.position.z += s * z.speed * dt * subInv;
498                    applyZombieTerrainColliders(z);
499                }
500            }
501
502            dirx = px - z.mesh.position.x;
503            dirz = pz - z.mesh.position.z;
504            dist = Math.hypot(dirx, dirz);
505            const minSep = pHitR + z.radius + 0.12;
506            if (dist < minSep) {
507                if (dist < 1e-5) {
508                    const ang = Math.random() * Math.PI * 2;
509                    dirx = Math.cos(ang);
510                    dirz = Math.sin(ang);
511                    dist = 1e-5;
512                } else {
513                    dirx /= dist;
514                    dirz /= dist;
515                }
516                const push = minSep - dist;
517                z.mesh.position.x -= dirx * push;
518                z.mesh.position.z -= dirz * push;
519                dist = minSep;
520            }
521
522            if (dist < pHitR + z.radius + attackPad && !state.downed) {
523                const now = Date.now();
524                const cooldown = GAME_CONFIG.zombie.attackCooldownMs ?? 1500;
525                if (now - (z.lastAttackTime || 0) >= cooldown) {
526                    z.lastAttackTime = now;
527                    state.lastDamageTime = now;
528                    state.health -= GAME_CONFIG.zombie.baseDamage;
529                    dialogue.onDamage();
530                    document.getElementById('damageOverlay').style.boxShadow = 'inset 0 0 100px rgba(255,0,0,0.5)';
531                    setTimeout(() => document.getElementById('damageOverlay').style.boxShadow = 'inset 0 0 100px rgba(255,0,0,0)', 200);
532                    if (state.health <= 0) {
533                        state.health = 0;
534                        tryEnterDownedOrDie();
535                    }
536                }
537            }
538        });
539
540        separateActiveZombies();
541    }
542
543    const trailCfg = !GAME_CONFIG.player.multiplayer && GAME_CONFIG.zombie.bloodTrail;
544    if (trailCfg) {
545        const ratio = trailCfg.healthRatio;
546        const step = trailCfg.stepDistance;
547        const jitter = trailCfg.jitter ?? 0;
548        for (const z of state.activeZombies) {
549            const maxHp = z.maxHealth ?? z.health;
550            if (maxHp <= 0 || z.health <= 0) continue;
551            if (z.health > maxHp * ratio) {
552                z._bloodTrailAnchor = null;
553                continue;
554            }
555            const zx = z.mesh.position.x;
556            const zz = z.mesh.position.z;
557            if (!z._bloodTrailAnchor) {
558                z._bloodTrailAnchor = { x: zx, z: zz };
559                continue;
560            }
561            const adx = zx - z._bloodTrailAnchor.x;
562            const adz = zz - z._bloodTrailAnchor.z;
563            if (adx * adx + adz * adz < step * step) continue;
564
565            const jx = (Math.random() - 0.5) * 2 * jitter;
566            const jz = (Math.random() - 0.5) * 2 * jitter;
567            const c = SPLAT_BLOOD_PALETTE[Math.floor(Math.random() * SPLAT_BLOOD_PALETTE.length)];
568            spawnBloodSplat(zx + jx, zz + jz, c, {
569                scaleMin: trailCfg.scaleMin,
570                scaleMax: trailCfg.scaleMax,
571                lifeMin: trailCfg.lifeMin,
572                lifeMax: trailCfg.lifeMax,
573                peakOpacity: trailCfg.peakOpacity
574            });
575            z._bloodTrailAnchor.x = zx;
576            z._bloodTrailAnchor.z = zz;
577        }
578    }
579
580    updateDamageNumbers();
581    updateShotBooms();
582}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.