|
| 1 | +import { describe, expect, test } from 'bun:test' |
| 2 | +import { Box3, BoxGeometry, Matrix4, Mesh, Vector3 } from 'three' |
| 3 | +import { moveCapsule, PLAYER_CAPSULE } from './collision' |
| 4 | +import { createFeelState, smoothEyeY } from './feel' |
| 5 | +import { MOVE, projectOnWalkableSlope, stepVelocity } from './movement' |
| 6 | +import { bvhFor, type ColliderEntry } from './world' |
| 7 | + |
| 8 | +/** |
| 9 | + * The camera's eased eye against the REAL move routine (stepVelocity → |
| 10 | + * projectOnWalkableSlope → moveCapsule, 60 Hz — the Player loop's order). |
| 11 | + * feel.test.ts pins smoothEyeY's math on synthetic feet heights; this suite |
| 12 | + * pins that what the collider actually does to the feet on a 43° plank, on |
| 13 | + * sawtooth stairs and on a jump landing is classified right: slope travel and |
| 14 | + * the last fall frame pass through (velocity explains them), riser lifts ease. |
| 15 | + * Review 2026-09-02: before the `expectedRise` hint the ease settled 24 cm |
| 16 | + * below the true eye on a stairs sprint — the control run below reproduces it. |
| 17 | + */ |
| 18 | + |
| 19 | +const RAMP_TILT = -Math.atan2(2.8, 3) |
| 20 | +const DT = 1 / 60 |
| 21 | + |
| 22 | +function boxCollider( |
| 23 | + nodeId: string, |
| 24 | + size: [number, number, number], |
| 25 | + center: [number, number, number], |
| 26 | + rotX = 0, |
| 27 | +): ColliderEntry { |
| 28 | + const mesh = new Mesh(new BoxGeometry(size[0], size[1], size[2])) |
| 29 | + mesh.position.set(center[0], center[1], center[2]) |
| 30 | + mesh.rotation.x = rotX |
| 31 | + mesh.updateMatrixWorld(true) |
| 32 | + mesh.geometry.computeBoundingBox() |
| 33 | + return { |
| 34 | + mesh, |
| 35 | + bvh: bvhFor(mesh), |
| 36 | + inverse: new Matrix4().copy(mesh.matrixWorld).invert(), |
| 37 | + worldBox: new Box3().setFromObject(mesh), |
| 38 | + root: mesh, |
| 39 | + nodeId, |
| 40 | + nodeType: 'stair', |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +const ramp = () => boxCollider('ramp', [3, 0.12, 8.2], [0, 2.8, 3], RAMP_TILT) |
| 45 | + |
| 46 | +function sawtoothStairs(): { colliders: ColliderEntry[]; onLanding: (p: Vector3) => boolean } { |
| 47 | + const riser = 0.19 |
| 48 | + const tread = 0.26 |
| 49 | + const steps = 10 |
| 50 | + const colliders: ColliderEntry[] = [] |
| 51 | + for (let i = 0; i < steps; i++) { |
| 52 | + const top = riser * (i + 1) |
| 53 | + colliders.push(boxCollider(`step-${i}`, [1.2, top, tread], [0, top / 2, -(i * tread + tread / 2)])) |
| 54 | + } |
| 55 | + const rise = riser * steps |
| 56 | + const runLen = tread * steps |
| 57 | + colliders.push(boxCollider('landing', [1.2, rise, 2], [0, rise / 2, -(runLen + 1)])) |
| 58 | + return { colliders, onLanding: (p) => p.z < -(runLen + 0.3) && p.y >= rise - 0.02 } |
| 59 | +} |
| 60 | + |
| 61 | +type Ride = { |
| 62 | + /** Largest |camera feet − true feet| on grounded frames after the settle. */ |
| 63 | + maxGap: number |
| 64 | + /** Same, restricted to frames whose contact normal is a real slope. */ |
| 65 | + maxGapSlope: number |
| 66 | + /** Gap on the first grounded frame after airtime (−1 = never landed). */ |
| 67 | + landingGap: number |
| 68 | + slopeFrames: number |
| 69 | + pos: Vector3 |
| 70 | +} |
| 71 | + |
| 72 | +/** Player-loop sim with the feel ease riding the feet: `explain` toggles the |
| 73 | + * expectedRise hint (false = the pre-review behaviour, the control). */ |
| 74 | +function ride( |
| 75 | + colliders: ColliderEntry[], |
| 76 | + start: Vector3, |
| 77 | + wishX: number, |
| 78 | + wishZ: number, |
| 79 | + seconds: number, |
| 80 | + explain: boolean, |
| 81 | + opts: { jumpAt?: number; goal?: (p: Vector3) => boolean } = {}, |
| 82 | +): Ride { |
| 83 | + const pos = start.clone() |
| 84 | + const vel = new Vector3() |
| 85 | + const normal = new Vector3(0, 1, 0) |
| 86 | + const f = createFeelState() |
| 87 | + let grounded = true |
| 88 | + let prevGrounded = true |
| 89 | + let maxGap = 0 |
| 90 | + let maxGapSlope = 0 |
| 91 | + let landingGap = -1 |
| 92 | + let slopeFrames = 0 |
| 93 | + for (let t = 0; t < seconds; t += DT) { |
| 94 | + const jump = opts.jumpAt !== undefined && t >= opts.jumpAt && t < opts.jumpAt + DT |
| 95 | + const jumped = stepVelocity(vel, { wishX, wishZ, walk: false, jump }, grounded, DT, MOVE) |
| 96 | + if (grounded && !jumped) projectOnWalkableSlope(vel, normal.x, normal.y, normal.z) |
| 97 | + const fallSpeed = vel.y // pre-move, exactly what player.tsx captures |
| 98 | + grounded = moveCapsule(pos, vel, DT, colliders, grounded, jumped, PLAYER_CAPSULE, normal) |
| 99 | + const camFeetY = smoothEyeY(f, pos.y, grounded, DT, explain ? fallSpeed * DT : 0) |
| 100 | + const gap = Math.abs(camFeetY - pos.y) |
| 101 | + if (grounded && !prevGrounded && landingGap < 0) landingGap = gap |
| 102 | + if (grounded && t > 0.15) { |
| 103 | + if (gap > maxGap) maxGap = gap |
| 104 | + if (normal.y < 0.99) { |
| 105 | + slopeFrames++ |
| 106 | + if (gap > maxGapSlope) maxGapSlope = gap |
| 107 | + } |
| 108 | + } |
| 109 | + prevGrounded = grounded |
| 110 | + if (opts.goal?.(pos)) break |
| 111 | + } |
| 112 | + return { maxGap, maxGapSlope, landingGap, slopeFrames, pos } |
| 113 | +} |
| 114 | + |
| 115 | +describe('camera eye on real geometry (feel.smoothEyeY × collision.moveCapsule)', () => { |
| 116 | + test('sprinting UP the 43° plank: camera within 2 cm of the true eye on every slope frame', () => { |
| 117 | + const up = ride([ramp()], new Vector3(0, 0, -1), 0, 1, 1.5, true, { goal: (p) => p.y >= 5 }) |
| 118 | + expect(up.pos.y).toBeGreaterThanOrEqual(5) // actually climbed it |
| 119 | + expect(up.slopeFrames).toBeGreaterThan(30) |
| 120 | + expect(up.maxGapSlope).toBeLessThan(0.02) |
| 121 | + }) |
| 122 | + |
| 123 | + test('CONTROL: the same sprint without the hint sinks the camera > 15 cm into the treads (the review finding)', () => { |
| 124 | + const up = ride([ramp()], new Vector3(0, 0, -1), 0, 1, 1.5, false, { goal: (p) => p.y >= 5 }) |
| 125 | + expect(up.pos.y).toBeGreaterThanOrEqual(5) |
| 126 | + expect(up.maxGapSlope).toBeGreaterThan(0.15) |
| 127 | + }) |
| 128 | + |
| 129 | + test('running DOWN the plank at full speed: within 2 cm on every slope frame', () => { |
| 130 | + const down = ride([ramp()], new Vector3(0, 5.5, 5.9), 0, -1, 1.2, true) |
| 131 | + expect(down.pos.y).toBeLessThan(0.05) // reached the lot plane |
| 132 | + expect(down.slopeFrames).toBeGreaterThan(30) |
| 133 | + expect(down.maxGapSlope).toBeLessThan(0.02) |
| 134 | + const control = ride([ramp()], new Vector3(0, 5.5, 5.9), 0, -1, 1.2, false) |
| 135 | + expect(control.maxGapSlope).toBeGreaterThan(0.15) |
| 136 | + }) |
| 137 | + |
| 138 | + test('sawtooth stairs (0.19 m risers): the riser lifts are STILL eased — a lag that exists but never exceeds a riser', () => { |
| 139 | + const { colliders, onLanding } = sawtoothStairs() |
| 140 | + const climb = ride(colliders, new Vector3(0, 0, 0.8), 0, -1, 3, true, { goal: onLanding }) |
| 141 | + expect(onLanding(climb.pos)).toBe(true) |
| 142 | + expect(climb.maxGap).toBeGreaterThan(0.03) // the ease is doing its job on the risers… |
| 143 | + expect(climb.maxGap).toBeLessThan(0.19) // …and never trails a full riser behind |
| 144 | + }) |
| 145 | + |
| 146 | + test('a run-and-jump on flat ground: the landing frame puts the camera exactly on the true eye', () => { |
| 147 | + const hop = ride([], new Vector3(0, 0, 0), 0, 1, 1.6, true, { jumpAt: 0.3 }) |
| 148 | + expect(hop.landingGap).toBeGreaterThanOrEqual(0) |
| 149 | + expect(hop.landingGap).toBeLessThan(1e-9) |
| 150 | + }) |
| 151 | +}) |
0 commit comments