Files
La-Fabrik/src/world/player/PlayerController.tsx
T
Tom Boullay bff8a16290
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
feat(intro): add ebike onboarding sequence
2026-05-31 10:42:46 +02:00

574 lines
18 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { Capsule, type Octree } from "three-stdlib";
import {
INTERACT_KEY,
JUMP_KEY,
MOVE_BACKWARD_KEY,
MOVE_FORWARD_KEY,
MOVE_LEFT_KEY,
MOVE_RIGHT_KEY,
PRIMARY_INTERACT_MOUSE_BUTTON,
} from "@/data/input/keybindings";
import {
PLAYER_ACCELERATION_MULTIPLIER,
PLAYER_AIR_CONTROL_FACTOR,
PLAYER_CAPSULE_RADIUS,
PLAYER_EYE_HEIGHT,
PLAYER_FALL_RESPAWN_DELAY,
PLAYER_FALL_RESPAWN_Y,
PLAYER_GRAVITY,
PLAYER_JUMP_SPEED,
PLAYER_MAX_DELTA,
PLAYER_XZ_DAMPING_FACTOR,
} from "@/data/player/playerConfig";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import { InteractionManager } from "@/managers/InteractionManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { Vector3Tuple } from "@/types/three/three";
import {
EBIKE_ACCELERATION_DURATION_MS,
EBIKE_CAMERA_TRANSFORM,
EBIKE_DECELERATION_DURATION_MS,
EBIKE_MAX_SPEED,
} from "@/data/ebike/ebikeConfig";
/** Global window properties used for ebike communication */
interface EbikeGlobalState {
ebikeParkedPosition?: Vector3Tuple;
ebikeParkedRotation?: number;
ebikeSteerFactor?: number;
ebikeVisualGroup?: React.RefObject<THREE.Group>;
playerPos?: Vector3Tuple;
ebikeAngle?: number;
ebikeBreakdownActive?: boolean;
ebikeDriveInputActive?: boolean;
ebikeSpeedFactor?: number;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Extending Window with EbikeGlobalState properties
interface Window extends EbikeGlobalState {}
}
type Keys = {
forward: boolean;
backward: boolean;
left: boolean;
right: boolean;
jump: boolean;
};
const DEFAULT_KEYS: Keys = {
forward: false,
backward: false,
left: false,
right: false,
jump: false,
};
const PLAYER_COLLISION_ITERATIONS = 3;
const PLAYER_FLOOR_NORMAL_MIN = 0.15;
const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
interface PlayerControllerProps {
octree: Octree | null;
spawnPosition: Vector3Tuple;
}
const _forward = new THREE.Vector3();
const _right = new THREE.Vector3();
const _wishDir = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
const _translateVec = new THREE.Vector3();
const _collisionCorrection = new THREE.Vector3();
function resetPlayerCapsule(
capsule: Capsule,
spawnPosition: Vector3Tuple,
camera: THREE.Camera,
velocity: THREE.Vector3,
): void {
capsule.start.set(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
spawnPosition[2],
);
capsule.end.set(...spawnPosition);
velocity.set(0, 0, 0);
camera.position.copy(capsule.end);
}
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
return new Capsule(
new THREE.Vector3(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
spawnPosition[2],
),
new THREE.Vector3(...spawnPosition),
PLAYER_CAPSULE_RADIUS,
);
}
function isPlayerInputLocked(): boolean {
return (
useSettingsStore.getState().isSettingsMenuOpen ||
useGameStore.getState().isCinematicPlaying
);
}
function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
switch (key.toLowerCase()) {
case MOVE_FORWARD_KEY:
keys.forward = pressed;
return true;
case MOVE_BACKWARD_KEY:
keys.backward = pressed;
return true;
case MOVE_LEFT_KEY:
keys.left = pressed;
return true;
case MOVE_RIGHT_KEY:
keys.right = pressed;
return true;
default:
return false;
}
}
function getCapsuleFootY(capsule: Capsule): number {
return capsule.start.y - capsule.radius;
}
export function PlayerController({
octree,
spawnPosition,
}: PlayerControllerProps): null {
const camera = useThree((state) => state.camera);
const movementLocked = useRepairMovementLocked();
const terrainHeight = useTerrainHeightSampler();
const movementLockedRef = useRef(movementLocked);
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
const velocity = useRef(new THREE.Vector3());
const fallDuration = useRef(0);
const onFloor = useRef(false);
const wantsJump = useRef(false);
const initializedRef = useRef(false);
const canMove = useGameStore((state) => state.missionFlow.canMove);
const currentSpeed = useGameStore((state) => state.player.currentSpeed);
const movementMode = useGameStore((state) => state.player.movementMode);
const movementModeRef = useRef(movementMode);
const prevMovementModeRef = useRef(movementMode);
const ebikeAngle = useRef(0);
const ebikeSpeedFactor = useRef(0);
const capsule = useRef(createSpawnCapsule(spawnPosition));
useEffect(() => {
movementModeRef.current = movementMode;
}, [movementMode]);
// eslint-disable-next-line react-hooks/immutability -- Three.js camera properties (position, rotation, fov) must be mutated directly; this is the standard pattern for R3F
useEffect(() => {
if (movementMode === "ebike") {
const targetPos: Vector3Tuple = window.ebikeParkedPosition ?? [0, 8.2, 0];
const targetRot: number = window.ebikeParkedRotation ?? 0;
const headY = targetPos[1] + PLAYER_EYE_HEIGHT;
const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS;
capsule.current.start.set(targetPos[0], bottomY, targetPos[2]);
capsule.current.end.set(targetPos[0], headY, targetPos[2]);
velocity.current.set(0, 0, 0);
onFloor.current = false;
wantsJump.current = false;
ebikeSpeedFactor.current = 0;
ebikeAngle.current = targetRot;
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
cameraOffset.applyAxisAngle(_up, targetRot);
const camPos = new THREE.Vector3()
.copy(capsule.current.end)
.add(cameraOffset);
camera.position.copy(camPos);
const pitchRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[0],
);
const yawRad =
THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) +
targetRot;
const rollRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[2],
);
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
} else if (
movementMode === "walk" &&
prevMovementModeRef.current === "ebike"
) {
const perspectiveCam = camera as THREE.PerspectiveCamera;
// eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes
perspectiveCam.fov = 60;
perspectiveCam.updateProjectionMatrix();
const rightDir = new THREE.Vector3();
camera.getWorldDirection(_forward);
_forward.setY(0).normalize();
rightDir.crossVectors(_forward, _up).normalize();
const shift = rightDir.multiplyScalar(3);
capsule.current.translate(shift);
camera.position.copy(capsule.current.end);
ebikeSpeedFactor.current = 0;
}
prevMovementModeRef.current = movementMode;
}, [movementMode, camera]);
useLayoutEffect(() => {
resetPlayerCapsule(
capsule.current,
spawnPosition,
camera,
velocity.current,
);
fallDuration.current = 0;
onFloor.current = false;
wantsJump.current = false;
initializedRef.current = true;
}, [camera, spawnPosition]);
useEffect(() => {
movementLockedRef.current = movementLocked;
if (!movementLocked) return;
keys.current = { ...DEFAULT_KEYS };
wantsJump.current = false;
velocity.current.setX(0);
velocity.current.setZ(0);
}, [movementLocked]);
useEffect(() => {
const interaction = InteractionManager.getInstance();
const handleKeyDown = (event: KeyboardEvent): void => {
if (isPlayerInputLocked()) return;
if (setMovementKey(keys.current, event.key, true)) {
if (movementLockedRef.current) {
keys.current = { ...DEFAULT_KEYS };
}
event.preventDefault();
return;
}
if (event.key === JUMP_KEY) {
if (movementLockedRef.current) {
wantsJump.current = false;
event.preventDefault();
return;
}
wantsJump.current = true;
event.preventDefault();
return;
}
if (event.key.toLowerCase() === INTERACT_KEY) {
if (interaction.getState().focused?.kind === "trigger") {
interaction.pressInteract();
}
event.preventDefault();
}
};
const handleKeyUp = (event: KeyboardEvent): void => {
if (isPlayerInputLocked()) return;
if (setMovementKey(keys.current, event.key, false)) {
event.preventDefault();
}
};
const handleMouseDown = (event: MouseEvent): void => {
if (isPlayerInputLocked()) return;
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
if (interaction.getState().focused?.kind === "grab") {
interaction.pressInteract();
}
};
const handleMouseUp = (event: MouseEvent): void => {
if (isPlayerInputLocked()) return;
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
if (interaction.getState().holding) {
interaction.releaseInteract();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
keys.current = { ...DEFAULT_KEYS };
};
}, []);
// eslint-disable-next-line react-hooks/immutability -- Three.js camera properties (position, rotation, fov) must be mutated directly in frame loop; this is the standard pattern for R3F game loops
useFrame((_, delta) => {
if (!initializedRef.current) return;
const dt = Math.min(delta, PLAYER_MAX_DELTA);
if (capsule.current.end.y < PLAYER_FALL_RESPAWN_Y) {
fallDuration.current += dt;
if (fallDuration.current >= PLAYER_FALL_RESPAWN_DELAY) {
resetPlayerCapsule(
capsule.current,
spawnPosition,
camera,
velocity.current,
);
fallDuration.current = 0;
onFloor.current = false;
wantsJump.current = false;
return;
}
} else {
fallDuration.current = 0;
}
if (isPlayerInputLocked() || !canMove) {
keys.current = { ...DEFAULT_KEYS };
velocity.current.set(0, 0, 0);
wantsJump.current = false;
return;
}
const isEbikeMounted = movementModeRef.current === "ebike";
const isEbikeBreakdown = window.ebikeBreakdownActive === true;
if (isEbikeMounted && !isEbikeBreakdown) {
const turnSpeed = 1.8;
if (keys.current.left) {
ebikeAngle.current += turnSpeed * dt;
}
if (keys.current.right) {
ebikeAngle.current -= turnSpeed * dt;
}
}
camera.getWorldDirection(_forward);
_forward.setY(0);
if (_forward.lengthSq() > 0) {
_forward.normalize();
_right.crossVectors(_forward, _up).normalize();
}
_wishDir.set(0, 0, 0);
if (!movementLocked && !isEbikeBreakdown) {
if (keys.current.forward) _wishDir.add(_forward);
if (keys.current.backward) _wishDir.sub(_forward);
if (!isEbikeMounted) {
if (keys.current.left) _wishDir.sub(_right);
if (keys.current.right) _wishDir.add(_right);
}
}
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
if (isEbikeMounted) {
const isDriveInputActive = _wishDir.lengthSq() > 0 && !isEbikeBreakdown;
const durationMs = isDriveInputActive
? EBIKE_ACCELERATION_DURATION_MS
: EBIKE_DECELERATION_DURATION_MS;
const factorDelta = durationMs > 0 ? (dt * 1000) / durationMs : 1;
ebikeSpeedFactor.current = THREE.MathUtils.clamp(
ebikeSpeedFactor.current +
(isDriveInputActive ? factorDelta : -factorDelta),
0,
1,
);
window.ebikeDriveInputActive = isDriveInputActive;
window.ebikeSpeedFactor = ebikeSpeedFactor.current;
} else {
window.ebikeDriveInputActive = false;
window.ebikeSpeedFactor = 0;
}
const movementSpeed = isEbikeMounted
? EBIKE_MAX_SPEED * ebikeSpeedFactor.current
: currentSpeed;
const accel = onFloor.current
? movementSpeed
: movementSpeed * PLAYER_AIR_CONTROL_FACTOR;
velocity.current.x +=
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
velocity.current.z +=
_wishDir.z * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
const damping = Math.exp(-PLAYER_XZ_DAMPING_FACTOR * dt);
velocity.current.x *= damping;
velocity.current.z *= damping;
if (
isEbikeMounted &&
isEbikeBreakdown &&
ebikeSpeedFactor.current <= 0.001 &&
Math.hypot(velocity.current.x, velocity.current.z) <= 0.05
) {
velocity.current.setX(0);
velocity.current.setZ(0);
useGameStore.getState().setPlayerMovementMode("walk");
return;
}
if (onFloor.current) {
velocity.current.y = Math.max(0, velocity.current.y);
if (wantsJump.current) {
velocity.current.y = PLAYER_JUMP_SPEED;
onFloor.current = false;
}
} else {
velocity.current.y -= PLAYER_GRAVITY * dt;
}
wantsJump.current = false;
_translateVec.copy(velocity.current).multiplyScalar(dt);
capsule.current.translate(_translateVec);
if (octree) {
onFloor.current = false;
for (let index = 0; index < PLAYER_COLLISION_ITERATIONS; index++) {
const result = octree.capsuleIntersect(capsule.current);
if (!result) break;
const isFloorCollision = result.normal.y > PLAYER_FLOOR_NORMAL_MIN;
onFloor.current ||= isFloorCollision;
const normalVelocity = result.normal.dot(velocity.current);
if (!isFloorCollision && normalVelocity < 0) {
velocity.current.addScaledVector(result.normal, -normalVelocity);
}
if (isFloorCollision) {
velocity.current.y = Math.max(0, velocity.current.y);
capsule.current.translate(
_collisionCorrection.set(0, result.depth / result.normal.y, 0),
);
continue;
}
capsule.current.translate(
_collisionCorrection.copy(result.normal).multiplyScalar(result.depth),
);
}
}
const groundHeight = terrainHeight.getHeight(
capsule.current.end.x,
capsule.current.end.z,
);
if (groundHeight !== null && velocity.current.y <= 0) {
const groundOffset = getCapsuleFootY(capsule.current) - groundHeight;
if (groundOffset <= PLAYER_GROUND_SNAP_DISTANCE) {
capsule.current.translate(
_collisionCorrection.set(0, -groundOffset, 0),
);
velocity.current.y = 0;
onFloor.current = true;
}
}
if (movementModeRef.current === "ebike") {
let targetSteer = 0;
if (keys.current.left) targetSteer = 1;
else if (keys.current.right) targetSteer = -1;
const currentSteer = window.ebikeSteerFactor ?? 0;
const steerFactor = THREE.MathUtils.lerp(
currentSteer,
targetSteer,
8 * dt,
);
window.ebikeSteerFactor = steerFactor;
const speed = velocity.current.length();
const targetFov = 60 + Math.min(speed * 0.35, 9);
const perspectiveCam = camera as THREE.PerspectiveCamera;
// eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes during frame updates
perspectiveCam.fov = THREE.MathUtils.lerp(
perspectiveCam.fov,
targetFov,
6 * dt,
);
perspectiveCam.updateProjectionMatrix();
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
const swingX = -Math.abs(steerFactor) * 1.5;
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
cameraOffset.add(cameraSwing);
const targetCamPos = new THREE.Vector3()
.copy(capsule.current.end)
.add(cameraOffset);
camera.position.lerp(targetCamPos, 12 * dt);
const pitchRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[0],
);
const yawRad =
THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) +
ebikeAngle.current;
const rollRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[2],
);
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
const ebikeVisual = window.ebikeVisualGroup?.current;
if (ebikeVisual) {
ebikeVisual.position.set(
capsule.current.end.x,
capsule.current.end.y - PLAYER_EYE_HEIGHT,
capsule.current.end.z,
);
const leanAngle = steerFactor * 0.26;
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
}
} else {
camera.position.copy(capsule.current.end);
}
window.playerPos = [
capsule.current.end.x,
capsule.current.end.y,
capsule.current.end.z,
];
window.ebikeAngle = ebikeAngle.current;
});
return null;
}