fe1192f4c9
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>
116 lines
5.3 KiB
TypeScript
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);
|
|
}
|
|
}
|