@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +503 -0
- package/dist/bodies.d.ts +121 -0
- package/dist/bodies.js +298 -0
- package/dist/buoyancy.d.ts +46 -0
- package/dist/buoyancy.js +45 -0
- package/dist/cloth.d.ts +226 -0
- package/dist/cloth.js +587 -0
- package/dist/collide/aabb.d.ts +10 -0
- package/dist/collide/aabb.js +11 -0
- package/dist/collide/body.d.ts +202 -0
- package/dist/collide/body.js +142 -0
- package/dist/collide/eject.d.ts +39 -0
- package/dist/collide/eject.js +142 -0
- package/dist/collide/index.d.ts +18 -0
- package/dist/collide/index.js +5 -0
- package/dist/collide/scratch.d.ts +8 -0
- package/dist/collide/scratch.js +9 -0
- package/dist/collide/segment.d.ts +12 -0
- package/dist/collide/segment.js +109 -0
- package/dist/collide/sweep.d.ts +3 -0
- package/dist/collide/sweep.js +1036 -0
- package/dist/colliderSet.d.ts +239 -0
- package/dist/colliderSet.js +546 -0
- package/dist/compoundContact.d.ts +70 -0
- package/dist/compoundContact.js +221 -0
- package/dist/controller.d.ts +351 -0
- package/dist/controller.js +641 -0
- package/dist/cylinder.d.ts +78 -0
- package/dist/cylinder.js +294 -0
- package/dist/cylinderContact.d.ts +7 -0
- package/dist/cylinderContact.js +704 -0
- package/dist/decompose.d.ts +105 -0
- package/dist/decompose.js +979 -0
- package/dist/events.d.ts +43 -0
- package/dist/events.js +95 -0
- package/dist/executor.d.ts +147 -0
- package/dist/executor.js +173 -0
- package/dist/faces.d.ts +26 -0
- package/dist/faces.js +34 -0
- package/dist/fingerprint.d.ts +61 -0
- package/dist/fingerprint.js +139 -0
- package/dist/groundMotor.d.ts +34 -0
- package/dist/groundMotor.js +41 -0
- package/dist/heightfieldShape.d.ts +128 -0
- package/dist/heightfieldShape.js +328 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +47 -0
- package/dist/island.d.ts +55 -0
- package/dist/island.js +227 -0
- package/dist/islandSolve.d.ts +70 -0
- package/dist/islandSolve.js +171 -0
- package/dist/islandWorker.d.ts +18 -0
- package/dist/islandWorker.js +125 -0
- package/dist/joints.d.ts +180 -0
- package/dist/joints.js +1094 -0
- package/dist/manifold.d.ts +89 -0
- package/dist/manifold.js +608 -0
- package/dist/mass.d.ts +53 -0
- package/dist/mass.js +353 -0
- package/dist/meshContact.d.ts +57 -0
- package/dist/meshContact.js +393 -0
- package/dist/meshShape.d.ts +76 -0
- package/dist/meshShape.js +175 -0
- package/dist/pairs.d.ts +62 -0
- package/dist/pairs.js +154 -0
- package/dist/query.d.ts +71 -0
- package/dist/query.js +683 -0
- package/dist/ragdoll.d.ts +135 -0
- package/dist/ragdoll.js +489 -0
- package/dist/shape.d.ts +177 -0
- package/dist/shape.js +533 -0
- package/dist/solver.d.ts +136 -0
- package/dist/solver.js +601 -0
- package/dist/stage.d.ts +167 -0
- package/dist/stage.js +350 -0
- package/dist/stallEscape.d.ts +141 -0
- package/dist/stallEscape.js +254 -0
- package/dist/sweptStep.d.ts +173 -0
- package/dist/sweptStep.js +472 -0
- package/dist/tree.d.ts +61 -0
- package/dist/tree.js +348 -0
- package/dist/vehicle.d.ts +154 -0
- package/dist/vehicle.js +343 -0
- package/dist/workerPool.d.ts +107 -0
- package/dist/workerPool.js +239 -0
- package/dist/workers.d.ts +37 -0
- package/dist/workers.js +35 -0
- package/dist/world.d.ts +298 -0
- package/dist/world.js +712 -0
- package/package.json +55 -0
- package/src/bodies.ts +369 -0
- package/src/buoyancy.ts +78 -0
- package/src/cloth.ts +734 -0
- package/src/collide/aabb.ts +28 -0
- package/src/collide/body.ts +336 -0
- package/src/collide/eject.ts +156 -0
- package/src/collide/index.ts +26 -0
- package/src/collide/scratch.ts +10 -0
- package/src/collide/segment.ts +127 -0
- package/src/collide/sweep.ts +1132 -0
- package/src/colliderSet.ts +637 -0
- package/src/compoundContact.ts +284 -0
- package/src/controller.ts +817 -0
- package/src/cylinder.ts +340 -0
- package/src/cylinderContact.ts +825 -0
- package/src/decompose.ts +1198 -0
- package/src/events.ts +108 -0
- package/src/executor.ts +224 -0
- package/src/faces.ts +37 -0
- package/src/fingerprint.ts +146 -0
- package/src/groundMotor.ts +53 -0
- package/src/heightfieldShape.ts +395 -0
- package/src/index.ts +164 -0
- package/src/island.ts +219 -0
- package/src/islandSolve.ts +218 -0
- package/src/islandWorker.ts +141 -0
- package/src/joints.ts +1367 -0
- package/src/manifold.ts +798 -0
- package/src/mass.ts +424 -0
- package/src/meshContact.ts +443 -0
- package/src/meshShape.ts +261 -0
- package/src/pairs.ts +169 -0
- package/src/query.ts +844 -0
- package/src/ragdoll.ts +662 -0
- package/src/shape.ts +656 -0
- package/src/solver.ts +873 -0
- package/src/stage.ts +406 -0
- package/src/stallEscape.ts +362 -0
- package/src/sweptStep.ts +609 -0
- package/src/tree.ts +366 -0
- package/src/vehicle.ts +489 -0
- package/src/workerPool.ts +283 -0
- package/src/workers.ts +38 -0
- 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
|
+
}
|