@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,609 @@
1
+ import { AXIS_X, AXIS_Y, AXIS_Z, moveAxis } from './collide/index.ts';
2
+ import type { Axis, Body } from './collide/index.ts';
3
+ import type { ColliderSet } from './colliderSet.ts';
4
+
5
+ /**
6
+ * Moving a kinematic body horizontally, one axis at a time, through a world that has opinions.
7
+ *
8
+ * **The half a sweep does not cover.** `moveAxis` answers "what stopped me"; it has nothing to say
9
+ * about whether the thing that stopped you was a kerb you should have stepped over, a face the
10
+ * world calls solid but carries no collider for, a facet seam you clipped in mid-air, or a corner
11
+ * that has just trapped you because both axes refused in the same tick. Every one of those is a
12
+ * rule rather than a query, and every one of them is the same in any game.
13
+ *
14
+ * **Three opinions the geometry cannot hold**, taken structurally through `WorldOpinion` — the
15
+ * third time this package has needed that shape, after `GroundProbe` and `EscapeVeto`, and for the
16
+ * same reason each time: a consumer with an analytic surface knows things a collider set does not.
17
+ * A deck drawn as a smooth ribbon carries no boxes at all, so its *edge* is as solid as a wall and
18
+ * nothing in the collider set says so.
19
+ *
20
+ * **What it will not do is decide what you are standing on.** That question drags in every noun a
21
+ * game has — what the surface is made of, whether it burns, whether it launches you — and it stays
22
+ * with the consumer. This owns the move; the consumer owns the meaning.
23
+ */
24
+
25
+ /** Anything with a planar velocity the sweep may redirect. */
26
+ export interface SweptState {
27
+ velX: number;
28
+ velZ: number;
29
+ }
30
+
31
+ /**
32
+ * What the world knows that its colliders do not.
33
+ *
34
+ * Declared structurally, so nothing is imported in either direction.
35
+ */
36
+ export interface WorldOpinion {
37
+ /**
38
+ * Is there a face standing where the body has just moved to, that it can neither step onto nor
39
+ * pass under? `from` is where it started on `axis`.
40
+ *
41
+ * The case this exists for: a deck drawn as a smooth surface carries no collision boxes, so
42
+ * every one of its edges is scenery. A body walks into the side of a raised slab and keeps
43
+ * going, which looks exactly like clipping through the world because it is.
44
+ */
45
+ blocked(axis: Axis, from: number): boolean;
46
+ /**
47
+ * How far the world's own surface would rise to meet a body standing at `(x, z)` with its feet
48
+ * at `feetY`, or null where it has no opinion. Negative for a descent.
49
+ */
50
+ riseTo(x: number, z: number, feetY: number): number | null;
51
+ /**
52
+ * Whether the world vouches for its own rises *here*.
53
+ *
54
+ * The retry below overrules a collider that refused a move, on the grounds that the surface says
55
+ * the ground rises to meet it. That is right on ground the world authored as travelable and
56
+ * wrong everywhere else: a terrace wall, a station lip and a courtyard edge are real, intended walls
57
+ * and the hull is the only honest answer for them. A consumer with no such distinction answers
58
+ * the same thing every time and pays nothing.
59
+ */
60
+ vouches(): boolean;
61
+ }
62
+
63
+ export interface SweptStepOptions {
64
+ /** How high a lip may be and still be stepped over. */
65
+ stepHeight?: number;
66
+ /**
67
+ * How much of a shortfall counts as a graze rather than a wall, for a body in the air, metres.
68
+ *
69
+ * A facet seam is a few centimetres and mid-flight is exactly where one is most likely to be
70
+ * clipped in passing rather than run into. A genuine wall does not pass it: hit one square and
71
+ * the move is near zero regardless of how small it was.
72
+ */
73
+ grazeM?: number;
74
+ /**
75
+ * How wide something has to be before it is scenery rather than a post, metres.
76
+ *
77
+ * A deck's own geometry spans most of a cross-section by construction, metres in at least one
78
+ * direction; a lamp post is fourteen centimetres. The width test alone tells them apart, and it
79
+ * is what keeps the vouched retry from walking a body through a real obstacle.
80
+ */
81
+ propFootprintM?: number;
82
+ /** How much exploratory lift a vouched retry is worth where the surface reports no rise. */
83
+ facetClearM?: number;
84
+ /** A rise smaller than this is not a rise. Matches whatever the consumer's own snap ignores. */
85
+ riseSlackM?: number;
86
+ /**
87
+ * The least horizontal contact normal that counts as a wall.
88
+ *
89
+ * Below it the contact is a floor or a ceiling, and redirecting horizontal motion along one of
90
+ * those would let a body climb.
91
+ */
92
+ wallNormalMin?: number;
93
+ /** How hard a body slides off something it is merely perched on, metres per second squared. */
94
+ slipAccel?: number;
95
+ /** The speed a slip starts at, so it is not defeated by a body pressing into the contact. */
96
+ slipNudge?: number;
97
+ /** How little movement counts as a slip having achieved nothing, metres. */
98
+ slipBlockedM?: number;
99
+ /** How long after a step up a rim is still treated as a plateau rather than a wall, seconds. */
100
+ stepGraceSec?: number;
101
+ }
102
+
103
+ /** Floating-point slack for "did the move arrive". */
104
+ const EPSILON = 1e-9;
105
+
106
+ export class SweptStep {
107
+ /**
108
+ * Whether the world refused a horizontal move this tick.
109
+ *
110
+ * Read by anything that wants to tell a body that is stuck from one that is going in circles —
111
+ * `StallEscape` is the obvious consumer, and the two were written for each other.
112
+ */
113
+ moveRefused = false;
114
+ /** Seconds since the last successful step up. See `stepGraceSec`. */
115
+ sinceStepUp = Infinity;
116
+
117
+ private readonly stepHeight: number;
118
+ private readonly grazeM: number;
119
+ private readonly propFootprintM: number;
120
+ private readonly facetClearM: number;
121
+ private readonly riseSlackM: number;
122
+ private readonly wallNormalMin: number;
123
+ private readonly slipAccel: number;
124
+ private readonly slipNudge: number;
125
+ private readonly slipBlockedM: number;
126
+ private readonly stepGraceSec: number;
127
+
128
+ /**
129
+ * Open a tick: forget last tick's refusal and age the step-up grace.
130
+ *
131
+ * **Both counters live here or they live in two places**, which is exactly the bug this seam
132
+ * shipped with for one commit: the consumer kept its own `sinceStepUp`, advanced it, and the
133
+ * engine's stayed pinned at whatever the last step set — so the grace below never expired and a
134
+ * body perched on a rim was told, for ever, that it had just climbed onto a plateau.
135
+ */
136
+ beginTick(dt: number): void {
137
+ this.moveRefused = false;
138
+ this.sinceStepUp += dt;
139
+ }
140
+
141
+ /**
142
+ * Scratch for the prop query, claimed once and sized for the worst measured.
143
+ *
144
+ * A dense cluster of geometry can return dozens of candidates — measured at 102 in one query on
145
+ * a wide bank — and a buffer smaller than that silently drops whatever does not fit, dropping
146
+ * the one post among them exactly as often as it keeps it.
147
+ */
148
+ private readonly propProbe = new Int32Array(256);
149
+
150
+ constructor(options: SweptStepOptions = {}) {
151
+ this.stepHeight = options.stepHeight ?? 0.35;
152
+ this.grazeM = options.grazeM ?? 0.12;
153
+ this.propFootprintM = options.propFootprintM ?? 1.2;
154
+ this.facetClearM = options.facetClearM ?? 0.3;
155
+ this.riseSlackM = options.riseSlackM ?? 0.02;
156
+ this.wallNormalMin = options.wallNormalMin ?? 0.2;
157
+ this.slipAccel = options.slipAccel ?? 18;
158
+ this.slipNudge = options.slipNudge ?? 3.5;
159
+ this.slipBlockedM = options.slipBlockedM ?? 0.002;
160
+ this.stepGraceSec = options.stepGraceSec ?? 0.1;
161
+ }
162
+
163
+ slipOffContact(body: Body, state: SweptState, dt: number, colliders: ColliderSet): boolean {
164
+ let nx = body.contactNormalX ?? 0;
165
+ let nz = body.contactNormalZ ?? 0;
166
+ let len = Math.sqrt(nx * nx + nz * nz);
167
+ if (len <= this.wallNormalMin) {
168
+ /* What the character just climbed onto looks exactly like a rim until they settle on it.
169
+ See `this.stepGraceSec` for the plateau this turned into a wall. */
170
+ if (this.sinceStepUp < this.stepGraceSec) return true;
171
+ /*
172
+ * **A perch on a rim has a contact normal pointing straight up, and this method used to
173
+ * give up on exactly that case.**
174
+ *
175
+ * The engine refuses to call a contact support unless the body's own column is over the
176
+ * thing holding it, which is right: a foot's corner on a nine-centimetre sliver of a slab
177
+ * is a tip-over, not standing. But the normal it reports is the way out of the *clamp*,
178
+ * and for a downward clamp on a walkable plane that is up. So the horizontal length was
179
+ * zero, this returned, and nothing at all pushed the body off — while `grounded` stayed
180
+ * false, so no jump, and the blocked fall re-clamped the vertical speed every tick, so
181
+ * gravity never accumulated either.
182
+ *
183
+ * Measured at the reporter's own coordinates on a courtyard pillar: **five seconds of held
184
+ * forward input, zero metres of descent**, the character standing in clear air on the corner
185
+ * of a column. Sliding along the rim did not help, because the rim runs the length of the
186
+ * slab: the only direction that ends it is off the edge, and the sweep is the only thing
187
+ * that saw which edge. It reports it now.
188
+ */
189
+ nx = body.contactRimX ?? 0;
190
+ nz = body.contactRimZ ?? 0;
191
+ len = Math.sqrt(nx * nx + nz * nz);
192
+ /* No normal and no rim: the sweep has named no way off, so there is none to try and
193
+ nothing here can say whether one exists. Left exactly as it was found. */
194
+ if (len <= this.wallNormalMin) return true;
195
+ }
196
+ const ux = nx / len;
197
+ const uz = nz / len;
198
+ /*
199
+ * **Whatever is pushing into the contact is dropped first, and without this the rest of
200
+ * this method loses an argument it cannot win.**
201
+ *
202
+ * A graze grants no grounding, so the drive that runs before this is the *air* one, and
203
+ * `airAccel` in its Quake form is `airAccel x wishSpeed` — 1.4 times a wish of twenty is
204
+ * 28 m/s² pointed straight into the face. `this.slipAccel` is 6, and small on purpose:
205
+ * the note above says a bigger number reads as being shoved by the scenery. So holding
206
+ * forward beat the slip roughly five to one, and it beat gravity too, because a blocked
207
+ * fall re-clamps `velY` to the distance the sweep allowed on *every* tick — the pull never
208
+ * accumulates while the contact keeps refusing it.
209
+ *
210
+ * The result was a character standing in clear air against the flank or the underside of a
211
+ * slab, motionless for as long as the key was held, with nothing drawn under them.
212
+ * Reported from the courtyard lamps, from a pillar row and from the ribbon's own edge, and
213
+ * called what it is: *"like glued while I keep press W."*
214
+ *
215
+ * You cannot accelerate into something you are already touching, so the component of the
216
+ * horizontal velocity pointing into the contact is removed and the tangential part is
217
+ * kept — the same clip a solid surface would apply, applied to a contact solid enough to
218
+ * stop a fall. Sliding *along* the flank survives untouched, which is what keeps a character
219
+ * brushing a wall at speed from being stopped by it. What does not survive is holding
220
+ * yourself onto it.
221
+ */
222
+ /*
223
+ * **The nudge goes first, because it is also the only honest test of the premise.**
224
+ *
225
+ * Everything below this takes something away from the player — the drive that pushes
226
+ * into the contact — on the understanding that they are hanging off an edge and about to
227
+ * come off it. A body that the world refuses to move is not hanging off anything, and
228
+ * charging it the clip anyway is how a character standing between two lamp bases ends up
229
+ * unable to walk out *and* unable to jump. So: try to leave first, and only pay for the
230
+ * slide if the slide happened.
231
+ */
232
+ const step = this.slipNudge * dt;
233
+ const slidX = moveAxis(body, colliders, AXIS_X, ux * step);
234
+ const slidZ = moveAxis(body, colliders, AXIS_Z, uz * step);
235
+ if (Math.abs(slidX) + Math.abs(slidZ) < this.slipBlockedM) return false;
236
+
237
+ const into = state.velX * ux + state.velZ * uz;
238
+ if (into < 0) {
239
+ state.velX -= ux * into;
240
+ state.velZ -= uz * into;
241
+ }
242
+ state.velX += ux * this.slipAccel * dt;
243
+ state.velZ += uz * this.slipAccel * dt;
244
+ return true;
245
+ }
246
+
247
+ moveHorizontal(
248
+ body: Body,
249
+ state: SweptState,
250
+ axis: Axis,
251
+ want: number,
252
+ colliders: ColliderSet,
253
+ supported: boolean,
254
+ world: WorldOpinion,
255
+ ): void {
256
+ const before = axis === AXIS_X ? body.x : body.z;
257
+ const moved = moveAxis(body, colliders, axis, want);
258
+ /*
259
+ * A deck's *edge* is as solid as a wall, and no box says so.
260
+ *
261
+ * A ridden deck may carry no collision boxes at all — deliberately, and the reasoning
262
+ * is sound: a world-axis box around a banked cross-section has a
263
+ * flat lid at its highest corner, which is floor where nothing is drawn and a wall
264
+ * where the next slab's lid rises above the character's feet. So a ribbon is solid from
265
+ * *above* only, because that is the one question `GroundSurface` answers.
266
+ *
267
+ * Which leaves every deck edge as scenery. Where a split's branch crosses the main
268
+ * line it stands a little higher, and a character on the lower deck walks straight into
269
+ * the side of the upper slab and keeps going — reported as passing through the link
270
+ * outright, as though the element were not solid. The same hole is under every raised
271
+ * lip and every raised platform.
272
+ *
273
+ * So the surface itself is asked, once per axis, whether the place the body just moved
274
+ * into has a *floor standing in it*. Three bands, and the boundaries are the movement
275
+ * model's own numbers rather than new ones:
276
+ *
277
+ * - a top at or below `feet + stepHeight` is a step, and `tryStepUp` owns it;
278
+ * - a top above the character's head is a flyover, and they pass underneath;
279
+ * - anything between is a face they cannot climb, so it stops them.
280
+ *
281
+ * Cheap and safe on a bank, which was the worry: this tests a single tick's
282
+ * displacement, and even at top speed across the steepest bank the generator authors
283
+ * that is under 0.4 m of rise — inside `stepHeight`, so a tube cannot wall itself off.
284
+ *
285
+ * Grounded characters only. The check predates the deck being solid; for a body in the
286
+ * air the hulls are the honest answer now, and the surface prediction was stopping
287
+ * jumps in open air a stride short of the drawn face — measured on day 9 as 13 m/s
288
+ * dying mid-flight over a gap, the character hanging where nothing is drawn.
289
+ */
290
+ if (supported && world.blocked(axis, before)) {
291
+ if (axis === AXIS_X) body.x = before;
292
+ else body.z = before;
293
+ if (axis === AXIS_X) state.velX = 0;
294
+ else state.velZ = 0;
295
+ // The world pushed back. `unwedge` is the only reader, and it is the difference
296
+ // between a character who is stuck and one who is merely going in circles.
297
+ this.moveRefused = true;
298
+ return;
299
+ }
300
+ if (Math.abs(moved - want) <= EPSILON) return;
301
+ if (supported && this.tryStepUp(body, axis, want - moved, colliders, world)) {
302
+ return;
303
+ }
304
+ this.moveRefused = true;
305
+ /*
306
+ * A graze, mid-air, is not a wall either — there is simply nothing to lift
307
+ * off of to find out. `tryStepUp` is a grounded character's tool; a body
308
+ * genuinely clear of the deck (`supported` false here means exactly that —
309
+ * not this tick's jump, not coyote, no floor under it at all) has no floor
310
+ * to step up onto, but a facet seam is still only a few centimetres, and
311
+ * mid-flight is exactly where one is most likely to be clipped in passing
312
+ * rather than run into. Measured on a low hop through a hairpin: a 1 cm
313
+ * shortfall — `moved` still 94% of `want` — killed a character's whole speed
314
+ * on the axis it grazed, well clear of the deck by the character's own
315
+ * reckoning. A genuine wall does not pass this: hit one square and `moved`
316
+ * is near zero regardless of how small `want` was that tick, so the ratio
317
+ * test still tells the two apart at a walking pace, not only a sprint.
318
+ */
319
+ if (
320
+ !supported &&
321
+ Math.abs(want - moved) <= this.grazeM &&
322
+ Math.abs(moved) >= Math.abs(want) * 0.5
323
+ ) {
324
+ return;
325
+ }
326
+ /*
327
+ * On a vouched deck, a rise that the surface itself vouches for is worth a
328
+ * second attempt at the same sweep, lifted to meet it. The requirement is
329
+ * that a ridden surface's tilt is followed however extreme — past vertical
330
+ * if the deck says so, which looks wrong and is correct — but only where
331
+ * the surface vouches, never on ordinary geometry. A banked span wide enough to
332
+ * matter builds a hull tall enough that a landing a few centimetres to
333
+ * the wrong side of its own facet seam can refuse *both* axes outright —
334
+ * not a graze, a flat `moved = 0`, on a column the smooth curve answers
335
+ * as perfectly ordinary track. `tryStepUp` already tried this with only
336
+ * `stepHeight` of lift and failed; this tries again with however much
337
+ * the deck's own rise actually calls for, uncapped.
338
+ *
339
+ * The retry is a real sweep, not an assumption — `moveAxis` decides,
340
+ * exactly as it did the first time, and only a *clean* result (the full
341
+ * remaining distance, not merely something nonzero) counts. That is what
342
+ * keeps this from being a licence to walk through anything the surface
343
+ * merely doesn't object to: a wall built across the deck — as opposed to
344
+ * being part of it — has nothing under it to lift into, so a flat deck's
345
+ * zero-rise retry meets exactly the same wall a second time and this
346
+ * falls through to the ordinary response below, same as it always did.
347
+ * `velocity.test.ts`'s own deliberate wall caught the difference between
348
+ * "trust the answer" and "retry the sweep": the surface still called the
349
+ * column ahead ordinary track (a synthetic wall carries no surface
350
+ * opinion at all), and trusting that outright walked a character straight
351
+ * through it.
352
+ *
353
+ * `world.vouches()` is the gate that keeps this off ordinary geometry: a
354
+ * terrace, a raised lip, a courtyard are all real, intended walls the hull
355
+ * is the only honest answer for, and none of them are ridden deck.
356
+ *
357
+ * Not gated on `supported` here — `surfaceRiseTo` already carries its own,
358
+ * tighter version of that question (ridden, or falling and within a snap
359
+ * of the deck below), which is what actually matters for a low hop that
360
+ * clips the same tall wedge without ever losing its claim to the ground
361
+ * underneath it. A real jump over open water is nowhere near a surface
362
+ * answer either way, so it never reaches here regardless.
363
+ *
364
+ * A lift left short of what it promised is put back exactly as it found
365
+ * it, along with the axis — nothing here is allowed to leave the body
366
+ * somewhere the ordinary sweep never actually cleared. Descents are left
367
+ * alone, the same "rises only" rule `preLift` follows: settling onto a
368
+ * lower column is an ordinary fall `resolveSurface` already owns.
369
+ */
370
+ if (world.vouches()) {
371
+ const targetX = axis === AXIS_X ? before + want : body.x;
372
+ const targetZ = axis === AXIS_Z ? before + want : body.z;
373
+ const rise = world.riseTo(targetX, targetZ, body.y - body.hy);
374
+ if (rise !== null && !this.blockedByProp(body, axis, before, want, colliders)) {
375
+ const savedY = body.y;
376
+ /*
377
+ * A near-flat or descending target still earns a *small* exploratory
378
+ * lift, capped at facet scale rather than left at the bare rise. The
379
+ * deck's own hull sits a `RIBBON_COLLIDER_GIVE_M` give under the
380
+ * curve and a `MAX_FACET_SLIP_M` ridge is normal at any bank — the
381
+ * ordinary sub-step budget nothing here changes — so a target the
382
+ * surface calls level can still be blocked by a genuine facet seam a
383
+ * few centimetres tall the smooth curve was never going to report as
384
+ * a rise. `this.facetClearM` is comfortably past both those tolerances
385
+ * and nowhere near tall enough to matter for a real wall — the same
386
+ * synthetic 12 m wall this whole retry exists to still refuse.
387
+ */
388
+ const wanted =
389
+ rise > this.riseSlackM
390
+ ? rise - this.riseSlackM
391
+ : rise > -this.facetClearM
392
+ ? this.facetClearM
393
+ : 0;
394
+ if (wanted > 0) moveAxis(body, colliders, AXIS_Y, wanted);
395
+ const remaining = want - moved;
396
+ const along = moveAxis(body, colliders, axis, remaining);
397
+ if (Math.abs(along - remaining) <= EPSILON) return;
398
+ // The deck's own rise did not clear it either — put both back.
399
+ if (axis === AXIS_X) body.x = before + moved;
400
+ else body.z = before + moved;
401
+ body.y = savedY;
402
+ }
403
+ }
404
+ /*
405
+ * A genuine wall. The move is refused — but the *velocity* is redirected along the
406
+ * face rather than deleted on this axis.
407
+ *
408
+ * Zeroing the axis is what turned every corner in the world into a trap. Movement
409
+ * resolves X and Z separately, so at any corner, edge or obliquely-met face both
410
+ * axes refuse in the same tick and both get zeroed: the body ends the tick pressed
411
+ * against the geometry with no speed, and next tick it asks for the same refused
412
+ * move again. Reported from two unrelated places: a character trapped with no jump
413
+ * available to it, able only to go back and fall.
414
+ *
415
+ * Sliding fixes it without weakening anything, because it removes only the
416
+ * component going *into* the surface. Met square on, the contact normal is the
417
+ * direction of travel and the projection takes the whole of it — a wall across the
418
+ * way costs exactly what it always did, which `edgeEscape.test.ts` guards with a
419
+ * twelve-metre slab. Met at an angle, the tangential part survives and the character
420
+ * skims along it, which is both what a skater would do and the escape route that
421
+ * was missing.
422
+ *
423
+ * Walls only, by the contact's own normal: a near-horizontal contact is a floor or
424
+ * a ceiling, and redirecting horizontal motion along one of those would let a body
425
+ * climb. `resolveSurface` owns those.
426
+ */
427
+ const nx = body.contactNormalX ?? 0;
428
+ const nz = body.contactNormalZ ?? 0;
429
+ const acrossLen = Math.sqrt(nx * nx + nz * nz);
430
+ if (acrossLen > this.wallNormalMin) {
431
+ const ux = nx / acrossLen;
432
+ const uz = nz / acrossLen;
433
+ const into = state.velX * ux + state.velZ * uz;
434
+ // Negative means travelling into the face; a positive dot is already leaving it.
435
+ if (into < 0) {
436
+ state.velX -= ux * into;
437
+ state.velZ -= uz * into;
438
+ }
439
+ return;
440
+ }
441
+ if (axis === AXIS_X) state.velX = 0;
442
+ else state.velZ = 0;
443
+ }
444
+
445
+ /**
446
+ * Is a standalone prop — not the deck's own wedge geometry — actually
447
+ * standing anywhere along this axis's swept move? The one distinction the
448
+ * on-Way trust in `moveHorizontal` cannot make from the smooth surface
449
+ * alone: the ground query answers for the deck itself, so a lamp, a
450
+ * brazier's stand or an arch post planted exactly where the deck reads
451
+ * clear is invisible to it. Real obstacles still have to collide — a lamp
452
+ * or any other placed item in the path must still register an impact — so
453
+ * before the deck's own hull is overruled, this asks whether anything
454
+ * narrow enough to be a *post*
455
+ * rather than a *slab* occupies the path. The deck's own wedges span most
456
+ * of a cross-section by construction, metres in at least one horizontal
457
+ * direction; a lamp post is fourteen centimetres. `this.propFootprintM` sits
458
+ * comfortably between the two, so the width test alone tells them apart.
459
+ *
460
+ * The box spans `before` to the full `want`, not only the endpoint —
461
+ * `moveAxis` already stopped short of a real prop, so the prop itself sits
462
+ * somewhere inside that span, never exactly on the far end this checks
463
+ * against. A box at the endpoint alone missed it outright: placed items
464
+ * such as lamps were ignored and a body passed straight through them.
465
+ *
466
+ * A dense cluster of wedges nearby can return dozens of candidates —
467
+ * measured at 102 in one query on a wide bank — well past the buffer this
468
+ * used to carry, which silently dropped whatever did not fit and dropped
469
+ * the one lamp among them exactly as often as it kept it. `propProbe` is
470
+ * sized for the worst of what was actually measured, with headroom.
471
+ */
472
+
473
+ private blockedByProp(
474
+ body: Body,
475
+ axis: Axis,
476
+ before: number,
477
+ want: number,
478
+ colliders: ColliderSet,
479
+ ): boolean {
480
+ const half = body.hx + 0.05;
481
+ const lo = Math.min(before, before + want) - half;
482
+ const hi = Math.max(before, before + want) + half;
483
+ const minX = axis === AXIS_X ? lo : body.x - half;
484
+ const maxX = axis === AXIS_X ? hi : body.x + half;
485
+ const minZ = axis === AXIS_Z ? lo : body.z - half;
486
+ const maxZ = axis === AXIS_Z ? hi : body.z + half;
487
+ const q = this.propProbe;
488
+ const n = colliders.query(minX, body.y - body.hy, minZ, maxX, body.y + body.hy, maxZ, q);
489
+ const data = colliders.data;
490
+ for (let k = 0; k < n; k++) {
491
+ const o = q[k] * 6;
492
+ const width = data[o + 3] - data[o];
493
+ const depth = data[o + 5] - data[o + 2];
494
+ if (width < this.propFootprintM && depth < this.propFootprintM) return true;
495
+ }
496
+ return false;
497
+ }
498
+
499
+ /**
500
+ * Is there a walkable face standing in the body's current column that the character can
501
+ * neither step onto nor pass under? See `moveHorizontal` for why this exists.
502
+ *
503
+ * Uses its own hit rather than `this.hit`, which `resolveSurface` owns for the whole
504
+ * tick and reads after both axes have moved.
505
+ */
506
+
507
+ private tryStepUp(
508
+ body: Body,
509
+ axis: Axis,
510
+ remaining: number,
511
+ colliders: ColliderSet,
512
+ world: WorldOpinion,
513
+ ): boolean {
514
+ const savedX = body.x;
515
+ const savedY = body.y;
516
+ const savedZ = body.z;
517
+
518
+ /*
519
+ * The lift is resolved, and it can be refused — but by then it never is by
520
+ * the floor itself: a body inside the deck it stands on is put back on top
521
+ * of it in `integrate`, before anything sweeps. What can still refuse a lift
522
+ * here is a genuine ceiling, and a character under one should not be stepping.
523
+ */
524
+ /*
525
+ * The budget is `stepHeight`, unless the deck being ridden itself vouches
526
+ * for more — see `surfaceRiseTo`.
527
+ *
528
+ * `stepHeight` is sized for a literal kerb, and it is the wrong number
529
+ * once the axis being swept stops agreeing with the road: `moveHorizontal`
530
+ * resolves X and Z one at a time, so a corner banked steeply enough asks a
531
+ * *pure*-X (or pure-Z) sweep to climb the bank directly whenever the road's
532
+ * own tangent runs mostly along the other axis — a real slope, just not one
533
+ * either isolated axis meets at its own angle. Measured on a hairpin banked
534
+ * 25°: the combined move asked `preLift` for 20 cm and got it, but the
535
+ * following pure-X sweep alone met a wall a full `stepHeight` could not
536
+ * clear, and the character's whole speed on that axis died on a bank the same
537
+ * tick's own pre-lift had already vouched for. Reported on the same class of
538
+ * junction: a body impacting the deck itself while sliding, and sometimes
539
+ * pushed off the track.
540
+ *
541
+ * Only the ridden deck's own rise can raise the budget — `surfaceRiseTo`
542
+ * is the same same-deck, same-normal check `preLift` trusts, so this can
543
+ * still never climb a railing or a prop standing beside the track: ask it
544
+ * about a point under either and it answers with the deck's own gentle
545
+ * rise, not the thing standing on it, and the budget stays at `stepHeight`.
546
+ * What actually stops a step, lift or not, is still `moveAxis`'s own sweep
547
+ * a few lines down — this only ever raises how far that sweep is allowed
548
+ * to *try*.
549
+ */
550
+ const targetX = axis === AXIS_X ? body.x + remaining : body.x;
551
+ const targetZ = axis === AXIS_Z ? body.z + remaining : body.z;
552
+ const surfaceRise = world.riseTo(targetX, targetZ, body.y - body.hy);
553
+ const liftBudget =
554
+ surfaceRise !== null && surfaceRise + this.riseSlackM > this.stepHeight
555
+ ? surfaceRise + this.riseSlackM
556
+ : this.stepHeight;
557
+ const lifted = moveAxis(body, colliders, AXIS_Y, liftBudget);
558
+ const along = moveAxis(body, colliders, axis, remaining);
559
+ moveAxis(body, colliders, AXIS_Y, -(lifted + 0.002));
560
+
561
+ /*
562
+ * Accepted on the physics alone: it went somewhere, and it climbed no more
563
+ * than the budget it was given. `climbed` is the guard that matters — a
564
+ * railing's bar tops out at `stepHeight × 1.4` precisely so an ordinary
565
+ * step can never mount it, and `liftBudget` only ever exceeds `stepHeight`
566
+ * when the ridden deck itself vouched for the extra, which a railing never
567
+ * does (see above) — so the railing's own margin over `stepHeight` still
568
+ * holds exactly as before.
569
+ *
570
+ * There used to be a third condition here, "the step must land on ground
571
+ * the surface recognises", refusing any landing 0.35–0.95 m above the
572
+ * surface's answer. It guarded against stepping onto collider tops standing
573
+ * proud of the drawn deck — which the twisted spans' hulls really did,
574
+ * before they were split into strips. Now a hull is the drawn slab to
575
+ * within centimetres, so anything a step can physically settle on *is*
576
+ * drawn, standable world — and the refusal's only remaining work was false:
577
+ * the finish terrace and the courtyard lips are drawn boxes a stride above a
578
+ * deck whose surface still answers underneath them, and refusing those
579
+ * turned every mixed-surface seam into a wall. Measured on day 8: a clean
580
+ * step onto the terrace refused, 10.7 m/s killed at the lip, and the last
581
+ * metres of the race crossed at a grind.
582
+ */
583
+ const progressed = Math.abs(along) > 1e-4;
584
+ const climbed = body.y > savedY + 1e-4 && body.y <= savedY + liftBudget + 1e-3;
585
+ if (progressed && climbed) {
586
+ /* What the character just climbed onto reads as a rim until they settle on it, and the
587
+ rim escape would shove them straight back off. See `this.stepGraceSec`. */
588
+ this.sinceStepUp = 0;
589
+ return true;
590
+ }
591
+
592
+ body.x = savedX;
593
+ body.y = savedY;
594
+ body.z = savedZ;
595
+ return false;
596
+ }
597
+
598
+ /**
599
+ * Skim a short break in the ground at speed instead of dipping into it.
600
+ *
601
+ * A velocity rule, not a collision one: a character at speed should cross a
602
+ * short break without falling, while one walking slowly may not. The reach is
603
+ * `speed × gapCoastTime`, so pace is the gate itself: a sprint clears a seam
604
+ * or a broken lip, a walk gets centimetres and still falls at an edge.
605
+ * Designed jump-holes stay jumps — they are several times longer than any
606
+ * reach this grants, and the far side must be drawn, walkable deck at run
607
+ * height for the skim to hold.
608
+ */
609
+ }