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,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
|
+
}
|