Files
onboarding-project/src/controllers/CameraFollowC.ts
T
Oleksandr Vlasiuk fe1192f4c9 Add joystick-controlled character with camera follow and bat/pistol combat
Replaces the placeholder cube with ZombiePunk_Character: rigged model with
Idle/SlowWalk/Loot animations, tap-to-move joystick control, AABB collision
against crate colliders, and a pistol/bat weapon swap driven by facing a
destructible object (Loot anim doubles as the melee hit since there's no
dedicated swing clip).

Camera (CameraFollowC) follows with framerate-independent damping, a
smoothed look-ahead that pans toward the character's facing direction, and
obstacle-avoidance raycasting from eye height so short props don't falsely
block the view. Spawn point and forward convention flipped to the other end
of the road per request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 16:58:51 +03:00

116 lines
5.3 KiB
TypeScript

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);
}
}