sindicate 0.1.0 → 0.4.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/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Player footsteps: surface-aware step sounds fired when a FOOT actually PLANTS — detected from the
|
|
2
|
+
// ankle bone reaching its lowest point in the walk/run cycle. This syncs the SFX to the animation's
|
|
3
|
+
// footfalls and paces the cadence off the clip (not world speed, which was firing them far too fast).
|
|
4
|
+
// Real recordings (cplomedia Footsteps pack) via audio.loadSamples/sample. Six surfaces × six variants.
|
|
5
|
+
import * as THREE from 'three/webgpu';
|
|
6
|
+
// register the road query before surface classification: setFootstepTerrain({ roadDistance })
|
|
7
|
+
let FT = { roadDistance: () => 1e9 };
|
|
8
|
+
export function setFootstepTerrain(t) { FT = { ...FT, ...t }; }
|
|
9
|
+
|
|
10
|
+
const _fv = new THREE.Vector3();
|
|
11
|
+
|
|
12
|
+
const SURFACES = ['grass', 'stone', 'earth', 'snow', 'mud', 'water'];
|
|
13
|
+
const VARIANTS = 6;
|
|
14
|
+
|
|
15
|
+
export class Footsteps {
|
|
16
|
+
constructor(game) {
|
|
17
|
+
this.game = game;
|
|
18
|
+
this._loaded = false;
|
|
19
|
+
this._lastN = 0;
|
|
20
|
+
this._feetCache = undefined; // {L,R} ankle bones once resolved, or null if the rig lacks them
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Find the two ankle/foot bones — resolved from the mixer's DRIVEN nodes. The hero model
|
|
24
|
+
// carries ~117 duplicate outfit-part skeletons; a name traverse lands on an undriven orphan
|
|
25
|
+
// whose world-Y never cycles with the walk, so plants only fired off terrain-bob noise
|
|
26
|
+
// (the one-sound-every-5-steps bug). This rig's ankle is Ball_L/R (UpperLeg→LowerLeg→Ball→Toes).
|
|
27
|
+
_feet() {
|
|
28
|
+
if (this._feetCache) return this._feetCache;
|
|
29
|
+
const p = this.game.player;
|
|
30
|
+
if (!p?.model || !p.animator?.mixer) return null;
|
|
31
|
+
let L = null, R = null, fL = null, fR = null;
|
|
32
|
+
for (const pm of p.animator.mixer._bindings ?? []) {
|
|
33
|
+
const n = pm.binding?.node;
|
|
34
|
+
if (!n) continue;
|
|
35
|
+
if (!L && n.name === 'Ball_L') L = n;
|
|
36
|
+
if (!R && n.name === 'Ball_R') R = n;
|
|
37
|
+
const low = n.name.toLowerCase();
|
|
38
|
+
if (/(twist|roll)/.test(low) || !/(ankle|foot)/.test(low)) continue;
|
|
39
|
+
if (!fL && /(_l\b|left)/.test(low)) fL = n;
|
|
40
|
+
else if (!fR && /(_r\b|right)/.test(low)) fR = n;
|
|
41
|
+
}
|
|
42
|
+
L = L ?? fL; R = R ?? fR;
|
|
43
|
+
if (!L || !R) return null; // bindings appear once clips play — retry next frame, never cache failure
|
|
44
|
+
this._feetCache = { L, R };
|
|
45
|
+
return this._feetCache;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_load() {
|
|
49
|
+
this._loaded = true;
|
|
50
|
+
const F = '/assets/audio/footsteps';
|
|
51
|
+
const map = {};
|
|
52
|
+
for (const s of SURFACES) for (let i = 1; i <= VARIANTS; i++) map[`${s}${i}`] = `${F}/${s}${i}.wav`;
|
|
53
|
+
this.game.audio?.loadSamples(map);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// What is the hero standing on? A desert plain: wading water (the creek) > packed
|
|
57
|
+
// main-street dirt (stone set reads hard-packed) > open sand (earth — no sand set ships).
|
|
58
|
+
surfaceAt(x, z) {
|
|
59
|
+
// a coach roof is boards, and there is no board set: 'stone' is the hard-packed one (SURFACES)
|
|
60
|
+
const d = this.game.player?.platform;
|
|
61
|
+
if (d) return d.deck.surface ?? 'stone';
|
|
62
|
+
return groundSurface(this.game, x, z);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Called every frame while the hero is on foot. `moving`/`running` come from the locomotion FSM.
|
|
66
|
+
// Fires a step at each foot-plant (local minimum of that ankle's world Y), so the SFX lands exactly
|
|
67
|
+
// when the foot meets the ground and the cadence matches the walk/run clip.
|
|
68
|
+
update(dt, moving, running) {
|
|
69
|
+
const a = this.game.audio;
|
|
70
|
+
if (!a || !a.ctx) return; // audio context not unlocked yet
|
|
71
|
+
if (!this._loaded) this._load();
|
|
72
|
+
const p = this.game.player;
|
|
73
|
+
const feet = this._feet();
|
|
74
|
+
if (!moving || p.airborne || !feet) { // reset tracking so re-entry doesn't fire a stale plant
|
|
75
|
+
this._pyL = this._pyR = undefined; this._pvL = this._pvR = 0;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// the floor he is ACTUALLY on — on a coach roof the heightfield is 2.7m of nonsense, and the
|
|
79
|
+
// rolling floor below only climbs 0.3 m/s, so his steps would go silent for nine seconds
|
|
80
|
+
const groundY = p.platform ? p.position.y : this.game.world.groundAt(p.position.x, p.position.z);
|
|
81
|
+
for (const side of ['L', 'R']) {
|
|
82
|
+
// height RELATIVE to the ground — world-Y minima drift with terrain slope, so on
|
|
83
|
+
// uphill runs each plant landed "too high" for the rolling floor and got skipped
|
|
84
|
+
// (the reason snow-country steps still fired sparsely: it's all hills up there)
|
|
85
|
+
const y = feet[side].getWorldPosition(_fv).y - groundY;
|
|
86
|
+
const py = this['_py' + side];
|
|
87
|
+
if (py === undefined) { this['_py' + side] = y; this['_pv' + side] = 0; this['_my' + side] = y; continue; }
|
|
88
|
+
// velocities in m/s (NOT per-frame deltas — those shrank below the noise floor at
|
|
89
|
+
// 120Hz and the plant test missed most footfalls)
|
|
90
|
+
const v = (y - py) / Math.max(dt, 1e-4), pv = this['_pv' + side];
|
|
91
|
+
this['_py' + side] = y; this['_pv' + side] = v;
|
|
92
|
+
this['_ct' + side] = Math.max(0, (this['_ct' + side] ?? 0) - dt);
|
|
93
|
+
// rolling floor: the foot's lowest recent Y, decaying upward so it tracks terrain.
|
|
94
|
+
// Only a bottom-out NEAR the floor is a plant — the mid-foot Ball bone also dips at
|
|
95
|
+
// push-off (higher), which double-fired every stride.
|
|
96
|
+
const my = Math.min((this['_my' + side] ?? y) + 0.3 * dt, y);
|
|
97
|
+
this['_my' + side] = my;
|
|
98
|
+
if (pv < -0.15 && v >= -0.03 && y <= my + 0.045 && this['_ct' + side] === 0) {
|
|
99
|
+
this['_ct' + side] = 0.22;
|
|
100
|
+
this._step(running);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_step(running) {
|
|
106
|
+
const p = this.game.player;
|
|
107
|
+
const surf = this.surfaceAt(p.position.x, p.position.z);
|
|
108
|
+
let n = 1 + ((Math.random() * VARIANTS) | 0);
|
|
109
|
+
if (n === this._lastN) n = (n % VARIANTS) + 1; // no immediate repeat
|
|
110
|
+
this._lastN = n;
|
|
111
|
+
const gain = (running ? 0.5 : 0.34) * (0.85 + Math.random() * 0.3);
|
|
112
|
+
const rate = 0.94 + Math.random() * 0.12; // slight pitch jitter per step
|
|
113
|
+
this.game.audio.sample(`${surf}${n}`, { gain, rate });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Ground surface at a point, WITHOUT the player's coach-roof special-case — for NPC/ambient footsteps.
|
|
118
|
+
export function groundSurface(game, x, z) {
|
|
119
|
+
const w = game.world;
|
|
120
|
+
if ((w.waterDepthAt?.(x, z) ?? 0) > 0.15) return 'water';
|
|
121
|
+
if (FT.roadDistance(x, z) < 2.6) return 'stone'; // Dustwater's packed main street
|
|
122
|
+
return 'earth'; // sand/dirt everywhere else
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// AMBIENT NPC FOOTSTEPS. The player's plant-accurate bone tracking is far too dear for 132 souls, so this
|
|
126
|
+
// is one cheap central pass a frame: find the WALKING NPCs within earshot, take the nearest few, and step
|
|
127
|
+
// them on a speed-derived cadence, distance-attenuated. Reuses the footstep samples the player already
|
|
128
|
+
// loaded, and the audio's gain hook. "Walking" is read from a POSITION DELTA, not the walk clip — an NPC
|
|
129
|
+
// behind you is frustum-culled (animator skipped, clip stale) but its position still integrates, so you
|
|
130
|
+
// still hear it pass. No stereo pan (the whole SFX bus is mono today), just distance falloff.
|
|
131
|
+
export class NpcFootsteps {
|
|
132
|
+
constructor(game) { this.game = game; }
|
|
133
|
+
update(dt) {
|
|
134
|
+
const a = this.game.audio, p = this.game.player;
|
|
135
|
+
if (!a?.ctx || !p || dt <= 0) return;
|
|
136
|
+
const R = 13, MAX = 7, near = [];
|
|
137
|
+
for (const n of this.game.npcs ?? []) {
|
|
138
|
+
if (!n.alive) { if (n._fsX !== undefined) n._fsX = undefined; continue; }
|
|
139
|
+
const d = Math.hypot(n.position.x - p.position.x, n.position.z - p.position.z);
|
|
140
|
+
if (d > R) { n._fsX = undefined; n._fsT = undefined; continue; } // out of earshot: forget its stride
|
|
141
|
+
const lx = n._fsX, lz = n._fsZ; n._fsX = n.position.x; n._fsZ = n.position.z;
|
|
142
|
+
if (lx === undefined) continue; // first frame in range — no delta yet
|
|
143
|
+
const spd = Math.hypot(n.position.x - lx, n.position.z - lz) / dt;
|
|
144
|
+
if (spd < 0.5) { n._fsT = undefined; continue; } // standing / idling — no steps
|
|
145
|
+
near.push({ n, d, spd });
|
|
146
|
+
}
|
|
147
|
+
if (!near.length) return;
|
|
148
|
+
near.sort((u, v) => u.d - v.d); // only the closest few are voiced
|
|
149
|
+
const lim = Math.min(near.length, MAX);
|
|
150
|
+
for (let i = 0; i < lim; i++) {
|
|
151
|
+
const { n, d, spd } = near[i];
|
|
152
|
+
n._fsT = (n._fsT ?? Math.random() * 0.35) - dt;
|
|
153
|
+
if (n._fsT > 0) continue;
|
|
154
|
+
n._fsT = Math.max(0.26, Math.min(0.62, 0.7 / spd)) * (0.88 + Math.random() * 0.24); // stride from speed
|
|
155
|
+
const surf = groundSurface(this.game, n.position.x, n.position.z);
|
|
156
|
+
const gain = (1 - d / R) * (spd > 3 ? 0.3 : 0.2) * (0.85 + Math.random() * 0.3);
|
|
157
|
+
const k = 1 + ((Math.random() * VARIANTS) | 0);
|
|
158
|
+
a.sample(`${surf}${k}`, { gain, rate: 0.93 + Math.random() * 0.14 });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// THE DRINK — a saloon cup (SM_Prop_Cup_01) with a liquid whose level DROPS as it's drunk, ported from
|
|
2
|
+
// the Kiwi bar (Assets/Shaders/BeerGlass.shader + BeerGlassController.cs: fillLevel 0..1, drinkAmount ~0.15,
|
|
3
|
+
// Empty/Fill/TakeDrink). Kiwi coloured a fixed liquid mesh by fragment Y; here we just SCALE a liquid
|
|
4
|
+
// cylinder from its base + ride a foam disc on top — same "content goes down in levels" read, far simpler
|
|
5
|
+
// and rock-solid. The cup is sized at runtime from its own bbox so the liquid always fits inside it.
|
|
6
|
+
import * as THREE from 'three/webgpu';
|
|
7
|
+
import { loadGLB, loadTexture } from '../core/assets.js';
|
|
8
|
+
|
|
9
|
+
const DRINK_AMOUNT = 0.15; // fill consumed per swig (Kiwi's default)
|
|
10
|
+
const EMPTY_AT = 0.08; // below this it reads as empty
|
|
11
|
+
const CHAR_TEX = '/assets/textures/atlas_western.png';
|
|
12
|
+
const LIQUID_COLOR = 0xcaa33e; // amber whiskey/beer
|
|
13
|
+
const FOAM_COLOR = 0xf3e6c4;
|
|
14
|
+
|
|
15
|
+
let _cupGlb = null, _atlas = null;
|
|
16
|
+
export async function initGlass() {
|
|
17
|
+
if (_cupGlb) return;
|
|
18
|
+
[_cupGlb, _atlas] = await Promise.all([
|
|
19
|
+
loadGLB('/assets/western/SM_Prop_Cup_01.glb'),
|
|
20
|
+
loadTexture(CHAR_TEX, { flipY: false }),
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class DrinkGlass {
|
|
25
|
+
constructor() {
|
|
26
|
+
this.group = new THREE.Group();
|
|
27
|
+
this._fill = 1;
|
|
28
|
+
// cup
|
|
29
|
+
this.cup = _cupGlb.clone();
|
|
30
|
+
this.cup.traverse((o) => { if (o.isMesh && o.material) { o.material = o.material.clone(); o.material.map = _atlas; o.material.needsUpdate = true; } });
|
|
31
|
+
this.group.add(this.cup);
|
|
32
|
+
// measure the cup so the liquid sits INSIDE it (a touch in from the rim, a touch up from the base)
|
|
33
|
+
const bb = new THREE.Box3().setFromObject(this.cup);
|
|
34
|
+
const size = bb.getSize(new THREE.Vector3());
|
|
35
|
+
const r = Math.min(size.x, size.z) * 0.5 * 0.82; // inner radius
|
|
36
|
+
this._base = bb.min.y + size.y * 0.06; // liquid floor
|
|
37
|
+
this._colH = size.y * 0.80; // full-liquid column height
|
|
38
|
+
// liquid: a cylinder pinned at its BASE (translate up by 0.5 so scale.y grows from the bottom)
|
|
39
|
+
const liqGeo = new THREE.CylinderGeometry(r, r * 0.92, 1, 20, 1, true).translate(0, 0.5, 0);
|
|
40
|
+
this.liquid = new THREE.Mesh(liqGeo, new THREE.MeshStandardNodeMaterial({ color: LIQUID_COLOR, roughness: 0.25, metalness: 0.0 }));
|
|
41
|
+
this.liquid.position.y = this._base;
|
|
42
|
+
this.group.add(this.liquid);
|
|
43
|
+
// foam: a thin disc that rides the liquid surface
|
|
44
|
+
const foamGeo = new THREE.CylinderGeometry(r * 1.005, r * 1.005, 0.006, 20).translate(0, 0.003, 0);
|
|
45
|
+
this.foam = new THREE.Mesh(foamGeo, new THREE.MeshStandardNodeMaterial({ color: FOAM_COLOR, roughness: 0.6 }));
|
|
46
|
+
this.group.add(this.foam);
|
|
47
|
+
this._apply();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get fill() { return this._fill; }
|
|
51
|
+
set fill(v) { this._fill = THREE.MathUtils.clamp(v, 0, 1); this._apply(); }
|
|
52
|
+
get isEmpty() { return this._fill <= EMPTY_AT; }
|
|
53
|
+
get isFull() { return this._fill >= 0.95; }
|
|
54
|
+
|
|
55
|
+
_apply() {
|
|
56
|
+
const h = this._colH * this._fill;
|
|
57
|
+
this.liquid.scale.y = Math.max(0.0001, h);
|
|
58
|
+
const top = this._base + h;
|
|
59
|
+
this.foam.position.y = top;
|
|
60
|
+
const show = this._fill > 0.001;
|
|
61
|
+
this.liquid.visible = show; this.foam.visible = show;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
drink(amount = DRINK_AMOUNT) { this.fill = this._fill - amount; return this.isEmpty; }
|
|
65
|
+
fillUp() { this.fill = 1; }
|
|
66
|
+
empty() { this.fill = 0; }
|
|
67
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// A Herd: every individual of one species rendered as ONE InstancedMesh (VAT-animated — see vat.js)
|
|
2
|
+
// with cheap per-instance AI: idle / graze / wander inside a home radius, and bolt from the player.
|
|
3
|
+
// Terrain-follow only, no BVH. Resident-for-life: built on the loading screen, never added or removed.
|
|
4
|
+
//
|
|
5
|
+
// This is the cheap way to fill a county that just quadrupled in area — one draw call per species and
|
|
6
|
+
// no animator on the CPU at all.
|
|
7
|
+
//
|
|
8
|
+
// PORTED FROM /Users/nick/Sites/fable/src/game/herd.js WITH TWO FIXES, AND THEY ARE NOT OPTIONAL.
|
|
9
|
+
// Fable's version is written for a world where every herd is near the player. Ours are spread over
|
|
10
|
+
// 18.66 km². Shipped as-is, it costs what it should not:
|
|
11
|
+
//
|
|
12
|
+
// 1. IT NEVER FRUSTUM-CULLS. fable sets `mesh.frustumCulled = false`, so all 24 herds are submitted
|
|
13
|
+
// every frame from anywhere in the county. Fixed by giving each herd a REAL bounding sphere
|
|
14
|
+
// (anchor, roam + scatter + a margin) and letting three cull it. It never goes stale because the
|
|
15
|
+
// AI physically cannot leave that sphere: `roam` is the leash.
|
|
16
|
+
//
|
|
17
|
+
// 2. IT SAMPLES THE GROUND FIVE TIMES PER MOVING ANIMAL PER FRAME. _writeMatrix takes one groundAt
|
|
18
|
+
// for the height and FOUR more for the slope basis, and world.js's groundAt is heightAt (plus
|
|
19
|
+
// walkHumps' grid-gated dust-pile lift, ~free off the piles) — three
|
|
20
|
+
// fbm stacks, a roadDistance, the outer relief, the pads loop and the rail box, measured at
|
|
21
|
+
// 626 ns a call. About 40 of ~150 animals are walking at any moment, so that is ~200 heightAt a
|
|
22
|
+
// frame, 0.13 ms, from herds you cannot see. Fixed with a DISTANCE GATE: past `cullR` the AI loop
|
|
23
|
+
// does not run at all. Fog ends at 300 m and entityCull is ≤110 m, so at most one or two herds
|
|
24
|
+
// are ever live, and the cost falls to ~20 calls a frame (0.013 ms).
|
|
25
|
+
//
|
|
26
|
+
// The VAT clock still ticks for a gated-out herd (one float add), so nothing pops when you ride up to
|
|
27
|
+
// it — the animals are simply standing where you left them.
|
|
28
|
+
import * as THREE from 'three/webgpu';
|
|
29
|
+
import { vatMaterial } from './vat.js';
|
|
30
|
+
// Terrain/crop queries are GAME data — register before spawning herds:
|
|
31
|
+
// setHerdTerrain({ roadDistance, railDistance, waterSurfaceAt, fieldK })
|
|
32
|
+
let HT = { roadDistance: () => 1e9, railDistance: () => 1e9, waterSurfaceAt: () => null, fieldK: () => 0 };
|
|
33
|
+
export function setHerdTerrain(t) { HT = { ...HT, ...t }; }
|
|
34
|
+
|
|
35
|
+
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _p = new THREE.Vector3();
|
|
36
|
+
const _gone = new THREE.Vector3(1e-4, 1e-4, 1e-4); // a skinned carcass's scale: buried and invisible
|
|
37
|
+
const _nrm = new THREE.Vector3(), _fwd = new THREE.Vector3(), _rgt = new THREE.Vector3(), _basis = new THREE.Matrix4();
|
|
38
|
+
|
|
39
|
+
export class Herd {
|
|
40
|
+
constructor(game, species, def, count, { anchor = { x: 0, z: 0 }, roam = 16, scatter = 10 } = {}) {
|
|
41
|
+
this.game = game; this.def = def;
|
|
42
|
+
this.anchor = anchor;
|
|
43
|
+
this.roam = roam;
|
|
44
|
+
this.fleeR = def.fleeR ?? 9;
|
|
45
|
+
// How far from his anchor an animal may EVER be, fleeing included. The bounding sphere below is
|
|
46
|
+
// built from this, and three culls the whole herd against that sphere — so this is not a taste
|
|
47
|
+
// decision, it is the thing that keeps the sphere true. (See the flee branch.)
|
|
48
|
+
this.leash = Math.max(roam, scatter) + 12;
|
|
49
|
+
// ground speed from the walk's root motion where the clip has any (legs-move == space-moved);
|
|
50
|
+
// the PolyPerfect clips are in-place, so this falls back to a hand pace
|
|
51
|
+
this.speed = species.walkSpeed || ((def.speed ?? 2.2) * 0.5);
|
|
52
|
+
this.runSpeed = species.runSpeed || def.runSpeed || this.speed * 2.6;
|
|
53
|
+
this.scale = species.scale;
|
|
54
|
+
this.ci = species.clipIndex; // { idle, walk, run, graze?, death?, dead? } → aClip indices
|
|
55
|
+
this.footY = species.footY;
|
|
56
|
+
this._scl = new THREE.Vector3(this.scale, this.scale, this.scale);
|
|
57
|
+
// HUNTABLE bookkeeping (hunting.js). The shader advances a clip at e.z = bakeFps × playRate
|
|
58
|
+
// frames/sec, so the death take runs table.frames / z seconds before it would WRAP back to the
|
|
59
|
+
// standing fall-start — kill() times the swap to the frozen `dead` still off this.
|
|
60
|
+
this.huntable = def.huntable ?? null;
|
|
61
|
+
const dte = this.ci.death != null ? species.vat.table[this.ci.death] : null;
|
|
62
|
+
this._deathZ = dte ? dte.fps * ((species.vat.playFps ?? species.vat.fps) / species.vat.fps) : 15;
|
|
63
|
+
this._deathDur = dte ? dte.frames / this._deathZ : 0;
|
|
64
|
+
this.hitR = (def.height ?? 1.2) * 0.45; // bullet test: one sphere about the chest (hunting.js)
|
|
65
|
+
this.hitY = (def.height ?? 1.2) * 0.55;
|
|
66
|
+
|
|
67
|
+
const geo = species.geometry.clone(); // per-herd instanced attrs
|
|
68
|
+
const aClip = new Float32Array(count), aPhase = new Float32Array(count), aTint = new Float32Array(count * 3);
|
|
69
|
+
const aClipAttr = new THREE.InstancedBufferAttribute(aClip, 1);
|
|
70
|
+
aClipAttr.setUsage(THREE.DynamicDrawUsage); // the AI switches clips at runtime; a static buffer never re-uploads in WebGPU
|
|
71
|
+
geo.setAttribute('aClip', aClipAttr);
|
|
72
|
+
const aPhaseAttr = new THREE.InstancedBufferAttribute(aPhase, 1);
|
|
73
|
+
aPhaseAttr.setUsage(THREE.DynamicDrawUsage); // kill() re-phases an instance so its death take starts NOW
|
|
74
|
+
geo.setAttribute('aPhase', aPhaseAttr);
|
|
75
|
+
this.aPhase = aPhase;
|
|
76
|
+
geo.setAttribute('aTint', new THREE.InstancedBufferAttribute(aTint, 3));
|
|
77
|
+
if (species.layers > 1) { // give each individual a random coat variant from the atlas
|
|
78
|
+
const aLayer = new Float32Array(count);
|
|
79
|
+
for (let i = 0; i < count; i++) aLayer[i] = Math.floor(Math.random() * species.layers);
|
|
80
|
+
geo.setAttribute('aLayer', new THREE.InstancedBufferAttribute(aLayer, 1));
|
|
81
|
+
}
|
|
82
|
+
this.aClip = aClip; this.geo = geo;
|
|
83
|
+
this.mat = vatMaterial(species.vat, species.albedo, species.layers);
|
|
84
|
+
this.mesh = new THREE.InstancedMesh(geo, this.mat, count);
|
|
85
|
+
this.mesh.name = `herd:${def.id ?? 'animal'}`;
|
|
86
|
+
|
|
87
|
+
const tint = def.tint || [0.9, 0.95, 1.0];
|
|
88
|
+
this.inst = [];
|
|
89
|
+
for (let i = 0; i < count; i++) {
|
|
90
|
+
// THE SPAWN THROW IS CHECKED TOO. The wander targets below re-dart through _clearGround,
|
|
91
|
+
// but the spawn scatter checked NOTHING — an animal could START the day standing in a
|
|
92
|
+
// channel (or on the rails) and keep it until his first walk. Same bounded retry as the
|
|
93
|
+
// wander loop: a few darts, and the last one stands whatever it hit, so a disc that is
|
|
94
|
+
// somehow all bad ground cannot spin forever.
|
|
95
|
+
let x = anchor.x, z = anchor.z;
|
|
96
|
+
for (let tries = 0; tries < 6; tries++) {
|
|
97
|
+
const a = Math.random() * Math.PI * 2, d = Math.random() * scatter;
|
|
98
|
+
x = anchor.x + Math.cos(a) * d; z = anchor.z + Math.sin(a) * d;
|
|
99
|
+
if (this._clearGround(x, z) || tries === 5) break;
|
|
100
|
+
}
|
|
101
|
+
this.inst.push({ x, z, y: 0, heading: Math.random() * Math.PI * 2, state: 'idle', t: Math.random() * 3, tx: x, tz: z });
|
|
102
|
+
aPhase[i] = Math.random() * 6;
|
|
103
|
+
aClip[i] = this.ci.idle ?? 0;
|
|
104
|
+
const b = 0.85 + Math.random() * 0.3;
|
|
105
|
+
aTint[i * 3] = b * tint[0]; aTint[i * 3 + 1] = b * tint[1]; aTint[i * 3 + 2] = b * tint[2];
|
|
106
|
+
this._writeMatrix(i, this.inst[i]);
|
|
107
|
+
}
|
|
108
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
109
|
+
|
|
110
|
+
// THE BOUNDING SPHERE, AND WHAT ACTUALLY KEEPS IT HONEST. An InstancedMesh with a null
|
|
111
|
+
// boundingSphere recomputes it from every instance matrix; set one explicitly and three TRUSTS
|
|
112
|
+
// it. This comment used to claim the AI could not leave the sphere because "roam is the leash" —
|
|
113
|
+
// and roam bounds the WALK TARGET, while the flee branch had no bound at all. A deer (runSpeed 7,
|
|
114
|
+
// fleeR 16) leaves this sphere in ten seconds of being chased, and then the whole herd blinks out
|
|
115
|
+
// the moment its anchor goes off-screen. The LEASH above is what makes the claim true.
|
|
116
|
+
// The Y span is generous — these things stand on rolling ground, and a sphere that clips its own
|
|
117
|
+
// herd off the bottom of the screen is worse than one a few metres too big.
|
|
118
|
+
const R = Math.max(roam, scatter) + 14;
|
|
119
|
+
const ay = game.world?.groundAt?.(anchor.x, anchor.z) ?? 0;
|
|
120
|
+
this.mesh.boundingSphere = new THREE.Sphere(new THREE.Vector3(anchor.x, ay + 2, anchor.z), R + 12);
|
|
121
|
+
this.mesh.frustumCulled = true;
|
|
122
|
+
// ...and the AI gate. Past this there is nothing to see: fog closes at 300 m on high (60–300 ×
|
|
123
|
+
// fogScale) and the camera's far plane is 420 on medium. 260 m + the herd's own extent means an
|
|
124
|
+
// animal is never frozen while it is on screen.
|
|
125
|
+
this.cullR = R + 260;
|
|
126
|
+
this._cull2 = this.cullR * this.cullR;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// GROUND AN ANIMAL MAY STAND ON. The same clearances the scatter respects (zones.js: 8 m off a
|
|
130
|
+
// road, 14 m off the rails) — a cow in the six-foot is a cow the 16 m/s ore train goes through.
|
|
131
|
+
// NO COWS IN THE CORN. The roads and the rails were already here; the PLOUGHED GROUND is new, and
|
|
132
|
+
// it is the same kind of rule — a wander target is not a place an animal would actually go.
|
|
133
|
+
// The farmyard herds sit ~34 m off their pin with 8–12 m of roam, and the fields' near edge is 32 m
|
|
134
|
+
// out, so their discs genuinely clip the crop: without this, Kessler's goats graze his wheat.
|
|
135
|
+
// It is the SAME `fieldK` the paint and the scatter read (world/data/biomes.js), so the corn the cow
|
|
136
|
+
// avoids is exactly the corn you can see — and it costs two compares for any animal that is not
|
|
137
|
+
// standing next to a farm, because fieldK keeps a bounding box over all twelve fields.
|
|
138
|
+
// (It does NOT know about the kitchen-garden beds inside a settlement's yard — those are props, not
|
|
139
|
+
// a mask. Those are kept safe by siting the herds clear of them; see animals.js on Ott's goats.)
|
|
140
|
+
// AND NO DEER STANDING IN THE RIVER. The water landed in P2 and three herd discs overlap a
|
|
141
|
+
// channel (measured: deer at (−500,780) and (340,−860)-adjacent country, foxes at (820,−1060) —
|
|
142
|
+
// up to 4 m INSIDE the wetted line). waterSurfaceAt is null on dry land — and null on the bridge
|
|
143
|
+
// deck's held plug too, the same P2 law the travellers' verifier reads, so a crossing that is
|
|
144
|
+
// legal to walk is legal to stand on.
|
|
145
|
+
_clearGround(x, z) {
|
|
146
|
+
return HT.roadDistance(x, z) > 8 && HT.railDistance(x, z) > 14 && HT.fieldK(x, z) < 0.25 &&
|
|
147
|
+
HT.waterSurfaceAt(x, z) == null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_writeMatrix(i, s) {
|
|
151
|
+
const g = this.game.world, e = 0.6;
|
|
152
|
+
s.y = g.groundAt(s.x, s.z) - this.footY;
|
|
153
|
+
// tilt to the slope, but only PITCH fully — lateral roll damped to 25 % so animals stay
|
|
154
|
+
// near-upright crossing a hillside (the legs compensate; this matches horse.js)
|
|
155
|
+
const gx = (g.groundAt(s.x + e, s.z) - g.groundAt(s.x - e, s.z)) / (2 * e);
|
|
156
|
+
const gz = (g.groundAt(s.x, s.z + e) - g.groundAt(s.x, s.z - e)) / (2 * e);
|
|
157
|
+
const fx = Math.sin(s.heading), fz = Math.cos(s.heading);
|
|
158
|
+
const rx = fz, rz = -fx;
|
|
159
|
+
const sF = gx * fx + gz * fz, sR = (gx * rx + gz * rz) * 0.25;
|
|
160
|
+
_nrm.set(-(fx * sF + rx * sR), 1, -(fz * sF + rz * sR)).normalize();
|
|
161
|
+
_fwd.set(Math.sin(s.heading), 0, Math.cos(s.heading));
|
|
162
|
+
_rgt.crossVectors(_nrm, _fwd).normalize();
|
|
163
|
+
_fwd.crossVectors(_rgt, _nrm).normalize();
|
|
164
|
+
_q.setFromRotationMatrix(_basis.makeBasis(_rgt, _nrm, _fwd));
|
|
165
|
+
this.mesh.setMatrixAt(i, _m.compose(_p.set(s.x, s.y, s.z), _q, this._scl));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_setClip(i, clip) { if (this.aClip[i] !== clip) { this.aClip[i] = clip; this._clipDirty = true; } }
|
|
169
|
+
|
|
170
|
+
// A ROUND TOOK HIM. Play the pack's death take from its FIRST frame — the take starts wherever
|
|
171
|
+
// (uTime + aPhase) lands, so the phase is rewritten to put frame 0 at NOW — then swap to the
|
|
172
|
+
// frozen `dead` still a couple of frames before the take would wrap back to standing.
|
|
173
|
+
kill(i) {
|
|
174
|
+
const s = this.inst[i];
|
|
175
|
+
if (!s || s.state === 'dying' || s.state === 'dead' || s.state === 'gone') return false;
|
|
176
|
+
if (this.ci.death != null && this._deathDur > 0) {
|
|
177
|
+
const uT = this.mat.userData.uTime.value, period = this._deathDur;
|
|
178
|
+
this.aPhase[i] = period * Math.ceil(uT / period + 1e-6) - uT;
|
|
179
|
+
this.geo.getAttribute('aPhase').needsUpdate = true;
|
|
180
|
+
this._setClip(i, this.ci.death);
|
|
181
|
+
this.geo.getAttribute('aClip').needsUpdate = true; // flush NOW — update()'s flush is gated on cullR
|
|
182
|
+
s.state = 'dying'; s.t = Math.max(0.1, this._deathDur - 2 / this._deathZ);
|
|
183
|
+
} else {
|
|
184
|
+
s.state = 'dead';
|
|
185
|
+
if (this.ci.dead != null) { this._setClip(i, this.ci.dead); this.geo.getAttribute('aClip').needsUpdate = true; }
|
|
186
|
+
}
|
|
187
|
+
// the SHOT SPOOKS THE HERD: prey scatters off the kill for a few seconds (update() widens the
|
|
188
|
+
// flee ring while this runs). Predators (fleeR 0 — the wolf) do not run from a dead packmate.
|
|
189
|
+
if (this.fleeR > 0) this._spookT = 4;
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Skinned: the carcass leaves the world. The instance slot stays (resident-for-life mesh), the
|
|
194
|
+
// body is buried under the ground at near-zero scale.
|
|
195
|
+
removeCarcass(i) {
|
|
196
|
+
const s = this.inst[i]; if (!s) return;
|
|
197
|
+
s.state = 'gone';
|
|
198
|
+
this.mesh.setMatrixAt(i, _m.compose(_p.set(s.x, s.y - 6, s.z), _q.identity(), _gone));
|
|
199
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
update(dt, playerPos) {
|
|
203
|
+
this.mat.userData.uTime.value += dt; // the VAT clock — cheap, and always: nothing pops on approach
|
|
204
|
+
// THE GATE. Everything below costs 5 heightAt per MOVING animal; none of it is worth a frame's
|
|
205
|
+
// work for a herd two kilometres away that is behind fog and outside the far plane anyway.
|
|
206
|
+
const dx0 = this.anchor.x - playerPos.x, dz0 = this.anchor.z - playerPos.z;
|
|
207
|
+
if (dx0 * dx0 + dz0 * dz0 > this._cull2) return;
|
|
208
|
+
|
|
209
|
+
// a fresh kill widens the flee ring: the whole herd breaks off the shot for a few seconds
|
|
210
|
+
const fleeR = (this._spookT ?? 0) > 0 ? Math.max(this.fleeR, 30) : this.fleeR;
|
|
211
|
+
if (this._spookT > 0) this._spookT -= dt;
|
|
212
|
+
|
|
213
|
+
let moved = false;
|
|
214
|
+
for (let i = 0; i < this.inst.length; i++) {
|
|
215
|
+
const s = this.inst[i];
|
|
216
|
+
// the dead don't flee. `dying` runs the death take down, then holds the frozen last frame.
|
|
217
|
+
if (s.state === 'dead' || s.state === 'gone') continue;
|
|
218
|
+
if (s.state === 'dying') {
|
|
219
|
+
s.t -= dt;
|
|
220
|
+
if (s.t <= 0) { s.state = 'dead'; if (this.ci.dead != null) this._setClip(i, this.ci.dead); }
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const dpx = s.x - playerPos.x, dpz = s.z - playerPos.z, pd = Math.hypot(dpx, dpz);
|
|
224
|
+
if (pd < fleeR) { // flee: run directly away from the player
|
|
225
|
+
s.heading = Math.atan2(dpx, dpz);
|
|
226
|
+
s.x += Math.sin(s.heading) * this.runSpeed * dt; s.z += Math.cos(s.heading) * this.runSpeed * dt;
|
|
227
|
+
// ...BUT NOT OUT OF THE COUNTY. The flee had NO leash: it ran directly away for as long as you
|
|
228
|
+
// stayed inside fleeR, so a deer (runSpeed 7, fleeR 16) leaves its herd's bounding sphere in
|
|
229
|
+
// TEN SECONDS of being chased — and three culls the whole InstancedMesh against that sphere.
|
|
230
|
+
// Turn so the anchor is off-screen and the entire herd blinks out while you are looking at it.
|
|
231
|
+
// The sphere is not the bug; the unbounded run is. Hold him inside the leash and the sphere
|
|
232
|
+
// stays honest, which is what lets the herd be frustum-culled at all.
|
|
233
|
+
const lx = s.x - this.anchor.x, lz = s.z - this.anchor.z;
|
|
234
|
+
const ld = Math.hypot(lx, lz);
|
|
235
|
+
if (ld > this.leash) { s.x = this.anchor.x + (lx / ld) * this.leash; s.z = this.anchor.z + (lz / ld) * this.leash; }
|
|
236
|
+
s.state = 'flee'; s.t = 1.5;
|
|
237
|
+
this._setClip(i, this.ci.run ?? this.ci.walk ?? 0);
|
|
238
|
+
this._writeMatrix(i, s); moved = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
s.t -= dt;
|
|
242
|
+
if (s.state === 'flee') { s.state = 'idle'; s.t = 1 + Math.random() * 2; this._setClip(i, this.ci.idle ?? 0); }
|
|
243
|
+
else if (s.state === 'walk') {
|
|
244
|
+
const dx = s.tx - s.x, dz = s.tz - s.z, dd = Math.hypot(dx, dz);
|
|
245
|
+
if (dd > 0.4 && s.t > 0) {
|
|
246
|
+
s.heading = Math.atan2(dx, dz);
|
|
247
|
+
s.x += (dx / dd) * this.speed * dt; s.z += (dz / dd) * this.speed * dt;
|
|
248
|
+
this._writeMatrix(i, s); moved = true; // ← the ONLY place the ground is sampled in the steady state
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
s.state = 'idle'; s.t = 2 + Math.random() * 3; this._setClip(i, this.ci.idle ?? 0);
|
|
252
|
+
}
|
|
253
|
+
if (s.t <= 0 && s.state !== 'walk') { // pick a new ambient action
|
|
254
|
+
const r = Math.random();
|
|
255
|
+
if (r < 0.45) { s.state = 'idle'; s.t = 2 + Math.random() * 3; this._setClip(i, this.ci.idle ?? 0); }
|
|
256
|
+
else if (r < 0.72 && this.ci.graze != null) { s.state = 'graze'; s.t = 3 + Math.random() * 4; this._setClip(i, this.ci.graze); }
|
|
257
|
+
else {
|
|
258
|
+
s.state = 'walk'; s.t = 4 + Math.random() * 5;
|
|
259
|
+
// A COW DOES NOT GRAZE ON THE CARRIAGEWAY. The anchor was checked against the roads and the
|
|
260
|
+
// rails; the ROAM DISC was not — and the animals do not stand on the anchor. Measured: 51 of
|
|
261
|
+
// the county's 152 head could walk into a road, and sixteen cattle could walk onto the RAILS,
|
|
262
|
+
// which the coaches and the trains come down. So the target is drawn, and then it has to pass:
|
|
263
|
+
// a few tries, and if the ground is no good he stays where he is and eats. Cheap, because this
|
|
264
|
+
// runs once every 4-9 seconds per animal, not per frame.
|
|
265
|
+
for (let tries = 0; tries < 6; tries++) {
|
|
266
|
+
const a = Math.random() * Math.PI * 2, d = 2 + Math.random() * this.roam;
|
|
267
|
+
const tx = this.anchor.x + Math.cos(a) * d, tz = this.anchor.z + Math.sin(a) * d;
|
|
268
|
+
if (this._clearGround(tx, tz) || tries === 5) { s.tx = tx; s.tz = tz; break; }
|
|
269
|
+
}
|
|
270
|
+
this._setClip(i, this.ci.walk ?? 0);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (moved) this.mesh.instanceMatrix.needsUpdate = true;
|
|
275
|
+
if (this._clipDirty) { this.geo.getAttribute('aClip').needsUpdate = true; this._clipDirty = false; }
|
|
276
|
+
}
|
|
277
|
+
}
|