@woosh/meep-engine 2.145.0 → 2.146.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +33 -3
  3. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  4. package/src/core/geom/3d/shape/HeightMapShape3D.js +486 -451
  5. package/src/engine/control/first-person/DESIGN_COLLISION.md +365 -352
  6. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +1 -3
  7. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  8. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +12 -2
  9. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  10. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +7 -2
  11. package/src/engine/control/first-person/TODO.md +13 -11
  12. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -1
  13. package/src/engine/control/first-person/abilities/WallJump.js +11 -3
  14. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
  15. package/src/engine/control/first-person/abilities/WallRun.js +12 -0
  16. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
  17. package/src/engine/control/first-person/collision/KinematicMover.js +634 -592
  18. package/src/engine/control/first-person/prototype_first_person_controller.js +1003 -901
  19. package/src/engine/physics/PLAN.md +943 -809
  20. package/src/engine/physics/body/BodyStorage.d.ts +9 -0
  21. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
  22. package/src/engine/physics/body/BodyStorage.js +23 -0
  23. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  24. package/src/engine/physics/broadphase/generate_pairs.js +7 -0
  25. package/src/engine/physics/ccd/linear_sweep.d.ts +97 -0
  26. package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -0
  27. package/src/engine/physics/ccd/linear_sweep.js +238 -0
  28. package/src/engine/physics/ecs/PhysicsSystem.d.ts +18 -3
  29. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  30. package/src/engine/physics/ecs/PhysicsSystem.js +59 -8
  31. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +6 -0
  32. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -1
  33. package/src/engine/physics/ecs/RigidBodyFlags.js +6 -0
  34. package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -811
  35. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  36. package/src/engine/physics/narrowphase/compute_penetration.js +325 -323
  37. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +27 -8
  38. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -1
  39. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +235 -204
  40. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  41. package/src/engine/physics/narrowphase/narrowphase_step.js +70 -13
  42. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
  43. package/src/engine/physics/queries/overlap_shape.js +185 -183
  44. package/src/engine/simulation/Ticker.d.ts +14 -0
  45. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  46. package/src/engine/simulation/Ticker.js +136 -1
@@ -1,901 +1,1003 @@
1
- import { BoxGeometry, MeshStandardMaterial, TorusKnotGeometry } from "three";
2
- import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
3
- import { BoxShape3D } from "../../../core/geom/3d/shape/BoxShape3D.js";
4
- import { CapsuleShape3D } from "../../../core/geom/3d/shape/CapsuleShape3D.js";
5
- import { TransformedShape3D } from "../../../core/geom/3d/shape/TransformedShape3D.js";
6
- import { shape_mesh_from_geometry } from "../../../core/geom/3d/shape/shape_mesh_from_geometry.js";
7
- import Vector2 from "../../../core/geom/Vector2.js";
8
- import Vector3 from "../../../core/geom/Vector3.js";
9
- import { SerializationMetadata } from "../../ecs/components/SerializationMetadata.js";
10
- import { Tag } from "../../ecs/components/Tag.js";
11
- import Entity from "../../ecs/Entity.js";
12
- import { obtainTerrain } from "../../ecs/terrain/util/obtainTerrain.js";
13
- import { Transform } from "../../ecs/transform/Transform.js";
14
- import { EngineHarness } from "../../EngineHarness.js";
15
- import { CameraSystem } from "../../graphics/ecs/camera/CameraSystem.js";
16
- import { ShadedGeometry } from "../../graphics/ecs/mesh-v2/ShadedGeometry.js";
17
- import { ShadedGeometryFlags } from "../../graphics/ecs/mesh-v2/ShadedGeometryFlags.js";
18
- import { ShadedGeometrySystem } from "../../graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
19
- import {
20
- AmbientOcclusionPostProcessEffect
21
- } from "../../graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
22
- import InputController from "../../input/ecs/components/InputController.js";
23
- import InputControllerSystem from "../../input/ecs/systems/InputControllerSystem.js";
24
- import { BodyKind } from "../../physics/ecs/BodyKind.js";
25
- import { Collider } from "../../physics/ecs/Collider.js";
26
- import { ColliderObserverSystem } from "../../physics/ecs/ColliderObserverSystem.js";
27
- import { PhysicsSystem } from "../../physics/ecs/PhysicsSystem.js";
28
- import { RigidBody } from "../../physics/ecs/RigidBody.js";
29
- import { PhysicsSurfacePoint } from "../../physics/queries/PhysicsSurfacePoint.js";
30
- import { LedgeGrab } from "./abilities/LedgeGrab.js";
31
- import { Mantle } from "./abilities/Mantle.js";
32
- import { Slide } from "./abilities/Slide.js";
33
- import { WallJump } from "./abilities/WallJump.js";
34
- import { WallRun } from "./abilities/WallRun.js";
35
- import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
36
- import { FirstPersonPlayerControllerSystem } from "./FirstPersonPlayerControllerSystem.js";
37
- import { BreathRhythmEvaluator } from "./mastery/BreathRhythmEvaluator.js";
38
- import { DecisionPoint } from "./mastery/DecisionPoint.js";
39
- import { FootAsymmetryTurnEvaluator } from "./mastery/FootAsymmetryTurnEvaluator.js";
40
- import { SlideInitiationTimingEvaluator } from "./mastery/SlideInitiationTimingEvaluator.js";
41
- import { StrideTimingJumpEvaluator } from "./mastery/StrideTimingJumpEvaluator.js";
42
- import { FirstPersonSensorsSystem } from "./sensors/FirstPersonSensorsSystem.js";
43
-
44
- /**
45
- * Prototype harness for {@link FirstPersonPlayerController} — a parkour
46
- * "gym" laid out as discrete test stations around the spawn point.
47
- *
48
- * Controls:
49
- * - Click to capture mouse (pointer lock); ESC to release
50
- * - WASD — move
51
- * - Mouse look
52
- * - Spacejump (tap; holding does not auto-repeat)
53
- * - Shiftsprint (hold while moving forward)
54
- * - C crouch (hold; sprint+crouch press → slide)
55
- *
56
- * Stations (compass directions from spawn):
57
- *
58
- * N Mantle row four obstacles from too-low to too-tall
59
- * NE Slide tunnel sprint run-up + low ceiling
60
- * E Wall-run wall one long wall to run alongside
61
- * SE Gap jump raised platforms with a gap to clear
62
- * S Ledge-grab wall too tall to mantle; jump + catch the lip
63
- * SW (open)
64
- * W Wall-jump chimney two parallel walls for back-and-forth jumps
65
- * NW (open)
66
- *
67
- * Each station is colour-coded at the entrance. Look around: the
68
- * coloured pad on the ground tells you what's there.
69
- *
70
- * @author Alex Goldring
71
- * @copyright Company Named Limited (c) 2026
72
- */
73
-
74
- const SPAWN_X = 100;
75
- const SPAWN_Z = 100;
76
-
77
- // Station palette — used for entry pads + matching obstacle colour.
78
- const COLOR_MANTLE = 0x55ff55;
79
- const COLOR_WALLRUN = 0xff5555;
80
- const COLOR_WALLJUMP = 0xaa55ff;
81
- const COLOR_LEDGE = 0xffaa44;
82
- const COLOR_SLIDE = 0x44ddee;
83
- const COLOR_GAP = 0xff66cc;
84
- const COLOR_STAIRS = 0xeedd44;
85
- const COLOR_NEUTRAL = 0xaaaaaa;
86
-
87
- const eh = new EngineHarness();
88
-
89
- async function main(engine) {
90
- const em = engine.entityManager;
91
- const ecd = engine.entityManager.dataset;
92
-
93
- // -- Systems ---------------------------------------------------------
94
- EngineHarness.addFpsCounter(engine);
95
-
96
- await EngineHarness.buildLights({
97
- engine,
98
- sunIntensity: 1.0,
99
- ambientIntensity: 0.25,
100
- shadowmapResolution: 2048,
101
- sunShadowDistance: 30,
102
- });
103
-
104
- engine.plugins.acquire(AmbientOcclusionPostProcessEffect);
105
-
106
- if (em.getSystem(CameraSystem) === null) {
107
- await em.addSystem(new CameraSystem(engine.graphics));
108
- }
109
- if (em.getSystem(InputControllerSystem) === null) {
110
- await em.addSystem(new InputControllerSystem(engine.devices));
111
- }
112
- if (em.getSystem(ShadedGeometrySystem) === null) {
113
- await em.addSystem(new ShadedGeometrySystem(engine));
114
- }
115
-
116
- // Physics — owns RigidBody+Collider lifecycle, fires contact events,
117
- // and serves spatial queries (raycast). Sensors and the ground
118
- // resolver both go through it. Added BEFORE the controller so the
119
- // sensors and ground resolver find it in their startup auto-acquire.
120
- const physicsSystem = new PhysicsSystem();
121
- await em.addSystem(physicsSystem);
122
- // ColliderObserver wires Collider components into PhysicsSystem
123
- // bodies as entities are built. Without it, every collider would
124
- // need an explicit attach_collider call.
125
- await em.addSystem(new ColliderObserverSystem(physicsSystem));
126
-
127
- const fpsSystem = new FirstPersonPlayerControllerSystem();
128
- // Built-in flat-ground OFF — the gym uses the physics-backed ground
129
- // resolver below (which finds the highest static surface under the
130
- // player from BVH raycasts). Leaving the flat baseline on would
131
- // pin the player to y=0 even when standing on a 2 m platform.
132
- fpsSystem.useBuiltInFlatGround = false;
133
- // Collision is resolved by the KinematicMover (recover + unified 3D
134
- // sweep-and-slide + ground categorize/stick/slope/stairs) whenever a
135
- // PhysicsSystem is present which it is here. The mover probes the
136
- // physics world for ground itself, so no `groundResolver` is wired.
137
- // See DESIGN_COLLISION.md.
138
- await em.addSystem(fpsSystem);
139
-
140
- // Sensors system — populates wall/obstacle/ledge probes via
141
- // PhysicsSystem.raycast. Auto-acquires the physics system in startup.
142
- const sensorsSystem = new FirstPersonSensorsSystem();
143
- await em.addSystem(sensorsSystem);
144
-
145
- if (!ecd.isComponentTypeRegistered(Tag)) {
146
- ecd.registerComponentType(Tag);
147
- }
148
- if (!ecd.isComponentTypeRegistered(SerializationMetadata)) {
149
- ecd.registerComponentType(SerializationMetadata);
150
- }
151
-
152
- // -- World -----------------------------------------------------------
153
- await EngineHarness.buildTerrain({
154
- engine,
155
- size: new Vector2(200, 200),
156
- resolution: 8,
157
- enableWater: false,
158
- diffuse0: "data/textures/materials/terrain_township_set/512/Grass_3.png",
159
- });
160
-
161
- obtainTerrain(ecd); // no-op call to ensure terrain is built
162
-
163
- buildGym(ecd);
164
-
165
- // -- Player ----------------------------------------------------------
166
- const player = buildPlayerEntity(ecd);
167
-
168
- // Ground resolver: downward raycast through PhysicsSystem, filtered
169
- // to exclude the player's own capsule. Wired AFTER the player is
170
- // built so the filter has the right entity id. The controller's
171
- // vertical integrator falls through when the resolver returns null
172
- // (no platform below) set up with `useBuiltInFlatGround = false`
173
- // above so that means actual airborne / void, not a hidden y=0
174
- // safety floor.
175
- fpsSystem.groundResolver = makePhysicsGroundResolver(physicsSystem, player.entity);
176
-
177
- // -- Inputs ----------------------------------------------------------
178
- buildInputBindings(ecd, player);
179
-
180
- setupPointerLock(engine);
181
-
182
- logHelp();
183
- }
184
-
185
- /**
186
- * Downward-raycast ground resolver, backed by {@link PhysicsSystem.raycast}.
187
- * Each call: from just above the player's feet, cast straight down
188
- * through static + dynamic broadphase; filter out the player's own
189
- * body; return the hit y, or null if no surface below.
190
- *
191
- * The controller's vertical integrator picks max(useBuiltInFlatGround,
192
- * resolver). In the gym we leave useBuiltInFlatGround off so the
193
- * resolver is the sole source of truth — gaps in the floor are real
194
- * gaps (the player falls into them).
195
- *
196
- * @param {PhysicsSystem} physicsSystem
197
- * @param {number} playerEntity excluded from raycasts to skip the player capsule
198
- * @returns {(x:number, y:number, z:number) => number|null}
199
- */
200
- function makePhysicsGroundResolver(physicsSystem, playerEntity) {
201
- // Probe starts slightly above the player feet so a grounded player
202
- // standing right on a platform still raycasts cleanly. Length is
203
- // longer than any reasonable gym platform.
204
- const PROBE_LIFT = 0.1;
205
- const PROBE_DOWN = 50;
206
- const filter = (entity) => entity !== playerEntity;
207
- // Reuse a single ray and surface-point across calls — the resolver
208
- // can fire many times per frame as the controller hunts for foothold.
209
- const ray = Ray3.from(0, 0, 0, 0, -1, 0, PROBE_DOWN);
210
- const hit = new PhysicsSurfacePoint();
211
- return function physicsGroundResolver(x, y, z) {
212
- ray.setOrigin(x, y + PROBE_LIFT, z);
213
- if (!physicsSystem.raycast(ray, hit, filter)) return null;
214
- // Direction is unit (0,-1,0); t is the distance to the surface
215
- // along that direction. Surface Y = origin Y minus t.
216
- return (y + PROBE_LIFT) - hit.t;
217
- };
218
- }
219
-
220
- // =====================================================================
221
- // Gym construction
222
- // =====================================================================
223
-
224
- function buildGym(ecd) {
225
- buildGroundPlane(ecd);
226
- buildSpawnPlaza(ecd);
227
- buildMantleStation(ecd);
228
- buildWallRunStation(ecd);
229
- buildWallJumpChimney(ecd);
230
- buildLedgeGrabStation(ecd);
231
- buildSlideTunnel(ecd);
232
- buildGapJumpStation(ecd);
233
- buildStairsStation(ecd);
234
- buildMeshShapeShowcase(ecd);
235
- }
236
-
237
- /**
238
- * Drop a torus-knot prop off to the side as a smoke test for
239
- * {@link MeshShape3D}: arbitrary indexed triangle mesh → tetrahedralised
240
- * collider via {@link shape_mesh_from_geometry}.
241
- *
242
- * Dynamic body, so the player capsule (kinematic) can bump it around.
243
- * KinematicPosition bodies don't receive impulses themselves (the
244
- * controller writes the Transform), so a static knot vs. kinematic
245
- * capsule produces zero solver response and the capsule clips through.
246
- * Making the knot dynamic flips the relationship: when the capsule
247
- * penetrates, the solver applies impulse to the knot it bounces away
248
- * and the player gets a visible reaction.
249
- *
250
- * Placed off the spawn plaza's main travel path (radius 18 north-east of
251
- * spawn) so it's visible but not in the way of the parkour drills.
252
- */
253
- function buildMeshShapeShowcase(ecd) {
254
- // TorusKnotGeometry (radius, tube, tubularSegments, radialSegments).
255
- // 64 × 8 = 512 segments → ~512 verts / ~1024 tris — plenty of detail
256
- // for visual interest, light enough that the per-vertex `support()`
257
- // scan stays fast.
258
- const geom = new TorusKnotGeometry(0.5, 0.15, 64, 8);
259
- geom.computeVertexNormals();
260
-
261
- // Three packs positions as Float32Array (interleaved x, y, z) and
262
- // indices as Uint16Array by default. The MeshShape3D factory wants
263
- // a Uint32Array index buffer — convert once at construction.
264
- const positions = geom.attributes.position.array;
265
- const src_indices = geom.index.array;
266
- const indices = src_indices instanceof Uint32Array
267
- ? src_indices
268
- : Uint32Array.from(src_indices);
269
-
270
- const shape = shape_mesh_from_geometry(positions, indices);
271
-
272
- const material = new MeshStandardMaterial({ color: 0xffaa66, roughness: 0.5 });
273
- const sg = ShadedGeometry.from(geom, material);
274
- sg.setFlag(ShadedGeometryFlags.CastShadow);
275
- sg.setFlag(ShadedGeometryFlags.ReceiveShadow);
276
- sg.setFlag(ShadedGeometryFlags.Visible);
277
-
278
- const transform = new Transform();
279
- // North-east of spawn, lifted ~2m off the ground so the knot drops
280
- // onto the floor when the sim starts and the player can walk over,
281
- // bump it, and watch it tumble.
282
- transform.position.set(SPAWN_X + 18, 2, SPAWN_Z - 18);
283
-
284
- const rigidBody = new RigidBody();
285
- rigidBody.kind = BodyKind.Dynamic;
286
- rigidBody.mass = 1;
287
- // Bounding radius ≈ main_radius + tube_radius = 0.65. For a chunky
288
- // bumpable body matching the existing 1m cube reference, use the
289
- // same `(6, 6, 6)` inverse inertia — it's slightly under-rotational
290
- // for the torus knot's mass distribution but reads as solid.
291
- rigidBody.inverseInertiaLocal.set(6, 6, 6);
292
- rigidBody.linearDamping = 0.5;
293
- rigidBody.angularDamping = 0.5;
294
-
295
- const collider = new Collider();
296
- collider.shape = shape;
297
- collider.friction = 0.5;
298
- collider.restitution = 0.2;
299
-
300
- new Entity()
301
- .add(sg)
302
- .add(rigidBody)
303
- .add(collider)
304
- .add(transform)
305
- .add(Tag.fromJSON(["GymObstacle"]))
306
- .add(SerializationMetadata.Transient)
307
- .build(ecd);
308
- }
309
-
310
- /**
311
- * Reference markers around spawn so motion is visually obvious — a ring
312
- * of coloured cubes (circle-strafe targets) and a few tall pillars at
313
- * radius 22 for parallax-at-distance.
314
- */
315
- function buildSpawnPlaza(ecd) {
316
- const unitCubeGeom = new BoxGeometry(1, 1, 1);
317
- const palette = [
318
- 0xff5555, 0x55ff55, 0x5577ff, 0xffaa44,
319
- 0xaa55ff, 0x44ddee, 0xff66cc, 0x66ffcc,
320
- ];
321
-
322
- // Inner ring at radius 6 — close enough to circle-strafe around.
323
- const RING_N = 8;
324
- for (let i = 0; i < RING_N; i++) {
325
- const a = (i / RING_N) * Math.PI * 2;
326
- spawnBox(ecd, {
327
- center: new Vector3(SPAWN_X + Math.cos(a) * 6, 0.5, SPAWN_Z + Math.sin(a) * 6),
328
- size: new Vector3(1, 1, 1),
329
- geometry: unitCubeGeom,
330
- color: palette[i % palette.length],
331
- });
332
- }
333
-
334
- // Tall pillars further out — parallax depth perception when running.
335
- const pillarGeom = new BoxGeometry(1, 6, 1);
336
- const PILLAR_N = 6;
337
- for (let i = 0; i < PILLAR_N; i++) {
338
- const a = (i / PILLAR_N) * Math.PI * 2 + 0.3;
339
- const r = 22;
340
- spawnBox(ecd, {
341
- center: new Vector3(SPAWN_X + Math.cos(a) * r, 3, SPAWN_Z + Math.sin(a) * r),
342
- size: new Vector3(1, 6, 1),
343
- geometry: pillarGeom,
344
- color: 0x888888,
345
- roughness: 0.9,
346
- });
347
- }
348
- }
349
-
350
- /**
351
- * Mantle row — directly north of spawn. Four obstacles in line, heights
352
- * stepping past the mantle range:
353
- *
354
- * - 0.6m: well within mantle range; lowest "step-up" test
355
- * - 1.0m: mid mantle range
356
- * - 1.3m: near top of mantle range
357
- * - 2.0m: too tall to mantle from the ground (heightDiff > maxHeight)
358
- *
359
- * The 2.0m block is also the canonical "LedgeGrab if you jump at it"
360
- * candidate, but the dedicated LedgeGrab station to the south has a
361
- * cleaner approach path.
362
- */
363
- function buildMantleStation(ecd) {
364
- const baseZ = SPAWN_Z + 15;
365
- spawnPad(ecd, SPAWN_X, baseZ - 3, COLOR_MANTLE);
366
-
367
- const heights = [0.6, 1.0, 1.3, 2.0];
368
- for (let i = 0; i < heights.length; i++) {
369
- const h = heights[i];
370
- spawnBox(ecd, {
371
- center: new Vector3(SPAWN_X, h / 2, baseZ + i * 5),
372
- size: new Vector3(3, h, 1),
373
- color: COLOR_MANTLE,
374
- roughness: 0.6,
375
- });
376
- }
377
- }
378
-
379
- /**
380
- * Wall-run wall — a long wall on the east side. Player runs east past
381
- * spawn, jumps near the wall, runs along it.
382
- *
383
- * Two walls actually left and right so the player can wall-run on
384
- * either side without needing to circle back.
385
- */
386
- function buildWallRunStation(ecd) {
387
- const baseX = SPAWN_X + 15;
388
- spawnPad(ecd, baseX - 3, SPAWN_Z, COLOR_WALLRUN);
389
-
390
- // South wall (run along its north face).
391
- spawnBox(ecd, {
392
- center: new Vector3(baseX + 5, 2, SPAWN_Z - 2),
393
- size: new Vector3(10, 4, 0.5),
394
- color: COLOR_WALLRUN,
395
- roughness: 0.6,
396
- });
397
- // North wall (run along its south face) — separated by 4m so the
398
- // wall-runner picks one side, not both.
399
- spawnBox(ecd, {
400
- center: new Vector3(baseX + 5, 2, SPAWN_Z + 2),
401
- size: new Vector3(10, 4, 0.5),
402
- color: COLOR_WALLRUN,
403
- roughness: 0.6,
404
- });
405
- }
406
-
407
- /**
408
- * Wall-jump chimney two parallel walls on the west side, ~3m apart,
409
- * 6m tall. Player walks into the gap, jumps, hits one wall, wall-jumps
410
- * to the other side. Repeat to climb.
411
- *
412
- * The walls are wide enough that wall-jumping straight back to the
413
- * other side stays inside the chimney.
414
- */
415
- function buildWallJumpChimney(ecd) {
416
- const baseX = SPAWN_X - 15;
417
- spawnPad(ecd, baseX + 3, SPAWN_Z, COLOR_WALLJUMP);
418
-
419
- // North wall of the chimney (pushes south).
420
- spawnBox(ecd, {
421
- center: new Vector3(baseX - 2, 3, SPAWN_Z + 2),
422
- size: new Vector3(4, 6, 0.5),
423
- color: COLOR_WALLJUMP,
424
- roughness: 0.6,
425
- });
426
- // South wall of the chimney (pushes north). 3m gap to the north wall.
427
- spawnBox(ecd, {
428
- center: new Vector3(baseX - 2, 3, SPAWN_Z - 1.25),
429
- size: new Vector3(4, 6, 0.5),
430
- color: COLOR_WALLJUMP,
431
- roughness: 0.6,
432
- });
433
- }
434
-
435
- /**
436
- * Ledge-grab station — a single 2m-tall wall just south of spawn.
437
- * Too tall to mantle from the ground (heightDiff = 2 > maxHeight 1.4),
438
- * but the player can run-jump at it and catch the lip on descent:
439
- *
440
- * 1. sprint south
441
- * 2. jump just before the wall
442
- * 3. on the way down, chest probe hits the wall face;
443
- * ledgeAhead probe finds the top → LedgeGrab fires
444
- * 4. press jump to chain into Mantle (mantle-up release)
445
- *
446
- * The wall is 6m wide so the player doesn't have to aim perfectly.
447
- */
448
- function buildLedgeGrabStation(ecd) {
449
- const baseZ = SPAWN_Z - 15;
450
- spawnPad(ecd, SPAWN_X, baseZ + 3, COLOR_LEDGE);
451
-
452
- spawnBox(ecd, {
453
- center: new Vector3(SPAWN_X, 1.0, baseZ - 1),
454
- size: new Vector3(6, 2.0, 2),
455
- color: COLOR_LEDGE,
456
- roughness: 0.6,
457
- });
458
-
459
- // A larger top-of-wall platform behind the lip, so once the player
460
- // climbs up they have somewhere to stand and look around.
461
- spawnBox(ecd, {
462
- center: new Vector3(SPAWN_X, 1.0, baseZ - 4.5),
463
- size: new Vector3(6, 2.0, 5),
464
- color: COLOR_LEDGE,
465
- roughness: 0.6,
466
- });
467
- }
468
-
469
- /**
470
- * Slide tunnel — a sprint run-up followed by a low ceiling that forces
471
- * the player to slide under. NE of spawn.
472
- *
473
- * Layout: a long flat lane (just an entry pad / marker, ground is
474
- * already flat), with an overhead box at 1m height. Approaching at
475
- * sprint + crouch press → slide → fits under the gap.
476
- */
477
- function buildSlideTunnel(ecd) {
478
- const baseX = SPAWN_X + 11;
479
- const baseZ = SPAWN_Z + 11;
480
- spawnPad(ecd, baseX, baseZ, COLOR_SLIDE);
481
-
482
- // The "ceiling" — a slab suspended 1.0m above the ground. Sliding
483
- // brings the eye height down to ~0.4m; standing-eye-height would
484
- // collide.
485
- spawnBox(ecd, {
486
- center: new Vector3(baseX + 4, 1.5, baseZ + 4),
487
- size: new Vector3(4, 1.0, 4),
488
- color: COLOR_SLIDE,
489
- roughness: 0.6,
490
- });
491
-
492
- // Side pillars to make the tunnel feel like a tunnel.
493
- spawnBox(ecd, {
494
- center: new Vector3(baseX + 4, 1.0, baseZ + 2.0),
495
- size: new Vector3(4, 2.0, 0.5),
496
- color: COLOR_SLIDE,
497
- roughness: 0.7,
498
- });
499
- spawnBox(ecd, {
500
- center: new Vector3(baseX + 4, 1.0, baseZ + 6.0),
501
- size: new Vector3(4, 2.0, 0.5),
502
- color: COLOR_SLIDE,
503
- roughness: 0.7,
504
- });
505
- }
506
-
507
- /**
508
- * Gap jump — two elevated platforms with a 3m gap between them. Player
509
- * approaches via a mantle stair, runs across the first platform,
510
- * sprints + jumps the gap.
511
- *
512
- * The platforms sit 1.5m above the spawn ground (mantleable). The gap
513
- * doesn't actually break the floor — landing flat would still catch
514
- * the player on the y=0 floor below. That's fine for testing the
515
- * mid-flight feel of a jump; survival of misses is a feature here.
516
- */
517
- function buildGapJumpStation(ecd) {
518
- const baseX = SPAWN_X + 11;
519
- const baseZ = SPAWN_Z - 11;
520
- spawnPad(ecd, baseX, baseZ, COLOR_GAP);
521
-
522
- // Approach mantle (step up to the first platform).
523
- spawnBox(ecd, {
524
- center: new Vector3(baseX + 2, 0.5, baseZ - 2),
525
- size: new Vector3(2, 1.0, 2),
526
- color: COLOR_GAP,
527
- roughness: 0.7,
528
- });
529
-
530
- // First platform — 4m long along Z.
531
- spawnBox(ecd, {
532
- center: new Vector3(baseX + 5, 0.75, baseZ - 4),
533
- size: new Vector3(4, 1.5, 4),
534
- color: COLOR_GAP,
535
- roughness: 0.6,
536
- });
537
-
538
- // Second platform — same height, 3m gap.
539
- spawnBox(ecd, {
540
- center: new Vector3(baseX + 5, 0.75, baseZ - 11),
541
- size: new Vector3(4, 1.5, 4),
542
- color: COLOR_GAP,
543
- roughness: 0.6,
544
- });
545
- }
546
-
547
- /**
548
- * Stairs station — a walkable staircase up to a landing, SW of spawn.
549
- * Exercises the KinematicMover's stair handling (DESIGN_COLLISION.md
550
- * Phase 3): the player walks up the steps staying grounded (no launch
551
- * off each lip) and walks back down staying grounded (stick-to-ground
552
- * snaps onto each lower step rather than going airborne).
553
- *
554
- * Each step rises `RISE` (0.2 m) — comfortably under the controller's
555
- * `stepHeight` (0.3 m), so the mover climbs it. A blocking reference
556
- * sits beside the stairs: a single 0.5 m riser (> stepHeight) the
557
- * player canNOT walk up, for contrast.
558
- *
559
- * Steps are solid pillars from the ground to each tread height, placed
560
- * edge-to-edge so they form a clean staircase profile (no floating
561
- * treads, no overlap). The player approaches from the +X (spawn) side
562
- * and walks −X up the flight.
563
- */
564
- function buildStairsStation(ecd) {
565
- const baseX = SPAWN_X - 11;
566
- const baseZ = SPAWN_Z - 11;
567
- spawnPad(ecd, baseX, baseZ, COLOR_STAIRS);
568
-
569
- const RISE = 0.2; // per-step rise — under stepHeight (0.3) → climbable
570
- const DEPTH = 0.45; // tread depth
571
- const WIDTH = 4; // generous width so aiming isn't required
572
- const STEPS = 7; // 1.4 m top
573
- const firstFrontX = baseX - 2; // near (east) edge of the first step
574
-
575
- for (let i = 1; i <= STEPS; i++) {
576
- const topY = i * RISE;
577
- spawnBox(ecd, {
578
- center: new Vector3(firstFrontX - (i - 0.5) * DEPTH, topY / 2, baseZ),
579
- size: new Vector3(DEPTH, topY, WIDTH),
580
- color: COLOR_STAIRS,
581
- roughness: 0.6,
582
- });
583
- }
584
-
585
- // Landing platform at the top, flush with the last tread.
586
- const topY = STEPS * RISE;
587
- spawnBox(ecd, {
588
- center: new Vector3(firstFrontX - STEPS * DEPTH - 1.5, topY / 2, baseZ),
589
- size: new Vector3(3, topY, WIDTH),
590
- color: COLOR_STAIRS,
591
- roughness: 0.6,
592
- });
593
-
594
- // Blocking reference — a single 0.5 m riser (> stepHeight) beside the
595
- // flight (offset +Z). Walk into it: the mover blocks rather than
596
- // climbing, the contrast to the stairs.
597
- spawnBox(ecd, {
598
- center: new Vector3(firstFrontX - 1, 0.25, baseZ + WIDTH / 2 + 2),
599
- size: new Vector3(3, 0.5, 3),
600
- color: COLOR_NEUTRAL,
601
- roughness: 0.8,
602
- });
603
- }
604
-
605
- // =====================================================================
606
- // Spawn helpers
607
- // =====================================================================
608
-
609
- /**
610
- * Add a box-shaped obstacle to the world. Position is the box centre.
611
- *
612
- * Single entity carrying both visual (ShadedGeometry) and physical
613
- * (RigidBody + Collider) representations so the player can stand on it,
614
- * sensors can raycast against it, and dynamic crates (when added) bump
615
- * off it. Bodies are {@link BodyKind.Static} — gym geometry never moves.
616
- *
617
- * @param {EntityComponentDataset} ecd
618
- * @param {object} p
619
- * @param {Vector3} p.center
620
- * @param {Vector3} p.size w, h, d (box dimensions)
621
- * @param {number} p.color
622
- * @param {number} [p.roughness]
623
- * @param {THREE.BufferGeometry} [p.geometry] reuse to avoid new geometry per spawn
624
- * @param {string} [p.tag]
625
- * @param {boolean} [p.collide] default true; set false for decorative pads
626
- */
627
- function spawnBox(ecd, { center, size, color, roughness = 0.7, geometry, tag = "GymObstacle", collide = true }) {
628
- const geom = geometry !== undefined ? geometry : new BoxGeometry(size.x, size.y, size.z);
629
- const material = new MeshStandardMaterial({ color, roughness });
630
- const sg = ShadedGeometry.from(geom, material);
631
- sg.setFlag(ShadedGeometryFlags.CastShadow);
632
- sg.setFlag(ShadedGeometryFlags.ReceiveShadow);
633
- sg.setFlag(ShadedGeometryFlags.Visible);
634
-
635
- const transform = new Transform();
636
- transform.position.copy(center);
637
-
638
- const eb = new Entity()
639
- .add(sg)
640
- .add(Tag.fromJSON([tag]))
641
- .add(SerializationMetadata.Transient);
642
-
643
- if (collide) {
644
- const rigidBody = new RigidBody();
645
- rigidBody.kind = BodyKind.Static;
646
- const collider = new Collider();
647
- // BoxShape3D with explicit half-extents — routes through the
648
- // closed-form sphere-box / box-box / capsule-box narrowphase paths
649
- // instead of degenerating into GJK+EPA, which is unreliable on
650
- // smooth-surface pairs like capsule-vs-box.
651
- collider.shape = BoxShape3D.from_size(size.x, size.y, size.z);
652
- // Transform is added LAST so the (RigidBody, Transform) tuple
653
- // completes only after the body is present — PhysicsSystem.link
654
- // is the trigger, not the rigidBody add.
655
- eb.add(rigidBody).add(collider).add(transform).build(ecd);
656
- } else {
657
- eb.add(transform).build(ecd);
658
- }
659
- }
660
-
661
- /**
662
- * Lay a thin coloured "entry pad" on the ground at (x, z). Visual
663
- * landmark only flush with the floor, no collision (a 5 cm-thick
664
- * static body in the player's walking path would bump the camera).
665
- */
666
- function spawnPad(ecd, x, z, color) {
667
- spawnBox(ecd, {
668
- center: new Vector3(x, 0.025, z),
669
- size: new Vector3(4, 0.05, 4),
670
- color,
671
- roughness: 0.95,
672
- tag: "GymEntryPad",
673
- collide: false,
674
- });
675
- }
676
-
677
- /**
678
- * Lay a physics-only ground body underneath the entire gym so the
679
- * player has something to stand on. The terrain mesh provides the
680
- * grass visual at y=0; this is its physics counterpart (the player
681
- * controller's vertical integrator and sensors only look at physics
682
- * bodies, not the terrain heightfield).
683
- *
684
- * Sized to comfortably encompass all stations (the gym spans ~30 m
685
- * radius around spawn). The slab top sits at y=0 — same as the
686
- * terrain visual so standing on it puts the player's feet on the
687
- * grass.
688
- */
689
- function buildGroundPlane(ecd) {
690
- const SIZE = 120;
691
- const THICKNESS = 1.0;
692
-
693
- const rigidBody = new RigidBody();
694
- rigidBody.kind = BodyKind.Static;
695
- const collider = new Collider();
696
- collider.shape = BoxShape3D.from_size(SIZE, THICKNESS, SIZE);
697
-
698
- const transform = new Transform();
699
- // Top face at y=0 → centre at y = -thickness/2.
700
- transform.position.set(SPAWN_X, -THICKNESS / 2, SPAWN_Z);
701
-
702
- new Entity()
703
- .add(rigidBody)
704
- .add(collider)
705
- .add(Tag.fromJSON(["GymGroundPlane"]))
706
- .add(SerializationMetadata.Transient)
707
- .add(transform)
708
- .build(ecd);
709
- }
710
-
711
- // =====================================================================
712
- // Player + input
713
- // =====================================================================
714
-
715
- /**
716
- * @param {EntityComponentDataset} ecd
717
- * @returns {{entity:number, controller:FirstPersonPlayerController}}
718
- */
719
- function buildPlayerEntity(ecd) {
720
- const transform = new Transform();
721
- // Spawn standing on the ground at the centre of the plaza.
722
- transform.position.set(SPAWN_X, 0, SPAWN_Z);
723
-
724
- const controller = new FirstPersonPlayerController();
725
-
726
- // Movement abilities (priority order see DESIGN_EXTENSIONS §2.2):
727
- // Slide(10) < Mantle(30) < LedgeGrab(40) < WallRun(50) < WallJump(60).
728
- controller.abilities.add(new Slide());
729
- controller.abilities.add(new Mantle());
730
- controller.abilities.add(new LedgeGrab());
731
- controller.abilities.add(new WallRun());
732
- controller.abilities.add(new WallJump());
733
-
734
- // Mastery evaluators small bonuses/penalties tied to gait phase,
735
- // foot asymmetry, and breath rhythm. Each fires on one (or a few)
736
- // DecisionPoint(s); contributions compose multiplicatively.
737
- controller.mastery.add(new StrideTimingJumpEvaluator());
738
- controller.mastery.add(new SlideInitiationTimingEvaluator());
739
- controller.mastery.add(new FootAsymmetryTurnEvaluator());
740
- controller.mastery.add(new BreathRhythmEvaluator());
741
- controller.mastery.add(new BreathRhythmEvaluator({
742
- decisionPoints: [DecisionPoint.GroundAccel],
743
- peakBonus: 0.015,
744
- troughPenalty: 0.005,
745
- requireExertionAbove: 0.3,
746
- }));
747
-
748
- // Kinematic capsule body: the controller writes Transform directly,
749
- // physics derives velocity from the per-step delta. Capsule centred
750
- // on the Transform bottom hemisphere extends below the feet (this
751
- // is fine for spatial queries since sensor probes start at chest
752
- // height and the ground resolver filters out the player's own body).
753
- const bodyCfg = controller.config.body;
754
- const radius = bodyCfg.radius;
755
- const totalHeight = bodyCfg.height; // standing eye height body extent
756
- const cylinderHeight = Math.max(0, totalHeight - 2 * radius);
757
- const rigidBody = new RigidBody();
758
- rigidBody.kind = BodyKind.KinematicPosition;
759
- rigidBody.mass = bodyCfg.mass;
760
- const collider = new Collider();
761
- // Capsule lives in [feet .. head] in world space. CapsuleShape3D is
762
- // centred at its local origin, so wrap it in a TransformedShape3D
763
- // with a +Y translation of totalHeight/2 — that puts the capsule
764
- // bottom at the player's feet (Transform.position) and the top at
765
- // the head. Without this, shape_cast queries against the ground
766
- // would stop the capsule centre at floor + half-extent, leaving
767
- // the player visibly floating half a body height above the floor.
768
- collider.shape = TransformedShape3D.from_translation(
769
- CapsuleShape3D.from(radius, cylinderHeight),
770
- [0, totalHeight / 2, 0],
771
- );
772
-
773
- // Build order matters: Transform is added LAST so the FirstPerson
774
- // controller system's link() sees the RigidBody already attached.
775
- // (Same pattern as the spec helpers.)
776
- const eb = new Entity()
777
- .add(controller)
778
- .add(rigidBody)
779
- .add(collider)
780
- .add(Tag.fromJSON(["Player"]))
781
- .add(SerializationMetadata.Transient)
782
- .add(transform);
783
-
784
- eb.build(ecd);
785
-
786
- return { entity: eb.id, controller };
787
- }
788
-
789
- /**
790
- * Wire keyboard + mouse to the controller's intent surface.
791
- *
792
- * @param {EntityComponentDataset} ecd
793
- * @param {{entity:number, controller:FirstPersonPlayerController}} player
794
- */
795
- function buildInputBindings(ecd, player) {
796
- const ic = new InputController();
797
- const intent = player.controller.intent;
798
-
799
- // Mouse sensitivity — radians of view rotation per pixel of mouse delta.
800
- const SENSITIVITY = (Math.PI * 2) / 1200;
801
-
802
- // -- WASD movement --------------------------------------------------
803
- const held = { w: false, a: false, s: false, d: false };
804
-
805
- function recomputeMove() {
806
- let mx = 0, my = 0;
807
- if (held.d) mx += 1;
808
- if (held.a) mx -= 1;
809
- if (held.w) my += 1;
810
- if (held.s) my -= 1;
811
- intent.move.set(mx, my);
812
- }
813
-
814
- function bindMoveKey(key, prop) {
815
- ic.bind(`keyboard/keys/${key}/down`, () => {
816
- held[prop] = true;
817
- recomputeMove();
818
- });
819
- ic.bind(`keyboard/keys/${key}/up`, () => {
820
- held[prop] = false;
821
- recomputeMove();
822
- });
823
- }
824
-
825
- bindMoveKey("w", "w");
826
- bindMoveKey("a", "a");
827
- bindMoveKey("s", "s");
828
- bindMoveKey("d", "d");
829
-
830
- // -- Jump ------------------------------------------------------------
831
- ic.bind("keyboard/keys/space/down", () => { intent.jump = true; });
832
- ic.bind("keyboard/keys/space/up", () => { intent.jump = false; });
833
-
834
- // -- Sprint ----------------------------------------------------------
835
- ic.bind("keyboard/keys/shift/down", () => { intent.sprint = true; });
836
- ic.bind("keyboard/keys/shift/up", () => { intent.sprint = false; });
837
-
838
- // -- Crouch ----------------------------------------------------------
839
- ic.bind("keyboard/keys/c/down", () => { intent.crouch = true; });
840
- ic.bind("keyboard/keys/c/up", () => { intent.crouch = false; });
841
-
842
- // -- Mouse look ------------------------------------------------------
843
- ic.bind("pointer/on/move", (position, event, delta) => {
844
- if (document.pointerLockElement === null) return;
845
- intent.look._add(delta.x * SENSITIVITY, delta.y * SENSITIVITY);
846
- });
847
-
848
- ic.on.unlinked.add(() => {
849
- intent.move.set(0, 0);
850
- intent.look.set(0, 0);
851
- intent.jump = false;
852
- intent.crouch = false;
853
- intent.sprint = false;
854
- });
855
-
856
- new Entity()
857
- .add(ic)
858
- .add(Tag.fromJSON(["FirstPersonInputBindings"]))
859
- .add(SerializationMetadata.Transient)
860
- .build(ecd);
861
- }
862
-
863
- /**
864
- * Click to capture; ESC (or other browser-default) to release.
865
- * @param {Engine} engine
866
- */
867
- function setupPointerLock(engine) {
868
- const el = engine.graphics.domElement;
869
- const captureEl = engine.viewStack.el;
870
-
871
- captureEl.addEventListener("click", () => {
872
- if (document.pointerLockElement !== el && el.requestPointerLock) {
873
- el.requestPointerLock();
874
- }
875
- });
876
-
877
- captureEl.addEventListener("contextmenu", (e) => e.preventDefault());
878
- }
879
-
880
- function logHelp() {
881
- /* eslint-disable no-console */
882
- console.log("%cFirstPersonPlayerController — Parkour Gym",
883
- "color:#88ddff;font-weight:bold;font-size:14px");
884
- console.log("Click the canvas to capture mouse. ESC to release.");
885
- console.log("WASD = move | Mouse = look | Space = jump | Shift = sprint | C = crouch");
886
- console.log("");
887
- console.log("%cStations:", "font-weight:bold");
888
- console.log(" N Mantle row four obstacles, last one is too tall");
889
- console.log(" NE Slide tunnel — sprint + C to slide under the low ceiling");
890
- console.log(" E Wall-run — sprint along, jump near the wall");
891
- console.log(" SE Gap jump — mantle up the stair, sprint, jump the gap");
892
- console.log(" S Ledge-grab too tall to mantle; jump at it, catch the lip");
893
- console.log(" W Wall-jump chimney — alternate walls to climb");
894
- }
895
-
896
- async function init() {
897
- await eh.initialize();
898
- await main(eh.engine);
899
- }
900
-
901
- init();
1
+ import { BoxGeometry, MeshStandardMaterial, TorusKnotGeometry } from "three";
2
+ import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
3
+ import { BoxShape3D } from "../../../core/geom/3d/shape/BoxShape3D.js";
4
+ import { CapsuleShape3D } from "../../../core/geom/3d/shape/CapsuleShape3D.js";
5
+ import { TransformedShape3D } from "../../../core/geom/3d/shape/TransformedShape3D.js";
6
+ import { shape_mesh_from_geometry } from "../../../core/geom/3d/shape/shape_mesh_from_geometry.js";
7
+ import Quaternion from "../../../core/geom/Quaternion.js";
8
+ import Vector2 from "../../../core/geom/Vector2.js";
9
+ import Vector3 from "../../../core/geom/Vector3.js";
10
+ import { SerializationMetadata } from "../../ecs/components/SerializationMetadata.js";
11
+ import { Tag } from "../../ecs/components/Tag.js";
12
+ import Entity from "../../ecs/Entity.js";
13
+ import { obtainTerrain } from "../../ecs/terrain/util/obtainTerrain.js";
14
+ import { Transform } from "../../ecs/transform/Transform.js";
15
+ import { EngineHarness } from "../../EngineHarness.js";
16
+ import { CameraSystem } from "../../graphics/ecs/camera/CameraSystem.js";
17
+ import { ShadedGeometry } from "../../graphics/ecs/mesh-v2/ShadedGeometry.js";
18
+ import { ShadedGeometryFlags } from "../../graphics/ecs/mesh-v2/ShadedGeometryFlags.js";
19
+ import { ShadedGeometrySystem } from "../../graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
20
+ import {
21
+ AmbientOcclusionPostProcessEffect
22
+ } from "../../graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
23
+ import InputController from "../../input/ecs/components/InputController.js";
24
+ import InputControllerSystem from "../../input/ecs/systems/InputControllerSystem.js";
25
+ import { BodyKind } from "../../physics/ecs/BodyKind.js";
26
+ import { Collider } from "../../physics/ecs/Collider.js";
27
+ import { ColliderObserverSystem } from "../../physics/ecs/ColliderObserverSystem.js";
28
+ import { PhysicsSystem } from "../../physics/ecs/PhysicsSystem.js";
29
+ import { RigidBody } from "../../physics/ecs/RigidBody.js";
30
+ import { PhysicsSurfacePoint } from "../../physics/queries/PhysicsSurfacePoint.js";
31
+ import { LedgeGrab } from "./abilities/LedgeGrab.js";
32
+ import { Mantle } from "./abilities/Mantle.js";
33
+ import { Slide } from "./abilities/Slide.js";
34
+ import { WallJump } from "./abilities/WallJump.js";
35
+ import { WallRun } from "./abilities/WallRun.js";
36
+ import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
37
+ import { FirstPersonPlayerControllerSystem } from "./FirstPersonPlayerControllerSystem.js";
38
+ import { BreathRhythmEvaluator } from "./mastery/BreathRhythmEvaluator.js";
39
+ import { DecisionPoint } from "./mastery/DecisionPoint.js";
40
+ import { FootAsymmetryTurnEvaluator } from "./mastery/FootAsymmetryTurnEvaluator.js";
41
+ import { SlideInitiationTimingEvaluator } from "./mastery/SlideInitiationTimingEvaluator.js";
42
+ import { StrideTimingJumpEvaluator } from "./mastery/StrideTimingJumpEvaluator.js";
43
+ import { FirstPersonSensorsSystem } from "./sensors/FirstPersonSensorsSystem.js";
44
+
45
+ /**
46
+ * Prototype harness for {@link FirstPersonPlayerController} a parkour
47
+ * "gym" laid out as discrete test stations around the spawn point.
48
+ *
49
+ * Controls:
50
+ * - Click to capture mouse (pointer lock); ESC to release
51
+ * - WASD move
52
+ * - Mouselook
53
+ * - Spacejump (tap; holding does not auto-repeat)
54
+ * - Shift sprint (hold while moving forward)
55
+ * - C — crouch (hold; sprint+crouch press → slide)
56
+ *
57
+ * Stations (compass directions from spawn):
58
+ *
59
+ * N Mantle row four obstacles from too-low to too-tall
60
+ * NE Slide tunnel sprint run-up + low ceiling
61
+ * E Wall-run wall one long wall to run alongside
62
+ * SE Gap jump raised platforms with a gap to clear
63
+ * S Ledge-grab wall too tall to mantle; jump + catch the lip
64
+ * SW Stairs a climbable flight + a too-tall blocking riser
65
+ * W Wall-jump chimney two parallel walls for back-and-forth jumps
66
+ * NW Ramp row inclines from gentle (walkable) to too-steep (slide)
67
+ *
68
+ * Each station is colour-coded at the entrance. Look around: the
69
+ * coloured pad on the ground tells you what's there.
70
+ *
71
+ * @author Alex Goldring
72
+ * @copyright Company Named Limited (c) 2026
73
+ */
74
+
75
+ const SPAWN_X = 100;
76
+ const SPAWN_Z = 100;
77
+
78
+ // Station palette — used for entry pads + matching obstacle colour.
79
+ const COLOR_MANTLE = 0x55ff55;
80
+ const COLOR_WALLRUN = 0xff5555;
81
+ const COLOR_WALLJUMP = 0xaa55ff;
82
+ const COLOR_LEDGE = 0xffaa44;
83
+ const COLOR_SLIDE = 0x44ddee;
84
+ const COLOR_GAP = 0xff66cc;
85
+ const COLOR_STAIRS = 0xeedd44;
86
+ const COLOR_RAMP = 0x3377dd;
87
+ const COLOR_NEUTRAL = 0xaaaaaa;
88
+
89
+ const eh = new EngineHarness();
90
+
91
+ async function main(engine) {
92
+ const em = engine.entityManager;
93
+ const ecd = engine.entityManager.dataset;
94
+
95
+ // -- Systems ---------------------------------------------------------
96
+ EngineHarness.addFpsCounter(engine);
97
+
98
+ await EngineHarness.buildLights({
99
+ engine,
100
+ sunIntensity: 1.0,
101
+ ambientIntensity: 0.25,
102
+ shadowmapResolution: 2048,
103
+ sunShadowDistance: 30,
104
+ });
105
+
106
+ engine.plugins.acquire(AmbientOcclusionPostProcessEffect);
107
+
108
+ if (em.getSystem(CameraSystem) === null) {
109
+ await em.addSystem(new CameraSystem(engine.graphics));
110
+ }
111
+ if (em.getSystem(InputControllerSystem) === null) {
112
+ await em.addSystem(new InputControllerSystem(engine.devices));
113
+ }
114
+ if (em.getSystem(ShadedGeometrySystem) === null) {
115
+ await em.addSystem(new ShadedGeometrySystem(engine));
116
+ }
117
+
118
+ // Physics owns RigidBody+Collider lifecycle, fires contact events,
119
+ // and serves spatial queries (raycast). Sensors and the ground
120
+ // resolver both go through it. Added BEFORE the controller so the
121
+ // sensors and ground resolver find it in their startup auto-acquire.
122
+ const physicsSystem = new PhysicsSystem();
123
+ await em.addSystem(physicsSystem);
124
+ // ColliderObserver wires Collider components into PhysicsSystem
125
+ // bodies as entities are built. Without it, every collider would
126
+ // need an explicit attach_collider call.
127
+ await em.addSystem(new ColliderObserverSystem(physicsSystem));
128
+
129
+ const fpsSystem = new FirstPersonPlayerControllerSystem();
130
+ // Built-in flat-ground OFF the gym uses the physics-backed ground
131
+ // resolver below (which finds the highest static surface under the
132
+ // player from BVH raycasts). Leaving the flat baseline on would
133
+ // pin the player to y=0 even when standing on a 2 m platform.
134
+ fpsSystem.useBuiltInFlatGround = false;
135
+ // Collision is resolved by the KinematicMover (recover + unified 3D
136
+ // sweep-and-slide + ground categorize/stick/slope/stairs) whenever a
137
+ // PhysicsSystem is present — which it is here. The mover probes the
138
+ // physics world for ground itself, so no `groundResolver` is wired.
139
+ // See DESIGN_COLLISION.md.
140
+ await em.addSystem(fpsSystem);
141
+
142
+ // Sensors system populates wall/obstacle/ledge probes via
143
+ // PhysicsSystem.raycast. Auto-acquires the physics system in startup.
144
+ const sensorsSystem = new FirstPersonSensorsSystem();
145
+ await em.addSystem(sensorsSystem);
146
+
147
+ if (!ecd.isComponentTypeRegistered(Tag)) {
148
+ ecd.registerComponentType(Tag);
149
+ }
150
+ if (!ecd.isComponentTypeRegistered(SerializationMetadata)) {
151
+ ecd.registerComponentType(SerializationMetadata);
152
+ }
153
+
154
+ // -- World -----------------------------------------------------------
155
+ await EngineHarness.buildTerrain({
156
+ engine,
157
+ size: new Vector2(200, 200),
158
+ resolution: 8,
159
+ enableWater: false,
160
+ diffuse0: "data/textures/materials/terrain_township_set/512/Grass_3.png",
161
+ });
162
+
163
+ obtainTerrain(ecd); // no-op call to ensure terrain is built
164
+
165
+ buildGym(ecd);
166
+
167
+ // -- Player ----------------------------------------------------------
168
+ const player = buildPlayerEntity(ecd);
169
+
170
+ // Ground resolver: downward raycast through PhysicsSystem, filtered
171
+ // to exclude the player's own capsule. Wired AFTER the player is
172
+ // built so the filter has the right entity id. The controller's
173
+ // vertical integrator falls through when the resolver returns null
174
+ // (no platform below) — set up with `useBuiltInFlatGround = false`
175
+ // above so that means actual airborne / void, not a hidden y=0
176
+ // safety floor.
177
+ fpsSystem.groundResolver = makePhysicsGroundResolver(physicsSystem, player.entity);
178
+
179
+ // -- Inputs ----------------------------------------------------------
180
+ buildInputBindings(ecd, player);
181
+
182
+ setupPointerLock(engine);
183
+
184
+ logHelp();
185
+ }
186
+
187
+ /**
188
+ * Downward-raycast ground resolver, backed by {@link PhysicsSystem.raycast}.
189
+ * Each call: from just above the player's feet, cast straight down
190
+ * through static + dynamic broadphase; filter out the player's own
191
+ * body; return the hit y, or null if no surface below.
192
+ *
193
+ * The controller's vertical integrator picks max(useBuiltInFlatGround,
194
+ * resolver). In the gym we leave useBuiltInFlatGround off so the
195
+ * resolver is the sole source of truth — gaps in the floor are real
196
+ * gaps (the player falls into them).
197
+ *
198
+ * @param {PhysicsSystem} physicsSystem
199
+ * @param {number} playerEntity excluded from raycasts to skip the player capsule
200
+ * @returns {(x:number, y:number, z:number) => number|null}
201
+ */
202
+ function makePhysicsGroundResolver(physicsSystem, playerEntity) {
203
+ // Probe starts slightly above the player feet so a grounded player
204
+ // standing right on a platform still raycasts cleanly. Length is
205
+ // longer than any reasonable gym platform.
206
+ const PROBE_LIFT = 0.1;
207
+ const PROBE_DOWN = 50;
208
+ const filter = (entity) => entity !== playerEntity;
209
+ // Reuse a single ray and surface-point across calls the resolver
210
+ // can fire many times per frame as the controller hunts for foothold.
211
+ const ray = Ray3.from(0, 0, 0, 0, -1, 0, PROBE_DOWN);
212
+ const hit = new PhysicsSurfacePoint();
213
+ return function physicsGroundResolver(x, y, z) {
214
+ ray.setOrigin(x, y + PROBE_LIFT, z);
215
+ if (!physicsSystem.raycast(ray, hit, filter)) return null;
216
+ // Direction is unit (0,-1,0); t is the distance to the surface
217
+ // along that direction. Surface Y = origin Y minus t.
218
+ return (y + PROBE_LIFT) - hit.t;
219
+ };
220
+ }
221
+
222
+ // =====================================================================
223
+ // Gym construction
224
+ // =====================================================================
225
+
226
+ function buildGym(ecd) {
227
+ buildGroundPlane(ecd);
228
+ buildSpawnPlaza(ecd);
229
+ buildMantleStation(ecd);
230
+ buildWallRunStation(ecd);
231
+ buildWallJumpChimney(ecd);
232
+ buildLedgeGrabStation(ecd);
233
+ buildSlideTunnel(ecd);
234
+ buildGapJumpStation(ecd);
235
+ buildStairsStation(ecd);
236
+ buildRampStation(ecd);
237
+ buildMeshShapeShowcase(ecd);
238
+ }
239
+
240
+ /**
241
+ * Drop a torus-knot prop off to the side as a smoke test for
242
+ * {@link MeshShape3D}: arbitrary indexed triangle mesh tetrahedralised
243
+ * collider via {@link shape_mesh_from_geometry}.
244
+ *
245
+ * Dynamic body, so the player capsule (kinematic) can bump it around.
246
+ * KinematicPosition bodies don't receive impulses themselves (the
247
+ * controller writes the Transform), so a static knot vs. kinematic
248
+ * capsule produces zero solver response and the capsule clips through.
249
+ * Making the knot dynamic flips the relationship: when the capsule
250
+ * penetrates, the solver applies impulse to the knot it bounces away
251
+ * and the player gets a visible reaction.
252
+ *
253
+ * Placed off the spawn plaza's main travel path (radius 18 north-east of
254
+ * spawn) so it's visible but not in the way of the parkour drills.
255
+ */
256
+ function buildMeshShapeShowcase(ecd) {
257
+ // TorusKnotGeometry (radius, tube, tubularSegments, radialSegments).
258
+ // 64 × 8 = 512 segments ~512 verts / ~1024 tris — plenty of detail
259
+ // for visual interest, light enough that the per-vertex `support()`
260
+ // scan stays fast.
261
+ const geom = new TorusKnotGeometry(0.5, 0.15, 64, 8);
262
+ geom.computeVertexNormals();
263
+
264
+ // Three packs positions as Float32Array (interleaved x, y, z) and
265
+ // indices as Uint16Array by default. The MeshShape3D factory wants
266
+ // a Uint32Array index buffer — convert once at construction.
267
+ const positions = geom.attributes.position.array;
268
+ const src_indices = geom.index.array;
269
+ const indices = src_indices instanceof Uint32Array
270
+ ? src_indices
271
+ : Uint32Array.from(src_indices);
272
+
273
+ const shape = shape_mesh_from_geometry(positions, indices);
274
+
275
+ const material = new MeshStandardMaterial({ color: 0xffaa66, roughness: 0.5 });
276
+ const sg = ShadedGeometry.from(geom, material);
277
+ sg.setFlag(ShadedGeometryFlags.CastShadow);
278
+ sg.setFlag(ShadedGeometryFlags.ReceiveShadow);
279
+ sg.setFlag(ShadedGeometryFlags.Visible);
280
+
281
+ const transform = new Transform();
282
+ // North-east of spawn, lifted ~2m off the ground so the knot drops
283
+ // onto the floor when the sim starts and the player can walk over,
284
+ // bump it, and watch it tumble.
285
+ transform.position.set(SPAWN_X + 18, 2, SPAWN_Z - 18);
286
+
287
+ const rigidBody = new RigidBody();
288
+ rigidBody.kind = BodyKind.Dynamic;
289
+ rigidBody.mass = 1;
290
+ // Bounding radius main_radius + tube_radius = 0.65. For a chunky
291
+ // bumpable body matching the existing 1m cube reference, use the
292
+ // same `(6, 6, 6)` inverse inertia — it's slightly under-rotational
293
+ // for the torus knot's mass distribution but reads as solid.
294
+ rigidBody.inverseInertiaLocal.set(6, 6, 6);
295
+ rigidBody.linearDamping = 0.5;
296
+ rigidBody.angularDamping = 0.5;
297
+
298
+ const collider = new Collider();
299
+ collider.shape = shape;
300
+ collider.friction = 0.5;
301
+ collider.restitution = 0.2;
302
+
303
+ new Entity()
304
+ .add(sg)
305
+ .add(rigidBody)
306
+ .add(collider)
307
+ .add(transform)
308
+ .add(Tag.fromJSON(["GymObstacle"]))
309
+ .add(SerializationMetadata.Transient)
310
+ .build(ecd);
311
+ }
312
+
313
+ /**
314
+ * Reference markers around spawn so motion is visually obvious — a ring
315
+ * of coloured cubes (circle-strafe targets) and a few tall pillars at
316
+ * radius 22 for parallax-at-distance.
317
+ */
318
+ function buildSpawnPlaza(ecd) {
319
+ const unitCubeGeom = new BoxGeometry(1, 1, 1);
320
+ const palette = [
321
+ 0xff5555, 0x55ff55, 0x5577ff, 0xffaa44,
322
+ 0xaa55ff, 0x44ddee, 0xff66cc, 0x66ffcc,
323
+ ];
324
+
325
+ // Inner ring at radius 6 close enough to circle-strafe around.
326
+ const RING_N = 8;
327
+ for (let i = 0; i < RING_N; i++) {
328
+ const a = (i / RING_N) * Math.PI * 2;
329
+ spawnBox(ecd, {
330
+ center: new Vector3(SPAWN_X + Math.cos(a) * 6, 0.5, SPAWN_Z + Math.sin(a) * 6),
331
+ size: new Vector3(1, 1, 1),
332
+ geometry: unitCubeGeom,
333
+ color: palette[i % palette.length],
334
+ });
335
+ }
336
+
337
+ // Tall pillars further out parallax depth perception when running.
338
+ const pillarGeom = new BoxGeometry(1, 6, 1);
339
+ const PILLAR_N = 6;
340
+ for (let i = 0; i < PILLAR_N; i++) {
341
+ const a = (i / PILLAR_N) * Math.PI * 2 + 0.3;
342
+ const r = 22;
343
+ spawnBox(ecd, {
344
+ center: new Vector3(SPAWN_X + Math.cos(a) * r, 3, SPAWN_Z + Math.sin(a) * r),
345
+ size: new Vector3(1, 6, 1),
346
+ geometry: pillarGeom,
347
+ color: 0x888888,
348
+ roughness: 0.9,
349
+ });
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Mantle row directly north of spawn. Four obstacles in line, heights
355
+ * stepping past the mantle range:
356
+ *
357
+ * - 0.6m: well within mantle range; lowest "step-up" test
358
+ * - 1.0m: mid mantle range
359
+ * - 1.3m: near top of mantle range
360
+ * - 2.0m: too tall to mantle from the ground (heightDiff > maxHeight)
361
+ *
362
+ * The 2.0m block is also the canonical "LedgeGrab if you jump at it"
363
+ * candidate, but the dedicated LedgeGrab station to the south has a
364
+ * cleaner approach path.
365
+ */
366
+ function buildMantleStation(ecd) {
367
+ const baseZ = SPAWN_Z + 15;
368
+ spawnPad(ecd, SPAWN_X, baseZ - 3, COLOR_MANTLE);
369
+
370
+ const heights = [0.6, 1.0, 1.3, 2.0];
371
+ for (let i = 0; i < heights.length; i++) {
372
+ const h = heights[i];
373
+ spawnBox(ecd, {
374
+ center: new Vector3(SPAWN_X, h / 2, baseZ + i * 5),
375
+ size: new Vector3(3, h, 1),
376
+ color: COLOR_MANTLE,
377
+ roughness: 0.6,
378
+ });
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Wall-run coursea dedicated traversal east of spawn. Two raised
384
+ * platforms separated by a gap too wide to plain-jump, with a tall wall
385
+ * down ONE side that spans the gap. Stairs climb onto each platform from
386
+ * its outer end. The drill:
387
+ *
388
+ * 1. climb the west stairs onto platform A
389
+ * 2. SPRINT east along the wall (a run, not a walk — wallRun.minSpeed 5.5)
390
+ * 3. jump at the platform edge → the wall-run engages and carries you
391
+ * across the gap along the wall
392
+ * 4. land on platform B and descend the east stairs
393
+ *
394
+ * The wall is on the −Z side, its north face flush with the platforms' south
395
+ * faces, rising from the gap floor; it overlaps ~2 m onto each platform so
396
+ * the run can engage before the gap and dismount onto the far side.
397
+ */
398
+ function buildWallRunStation(ecd) {
399
+ const z = SPAWN_Z; // course centreline along Z
400
+ const gapCenterX = SPAWN_X + 22; // east of spawn (clear of the inner ring)
401
+ const H = 1.5; // platform top height
402
+ const GAP = 7; // wider than a running jump (~5 m) → must wall-run
403
+ const platX = 5, platZ = 4; // platform footprint (X × Z)
404
+ const halfX = platX / 2;
405
+ const RISE = 0.25, DEPTH = 0.4, STEPS = Math.round(H / RISE); // 6 steps to y=H
406
+
407
+ const aCenterX = gapCenterX - GAP / 2 - halfX; // platform A (west, launch)
408
+ const bCenterX = gapCenterX + GAP / 2 + halfX; // platform B (east, land)
409
+ const aWestEdge = aCenterX - halfX;
410
+ const bEastEdge = bCenterX + halfX;
411
+
412
+ // Entry pad on open ground, just west of the foot of the west stairs.
413
+ spawnPad(ecd, aWestEdge - STEPS * DEPTH - 2.5, z, COLOR_WALLRUN);
414
+
415
+ // Two platforms, solid from the ground (top at y=H).
416
+ spawnBox(ecd, { center: new Vector3(aCenterX, H / 2, z), size: new Vector3(platX, H, platZ), color: COLOR_WALLRUN, roughness: 0.6 });
417
+ spawnBox(ecd, { center: new Vector3(bCenterX, H / 2, z), size: new Vector3(platX, H, platZ), color: COLOR_WALLRUN, roughness: 0.6 });
418
+
419
+ // The wall to run on south edge, north face flush with the platforms'
420
+ // −Z faces, spanning the gap plus 2 m onto each platform, rising from the
421
+ // gap floor well above head height through the run arc.
422
+ const wallH = 5;
423
+ const wallZ = z - platZ / 2 - 0.25; // 0.5-thick wall; north face at the platform south edge
424
+ spawnBox(ecd, {
425
+ center: new Vector3(gapCenterX, wallH / 2, wallZ),
426
+ size: new Vector3(GAP + 4, wallH, 0.5),
427
+ color: COLOR_WALLRUN,
428
+ roughness: 0.6,
429
+ });
430
+
431
+ // Stairs onto each platform from its outer end, each flight rising toward
432
+ // the platform it serves.
433
+ spawnStairFlight(ecd, aWestEdge, z, +1, STEPS, RISE, DEPTH, platZ, COLOR_WALLRUN); // west: ascend +X onto A
434
+ spawnStairFlight(ecd, bEastEdge, z, -1, STEPS, RISE, DEPTH, platZ, COLOR_WALLRUN); // east: ascend −X onto B
435
+ }
436
+
437
+ /**
438
+ * A flight of solid-pillar steps (the buildStairsStation profile) reaching
439
+ * height `steps*rise`, flush at `topEdgeX`, descending to the ground away
440
+ * from it. `ascendDir` is the climb direction along X (+1 toward +X, −1
441
+ * toward −X); steps march the other way, edge-to-edge, each a pillar from the
442
+ * ground up to its tread height.
443
+ */
444
+ function spawnStairFlight(ecd, topEdgeX, z, ascendDir, steps, rise, depth, width, color) {
445
+ for (let i = 1; i <= steps; i++) {
446
+ const topY = i * rise;
447
+ const cx = topEdgeX - ascendDir * (steps - i + 0.5) * depth;
448
+ spawnBox(ecd, {
449
+ center: new Vector3(cx, topY / 2, z),
450
+ size: new Vector3(depth, topY, width),
451
+ color,
452
+ roughness: 0.6,
453
+ });
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Wall-jump chimney — two parallel walls on the west side, ~3m apart,
459
+ * 6m tall. Player walks into the gap, jumps, hits one wall, wall-jumps
460
+ * to the other side. Repeat to climb.
461
+ *
462
+ * The walls are wide enough that wall-jumping straight back to the
463
+ * other side stays inside the chimney.
464
+ */
465
+ function buildWallJumpChimney(ecd) {
466
+ const baseX = SPAWN_X - 15;
467
+ spawnPad(ecd, baseX + 3, SPAWN_Z, COLOR_WALLJUMP);
468
+
469
+ // North wall of the chimney (pushes south).
470
+ spawnBox(ecd, {
471
+ center: new Vector3(baseX - 2, 3, SPAWN_Z + 2),
472
+ size: new Vector3(4, 6, 0.5),
473
+ color: COLOR_WALLJUMP,
474
+ roughness: 0.6,
475
+ });
476
+ // South wall of the chimney (pushes north). 3m gap to the north wall.
477
+ spawnBox(ecd, {
478
+ center: new Vector3(baseX - 2, 3, SPAWN_Z - 1.25),
479
+ size: new Vector3(4, 6, 0.5),
480
+ color: COLOR_WALLJUMP,
481
+ roughness: 0.6,
482
+ });
483
+ }
484
+
485
+ /**
486
+ * Ledge-grab station a single 2m-tall wall just south of spawn.
487
+ * Too tall to mantle from the ground (heightDiff = 2 > maxHeight 1.4),
488
+ * but the player can run-jump at it and catch the lip on descent:
489
+ *
490
+ * 1. sprint south
491
+ * 2. jump just before the wall
492
+ * 3. on the way down, chest probe hits the wall face;
493
+ * ledgeAhead probe finds the top → LedgeGrab fires
494
+ * 4. press jump to chain into Mantle (mantle-up release)
495
+ *
496
+ * The wall is 6m wide so the player doesn't have to aim perfectly.
497
+ */
498
+ function buildLedgeGrabStation(ecd) {
499
+ const baseZ = SPAWN_Z - 15;
500
+ spawnPad(ecd, SPAWN_X, baseZ + 3, COLOR_LEDGE);
501
+
502
+ spawnBox(ecd, {
503
+ center: new Vector3(SPAWN_X, 1.0, baseZ - 1),
504
+ size: new Vector3(6, 2.0, 2),
505
+ color: COLOR_LEDGE,
506
+ roughness: 0.6,
507
+ });
508
+
509
+ // A larger top-of-wall platform behind the lip, so once the player
510
+ // climbs up they have somewhere to stand and look around.
511
+ spawnBox(ecd, {
512
+ center: new Vector3(SPAWN_X, 1.0, baseZ - 4.5),
513
+ size: new Vector3(6, 2.0, 5),
514
+ color: COLOR_LEDGE,
515
+ roughness: 0.6,
516
+ });
517
+ }
518
+
519
+ /**
520
+ * Slide tunnel — a sprint run-up followed by a low ceiling that forces
521
+ * the player to slide under. NE of spawn.
522
+ *
523
+ * Layout: a long flat lane (just an entry pad / marker, ground is
524
+ * already flat), with an overhead box at 1m height. Approaching at
525
+ * sprint + crouch press → slide → fits under the gap.
526
+ */
527
+ function buildSlideTunnel(ecd) {
528
+ const baseX = SPAWN_X + 11;
529
+ const baseZ = SPAWN_Z + 11;
530
+ spawnPad(ecd, baseX, baseZ, COLOR_SLIDE);
531
+
532
+ // The "ceiling" a slab suspended 1.0m above the ground. Sliding
533
+ // brings the eye height down to ~0.4m; standing-eye-height would
534
+ // collide.
535
+ spawnBox(ecd, {
536
+ center: new Vector3(baseX + 4, 1.5, baseZ + 4),
537
+ size: new Vector3(4, 1.0, 4),
538
+ color: COLOR_SLIDE,
539
+ roughness: 0.6,
540
+ });
541
+
542
+ // Side pillars to make the tunnel feel like a tunnel.
543
+ spawnBox(ecd, {
544
+ center: new Vector3(baseX + 4, 1.0, baseZ + 2.0),
545
+ size: new Vector3(4, 2.0, 0.5),
546
+ color: COLOR_SLIDE,
547
+ roughness: 0.7,
548
+ });
549
+ spawnBox(ecd, {
550
+ center: new Vector3(baseX + 4, 1.0, baseZ + 6.0),
551
+ size: new Vector3(4, 2.0, 0.5),
552
+ color: COLOR_SLIDE,
553
+ roughness: 0.7,
554
+ });
555
+ }
556
+
557
+ /**
558
+ * Gap jump — two elevated platforms with a 3m gap between them. Player
559
+ * approaches via a mantle stair, runs across the first platform,
560
+ * sprints + jumps the gap.
561
+ *
562
+ * The platforms sit 1.5m above the spawn ground (mantleable). The gap
563
+ * doesn't actually break the floor — landing flat would still catch
564
+ * the player on the y=0 floor below. That's fine for testing the
565
+ * mid-flight feel of a jump; survival of misses is a feature here.
566
+ */
567
+ function buildGapJumpStation(ecd) {
568
+ const baseX = SPAWN_X + 11;
569
+ const baseZ = SPAWN_Z - 11;
570
+ spawnPad(ecd, baseX, baseZ, COLOR_GAP);
571
+
572
+ // Approach mantle (step up to the first platform).
573
+ spawnBox(ecd, {
574
+ center: new Vector3(baseX + 2, 0.5, baseZ - 2),
575
+ size: new Vector3(2, 1.0, 2),
576
+ color: COLOR_GAP,
577
+ roughness: 0.7,
578
+ });
579
+
580
+ // First platform — 4m long along Z.
581
+ spawnBox(ecd, {
582
+ center: new Vector3(baseX + 5, 0.75, baseZ - 4),
583
+ size: new Vector3(4, 1.5, 4),
584
+ color: COLOR_GAP,
585
+ roughness: 0.6,
586
+ });
587
+
588
+ // Second platform same height, 3m gap.
589
+ spawnBox(ecd, {
590
+ center: new Vector3(baseX + 5, 0.75, baseZ - 11),
591
+ size: new Vector3(4, 1.5, 4),
592
+ color: COLOR_GAP,
593
+ roughness: 0.6,
594
+ });
595
+ }
596
+
597
+ /**
598
+ * Stairs station a walkable staircase up to a landing, SW of spawn.
599
+ * Exercises the KinematicMover's stair handling (DESIGN_COLLISION.md
600
+ * Phase 3): the player walks up the steps staying grounded (no launch
601
+ * off each lip) and walks back down staying grounded (stick-to-ground
602
+ * snaps onto each lower step rather than going airborne).
603
+ *
604
+ * Each step rises `RISE` (0.2 m) — comfortably under the controller's
605
+ * `stepHeight` (0.3 m), so the mover climbs it. A blocking reference
606
+ * sits beside the stairs: a single 0.5 m riser (> stepHeight) the
607
+ * player canNOT walk up, for contrast.
608
+ *
609
+ * Steps are solid pillars from the ground to each tread height, placed
610
+ * edge-to-edge so they form a clean staircase profile (no floating
611
+ * treads, no overlap). The player approaches from the +X (spawn) side
612
+ * and walks −X up the flight.
613
+ */
614
+ function buildStairsStation(ecd) {
615
+ const baseX = SPAWN_X - 11;
616
+ const baseZ = SPAWN_Z - 11;
617
+ spawnPad(ecd, baseX, baseZ, COLOR_STAIRS);
618
+
619
+ const RISE = 0.2; // per-step rise — under stepHeight (0.3) → climbable
620
+ const DEPTH = 0.45; // tread depth
621
+ const WIDTH = 4; // generous width so aiming isn't required
622
+ const STEPS = 7; // → 1.4 m top
623
+ const firstFrontX = baseX - 2; // near (east) edge of the first step
624
+
625
+ for (let i = 1; i <= STEPS; i++) {
626
+ const topY = i * RISE;
627
+ spawnBox(ecd, {
628
+ center: new Vector3(firstFrontX - (i - 0.5) * DEPTH, topY / 2, baseZ),
629
+ size: new Vector3(DEPTH, topY, WIDTH),
630
+ color: COLOR_STAIRS,
631
+ roughness: 0.6,
632
+ });
633
+ }
634
+
635
+ // Landing platform at the top, flush with the last tread.
636
+ const topY = STEPS * RISE;
637
+ spawnBox(ecd, {
638
+ center: new Vector3(firstFrontX - STEPS * DEPTH - 1.5, topY / 2, baseZ),
639
+ size: new Vector3(3, topY, WIDTH),
640
+ color: COLOR_STAIRS,
641
+ roughness: 0.6,
642
+ });
643
+
644
+ // Blocking reference a single 0.5 m riser (> stepHeight) beside the
645
+ // flight (offset +Z). Walk into it: the mover blocks rather than
646
+ // climbing, the contrast to the stairs.
647
+ spawnBox(ecd, {
648
+ center: new Vector3(firstFrontX - 1, 0.25, baseZ + WIDTH / 2 + 2),
649
+ size: new Vector3(3, 0.5, 3),
650
+ color: COLOR_NEUTRAL,
651
+ roughness: 0.8,
652
+ });
653
+ }
654
+
655
+ /**
656
+ * NW — Ramp row. A line of inclined slabs at increasing angle. The mover's
657
+ * walkability gate is `minWalkNormal` 0.7 (≈ 45.), so the gentle ramps can
658
+ * be walked / run up, while the steep ones can't be stood on — step onto one
659
+ * and you slide back down. Approach from the south (the pad) and head +Z up
660
+ * each.
661
+ *
662
+ * Each ramp is a thin slab rotated about X so its top face rises in +Z. The
663
+ * lower half is buried in the ground plane (the realistic "ramp base sunk in
664
+ * terrain" pattern), so the surface emerges flush no lip to catch on.
665
+ */
666
+ function buildRampStation(ecd) {
667
+ const baseX = SPAWN_X - 11;
668
+ const baseZ = SPAWN_Z + 11; // emergence line — ramps rise +Z (north) from here
669
+ spawnPad(ecd, baseX, baseZ - 3, COLOR_RAMP);
670
+
671
+ const angles = [20, 30, 40, 50, 60]; // ° — 20/30/40 walkable, 50/60 too steep (slide)
672
+ const LENGTH = 7; // slab length (half buried) → ~3.5·cosθ of usable run
673
+ const WIDTH = 2.5; // generous enough to aim up without precision
674
+ const SPACING = 3; // between ramp centres, along X
675
+ const x0 = baseX - ((angles.length - 1) * SPACING) / 2; // centre the row on baseX
676
+
677
+ angles.forEach((deg, i) => {
678
+ spawnRamp(ecd, x0 + i * SPACING, baseZ, deg, LENGTH, WIDTH, COLOR_RAMP);
679
+ });
680
+ }
681
+
682
+ /**
683
+ * Spawn one inclined ramp slab whose top face emerges from the ground at
684
+ * (ex, 0, ez) and rises in +Z at `angleDeg`. Built as a `length`×`THICK`×
685
+ * `width`... actually width×THICK×length box (X=width, Y=thickness, Z=length)
686
+ * rotated −angle about X, then offset so the top-face centre lands on the
687
+ * emergence point (lower half buried, upper half the walkable incline).
688
+ */
689
+ function spawnRamp(ecd, ex, ez, angleDeg, length, width, color) {
690
+ const theta = (angleDeg * Math.PI) / 180;
691
+ const THICK = 0.5;
692
+ const h = THICK / 2;
693
+ const rot = new Quaternion();
694
+ rot.fromAxisAngle(new Vector3(1, 0, 0), -theta); // top face tilts up toward +Z
695
+ spawnBox(ecd, {
696
+ center: new Vector3(ex, -h * Math.cos(theta), ez + h * Math.sin(theta)),
697
+ size: new Vector3(width, THICK, length),
698
+ color,
699
+ roughness: 0.6,
700
+ rotation: rot,
701
+ });
702
+ }
703
+
704
+ // =====================================================================
705
+ // Spawn helpers
706
+ // =====================================================================
707
+
708
+ /**
709
+ * Add a box-shaped obstacle to the world. Position is the box centre.
710
+ *
711
+ * Single entity carrying both visual (ShadedGeometry) and physical
712
+ * (RigidBody + Collider) representations so the player can stand on it,
713
+ * sensors can raycast against it, and dynamic crates (when added) bump
714
+ * off it. Bodies are {@link BodyKind.Static} — gym geometry never moves.
715
+ *
716
+ * @param {EntityComponentDataset} ecd
717
+ * @param {object} p
718
+ * @param {Vector3} p.center
719
+ * @param {Vector3} p.size w, h, d (box dimensions)
720
+ * @param {number} p.color
721
+ * @param {number} [p.roughness]
722
+ * @param {THREE.BufferGeometry} [p.geometry] reuse to avoid new geometry per spawn
723
+ * @param {string} [p.tag]
724
+ * @param {boolean} [p.collide] default true; set false for decorative pads
725
+ */
726
+ function spawnBox(ecd, { center, size, color, roughness = 0.7, geometry, tag = "GymObstacle", collide = true, rotation }) {
727
+ const geom = geometry !== undefined ? geometry : new BoxGeometry(size.x, size.y, size.z);
728
+ const material = new MeshStandardMaterial({ color, roughness });
729
+ const sg = ShadedGeometry.from(geom, material);
730
+ sg.setFlag(ShadedGeometryFlags.CastShadow);
731
+ sg.setFlag(ShadedGeometryFlags.ReceiveShadow);
732
+ sg.setFlag(ShadedGeometryFlags.Visible);
733
+
734
+ const transform = new Transform();
735
+ transform.position.copy(center);
736
+ // One Transform drives both the visual mesh and the collider, so a
737
+ // rotation here tilts a box into a ramp consistently for render + physics.
738
+ if (rotation !== undefined) transform.rotation.copy(rotation);
739
+
740
+ const eb = new Entity()
741
+ .add(sg)
742
+ .add(Tag.fromJSON([tag]))
743
+ .add(SerializationMetadata.Transient);
744
+
745
+ if (collide) {
746
+ const rigidBody = new RigidBody();
747
+ rigidBody.kind = BodyKind.Static;
748
+ const collider = new Collider();
749
+ // BoxShape3D with explicit half-extents routes through the
750
+ // closed-form sphere-box / box-box / capsule-box narrowphase paths
751
+ // instead of degenerating into GJK+EPA, which is unreliable on
752
+ // smooth-surface pairs like capsule-vs-box.
753
+ collider.shape = BoxShape3D.from_size(size.x, size.y, size.z);
754
+ // Transform is added LAST so the (RigidBody, Transform) tuple
755
+ // completes only after the body is present PhysicsSystem.link
756
+ // is the trigger, not the rigidBody add.
757
+ eb.add(rigidBody).add(collider).add(transform).build(ecd);
758
+ } else {
759
+ eb.add(transform).build(ecd);
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Lay a thin coloured "entry pad" on the ground at (x, z). Visual
765
+ * landmark only — flush with the floor, no collision (a 5 cm-thick
766
+ * static body in the player's walking path would bump the camera).
767
+ */
768
+ function spawnPad(ecd, x, z, color) {
769
+ spawnBox(ecd, {
770
+ center: new Vector3(x, 0.025, z),
771
+ size: new Vector3(4, 0.05, 4),
772
+ color,
773
+ roughness: 0.95,
774
+ tag: "GymEntryPad",
775
+ collide: false,
776
+ });
777
+ }
778
+
779
+ /**
780
+ * Lay a physics-only ground body underneath the entire gym so the
781
+ * player has something to stand on. The terrain mesh provides the
782
+ * grass visual at y=0; this is its physics counterpart (the player
783
+ * controller's vertical integrator and sensors only look at physics
784
+ * bodies, not the terrain heightfield).
785
+ *
786
+ * Sized to comfortably encompass all stations (the gym spans ~30 m
787
+ * radius around spawn). The slab top sits at y=0 — same as the
788
+ * terrain visual — so standing on it puts the player's feet on the
789
+ * grass.
790
+ */
791
+ function buildGroundPlane(ecd) {
792
+ const SIZE = 120;
793
+ const THICKNESS = 1.0;
794
+
795
+ const rigidBody = new RigidBody();
796
+ rigidBody.kind = BodyKind.Static;
797
+ const collider = new Collider();
798
+ collider.shape = BoxShape3D.from_size(SIZE, THICKNESS, SIZE);
799
+
800
+ const transform = new Transform();
801
+ // Top face at y=0 → centre at y = -thickness/2.
802
+ transform.position.set(SPAWN_X, -THICKNESS / 2, SPAWN_Z);
803
+
804
+ new Entity()
805
+ .add(rigidBody)
806
+ .add(collider)
807
+ .add(Tag.fromJSON(["GymGroundPlane"]))
808
+ .add(SerializationMetadata.Transient)
809
+ .add(transform)
810
+ .build(ecd);
811
+ }
812
+
813
+ // =====================================================================
814
+ // Player + input
815
+ // =====================================================================
816
+
817
+ /**
818
+ * @param {EntityComponentDataset} ecd
819
+ * @returns {{entity:number, controller:FirstPersonPlayerController}}
820
+ */
821
+ function buildPlayerEntity(ecd) {
822
+ const transform = new Transform();
823
+ // Spawn standing on the ground at the centre of the plaza.
824
+ transform.position.set(SPAWN_X, 0, SPAWN_Z);
825
+
826
+ const controller = new FirstPersonPlayerController();
827
+
828
+ // Movement abilities (priority order — see DESIGN_EXTENSIONS §2.2):
829
+ // Slide(10) < Mantle(30) < LedgeGrab(40) < WallRun(50) < WallJump(60).
830
+ controller.abilities.add(new Slide());
831
+ controller.abilities.add(new Mantle());
832
+ controller.abilities.add(new LedgeGrab());
833
+ controller.abilities.add(new WallRun());
834
+ controller.abilities.add(new WallJump());
835
+
836
+ // Mastery evaluators small bonuses/penalties tied to gait phase,
837
+ // foot asymmetry, and breath rhythm. Each fires on one (or a few)
838
+ // DecisionPoint(s); contributions compose multiplicatively.
839
+ controller.mastery.add(new StrideTimingJumpEvaluator());
840
+ controller.mastery.add(new SlideInitiationTimingEvaluator());
841
+ controller.mastery.add(new FootAsymmetryTurnEvaluator());
842
+ controller.mastery.add(new BreathRhythmEvaluator());
843
+ controller.mastery.add(new BreathRhythmEvaluator({
844
+ decisionPoints: [DecisionPoint.GroundAccel],
845
+ peakBonus: 0.015,
846
+ troughPenalty: 0.005,
847
+ requireExertionAbove: 0.3,
848
+ }));
849
+
850
+ // Kinematic capsule body: the controller writes Transform directly,
851
+ // physics derives velocity from the per-step delta. Capsule centred
852
+ // on the Transform — bottom hemisphere extends below the feet (this
853
+ // is fine for spatial queries since sensor probes start at chest
854
+ // height and the ground resolver filters out the player's own body).
855
+ const bodyCfg = controller.config.body;
856
+ const radius = bodyCfg.radius;
857
+ const totalHeight = bodyCfg.height; // standing eye height ≈ body extent
858
+ const cylinderHeight = Math.max(0, totalHeight - 2 * radius);
859
+ const rigidBody = new RigidBody();
860
+ rigidBody.kind = BodyKind.KinematicPosition;
861
+ rigidBody.mass = bodyCfg.mass;
862
+ const collider = new Collider();
863
+ // Capsule lives in [feet .. head] in world space. CapsuleShape3D is
864
+ // centred at its local origin, so wrap it in a TransformedShape3D
865
+ // with a +Y translation of totalHeight/2 — that puts the capsule
866
+ // bottom at the player's feet (Transform.position) and the top at
867
+ // the head. Without this, shape_cast queries against the ground
868
+ // would stop the capsule centre at floor + half-extent, leaving
869
+ // the player visibly floating half a body height above the floor.
870
+ collider.shape = TransformedShape3D.from_translation(
871
+ CapsuleShape3D.from(radius, cylinderHeight),
872
+ [0, totalHeight / 2, 0],
873
+ );
874
+
875
+ // Build order matters: Transform is added LAST so the FirstPerson
876
+ // controller system's link() sees the RigidBody already attached.
877
+ // (Same pattern as the spec helpers.)
878
+ const eb = new Entity()
879
+ .add(controller)
880
+ .add(rigidBody)
881
+ .add(collider)
882
+ .add(Tag.fromJSON(["Player"]))
883
+ .add(SerializationMetadata.Transient)
884
+ .add(transform);
885
+
886
+ eb.build(ecd);
887
+
888
+ return { entity: eb.id, controller };
889
+ }
890
+
891
+ /**
892
+ * Wire keyboard + mouse to the controller's intent surface.
893
+ *
894
+ * @param {EntityComponentDataset} ecd
895
+ * @param {{entity:number, controller:FirstPersonPlayerController}} player
896
+ */
897
+ function buildInputBindings(ecd, player) {
898
+ const ic = new InputController();
899
+ const intent = player.controller.intent;
900
+
901
+ // Mouse sensitivity — radians of view rotation per pixel of mouse delta.
902
+ const SENSITIVITY = (Math.PI * 2) / 1200;
903
+
904
+ // -- WASD movement --------------------------------------------------
905
+ const held = { w: false, a: false, s: false, d: false };
906
+
907
+ function recomputeMove() {
908
+ let mx = 0, my = 0;
909
+ if (held.d) mx += 1;
910
+ if (held.a) mx -= 1;
911
+ if (held.w) my += 1;
912
+ if (held.s) my -= 1;
913
+ intent.move.set(mx, my);
914
+ }
915
+
916
+ function bindMoveKey(key, prop) {
917
+ ic.bind(`keyboard/keys/${key}/down`, () => {
918
+ held[prop] = true;
919
+ recomputeMove();
920
+ });
921
+ ic.bind(`keyboard/keys/${key}/up`, () => {
922
+ held[prop] = false;
923
+ recomputeMove();
924
+ });
925
+ }
926
+
927
+ bindMoveKey("w", "w");
928
+ bindMoveKey("a", "a");
929
+ bindMoveKey("s", "s");
930
+ bindMoveKey("d", "d");
931
+
932
+ // -- Jump ------------------------------------------------------------
933
+ ic.bind("keyboard/keys/space/down", () => { intent.jump = true; });
934
+ ic.bind("keyboard/keys/space/up", () => { intent.jump = false; });
935
+
936
+ // -- Sprint ----------------------------------------------------------
937
+ ic.bind("keyboard/keys/shift/down", () => { intent.sprint = true; });
938
+ ic.bind("keyboard/keys/shift/up", () => { intent.sprint = false; });
939
+
940
+ // -- Crouch ----------------------------------------------------------
941
+ ic.bind("keyboard/keys/c/down", () => { intent.crouch = true; });
942
+ ic.bind("keyboard/keys/c/up", () => { intent.crouch = false; });
943
+
944
+ // -- Mouse look ------------------------------------------------------
945
+ ic.bind("pointer/on/move", (position, event, delta) => {
946
+ if (document.pointerLockElement === null) return;
947
+ intent.look._add(delta.x * SENSITIVITY, delta.y * SENSITIVITY);
948
+ });
949
+
950
+ ic.on.unlinked.add(() => {
951
+ intent.move.set(0, 0);
952
+ intent.look.set(0, 0);
953
+ intent.jump = false;
954
+ intent.crouch = false;
955
+ intent.sprint = false;
956
+ });
957
+
958
+ new Entity()
959
+ .add(ic)
960
+ .add(Tag.fromJSON(["FirstPersonInputBindings"]))
961
+ .add(SerializationMetadata.Transient)
962
+ .build(ecd);
963
+ }
964
+
965
+ /**
966
+ * Click to capture; ESC (or other browser-default) to release.
967
+ * @param {Engine} engine
968
+ */
969
+ function setupPointerLock(engine) {
970
+ const el = engine.graphics.domElement;
971
+ const captureEl = engine.viewStack.el;
972
+
973
+ captureEl.addEventListener("click", () => {
974
+ if (document.pointerLockElement !== el && el.requestPointerLock) {
975
+ el.requestPointerLock();
976
+ }
977
+ });
978
+
979
+ captureEl.addEventListener("contextmenu", (e) => e.preventDefault());
980
+ }
981
+
982
+ function logHelp() {
983
+ /* eslint-disable no-console */
984
+ console.log("%cFirstPersonPlayerController — Parkour Gym",
985
+ "color:#88ddff;font-weight:bold;font-size:14px");
986
+ console.log("Click the canvas to capture mouse. ESC to release.");
987
+ console.log("WASD = move | Mouse = look | Space = jump | Shift = sprint | C = crouch");
988
+ console.log("");
989
+ console.log("%cStations:", "font-weight:bold");
990
+ console.log(" N Mantle row — four obstacles, last one is too tall");
991
+ console.log(" NE Slide tunnel — sprint + C to slide under the low ceiling");
992
+ console.log(" E Wall-run — sprint along, jump near the wall");
993
+ console.log(" SE Gap jump — mantle up the stair, sprint, jump the gap");
994
+ console.log(" S Ledge-grab — too tall to mantle; jump at it, catch the lip");
995
+ console.log(" W Wall-jump chimney — alternate walls to climb");
996
+ }
997
+
998
+ async function init() {
999
+ await eh.initialize();
1000
+ await main(eh.engine);
1001
+ }
1002
+
1003
+ init();