import { Object3D, Raycaster, Vector3 } from "three"; import { CameraC_internal, UpdateController } from "@hitplay/playable_template"; // How fast the camera closes the gap to its target position/look point, per // second. Higher = snappier, lower = floatier. Framerate-independent (see // the exponential smoothing in update()), so the feel is the same at 30fps // and 60fps instead of drifting with frame time. const POSITION_DAMPING = 5; const LOOK_DAMPING = 8; // Keeps the camera a bit off any obstacle it lands on, and never lets it // collapse onto the target itself. const OBSTACLE_SKIN = 0.4; const MIN_DISTANCE = 1.5; // How far (world units) the whole camera rig — position and look-at point // alike — pans toward wherever the character is currently facing, to open // up more of the space ahead of them instead of centering them dead-on. // Matches PlayerC's own forward convention (+z at rest); the two aren't // coupled in code, but there's only one character to stay in sync with. const LOOK_AHEAD_DISTANCE = 1.2; // The character's body can spin at up to 540°/s (PlayerC.MAX_TURN_SPEED), // which would yank the look-ahead point around just as fast if it followed // the raw facing direction. Smoothing the direction itself, separately from // (and slower than) the position/look damping below, is what actually makes // the pan gentle regardless of how fast the character turns. const LOOK_AHEAD_DAMPING = 2; const FORWARD_AXIS = new Vector3(0, 0, 1); export class CameraFollowC { private static target: Object3D | null = null; private static offset = new Vector3(); private static obstacles: Object3D[] = []; private static eyeHeight = 0; private static smoothedLookAt = new Vector3(); // The actual (x,y,z) offset, not a direction — see update() for why that // distinction matters. private static smoothedLookAhead = FORWARD_AXIS.clone().multiplyScalar(LOOK_AHEAD_DISTANCE); private static raycaster = new Raycaster(); static init(target: Object3D, offset: Vector3, obstacles: Object3D[] = [], eyeHeight = 0) { this.target = target; this.offset = offset.clone(); this.obstacles = obstacles; this.eyeHeight = eyeHeight; this.smoothedLookAt.copy(target.position); UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); } private static update(delta: number) { if (!this.target) return; const camera = CameraC_internal.getCamera(); const targetPosition = this.target.position; const facing = FORWARD_AXIS.clone().applyQuaternion(this.target.quaternion); facing.y = 0; facing.normalize(); const desiredLookAhead = facing.multiplyScalar(LOOK_AHEAD_DISTANCE); // Damping the raw offset vector (not a unit direction re-normalized // every frame) is what keeps this a straight-line move through the // character on a direction change, instead of an arc: re-normalizing // pins the vector's length at LOOK_AHEAD_DISTANCE the whole time, which // forces it to sweep around a circle of that radius as the direction // changes. Left as plain (x,y,z) lerp, it can shrink through zero and // grow back out the other way — a straight line, not a curve. const lookAheadT = 1 - Math.exp(-LOOK_AHEAD_DAMPING * delta); this.smoothedLookAhead.lerp(desiredLookAhead, lookAheadT); const lookAhead = this.smoothedLookAhead; const desiredPosition = targetPosition.clone().add(this.offset).add(lookAhead); this.avoidObstacles(targetPosition, desiredPosition); // Exponential (framerate-independent) damping instead of a fixed lerp // factor per frame — a fixed factor changes speed with the frame rate // and reads as jerky; this keeps the same feel regardless of fps. const positionT = 1 - Math.exp(-POSITION_DAMPING * delta); camera.position.lerp(desiredPosition, positionT); const lookT = 1 - Math.exp(-LOOK_DAMPING * delta); this.smoothedLookAt.lerp(targetPosition.clone().add(lookAhead), lookT); camera.lookAt(this.smoothedLookAt); } // Only pulls the camera in when an obstacle would actually hide the // character. The check ray starts at (roughly) eye height instead of the // character's center/feet, so something that doesn't reach that high never // triggers a zoom — the character is still visible over it. When it does // trigger, the ray's own hit distance (measured from the character, not // from the camera) is what places the camera, so it lands right next to // the obstacle on the character's side instead of collapsing onto the // character whenever they aren't standing flush against it. private static avoidObstacles(targetPosition: Vector3, desired: Vector3) { if (this.obstacles.length === 0) return; const eyePosition = targetPosition.clone().add(new Vector3(0, this.eyeHeight, 0)); const toDesired = desired.clone().sub(eyePosition); const distance = toDesired.length(); if (distance < 1e-4) return; const direction = toDesired.divideScalar(distance); this.raycaster.set(eyePosition, direction); this.raycaster.far = distance; const hits = this.raycaster.intersectObjects(this.obstacles, true); if (hits.length === 0) return; const safeDistance = Math.max(hits[0].distance - OBSTACLE_SKIN, MIN_DISTANCE); desired.copy(eyePosition).addScaledVector(direction, safeDistance); } }