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.
Files changed (153) hide show
  1. package/CHANGELOG.md +900 -0
  2. package/dist/b3d-aircraft.d.ts +14 -0
  3. package/dist/b3d-aircraft.d.ts.map +1 -1
  4. package/dist/b3d-aircraft.js +45 -0
  5. package/dist/b3d-aircraft.js.map +1 -1
  6. package/dist/b3d-ambient.d.ts +12 -0
  7. package/dist/b3d-ambient.d.ts.map +1 -1
  8. package/dist/b3d-ambient.js +83 -1
  9. package/dist/b3d-ambient.js.map +1 -1
  10. package/dist/b3d-biped.d.ts +290 -0
  11. package/dist/b3d-biped.d.ts.map +1 -1
  12. package/dist/b3d-biped.js +1366 -22
  13. package/dist/b3d-biped.js.map +1 -1
  14. package/dist/b3d-collisions.d.ts.map +1 -1
  15. package/dist/b3d-collisions.js +45 -7
  16. package/dist/b3d-collisions.js.map +1 -1
  17. package/dist/b3d-controllable.d.ts +50 -0
  18. package/dist/b3d-controllable.d.ts.map +1 -1
  19. package/dist/b3d-controllable.js +61 -0
  20. package/dist/b3d-controllable.js.map +1 -1
  21. package/dist/b3d-death.d.ts +26 -0
  22. package/dist/b3d-death.d.ts.map +1 -1
  23. package/dist/b3d-death.js +224 -2
  24. package/dist/b3d-death.js.map +1 -1
  25. package/dist/b3d-interactive.d.ts +80 -0
  26. package/dist/b3d-interactive.d.ts.map +1 -0
  27. package/dist/b3d-interactive.js +284 -0
  28. package/dist/b3d-interactive.js.map +1 -0
  29. package/dist/b3d-library.d.ts +39 -0
  30. package/dist/b3d-library.d.ts.map +1 -1
  31. package/dist/b3d-library.js +67 -0
  32. package/dist/b3d-library.js.map +1 -1
  33. package/dist/b3d-svg-plane.d.ts +35 -0
  34. package/dist/b3d-svg-plane.d.ts.map +1 -1
  35. package/dist/b3d-svg-plane.js +96 -8
  36. package/dist/b3d-svg-plane.js.map +1 -1
  37. package/dist/b3d-water.d.ts.map +1 -1
  38. package/dist/b3d-water.js +32 -6
  39. package/dist/b3d-water.js.map +1 -1
  40. package/dist/buoyancy.d.ts +90 -0
  41. package/dist/buoyancy.d.ts.map +1 -0
  42. package/dist/buoyancy.js +150 -0
  43. package/dist/buoyancy.js.map +1 -0
  44. package/dist/curve-field.d.ts +39 -0
  45. package/dist/curve-field.d.ts.map +1 -0
  46. package/dist/curve-field.js +491 -0
  47. package/dist/curve-field.js.map +1 -0
  48. package/dist/curve.d.ts +241 -0
  49. package/dist/curve.d.ts.map +1 -0
  50. package/dist/curve.js +559 -0
  51. package/dist/curve.js.map +1 -0
  52. package/dist/dialog-placement.d.ts +69 -7
  53. package/dist/dialog-placement.d.ts.map +1 -1
  54. package/dist/dialog-placement.js +90 -9
  55. package/dist/dialog-placement.js.map +1 -1
  56. package/dist/embed-font.d.ts +48 -0
  57. package/dist/embed-font.d.ts.map +1 -0
  58. package/dist/embed-font.js +135 -0
  59. package/dist/embed-font.js.map +1 -0
  60. package/dist/footprint-field.d.ts +30 -0
  61. package/dist/footprint-field.d.ts.map +1 -0
  62. package/dist/footprint-field.js +256 -0
  63. package/dist/footprint-field.js.map +1 -0
  64. package/dist/frame-panel.d.ts.map +1 -1
  65. package/dist/frame-panel.js +15 -12
  66. package/dist/frame-panel.js.map +1 -1
  67. package/dist/glb-manifest.d.ts +75 -0
  68. package/dist/glb-manifest.d.ts.map +1 -0
  69. package/dist/glb-manifest.js +198 -0
  70. package/dist/glb-manifest.js.map +1 -0
  71. package/dist/icon-data.d.ts.map +1 -1
  72. package/dist/icon-data.js +20 -1
  73. package/dist/icon-data.js.map +1 -1
  74. package/dist/index.d.ts +33 -4
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +38 -3
  77. package/dist/index.js.map +1 -1
  78. package/dist/interaction.d.ts +66 -0
  79. package/dist/interaction.d.ts.map +1 -0
  80. package/dist/interaction.js +117 -0
  81. package/dist/interaction.js.map +1 -0
  82. package/dist/interactive-behavior.d.ts +105 -0
  83. package/dist/interactive-behavior.d.ts.map +1 -0
  84. package/dist/interactive-behavior.js +253 -0
  85. package/dist/interactive-behavior.js.map +1 -0
  86. package/dist/key-layout.d.ts +70 -0
  87. package/dist/key-layout.d.ts.map +1 -1
  88. package/dist/key-layout.js +122 -0
  89. package/dist/key-layout.js.map +1 -1
  90. package/dist/keyboard-gamepad.d.ts.map +1 -1
  91. package/dist/keyboard-gamepad.js +19 -6
  92. package/dist/keyboard-gamepad.js.map +1 -1
  93. package/dist/keyboard.d.ts +100 -1
  94. package/dist/keyboard.d.ts.map +1 -1
  95. package/dist/keyboard.js +280 -35
  96. package/dist/keyboard.js.map +1 -1
  97. package/dist/mantle.d.ts +56 -0
  98. package/dist/mantle.d.ts.map +1 -0
  99. package/dist/mantle.js +115 -0
  100. package/dist/mantle.js.map +1 -0
  101. package/dist/popup-surface.d.ts +5 -1
  102. package/dist/popup-surface.d.ts.map +1 -1
  103. package/dist/popup-surface.js +181 -14
  104. package/dist/popup-surface.js.map +1 -1
  105. package/dist/surface.d.ts.map +1 -1
  106. package/dist/surface.js +111 -15
  107. package/dist/surface.js.map +1 -1
  108. package/dist/svg-icons.d.ts.map +1 -1
  109. package/dist/svg-icons.js +13 -1
  110. package/dist/svg-icons.js.map +1 -1
  111. package/dist/svg-texture.d.ts.map +1 -1
  112. package/dist/svg-texture.js +40 -1
  113. package/dist/svg-texture.js.map +1 -1
  114. package/dist/swim-aim.d.ts +78 -0
  115. package/dist/swim-aim.d.ts.map +1 -0
  116. package/dist/swim-aim.js +140 -0
  117. package/dist/swim-aim.js.map +1 -0
  118. package/dist/theme-editor.d.ts +41 -0
  119. package/dist/theme-editor.d.ts.map +1 -0
  120. package/dist/theme-editor.js +303 -0
  121. package/dist/theme-editor.js.map +1 -0
  122. package/dist/tosi-b3d.d.ts +24 -4
  123. package/dist/tosi-b3d.d.ts.map +1 -1
  124. package/dist/tosi-b3d.js +238 -37
  125. package/dist/tosi-b3d.js.map +1 -1
  126. package/dist/vector-field.d.ts +58 -0
  127. package/dist/vector-field.d.ts.map +1 -0
  128. package/dist/vector-field.js +321 -0
  129. package/dist/vector-field.js.map +1 -0
  130. package/dist/virtual-gamepad.d.ts.map +1 -1
  131. package/dist/virtual-gamepad.js +46 -9
  132. package/dist/virtual-gamepad.js.map +1 -1
  133. package/dist/w3d-theme.d.ts +115 -17
  134. package/dist/w3d-theme.d.ts.map +1 -1
  135. package/dist/w3d-theme.js +311 -0
  136. package/dist/w3d-theme.js.map +1 -1
  137. package/dist/water-normal.d.ts +26 -0
  138. package/dist/water-normal.d.ts.map +1 -0
  139. package/dist/water-normal.js +105 -0
  140. package/dist/water-normal.js.map +1 -0
  141. package/dist/widgets3d-layout.d.ts +58 -0
  142. package/dist/widgets3d-layout.d.ts.map +1 -1
  143. package/dist/widgets3d-layout.js +79 -1
  144. package/dist/widgets3d-layout.js.map +1 -1
  145. package/dist/widgets3d.d.ts +51 -2
  146. package/dist/widgets3d.d.ts.map +1 -1
  147. package/dist/widgets3d.js +418 -112
  148. package/dist/widgets3d.js.map +1 -1
  149. package/dist/wreck-fall.d.ts +86 -0
  150. package/dist/wreck-fall.d.ts.map +1 -0
  151. package/dist/wreck-fall.js +129 -0
  152. package/dist/wreck-fall.js.map +1 -0
  153. 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
- cameraHeightOffset: 1,
156
- cameraTargetHeight: 0.75,
157
- cameraMinFollowDistance: 2,
158
- cameraMaxFollowDistance: 5,
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
- const idx = this.entries.animationGroups.findIndex((g) => g.name.endsWith(newState.animation));
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
- const rotation = input.turn;
283
- const sprint = input.sprint;
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 totalSpeed = speed * attrs.forwardSpeed +
286
- sprintSpeed * (attrs.runSpeed - attrs.forwardSpeed);
287
- // Camera zoom from input
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, input.cameraZoom)));
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
- // Gravity: only apply if not grounded (raycast down from feet)
305
- const feetY = node.position.y + 0.05;
306
- const rayOrigin = new BABYLON.Vector3(node.position.x, feetY, node.position.z);
307
- const ray = new BABYLON.Ray(rayOrigin, BABYLON.Vector3.Down(), 0.15);
308
- const hit = this.owner.scene.pickWithRay(ray, (m) => m !== node && m.checkCollisions);
309
- if (!hit?.hit) {
310
- const gravity = Math.min(0.1, 9.81 * dt);
311
- node.moveWithCollisions(new BABYLON.Vector3(0, -gravity, 0));
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 (speed > 0.1) {
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