/** * Lane A — third-person soft-follow camera. * * Trails the blob with position lag, eases its yaw to sit behind the direction * of travel, and kicks FOV a touch at speed for a sense of hustle. Runs as a * per-frame (visual-only) hook. The controller reads `world.camera` for its * move basis, so this cam swinging around keeps WASD camera-relative. */ import * as THREE from 'three' import type { World } from '../contracts' import type { BlobHandle } from './createBlob' export interface FollowCameraOptions { distance?: number height?: number /** How fast the camera position catches up (higher = tighter). */ followLag?: number /** How fast the camera swings behind travel (higher = snappier). */ yawLag?: number } export interface FollowCamera { update(dt: number): void yaw(): number } function expo(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt) } export function createFollowCamera( world: World, blob: BlobHandle, opts: FollowCameraOptions = {}, ): FollowCamera { const cam = world.camera const distance = opts.distance ?? 9 const height = opts.height ?? 4.5 const followLag = opts.followLag ?? 6 const yawLag = opts.yawLag ?? 2.5 const baseFov = cam.fov /** * Narrow-screen pullback. FOV is VERTICAL, so the horizontal view shrinks * with the aspect ratio (hFov = 2·atan(tan(vFov/2)·aspect)) — a portrait * phone saw ~30° across vs ~92° on a 16:9 desktop, i.e. the blob filled the * screen and the course was invisible. Pulling the rig back restores a * playable view without a fisheye FOV. Recomputed each frame so rotating the * device adapts. Exactly 1.0 at 16:9 and wider — desktop is untouched. */ const aspectPull = (): number => { const a = cam.aspect if (!isFinite(a) || a >= 1.6) return 1 return Math.min(1.75, Math.sqrt(1.6 / Math.max(a, 0.4))) } let camYaw = Math.PI // start looking toward -Z (down the course) const desired = new THREE.Vector3() const look = new THREE.Vector3() let lookInit = false return { update(dt: number): void { const p = blob.body.translation() const v = blob.body.linvel() const size = blob.modifiers.size const speed = Math.hypot(v.x, v.z) // ease the rig behind the direction of travel (only while actually moving) if (speed > 1.5) { const targetYaw = Math.atan2(v.x, v.z) let delta = targetYaw - camYaw delta = Math.atan2(Math.sin(delta), Math.cos(delta)) camYaw += delta * expo(yawLag, dt) } const pull = aspectPull() const dist = distance * size * pull const h = height * size * pull desired.set( p.x - Math.sin(camYaw) * dist, p.y + h, p.z - Math.cos(camYaw) * dist, ) cam.position.lerp(desired, expo(followLag, dt)) // Look at a point a little above the blob, itself eased for smoothness. // ponytail: tried two portrait refinements here and reverted both — // flattening the rig and leading the aim down the track each traded floor // for SKY and framed less course than the plain pulled-back rig above. // On a tall screen, high-and-looking-down is what fills frame with track. const lookTarget = new THREE.Vector3(p.x, p.y + 1.0 * size, p.z) if (!lookInit) { look.copy(lookTarget) lookInit = true } else { look.lerp(lookTarget, expo(8, dt)) } cam.lookAt(look) // FOV kick at speed const targetFov = baseFov + Math.min(speed * 0.9, 12) cam.fov += (targetFov - cam.fov) * expo(4, dt) cam.updateProjectionMatrix() }, yaw: () => camYaw, } }