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.
Files changed (65) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/quality.md +159 -0
  10. package/docs/api/renderer.md +115 -0
  11. package/docs/api/retarget.md +149 -0
  12. package/docs/api/shatter.md +121 -0
  13. package/docs/api/utils-decals.md +188 -0
  14. package/docs/api/water.md +143 -0
  15. package/docs/api/wind-weather-uniforms.md +105 -0
  16. package/docs/contracts/render.md +32 -0
  17. package/docs/contracts/time-feel.md +52 -0
  18. package/package.json +1 -1
  19. package/src/core/anim.js +184 -0
  20. package/src/core/assets.js +596 -0
  21. package/src/core/engine.js +284 -0
  22. package/src/core/input/bindings.js +30 -0
  23. package/src/core/input/gamepad.js +92 -0
  24. package/src/core/input/index.js +62 -0
  25. package/src/core/input/keyboard.js +16 -0
  26. package/src/core/input/mouse.js +48 -0
  27. package/src/core/quality.js +5 -1
  28. package/src/core/renderer.js +104 -0
  29. package/src/core/retarget.js +282 -0
  30. package/src/index.js +19 -2
  31. package/src/systems/aim.js +114 -0
  32. package/src/systems/campfire.js +150 -0
  33. package/src/systems/climbing.js +136 -0
  34. package/src/systems/deadeye.js +332 -0
  35. package/src/systems/encounters.js +123 -0
  36. package/src/systems/entity.js +524 -0
  37. package/src/systems/fistCurl.js +38 -0
  38. package/src/systems/footsteps.js +161 -0
  39. package/src/systems/glass.js +67 -0
  40. package/src/systems/herd.js +277 -0
  41. package/src/systems/horse.js +361 -0
  42. package/src/systems/mountedTraveller.js +518 -0
  43. package/src/systems/nav.js +343 -0
  44. package/src/systems/npc.js +880 -0
  45. package/src/systems/pathFollow.js +107 -0
  46. package/src/systems/platform.js +484 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/seatedRider.js +396 -0
  49. package/src/systems/skybirds.js +129 -0
  50. package/src/systems/trainCamera.js +414 -0
  51. package/src/systems/traveller.js +254 -0
  52. package/src/systems/tumbleweeds.js +92 -0
  53. package/src/systems/vat.js +327 -0
  54. package/src/systems/vfx.js +472 -0
  55. package/src/world/blobShadows.js +109 -0
  56. package/src/world/clouds.js +61 -0
  57. package/src/world/flora.js +87 -0
  58. package/src/world/footprints.js +117 -0
  59. package/src/world/lampField.js +58 -0
  60. package/src/world/lampGlow.js +92 -0
  61. package/src/world/scatter.js +1077 -0
  62. package/src/world/sky.js +363 -0
  63. package/src/world/tileManager.js +197 -0
  64. package/src/world/walkHumps.js +74 -0
  65. package/src/world/weather.js +312 -0
@@ -0,0 +1,396 @@
1
+ // A MAN SAT ON SOMETHING — the driver on the box, the guard beside him, the fares inside.
2
+ //
3
+ // All of them are the same problem: take a standing western body, sit it down, and put its boots
4
+ // and its fists WHERE THIS PARTICULAR SEAT PUTS THEM. So there is one class, tuned once in
5
+ // /coachtest.html, and everybody who sits reuses it verbatim — the mate on the box is the driver
6
+ // with a different x, and a passenger is the driver with his hands in his lap.
7
+ //
8
+ // TWO THINGS THAT COST ME A DAY, WRITTEN DOWN SO NOBODY PAYS AGAIN:
9
+ //
10
+ // 1. THE TARGETS ARE IN THE RIDER'S OWN SPACE, NOT THE VEHICLE'S. Author them against the coach
11
+ // and they are one man's numbers: the fellow sat beside him reaches for thin air, and a fare
12
+ // facing backwards reaches through the wall. Against his own pivot they are a POSTURE — "fists
13
+ // forward and a little up, boots forward and down" — and a posture is portable. That is what
14
+ // lets the same six numbers seat every man on the vehicle.
15
+ //
16
+ // 2. A TARGET OUT OF REACH IS NOT A POSE, IT IS A POINTED STICK. twoBoneIK cannot put a hand where
17
+ // an arm does not go; it straightens the limb and AIMS it. Two foot targets 0.36m apart, seen
18
+ // from a hip 1.5m away (a leg is 0.84m), differ by 13 degrees — so both boots land in nearly
19
+ // the same place and every width slider in the world does nothing. That was the "feet are
20
+ // connecting to each other" bug, and it is why the reach clamp below is not an optimisation.
21
+ import * as THREE from 'three/webgpu';
22
+ import { instantiate } from '../core/assets.js';
23
+ import { makeSeatedPose, twoBoneIK, BIPED_TO_SYNTY } from './riding.js';
24
+ // Seat posture numbers are GAME data (tuned in /coachtest.html) — register at boot,
25
+ // BEFORE any rider is seated: setSeatRig(coachRigJson). SEAT/LEGS are live bindings:
26
+ // importers see the registered values the moment registration runs.
27
+ const SEAT_FALLBACK = {
28
+ reins: { x: 0, y: 1.08, z: 0.42 },
29
+ feet: { x: 0, y: 0.18, z: 0.40 },
30
+ lap: { x: 0, y: 0.93, z: 0.22 },
31
+ width: { hands: 0.28, feet: 0.34, knees: 0.16 },
32
+ };
33
+ const LEGS_FALLBACK = { hip: 1.35, knee: 1.45, ankle: 0.25, spread: 0.12 };
34
+ export function setSeatRig(rig) {
35
+ SEAT = rig?.seat ?? SEAT_FALLBACK;
36
+ LEGS = rig?.seat?.legs ?? LEGS_FALLBACK;
37
+ }
38
+
39
+ // The seat posture, in the rider's own space. Authored in /coachtest.html; the fallback
40
+ // is only for a rig file that predates it.
41
+ export let SEAT = SEAT_FALLBACK;
42
+
43
+ // A PASSENGER IS THE DRIVER WITH HIS HANDS DOWN. Only the man holding the reins holds the reins;
44
+ // the mate beside him and the fares inside rest their hands in their laps. Same seat, same boots,
45
+ // same widths — one different hand target. Sharing the feet/width objects BY REFERENCE is
46
+ // deliberate: tune the boots once in the bench and every man on the coach moves with them.
47
+ // The hand-posed bones, split by WHO they belong to. Legs and spine are the same man sitting down,
48
+ // whoever he is — but the driver's arms are on the reins and everyone else's are in their lap, so
49
+ // those two sets can never be shared. Merged here, once, into the flat map solve() applies.
50
+ export function driverFk(p = SEAT) {
51
+ const f = p.fk ?? {};
52
+ return { ...(f.legs ?? f), ...(f.spine ?? {}), ...(f.driverArms ?? {}) };
53
+ }
54
+ export function paxFk(p = SEAT) {
55
+ const f = p.fk ?? {};
56
+ return { ...(f.legs ?? f), ...(f.spine ?? {}), ...(f.paxArms ?? {}) };
57
+ }
58
+
59
+ export function passengerPosture(p = SEAT) {
60
+ return { reins: p.lap ?? p.reins, feet: p.feet, width: p.width, handRot: p.lapRot, legs: p.legs, fk: paxFk(p) };
61
+ }
62
+ export function driverPosture(p = SEAT) {
63
+ return { reins: p.reins, feet: p.feet, width: p.width, handRot: p.reinRot, legs: p.legs, fk: driverFk(p) };
64
+ }
65
+ // A DEAD MAN DOES NOT HOLD THE REINS. The hand-posed driver arms (fk.driverArms) are a GRIP —
66
+ // leave them on and a corpse drives the coach with his fists up, which is the single most
67
+ // ridiculous thing this file could ship. So a dead rider wears the passenger arms with the target
68
+ // dropped below his lap and splayed: the reins fall out of his hands and his arms hang. Solved
69
+ // ONCE at the moment of death, then frozen (see SeatedRider.slump).
70
+ export function limpPosture(p = SEAT) {
71
+ const pax = passengerPosture(p);
72
+ const lap = pax.reins ?? { x: 0, y: 0.93, z: 0.22 };
73
+ return {
74
+ ...pax,
75
+ reins: { x: lap.x ?? 0, y: (lap.y ?? 0.93) - 0.26, z: (lap.z ?? 0.22) - 0.06 },
76
+ width: { ...(p.width ?? {}), hands: (p.width?.hands ?? 0.28) + 0.16 },
77
+ };
78
+ }
79
+
80
+ // WHERE HIS FEET WOULD BE IF HE STOOD UP — the datum combat.js measures a head from.
81
+ //
82
+ // combat.js calls a round a headshot when hitY = bulletY - position.y >= HEAD_Y (1.5), and scores
83
+ // the hit VFX at position.y + 1.62. Both numbers assume a STANDING body: feet on the ground, head
84
+ // at ~1.56 (measured off the player's own Head bone). A seat pivot is NOT that datum, and you
85
+ // cannot eyeball what it is: the seated pose only ROTATES bones, so this man's hips stay at their
86
+ // standing height above the model origin and his folded boots end up ABOVE it — on the driver's
87
+ // box, his head is 1.556 above his pivot and his ankles are +0.522. Feed combat the seat height
88
+ // and his whole body sits 1.5m over the line: every round through the driver's hat scored as a
89
+ // HEADSHOT, and so did one through his boot.
90
+ //
91
+ // So ask HIS OWN HEAD BONE where the ground under him would be (bodies and seats differ; the
92
+ // driver's answer is not the backward-facing fare's) and publish that. Then HEAD_Y lands on his
93
+ // collar and 1.62 lands exactly on his head bone, and every number in combat.js means what it
94
+ // says. Measured, never guessed — the clamp below is a seatbelt, not a value.
95
+ const STAND_HEAD = 1.62;
96
+ const SLUMP_SECS = 0.6; // how long he takes to fold. A body falls; it does not lie down.
97
+ const SLUMP_PITCH = 1.2; // radians forward, over his own knees (his own axes — the model, not the pivot)
98
+ const _hp = new THREE.Vector3(), _pp = new THREE.Vector3();
99
+
100
+ const _t = new THREE.Vector3(), _l = new THREE.Vector3(), _r = new THREE.Vector3();
101
+ const _root = new THREE.Vector3(), _mid = new THREE.Vector3(), _end = new THREE.Vector3();
102
+ const _axis = new THREE.Vector3(), _q = new THREE.Quaternion(), _qp = new THREE.Quaternion();
103
+ const _qi = new THREE.Quaternion();
104
+ const _e = new THREE.Euler(), _qh = new THREE.Quaternion();
105
+ const _cur = new THREE.Vector3(), _want = new THREE.Vector3(), _pole = new THREE.Vector3();
106
+
107
+ // Which way the joints break, in the rider's own space (+Z is the way he faces).
108
+ const ELBOW_POLE = new THREE.Vector3(0, -1, -0.35).normalize(); // elbow down and back
109
+ const _lx = new THREE.Vector3(), _lz = new THREE.Vector3(), _qw = new THREE.Quaternion();
110
+
111
+ // A seated leg, in degrees-you-can-picture (stored as radians). Tuned in /coachtest.html.
112
+ export let LEGS = LEGS_FALLBACK;
113
+
114
+ // Turn a bone about an axis given in WORLD space, leaving its parent alone.
115
+ function turn(bone, worldAxis, angle) {
116
+ if (!bone || Math.abs(angle) < 1e-5) return;
117
+ _q.setFromAxisAngle(worldAxis, angle);
118
+ bone.parent.getWorldQuaternion(_qp);
119
+ _qi.copy(_qp).invert();
120
+ bone.quaternion.premultiply(_qp).premultiply(_q).premultiply(_qi);
121
+ bone.updateWorldMatrix(false, true);
122
+ }
123
+
124
+ // Pull a target inside the limb's reach. Reach is measured from the CURRENT pose (bones move), and
125
+ // 0.95 of it, never 1.0 — at full stretch the limb is straight, the knee has NO bend plane to
126
+ // speak of, and the two cross-products twoBoneIK builds it from are cross-products of parallel
127
+ // vectors: pure noise. Rotate such a rider 180 degrees and the noise lands the other way, which is
128
+ // precisely how the two backward-facing fares ended up with their knees on backwards.
129
+ function clampReach(root, mid, end, target) {
130
+ root.getWorldPosition(_root); mid.getWorldPosition(_mid); end.getWorldPosition(_end);
131
+ const reach = (_root.distanceTo(_mid) + _mid.distanceTo(_end)) * 0.95;
132
+ const d = _root.distanceTo(target);
133
+ if (d > reach && d > 1e-6) target.copy(_root).addScaledVector(_t.copy(target).sub(_root).divideScalar(d), reach);
134
+ return target;
135
+ }
136
+
137
+ // POINT THE KNEE. twoBoneIK gets the foot to the target but says nothing about WHICH WAY the knee
138
+ // bends — the bend plane falls out of whatever cross-products the current pose happens to produce,
139
+ // and near full extension those are cross-products of near-parallel vectors, i.e. noise. So don't
140
+ // leave it to chance: spin the whole limb about the hip→foot line (the one axis the foot doesn't
141
+ // care about — it stays exactly where the IK put it) until the knee points where we SAY it points.
142
+ //
143
+ // `poleWorld` is the direction the knee should aim, in world space. `extra` is the knee-width
144
+ // splay, added on top. This is what makes a rotated rider bend the same way as an unrotated one:
145
+ // the pole is HIS forward, so it turns with him.
146
+ function aimPole(upper, mid, end, poleWorld, extra) {
147
+ if (!upper || !mid || !end) return;
148
+ upper.getWorldPosition(_root); mid.getWorldPosition(_mid); end.getWorldPosition(_end);
149
+ _axis.copy(_end).sub(_root);
150
+ if (_axis.lengthSq() < 1e-8) return;
151
+ _axis.normalize();
152
+ // where the knee points now, and where we want it — both flattened onto the plane the hinge
153
+ // sweeps (i.e. with the component along the hip→foot line removed)
154
+ _cur.copy(_mid).sub(_root).addScaledVector(_axis, -_mid.clone().sub(_root).dot(_axis));
155
+ _want.copy(poleWorld).addScaledVector(_axis, -poleWorld.dot(_axis));
156
+ if (_cur.lengthSq() < 1e-8 || _want.lengthSq() < 1e-8) return;
157
+ _cur.normalize(); _want.normalize();
158
+ let ang = Math.acos(Math.max(-1, Math.min(1, _cur.dot(_want))));
159
+ if (_cur.clone().cross(_want).dot(_axis) < 0) ang = -ang; // signed, about the hinge axis
160
+ ang += extra || 0;
161
+ if (Math.abs(ang) < 1e-4) return;
162
+ _q.setFromAxisAngle(_axis, ang);
163
+ upper.parent.getWorldQuaternion(_qp);
164
+ _qi.copy(_qp).invert();
165
+ upper.quaternion.premultiply(_qp).premultiply(_q).premultiply(_qi);
166
+ upper.updateWorldMatrix(false, true);
167
+ }
168
+
169
+ export class SeatedRider {
170
+ // parent: what he is sitting IN (the coach root). seat: {x,y,z,ry} in that parent's space.
171
+ static async create(parent, def, seat, posture = SEAT) {
172
+ const model = await instantiate(def.src, { keep: def.keep, texture: def.tex, scale: 0.01 });
173
+ model.scale.setScalar(0.01);
174
+ const pivot = new THREE.Group();
175
+ pivot.position.set(seat.x, seat.y, seat.z);
176
+ // BAKE THE POSE FACING FORWARD, TURN HIM AFTERWARDS.
177
+ // makeSeatedPose reads the body through its WORLD matrix, so a rider who is already spun to
178
+ // face backwards bakes his own heading into the pose — the identical disease that made the
179
+ // townsfolk come out contorted (retarget.js captureBind). Two men, same seat data, different
180
+ // poses, and no amount of tuning reconciles them: one of them is wearing a rotation. So he
181
+ // faces +Z while the pose is taken, and only then does he turn round to face his seat.
182
+ pivot.rotation.y = 0;
183
+ pivot.add(model);
184
+ parent.add(pivot);
185
+ pivot.updateWorldMatrix(true, true);
186
+ const r = new SeatedRider(pivot, model, posture);
187
+ r.def = def; // who he is, for anything that wants to name him
188
+ try {
189
+ const { pose, pbone } = await makeSeatedPose(model, BIPED_TO_SYNTY);
190
+ // the STANDING bind of the leg chain — the angles below are measured from a man stood up
191
+ for (const n of ['UpperLeg_L', 'LowerLeg_L', 'Ankle_L', 'UpperLeg_R', 'LowerLeg_R', 'Ankle_R']) {
192
+ if (pbone[n]) r.bind[n] = pbone[n].quaternion.clone();
193
+ }
194
+ for (const [n, q] of Object.entries(pose)) pbone[n]?.quaternion.copy(q);
195
+ r.bones = pbone;
196
+ r.pose = pose;
197
+ r._measureSide(); // ask THIS body which side its "_L" bones are on
198
+ } catch (e) { console.warn('[seat] seated pose failed — he stands', e); }
199
+ pivot.rotation.y = seat.ry ?? 0; // NOW he can turn round
200
+ pivot.updateWorldMatrix(true, true);
201
+ // NO SOLVE HERE. The caller may still be seeding the posture from this very body (the bench
202
+ // does exactly that), and solving against a half-filled posture aims his limbs at his own
203
+ // origin. Everyone who owns a rider solves him every frame anyway.
204
+ return r;
205
+ }
206
+
207
+ // HATS OFF INSIDE. A Synty body wears its hat as part of the one mesh, so there is no hat node to
208
+ // switch off — but the hat has its OWN BONE, and a bone scaled to nothing takes its vertices with
209
+ // it. Costs no draw call, no material, no rebuild: the hat simply stops existing.
210
+ hat(on) {
211
+ for (const n of ['Hat', 'Head_Hat', 'Attach_Hat', 'Hat_01']) {
212
+ const b = this.bones?.[n];
213
+ if (b) { b.scale.setScalar(on ? 1 : 1e-4); return true; }
214
+ }
215
+ return false;
216
+ }
217
+
218
+ constructor(pivot, model, posture) {
219
+ this.pivot = pivot;
220
+ this.model = model;
221
+ this.posture = posture;
222
+ this.bones = null;
223
+ this.pose = null;
224
+ this.side = 1; // +1 when the bones named _L live at +x (set by _measureSide)
225
+ this.bind = {}; // the standing leg chain — what the leg angles are measured from
226
+ this.baseRot = model.rotation.clone(); // what "sitting up straight" is — the slump is measured from it
227
+
228
+ // ---- THE SHOOTABLE CONTRACT (combat.js tests these exactly as it tests an outlaw) --------
229
+ // He is a child of a moving coach and has no world position of his own, so he is GIVEN one:
230
+ // a single Vector3, refreshed every frame by Coach._syncRiders (no allocation, no matrix
231
+ // walk — the same rotate-the-offset arithmetic the horse colliders already use).
232
+ this.position = new THREE.Vector3();
233
+ this.feetDrop = 0.06; // pivot → standing datum; re-measured off his own head bone on the first solve
234
+ this.civilian = true; // a fare is not an outlaw — shooting him is a crime
235
+ this.alive = true;
236
+ this.maxHealth = 40; // he is a passenger, not a gunfighter: one good round does it
237
+ this.health = 40;
238
+ this.slumpT = -1; // -1 = upright; 0..1 = folding over
239
+ this.game = null; // set by the coach that owns him
240
+ this.coach = null;
241
+ this.role = 'fare'; // 'driver' | 'mate' | 'fare' — decides the purse and who bolts the team
242
+ }
243
+
244
+ // He takes a hit the same way everyone else does — minus the parts a man bolted to a bench
245
+ // cannot do. `opts.knockback` is deliberately IGNORED: a coach at a canter is not the place to
246
+ // shove a body 0.35m sideways through the box he is sitting in. He absorbs it and he folds.
247
+ takeDamage(amount, fromPos = null, opts = {}) {
248
+ if (!this.alive) return;
249
+ this.health -= amount;
250
+ this.game?.audio?.play('hurt');
251
+ if (this.health <= 0) this.die(opts.attacker ?? null);
252
+ }
253
+
254
+ die(killer = null) {
255
+ if (!this.alive) return;
256
+ this.alive = false;
257
+ this.posture = limpPosture(SEAT); // the reins fall out of his hands...
258
+ this.solve(); // ...one last solve to put them there, and then never again
259
+ this.slumpT = 0; // ...and he starts to go over
260
+ this._slumpRoll = (Math.random() < 0.5 ? -1 : 1) * (0.18 + Math.random() * 0.25);
261
+ this.coach?._crewDown(this, killer);
262
+ }
263
+
264
+ // The fold. Runs INSTEAD of solve() once he's dead: the bones stay exactly where that last limp
265
+ // solve left them and the whole man tips over his own hips (the model's origin), in HIS OWN axes
266
+ // — so a backwards-facing fare falls forward onto his own knees and not out through the roof.
267
+ slump(dt) {
268
+ if (this.slumpT >= 1) return; // down, and staying down — nothing left to do per frame
269
+ this.slumpT = Math.min(1, this.slumpT + dt / SLUMP_SECS);
270
+ const k = this.slumpT * this.slumpT * (3 - 2 * this.slumpT); // ease — the fall accelerates then settles
271
+ this.model.rotation.x = this.baseRot.x + k * SLUMP_PITCH;
272
+ this.model.rotation.z = this.baseRot.z + k * (this._slumpRoll ?? 0.2);
273
+ this.model.position.y = -k * 0.06; // and he sinks into the bench as he goes
274
+ }
275
+
276
+ // Measure the standing datum off his own head bone, once, from the SOLVED pose (a bone read
277
+ // before the pose is applied is a bone in the T-pose, and a T-posed head is not where his head
278
+ // is). Measured on the box: head +1.556 above the pivot → a drop of 0.06, i.e. the datum lands a
279
+ // whisker under the seat pivot. The clamp is a seatbelt for a body that hands us a nonsense head
280
+ // (or none): it must never let a rider publish a datum that makes his boots a headshot.
281
+ _measureDatum() {
282
+ const head = this.bones?.Head ?? this.bones?.Neck;
283
+ if (!head) { this._datum = true; return; }
284
+ head.getWorldPosition(_hp);
285
+ this.pivot.getWorldPosition(_pp);
286
+ this.feetDrop = Math.max(-0.35, Math.min(1.35, STAND_HEAD - (_hp.y - _pp.y)));
287
+ this._datum = true;
288
+ }
289
+
290
+ // WHICH SIDE IS HIS LEFT? Do not assume. In this rig the bones NAMED _L sit at POSITIVE x in the
291
+ // body's own space (Hand_L x = +0.58, Hand_R x = -0.63) — the opposite of the convention I'd have
292
+ // bet on. Assume it and every left limb gets sent to his right: the arms and legs cross, which is
293
+ // exactly what they did. So ask the skeleton, once, and let the body tell you which way it is
294
+ // built. Measured off the RAW seated pose, before any IK has had a chance to move anything.
295
+ _measureSide() {
296
+ const B = this.bones;
297
+ if (!B.Hand_L || !B.Hand_R) { this.side = 1; return; }
298
+ this.model.updateWorldMatrix(true, true);
299
+ const xl = this.pivot.worldToLocal(B.Hand_L.getWorldPosition(new THREE.Vector3())).x;
300
+ const xr = this.pivot.worldToLocal(B.Hand_R.getWorldPosition(new THREE.Vector3())).x;
301
+ this.side = xl >= xr ? 1 : -1; // +1 when "_L" means +x
302
+ }
303
+
304
+ // One limb: reset is the caller's job (see solve). lr is 'L' or 'R'; the actual sign comes from
305
+ // the body (_measureSide), never from a guess about the rig's handedness.
306
+ _limb(a, b, c, localTarget, lr, width, poleLocal, splayAngle) {
307
+ const B = this.bones;
308
+ if (!B[a] || !B[b] || !B[c]) return;
309
+ const side = (lr === 'L' ? 1 : -1) * this.side;
310
+ _l.set(localTarget.x + side * width / 2, localTarget.y, localTarget.z);
311
+ this.pivot.localToWorld(_l);
312
+ clampReach(B[a], B[b], B[c], _l);
313
+ twoBoneIK(B[a], B[b], B[c], _l);
314
+ // the pole is given in HIS space and rotated into the world, so a fare facing backwards bends
315
+ // his knees toward HIS front, not toward the coach's
316
+ _pole.copy(poleLocal).applyQuaternion(this.pivot.getWorldQuaternion(_qh));
317
+ aimPole(B[a], B[b], B[c], _pole, side * (splayAngle || 0));
318
+ }
319
+
320
+ // Roll/pitch/yaw both wrists, mirrored. Pitch (x) is the same on both hands; yaw and roll flip,
321
+ // because a left hand and a right hand are reflections, not copies.
322
+ _wrists(r) {
323
+ for (const [n, m] of [['Hand_L', this.side], ['Hand_R', -this.side]]) {
324
+ const b = this.bones[n];
325
+ if (!b) continue;
326
+ _e.set(r.x ?? 0, (r.y ?? 0) * m, (r.z ?? 0) * m);
327
+ b.quaternion.multiply(_qh.setFromEuler(_e));
328
+ }
329
+ }
330
+
331
+ // THE LEGS ARE POSED, NOT SOLVED.
332
+ //
333
+ // I tried to place the boots with an IK target and it was the wrong tool. IK gives you the FOOT
334
+ // and infers everything else — which knee bend, which way the shin twists, where the toe ends up
335
+ // pointing. Near full extension that inference is unstable (cross-products of near-parallel
336
+ // vectors: noise), so the leg juddered as you dragged the target, and because the noise is in
337
+ // WORLD space, a man facing backwards inferred a different answer and sat there with his feet on
338
+ // backwards. You cannot hand-tune your way out of that; the instability is in the method.
339
+ //
340
+ // A seated leg is four angles and every animator on earth knows what they are: how far the thigh
341
+ // comes up, how far the knee folds, where the foot points, how far the knees are apart. So say
342
+ // them. Applied to the STANDING bind (legs straight down), not to the horse-sit, so the numbers
343
+ // mean what they say — and applied in the RIDER'S OWN axes, so a backwards-facing fare bends his
344
+ // knees toward HIS front. Deterministic: same input, same pose, every frame, every heading.
345
+ _legs(L) {
346
+ const B = this.bones, P = this.pivot;
347
+ P.getWorldQuaternion(_qw);
348
+ const X = _lx.set(1, 0, 0).applyQuaternion(_qw); // his lateral axis: pitch and knee-fold turn about this
349
+ const Z = _lz.set(0, 0, 1).applyQuaternion(_qw); // his forward axis: the knees splay about this
350
+ for (const lr of ['L', 'R']) {
351
+ const s = (lr === 'L' ? 1 : -1) * this.side;
352
+ const up = B[`UpperLeg_${lr}`], lo = B[`LowerLeg_${lr}`], an = B[`Ankle_${lr}`];
353
+ if (!up || !lo) continue;
354
+ // back to STANDING for the leg chain — the seated pose above is for his spine and arms
355
+ for (const b of [up, lo, an]) if (b && this.bind[b.name]) b.quaternion.copy(this.bind[b.name]);
356
+ up.updateWorldMatrix(false, true);
357
+ turn(up, Z, -s * L.spread); // knees apart
358
+ turn(up, X, -L.hip); // thigh forward and up
359
+ turn(lo, X, L.knee); // knee folds, shin drops
360
+ if (an) turn(an, X, L.ankle); // and the boot sits flat on the board
361
+ }
362
+ }
363
+
364
+ // Call every frame the rider is visible. Cheap: four two-bone solves, no mixer.
365
+ solve() {
366
+ if (!this.bones || !this.pose) return;
367
+ // RE-SEAT THE BASE POSE FIRST, EVERY TIME. twoBoneIK rotates a bone from wherever it already
368
+ // is, so solving on top of yesterday's solve compounds — measured at 0.22m of drift per frame,
369
+ // which is a man shaking himself to pieces. From a fixed start it is dead still (0.0000m).
370
+ for (const [n, q] of Object.entries(this.pose)) this.bones[n]?.quaternion.copy(q);
371
+ this.model.updateWorldMatrix(true, true);
372
+ const P = this.posture;
373
+ // legs first — the arms hang off the spine, which hangs off the hips
374
+ this._legs(P.legs ?? LEGS);
375
+ this.model.updateWorldMatrix(true, true);
376
+ this._limb('Shoulder_L', 'Elbow_L', 'Hand_L', P.reins, 'L', P.width.hands, ELBOW_POLE, 0);
377
+ this._limb('Shoulder_R', 'Elbow_R', 'Hand_R', P.reins, 'R', P.width.hands, ELBOW_POLE, 0);
378
+ // HAND-POSED BONES WIN. If someone sat in the bench and turned a knee until it looked right,
379
+ // that is the answer — no solver gets to overrule it. This is the escape hatch from every
380
+ // clever thing above, and it is applied last precisely so it cannot be argued with.
381
+ if (P.fk) {
382
+ for (const [n, q] of Object.entries(P.fk)) {
383
+ const b = this.bones[n];
384
+ if (b) b.quaternion.set(q.x ?? q[0], q.y ?? q[1], q.z ?? q[2], q.w ?? q[3]);
385
+ }
386
+ this.model.updateWorldMatrix(true, true);
387
+ }
388
+ // AIM THE WRISTS. twoBoneIK puts the hand WHERE you asked and leaves it pointing wherever the
389
+ // arm happened to leave it — which is why a man resting his hands in his lap can end up palms-
390
+ // up like he's begging. Turn the wrist afterwards, mirrored about the body's centreline so the
391
+ // two hands stay a pair rather than both rolling the same way.
392
+ if (P.handRot) this._wrists(P.handRot);
393
+ // ...and now that he is actually SITTING, ask him where his head is (once).
394
+ if (!this._datum) this._measureDatum();
395
+ }
396
+ }
@@ -0,0 +1,129 @@
1
+ // Sky birds: one flock = ONE VAT InstancedMesh (like herd.js, but airborne). Each bird flies its own
2
+ // slow orbit around the flock's centre, banking into the turn and mixing flap and glide. Resident for
3
+ // life, one draw call per flock.
4
+ //
5
+ // Nick asked for eagles by name, so these are eagles — the pack ships Eagle_Flying and a perched
6
+ // Eagle_Idle, and the badlands are the right country for them. NO SEAGULLS: this county is eight
7
+ // hundred miles from the sea, and fable's flock (which is where this is ported from) is a gull flock.
8
+ //
9
+ // TWO DIFFERENCES FROM FABLE'S:
10
+ // • THE FLOCK CAN BE ANCHORED. Fable's flock centre drifts after the player forever, so there are
11
+ // always birds overhead. That is right for a coastal town and wrong for a county: an eagle that
12
+ // follows you into the saloon is not an eagle. An `over: {x,z}` flock circles ITS OWN ground —
13
+ // the badlands, the quarry — and you ride to it. The drifting kind is kept for the one flock that
14
+ // should be everywhere (a pair of high birds, the kind you always see in an empty sky).
15
+ // • The gate is the flock's own radius, not a hardcoded 400 m.
16
+ import * as THREE from 'three/webgpu';
17
+ import { vatMaterial } from './vat.js';
18
+
19
+ const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _p = new THREE.Vector3(), _e = new THREE.Euler();
20
+
21
+ export class Flock {
22
+ // `over: {x,z}` = circle THIS ground and stay there. Omit it and the flock trails the player.
23
+ constructor(game, species, count = 6, { alt = 40, spread = 60, over = null } = {}) {
24
+ this.game = game;
25
+ this.over = over;
26
+ this.scale = species.scale;
27
+ this.ci = species.clipIndex;
28
+ this._scl = new THREE.Vector3(this.scale, this.scale, this.scale);
29
+
30
+ const geo = species.geometry.clone();
31
+ const aClip = new Float32Array(count), aPhase = new Float32Array(count), aTint = new Float32Array(count * 3);
32
+ const aClipAttr = new THREE.InstancedBufferAttribute(aClip, 1);
33
+ aClipAttr.setUsage(THREE.DynamicDrawUsage);
34
+ geo.setAttribute('aClip', aClipAttr);
35
+ geo.setAttribute('aPhase', new THREE.InstancedBufferAttribute(aPhase, 1));
36
+ geo.setAttribute('aTint', new THREE.InstancedBufferAttribute(aTint, 3));
37
+ if (species.layers > 1) geo.setAttribute('aLayer', new THREE.InstancedBufferAttribute(new Float32Array(count), 1));
38
+ this.aClip = aClip; this._aClipAttr = aClipAttr;
39
+ this.mesh = new THREE.InstancedMesh(geo, vatMaterial(species.vat, species.albedo, species.layers), count);
40
+ this.mesh.name = 'flock:eagle';
41
+
42
+ this.centre = new THREE.Vector3();
43
+ this.alt = alt;
44
+ this.birds = [];
45
+ for (let i = 0; i < count; i++) {
46
+ this.birds.push({
47
+ r: spread * (0.35 + Math.random() * 0.65), // orbit radius
48
+ a: Math.random() * Math.PI * 2, // orbit angle
49
+ w: (0.05 + Math.random() * 0.06) * (Math.random() < 0.5 ? 1 : -1), // rad/s, either way — a raptor turns SLOWLY
50
+ h: alt * (0.7 + Math.random() * 0.6), // altitude above the ground under it
51
+ bob: Math.random() * Math.PI * 2,
52
+ });
53
+ aPhase[i] = Math.random() * 6;
54
+ // an eagle GLIDES; it does not flap its way across a county. Two in three are on the glide slot.
55
+ aClip[i] = Math.random() < 0.34 ? (this.ci.walk ?? 0) : (this.ci.idle ?? 0);
56
+ const b = 0.9 + Math.random() * 0.2;
57
+ aTint[i * 3] = b; aTint[i * 3 + 1] = b; aTint[i * 3 + 2] = b;
58
+ }
59
+ this._clipT = 1.5;
60
+
61
+ if (over) {
62
+ // an anchored flock never leaves its own sky — so it gets a real bounding sphere and is culled
63
+ // like anything else. A drifting one is always near the player by construction.
64
+ const gy = game.world?.groundAt?.(over.x, over.z) ?? 0;
65
+ this.mesh.boundingSphere = new THREE.Sphere(new THREE.Vector3(over.x, gy + alt, over.z), spread + alt + 30);
66
+ this.mesh.frustumCulled = true;
67
+ this.gate2 = (spread + 700) ** 2; // eagles are the one thing you are MEANT to see from far off
68
+ } else {
69
+ this.mesh.frustumCulled = false;
70
+ this.gate2 = Infinity;
71
+ }
72
+
73
+ // Place every instance in its sky NOW. An anchored flock's update() early-returns while the
74
+ // player is far (the gate) BEFORE it ever runs the placement loop — so without this its instances
75
+ // sit at the identity matrix, i.e. at (0,0,0), flapping in the ground, until you finally ride up
76
+ // to it (Nick: "birds at 0,0,0 in the floor"). Culling then hides them by their bounding sphere.
77
+ const cx = over?.x ?? 0, cz = over?.z ?? 0;
78
+ this.centre.set(cx, 0, cz);
79
+ for (let i = 0; i < count; i++) {
80
+ const b = this.birds[i];
81
+ const x = cx + Math.cos(b.a) * b.r, z = cz + Math.sin(b.a) * b.r;
82
+ const y = (game.world?.groundAt?.(x, z) ?? 0) + b.h;
83
+ _m.compose(_p.set(x, y, z), _q.identity(), this._scl);
84
+ this.mesh.setMatrixAt(i, _m);
85
+ }
86
+ this.mesh.instanceMatrix.needsUpdate = true;
87
+ }
88
+
89
+ update(dt, playerPos) {
90
+ this.mesh.material.userData.uTime.value += dt; // the VAT clock — without it the bake is frozen on frame 0
91
+ const c = this.centre;
92
+ if (this.over) {
93
+ c.set(this.over.x, 0, this.over.z);
94
+ const dx = playerPos.x - c.x, dz = playerPos.z - c.z;
95
+ if (dx * dx + dz * dz > this.gate2) return; // nobody within sight of that sky
96
+ } else {
97
+ if (!this._init) { this._init = true; c.set(playerPos.x + 40, 0, playerPos.z - 60); }
98
+ // drift the flock centre toward the player's country — never snap
99
+ const dx = playerPos.x - c.x, dz = playerPos.z - c.z, d = Math.hypot(dx, dz);
100
+ if (d > 90) { const k = Math.min(3, d / 120) * 4 * dt; c.x += (dx / d) * k; c.z += (dz / d) * k; }
101
+ }
102
+
103
+ // occasionally a bird switches glide ↔ flap. That is all "soaring" is.
104
+ this._clipT -= dt;
105
+ if (this._clipT <= 0) {
106
+ this._clipT = 1.4 + Math.random() * 2.6;
107
+ const i = (Math.random() * this.birds.length) | 0;
108
+ this.aClip[i] = this.aClip[i] === (this.ci.idle ?? 0) ? (this.ci.walk ?? 0) : (this.ci.idle ?? 0);
109
+ this._aClipAttr.needsUpdate = true;
110
+ }
111
+
112
+ const g = this.game.world;
113
+ for (let i = 0; i < this.birds.length; i++) {
114
+ const b = this.birds[i];
115
+ b.a += b.w * dt;
116
+ b.bob += dt;
117
+ const x = c.x + Math.cos(b.a) * b.r;
118
+ const z = c.z + Math.sin(b.a) * b.r;
119
+ const y = g.groundAt(x, z) + b.h + Math.sin(b.bob * 0.35) * 4.0; // the long slow rise and fall of a thermal
120
+ // heading = the orbit's tangent; the bank rolls INTO the turn
121
+ const vx = -Math.sin(b.a) * b.w, vz = Math.cos(b.a) * b.w;
122
+ const heading = Math.atan2(vx, vz) + Math.PI; // +PI: the rig faces −Z, so without this it flies tail-first
123
+ const bank = Math.max(-0.5, Math.min(0.5, b.w * b.r * 0.09));
124
+ _m.compose(_p.set(x, y, z), _q.setFromEuler(_e.set(0, heading, bank)), this._scl);
125
+ this.mesh.setMatrixAt(i, _m);
126
+ }
127
+ this.mesh.instanceMatrix.needsUpdate = true;
128
+ }
129
+ }