@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,817 @@
1
+ import { BODY_DYNAMIC } from './bodies.ts';
2
+ import { createRayHit } from './query.ts';
3
+ import type { QueryFilter, RayHit } from './query.ts';
4
+ import { capsuleShape, sphereShape } from './shape.ts';
5
+ import type { ConvexShape } from './shape.ts';
6
+ import type { ShapePose } from './manifold.ts';
7
+ import type { PhysicsWorld } from './world.ts';
8
+
9
+ /**
10
+ * A kinematic capsule character: the mechanism, and the feel that rides on it.
11
+ *
12
+ * **Kinematic and not a dynamic body.** A dynamic character falls over, cannot be steered crisply,
13
+ * and turns every design question about movement into a question about torque. Every shipping
14
+ * engine ships a kinematic controller for exactly that reason, and this one sweeps the same
15
+ * `PhysicsWorld` everything else queries, so it sees moving bodies as well as scenery.
16
+ *
17
+ * **Feel is here too, and that is a decision `AGENTS.md` records.** That file's worked example for
18
+ * the engine/game boundary once said a controller was game because "friction, coyote time and slide
19
+ * boost are one game's feel". The same file's *audio* rule draws the line correctly: the DSP graph
20
+ * is engine and which sounds play is game. A controller that takes a friction curve and a
21
+ * coyote-time duration takes numbers and time, which is what that file says engine APIs take; what
22
+ * stays game is which numbers. **What would make this wrong** is a parameter that only means
23
+ * something in one game.
24
+ *
25
+ * **Coyote time and jump buffering are counted in ticks**, never accumulated in a float of seconds.
26
+ * That is the determinism contract reaching into the feel layer, and the difference between a
27
+ * replay that matches and one that drifts by a frame after an hour of uptime.
28
+ */
29
+
30
+ export const GROUNDED = 0;
31
+ export const AIRBORNE = 1;
32
+ export const SLIDING = 2;
33
+ export type ControllerState = 0 | 1 | 2;
34
+
35
+ export interface ControllerOptions {
36
+ radius?: number;
37
+ /** Half the cylindrical section, so total height is `2 * (halfHeight + radius)`. */
38
+ halfHeight?: number;
39
+ /** How high a lip may be and still be walked over. */
40
+ stepHeight?: number;
41
+ /** Cosine of the steepest walkable slope. Above it the character slides. */
42
+ slopeCos?: number;
43
+ maxSpeed?: number;
44
+ acceleration?: number;
45
+ deceleration?: number;
46
+ airControl?: number;
47
+ jumpSpeed?: number;
48
+ /** Upward velocity is cut to this fraction when the jump is released early. */
49
+ jumpCutoff?: number;
50
+ gravity?: number;
51
+ /** Ticks after leaving the ground during which a jump still counts. */
52
+ coyoteTicks?: number;
53
+ /** Ticks a jump press is remembered for, so pressing just before landing still fires. */
54
+ jumpBufferTicks?: number;
55
+ /** Extra downhill acceleration while sliding on too steep a surface. */
56
+ slideBoost?: number;
57
+ /** Impulse scale applied to dynamic bodies the character walks into. */
58
+ pushStrength?: number;
59
+ filter?: QueryFilter;
60
+ /**
61
+ * A surface to stand on beside the bodies, where the world has one.
62
+ *
63
+ * Consulted *after* the bodies and only when they found nothing walkable, so a consumer with
64
+ * both keeps whatever a collider said — a body is a thing that is there, and a surface is a
65
+ * description of where the ground is. Absent means bodies alone, which is every consumer that
66
+ * has not asked.
67
+ */
68
+ ground?: GroundProbe;
69
+ }
70
+
71
+ /**
72
+ * A surface the controller can stand on that is not a body.
73
+ *
74
+ * **Declared structurally, so nothing is imported in either direction.** `@driftengine/core`'s
75
+ * `GroundSurface` satisfies this exactly and neither package names the other — the same shape
76
+ * `ragdollFromBones` uses for a pose, and for the same reason: this package imports no other
77
+ * engine package, and a surface that knew about splines and ribbon meshes could not live here.
78
+ *
79
+ * **Why a character needs one at all.** An analytic surface answers *where the ground is in this
80
+ * column*; a collider answers *what is solid*. They are different questions and a banked deck is
81
+ * where they diverge — a world-axis box over a banked strip has a flat top at the strip's highest
82
+ * corner, so a character standing on the box hovers above the track through every corner. A
83
+ * consumer with such a deck could sweep bodies or stand on the deck, and never both.
84
+ *
85
+ * `atY` is the height of whatever is asking, so a route that passes over itself answers with the
86
+ * deck *under* the asker rather than the highest one in the column.
87
+ *
88
+ * **The field names were `nx`, `ny`, `nz` until 2026-08-28 and that was a defect**, because the
89
+ * paragraph above was not true: `SurfaceHit` spells them `normalX`, `normalY`, `normalZ`, so a real
90
+ * `GroundSurface` handed to a controller wrote three fields nothing read and left three fields
91
+ * nothing wrote. TypeScript allows the assignment — method parameters are bivariant — so it
92
+ * compiled, and a *flat* surface still behaved, because the scratch it was handed happens to start
93
+ * at `(0, 1, 0)`. Only a banked one showed it, by giving the character the normal of whatever body
94
+ * it last touched. Renamed rather than aliased: nothing in three consumers implements this
95
+ * interface, and a compile error naming the field is a one-line fix where a stale normal is a week.
96
+ */
97
+ export interface GroundProbeHit {
98
+ /** Height of the surface in this column. */
99
+ y: number;
100
+ normalX: number;
101
+ normalY: number;
102
+ normalZ: number;
103
+ }
104
+
105
+ export interface GroundProbe {
106
+ sample(x: number, z: number, out: GroundProbeHit, atY?: number): boolean;
107
+ }
108
+
109
+ export interface ControllerInput {
110
+ /** Desired direction, in world units per second. Length above `maxSpeed` is clamped. */
111
+ moveX: number;
112
+ moveZ: number;
113
+ /**
114
+ * The third component, for a body whose up is not world Y. Absent means zero, which is what
115
+ * every caller with a level floor means and is arithmetically what they got before this existed.
116
+ *
117
+ * **Two components span the tangent plane only while up is world Y**, and `setUp` made up a
118
+ * parameter without making the input one. On a wall whose normal is +Z, up the wall is +Y and no
119
+ * pair of `moveX` and `moveZ` describes it: the input plane collapses onto a line, so a body that
120
+ * could stand on the wall could not be steered along it. Reported from outside by a consumer that
121
+ * gave up on the acceleration curves, the air control and the speed clamp entirely — writing
122
+ * `velX`/`velY`/`velZ` directly each tick with `acceleration: 0, deceleration: 0` so that
123
+ * `applyFeel` would leave the velocity alone — which is the whole of the feel this class exists
124
+ * to provide, bypassed to get one axis back.
125
+ *
126
+ * Read as a world direction and flattened onto the surface, exactly as the other two are. A
127
+ * caller supplying all three describes "forward" in whatever frame it is standing in.
128
+ */
129
+ moveY?: number;
130
+ /**
131
+ * Whether to flatten the move vector onto the plane the body is standing on. Absent means yes,
132
+ * which is what every caller before this got and what a body moving on one surface wants.
133
+ *
134
+ * **False is for a body between two surfaces, which is the one case the projection cannot
135
+ * serve.** Steering normally holds the along-up component out, steers the rest and puts it back,
136
+ * because that component is gravity and the jump rather than the stick. A body crossing a convex
137
+ * edge has a third thing there: while its frame rotates from the old face to the new, the correct
138
+ * direction of travel is neither face's tangent but a blend of them, held by the contacts already
139
+ * on the far side. Flattened onto the frame's current plane that command is turned aside — by
140
+ * **8.5 degrees early in a 90-degree crossing and more later** — and the body crawls the transfer
141
+ * instead of crossing it. Measured from outside on a sprint across a 90-degree convex edge:
142
+ * contacts decaying from four to none by tick 46, slipping at 46, falling by 51, **21 ticks of 90
143
+ * with nothing under it** against none when the velocity was written directly.
144
+ *
145
+ * **It is the projection and not the acceleration**, which the reporter separated before asking:
146
+ * the same crossing at acceleration times 15, 60, 240 and 2000 of `maxSpeed` gave 21 ticks every
147
+ * time. A lag would have improved.
148
+ *
149
+ * With this false the move vector is the velocity the body is steered toward *whole* — the
150
+ * along-up component included, because a caller that has taken responsibility for the direction
151
+ * has taken responsibility for all of it. Everything else still applies: the speed clamp, the
152
+ * acceleration and deceleration curves, `airControl` when off the ground, and gravity afterwards.
153
+ * That is the difference between this and the direct velocity write it exists to retire, which
154
+ * bypasses all four.
155
+ */
156
+ projectMove?: boolean;
157
+ /** Held, not edge-triggered: the controller does its own edge detection and buffering. */
158
+ jump: boolean;
159
+ }
160
+
161
+ /** How many sweep-and-slide passes one move takes. Fixed, because a tolerance loop is banned. */
162
+ const SLIDE_PASSES = 4;
163
+ /**
164
+ * How far below the feet a snap looks. Not a skin.
165
+ *
166
+ * There was a skin here, subtracted from the sweep as `fraction − SKIN / travel`, and it made the
167
+ * controller **stop moving entirely** whenever a move was shorter than the skin: a character
168
+ * sliding downhill at a tenth of a metre per second travels 0.0017 m a tick, the term came to ten,
169
+ * the fraction clamped to zero, and it stood still on a slope it should have slid down. The sweep's
170
+ * own stop margin already leaves a tenth of a millimetre of clearance, which is what a skin was for.
171
+ */
172
+ const SNAP_EPS = 0.001;
173
+
174
+ export class CharacterController {
175
+ x = 0;
176
+ y = 0;
177
+ z = 0;
178
+ velX = 0;
179
+ velY = 0;
180
+ velZ = 0;
181
+ state: ControllerState = AIRBORNE;
182
+ /** The surface normal under the feet, meaningful when grounded or sliding. */
183
+ groundX = 0;
184
+ groundY = 1;
185
+ groundZ = 0;
186
+
187
+ /**
188
+ * Which way is up for this body, as a unit vector. World up unless a caller says otherwise.
189
+ *
190
+ * **The whole of this class used to assume one axis, and none of it was wrong for doing so.**
191
+ * `velY` was the only representation of vertical velocity, `gravity` was a scalar applied to it,
192
+ * `slopeCos` was measured against world Y, `stepHeight` was a height only because up was fixed,
193
+ * and the three states were decided by a downward probe. That is a complete and correct
194
+ * controller for a body on a floor, and it cannot represent one on a wall at all.
195
+ *
196
+ * A consumer needing one had to reimplement the sweep, the step-over, the slope test and the
197
+ * ground probe — roughly nine hundred lines that would not share this file's contact code, so
198
+ * the two would drift. It is not a gecko's problem: it is every wall-crawler, every
199
+ * arbitrary-gravity level and every walker on the inside of a rotating station.
200
+ *
201
+ * Settable per tick, because a body walking around a corner changes frame continuously and the
202
+ * alternative is rebuilding the controller. Read through `setUp`, which normalises, so the dot
203
+ * products below can assume unit length.
204
+ */
205
+ upX = 0;
206
+ upY = 1;
207
+ upZ = 0;
208
+
209
+ /**
210
+ * Which way this body falls, which is not always the way it stands.
211
+ *
212
+ * Tracks `up` until a caller sets it, and that default is the one that keeps every existing
213
+ * consumer unchanged. Kept separate because a support normal and a fall direction are different
214
+ * questions: a body clinging to a wall stands in the wall's frame and may still fall the way the
215
+ * world falls the moment it lets go.
216
+ */
217
+ private gravityX = 0;
218
+ private gravityY = 1;
219
+ private gravityZ = 0;
220
+ /** Whether `setUp` should carry the fall direction with it. Cleared by `setGravityDirection`. */
221
+ private gravityFollowsUp = true;
222
+ /** Which body is being stood on, or −1. */
223
+ groundBody = -1;
224
+
225
+ readonly shape: ConvexShape;
226
+ /**
227
+ * A ball the size of the capsule's foot, swept down to find ground.
228
+ *
229
+ * **A single ray from the capsule's axis is not enough**, and the failure is specific: a character
230
+ * balanced on the lip of a step has its axis over open air, the ray misses, it is reported
231
+ * airborne, and stepping is disabled — so it stalls on the edge of the very step it was climbing.
232
+ * A ball finds ground under any part of the foot. It is slightly smaller than the capsule so a
233
+ * wall pressed against the side cannot be mistaken for a floor.
234
+ */
235
+ readonly foot: ConvexShape;
236
+ readonly radius: number;
237
+ readonly halfHeight: number;
238
+ readonly stepHeight: number;
239
+ readonly slopeCos: number;
240
+ readonly maxSpeed: number;
241
+ readonly acceleration: number;
242
+ readonly deceleration: number;
243
+ readonly airControl: number;
244
+ readonly jumpSpeed: number;
245
+ readonly jumpCutoff: number;
246
+ readonly gravity: number;
247
+ readonly coyoteTicks: number;
248
+ readonly jumpBufferTicks: number;
249
+ readonly slideBoost: number;
250
+ readonly pushStrength: number;
251
+ readonly filter: QueryFilter | undefined;
252
+
253
+ /** Ticks since the character last had ground under it. */
254
+ private airTicks = 0;
255
+ /** Ticks since a jump was pressed and not yet used. */
256
+ private bufferTicks = -1;
257
+ private jumpHeld = false;
258
+ private rising = false;
259
+ private pose: ShapePose = { x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1 };
260
+ private hit: RayHit = createRayHit();
261
+ /** `tryStep` needs its own, because it sweeps twice and `hit` is still being read afterwards. */
262
+ private stepHit: RayHit = createRayHit();
263
+ private ground: RayHit = createRayHit();
264
+ /** The analytic surface, where a consumer supplied one. See `GroundProbe`. */
265
+ private readonly probe: GroundProbe | null;
266
+ /** Scratch for the probe, claimed once: `senseGround` runs every tick. */
267
+ private readonly probed: GroundProbeHit = {
268
+ y: 0,
269
+ normalX: 0,
270
+ normalY: 1,
271
+ normalZ: 0,
272
+ };
273
+
274
+ constructor(options: ControllerOptions = {}) {
275
+ this.radius = options.radius ?? 0.35;
276
+ this.halfHeight = options.halfHeight ?? 0.6;
277
+ this.shape = capsuleShape(this.radius, this.halfHeight);
278
+ this.foot = sphereShape(this.radius * 0.95);
279
+ this.stepHeight = options.stepHeight ?? 0.35;
280
+ this.slopeCos = options.slopeCos ?? 0.7;
281
+ this.maxSpeed = options.maxSpeed ?? 5;
282
+ this.acceleration = options.acceleration ?? 40;
283
+ this.deceleration = options.deceleration ?? 30;
284
+ this.airControl = options.airControl ?? 0.3;
285
+ this.jumpSpeed = options.jumpSpeed ?? 6;
286
+ this.jumpCutoff = options.jumpCutoff ?? 0.4;
287
+ this.gravity = options.gravity ?? -20;
288
+ this.coyoteTicks = options.coyoteTicks ?? 6;
289
+ this.jumpBufferTicks = options.jumpBufferTicks ?? 6;
290
+ this.slideBoost = options.slideBoost ?? 8;
291
+ this.pushStrength = options.pushStrength ?? 1;
292
+ this.filter = options.filter;
293
+ this.probe = options.ground ?? null;
294
+ }
295
+
296
+ /**
297
+ * Point this body's up at a direction, normalising it.
298
+ *
299
+ * A zero-length vector is refused rather than normalised into a NaN frame, because every dot
300
+ * product below would then answer NaN and the states would read as a body that is nowhere. It
301
+ * keeps the frame it had, which is the defined state the two-backends rule asks for in the
302
+ * renderer and is the right answer here for the same reason.
303
+ */
304
+ setUp(x: number, y: number, z: number): void {
305
+ const length = Math.sqrt(x * x + y * y + z * z);
306
+ if (length < 1e-6) return;
307
+ this.upX = x / length;
308
+ this.upY = y / length;
309
+ this.upZ = z / length;
310
+ if (this.gravityFollowsUp) {
311
+ this.gravityX = this.upX;
312
+ this.gravityY = this.upY;
313
+ this.gravityZ = this.upZ;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Point the fall direction somewhere other than up, and stop it following.
319
+ *
320
+ * The sign convention is `up`'s: `gravity` is negative, so this is the direction a *rise* goes
321
+ * and gravity is applied against it. That is the same arithmetic as before — `velY += gravity *
322
+ * dt` with a gravity of −20 is an acceleration along −Y — generalised rather than reversed.
323
+ */
324
+ setGravityDirection(x: number, y: number, z: number): void {
325
+ const length = Math.sqrt(x * x + y * y + z * z);
326
+ if (length < 1e-6) return;
327
+ this.gravityX = x / length;
328
+ this.gravityY = y / length;
329
+ this.gravityZ = z / length;
330
+ this.gravityFollowsUp = false;
331
+ }
332
+
333
+ /** This body's speed along its own up. What `velY` was when up was always world Y. */
334
+ get speedAlongUp(): number {
335
+ return this.velX * this.upX + this.velY * this.upY + this.velZ * this.upZ;
336
+ }
337
+
338
+ /** Set that component, leaving the tangent velocity exactly as it was. */
339
+ private setSpeedAlongUp(speed: number): void {
340
+ const delta = speed - this.speedAlongUp;
341
+ this.velX += this.upX * delta;
342
+ this.velY += this.upY * delta;
343
+ this.velZ += this.upZ * delta;
344
+ }
345
+
346
+ /** Place the character, clearing whatever it was doing. */
347
+ teleport(x: number, y: number, z: number): void {
348
+ this.x = x;
349
+ this.y = y;
350
+ this.z = z;
351
+ this.velX = 0;
352
+ this.velY = 0;
353
+ this.velZ = 0;
354
+ this.state = AIRBORNE;
355
+ this.airTicks = this.coyoteTicks + 1;
356
+ }
357
+
358
+ /** Advance one fixed tick. */
359
+ move(world: PhysicsWorld, dt: number, input: ControllerInput): void {
360
+ this.senseGround(world);
361
+ this.applyFeel(dt, input);
362
+ this.sweep(world, dt);
363
+ this.standOnSurface(dt);
364
+ this.senseGround(world);
365
+ }
366
+
367
+ /**
368
+ * Let the analytic surface stop a descent, the way a body would have.
369
+ *
370
+ * **Without this a probe is a sensor and not a floor, and that was the shape of it until
371
+ * 2026-08-28.** `senseGround` read the surface and set `GROUNDED`, and nothing anywhere put the
372
+ * feet back on it: the sweep resolves bodies, the surface is not a body, so a character over a
373
+ * purely analytic world sank through it at gravity. It reported `GROUNDED` for three ticks while
374
+ * the gap stayed inside the six-centimetre tolerance, then went airborne and fell for ever —
375
+ * measured at ten metres under the floor after one second. Every test the seam had ran a single
376
+ * tick from feet already exactly on the surface, which is the one arrangement that cannot see it.
377
+ *
378
+ * A consumer with terrain therefore had to hold their own character up, which is the hundred
379
+ * lines they reported writing, and to write them again for anything else that stands on ground.
380
+ *
381
+ * **Only a descent this tick could have caused, which is what keeps it a floor rather than a
382
+ * magnet.** A character that is under the surface by more than this tick's fall is under it for
383
+ * some other reason — spawned in a cellar, walked into a tunnel, thrown through by an impulse —
384
+ * and hoisting them would be teleporting them through whatever is over their head. Rising is left
385
+ * alone entirely, so a jump leaves.
386
+ */
387
+ private standOnSurface(dt: number): void {
388
+ /*
389
+ * **A `GroundProbe` answers a height for an (x, z), so it describes a world-Y surface and
390
+ * nothing else.** There is no reading of it in a tilted frame: `sample(x, z)` has already
391
+ * chosen which axis is vertical. So this holds a body up only in the frame the probe is
392
+ * written in, and a consumer standing on a wall is standing on a collider rather than on an
393
+ * analytic surface. Skipped rather than approximated, because an approximation here is a body
394
+ * hoisted along the wrong axis, which is worse than not being hoisted.
395
+ */
396
+ if (this.upY < 0.999) return;
397
+ if (this.probe === null || this.velY > 0) return;
398
+ const feet = this.y - this.halfHeight - this.radius;
399
+ if (!this.probe.sample(this.x, this.z, this.probed, this.y)) return;
400
+ const sunk = this.probed.y - feet;
401
+ if (sunk < 0) return;
402
+ /* This tick's fall, plus the same tolerance `senseGround` calls standing on something. */
403
+ if (sunk > -this.velY * dt + 0.06) return;
404
+ this.y = this.probed.y + this.halfHeight + this.radius;
405
+ this.velY = 0;
406
+ }
407
+
408
+ /* ---------- feel ---------- */
409
+
410
+ private applyFeel(dt: number, input: ControllerInput): void {
411
+ const grounded = this.state === GROUNDED;
412
+ if (grounded) this.airTicks = 0;
413
+ else this.airTicks++;
414
+
415
+ // Edge detection and buffering, both in ticks.
416
+ if (input.jump && !this.jumpHeld) this.bufferTicks = 0;
417
+ else if (this.bufferTicks >= 0) this.bufferTicks++;
418
+ if (this.bufferTicks > this.jumpBufferTicks) this.bufferTicks = -1;
419
+ this.jumpHeld = input.jump;
420
+
421
+ // Steering, with less authority in the air.
422
+ let wantX = input.moveX;
423
+ let wantY = input.moveY ?? 0;
424
+ let wantZ = input.moveZ;
425
+ const want = Math.sqrt(wantX * wantX + wantY * wantY + wantZ * wantZ);
426
+ if (want > this.maxSpeed) {
427
+ wantX = (wantX / want) * this.maxSpeed;
428
+ wantY = (wantY / want) * this.maxSpeed;
429
+ wantZ = (wantZ / want) * this.maxSpeed;
430
+ }
431
+ const authority = grounded ? 1 : this.airControl;
432
+ const rate = want > 0 ? this.acceleration : this.deceleration;
433
+ const step = rate * authority * dt;
434
+
435
+ /*
436
+ * **Steering happens in the tangent plane, and the input is read as a world direction.**
437
+ *
438
+ * With up at world Y and no `moveY`, this is exactly what it was: the wanted vector has no Y
439
+ * component, the projection removes nothing, and approaching the three components approaches
440
+ * `velX` and `velZ` while leaving `velY` alone. With up anywhere else, the same input describes
441
+ * a direction in the world that is flattened onto the surface the body is standing on, which is
442
+ * what a caller means by "forward" when forward is along a wall — and `moveY` is what lets that
443
+ * direction leave the world XZ plane at all. See `ControllerInput.moveY`.
444
+ *
445
+ * The speed along up is held out and put back rather than being steered, because it belongs to
446
+ * gravity and the jump and not to the stick.
447
+ *
448
+ * **Unless the caller says the command is already the velocity it wants**, which is a body
449
+ * between two surfaces and is the one case this plane is the wrong one to read in. See
450
+ * `ControllerInput.projectMove`. The whole vector is steered then, at the same rate and under
451
+ * the same clamp and air control — only the plane is given up, not the feel.
452
+ */
453
+ if (input.projectMove === false) {
454
+ this.velX = approach(this.velX, wantX, step);
455
+ this.velY = approach(this.velY, wantY, step);
456
+ this.velZ = approach(this.velZ, wantZ, step);
457
+ } else {
458
+ const alongUp = this.speedAlongUp;
459
+ let tangentX = this.velX - this.upX * alongUp;
460
+ let tangentY = this.velY - this.upY * alongUp;
461
+ let tangentZ = this.velZ - this.upZ * alongUp;
462
+ const wantIntoUp = wantX * this.upX + wantY * this.upY + wantZ * this.upZ;
463
+ const wantTangentX = wantX - this.upX * wantIntoUp;
464
+ const wantTangentY = wantY - this.upY * wantIntoUp;
465
+ const wantTangentZ = wantZ - this.upZ * wantIntoUp;
466
+ tangentX = approach(tangentX, wantTangentX, step);
467
+ tangentY = approach(tangentY, wantTangentY, step);
468
+ tangentZ = approach(tangentZ, wantTangentZ, step);
469
+ this.velX = tangentX + this.upX * alongUp;
470
+ this.velY = tangentY + this.upY * alongUp;
471
+ this.velZ = tangentZ + this.upZ * alongUp;
472
+ }
473
+
474
+ if (this.state === SLIDING) {
475
+ /*
476
+ * Downhill along the surface: the gravity direction projected onto the plane. A slide that
477
+ * simply disabled control would leave a character stuck on a slope it cannot climb, which
478
+ * reads as being caught on geometry rather than as sliding.
479
+ */
480
+ const n = this.groundX * this.upX + this.groundY * this.upY + this.groundZ * this.upZ;
481
+ const downX = -(this.groundX - this.upX * n) * n;
482
+ const downY = -(this.groundY - this.upY * n) * n;
483
+ const downZ = -(this.groundZ - this.upZ * n) * n;
484
+ this.velX += downX * this.slideBoost * dt;
485
+ this.velY += downY * this.slideBoost * dt;
486
+ this.velZ += downZ * this.slideBoost * dt;
487
+ }
488
+
489
+ this.velX += this.gravityX * this.gravity * dt;
490
+ this.velY += this.gravityY * this.gravity * dt;
491
+ this.velZ += this.gravityZ * this.gravity * dt;
492
+ if (this.speedAlongUp <= 0) this.rising = false;
493
+
494
+ // A jump is allowed while grounded, or within coyote ticks of having been.
495
+ const mayJump = this.airTicks <= this.coyoteTicks && this.state !== SLIDING;
496
+ if (this.bufferTicks >= 0 && mayJump) {
497
+ this.setSpeedAlongUp(this.jumpSpeed);
498
+ this.bufferTicks = -1;
499
+ this.airTicks = this.coyoteTicks + 1;
500
+ this.rising = true;
501
+ this.state = AIRBORNE;
502
+ } else if (this.rising && !input.jump) {
503
+ // Released early: cut the rise, which is what makes a tap shorter than a hold.
504
+ this.setSpeedAlongUp(this.speedAlongUp * this.jumpCutoff);
505
+ this.rising = false;
506
+ }
507
+ }
508
+
509
+ /* ---------- mechanism ---------- */
510
+
511
+ /**
512
+ * Sweep, slide along whatever was hit, and try again — a fixed number of times.
513
+ *
514
+ * A step is attempted before sliding: lift by the step height, sweep forward, and drop. A
515
+ * character that slid off every lip would catch on kerbs and stair nosings, which reads as the
516
+ * world being sticky rather than as the character being short.
517
+ */
518
+ private sweep(world: PhysicsWorld, dt: number): void {
519
+ let dx = this.velX * dt;
520
+ let dy = this.velY * dt;
521
+ let dz = this.velZ * dt;
522
+
523
+ for (let pass = 0; pass < SLIDE_PASSES; pass++) {
524
+ const travel = Math.sqrt(dx * dx + dy * dy + dz * dz);
525
+ if (travel < 1e-6) return;
526
+ this.readPose();
527
+ if (!world.shapecast(this.shape, this.pose, dx, dy, dz, this.hit, this.filter)) {
528
+ this.x += dx;
529
+ this.y += dy;
530
+ this.z += dz;
531
+ return;
532
+ }
533
+
534
+ const t = this.hit.fraction;
535
+ this.x += dx * t;
536
+ this.y += dy * t;
537
+ this.z += dz * t;
538
+ this.push(world, this.hit.body, this.hit.nx, this.hit.ny, this.hit.nz);
539
+
540
+ let remainX = dx * (1 - t);
541
+ let remainY = dy * (1 - t);
542
+ let remainZ = dz * (1 - t);
543
+
544
+ /*
545
+ * Try to step over the lip before sliding along it. The normal is read out first, because
546
+ * `tryStep` sweeps twice and the projection below still needs it — sharing one `RayHit`
547
+ * between them left the slide projecting against whatever the step's own sweep last found,
548
+ * which stopped the character dead at every kerb.
549
+ */
550
+ const hx = this.hit.nx;
551
+ const hy = this.hit.ny;
552
+ const hz = this.hit.nz;
553
+ const intoUp = hx * this.upX + hy * this.upY + hz * this.upZ;
554
+ if (
555
+ intoUp < this.slopeCos &&
556
+ this.state === GROUNDED &&
557
+ this.tryStep(world, remainX, remainY, remainZ)
558
+ ) {
559
+ return;
560
+ }
561
+
562
+ // Project what is left onto the surface, which is what turns a stop into a slide.
563
+ const into = remainX * hx + remainY * hy + remainZ * hz;
564
+ remainX -= hx * into;
565
+ remainY -= hy * into;
566
+ remainZ -= hz * into;
567
+ const vInto = this.velX * hx + this.velY * hy + this.velZ * hz;
568
+ if (vInto < 0) {
569
+ this.velX -= hx * vInto;
570
+ this.velY -= hy * vInto;
571
+ this.velZ -= hz * vInto;
572
+ }
573
+ dx = remainX;
574
+ dy = remainY;
575
+ dz = remainZ;
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Lift, sweep forward, drop. Returns whether the whole step was taken.
581
+ *
582
+ * **The lift and the drop are along this body's up rather than along world Y**, which is what
583
+ * makes `stepHeight` a height rather than a Y offset. The forward part is the remainder of the
584
+ * move flattened into the tangent plane, for the same reason: what is left of a blocked move
585
+ * includes whatever of it was along up, and lifting by the step height and then also moving up
586
+ * would climb twice.
587
+ */
588
+ private tryStep(world: PhysicsWorld, dx: number, dy: number, dz: number): boolean {
589
+ const startX = this.x;
590
+ const startY = this.y;
591
+ const startZ = this.z;
592
+ const liftX = this.upX * this.stepHeight;
593
+ const liftY = this.upY * this.stepHeight;
594
+ const liftZ = this.upZ * this.stepHeight;
595
+
596
+ const intoUp = dx * this.upX + dy * this.upY + dz * this.upZ;
597
+ const flatX = dx - this.upX * intoUp;
598
+ const flatY = dy - this.upY * intoUp;
599
+ const flatZ = dz - this.upZ * intoUp;
600
+
601
+ this.x += liftX;
602
+ this.y += liftY;
603
+ this.z += liftZ;
604
+ this.readPose();
605
+ if (world.shapecast(this.shape, this.pose, flatX, flatY, flatZ, this.stepHit, this.filter)) {
606
+ this.x = startX;
607
+ this.y = startY;
608
+ this.z = startZ;
609
+ return false;
610
+ }
611
+ this.x += flatX;
612
+ this.y += flatY;
613
+ this.z += flatZ;
614
+ this.readPose();
615
+ if (world.shapecast(this.shape, this.pose, -liftX, -liftY, -liftZ, this.stepHit, this.filter)) {
616
+ const drop = this.stepHeight * this.stepHit.fraction - SNAP_EPS;
617
+ this.x -= this.upX * drop;
618
+ this.y -= this.upY * drop;
619
+ this.z -= this.upZ * drop;
620
+ return true;
621
+ }
622
+ // Nothing beneath the lifted position, so this was a gap rather than a step.
623
+ this.x = startX;
624
+ this.y = startY;
625
+ this.z = startZ;
626
+ return false;
627
+ }
628
+
629
+ /**
630
+ * Sweep the foot ball down, and fall back to a ray when what it finds is not walkable.
631
+ *
632
+ * The ball is what lets a character stand on the lip of a step: a ray from the capsule's axis has
633
+ * open air beneath it there, reports airborne, and disables the stepping that was carrying it up.
634
+ *
635
+ * **But a ball also brushes the side of the ledge it is walking off**, and a wall's normal is
636
+ * horizontal, so the ball alone reported *sliding* — which refused the coyote-time jump the
637
+ * character had every right to. So a non-walkable ball result is not trusted on its own: a ray
638
+ * down the axis decides whether there is genuinely a steep surface underfoot, or merely a wall
639
+ * beside it. Two cheap queries, and each covers the other's blind spot.
640
+ */
641
+ private senseGround(world: PhysicsWorld): void {
642
+ this.readPose();
643
+ this.pose.x = this.x - this.upX * this.halfHeight;
644
+ this.pose.y = this.y - this.upY * this.halfHeight;
645
+ this.pose.z = this.z - this.upZ * this.halfHeight;
646
+ const reach = this.radius * 0.05 + 0.06;
647
+ if (
648
+ world.shapecast(
649
+ this.foot,
650
+ this.pose,
651
+ -this.upX * reach,
652
+ -this.upY * reach,
653
+ -this.upZ * reach,
654
+ this.ground,
655
+ this.filter,
656
+ )
657
+ ) {
658
+ const intoUp =
659
+ this.ground.nx * this.upX + this.ground.ny * this.upY + this.ground.nz * this.upZ;
660
+ if (intoUp >= this.slopeCos) {
661
+ this.groundX = this.ground.nx;
662
+ this.groundY = this.ground.ny;
663
+ this.groundZ = this.ground.nz;
664
+ this.groundBody = this.ground.body;
665
+ this.state = GROUNDED;
666
+ return;
667
+ }
668
+ }
669
+ const axis = this.halfHeight + this.radius + 0.06;
670
+ if (
671
+ !world.raycast(
672
+ this.x,
673
+ this.y,
674
+ this.z,
675
+ -this.upX,
676
+ -this.upY,
677
+ -this.upZ,
678
+ axis,
679
+ this.ground,
680
+ this.filter,
681
+ )
682
+ ) {
683
+ /*
684
+ * Nothing solid under the feet. Ask the surface, where there is one — a banked deck is
685
+ * exactly the case a collider answers badly, and a consumer with one would otherwise be
686
+ * reported airborne while standing on it.
687
+ *
688
+ * Asked last rather than first, and that ordering is the decision: a body is a thing that
689
+ * is *there* and a surface is a description of where the ground *is*, so anything solid
690
+ * wins. A consumer with no probe reaches none of this.
691
+ */
692
+ if (this.probe !== null) {
693
+ const feet = this.y - this.halfHeight - this.radius;
694
+ if (
695
+ this.probe.sample(this.x, this.z, this.probed, this.y) &&
696
+ this.probed.y <= feet + 0.06
697
+ ) {
698
+ if (feet - this.probed.y <= 0.06) {
699
+ this.groundX = this.probed.normalX;
700
+ this.groundY = this.probed.normalY;
701
+ this.groundZ = this.probed.normalZ;
702
+ /* No body holds this character up, and −1 is what that has always meant. */
703
+ this.groundBody = -1;
704
+ this.state = this.probed.normalY >= this.slopeCos ? GROUNDED : SLIDING;
705
+ return;
706
+ }
707
+ }
708
+ }
709
+ this.state = AIRBORNE;
710
+ this.groundBody = -1;
711
+ return;
712
+ }
713
+ this.groundX = this.ground.nx;
714
+ this.groundY = this.ground.ny;
715
+ this.groundZ = this.ground.nz;
716
+ this.groundBody = this.ground.body;
717
+ this.state = this.ground.ny >= this.slopeCos ? GROUNDED : SLIDING;
718
+ }
719
+
720
+ /*
721
+ * **There was a `snapToGround` here and it is gone, because it was measured and did nothing.**
722
+ *
723
+ * The idea is standard: horizontal speed can carry the feet past a crest faster than gravity
724
+ * pulls them down, so a controller pulls them back onto a surface just below. This one never
725
+ * fired at all at first — it ran only while still grounded, and at a crest the ground sense has
726
+ * already said airborne — and once that ordering was fixed it made the result *worse*. Descending
727
+ * a sharp forty-degree crest at six metres a second, the largest gap between the feet and the
728
+ * surface was **0.490 metres with it and 0.457 without**, over 120 ticks.
729
+ *
730
+ * The sweep-and-slide already follows a surface closely enough that there is nothing left for a
731
+ * snap to correct, because the slide projection puts the remaining motion *along* the surface
732
+ * rather than through it. **What would bring it back** is a measurement showing a visible hop at
733
+ * a crest that the slide does not absorb — at which point the instrument to build first is the
734
+ * foot-gap probe above, not the airborne-tick count, which reports a state rather than a distance
735
+ * and called this feature useful when it was inert.
736
+ */
737
+
738
+ /**
739
+ * Push a dynamic body the character walked into, so a crate is not a wall.
740
+ *
741
+ * **The push carries the character's speed, not its per-tick step.** Adding the displacement —
742
+ * four metres a second comes to 0.067 of a metre in a tick — is less than the impulse ground
743
+ * friction takes off a crate in the same tick, so the crate is pushed and immediately stopped and
744
+ * never moves at all. Measured: a 30 kg crate under 0.5 friction shed 0.082 a tick against 0.067
745
+ * put in.
746
+ *
747
+ * So it sets a *target* rather than accumulating: the body is brought up to the character's own
748
+ * speed into the surface, scaled by `pushStrength`, and never slowed if it is already faster.
749
+ * **What this gives up** is momentum transfer — a heavy crate moves as readily as a light one,
750
+ * because a kinematic character has no mass to trade. **What would make it wrong** is a consumer
751
+ * wanting mass to matter, and the answer then is a dynamic character, which §12 refuses for
752
+ * reasons that have nothing to do with this.
753
+ */
754
+ private push(world: PhysicsWorld, body: number, nx: number, ny: number, nz: number): void {
755
+ if (body < 0 || this.pushStrength === 0) return;
756
+ if (world.bodies.type[body] !== BODY_DYNAMIC) return;
757
+ if ((world.bodies.invMass[body] ?? 0) === 0) return;
758
+ // The surface normal points at the character, so the push goes the other way.
759
+ const into = -(this.velX * nx + this.velY * ny + this.velZ * nz);
760
+ if (into <= 0) return;
761
+ const want = into * this.pushStrength;
762
+ const already = -(
763
+ (world.bodies.velX[body] ?? 0) * nx +
764
+ (world.bodies.velY[body] ?? 0) * ny +
765
+ (world.bodies.velZ[body] ?? 0) * nz
766
+ );
767
+ if (already >= want) return;
768
+ const add = want - already;
769
+ world.bodies.velX[body] = (world.bodies.velX[body] ?? 0) - nx * add;
770
+ world.bodies.velY[body] = (world.bodies.velY[body] ?? 0) - ny * add;
771
+ world.bodies.velZ[body] = (world.bodies.velZ[body] ?? 0) - nz * add;
772
+ world.wakeIsland(body);
773
+ }
774
+
775
+ /**
776
+ * The capsule at this body's position, standing along this body's up.
777
+ *
778
+ * **The quaternion used to be left at identity**, which was right while up was always world Y
779
+ * and is the one thing that cannot be left out once it is not: a body on a wall whose capsule is
780
+ * still world-vertical is swept against the wall by its side rather than its foot, so it rests a
781
+ * radius away instead of standing on it.
782
+ *
783
+ * The shortest arc from world Y to `up`, built directly rather than through an axis-angle,
784
+ * because the half-angle form is one square root. **The half turn is the case that has to be
785
+ * written out**: an up of exactly −Y makes the cross product zero and `w` zero with it, which
786
+ * normalises to NaN, and there is no shortest arc to pick because every arc is the same length.
787
+ * A turn about X is chosen, which is as good as any and is stable.
788
+ */
789
+ private readPose(): void {
790
+ this.pose.x = this.x;
791
+ this.pose.y = this.y;
792
+ this.pose.z = this.z;
793
+ const w = 1 + this.upY;
794
+ if (w < 1e-6) {
795
+ this.pose.qx = 1;
796
+ this.pose.qy = 0;
797
+ this.pose.qz = 0;
798
+ this.pose.qw = 0;
799
+ return;
800
+ }
801
+ const qx = this.upZ;
802
+ const qz = -this.upX;
803
+ const inverse = 1 / Math.sqrt(qx * qx + qz * qz + w * w);
804
+ this.pose.qx = qx * inverse;
805
+ this.pose.qy = 0;
806
+ this.pose.qz = qz * inverse;
807
+ this.pose.qw = w * inverse;
808
+ }
809
+ }
810
+
811
+ /** Move `from` toward `to` by at most `step`. */
812
+ function approach(from: number, to: number, step: number): number {
813
+ const d = to - from;
814
+ if (d > step) return from + step;
815
+ if (d < -step) return from - step;
816
+ return to;
817
+ }