tosijs-3d 0.7.2 → 0.7.4
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/CHANGELOG.md +900 -0
- package/dist/b3d-aircraft.d.ts +14 -0
- package/dist/b3d-aircraft.d.ts.map +1 -1
- package/dist/b3d-aircraft.js +45 -0
- package/dist/b3d-aircraft.js.map +1 -1
- package/dist/b3d-ambient.d.ts +12 -0
- package/dist/b3d-ambient.d.ts.map +1 -1
- package/dist/b3d-ambient.js +83 -1
- package/dist/b3d-ambient.js.map +1 -1
- package/dist/b3d-biped.d.ts +290 -0
- package/dist/b3d-biped.d.ts.map +1 -1
- package/dist/b3d-biped.js +1366 -22
- package/dist/b3d-biped.js.map +1 -1
- package/dist/b3d-collisions.d.ts.map +1 -1
- package/dist/b3d-collisions.js +45 -7
- package/dist/b3d-collisions.js.map +1 -1
- package/dist/b3d-controllable.d.ts +50 -0
- package/dist/b3d-controllable.d.ts.map +1 -1
- package/dist/b3d-controllable.js +61 -0
- package/dist/b3d-controllable.js.map +1 -1
- package/dist/b3d-death.d.ts +26 -0
- package/dist/b3d-death.d.ts.map +1 -1
- package/dist/b3d-death.js +224 -2
- package/dist/b3d-death.js.map +1 -1
- package/dist/b3d-interactive.d.ts +80 -0
- package/dist/b3d-interactive.d.ts.map +1 -0
- package/dist/b3d-interactive.js +284 -0
- package/dist/b3d-interactive.js.map +1 -0
- package/dist/b3d-library.d.ts +39 -0
- package/dist/b3d-library.d.ts.map +1 -1
- package/dist/b3d-library.js +67 -0
- package/dist/b3d-library.js.map +1 -1
- package/dist/b3d-svg-plane.d.ts +35 -0
- package/dist/b3d-svg-plane.d.ts.map +1 -1
- package/dist/b3d-svg-plane.js +96 -8
- package/dist/b3d-svg-plane.js.map +1 -1
- package/dist/b3d-water.d.ts.map +1 -1
- package/dist/b3d-water.js +32 -6
- package/dist/b3d-water.js.map +1 -1
- package/dist/buoyancy.d.ts +90 -0
- package/dist/buoyancy.d.ts.map +1 -0
- package/dist/buoyancy.js +150 -0
- package/dist/buoyancy.js.map +1 -0
- package/dist/curve-field.d.ts +39 -0
- package/dist/curve-field.d.ts.map +1 -0
- package/dist/curve-field.js +491 -0
- package/dist/curve-field.js.map +1 -0
- package/dist/curve.d.ts +241 -0
- package/dist/curve.d.ts.map +1 -0
- package/dist/curve.js +559 -0
- package/dist/curve.js.map +1 -0
- package/dist/dialog-placement.d.ts +69 -7
- package/dist/dialog-placement.d.ts.map +1 -1
- package/dist/dialog-placement.js +90 -9
- package/dist/dialog-placement.js.map +1 -1
- package/dist/embed-font.d.ts +48 -0
- package/dist/embed-font.d.ts.map +1 -0
- package/dist/embed-font.js +135 -0
- package/dist/embed-font.js.map +1 -0
- package/dist/footprint-field.d.ts +30 -0
- package/dist/footprint-field.d.ts.map +1 -0
- package/dist/footprint-field.js +256 -0
- package/dist/footprint-field.js.map +1 -0
- package/dist/frame-panel.d.ts.map +1 -1
- package/dist/frame-panel.js +15 -12
- package/dist/frame-panel.js.map +1 -1
- package/dist/glb-manifest.d.ts +75 -0
- package/dist/glb-manifest.d.ts.map +1 -0
- package/dist/glb-manifest.js +198 -0
- package/dist/glb-manifest.js.map +1 -0
- package/dist/icon-data.d.ts.map +1 -1
- package/dist/icon-data.js +20 -1
- package/dist/icon-data.js.map +1 -1
- package/dist/index.d.ts +33 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -3
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +66 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +117 -0
- package/dist/interaction.js.map +1 -0
- package/dist/interactive-behavior.d.ts +105 -0
- package/dist/interactive-behavior.d.ts.map +1 -0
- package/dist/interactive-behavior.js +253 -0
- package/dist/interactive-behavior.js.map +1 -0
- package/dist/key-layout.d.ts +70 -0
- package/dist/key-layout.d.ts.map +1 -1
- package/dist/key-layout.js +122 -0
- package/dist/key-layout.js.map +1 -1
- package/dist/keyboard-gamepad.d.ts.map +1 -1
- package/dist/keyboard-gamepad.js +19 -6
- package/dist/keyboard-gamepad.js.map +1 -1
- package/dist/keyboard.d.ts +100 -1
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +280 -35
- package/dist/keyboard.js.map +1 -1
- package/dist/mantle.d.ts +56 -0
- package/dist/mantle.d.ts.map +1 -0
- package/dist/mantle.js +115 -0
- package/dist/mantle.js.map +1 -0
- package/dist/popup-surface.d.ts +5 -1
- package/dist/popup-surface.d.ts.map +1 -1
- package/dist/popup-surface.js +181 -14
- package/dist/popup-surface.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +111 -15
- package/dist/surface.js.map +1 -1
- package/dist/svg-icons.d.ts.map +1 -1
- package/dist/svg-icons.js +13 -1
- package/dist/svg-icons.js.map +1 -1
- package/dist/svg-texture.d.ts.map +1 -1
- package/dist/svg-texture.js +40 -1
- package/dist/svg-texture.js.map +1 -1
- package/dist/swim-aim.d.ts +78 -0
- package/dist/swim-aim.d.ts.map +1 -0
- package/dist/swim-aim.js +140 -0
- package/dist/swim-aim.js.map +1 -0
- package/dist/theme-editor.d.ts +41 -0
- package/dist/theme-editor.d.ts.map +1 -0
- package/dist/theme-editor.js +303 -0
- package/dist/theme-editor.js.map +1 -0
- package/dist/tosi-b3d.d.ts +24 -4
- package/dist/tosi-b3d.d.ts.map +1 -1
- package/dist/tosi-b3d.js +238 -37
- package/dist/tosi-b3d.js.map +1 -1
- package/dist/vector-field.d.ts +58 -0
- package/dist/vector-field.d.ts.map +1 -0
- package/dist/vector-field.js +321 -0
- package/dist/vector-field.js.map +1 -0
- package/dist/virtual-gamepad.d.ts.map +1 -1
- package/dist/virtual-gamepad.js +46 -9
- package/dist/virtual-gamepad.js.map +1 -1
- package/dist/w3d-theme.d.ts +115 -17
- package/dist/w3d-theme.d.ts.map +1 -1
- package/dist/w3d-theme.js +311 -0
- package/dist/w3d-theme.js.map +1 -1
- package/dist/water-normal.d.ts +26 -0
- package/dist/water-normal.d.ts.map +1 -0
- package/dist/water-normal.js +105 -0
- package/dist/water-normal.js.map +1 -0
- package/dist/widgets3d-layout.d.ts +58 -0
- package/dist/widgets3d-layout.d.ts.map +1 -1
- package/dist/widgets3d-layout.js +79 -1
- package/dist/widgets3d-layout.js.map +1 -1
- package/dist/widgets3d.d.ts +51 -2
- package/dist/widgets3d.d.ts.map +1 -1
- package/dist/widgets3d.js +418 -112
- package/dist/widgets3d.js.map +1 -1
- package/dist/wreck-fall.d.ts +86 -0
- package/dist/wreck-fall.d.ts.map +1 -0
- package/dist/wreck-fall.js +129 -0
- package/dist/wreck-fall.js.map +1 -0
- package/package.json +9 -6
package/dist/b3d-biped.js
CHANGED
|
@@ -106,6 +106,10 @@ document.body.append(
|
|
|
106
106
|
*/
|
|
107
107
|
/*{ "parent": "Vehicles" }*/
|
|
108
108
|
import * as BABYLON from '@babylonjs/core';
|
|
109
|
+
import { collidable, isOff } from './b3d-utils';
|
|
110
|
+
import { canMantle, mantleClip, mantlePath, defaultMantleLimits, } from './mantle';
|
|
111
|
+
import { buoyantStep, submergedFraction, isSwimming, swimBuoyancy, } from './buoyancy';
|
|
112
|
+
import { aimFromLook, clampAim, easeAim, aimTarget, surfaceAimLimit, } from './swim-aim';
|
|
109
113
|
import { xrControllers } from './gamepad';
|
|
110
114
|
import { B3dControllable } from './b3d-controllable';
|
|
111
115
|
import { CompositeInputProvider } from './control-input';
|
|
@@ -131,6 +135,98 @@ export class AnimState {
|
|
|
131
135
|
return specs.map((spec) => new AnimState(spec));
|
|
132
136
|
}
|
|
133
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* **Animation states for a Quaternius UAL rig** (the Universal Animation
|
|
140
|
+
* Library, on `cdn.tosijs.net/quaternius/`).
|
|
141
|
+
*
|
|
142
|
+
* The biped drives states by NAME — `walk`, `run`, `sneak` — and a rig supplies
|
|
143
|
+
* whatever its animator called them. This is the translation for UAL, so
|
|
144
|
+
* adopting that library is one line:
|
|
145
|
+
*
|
|
146
|
+
* ```js
|
|
147
|
+
* b3dBiped({
|
|
148
|
+
* url: assetUrl('quaternius/UAL1_core.glb'),
|
|
149
|
+
* animationStates: ualAnimationStates(),
|
|
150
|
+
* })
|
|
151
|
+
* ```
|
|
152
|
+
*
|
|
153
|
+
* Two things it buys beyond names. `walkBackwards` becomes a real
|
|
154
|
+
* `Jog_Bwd_Loop` rather than the walk cycle played in reverse, and `sneak` gets
|
|
155
|
+
* a crouch that holds at rest — both were fakes against the stock rig.
|
|
156
|
+
*
|
|
157
|
+
* Pass `extra` to add or override entries; a later entry with the same `name`
|
|
158
|
+
* wins, so a project can retarget one state without restating the rest.
|
|
159
|
+
*/
|
|
160
|
+
export function ualAnimationStates(extra = []) {
|
|
161
|
+
const base = [
|
|
162
|
+
{ name: 'idle', animation: 'Idle_Loop', loop: true },
|
|
163
|
+
{ name: 'walk', animation: 'Walk_Loop', loop: true },
|
|
164
|
+
{ name: 'run', animation: 'Jog_Fwd_Loop', loop: true },
|
|
165
|
+
{ name: 'sprint', animation: 'Sprint_Loop', loop: true },
|
|
166
|
+
{ name: 'walkBackwards', animation: 'Jog_Bwd_Loop', loop: true },
|
|
167
|
+
{ name: 'strafeLeft', animation: 'Jog_Left_Loop', loop: true },
|
|
168
|
+
{ name: 'strafeRight', animation: 'Jog_Right_Loop', loop: true },
|
|
169
|
+
{ name: 'sneak', animation: 'Crouch_Fwd_Loop', loop: true },
|
|
170
|
+
{ name: 'sneakIdle', animation: 'Crouch_Idle_Loop', loop: true },
|
|
171
|
+
{ name: 'sneakLeft', animation: 'Crouch_Left_Loop', loop: true },
|
|
172
|
+
{ name: 'sneakRight', animation: 'Crouch_Right_Loop', loop: true },
|
|
173
|
+
// One clip per phase, which is why the jump can be done properly here:
|
|
174
|
+
// `jump` is the wind-up, and the loop and landing are addressable.
|
|
175
|
+
{ name: 'jump', animation: 'Jump_Start', loop: false },
|
|
176
|
+
{ name: 'jumpLoop', animation: 'Jump_Loop', loop: true },
|
|
177
|
+
{ name: 'jumpLand', animation: 'Jump_Land', loop: false },
|
|
178
|
+
/*
|
|
179
|
+
CLIMB clips are named for the HEIGHT they cover, not for the state that
|
|
180
|
+
preceded them — which is why `mantle.mantleClip` picks one by measuring the
|
|
181
|
+
ledge rather than by asking what the character was doing. A rig carrying
|
|
182
|
+
only `ClimbLedge` still works; one carrying none falls back to the jump and
|
|
183
|
+
the climb still happens, just plainly.
|
|
184
|
+
|
|
185
|
+
Split across both megafiles: `ClimbLedge` is UAL1, `ClimbUp_1m`/`_2m` are
|
|
186
|
+
UAL2. Listing all three is harmless — `setAnimationState` skips states whose
|
|
187
|
+
clip the loaded GLB does not contain — so a rig gains the better ones simply
|
|
188
|
+
by shipping them.
|
|
189
|
+
*/
|
|
190
|
+
{ name: 'ClimbLedge', animation: 'ClimbLedge', loop: false },
|
|
191
|
+
{ name: 'ClimbUp_1m', animation: 'ClimbUp_1m', loop: false },
|
|
192
|
+
{ name: 'ClimbUp_2m', animation: 'ClimbUp_2m', loop: false },
|
|
193
|
+
{ name: 'running-jump', animation: 'Jump_Loop', loop: false },
|
|
194
|
+
{ name: 'swim', animation: 'Swim_Fwd_Loop', loop: true },
|
|
195
|
+
{ name: 'tread-water', animation: 'Swim_Idle_Loop', loop: true },
|
|
196
|
+
{ name: 'dance', animation: 'Dance_Loop', loop: true },
|
|
197
|
+
{ name: 'pilot', animation: 'Driving_Loop', loop: true },
|
|
198
|
+
{ name: 'pickup', animation: 'Interact', loop: false },
|
|
199
|
+
{ name: 'look', animation: 'Idle_Loop', loop: true },
|
|
200
|
+
];
|
|
201
|
+
const byName = new Map();
|
|
202
|
+
for (const spec of [...base, ...extra]) {
|
|
203
|
+
byName.set(spec.name ?? spec.animation, spec);
|
|
204
|
+
}
|
|
205
|
+
return AnimState.buildList(...byName.values());
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* How far above the feet the collision body starts — everything below this is
|
|
209
|
+
* walked over rather than collided with. Unity calls it Step Offset.
|
|
210
|
+
*/
|
|
211
|
+
/** Ratio-to-weight for a body with no swim pose to read a waterline from. */
|
|
212
|
+
const DEFAULT_BUOYANCY = 1.15;
|
|
213
|
+
const STEP_OFFSET = 0.35;
|
|
214
|
+
/** How high a lip the biped walks straight over instead of being stopped by. */
|
|
215
|
+
const STEP_UP = 0.5;
|
|
216
|
+
/** How far the ground may drop before it becomes a FALL rather than a step. */
|
|
217
|
+
const STEP_DOWN = 0.6;
|
|
218
|
+
/**
|
|
219
|
+
* Vertical kick while swimming, m/s². Enough to beat buoyancy comfortably
|
|
220
|
+
* (which is ~1.5 m/s² of upward push at full submersion) without feeling like a
|
|
221
|
+
* jetpack.
|
|
222
|
+
*/
|
|
223
|
+
const SWIM_THRUST = 6;
|
|
224
|
+
/** Local +Z. Allocated once — this is read every frame while swimming. */
|
|
225
|
+
const LOCAL_FORWARD = new BABYLON.Vector3(0, 0, 1);
|
|
226
|
+
/** Scratch for the yaw read-back; never allocate inside the per-frame loop. */
|
|
227
|
+
const _fwdScratch = new BABYLON.Vector3();
|
|
228
|
+
/** Scratch for the derived right axis, same reason. */
|
|
229
|
+
const _rightScratch = new BABYLON.Vector3();
|
|
134
230
|
export class B3dBiped extends B3dControllable {
|
|
135
231
|
static initAttributes = {
|
|
136
232
|
...B3dControllable.initAttributes,
|
|
@@ -152,10 +248,22 @@ export class B3dBiped extends B3dControllable {
|
|
|
152
248
|
forwardSpeed: 2,
|
|
153
249
|
runSpeed: 5,
|
|
154
250
|
backwardSpeed: 1,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
251
|
+
/*
|
|
252
|
+
CAMERA FRAMING IS IN METRES, so it is scale-bound — and these were tuned
|
|
253
|
+
against a 0.88 m rig. At human scale (1.8 m, see CLAUDE.md → "Scale") the
|
|
254
|
+
old numbers put the camera at chest height two metres back, which frames
|
|
255
|
+
the character's shoulders and not the world. Roughly doubled, which is the
|
|
256
|
+
ratio between the rigs.
|
|
257
|
+
|
|
258
|
+
Note `eyeHeight` below was ALREADY 1.6 — a human number on a half-height
|
|
259
|
+
character. That mismatch is the tell that the defaults were written for a
|
|
260
|
+
person and the rig drifted, which is the argument for standardising on 1.8 m
|
|
261
|
+
rather than retuning everything down.
|
|
262
|
+
*/
|
|
263
|
+
cameraHeightOffset: 2,
|
|
264
|
+
cameraTargetHeight: 1.4,
|
|
265
|
+
cameraMinFollowDistance: 4,
|
|
266
|
+
cameraMaxFollowDistance: 10,
|
|
159
267
|
// Void-catch backstop: a biped never falls below this Y. It's a last resort for
|
|
160
268
|
// scenes with NO collidable ground at all (a GLB floor not named `_collide`), so
|
|
161
269
|
// set it DEEP — not at walking level. At 0 it would act as an invisible floor,
|
|
@@ -166,6 +274,100 @@ export class B3dBiped extends B3dControllable {
|
|
|
166
274
|
// Eye height for the first-person camera (the view button toggles between
|
|
167
275
|
// third-person over-the-shoulder and this).
|
|
168
276
|
eyeHeight: 1.6,
|
|
277
|
+
/** Degrees per second the right stick swings the view. */
|
|
278
|
+
lookRate: 120,
|
|
279
|
+
/** How far up/down the look can go, degrees. */
|
|
280
|
+
maxLookPitch: 70,
|
|
281
|
+
/**
|
|
282
|
+
* Invert the right stick's vertical. **On by default** — Tonio's call, and
|
|
283
|
+
* the conventional one for a third-person camera: pushing the stick away
|
|
284
|
+
* from you tips the view DOWN, the way a physical camera head works. Set
|
|
285
|
+
* `'off'` for the direct mapping.
|
|
286
|
+
*
|
|
287
|
+
* A string enum rather than a boolean because an absent boolean attribute
|
|
288
|
+
* is false, so a default-true boolean can never turn on (and tosijs now
|
|
289
|
+
* throws on one) — see CLAUDE.md.
|
|
290
|
+
*/
|
|
291
|
+
invertLookY: 'on',
|
|
292
|
+
/**
|
|
293
|
+
* Never let the follow camera drop below this above the character's feet.
|
|
294
|
+
* Pitch drives the camera's HEIGHT, so looking up walks it downward — and
|
|
295
|
+
* without a floor it ends up underground, which reads as the world
|
|
296
|
+
* vanishing. Metres.
|
|
297
|
+
*/
|
|
298
|
+
cameraMinHeight: 0.5,
|
|
299
|
+
/**
|
|
300
|
+
* Upward speed of a FULLY wound-up jump, m/s. The physics is fixed and the
|
|
301
|
+
* ANIMATION is retimed to match it — not the other way round. Matching the
|
|
302
|
+
* jump to the clip was tried and was wrong: it made the jump a consequence
|
|
303
|
+
* of whatever the animator exported.
|
|
304
|
+
*/
|
|
305
|
+
jumpSpeed: 4.5,
|
|
306
|
+
/** Fraction of walking speed while sneaking. */
|
|
307
|
+
sneakSpeed: 0.4,
|
|
308
|
+
/** Sidestep speed as a fraction of walking. Slower than forward on purpose. */
|
|
309
|
+
strafeSpeed: 0.75,
|
|
310
|
+
/**
|
|
311
|
+
* `'on'` to sidestep with the left stick, `'off'` (default) to turn with it
|
|
312
|
+
* instead — both sticks then steer and the sidestep clips never play.
|
|
313
|
+
*
|
|
314
|
+
* Off by default on principle rather than as a workaround: strafing is a
|
|
315
|
+
* shooter idiom that reads oddly on a character meant to move like a
|
|
316
|
+
* person. That it also avoids the weakest clips in the Quaternius set is a
|
|
317
|
+
* bonus, not the reason.
|
|
318
|
+
*
|
|
319
|
+
* A string enum rather than a boolean because a boolean attribute cannot
|
|
320
|
+
* default to true, and the useful default here is "strafing off".
|
|
321
|
+
*/
|
|
322
|
+
/**
|
|
323
|
+
* **How deep the water gets before you swim, as a fraction of standing
|
|
324
|
+
* height.** Below this you wade — feet on the bottom, walking; above it
|
|
325
|
+
* buoyancy takes over.
|
|
326
|
+
*
|
|
327
|
+
* `0.45` is about 0.8 m on a 1.83 m rig. Tonio set the band: *"make it
|
|
328
|
+
* about 0.4-0.5 (it's hard to swim in water less than waist deep)"* — which
|
|
329
|
+
* is the real constraint. Swimming shallower than that is not a choice a
|
|
330
|
+
* person gets to make; the bottom is in the way.
|
|
331
|
+
*
|
|
332
|
+
* A fraction rather than metres because it is a fact about the BODY — a
|
|
333
|
+
* smaller character should start swimming sooner in the same pond, and a
|
|
334
|
+
* fraction tracks that for free. Metres would be right for a fact about the
|
|
335
|
+
* WORLD; the two are easy to confuse and worth keeping apart.
|
|
336
|
+
*
|
|
337
|
+
* Leaving the water uses a lower threshold (70% of this) so the boundary
|
|
338
|
+
* does not flicker; see `buoyancy.isSwimming`.
|
|
339
|
+
*/
|
|
340
|
+
/**
|
|
341
|
+
* **How high a ledge the character can pull itself onto, metres.** Below
|
|
342
|
+
* `STEP_UP` (0.5) the walking code just steps up; above this it is a wall.
|
|
343
|
+
*
|
|
344
|
+
* `2.2` is a touch over shoulder height on a 1.83 m rig — a decent mantle
|
|
345
|
+
* for someone athletic. Lower it for a heavier or clumsier character; that
|
|
346
|
+
* is the mobility half of the skill dial AI-DESIGN argues for, and it costs
|
|
347
|
+
* nothing to expose because the band it defines is read from geometry
|
|
348
|
+
* rather than painted onto it.
|
|
349
|
+
*/
|
|
350
|
+
climbReach: 2.2,
|
|
351
|
+
wadeDepth: 0.45,
|
|
352
|
+
strafing: 'off',
|
|
353
|
+
/**
|
|
354
|
+
* **How high this figure rides in the water, in METRES.** `0` (default)
|
|
355
|
+
* puts the waterline at the head — what swimming means — and it is a
|
|
356
|
+
* straight vertical offset from there: `0.1` floats ten centimetres
|
|
357
|
+
* higher, `-0.1` ten lower.
|
|
358
|
+
*
|
|
359
|
+
* Metres rather than a dimensionless multiplier because a multiplier is not
|
|
360
|
+
* authorable — its effect depends on how tall the pose happens to be, so
|
|
361
|
+
* the same number means different things for a tread and a crawl, and you
|
|
362
|
+
* tune it by bisection. An offset is the thing you actually want to say:
|
|
363
|
+
* *this figure sits a bit lower in the water.* Tonio: *"we can keep
|
|
364
|
+
* buoyancy as a strict z offset for a given figure in water."*
|
|
365
|
+
*
|
|
366
|
+
* It is per-FIGURE, which is the useful axis — a heavy pack, armour, a
|
|
367
|
+
* different body — and it composes with any animation set, because the
|
|
368
|
+
* anchor it offsets from is measured rather than authored.
|
|
369
|
+
*/
|
|
370
|
+
buoyancy: 0,
|
|
169
371
|
};
|
|
170
372
|
entries;
|
|
171
373
|
camera;
|
|
@@ -208,10 +410,396 @@ export class B3dBiped extends B3dControllable {
|
|
|
208
410
|
xrInputProvider;
|
|
209
411
|
animationState;
|
|
210
412
|
animationGroup;
|
|
413
|
+
/** Measured vertical extent of the body per animation clip. See `_poseExtent`. */
|
|
414
|
+
_poseCache = new Map();
|
|
415
|
+
/** Seconds the current clip has been playing — a pose needs settling before measuring. */
|
|
416
|
+
_poseAge = 0;
|
|
417
|
+
/**
|
|
418
|
+
* The climb in progress, if any — a COMMITTED transition. Time-boxed by the
|
|
419
|
+
* clip and always exited, so it is not a mode you can be stuck in (the
|
|
420
|
+
* distinction MOBILITY-DESIGN draws about cover applies here too).
|
|
421
|
+
*/
|
|
422
|
+
/** Seconds until the ledge probe may run again — see the note at `_tryMantle`. */
|
|
423
|
+
_ledgeCooldown = 0;
|
|
424
|
+
_mantle = null;
|
|
425
|
+
/** Samples in flight for the clip being measured — see `_currentPose`. */
|
|
426
|
+
_poseAccum = null;
|
|
427
|
+
/** Last measured pose that hangs below its root, i.e. a swim pose. See the note in water. */
|
|
428
|
+
_lastSwimPose = null;
|
|
211
429
|
gameController;
|
|
212
430
|
// XR camera: zoom goes from (1 back, 1 up) to (5 back, 2 up), default (2 back, 1.25 up)
|
|
431
|
+
/**
|
|
432
|
+
* Downward speed while airborne, m/s. One value per element rather than per
|
|
433
|
+
* root node: a character GLB has a single root, and sharing it across two
|
|
434
|
+
* would only matter for a rig that does not exist.
|
|
435
|
+
*/
|
|
436
|
+
_fallVel = 0;
|
|
437
|
+
/** True while in the water and off the bottom — see `buoyancy.isSwimming`. */
|
|
438
|
+
_swimming = false;
|
|
439
|
+
/** Held aim while swimming, DEGREES, positive down — see [[swim-aim]]. */
|
|
440
|
+
_swimAim = 0;
|
|
441
|
+
/** The pitch actually applied to the body, eased toward `_swimAim`. */
|
|
442
|
+
_swimPitch = 0;
|
|
443
|
+
/** Whether the body carried a pitch last frame, so unwinding runs to zero. */
|
|
444
|
+
_swimWasPitched = false;
|
|
445
|
+
/** Body yaw in radians while pitched — integrated, never read back. */
|
|
446
|
+
_bodyYaw = 0;
|
|
447
|
+
/**
|
|
448
|
+
* Camera/body pitch in degrees, positive up. There is no `_lookYaw`: the
|
|
449
|
+
* right stick turns the BODY now, so the camera has no yaw of its own.
|
|
450
|
+
*/
|
|
451
|
+
_lookPitch = 0;
|
|
452
|
+
_sneaking = false;
|
|
453
|
+
/** Held the jump button while grounded: winding up, not yet launched. */
|
|
454
|
+
_jumpWasDown = false;
|
|
455
|
+
/** Off the ground and not in water — the jump clip owns the animation. */
|
|
456
|
+
_inAir = false;
|
|
457
|
+
_jumpClip = 'jump';
|
|
458
|
+
/**
|
|
459
|
+
* Which phase of the three-part jump is showing. `Jump_Start` is a takeoff,
|
|
460
|
+
* `Jump_Loop` holds you in the air however long the arc lasts, `Jump_Land`
|
|
461
|
+
* plays on touchdown — so the clips no longer have to be stretched to fit the
|
|
462
|
+
* flight, which is what `speedRatio` retiming was compensating for.
|
|
463
|
+
*/
|
|
464
|
+
_airPhase = null;
|
|
465
|
+
_phaseLeft = 0;
|
|
466
|
+
/**
|
|
467
|
+
* How long an animation clip runs, in seconds — `0` if there is no such clip.
|
|
468
|
+
*
|
|
469
|
+
* `from`/`to` are FRAMES, so this needs the clip's own frame rate rather than
|
|
470
|
+
* an assumed 60: a 24 fps export would otherwise read as two and a half times
|
|
471
|
+
* too long and launch the character accordingly. `speedRatio` counts too,
|
|
472
|
+
* since the biped scales playback by movement speed.
|
|
473
|
+
*/
|
|
474
|
+
_clipSeconds(name) {
|
|
475
|
+
/*
|
|
476
|
+
Resolve the STATE name to its clip name first.
|
|
477
|
+
|
|
478
|
+
States and clips are only the same string on a rig that happens to name its
|
|
479
|
+
animations the way the biped names its states. Looking up `jump` directly
|
|
480
|
+
found nothing on a rig whose clip is `Jump_Start`, so this returned 0, the
|
|
481
|
+
start phase expired in the frame it began, and the takeoff never played.
|
|
482
|
+
*/
|
|
483
|
+
const state = this.animationStates.find((st) => st.name === name);
|
|
484
|
+
const clip = state?.animation ?? name;
|
|
485
|
+
const groups = this.entries?.animationGroups ?? [];
|
|
486
|
+
const g = groups.find((x) => x.name.replace(/^Clone of /, '') === clip) ??
|
|
487
|
+
groups.find((x) => new RegExp(`(^|[^a-z])${clip}$`, 'i').test(x.name));
|
|
488
|
+
if (g == null)
|
|
489
|
+
return 0;
|
|
490
|
+
const fps = g.targetedAnimations[0]?.animation?.framePerSecond ?? 60;
|
|
491
|
+
const ratio = Math.abs(g.speedRatio) || 1;
|
|
492
|
+
return (g.to - g.from) / fps / ratio;
|
|
493
|
+
}
|
|
494
|
+
_sneakWas = false;
|
|
495
|
+
/** Zoom 0..1, now integrated from the d-pad rather than read off a stick. */
|
|
496
|
+
_camZoom = 0;
|
|
497
|
+
_waterEl;
|
|
498
|
+
/**
|
|
499
|
+
* Surface height of the scene's water, or `null` if there is none.
|
|
500
|
+
*
|
|
501
|
+
* Looked up lazily and cached — `undefined` means "not asked yet", `null`
|
|
502
|
+
* means "asked, no water", which is the common case and must cost nothing per
|
|
503
|
+
* frame. Read from the MESH rather than the element's `y`: water is
|
|
504
|
+
* viewer-centred and not origin-shifted, so the mesh is the honest answer.
|
|
505
|
+
*/
|
|
506
|
+
_waterSurfaceY() {
|
|
507
|
+
if (this._waterEl === undefined) {
|
|
508
|
+
this._waterEl =
|
|
509
|
+
this.owner?.querySelector('tosi-b3d-water') ?? null;
|
|
510
|
+
}
|
|
511
|
+
const mesh = this._waterEl?.mesh;
|
|
512
|
+
return mesh ? mesh.absolutePosition.y : null;
|
|
513
|
+
}
|
|
213
514
|
xrCamZoom = 0.25; // 0 = closest, 1 = furthest
|
|
214
515
|
animationStates = AnimState.buildList({ animation: 'idle', loop: true }, { animation: 'walk', loop: true }, { animation: 'sneak', loop: true }, { animation: 'run', loop: true }, { animation: 'climb', loop: true }, { name: 'walkBackwards', animation: 'walk', backwards: true, loop: true }, { animation: 'jump', loop: false }, { animation: 'running-jump', loop: false }, { animation: 'salute', loop: false }, { animation: 'wave', loop: false, additive: true }, { animation: 'tread-water', loop: true }, { animation: 'swim', loop: true }, { animation: 'talk', loop: true }, { animation: 'look', loop: true }, { animation: 'dance', loop: true }, { animation: 'pickup', loop: false }, { animation: 'pilot', loop: true });
|
|
516
|
+
/**
|
|
517
|
+
* **How tall the body actually is, in the pose it is holding right now.**
|
|
518
|
+
*
|
|
519
|
+
* Returns the body's vertical extent relative to the root node — `bottom` is
|
|
520
|
+
* how far BELOW the root the lowest point is, `height` the full span — or
|
|
521
|
+
* `null` until a skinned mesh is available.
|
|
522
|
+
*
|
|
523
|
+
* This exists because the root node is only at the feet when the character is
|
|
524
|
+
* STANDING. Measured on the Quaternius rig: `Idle_Loop` spans 0 → 1.78 above
|
|
525
|
+
* the root, but `Swim_Idle_Loop` hangs the legs 1.37 m BELOW it and puts the
|
|
526
|
+
* head just 0.24 m above. Treating the root as the feet therefore floated a
|
|
527
|
+
* body that was not there and left the real head about 1.2 m under —
|
|
528
|
+
* Tonio: *"the biped is much lower when treading water relative to the old
|
|
529
|
+
* biped"*, which is exactly right and now measured rather than guessed.
|
|
530
|
+
*
|
|
531
|
+
* It also killed the `buoyancy` dial: getting that head out of the water
|
|
532
|
+
* needed a submersion of 0.14, i.e. `buoyancy ≈ 7`, so raising it to 2 moved
|
|
533
|
+
* the body and changed nothing anyone could see.
|
|
534
|
+
*
|
|
535
|
+
* **Measured, never authored.** A per-rig table of swim offsets would be the
|
|
536
|
+
* obvious alternative and is the wrong shape — it is `_cover` painting by
|
|
537
|
+
* another name (MOBILITY-DESIGN.md): a fact the geometry already knows, wired
|
|
538
|
+
* by hand, silently wrong for the next animation set. Since the numbers here
|
|
539
|
+
* come out of the pose itself, a Mixamo or Mocap rig with different
|
|
540
|
+
* proportions floats correctly with no tuning.
|
|
541
|
+
*
|
|
542
|
+
* Cost is one CPU skinning pass per CLIP, cached forever after — not per
|
|
543
|
+
* frame. `refreshBoundingInfo({applySkeleton:true})` is far too expensive to
|
|
544
|
+
* run continuously.
|
|
545
|
+
*/
|
|
546
|
+
_poseExtent() {
|
|
547
|
+
const node = this.entries?.rootNodes?.[0];
|
|
548
|
+
if (node == null || node.position == null)
|
|
549
|
+
return null;
|
|
550
|
+
let lo = Infinity;
|
|
551
|
+
let hi = -Infinity;
|
|
552
|
+
for (const m of node.getChildMeshes()) {
|
|
553
|
+
if (!m.skeleton || m.getTotalVertices() === 0)
|
|
554
|
+
continue;
|
|
555
|
+
try {
|
|
556
|
+
m.refreshBoundingInfo({ applySkeleton: true, applyMorph: true });
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
// Older Babylon builds take a boolean here. A pose we cannot measure
|
|
560
|
+
// must not throw — it falls back to the standing assumption.
|
|
561
|
+
try {
|
|
562
|
+
;
|
|
563
|
+
m.refreshBoundingInfo(true);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const bb = m.getBoundingInfo().boundingBox;
|
|
570
|
+
if (bb.minimumWorld.y < lo)
|
|
571
|
+
lo = bb.minimumWorld.y;
|
|
572
|
+
if (bb.maximumWorld.y > hi)
|
|
573
|
+
hi = bb.maximumWorld.y;
|
|
574
|
+
}
|
|
575
|
+
if (!isFinite(lo) || !isFinite(hi) || hi <= lo)
|
|
576
|
+
return null;
|
|
577
|
+
const scale = node.scaling?.y || 1;
|
|
578
|
+
return {
|
|
579
|
+
bottom: (lo - node.position.y) / (scale || 1),
|
|
580
|
+
height: (hi - lo) / (scale || 1),
|
|
581
|
+
head: this._headOffset(node, scale || 1),
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Height of the head bone above the root in the current pose, or `null` on a
|
|
586
|
+
* rig with no findable head. See `_swimWaterline` for what it is for.
|
|
587
|
+
*/
|
|
588
|
+
_headOffset(node, scale) {
|
|
589
|
+
for (const m of node.getChildMeshes()) {
|
|
590
|
+
const bone = m.skeleton?.bones?.find((b) => /^head$/i.test(b.name));
|
|
591
|
+
if (bone == null)
|
|
592
|
+
continue;
|
|
593
|
+
return (bone.getAbsolutePosition(m).y - node.position.y) / scale;
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The extent for the clip currently playing, **averaged over a couple of
|
|
599
|
+
* seconds** rather than sampled once, and cached per clip.
|
|
600
|
+
*
|
|
601
|
+
* Averaging is not polish, it is the difference between working and not. A
|
|
602
|
+
* swim cycle is not a fixed shape: measured on `Swim_Fwd_Loop` (1.33 s), the
|
|
603
|
+
* body's lowest point swings from −1.26 to −0.28 as the legs kick — nearly a
|
|
604
|
+
* metre — and the head from −0.03 to +0.28. A single sample therefore lands
|
|
605
|
+
* wherever the settle timer happens to fall, and since the waterline is
|
|
606
|
+
* derived from `height / depth`, the shallow end of that swing produced a
|
|
607
|
+
* buoyancy at the clamp and fired the swimmer out of the water. Tonio: *"I
|
|
608
|
+
* seem to porpoise out of the water with a dead right stick"* — intermittent,
|
|
609
|
+
* because it depended on the phase, which is exactly how it read.
|
|
610
|
+
*
|
|
611
|
+
* Returns the running mean while it accumulates, so the value is usable
|
|
612
|
+
* immediately and merely gets better; it is committed to the cache once the
|
|
613
|
+
* window closes.
|
|
614
|
+
*/
|
|
615
|
+
_currentPose(settle = 0.25, window = 2, interval = 0.08) {
|
|
616
|
+
const key = this.animationState?.animation ?? this.animationState?.name;
|
|
617
|
+
if (key == null)
|
|
618
|
+
return null;
|
|
619
|
+
const hit = this._poseCache.get(key);
|
|
620
|
+
if (hit != null)
|
|
621
|
+
return hit;
|
|
622
|
+
if (this._poseAge < settle)
|
|
623
|
+
return null;
|
|
624
|
+
let acc = this._poseAccum;
|
|
625
|
+
if (acc == null || acc.key !== key) {
|
|
626
|
+
acc = this._poseAccum = {
|
|
627
|
+
key,
|
|
628
|
+
n: 0,
|
|
629
|
+
bottom: 0,
|
|
630
|
+
height: 0,
|
|
631
|
+
head: 0,
|
|
632
|
+
headN: 0,
|
|
633
|
+
next: 0,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
// Spread the samples across the cycle rather than taking them back to back:
|
|
637
|
+
// a skinning pass per frame for two seconds is a real cost, and adjacent
|
|
638
|
+
// frames say almost the same thing anyway.
|
|
639
|
+
if (this._poseAge >= acc.next) {
|
|
640
|
+
acc.next = this._poseAge + interval;
|
|
641
|
+
const m = this._poseExtent();
|
|
642
|
+
if (m != null) {
|
|
643
|
+
acc.n++;
|
|
644
|
+
acc.bottom += m.bottom;
|
|
645
|
+
acc.height += m.height;
|
|
646
|
+
if (m.head != null) {
|
|
647
|
+
acc.head += m.head;
|
|
648
|
+
acc.headN++;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (acc.n === 0)
|
|
653
|
+
return null;
|
|
654
|
+
const mean = {
|
|
655
|
+
bottom: acc.bottom / acc.n,
|
|
656
|
+
height: acc.height / acc.n,
|
|
657
|
+
head: acc.headN > 0 ? acc.head / acc.headN : null,
|
|
658
|
+
};
|
|
659
|
+
if (this._poseAge >= settle + window)
|
|
660
|
+
this._poseCache.set(key, mean);
|
|
661
|
+
return mean;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* **Where the water should sit on this pose**, expressed as the buoyancy that
|
|
665
|
+
* puts it there — a body rests at submersion `1 / buoyancy`, so the two are
|
|
666
|
+
* the same statement.
|
|
667
|
+
*
|
|
668
|
+
* The anchor is the **head**, because that is what swimming IS: a swimmer
|
|
669
|
+
* keeps their head at the surface, and does it by swimming rather than by
|
|
670
|
+
* floating. That makes this a fact about the activity rather than about a
|
|
671
|
+
* clip, so it holds for any humanoid rig — the head bone exists in all of
|
|
672
|
+
* them — and needs no per-animation-set tuning.
|
|
673
|
+
*
|
|
674
|
+
* It also beats the two conventions it sits between, both of which we tried.
|
|
675
|
+
* The root is not a reliable anchor because it means different things in
|
|
676
|
+
* different clips (Tonio: *"the whole root means two completely different
|
|
677
|
+
* things ... is quite problematic"*) — feet when standing, roughly waterline
|
|
678
|
+
* when swimming. Taking it literally floated this rig at ARMPIT height, since
|
|
679
|
+
* its root sits 73% up the treading pose: *"he's still floating way too
|
|
680
|
+
* high."* Anchoring at the head instead puts the water at the neck with the
|
|
681
|
+
* chin clear, which is what treading water looks like, and the same rule
|
|
682
|
+
* leaves a front crawl's head breaking the surface.
|
|
683
|
+
*
|
|
684
|
+
* Falls back to the root convention on a rig with no head bone, and to a
|
|
685
|
+
* plain physical ratio for a pose that does not hang below its root (i.e. a
|
|
686
|
+
* standing one, where there is no waterline being declared at all).
|
|
687
|
+
*
|
|
688
|
+
* Clamped, because `height / depth` diverges as the depth approaches zero and
|
|
689
|
+
* one mid-blend measurement would otherwise fire a swimmer out of the water.
|
|
690
|
+
*/
|
|
691
|
+
_swimWaterline(pose, riseMetres = 0) {
|
|
692
|
+
if (pose == null || pose.bottom >= -0.01)
|
|
693
|
+
return DEFAULT_BUOYANCY;
|
|
694
|
+
// Riding `riseMetres` higher is the same as the water sitting that much
|
|
695
|
+
// lower on the body, which is the form the equilibrium wants.
|
|
696
|
+
const waterline = (pose.head ?? 0) - riseMetres;
|
|
697
|
+
const submergedDepth = waterline - pose.bottom;
|
|
698
|
+
if (!(submergedDepth > 0.01))
|
|
699
|
+
return DEFAULT_BUOYANCY;
|
|
700
|
+
return Math.min(3, Math.max(1, pose.height / submergedDepth));
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* **Look for a lip in front of the character.** Two rays: one forward at shin
|
|
704
|
+
* height to find the face of the thing, one down from above the far side to
|
|
705
|
+
* find what you would be standing on.
|
|
706
|
+
*
|
|
707
|
+
* Returns `null` when there is nothing to read. Everything it does return is
|
|
708
|
+
* a MEASUREMENT — see `mantle.canMantle` for the decision, which is pure and
|
|
709
|
+
* tested, and see MOBILITY-DESIGN for why a `_climbable` suffix would be a
|
|
710
|
+
* bug rather than a shortcut.
|
|
711
|
+
*/
|
|
712
|
+
_readLedge(node, feetY, reach) {
|
|
713
|
+
const scene = this.owner?.scene;
|
|
714
|
+
if (scene == null)
|
|
715
|
+
return null;
|
|
716
|
+
const fwd = node.forward.clone();
|
|
717
|
+
fwd.y = 0;
|
|
718
|
+
if (fwd.lengthSquared() < 1e-6)
|
|
719
|
+
return null;
|
|
720
|
+
fwd.normalize();
|
|
721
|
+
const filter = collidable((m) => m === node || !m.checkCollisions);
|
|
722
|
+
/*
|
|
723
|
+
1. Is there a face in front of us at all?
|
|
724
|
+
|
|
725
|
+
SEVERAL heights, not one. A single shin-height ray is the obvious version
|
|
726
|
+
and it is too fragile for real terrain: measured at a canal bank in the
|
|
727
|
+
demo, the face existed only between 0.25 m and 1.25 m — undercut below,
|
|
728
|
+
sloping away above — so a ray at 0.05 m passed clean underneath a wall the
|
|
729
|
+
character was visibly stuck against, and the climb never even considered
|
|
730
|
+
itself. Nothing was wrong except the height of one line.
|
|
731
|
+
|
|
732
|
+
So sample up the reachable band and take the CLOSEST hit. That also handles
|
|
733
|
+
the cases a fixed height cannot: overhangs, sloped banks, and a lip whose
|
|
734
|
+
base is under water.
|
|
735
|
+
*/
|
|
736
|
+
let face = null;
|
|
737
|
+
let distance = Infinity;
|
|
738
|
+
let origin = null;
|
|
739
|
+
for (let h = 0.2; h <= reach; h += 0.4) {
|
|
740
|
+
const from = new BABYLON.Vector3(node.position.x, feetY + h, node.position.z);
|
|
741
|
+
const hit = scene.pickWithRay(new BABYLON.Ray(from, fwd, defaultMantleLimits.grabDistance), filter);
|
|
742
|
+
if (!hit?.hit || hit.pickedPoint == null)
|
|
743
|
+
continue;
|
|
744
|
+
const d = BABYLON.Vector3.Distance(from, hit.pickedPoint);
|
|
745
|
+
if (d < distance) {
|
|
746
|
+
distance = d;
|
|
747
|
+
face = hit;
|
|
748
|
+
origin = from;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (face?.pickedPoint == null || origin == null)
|
|
752
|
+
return null;
|
|
753
|
+
// 2. What is on top of it? Drop a ray just beyond the face, from above the
|
|
754
|
+
// highest we could possibly climb.
|
|
755
|
+
const beyond = face.pickedPoint.add(fwd.scale(0.35));
|
|
756
|
+
const top = scene.pickWithRay(new BABYLON.Ray(new BABYLON.Vector3(beyond.x, feetY + reach + 0.5, beyond.z), new BABYLON.Vector3(0, -1, 0), reach + 1), filter);
|
|
757
|
+
if (!top?.hit || top.pickedPoint == null)
|
|
758
|
+
return null;
|
|
759
|
+
const height = top.pickedPoint.y - feetY;
|
|
760
|
+
// 3. Is there room to stand there, and floor to stand ON? A second drop
|
|
761
|
+
// further in distinguishes a ledge from the top of a fence.
|
|
762
|
+
const inward = face.pickedPoint.add(fwd.scale(0.75));
|
|
763
|
+
const landingHit = scene.pickWithRay(new BABYLON.Ray(new BABYLON.Vector3(inward.x, feetY + reach + 0.5, inward.z), new BABYLON.Vector3(0, -1, 0), reach + 1), filter);
|
|
764
|
+
const landing = landingHit?.hit &&
|
|
765
|
+
landingHit.pickedPoint != null &&
|
|
766
|
+
Math.abs(landingHit.pickedPoint.y - top.pickedPoint.y) < 0.3
|
|
767
|
+
? 0.75
|
|
768
|
+
: 0;
|
|
769
|
+
const headHit = scene.pickWithRay(new BABYLON.Ray(top.pickedPoint.add(new BABYLON.Vector3(0, 0.05, 0)), new BABYLON.Vector3(0, 1, 0), defaultMantleLimits.clearance + 0.5), filter);
|
|
770
|
+
const headroom = headHit?.hit && headHit.pickedPoint != null
|
|
771
|
+
? headHit.pickedPoint.y - top.pickedPoint.y
|
|
772
|
+
: defaultMantleLimits.clearance + 0.5;
|
|
773
|
+
return { height, distance, headroom, landing };
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Try to start a climb. Returns true if one began, in which case the caller
|
|
777
|
+
* hands this frame over — a mantle owns the body until it finishes.
|
|
778
|
+
*/
|
|
779
|
+
_tryMantle(node, feetY, reach, stepUp) {
|
|
780
|
+
const reading = this._readLedge(node, feetY, reach);
|
|
781
|
+
if (reading == null)
|
|
782
|
+
return false;
|
|
783
|
+
if (!canMantle(reading, { ...defaultMantleLimits, stepUp, reach }))
|
|
784
|
+
return false;
|
|
785
|
+
const fwd = node.forward.clone();
|
|
786
|
+
fwd.y = 0;
|
|
787
|
+
fwd.normalize();
|
|
788
|
+
const from = node.position.clone();
|
|
789
|
+
// Land far enough in that the character is standing ON the surface rather
|
|
790
|
+
// than balanced on its edge.
|
|
791
|
+
const to = new BABYLON.Vector3(from.x + fwd.x * (reading.distance + 0.6), from.y + reading.height, from.z + fwd.z * (reading.distance + 0.6));
|
|
792
|
+
const clip = mantleClip(reading.height, this.animationStates?.map((a) => a.animation ?? a.name) ?? [], 'jump');
|
|
793
|
+
this.setAnimationState(clip);
|
|
794
|
+
// The clip's own length, so the climb retimes itself when the animation
|
|
795
|
+
// set changes rather than needing a matching constant here.
|
|
796
|
+
const dur = Math.max(0.4, Math.min(2.5, this._clipSeconds(clip) || 0.9));
|
|
797
|
+
this._mantle = { t: 0, dur, from, to };
|
|
798
|
+
this._swimming = false;
|
|
799
|
+
this._fallVel = 0;
|
|
800
|
+
this._inAir = false;
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
215
803
|
setAnimationState(name, speed = 1) {
|
|
216
804
|
if (name == null) {
|
|
217
805
|
throw new Error('setAnimationState failed, no animation name specified.');
|
|
@@ -223,12 +811,34 @@ export class B3dBiped extends B3dControllable {
|
|
|
223
811
|
}
|
|
224
812
|
if (this.entries == null)
|
|
225
813
|
return;
|
|
814
|
+
// A new clip means a new pose; it must settle before it can be measured.
|
|
815
|
+
this._poseAge = 0;
|
|
226
816
|
const newState = this.animationStates.find((state) => state.name === name || state.animation === name);
|
|
227
817
|
if (newState == null) {
|
|
228
818
|
console.error(`setAnimationState: no state named "${name}"`);
|
|
229
819
|
return;
|
|
230
820
|
}
|
|
231
|
-
|
|
821
|
+
/*
|
|
822
|
+
EXACT NAME FIRST, suffix only as a fallback.
|
|
823
|
+
|
|
824
|
+
The lookup was `g.name.endsWith(animation)`, and a suffix match is ambiguous
|
|
825
|
+
the moment one clip's name ends with another's. Quaternius exposes it
|
|
826
|
+
immediately — asking for `Idle_Loop` played `Crouch_Idle_Loop`, because that
|
|
827
|
+
ends with it and sorts earlier — but it was already latent in the stock set:
|
|
828
|
+
`running-jump` ends with `jump`, so `setAnimationState('jump')` could match
|
|
829
|
+
the running jump depending on array order.
|
|
830
|
+
|
|
831
|
+
Babylon prefixes cloned groups with "Clone of ", so exactness has to be
|
|
832
|
+
measured after stripping that. The suffix fallback stays for rigs whose
|
|
833
|
+
exporter added some other prefix — permissive when it must be, precise when
|
|
834
|
+
it can be.
|
|
835
|
+
*/
|
|
836
|
+
const clipName = (n) => n.replace(/^Clone of /, '');
|
|
837
|
+
const groups = this.entries.animationGroups;
|
|
838
|
+
let idx = groups.findIndex((g) => clipName(g.name) === newState.animation);
|
|
839
|
+
if (idx === -1) {
|
|
840
|
+
idx = groups.findIndex((g) => g.name.endsWith(newState.animation));
|
|
841
|
+
}
|
|
232
842
|
if (idx === -1) {
|
|
233
843
|
console.error(`setAnimationState: could not find animation "${newState.animation}"`);
|
|
234
844
|
return;
|
|
@@ -257,6 +867,27 @@ export class B3dBiped extends B3dControllable {
|
|
|
257
867
|
if (this.entries == null)
|
|
258
868
|
return;
|
|
259
869
|
const attrs = this;
|
|
870
|
+
this._poseAge += dt;
|
|
871
|
+
/*
|
|
872
|
+
A CLIMB IN PROGRESS OWNS THE BODY.
|
|
873
|
+
|
|
874
|
+
Advance it and take the frame. It is time-boxed by the clip and always
|
|
875
|
+
ends, so there is nothing to be stuck in — the property MOBILITY-DESIGN
|
|
876
|
+
insists on for cover, and the reason this is a committed transition rather
|
|
877
|
+
than a mode.
|
|
878
|
+
*/
|
|
879
|
+
if (this._mantle != null) {
|
|
880
|
+
const m = this._mantle;
|
|
881
|
+
m.t += dt / m.dur;
|
|
882
|
+
const node = this.entries.rootNodes[0];
|
|
883
|
+
const p = mantlePath(m.from, m.to, m.t);
|
|
884
|
+
node.position.set(p.x, p.y, p.z);
|
|
885
|
+
if (m.t >= 1) {
|
|
886
|
+
this._mantle = null;
|
|
887
|
+
this._fallVel = 0;
|
|
888
|
+
}
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
260
891
|
// Camera toggle on the view button (edge-detected).
|
|
261
892
|
const viewPressed = input.view > 0.5;
|
|
262
893
|
if (viewPressed && !this.viewWasPressed) {
|
|
@@ -278,15 +909,115 @@ export class B3dBiped extends B3dControllable {
|
|
|
278
909
|
const f = this.mesh.forward;
|
|
279
910
|
this.fpvCamera.rotation.set(0, Math.atan2(f.x, f.z), 0);
|
|
280
911
|
}
|
|
912
|
+
/*
|
|
913
|
+
SNEAK IS A TOGGLE ON LAND AND A HELD CONTROL IN WATER.
|
|
914
|
+
|
|
915
|
+
Tonio's call, and it is the right one for both: sneaking is a stance you
|
|
916
|
+
adopt for a while, so holding a bumper the whole time is a chore — but
|
|
917
|
+
diving is a thing you do continuously, and a toggle you have to remember the
|
|
918
|
+
state of while your head is under is worse than useless. Same button, and
|
|
919
|
+
the medium decides which verb it is.
|
|
920
|
+
|
|
921
|
+
The edge is tracked rather than the level, so the toggle flips once per
|
|
922
|
+
press. Leaving the water does NOT clear the flag: you sneak out of the sea
|
|
923
|
+
if you were sneaking when you went in, which is the least surprising thing.
|
|
924
|
+
*/
|
|
925
|
+
const sneakDown = (input.sneak ?? 0) > 0.5;
|
|
926
|
+
if (!this._swimming && sneakDown && !this._sneakWas) {
|
|
927
|
+
this._sneaking = !this._sneaking;
|
|
928
|
+
}
|
|
929
|
+
this._sneakWas = sneakDown;
|
|
281
930
|
const speed = input.forward;
|
|
282
|
-
|
|
283
|
-
|
|
931
|
+
/*
|
|
932
|
+
STRAFING IS OPTIONAL, AND OFF BY DEFAULT.
|
|
933
|
+
|
|
934
|
+
Tonio: *"I've decided I hate strafing both on principle and specifically the
|
|
935
|
+
way its animated by quaternius."* Two objections and they are worth keeping
|
|
936
|
+
apart, because only one of them is about this rig: sidestepping is a shooter
|
|
937
|
+
idiom that reads oddly on a character who is supposed to move like a person,
|
|
938
|
+
and the lateral clips are the weakest in the set. The first outlives the
|
|
939
|
+
animation library.
|
|
940
|
+
|
|
941
|
+
With it off, BOTH sticks turn you — the left while moving or not, the right
|
|
942
|
+
without moving — so nothing is lost from the control surface and the
|
|
943
|
+
sidestep clips simply never play. Summed and clamped rather than picked
|
|
944
|
+
between, so using both at once is not a fight.
|
|
945
|
+
*/
|
|
946
|
+
const strafeIsTurn = isOff(this.strafing);
|
|
947
|
+
const rotation = strafeIsTurn
|
|
948
|
+
? Math.max(-1, Math.min(1, (input.turn ?? 0) + (input.strafe ?? 0)))
|
|
949
|
+
: input.turn;
|
|
950
|
+
const sprint = this._sneaking ? 0 : input.sprint;
|
|
284
951
|
const sprintSpeed = speed * sprint;
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
952
|
+
const walk = this._sneaking
|
|
953
|
+
? attrs.forwardSpeed * attrs.sneakSpeed
|
|
954
|
+
: attrs.forwardSpeed;
|
|
955
|
+
const totalSpeed = speed * walk + sprintSpeed * (attrs.runSpeed - attrs.forwardSpeed);
|
|
956
|
+
/*
|
|
957
|
+
LOOK — the right stick, and the reason swimming had no aim on a flat screen.
|
|
958
|
+
|
|
959
|
+
Persistent, not sprung: a character's camera is how you look AROUND, so it
|
|
960
|
+
stays where you put it. (The aircraft's springs back because there it is a
|
|
961
|
+
glance off the flight path, which is a different job with the same stick.)
|
|
962
|
+
|
|
963
|
+
A `FollowCamera` has no pitch of its own; it looks at its locked target from
|
|
964
|
+
`heightOffset` above and `rotationOffset` around. So pitch is height — raise
|
|
965
|
+
the camera and it looks down — which is exactly the third-person behaviour
|
|
966
|
+
and needs no second camera type.
|
|
967
|
+
*/
|
|
968
|
+
/*
|
|
969
|
+
WHILE SWIMMING, LEVEL IS THE RESTING STATE.
|
|
970
|
+
|
|
971
|
+
Persistent look is right on land — you look around and it stays where you
|
|
972
|
+
put it. In the water it is a trap, because the swim aim IS the look pitch:
|
|
973
|
+
aim down once to dive and you are aimed down forever, so every stroke digs
|
|
974
|
+
deeper and getting back out means holding the stick up for a second and a
|
|
975
|
+
half against the 70° clamp. Tonio: "trying to go up from on the surface is a
|
|
976
|
+
big issue but I can't get out of the water now."
|
|
977
|
+
|
|
978
|
+
So while swimming the pitch springs back to level when the stick is
|
|
979
|
+
released. Push up to climb, push down to dive, let go to swim flat — and
|
|
980
|
+
buoyancy then does the rest, which is the behaviour that makes surfacing
|
|
981
|
+
automatic instead of a manoeuvre. Yaw does NOT spring: turning is turning,
|
|
982
|
+
in or out of the water.
|
|
983
|
+
|
|
984
|
+
In a headset none of this applies: your neck already returns to level, and
|
|
985
|
+
the aim comes from your head rather than from here.
|
|
986
|
+
*/
|
|
987
|
+
if (this._swimming && Math.abs(input.lookY ?? 0) < 0.08) {
|
|
988
|
+
this._lookPitch *= Math.exp(-3 * dt); // frame-rate independent
|
|
989
|
+
if (Math.abs(this._lookPitch) < 0.5)
|
|
990
|
+
this._lookPitch = 0;
|
|
991
|
+
}
|
|
992
|
+
// Inverted in BOTH media, deliberately: it is one control and it should not
|
|
993
|
+
// change sense when you get your feet wet. (Tried land-only; Tonio's call
|
|
994
|
+
// is that consistency wins, and the head-underwater problem was elsewhere.)
|
|
995
|
+
const lookYSign = isOff(attrs.invertLookY) ? 1 : -1;
|
|
996
|
+
this._lookPitch = Math.max(-attrs.maxLookPitch, Math.min(attrs.maxLookPitch, this._lookPitch + (input.lookY ?? 0) * lookYSign * attrs.lookRate * dt));
|
|
288
997
|
if (this.camera instanceof BABYLON.FollowCamera) {
|
|
289
|
-
this.camera.radius = lerp(attrs.cameraMinFollowDistance, attrs.cameraMaxFollowDistance, Math.max(0, Math.min(1,
|
|
998
|
+
this.camera.radius = lerp(attrs.cameraMinFollowDistance, attrs.cameraMaxFollowDistance, Math.max(0, Math.min(1, this._camZoom)));
|
|
999
|
+
this._camZoom = Math.max(0, Math.min(1, this._camZoom + (input.cameraZoom ?? 0) * dt));
|
|
1000
|
+
// Straight behind the body. The right stick turns the CHARACTER now, so
|
|
1001
|
+
// the camera has no yaw of its own and cannot end up pointing somewhere
|
|
1002
|
+
// the character is not — which is the failure mode an orbiting
|
|
1003
|
+
// third-person camera has and a GTA-style one does not.
|
|
1004
|
+
this.camera.rotationOffset = 180;
|
|
1005
|
+
// Pitch as height: +look is up, which means the camera drops BELOW the
|
|
1006
|
+
// subject to look up at it, so the offset runs the other way.
|
|
1007
|
+
/*
|
|
1008
|
+
KEEP THE CAMERA ABOVE GROUND.
|
|
1009
|
+
|
|
1010
|
+
Pitch drives HEIGHT on a FollowCamera, so looking up walks the camera
|
|
1011
|
+
downward — and past a certain angle it goes underground and the world
|
|
1012
|
+
vanishes. A floor is the cheap, always-correct half of the fix; the
|
|
1013
|
+
thorough version raycasts from the subject to the camera and pulls in, the
|
|
1014
|
+
way the world-dialog depth guard does, which also handles walls rather
|
|
1015
|
+
than just terrain.
|
|
1016
|
+
*/
|
|
1017
|
+
this.camera.heightOffset = Math.max(attrs.cameraMinHeight, attrs.cameraHeightOffset +
|
|
1018
|
+
Math.tan((-this._lookPitch * Math.PI) / 180) *
|
|
1019
|
+
this.camera.radius *
|
|
1020
|
+
0.5);
|
|
290
1021
|
}
|
|
291
1022
|
// XR camera zoom from right stick
|
|
292
1023
|
if (input.cameraZoom !== 0 && this.xrStuff) {
|
|
@@ -300,15 +1031,534 @@ export class B3dBiped extends B3dControllable {
|
|
|
300
1031
|
else if (speed < 0) {
|
|
301
1032
|
node.moveWithCollisions(node.forward.scaleInPlace(speed * dt * attrs.backwardSpeed));
|
|
302
1033
|
}
|
|
1034
|
+
/*
|
|
1035
|
+
STRAFE — the left stick's X, now that the right stick turns.
|
|
1036
|
+
|
|
1037
|
+
Along the body's own right axis, so it follows the facing (and, while
|
|
1038
|
+
swimming, the pitch) for free. Deliberately not sprint-scaled: sprinting
|
|
1039
|
+
sideways is not a thing, and letting it happen makes the sprint modifier
|
|
1040
|
+
feel like a general speed multiplier rather than a run.
|
|
1041
|
+
*/
|
|
1042
|
+
const strafe = strafeIsTurn ? 0 : input.strafe ?? 0;
|
|
1043
|
+
if (Math.abs(strafe) > 0.01) {
|
|
1044
|
+
/*
|
|
1045
|
+
DERIVE RIGHT FROM FORWARD — `node.right` is not it.
|
|
1046
|
+
|
|
1047
|
+
A glTF root arrives mirrored (`scaling.z = -1`), and the scale applies in
|
|
1048
|
+
LOCAL space, so `forward` comes back negated while `right` does not. The
|
|
1049
|
+
two then describe opposite handedness: measured on the live rig,
|
|
1050
|
+
`node.right` versus `cross(up, forward)` gives a dot product of exactly
|
|
1051
|
+
−1. Strafing right walked left.
|
|
1052
|
+
|
|
1053
|
+
`cross(up, forward)` is right-by-construction in this coordinate system
|
|
1054
|
+
(see `babylon-orientation.test.ts`: `cross(forward, up)` is LEFT), and it
|
|
1055
|
+
cannot disagree with the facing because it is DERIVED from it. That is
|
|
1056
|
+
the fix rather than another `scaling.z` sign test — the pitch needed one
|
|
1057
|
+
because it feeds a quaternion, but a direction can just be computed.
|
|
1058
|
+
*/
|
|
1059
|
+
BABYLON.Vector3.CrossToRef(BABYLON.Vector3.Up(), node.forward, _rightScratch);
|
|
1060
|
+
node.moveWithCollisions(_rightScratch.scaleInPlace(strafe * walk * attrs.strafeSpeed * dt));
|
|
1061
|
+
}
|
|
303
1062
|
node.rotate(BABYLON.Vector3.Up(), rotation * dt * attrs.turnSpeed * DEG_TO_RAD);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
1063
|
+
/*
|
|
1064
|
+
STAND ON THE GROUND — a SNAP, not a dead band.
|
|
1065
|
+
|
|
1066
|
+
This used to probe 0.15 m down from just above the feet and, if it found
|
|
1067
|
+
anything, do nothing at all. So there was no term pulling the biped TOWARD
|
|
1068
|
+
the surface: it fell until the probe happened to see ground, then stopped
|
|
1069
|
+
wherever in that 0.15 m window it landed. Anything inside the band was
|
|
1070
|
+
permanent, which is exactly how it was reported — Tonio: "you often end up
|
|
1071
|
+
a little offset from the ground (floating above or sunken in) and this
|
|
1072
|
+
never really corrects. It just gets randomly messed up again when you
|
|
1073
|
+
navigate another slope."
|
|
1074
|
+
|
|
1075
|
+
Slopes made it worse in both directions. Going UP, `moveWithCollisions`
|
|
1076
|
+
slides the body up the ellipsoid and leaves it high in the band. Going
|
|
1077
|
+
DOWN — "especially down" — the ground fell away faster than the old
|
|
1078
|
+
gravity could follow: it moved at most `min(0.1, 9.81·dt)` per frame, a
|
|
1079
|
+
hard 0.1 m clamp, so a brisk descent outran it and it floated the whole
|
|
1080
|
+
way down.
|
|
1081
|
+
|
|
1082
|
+
Now: probe a step's worth up and a step's worth down, and if there is
|
|
1083
|
+
ground in that range put the feet ON it (the root origin IS the feet — see
|
|
1084
|
+
the ellipsoid offset in setupMesh). Otherwise fall properly, accumulating
|
|
1085
|
+
velocity rather than moving a fixed amount per frame.
|
|
1086
|
+
*/
|
|
1087
|
+
const fallStep = Math.abs(this._fallVel * dt);
|
|
1088
|
+
const ray = new BABYLON.Ray(new BABYLON.Vector3(node.position.x, node.position.y + STEP_UP, node.position.z), BABYLON.Vector3.Down(),
|
|
1089
|
+
// Extended by this frame's fall so a fast descent cannot step over the
|
|
1090
|
+
// surface between two frames and keep going.
|
|
1091
|
+
STEP_UP + STEP_DOWN + fallStep);
|
|
1092
|
+
const hit = this.owner.scene.pickWithRay(ray,
|
|
1093
|
+
// `collidable()` for the shared rules (UI never counts as floor,
|
|
1094
|
+
// isPickable/isEnabled re-checked because a predicate replaces
|
|
1095
|
+
// Babylon's own filter); `checkCollisions` stays as OUR clause.
|
|
1096
|
+
collidable((m) => m === node || !m.checkCollisions));
|
|
1097
|
+
/*
|
|
1098
|
+
WATER IS A MEDIUM, NOT A LINE.
|
|
1099
|
+
|
|
1100
|
+
Falling through water is not falling through air with a smaller number: a
|
|
1101
|
+
body is slightly less dense than water, so it is pushed up in proportion
|
|
1102
|
+
to how much of it is under, and it comes to rest where that balances its
|
|
1103
|
+
weight. Plunge-and-bob, a head that ends up ABOVE the surface, and wading
|
|
1104
|
+
that does nothing until it is deep enough to lift you all fall out of that
|
|
1105
|
+
one equation — see [[buoyancy]], where it is pure and tested.
|
|
1106
|
+
|
|
1107
|
+
SWIMMING IS IN THE WATER **AND** OFF THE BOTTOM. Both halves matter: deep
|
|
1108
|
+
water while standing on a sandbar is wading, and getting it wrong gives you
|
|
1109
|
+
a character doing breaststroke while visibly standing up.
|
|
1110
|
+
*/
|
|
1111
|
+
/*
|
|
1112
|
+
JUMP — an impulse, not a teleport, so the existing gravity carries it.
|
|
1113
|
+
|
|
1114
|
+
Only from the ground: no double-jumping and no jumping out of water (in
|
|
1115
|
+
water the same button SURFACES you, which is the continuous verb). Edge
|
|
1116
|
+
triggered, so holding it does not pogo.
|
|
1117
|
+
*/
|
|
1118
|
+
/*
|
|
1119
|
+
CROUCH ON PRESS, LAUNCH ON RELEASE — Tonio's call, and the animation is
|
|
1120
|
+
the reason.
|
|
1121
|
+
|
|
1122
|
+
Firing the impulse on the press edge put the wind-up in the wrong place:
|
|
1123
|
+
the `jump` clip opens with a crouch, so launching immediately meant "he
|
|
1124
|
+
crouches AFTER launching". Anticipation has to precede the thing it
|
|
1125
|
+
anticipates or it is not anticipation, it is a stumble.
|
|
1126
|
+
|
|
1127
|
+
So the press starts the crouch and the release launches. The clip is
|
|
1128
|
+
requested while still grounded and simply keeps playing through the
|
|
1129
|
+
launch — `setAnimationState` is idempotent, so asking for `jump` again
|
|
1130
|
+
while airborne does not restart it and the wind-up is not replayed.
|
|
1131
|
+
|
|
1132
|
+
Charging is cancelled by leaving the ground or entering water. In water
|
|
1133
|
+
this button is the SURFACE control and continuous, so it never charges.
|
|
1134
|
+
*/
|
|
1135
|
+
const jumpDown = (input.jump ?? 0) > 0.5;
|
|
1136
|
+
this._jumpWas = jumpDown;
|
|
1137
|
+
const surfaceY = this._waterSurfaceY();
|
|
1138
|
+
// `eyeHeight` as a proxy for body height. It is a little short by
|
|
1139
|
+
// definition, which is the harmless direction: equilibrium is a FRACTION
|
|
1140
|
+
// of whatever height you give it, so erring small floats you a touch
|
|
1141
|
+
// lower rather than leaving you standing on the water.
|
|
1142
|
+
const standHeight = this.eyeHeight || 1.6;
|
|
1143
|
+
/*
|
|
1144
|
+
A cheap standing test FIRST, purely to decide whether measuring is worth
|
|
1145
|
+
it: `_currentPose` costs a CPU skinning pass on the frame it measures a
|
|
1146
|
+
new clip, and running that for every walk/run/jump clip on dry land would
|
|
1147
|
+
be a hitch bought for nothing. Near the water it pays for itself once per
|
|
1148
|
+
clip and is cached forever.
|
|
1149
|
+
*/
|
|
1150
|
+
const nearWater = surfaceY != null && node.position.y < surfaceY + standHeight;
|
|
1151
|
+
/*
|
|
1152
|
+
A CLIP CHANGE MUST NOT BRIEFLY MAKE A SWIMMER STAND.
|
|
1153
|
+
|
|
1154
|
+
`_currentPose` returns null until the new clip has settled enough to
|
|
1155
|
+
measure, and falling back to the standing assumption for those few frames
|
|
1156
|
+
put the root back at the feet — so a swimmer floating at the waterline
|
|
1157
|
+
read as barely submerged and started walking on it. Moving is exactly what
|
|
1158
|
+
changes the clip (tread → forward stroke), which is why it showed up as
|
|
1159
|
+
"when you move he tends to jump up to the surface and walk".
|
|
1160
|
+
|
|
1161
|
+
So while swimming, an unmeasured clip inherits the last swim pose. It is
|
|
1162
|
+
the better guess by far: consecutive swim clips hang the body off the root
|
|
1163
|
+
the same way, and the alternative is a pose we know to be wrong.
|
|
1164
|
+
*/
|
|
1165
|
+
const pose = nearWater
|
|
1166
|
+
? this._currentPose() ?? (this._swimming ? this._lastSwimPose : null)
|
|
1167
|
+
: null;
|
|
1168
|
+
if (pose != null && pose.bottom < -0.01)
|
|
1169
|
+
this._lastSwimPose = pose;
|
|
1170
|
+
// No measurement yet ⇒ assume the standing pose: root at the feet, body
|
|
1171
|
+
// upward. True while standing, and merely the previous behaviour
|
|
1172
|
+
// otherwise, so a pose we cannot measure degrades rather than breaks.
|
|
1173
|
+
const bodyBottom = pose ? pose.bottom : 0;
|
|
1174
|
+
const bodyHeight = pose ? pose.height : standHeight;
|
|
1175
|
+
const feetY = node.position.y + bodyBottom;
|
|
1176
|
+
const submerged = surfaceY == null ? 0 : submergedFraction(feetY, bodyHeight, surfaceY);
|
|
1177
|
+
const grounded = hit?.hit === true && hit.pickedPoint != null;
|
|
1178
|
+
const groundY = grounded ? hit.pickedPoint.y : -Infinity;
|
|
1179
|
+
/*
|
|
1180
|
+
THE SWIM/STAND TEST MUST NOT USE THE POSE, or it feeds back on itself.
|
|
1181
|
+
|
|
1182
|
+
Buoyancy needs the CURRENT pose, because displacement is a fact about the
|
|
1183
|
+
body that is actually in the water. The DECISION to swim must not, because
|
|
1184
|
+
the pose is a consequence of that decision: raise buoyancy, the body
|
|
1185
|
+
rises, submersion falls under the exit threshold, the pose snaps upright,
|
|
1186
|
+
and an upright body measured from the same root reads as barely
|
|
1187
|
+
submerged — which locks the flip in. Observed directly: at `buoyancy` 1.3
|
|
1188
|
+
the character corked out and stood on the surface, the "wade on water" bug
|
|
1189
|
+
arriving by a new route.
|
|
1190
|
+
|
|
1191
|
+
So the decision asks a question whose answer cannot depend on it: **how
|
|
1192
|
+
deep would this water be if I STOOD here?** Which is also the question it
|
|
1193
|
+
means — you swim because you cannot stand — and it is stable across the
|
|
1194
|
+
switch, so there is no loop left to close.
|
|
1195
|
+
*/
|
|
1196
|
+
const standSubmerged = surfaceY == null
|
|
1197
|
+
? 0
|
|
1198
|
+
: grounded
|
|
1199
|
+
? /*
|
|
1200
|
+
Feet ON THE FLOOR, not the root — while swimming the root sits
|
|
1201
|
+
mid-torso, so measuring from it asks "how deep would it be if I
|
|
1202
|
+
stood with my feet where my chest is".
|
|
1203
|
+
|
|
1204
|
+
Scaled so `wadeDepth` lands exactly on `isSwimming`'s 0.5 entry
|
|
1205
|
+
threshold, which keeps that model's tested hysteresis — you stop
|
|
1206
|
+
swimming at 0.7 × wadeDepth — while letting the threshold be stated
|
|
1207
|
+
as the fraction it is.
|
|
1208
|
+
*/
|
|
1209
|
+
submergedFraction(groundY, standHeight, surfaceY) /
|
|
1210
|
+
(2 * Math.max(0.05, attrs.wadeDepth))
|
|
1211
|
+
: // NO FLOOR AT ALL ⇒ you cannot stand, and no position can argue
|
|
1212
|
+
// otherwise. Deriving this from where the body happens to be let
|
|
1213
|
+
// a swimmer who rode high read as barely submerged and walk off
|
|
1214
|
+
// across the surface; the honest answer does not depend on how
|
|
1215
|
+
// buoyant the last frame was.
|
|
1216
|
+
1;
|
|
1217
|
+
const wasInAir = this._inAir;
|
|
1218
|
+
/*
|
|
1219
|
+
HOLDING BRACES YOU; RELEASING JUMPS.
|
|
1220
|
+
|
|
1221
|
+
The charge is TIME, and it scales the launch: release immediately and you
|
|
1222
|
+
*/
|
|
1223
|
+
// Read the charge BEFORE the guard below can clear it. On the release
|
|
1224
|
+
// frame `jumpDown` is already false, so clearing first meant `jumpLaunch`
|
|
1225
|
+
// never saw a charge and the jump could not fire at all — the same
|
|
1226
|
+
// read-then-clear ordering slip as the ground snap swallowing the impulse.
|
|
1227
|
+
/*
|
|
1228
|
+
JUMPS ARE INSTANTANEOUS. The animation set says so.
|
|
1229
|
+
|
|
1230
|
+
This used to crouch on press and launch on release, which was an
|
|
1231
|
+
adaptation to the stock rig's single one-shot clip that happened to open
|
|
1232
|
+
with a crouch. Quaternius ships the standard three phases — `Jump_Start`
|
|
1233
|
+
is a TAKEOFF, not a wind-up — and Tonio read it correctly: "it's basically
|
|
1234
|
+
designed for instantaneous jumps where once you jump you enter the jump
|
|
1235
|
+
state and that's it."
|
|
1236
|
+
|
|
1237
|
+
Which is also the platform-jumper contract in MOBILITY-DESIGN.md: the
|
|
1238
|
+
character does exactly what you pressed, now. Anticipation belongs to the
|
|
1239
|
+
intent model, where the character decides to jump before you ask — not to
|
|
1240
|
+
a button that makes you wait for it.
|
|
1241
|
+
*/
|
|
1242
|
+
const jumpPressed = jumpDown && !this._jumpWasDown;
|
|
1243
|
+
this._jumpWasDown = jumpDown;
|
|
1244
|
+
const moving = Math.abs(speed) > 0.1;
|
|
1245
|
+
const canJump = grounded && standSubmerged <= 0 && this._fallVel <= 0;
|
|
1246
|
+
const jumpLaunch = jumpPressed && canJump;
|
|
1247
|
+
/*
|
|
1248
|
+
PUSH INTO A LEDGE AND YOU CLIMB IT.
|
|
1249
|
+
|
|
1250
|
+
No button. Tonio: *"we basically want the biped rig to sense we've hit a
|
|
1251
|
+
ledge and it's too high to just step onto so let's climb onto it."* That
|
|
1252
|
+
is the intent model in MOBILITY-DESIGN — you steer at the bank and the
|
|
1253
|
+
character solves the terrain — and it is why this is checked from ordinary
|
|
1254
|
+
forward movement rather than bound to a key.
|
|
1255
|
+
|
|
1256
|
+
It answers the swimming complaint as a side effect rather than as a case:
|
|
1257
|
+
*"when you swim to the water's edge, you just pop instantly to the surface
|
|
1258
|
+
onto the land."* Climbing out of a pond IS mantling a lip of height h, so
|
|
1259
|
+
the bank, the low wall and the crate are one verb.
|
|
1260
|
+
|
|
1261
|
+
Gated on actually moving forward, so brushing a wall while strafing or
|
|
1262
|
+
turning does not launch a climb.
|
|
1263
|
+
*/
|
|
1264
|
+
/*
|
|
1265
|
+
PROBES ARE RATIONED, not run every frame.
|
|
1266
|
+
|
|
1267
|
+
This costs eight raycasts — five up the face plus three for the top,
|
|
1268
|
+
headroom and landing — and it is only the first of several features that
|
|
1269
|
+
will want to read the environment this way. Tonio: *"we're probably going
|
|
1270
|
+
to need a bunch of raycasts (maybe not sampled constantly) to handle
|
|
1271
|
+
tomb-raider style movement and cover discovery."*
|
|
1272
|
+
|
|
1273
|
+
At 12 Hz a ledge cannot be missed (you cover ~0.4 m between probes at a
|
|
1274
|
+
run) and the per-frame cost drops by four fifths. When cover discovery and
|
|
1275
|
+
vaulting arrive they should share ONE budgeted read of the surroundings
|
|
1276
|
+
rather than each growing their own fan of rays — see MOBILITY-DESIGN.
|
|
1277
|
+
*/
|
|
1278
|
+
this._ledgeCooldown -= dt;
|
|
1279
|
+
if (speed > 0.3 &&
|
|
1280
|
+
this._mantle == null &&
|
|
1281
|
+
this._ledgeCooldown <= 0 &&
|
|
1282
|
+
(grounded || this._swimming) &&
|
|
1283
|
+
((this._ledgeCooldown = 1 / 12), true) &&
|
|
1284
|
+
this._tryMantle(node,
|
|
1285
|
+
/*
|
|
1286
|
+
A SWIMMER REACHES FROM THE WATERLINE, NOT FROM THEIR FEET.
|
|
1287
|
+
|
|
1288
|
+
Treading water the feet dangle ~1.4 m down, and measuring the bank
|
|
1289
|
+
from there made every shore in the demo 3 m tall — beyond any
|
|
1290
|
+
plausible reach, so the climb declined and you were left bumping the
|
|
1291
|
+
wall. But you do not climb out of a pond with your legs; your hands
|
|
1292
|
+
are at the surface, which is where the reach starts.
|
|
1293
|
+
|
|
1294
|
+
Measured here: the banks stand 1.45–1.80 m above the water, i.e. a
|
|
1295
|
+
hard but possible pull-up from the waterline, and an impossible one
|
|
1296
|
+
from the feet. Same geometry, and only one of the two readings
|
|
1297
|
+
matches what a person would do.
|
|
1298
|
+
*/
|
|
1299
|
+
this._swimming && surfaceY != null ? surfaceY : node.position.y, attrs.climbReach, STEP_UP)) {
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (jumpLaunch)
|
|
1303
|
+
this._jumpClip = moving ? 'running-jump' : 'jump';
|
|
1304
|
+
if (submerged > 0) {
|
|
1305
|
+
/*
|
|
1306
|
+
In water, buoyancy owns the vertical and THE FLOOR IS ONLY A FLOOR: it
|
|
1307
|
+
stops you sinking past it, it does not hold you down. Snapping to it
|
|
1308
|
+
whenever it was in reach — which is what "grounded" meant a moment ago —
|
|
1309
|
+
stood the character on the seabed under six metres of water, technically
|
|
1310
|
+
grounded and visibly wrong.
|
|
1311
|
+
|
|
1312
|
+
DIVING: `sneak` takes you down, `jump` takes you up — crouch-to-descend
|
|
1313
|
+
matches the GTA-V control vocabulary this project follows, and leaves
|
|
1314
|
+
the triggers alone. Thrust competes with buoyancy rather than replacing
|
|
1315
|
+
it, so letting go hands the vertical back to physics instead of pinning
|
|
1316
|
+
you.
|
|
1317
|
+
|
|
1318
|
+
And once your head is properly under, buoyancy blends toward NEUTRAL
|
|
1319
|
+
(see `swimBuoyancy`) so you hold the depth you swam to, drifting up
|
|
1320
|
+
slowly rather than corking — Tonio's call: "holding with a slow drift
|
|
1321
|
+
upward by default." Note you also GLIDE a little deeper after releasing
|
|
1322
|
+
the control; that is momentum, not a bug, and letting go is not a brake.
|
|
1323
|
+
*/
|
|
1324
|
+
const headDepth = surfaceY - (feetY + bodyHeight);
|
|
1325
|
+
/*
|
|
1326
|
+
YOU CANNOT PUSH YOURSELF OUT OF WATER.
|
|
1327
|
+
|
|
1328
|
+
Surface thrust used to apply at any depth, so holding it lifted you
|
|
1329
|
+
until submersion fell under the swim threshold — at which point you were
|
|
1330
|
+
classed as standing, got a walk cycle, and could stroll across the
|
|
1331
|
+
surface. Tonio: "pressing up in water causes me to stand (it's allowing
|
|
1332
|
+
me to 'wade on water')."
|
|
1333
|
+
|
|
1334
|
+
So the up thrust is gated by head depth, exactly as the upward AIM is:
|
|
1335
|
+
full while properly under, fading to nothing as your head breaks the
|
|
1336
|
+
surface. Getting to the surface is buoyancy's job and it does it for
|
|
1337
|
+
free; this button exists to get you up when you are deep. Down is
|
|
1338
|
+
ungated — you can always dive.
|
|
1339
|
+
*/
|
|
1340
|
+
const upAllowed = surfaceAimLimit(headDepth, 1);
|
|
1341
|
+
const swimUp = (input.jump ?? 0) > 0.5 ? upAllowed : 0;
|
|
1342
|
+
const swimDown = (input.sneak ?? 0) > 0.5 ? 1 : 0;
|
|
1343
|
+
/*
|
|
1344
|
+
THE CLIP DECLARES THE WATERLINE.
|
|
1345
|
+
|
|
1346
|
+
Tonio's read, and the measurements agree exactly: the swim animations
|
|
1347
|
+
are authored with the ROOT AT WATER LEVEL. `Swim_Idle_Loop` spans −1.37
|
|
1348
|
+
to +0.50 about the root and `Swim_Fwd_Loop` −0.60 to +0.31 — float the
|
|
1349
|
+
root on the surface and the first is a tread with head and shoulders
|
|
1350
|
+
out, the second a crawl with the head just breaking. Both are right, and
|
|
1351
|
+
neither needed a number chosen by anyone.
|
|
1352
|
+
|
|
1353
|
+
So the equilibrium is not tuned, it is READ: a body rests where its
|
|
1354
|
+
displacement balances its weight, at submersion `1 / buoyancy`, so the
|
|
1355
|
+
buoyancy that parks the root on the surface is `height / -bottom`. The
|
|
1356
|
+
animation set therefore tunes its own flotation, which is the same
|
|
1357
|
+
argument as `_poseExtent` — a fact the content already knows, taken from
|
|
1358
|
+
the content rather than typed in beside it.
|
|
1359
|
+
|
|
1360
|
+
Below, `swimBuoyancy` blends this toward neutral as you go deeper, so
|
|
1361
|
+
diving still holds its depth; this only sets where the SURFACE is.
|
|
1362
|
+
*/
|
|
1363
|
+
const poseBuoyancy = this._swimWaterline(pose, attrs.buoyancy);
|
|
1364
|
+
this._fallVel = buoyantStep(this._fallVel, submerged, dt, {
|
|
1365
|
+
buoyancy: swimBuoyancy(headDepth, { buoyancy: poseBuoyancy }),
|
|
1366
|
+
thrust: (swimUp - swimDown) * SWIM_THRUST,
|
|
1367
|
+
});
|
|
1368
|
+
let nextY = node.position.y + this._fallVel * dt;
|
|
1369
|
+
let onFloor = false;
|
|
1370
|
+
// The floor stops the body's LOWEST point, which in a swim pose is the
|
|
1371
|
+
// trailing legs rather than the root — treading, they hang 1.37 m below
|
|
1372
|
+
// it. Clamping the root instead buried them in the seabed.
|
|
1373
|
+
if (nextY + bodyBottom <= groundY) {
|
|
1374
|
+
nextY = groundY - bodyBottom;
|
|
1375
|
+
this._fallVel = 0;
|
|
1376
|
+
onFloor = true;
|
|
1377
|
+
}
|
|
1378
|
+
node.position.y = nextY;
|
|
1379
|
+
this._swimming = isSwimming(standSubmerged, onFloor, this._swimming);
|
|
1380
|
+
}
|
|
1381
|
+
else if (jumpLaunch && grounded) {
|
|
1382
|
+
this._swimming = false;
|
|
1383
|
+
this._fallVel = attrs.jumpSpeed;
|
|
1384
|
+
this._airPhase = 'start';
|
|
1385
|
+
this._phaseLeft = this._clipSeconds(this._jumpClip);
|
|
1386
|
+
this.setAnimationState(this._jumpClip);
|
|
1387
|
+
node.moveWithCollisions(new BABYLON.Vector3(0, this._fallVel * dt, 0));
|
|
1388
|
+
this._inAir = true;
|
|
1389
|
+
}
|
|
1390
|
+
else if (grounded && this._fallVel <= 0) {
|
|
1391
|
+
/*
|
|
1392
|
+
THE SNAP HAS TO YIELD WHILE YOU ARE RISING.
|
|
1393
|
+
|
|
1394
|
+
Snapping the feet to the ground is unconditional the rest of the time,
|
|
1395
|
+
and that swallowed the jump whole: the impulse lifted the body ~7 cm,
|
|
1396
|
+
the probe still saw ground 0.6 m below on the next frame, and the snap
|
|
1397
|
+
put it straight back. Measured — a jump that rose exactly 0.00 m.
|
|
1398
|
+
|
|
1399
|
+
So the ground only claims you when you are not moving away from it.
|
|
1400
|
+
`_fallVel <= 0` is the whole condition: falling or at rest, snap; rising,
|
|
1401
|
+
let ballistics have it.
|
|
1402
|
+
*/
|
|
1403
|
+
this._swimming = false;
|
|
1404
|
+
this._inAir = false;
|
|
1405
|
+
node.position.y = groundY;
|
|
1406
|
+
this._fallVel = 0;
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
this._swimming = false;
|
|
1410
|
+
// Terminal velocity keeps one slow frame from teleporting a biped
|
|
1411
|
+
// through the floor, on top of the probe extension above.
|
|
1412
|
+
this._fallVel = Math.max(-20, this._fallVel - 9.81 * dt);
|
|
1413
|
+
node.moveWithCollisions(new BABYLON.Vector3(0, this._fallVel * dt, 0));
|
|
1414
|
+
this._inAir = true;
|
|
1415
|
+
}
|
|
1416
|
+
if (submerged > 0)
|
|
1417
|
+
this._inAir = false;
|
|
1418
|
+
/*
|
|
1419
|
+
START → LOOP → LAND, each for as long as it is actually true.
|
|
1420
|
+
|
|
1421
|
+
`Jump_Start` runs its own length; `Jump_Loop` then covers however long the
|
|
1422
|
+
arc lasts, whatever the launch speed; `Jump_Land` plays on touchdown and
|
|
1423
|
+
releases back to normal locomotion when it finishes. Nothing is stretched
|
|
1424
|
+
to fit, which is why the `speedRatio` retiming could go.
|
|
1425
|
+
|
|
1426
|
+
Touchdown is the airborne→grounded EDGE rather than a velocity test, so a
|
|
1427
|
+
jump interrupted by a ceiling or a slope still lands.
|
|
1428
|
+
*/
|
|
1429
|
+
if (this._airPhase != null) {
|
|
1430
|
+
this._phaseLeft -= dt;
|
|
1431
|
+
if (wasInAir && !this._inAir) {
|
|
1432
|
+
this._airPhase = 'land';
|
|
1433
|
+
this._phaseLeft = this._clipSeconds('jumpLand') || 0.3;
|
|
1434
|
+
}
|
|
1435
|
+
else if (this._airPhase === 'start' && this._phaseLeft <= 0) {
|
|
1436
|
+
this._airPhase = this._inAir ? 'loop' : null;
|
|
1437
|
+
}
|
|
1438
|
+
else if (this._airPhase === 'land' && this._phaseLeft <= 0) {
|
|
1439
|
+
this._airPhase = null;
|
|
1440
|
+
}
|
|
1441
|
+
else if (this._airPhase === 'loop' && !this._inAir) {
|
|
1442
|
+
this._airPhase = 'land';
|
|
1443
|
+
this._phaseLeft = this._clipSeconds('jumpLand') || 0.3;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
/*
|
|
1447
|
+
LOOK-DIRECTED SWIMMING: pitch the BODY, and the stroke follows.
|
|
1448
|
+
|
|
1449
|
+
The biped already swims along its own forward vector, so tilting the body
|
|
1450
|
+
tilts the movement — one rotation, no separate vertical term for the
|
|
1451
|
+
stroke, and no way for the aim and the motion to disagree. It also fixes
|
|
1452
|
+
the thing that made swimming read wrong even when it worked: a character
|
|
1453
|
+
moving downward while standing bolt upright.
|
|
1454
|
+
|
|
1455
|
+
Aim comes from your HEAD in a headset (that is what look-directed means
|
|
1456
|
+
when you have a neck) and from the right stick flat, because the biped's
|
|
1457
|
+
follow camera has a fixed pitch and there is simply nothing to read. Both
|
|
1458
|
+
land in the same stored value, so nothing downstream knows which.
|
|
1459
|
+
|
|
1460
|
+
Rebuilt as yaw+pitch rather than rotated incrementally: turning is
|
|
1461
|
+
`node.rotate(UP)` accumulating into the quaternion, so the yaw is read
|
|
1462
|
+
back out of it and re-composed with the pitch. Incremental pitching would
|
|
1463
|
+
drift and eventually roll.
|
|
1464
|
+
*/
|
|
1465
|
+
const headCam = this.owner?.scene.activeCamera;
|
|
1466
|
+
if (this._swimming && this.xrStuff && headCam != null) {
|
|
1467
|
+
const look = headCam.getDirection(LOCAL_FORWARD);
|
|
1468
|
+
this._swimAim = aimFromLook(look.y);
|
|
1469
|
+
}
|
|
1470
|
+
else if (this._swimming) {
|
|
1471
|
+
/*
|
|
1472
|
+
The aim IS the look now, rather than a second thing integrated from the
|
|
1473
|
+
same stick. That makes "swim where you are looking" literally true on a
|
|
1474
|
+
flat screen, and it is the same rule the headset already followed — the
|
|
1475
|
+
head there, the camera here, one source of truth either way.
|
|
1476
|
+
|
|
1477
|
+
Sign: `_lookPitch` is positive UP (it raises the view), and swim aim is
|
|
1478
|
+
positive DOWN to match the quaternion. Hence the negation, once, here.
|
|
1479
|
+
*/
|
|
1480
|
+
this._swimAim = clampAim(-this._lookPitch, attrs.maxLookPitch);
|
|
1481
|
+
}
|
|
1482
|
+
/*
|
|
1483
|
+
You cannot swim up out of water, and trying is not merely useless — the
|
|
1484
|
+
stroke fights the surface and the body porpoises. Cap the UPWARD aim by
|
|
1485
|
+
how deep the head is: none at the surface, full once properly under.
|
|
1486
|
+
Downward is never capped, so diving always works.
|
|
1487
|
+
*/
|
|
1488
|
+
if (this._swimming) {
|
|
1489
|
+
const up = surfaceAimLimit(surfaceY - (node.position.y + bodyHeight), attrs.maxLookPitch);
|
|
1490
|
+
if (this._swimAim < -up)
|
|
1491
|
+
this._swimAim = -up;
|
|
1492
|
+
}
|
|
1493
|
+
const target = aimTarget(this._swimming, this._swimAim);
|
|
1494
|
+
this._swimPitch = easeAim(this._swimPitch, target, dt);
|
|
1495
|
+
const pitched = Math.abs(this._swimPitch) > 0.01;
|
|
1496
|
+
if (pitched || this._swimWasPitched) {
|
|
1497
|
+
/*
|
|
1498
|
+
DO NOT READ THE YAW BACK OUT OF A PITCHED MATRIX.
|
|
1499
|
+
|
|
1500
|
+
`atan2(forward.x, forward.z)` is fine while level and ill-conditioned
|
|
1501
|
+
while pitched: at 70° the forward vector's horizontal part is scaled by
|
|
1502
|
+
`cos 70° = 0.34`, so x and z collapse toward zero and the recovered yaw
|
|
1503
|
+
gets noisy — then it is written straight back, so the noise compounds
|
|
1504
|
+
into a body that wanders or spins. You only reach it by pitching AND
|
|
1505
|
+
turning at once, which is one stick on a controller and two hands on a
|
|
1506
|
+
keyboard, so it hid from every test I ran ("this was happening with
|
|
1507
|
+
joystick").
|
|
1508
|
+
|
|
1509
|
+
So the yaw is CAPTURED ONCE from the level matrix, on the frame the
|
|
1510
|
+
pitch starts, and integrated from the turn input after that. Well
|
|
1511
|
+
conditioned by construction: the only reading happens while level.
|
|
1512
|
+
*/
|
|
1513
|
+
if (!this._swimWasPitched) {
|
|
1514
|
+
node.computeWorldMatrix(true);
|
|
1515
|
+
node.getDirectionToRef(LOCAL_FORWARD, _fwdScratch);
|
|
1516
|
+
this._bodyYaw = Math.atan2(_fwdScratch.x, _fwdScratch.z);
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
this._bodyYaw += rotation * dt * attrs.turnSpeed * DEG_TO_RAD;
|
|
1520
|
+
}
|
|
1521
|
+
this._swimWasPitched = pitched;
|
|
1522
|
+
const yaw = this._bodyYaw;
|
|
1523
|
+
/*
|
|
1524
|
+
THE `__root__` MIRROR FLIPS THE PITCH SIGN.
|
|
1525
|
+
|
|
1526
|
+
A glTF root arrives with `scaling.z = -1` — the handedness mirror. The
|
|
1527
|
+
scale is applied in LOCAL space, so world forward is `-(R·ẑ)`, and the
|
|
1528
|
+
y component comes out `+sin(pitch)` where a clean node gives `-sin`.
|
|
1529
|
+
Aiming down therefore swam the character UP, measured: aim +70°, world
|
|
1530
|
+
forward.y +0.94, and a 12.9 m ASCENT.
|
|
1531
|
+
|
|
1532
|
+
Two things fall out, and only one of them is a bug:
|
|
1533
|
+
|
|
1534
|
+
- The pitch needs the mirror applied. Hence `zSign`, read from the node
|
|
1535
|
+
rather than hard-coded, so a canonicalised (unmirrored) root is right
|
|
1536
|
+
too — `canonicalize` strips this exact mirror, and the biped's load
|
|
1537
|
+
path does not go through it.
|
|
1538
|
+
- The YAW is fine untouched, which is why turning never looked wrong.
|
|
1539
|
+
The mirror also turns the yaw by π, but it is read back OUT of the
|
|
1540
|
+
same mirrored matrix a line above, so it round-trips exactly.
|
|
1541
|
+
|
|
1542
|
+
Verified against a clean node in the same scene rather than reasoned
|
|
1543
|
+
about: identical quaternion, opposite sign.
|
|
1544
|
+
*/
|
|
1545
|
+
const zSign = node.scaling.z < 0 ? -1 : 1;
|
|
1546
|
+
/*
|
|
1547
|
+
THE YAW NEEDS THE MIRROR TOO, and I got this wrong the first time.
|
|
1548
|
+
|
|
1549
|
+
With `scaling.z = -1` the world forward is `-(R·ẑ)`, so reading it back
|
|
1550
|
+
gives `θ + π`. I argued that writing that value returned the same
|
|
1551
|
+
heading — it does not. Writing `Ryaw(θ + π)` yields a forward of
|
|
1552
|
+
`-(R'·ẑ) = -F`: the body faces exactly backwards. On land nothing
|
|
1553
|
+
rewrites the quaternion, so it never showed; the instant pitch engages —
|
|
1554
|
+
which is the instant you enter water — the character flips. Reported as
|
|
1555
|
+
"when I enter the water my direction gets flipped".
|
|
1556
|
+
|
|
1557
|
+
So the same π that the read introduced is removed on the write. Both
|
|
1558
|
+
halves of the mirror are now accounted for: π on the yaw, a sign on the
|
|
1559
|
+
pitch.
|
|
1560
|
+
*/
|
|
1561
|
+
node.rotationQuaternion = BABYLON.Quaternion.RotationYawPitchRoll(yaw + (zSign < 0 ? Math.PI : 0), (this._swimPitch * zSign * Math.PI) / 180, 0);
|
|
312
1562
|
}
|
|
313
1563
|
// Void-catch backstop: never sink below groundY, so a biped can't fall forever
|
|
314
1564
|
// when a scene has no collidable ground at all. Default is DEEP (see
|
|
@@ -316,7 +1566,58 @@ export class B3dBiped extends B3dControllable {
|
|
|
316
1566
|
// or water — real collidable surfaces above it ground the biped via the probe.
|
|
317
1567
|
if (node.position.y < attrs.groundY)
|
|
318
1568
|
node.position.y = attrs.groundY;
|
|
319
|
-
if (
|
|
1569
|
+
if (this._swimming) {
|
|
1570
|
+
// Moving = swim, holding station = tread water. Both are in the standard
|
|
1571
|
+
// animation set (`swim`, `tread-water`), so this is wiring, not art.
|
|
1572
|
+
if (Math.abs(speed) > 0.1) {
|
|
1573
|
+
this.setAnimationState('swim', Math.abs(speed) + 0.25);
|
|
1574
|
+
}
|
|
1575
|
+
else {
|
|
1576
|
+
this.setAnimationState('tread-water');
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
else if (this._airPhase != null) {
|
|
1580
|
+
/*
|
|
1581
|
+
Three clips, each for as long as it is true — not one clip stretched to
|
|
1582
|
+
fit. `Jump_Start` plays out, `Jump_Loop` covers however long the arc
|
|
1583
|
+
actually lasts, and `Jump_Land` plays on touchdown. That retires the
|
|
1584
|
+
`speedRatio` retiming, which existed only because a single clip had to
|
|
1585
|
+
span a flight whose duration it could not know.
|
|
1586
|
+
*/
|
|
1587
|
+
const want = this._airPhase === 'start'
|
|
1588
|
+
? this._jumpClip
|
|
1589
|
+
: this._airPhase === 'loop'
|
|
1590
|
+
? 'jumpLoop'
|
|
1591
|
+
: 'jumpLand';
|
|
1592
|
+
const has = this.animationStates.some((st) => st.name === want);
|
|
1593
|
+
this.setAnimationState(has ? want : this._jumpClip);
|
|
1594
|
+
}
|
|
1595
|
+
else if (Math.abs(strafe) > 0.1 && Math.abs(strafe) > Math.abs(speed)) {
|
|
1596
|
+
/*
|
|
1597
|
+
SIDESTEPPING HAS ITS OWN CLIPS — use them.
|
|
1598
|
+
|
|
1599
|
+
Playing the forward walk while moving sideways is the slide Tonio
|
|
1600
|
+
reported, and it is not a missing-asset problem: UAL ships a full
|
|
1601
|
+
eight-way set (Fwd, Fwd_L/R, Left, Right, Bwd, Bwd_L/R) for jog, crouch
|
|
1602
|
+
AND crawl. This picks the lateral one when sideways motion dominates.
|
|
1603
|
+
|
|
1604
|
+
Guarded by `hasState`, because a rig without these clips must degrade to
|
|
1605
|
+
walking rather than to `setAnimationState` logging an error every frame.
|
|
1606
|
+
*/
|
|
1607
|
+
const side = strafe > 0 ? 'Right' : 'Left';
|
|
1608
|
+
const want = this._sneaking ? `sneak${side}` : `strafe${side}`;
|
|
1609
|
+
const speedScale = Math.abs(strafe) + 0.25;
|
|
1610
|
+
if (this.animationStates.some((st) => st.name === want)) {
|
|
1611
|
+
this.setAnimationState(want, speedScale);
|
|
1612
|
+
}
|
|
1613
|
+
else {
|
|
1614
|
+
this.setAnimationState(this._sneaking ? 'sneak' : 'walk', speedScale);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
else if (this._sneaking && Math.abs(speed) > 0.1) {
|
|
1618
|
+
this.setAnimationState('sneak', Math.abs(speed) + 0.25);
|
|
1619
|
+
}
|
|
1620
|
+
else if (speed > 0.1) {
|
|
320
1621
|
if (sprintSpeed > 0.25) {
|
|
321
1622
|
this.setAnimationState('run', sprintSpeed + 0.25);
|
|
322
1623
|
}
|
|
@@ -327,6 +1628,12 @@ export class B3dBiped extends B3dControllable {
|
|
|
327
1628
|
else if (speed < -0.1) {
|
|
328
1629
|
this.setAnimationState('walkBackwards', Math.abs(speed) + 0.25);
|
|
329
1630
|
}
|
|
1631
|
+
else if (this._sneaking &&
|
|
1632
|
+
this.animationStates.some((st) => st.name === 'sneakIdle')) {
|
|
1633
|
+
// A crouch that HOLDS at rest — the stock rig had no such clip, so
|
|
1634
|
+
// standing still while sneaking used to stand you up.
|
|
1635
|
+
this.setAnimationState('sneakIdle');
|
|
1636
|
+
}
|
|
330
1637
|
else if (Math.abs(rotation) > 0.1) {
|
|
331
1638
|
this.setAnimationState('walk', Math.abs(rotation * 0.5) + 0.25);
|
|
332
1639
|
}
|
|
@@ -402,10 +1709,27 @@ export class B3dBiped extends B3dControllable {
|
|
|
402
1709
|
// Zoom: 0 = (1 back, 1 up), 1 = (5 back, 2 up), default 0.25 = (2 back, 1.25 up)
|
|
403
1710
|
const backDist = lerp(1, 5, this.xrCamZoom);
|
|
404
1711
|
const upDist = lerp(1, 2, this.xrCamZoom);
|
|
1712
|
+
/*
|
|
1713
|
+
VERTICAL LOOK HAS TO MOVE THE RIG IN XR, because there is nothing else for
|
|
1714
|
+
it to move. Flat, `lookY` tilts the FollowCamera via `heightOffset`; in a
|
|
1715
|
+
headset there is no FollowCamera, so it did nothing — Tonio, from the
|
|
1716
|
+
goggles: *"the right stick isn't tilting the view vertically any more,
|
|
1717
|
+
only rotating the character horizontally."*
|
|
1718
|
+
|
|
1719
|
+
A regression I caused. The XR rig's height comes from `xrCamZoom`, which
|
|
1720
|
+
the right stick used to drive; moving zoom onto the D-pad left the axis
|
|
1721
|
+
free for `lookY`, and XR controllers have no D-pad — so the headset lost
|
|
1722
|
+
both the zoom and the tilt in one move.
|
|
1723
|
+
|
|
1724
|
+
Same formula as flat so the two feel alike: raising the view lifts the rig
|
|
1725
|
+
and looks down on the character. Clamped, because `tan` runs away near the
|
|
1726
|
+
70° limit and the flat version is bounded by a shorter camera arm.
|
|
1727
|
+
*/
|
|
1728
|
+
const lookLift = Math.max(-1.5, Math.min(6, Math.tan((-this._lookPitch * Math.PI) / 180) * backDist * 0.6));
|
|
405
1729
|
// Target position: behind and above the character
|
|
406
1730
|
const behind = node.forward.scale(-backDist);
|
|
407
1731
|
const targetX = node.position.x + behind.x;
|
|
408
|
-
const targetY = node.position.y + upDist;
|
|
1732
|
+
const targetY = node.position.y + upDist + lookLift;
|
|
409
1733
|
const targetZ = node.position.z + behind.z;
|
|
410
1734
|
// Target yaw: face same direction as character
|
|
411
1735
|
const fwd = node.forward;
|
|
@@ -619,8 +1943,28 @@ export class B3dBiped extends B3dControllable {
|
|
|
619
1943
|
this.mesh
|
|
620
1944
|
.getChildTransformNodes(false)
|
|
621
1945
|
.find((n) => /head/i.test(n.name) && !(n instanceof BABYLON.AbstractMesh)) ?? null;
|
|
1946
|
+
/*
|
|
1947
|
+
THE COLLISION BODY STARTS A STEP ABOVE THE FEET.
|
|
1948
|
+
|
|
1949
|
+
Its bottom used to sit exactly ON the feet, which was survivable only
|
|
1950
|
+
because the old ground check left the biped floating somewhere in a
|
|
1951
|
+
15 cm dead band — that float WAS the clearance. Snapping the feet onto
|
|
1952
|
+
the surface removed the float and, with it, the clearance: moving into
|
|
1953
|
+
rising ground embedded the ellipsoid in the slope, Babylon refused the
|
|
1954
|
+
move, and you stopped dead. Tonio: "you can get stuck on sloped
|
|
1955
|
+
surfaces."
|
|
1956
|
+
|
|
1957
|
+
Raising the offset by `STEP_OFFSET` is the standard fix (it is Unity's
|
|
1958
|
+
Step Offset): the capsule ignores anything lower than a step, so a slope
|
|
1959
|
+
rising under you is not an obstacle, and the ground probe puts the feet
|
|
1960
|
+
on the surface afterwards. A wall is still a wall — the capsule above
|
|
1961
|
+
the step height is unchanged.
|
|
1962
|
+
|
|
1963
|
+
Kept below `STEP_UP` (the probe's reach), so anything the body walks over
|
|
1964
|
+
is something the probe can then stand you on.
|
|
1965
|
+
*/
|
|
622
1966
|
this.mesh.ellipsoid = new BABYLON.Vector3(0.3, 0.75, 0.3);
|
|
623
|
-
this.mesh.ellipsoidOffset = new BABYLON.Vector3(0, 0.75, 0);
|
|
1967
|
+
this.mesh.ellipsoidOffset = new BABYLON.Vector3(0, 0.75 + STEP_OFFSET, 0);
|
|
624
1968
|
this.mesh.checkCollisions = true;
|
|
625
1969
|
owner.register({ meshes });
|
|
626
1970
|
// Skin materials that export as alphaMode MASK + base-color alpha 0 (an FBX
|