Skip to content

Commit cdb53cf

Browse files
Snoopy147claude
andcommitted
feel: the camera has weight — one stride, landing dip, strafe lean, a hurt kick; the frame loop is guarded
The player camera was a bare rig: a head bob that aliased into a 26 mm/frame eye buzz whenever you left the ground at a run, a bob cadence that did not match the footstep sound, a weapon on its own unsynced bob, no landing dip, no strafe roll, 35 cm eye pops on step-offset lifts, per-frame white-noise shake, and no camera reaction at all to getting hurt. feel.ts is one pure, tested feel core: a single 2.3 m stride whose low point IS the footstep, a landing dip that scales with fall depth (up to 10 cm, back in 0.32 s), eased step lifts (smoothEyeY eases only the rise the slope velocity does not explain, so a 43° ramp rides true), strafe lean, a damped-oscillator shake identical on 60 and 120 Hz screens, and a directional hurt kick that matches the HUD edge flash. player.tsx drives it; viewmodel.tsx reads the same bob phase (no out-of-phase restart after a stop), dips with the body on landing, recoils with an instant kick and a shaped return, and sways lateral- and-rolling at a walk, tighter at a run. The eye on the wire carries no bob, so peers' avatars stop double-bobbing. Mouse look is raw where the browser allows. frame-guard.ts: guarded useFrame subscribers (three consecutive throws exit the session cleanly instead of freezing fullscreen), FrameBooster rewinds R3F's clock to the host limiter's epoch so the first editor frame after Esc carries a small positive dt (it carried a hugely negative one). game-root.tsx also carries the see lane's co-presence bind retry (untilNet): a fast Jump In no longer strands a player in a solo session when the host's collab bus installs a second late — presence, world-sync, PvP and voice all start the moment it lands. docs/qa/qa-boots-feel.mjs pins all of it live on :3002 (9 checks). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 728a75d commit cdb53cf

11 files changed

Lines changed: 2349 additions & 136 deletions

docs/qa/qa-boots-feel.mjs

Lines changed: 614 additions & 0 deletions
Large diffs are not rendered by default.

src/game/feel-slope.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)