@driftengine/physics 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +503 -0
  4. package/dist/bodies.d.ts +121 -0
  5. package/dist/bodies.js +298 -0
  6. package/dist/buoyancy.d.ts +46 -0
  7. package/dist/buoyancy.js +45 -0
  8. package/dist/cloth.d.ts +226 -0
  9. package/dist/cloth.js +587 -0
  10. package/dist/collide/aabb.d.ts +10 -0
  11. package/dist/collide/aabb.js +11 -0
  12. package/dist/collide/body.d.ts +202 -0
  13. package/dist/collide/body.js +142 -0
  14. package/dist/collide/eject.d.ts +39 -0
  15. package/dist/collide/eject.js +142 -0
  16. package/dist/collide/index.d.ts +18 -0
  17. package/dist/collide/index.js +5 -0
  18. package/dist/collide/scratch.d.ts +8 -0
  19. package/dist/collide/scratch.js +9 -0
  20. package/dist/collide/segment.d.ts +12 -0
  21. package/dist/collide/segment.js +109 -0
  22. package/dist/collide/sweep.d.ts +3 -0
  23. package/dist/collide/sweep.js +1036 -0
  24. package/dist/colliderSet.d.ts +239 -0
  25. package/dist/colliderSet.js +546 -0
  26. package/dist/compoundContact.d.ts +70 -0
  27. package/dist/compoundContact.js +221 -0
  28. package/dist/controller.d.ts +351 -0
  29. package/dist/controller.js +641 -0
  30. package/dist/cylinder.d.ts +78 -0
  31. package/dist/cylinder.js +294 -0
  32. package/dist/cylinderContact.d.ts +7 -0
  33. package/dist/cylinderContact.js +704 -0
  34. package/dist/decompose.d.ts +105 -0
  35. package/dist/decompose.js +979 -0
  36. package/dist/events.d.ts +43 -0
  37. package/dist/events.js +95 -0
  38. package/dist/executor.d.ts +147 -0
  39. package/dist/executor.js +173 -0
  40. package/dist/faces.d.ts +26 -0
  41. package/dist/faces.js +34 -0
  42. package/dist/fingerprint.d.ts +61 -0
  43. package/dist/fingerprint.js +139 -0
  44. package/dist/groundMotor.d.ts +34 -0
  45. package/dist/groundMotor.js +41 -0
  46. package/dist/heightfieldShape.d.ts +128 -0
  47. package/dist/heightfieldShape.js +328 -0
  48. package/dist/index.d.ts +73 -0
  49. package/dist/index.js +47 -0
  50. package/dist/island.d.ts +55 -0
  51. package/dist/island.js +227 -0
  52. package/dist/islandSolve.d.ts +70 -0
  53. package/dist/islandSolve.js +171 -0
  54. package/dist/islandWorker.d.ts +18 -0
  55. package/dist/islandWorker.js +125 -0
  56. package/dist/joints.d.ts +180 -0
  57. package/dist/joints.js +1094 -0
  58. package/dist/manifold.d.ts +89 -0
  59. package/dist/manifold.js +608 -0
  60. package/dist/mass.d.ts +53 -0
  61. package/dist/mass.js +353 -0
  62. package/dist/meshContact.d.ts +57 -0
  63. package/dist/meshContact.js +393 -0
  64. package/dist/meshShape.d.ts +76 -0
  65. package/dist/meshShape.js +175 -0
  66. package/dist/pairs.d.ts +62 -0
  67. package/dist/pairs.js +154 -0
  68. package/dist/query.d.ts +71 -0
  69. package/dist/query.js +683 -0
  70. package/dist/ragdoll.d.ts +135 -0
  71. package/dist/ragdoll.js +489 -0
  72. package/dist/shape.d.ts +177 -0
  73. package/dist/shape.js +533 -0
  74. package/dist/solver.d.ts +136 -0
  75. package/dist/solver.js +601 -0
  76. package/dist/stage.d.ts +167 -0
  77. package/dist/stage.js +350 -0
  78. package/dist/stallEscape.d.ts +141 -0
  79. package/dist/stallEscape.js +254 -0
  80. package/dist/sweptStep.d.ts +173 -0
  81. package/dist/sweptStep.js +472 -0
  82. package/dist/tree.d.ts +61 -0
  83. package/dist/tree.js +348 -0
  84. package/dist/vehicle.d.ts +154 -0
  85. package/dist/vehicle.js +343 -0
  86. package/dist/workerPool.d.ts +107 -0
  87. package/dist/workerPool.js +239 -0
  88. package/dist/workers.d.ts +37 -0
  89. package/dist/workers.js +35 -0
  90. package/dist/world.d.ts +298 -0
  91. package/dist/world.js +712 -0
  92. package/package.json +55 -0
  93. package/src/bodies.ts +369 -0
  94. package/src/buoyancy.ts +78 -0
  95. package/src/cloth.ts +734 -0
  96. package/src/collide/aabb.ts +28 -0
  97. package/src/collide/body.ts +336 -0
  98. package/src/collide/eject.ts +156 -0
  99. package/src/collide/index.ts +26 -0
  100. package/src/collide/scratch.ts +10 -0
  101. package/src/collide/segment.ts +127 -0
  102. package/src/collide/sweep.ts +1132 -0
  103. package/src/colliderSet.ts +637 -0
  104. package/src/compoundContact.ts +284 -0
  105. package/src/controller.ts +817 -0
  106. package/src/cylinder.ts +340 -0
  107. package/src/cylinderContact.ts +825 -0
  108. package/src/decompose.ts +1198 -0
  109. package/src/events.ts +108 -0
  110. package/src/executor.ts +224 -0
  111. package/src/faces.ts +37 -0
  112. package/src/fingerprint.ts +146 -0
  113. package/src/groundMotor.ts +53 -0
  114. package/src/heightfieldShape.ts +395 -0
  115. package/src/index.ts +164 -0
  116. package/src/island.ts +219 -0
  117. package/src/islandSolve.ts +218 -0
  118. package/src/islandWorker.ts +141 -0
  119. package/src/joints.ts +1367 -0
  120. package/src/manifold.ts +798 -0
  121. package/src/mass.ts +424 -0
  122. package/src/meshContact.ts +443 -0
  123. package/src/meshShape.ts +261 -0
  124. package/src/pairs.ts +169 -0
  125. package/src/query.ts +844 -0
  126. package/src/ragdoll.ts +662 -0
  127. package/src/shape.ts +656 -0
  128. package/src/solver.ts +873 -0
  129. package/src/stage.ts +406 -0
  130. package/src/stallEscape.ts +362 -0
  131. package/src/sweptStep.ts +609 -0
  132. package/src/tree.ts +366 -0
  133. package/src/vehicle.ts +489 -0
  134. package/src/workerPool.ts +283 -0
  135. package/src/workers.ts +38 -0
  136. package/src/world.ts +961 -0
@@ -0,0 +1,641 @@
1
+ import { BODY_DYNAMIC } from './bodies.js';
2
+ import { createRayHit } from './query.js';
3
+ import { capsuleShape, sphereShape } from './shape.js';
4
+ /**
5
+ * A kinematic capsule character: the mechanism, and the feel that rides on it.
6
+ *
7
+ * **Kinematic and not a dynamic body.** A dynamic character falls over, cannot be steered crisply,
8
+ * and turns every design question about movement into a question about torque. Every shipping
9
+ * engine ships a kinematic controller for exactly that reason, and this one sweeps the same
10
+ * `PhysicsWorld` everything else queries, so it sees moving bodies as well as scenery.
11
+ *
12
+ * **Feel is here too, and that is a decision `AGENTS.md` records.** That file's worked example for
13
+ * the engine/game boundary once said a controller was game because "friction, coyote time and slide
14
+ * boost are one game's feel". The same file's *audio* rule draws the line correctly: the DSP graph
15
+ * is engine and which sounds play is game. A controller that takes a friction curve and a
16
+ * coyote-time duration takes numbers and time, which is what that file says engine APIs take; what
17
+ * stays game is which numbers. **What would make this wrong** is a parameter that only means
18
+ * something in one game.
19
+ *
20
+ * **Coyote time and jump buffering are counted in ticks**, never accumulated in a float of seconds.
21
+ * That is the determinism contract reaching into the feel layer, and the difference between a
22
+ * replay that matches and one that drifts by a frame after an hour of uptime.
23
+ */
24
+ export const GROUNDED = 0;
25
+ export const AIRBORNE = 1;
26
+ export const SLIDING = 2;
27
+ /** How many sweep-and-slide passes one move takes. Fixed, because a tolerance loop is banned. */
28
+ const SLIDE_PASSES = 4;
29
+ /**
30
+ * How far below the feet a snap looks. Not a skin.
31
+ *
32
+ * There was a skin here, subtracted from the sweep as `fraction − SKIN / travel`, and it made the
33
+ * controller **stop moving entirely** whenever a move was shorter than the skin: a character
34
+ * sliding downhill at a tenth of a metre per second travels 0.0017 m a tick, the term came to ten,
35
+ * the fraction clamped to zero, and it stood still on a slope it should have slid down. The sweep's
36
+ * own stop margin already leaves a tenth of a millimetre of clearance, which is what a skin was for.
37
+ */
38
+ const SNAP_EPS = 0.001;
39
+ export class CharacterController {
40
+ x = 0;
41
+ y = 0;
42
+ z = 0;
43
+ velX = 0;
44
+ velY = 0;
45
+ velZ = 0;
46
+ state = AIRBORNE;
47
+ /** The surface normal under the feet, meaningful when grounded or sliding. */
48
+ groundX = 0;
49
+ groundY = 1;
50
+ groundZ = 0;
51
+ /**
52
+ * Which way is up for this body, as a unit vector. World up unless a caller says otherwise.
53
+ *
54
+ * **The whole of this class used to assume one axis, and none of it was wrong for doing so.**
55
+ * `velY` was the only representation of vertical velocity, `gravity` was a scalar applied to it,
56
+ * `slopeCos` was measured against world Y, `stepHeight` was a height only because up was fixed,
57
+ * and the three states were decided by a downward probe. That is a complete and correct
58
+ * controller for a body on a floor, and it cannot represent one on a wall at all.
59
+ *
60
+ * A consumer needing one had to reimplement the sweep, the step-over, the slope test and the
61
+ * ground probe — roughly nine hundred lines that would not share this file's contact code, so
62
+ * the two would drift. It is not a gecko's problem: it is every wall-crawler, every
63
+ * arbitrary-gravity level and every walker on the inside of a rotating station.
64
+ *
65
+ * Settable per tick, because a body walking around a corner changes frame continuously and the
66
+ * alternative is rebuilding the controller. Read through `setUp`, which normalises, so the dot
67
+ * products below can assume unit length.
68
+ */
69
+ upX = 0;
70
+ upY = 1;
71
+ upZ = 0;
72
+ /**
73
+ * Which way this body falls, which is not always the way it stands.
74
+ *
75
+ * Tracks `up` until a caller sets it, and that default is the one that keeps every existing
76
+ * consumer unchanged. Kept separate because a support normal and a fall direction are different
77
+ * questions: a body clinging to a wall stands in the wall's frame and may still fall the way the
78
+ * world falls the moment it lets go.
79
+ */
80
+ gravityX = 0;
81
+ gravityY = 1;
82
+ gravityZ = 0;
83
+ /** Whether `setUp` should carry the fall direction with it. Cleared by `setGravityDirection`. */
84
+ gravityFollowsUp = true;
85
+ /** Which body is being stood on, or −1. */
86
+ groundBody = -1;
87
+ shape;
88
+ /**
89
+ * A ball the size of the capsule's foot, swept down to find ground.
90
+ *
91
+ * **A single ray from the capsule's axis is not enough**, and the failure is specific: a character
92
+ * balanced on the lip of a step has its axis over open air, the ray misses, it is reported
93
+ * airborne, and stepping is disabled — so it stalls on the edge of the very step it was climbing.
94
+ * A ball finds ground under any part of the foot. It is slightly smaller than the capsule so a
95
+ * wall pressed against the side cannot be mistaken for a floor.
96
+ */
97
+ foot;
98
+ radius;
99
+ halfHeight;
100
+ stepHeight;
101
+ slopeCos;
102
+ maxSpeed;
103
+ acceleration;
104
+ deceleration;
105
+ airControl;
106
+ jumpSpeed;
107
+ jumpCutoff;
108
+ gravity;
109
+ coyoteTicks;
110
+ jumpBufferTicks;
111
+ slideBoost;
112
+ pushStrength;
113
+ filter;
114
+ /** Ticks since the character last had ground under it. */
115
+ airTicks = 0;
116
+ /** Ticks since a jump was pressed and not yet used. */
117
+ bufferTicks = -1;
118
+ jumpHeld = false;
119
+ rising = false;
120
+ pose = { x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1 };
121
+ hit = createRayHit();
122
+ /** `tryStep` needs its own, because it sweeps twice and `hit` is still being read afterwards. */
123
+ stepHit = createRayHit();
124
+ ground = createRayHit();
125
+ /** The analytic surface, where a consumer supplied one. See `GroundProbe`. */
126
+ probe;
127
+ /** Scratch for the probe, claimed once: `senseGround` runs every tick. */
128
+ probed = {
129
+ y: 0,
130
+ normalX: 0,
131
+ normalY: 1,
132
+ normalZ: 0,
133
+ };
134
+ constructor(options = {}) {
135
+ this.radius = options.radius ?? 0.35;
136
+ this.halfHeight = options.halfHeight ?? 0.6;
137
+ this.shape = capsuleShape(this.radius, this.halfHeight);
138
+ this.foot = sphereShape(this.radius * 0.95);
139
+ this.stepHeight = options.stepHeight ?? 0.35;
140
+ this.slopeCos = options.slopeCos ?? 0.7;
141
+ this.maxSpeed = options.maxSpeed ?? 5;
142
+ this.acceleration = options.acceleration ?? 40;
143
+ this.deceleration = options.deceleration ?? 30;
144
+ this.airControl = options.airControl ?? 0.3;
145
+ this.jumpSpeed = options.jumpSpeed ?? 6;
146
+ this.jumpCutoff = options.jumpCutoff ?? 0.4;
147
+ this.gravity = options.gravity ?? -20;
148
+ this.coyoteTicks = options.coyoteTicks ?? 6;
149
+ this.jumpBufferTicks = options.jumpBufferTicks ?? 6;
150
+ this.slideBoost = options.slideBoost ?? 8;
151
+ this.pushStrength = options.pushStrength ?? 1;
152
+ this.filter = options.filter;
153
+ this.probe = options.ground ?? null;
154
+ }
155
+ /**
156
+ * Point this body's up at a direction, normalising it.
157
+ *
158
+ * A zero-length vector is refused rather than normalised into a NaN frame, because every dot
159
+ * product below would then answer NaN and the states would read as a body that is nowhere. It
160
+ * keeps the frame it had, which is the defined state the two-backends rule asks for in the
161
+ * renderer and is the right answer here for the same reason.
162
+ */
163
+ setUp(x, y, z) {
164
+ const length = Math.sqrt(x * x + y * y + z * z);
165
+ if (length < 1e-6)
166
+ return;
167
+ this.upX = x / length;
168
+ this.upY = y / length;
169
+ this.upZ = z / length;
170
+ if (this.gravityFollowsUp) {
171
+ this.gravityX = this.upX;
172
+ this.gravityY = this.upY;
173
+ this.gravityZ = this.upZ;
174
+ }
175
+ }
176
+ /**
177
+ * Point the fall direction somewhere other than up, and stop it following.
178
+ *
179
+ * The sign convention is `up`'s: `gravity` is negative, so this is the direction a *rise* goes
180
+ * and gravity is applied against it. That is the same arithmetic as before — `velY += gravity *
181
+ * dt` with a gravity of −20 is an acceleration along −Y — generalised rather than reversed.
182
+ */
183
+ setGravityDirection(x, y, z) {
184
+ const length = Math.sqrt(x * x + y * y + z * z);
185
+ if (length < 1e-6)
186
+ return;
187
+ this.gravityX = x / length;
188
+ this.gravityY = y / length;
189
+ this.gravityZ = z / length;
190
+ this.gravityFollowsUp = false;
191
+ }
192
+ /** This body's speed along its own up. What `velY` was when up was always world Y. */
193
+ get speedAlongUp() {
194
+ return this.velX * this.upX + this.velY * this.upY + this.velZ * this.upZ;
195
+ }
196
+ /** Set that component, leaving the tangent velocity exactly as it was. */
197
+ setSpeedAlongUp(speed) {
198
+ const delta = speed - this.speedAlongUp;
199
+ this.velX += this.upX * delta;
200
+ this.velY += this.upY * delta;
201
+ this.velZ += this.upZ * delta;
202
+ }
203
+ /** Place the character, clearing whatever it was doing. */
204
+ teleport(x, y, z) {
205
+ this.x = x;
206
+ this.y = y;
207
+ this.z = z;
208
+ this.velX = 0;
209
+ this.velY = 0;
210
+ this.velZ = 0;
211
+ this.state = AIRBORNE;
212
+ this.airTicks = this.coyoteTicks + 1;
213
+ }
214
+ /** Advance one fixed tick. */
215
+ move(world, dt, input) {
216
+ this.senseGround(world);
217
+ this.applyFeel(dt, input);
218
+ this.sweep(world, dt);
219
+ this.standOnSurface(dt);
220
+ this.senseGround(world);
221
+ }
222
+ /**
223
+ * Let the analytic surface stop a descent, the way a body would have.
224
+ *
225
+ * **Without this a probe is a sensor and not a floor, and that was the shape of it until
226
+ * 2026-08-28.** `senseGround` read the surface and set `GROUNDED`, and nothing anywhere put the
227
+ * feet back on it: the sweep resolves bodies, the surface is not a body, so a character over a
228
+ * purely analytic world sank through it at gravity. It reported `GROUNDED` for three ticks while
229
+ * the gap stayed inside the six-centimetre tolerance, then went airborne and fell for ever —
230
+ * measured at ten metres under the floor after one second. Every test the seam had ran a single
231
+ * tick from feet already exactly on the surface, which is the one arrangement that cannot see it.
232
+ *
233
+ * A consumer with terrain therefore had to hold their own character up, which is the hundred
234
+ * lines they reported writing, and to write them again for anything else that stands on ground.
235
+ *
236
+ * **Only a descent this tick could have caused, which is what keeps it a floor rather than a
237
+ * magnet.** A character that is under the surface by more than this tick's fall is under it for
238
+ * some other reason — spawned in a cellar, walked into a tunnel, thrown through by an impulse —
239
+ * and hoisting them would be teleporting them through whatever is over their head. Rising is left
240
+ * alone entirely, so a jump leaves.
241
+ */
242
+ standOnSurface(dt) {
243
+ /*
244
+ * **A `GroundProbe` answers a height for an (x, z), so it describes a world-Y surface and
245
+ * nothing else.** There is no reading of it in a tilted frame: `sample(x, z)` has already
246
+ * chosen which axis is vertical. So this holds a body up only in the frame the probe is
247
+ * written in, and a consumer standing on a wall is standing on a collider rather than on an
248
+ * analytic surface. Skipped rather than approximated, because an approximation here is a body
249
+ * hoisted along the wrong axis, which is worse than not being hoisted.
250
+ */
251
+ if (this.upY < 0.999)
252
+ return;
253
+ if (this.probe === null || this.velY > 0)
254
+ return;
255
+ const feet = this.y - this.halfHeight - this.radius;
256
+ if (!this.probe.sample(this.x, this.z, this.probed, this.y))
257
+ return;
258
+ const sunk = this.probed.y - feet;
259
+ if (sunk < 0)
260
+ return;
261
+ /* This tick's fall, plus the same tolerance `senseGround` calls standing on something. */
262
+ if (sunk > -this.velY * dt + 0.06)
263
+ return;
264
+ this.y = this.probed.y + this.halfHeight + this.radius;
265
+ this.velY = 0;
266
+ }
267
+ /* ---------- feel ---------- */
268
+ applyFeel(dt, input) {
269
+ const grounded = this.state === GROUNDED;
270
+ if (grounded)
271
+ this.airTicks = 0;
272
+ else
273
+ this.airTicks++;
274
+ // Edge detection and buffering, both in ticks.
275
+ if (input.jump && !this.jumpHeld)
276
+ this.bufferTicks = 0;
277
+ else if (this.bufferTicks >= 0)
278
+ this.bufferTicks++;
279
+ if (this.bufferTicks > this.jumpBufferTicks)
280
+ this.bufferTicks = -1;
281
+ this.jumpHeld = input.jump;
282
+ // Steering, with less authority in the air.
283
+ let wantX = input.moveX;
284
+ let wantY = input.moveY ?? 0;
285
+ let wantZ = input.moveZ;
286
+ const want = Math.sqrt(wantX * wantX + wantY * wantY + wantZ * wantZ);
287
+ if (want > this.maxSpeed) {
288
+ wantX = (wantX / want) * this.maxSpeed;
289
+ wantY = (wantY / want) * this.maxSpeed;
290
+ wantZ = (wantZ / want) * this.maxSpeed;
291
+ }
292
+ const authority = grounded ? 1 : this.airControl;
293
+ const rate = want > 0 ? this.acceleration : this.deceleration;
294
+ const step = rate * authority * dt;
295
+ /*
296
+ * **Steering happens in the tangent plane, and the input is read as a world direction.**
297
+ *
298
+ * With up at world Y and no `moveY`, this is exactly what it was: the wanted vector has no Y
299
+ * component, the projection removes nothing, and approaching the three components approaches
300
+ * `velX` and `velZ` while leaving `velY` alone. With up anywhere else, the same input describes
301
+ * a direction in the world that is flattened onto the surface the body is standing on, which is
302
+ * what a caller means by "forward" when forward is along a wall — and `moveY` is what lets that
303
+ * direction leave the world XZ plane at all. See `ControllerInput.moveY`.
304
+ *
305
+ * The speed along up is held out and put back rather than being steered, because it belongs to
306
+ * gravity and the jump and not to the stick.
307
+ *
308
+ * **Unless the caller says the command is already the velocity it wants**, which is a body
309
+ * between two surfaces and is the one case this plane is the wrong one to read in. See
310
+ * `ControllerInput.projectMove`. The whole vector is steered then, at the same rate and under
311
+ * the same clamp and air control — only the plane is given up, not the feel.
312
+ */
313
+ if (input.projectMove === false) {
314
+ this.velX = approach(this.velX, wantX, step);
315
+ this.velY = approach(this.velY, wantY, step);
316
+ this.velZ = approach(this.velZ, wantZ, step);
317
+ }
318
+ else {
319
+ const alongUp = this.speedAlongUp;
320
+ let tangentX = this.velX - this.upX * alongUp;
321
+ let tangentY = this.velY - this.upY * alongUp;
322
+ let tangentZ = this.velZ - this.upZ * alongUp;
323
+ const wantIntoUp = wantX * this.upX + wantY * this.upY + wantZ * this.upZ;
324
+ const wantTangentX = wantX - this.upX * wantIntoUp;
325
+ const wantTangentY = wantY - this.upY * wantIntoUp;
326
+ const wantTangentZ = wantZ - this.upZ * wantIntoUp;
327
+ tangentX = approach(tangentX, wantTangentX, step);
328
+ tangentY = approach(tangentY, wantTangentY, step);
329
+ tangentZ = approach(tangentZ, wantTangentZ, step);
330
+ this.velX = tangentX + this.upX * alongUp;
331
+ this.velY = tangentY + this.upY * alongUp;
332
+ this.velZ = tangentZ + this.upZ * alongUp;
333
+ }
334
+ if (this.state === SLIDING) {
335
+ /*
336
+ * Downhill along the surface: the gravity direction projected onto the plane. A slide that
337
+ * simply disabled control would leave a character stuck on a slope it cannot climb, which
338
+ * reads as being caught on geometry rather than as sliding.
339
+ */
340
+ const n = this.groundX * this.upX + this.groundY * this.upY + this.groundZ * this.upZ;
341
+ const downX = -(this.groundX - this.upX * n) * n;
342
+ const downY = -(this.groundY - this.upY * n) * n;
343
+ const downZ = -(this.groundZ - this.upZ * n) * n;
344
+ this.velX += downX * this.slideBoost * dt;
345
+ this.velY += downY * this.slideBoost * dt;
346
+ this.velZ += downZ * this.slideBoost * dt;
347
+ }
348
+ this.velX += this.gravityX * this.gravity * dt;
349
+ this.velY += this.gravityY * this.gravity * dt;
350
+ this.velZ += this.gravityZ * this.gravity * dt;
351
+ if (this.speedAlongUp <= 0)
352
+ this.rising = false;
353
+ // A jump is allowed while grounded, or within coyote ticks of having been.
354
+ const mayJump = this.airTicks <= this.coyoteTicks && this.state !== SLIDING;
355
+ if (this.bufferTicks >= 0 && mayJump) {
356
+ this.setSpeedAlongUp(this.jumpSpeed);
357
+ this.bufferTicks = -1;
358
+ this.airTicks = this.coyoteTicks + 1;
359
+ this.rising = true;
360
+ this.state = AIRBORNE;
361
+ }
362
+ else if (this.rising && !input.jump) {
363
+ // Released early: cut the rise, which is what makes a tap shorter than a hold.
364
+ this.setSpeedAlongUp(this.speedAlongUp * this.jumpCutoff);
365
+ this.rising = false;
366
+ }
367
+ }
368
+ /* ---------- mechanism ---------- */
369
+ /**
370
+ * Sweep, slide along whatever was hit, and try again — a fixed number of times.
371
+ *
372
+ * A step is attempted before sliding: lift by the step height, sweep forward, and drop. A
373
+ * character that slid off every lip would catch on kerbs and stair nosings, which reads as the
374
+ * world being sticky rather than as the character being short.
375
+ */
376
+ sweep(world, dt) {
377
+ let dx = this.velX * dt;
378
+ let dy = this.velY * dt;
379
+ let dz = this.velZ * dt;
380
+ for (let pass = 0; pass < SLIDE_PASSES; pass++) {
381
+ const travel = Math.sqrt(dx * dx + dy * dy + dz * dz);
382
+ if (travel < 1e-6)
383
+ return;
384
+ this.readPose();
385
+ if (!world.shapecast(this.shape, this.pose, dx, dy, dz, this.hit, this.filter)) {
386
+ this.x += dx;
387
+ this.y += dy;
388
+ this.z += dz;
389
+ return;
390
+ }
391
+ const t = this.hit.fraction;
392
+ this.x += dx * t;
393
+ this.y += dy * t;
394
+ this.z += dz * t;
395
+ this.push(world, this.hit.body, this.hit.nx, this.hit.ny, this.hit.nz);
396
+ let remainX = dx * (1 - t);
397
+ let remainY = dy * (1 - t);
398
+ let remainZ = dz * (1 - t);
399
+ /*
400
+ * Try to step over the lip before sliding along it. The normal is read out first, because
401
+ * `tryStep` sweeps twice and the projection below still needs it — sharing one `RayHit`
402
+ * between them left the slide projecting against whatever the step's own sweep last found,
403
+ * which stopped the character dead at every kerb.
404
+ */
405
+ const hx = this.hit.nx;
406
+ const hy = this.hit.ny;
407
+ const hz = this.hit.nz;
408
+ const intoUp = hx * this.upX + hy * this.upY + hz * this.upZ;
409
+ if (intoUp < this.slopeCos &&
410
+ this.state === GROUNDED &&
411
+ this.tryStep(world, remainX, remainY, remainZ)) {
412
+ return;
413
+ }
414
+ // Project what is left onto the surface, which is what turns a stop into a slide.
415
+ const into = remainX * hx + remainY * hy + remainZ * hz;
416
+ remainX -= hx * into;
417
+ remainY -= hy * into;
418
+ remainZ -= hz * into;
419
+ const vInto = this.velX * hx + this.velY * hy + this.velZ * hz;
420
+ if (vInto < 0) {
421
+ this.velX -= hx * vInto;
422
+ this.velY -= hy * vInto;
423
+ this.velZ -= hz * vInto;
424
+ }
425
+ dx = remainX;
426
+ dy = remainY;
427
+ dz = remainZ;
428
+ }
429
+ }
430
+ /**
431
+ * Lift, sweep forward, drop. Returns whether the whole step was taken.
432
+ *
433
+ * **The lift and the drop are along this body's up rather than along world Y**, which is what
434
+ * makes `stepHeight` a height rather than a Y offset. The forward part is the remainder of the
435
+ * move flattened into the tangent plane, for the same reason: what is left of a blocked move
436
+ * includes whatever of it was along up, and lifting by the step height and then also moving up
437
+ * would climb twice.
438
+ */
439
+ tryStep(world, dx, dy, dz) {
440
+ const startX = this.x;
441
+ const startY = this.y;
442
+ const startZ = this.z;
443
+ const liftX = this.upX * this.stepHeight;
444
+ const liftY = this.upY * this.stepHeight;
445
+ const liftZ = this.upZ * this.stepHeight;
446
+ const intoUp = dx * this.upX + dy * this.upY + dz * this.upZ;
447
+ const flatX = dx - this.upX * intoUp;
448
+ const flatY = dy - this.upY * intoUp;
449
+ const flatZ = dz - this.upZ * intoUp;
450
+ this.x += liftX;
451
+ this.y += liftY;
452
+ this.z += liftZ;
453
+ this.readPose();
454
+ if (world.shapecast(this.shape, this.pose, flatX, flatY, flatZ, this.stepHit, this.filter)) {
455
+ this.x = startX;
456
+ this.y = startY;
457
+ this.z = startZ;
458
+ return false;
459
+ }
460
+ this.x += flatX;
461
+ this.y += flatY;
462
+ this.z += flatZ;
463
+ this.readPose();
464
+ if (world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
465
+ const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
466
+ this.x -= this.upX * drop;
467
+ this.y -= this.upY * drop;
468
+ this.z -= this.upZ * drop;
469
+ return true;
470
+ }
471
+ // Nothing beneath the lifted position, so this was a gap rather than a step.
472
+ this.x = startX;
473
+ this.y = startY;
474
+ this.z = startZ;
475
+ return false;
476
+ }
477
+ /**
478
+ * Sweep the foot ball down, and fall back to a ray when what it finds is not walkable.
479
+ *
480
+ * The ball is what lets a character stand on the lip of a step: a ray from the capsule's axis has
481
+ * open air beneath it there, reports airborne, and disables the stepping that was carrying it up.
482
+ *
483
+ * **But a ball also brushes the side of the ledge it is walking off**, and a wall's normal is
484
+ * horizontal, so the ball alone reported *sliding* — which refused the coyote-time jump the
485
+ * character had every right to. So a non-walkable ball result is not trusted on its own: a ray
486
+ * down the axis decides whether there is genuinely a steep surface underfoot, or merely a wall
487
+ * beside it. Two cheap queries, and each covers the other's blind spot.
488
+ */
489
+ senseGround(world) {
490
+ this.readPose();
491
+ this.pose.x = this.x - this.upX * this.halfHeight;
492
+ this.pose.y = this.y - this.upY * this.halfHeight;
493
+ this.pose.z = this.z - this.upZ * this.halfHeight;
494
+ const reach = this.radius * 0.05 + 0.06;
495
+ if (world.shapecast(this.foot, this.pose, -this.upX * reach, -this.upY * reach, -this.upZ * reach, this.ground, this.filter)) {
496
+ const intoUp = this.ground.nx * this.upX + this.ground.ny * this.upY + this.ground.nz * this.upZ;
497
+ if (intoUp >= this.slopeCos) {
498
+ this.groundX = this.ground.nx;
499
+ this.groundY = this.ground.ny;
500
+ this.groundZ = this.ground.nz;
501
+ this.groundBody = this.ground.body;
502
+ this.state = GROUNDED;
503
+ return;
504
+ }
505
+ }
506
+ const axis = this.halfHeight + this.radius + 0.06;
507
+ if (!world.raycast(this.x, this.y, this.z, -this.upX, -this.upY, -this.upZ, axis, this.ground, this.filter)) {
508
+ /*
509
+ * Nothing solid under the feet. Ask the surface, where there is one — a banked deck is
510
+ * exactly the case a collider answers badly, and a consumer with one would otherwise be
511
+ * reported airborne while standing on it.
512
+ *
513
+ * Asked last rather than first, and that ordering is the decision: a body is a thing that
514
+ * is *there* and a surface is a description of where the ground *is*, so anything solid
515
+ * wins. A consumer with no probe reaches none of this.
516
+ */
517
+ if (this.probe !== null) {
518
+ const feet = this.y - this.halfHeight - this.radius;
519
+ if (this.probe.sample(this.x, this.z, this.probed, this.y) &&
520
+ this.probed.y <= feet + 0.06) {
521
+ if (feet - this.probed.y <= 0.06) {
522
+ this.groundX = this.probed.normalX;
523
+ this.groundY = this.probed.normalY;
524
+ this.groundZ = this.probed.normalZ;
525
+ /* No body holds this character up, and −1 is what that has always meant. */
526
+ this.groundBody = -1;
527
+ this.state = this.probed.normalY >= this.slopeCos ? GROUNDED : SLIDING;
528
+ return;
529
+ }
530
+ }
531
+ }
532
+ this.state = AIRBORNE;
533
+ this.groundBody = -1;
534
+ return;
535
+ }
536
+ this.groundX = this.ground.nx;
537
+ this.groundY = this.ground.ny;
538
+ this.groundZ = this.ground.nz;
539
+ this.groundBody = this.ground.body;
540
+ this.state = this.ground.ny >= this.slopeCos ? GROUNDED : SLIDING;
541
+ }
542
+ /*
543
+ * **There was a `snapToGround` here and it is gone, because it was measured and did nothing.**
544
+ *
545
+ * The idea is standard: horizontal speed can carry the feet past a crest faster than gravity
546
+ * pulls them down, so a controller pulls them back onto a surface just below. This one never
547
+ * fired at all at first — it ran only while still grounded, and at a crest the ground sense has
548
+ * already said airborne — and once that ordering was fixed it made the result *worse*. Descending
549
+ * a sharp forty-degree crest at six metres a second, the largest gap between the feet and the
550
+ * surface was **0.490 metres with it and 0.457 without**, over 120 ticks.
551
+ *
552
+ * The sweep-and-slide already follows a surface closely enough that there is nothing left for a
553
+ * snap to correct, because the slide projection puts the remaining motion *along* the surface
554
+ * rather than through it. **What would bring it back** is a measurement showing a visible hop at
555
+ * a crest that the slide does not absorb — at which point the instrument to build first is the
556
+ * foot-gap probe above, not the airborne-tick count, which reports a state rather than a distance
557
+ * and called this feature useful when it was inert.
558
+ */
559
+ /**
560
+ * Push a dynamic body the character walked into, so a crate is not a wall.
561
+ *
562
+ * **The push carries the character's speed, not its per-tick step.** Adding the displacement —
563
+ * four metres a second comes to 0.067 of a metre in a tick — is less than the impulse ground
564
+ * friction takes off a crate in the same tick, so the crate is pushed and immediately stopped and
565
+ * never moves at all. Measured: a 30 kg crate under 0.5 friction shed 0.082 a tick against 0.067
566
+ * put in.
567
+ *
568
+ * So it sets a *target* rather than accumulating: the body is brought up to the character's own
569
+ * speed into the surface, scaled by `pushStrength`, and never slowed if it is already faster.
570
+ * **What this gives up** is momentum transfer — a heavy crate moves as readily as a light one,
571
+ * because a kinematic character has no mass to trade. **What would make it wrong** is a consumer
572
+ * wanting mass to matter, and the answer then is a dynamic character, which §12 refuses for
573
+ * reasons that have nothing to do with this.
574
+ */
575
+ push(world, body, nx, ny, nz) {
576
+ if (body < 0 || this.pushStrength === 0)
577
+ return;
578
+ if (world.bodies.type[body] !== BODY_DYNAMIC)
579
+ return;
580
+ if ((world.bodies.invMass[body] ?? 0) === 0)
581
+ return;
582
+ // The surface normal points at the character, so the push goes the other way.
583
+ const into = -(this.velX * nx + this.velY * ny + this.velZ * nz);
584
+ if (into <= 0)
585
+ return;
586
+ const want = into * this.pushStrength;
587
+ const already = -((world.bodies.velX[body] ?? 0) * nx +
588
+ (world.bodies.velY[body] ?? 0) * ny +
589
+ (world.bodies.velZ[body] ?? 0) * nz);
590
+ if (already >= want)
591
+ return;
592
+ const add = want - already;
593
+ world.bodies.velX[body] = (world.bodies.velX[body] ?? 0) - nx * add;
594
+ world.bodies.velY[body] = (world.bodies.velY[body] ?? 0) - ny * add;
595
+ world.bodies.velZ[body] = (world.bodies.velZ[body] ?? 0) - nz * add;
596
+ world.wakeIsland(body);
597
+ }
598
+ /**
599
+ * The capsule at this body's position, standing along this body's up.
600
+ *
601
+ * **The quaternion used to be left at identity**, which was right while up was always world Y
602
+ * and is the one thing that cannot be left out once it is not: a body on a wall whose capsule is
603
+ * still world-vertical is swept against the wall by its side rather than its foot, so it rests a
604
+ * radius away instead of standing on it.
605
+ *
606
+ * The shortest arc from world Y to `up`, built directly rather than through an axis-angle,
607
+ * because the half-angle form is one square root. **The half turn is the case that has to be
608
+ * written out**: an up of exactly −Y makes the cross product zero and `w` zero with it, which
609
+ * normalises to NaN, and there is no shortest arc to pick because every arc is the same length.
610
+ * A turn about X is chosen, which is as good as any and is stable.
611
+ */
612
+ readPose() {
613
+ this.pose.x = this.x;
614
+ this.pose.y = this.y;
615
+ this.pose.z = this.z;
616
+ const w = 1 + this.upY;
617
+ if (w < 1e-6) {
618
+ this.pose.qx = 1;
619
+ this.pose.qy = 0;
620
+ this.pose.qz = 0;
621
+ this.pose.qw = 0;
622
+ return;
623
+ }
624
+ const qx = this.upZ;
625
+ const qz = -this.upX;
626
+ const inverse = 1 / Math.sqrt(qx * qx + qz * qz + w * w);
627
+ this.pose.qx = qx * inverse;
628
+ this.pose.qy = 0;
629
+ this.pose.qz = qz * inverse;
630
+ this.pose.qw = w * inverse;
631
+ }
632
+ }
633
+ /** Move `from` toward `to` by at most `step`. */
634
+ function approach(from, to, step) {
635
+ const d = to - from;
636
+ if (d > step)
637
+ return from + step;
638
+ if (d < -step)
639
+ return from - step;
640
+ return to;
641
+ }