import { useRef, useEffect, useState, forwardRef, useImperativeHandle, useCallback } from 'react' import { useFrame, useThree } from '@react-three/fiber' import { RigidBody, CapsuleCollider, useRapier } from '@react-three/rapier' import * as THREE from 'three' import { useCameraGestures } from '../camera/useCameraGestures' import { useDebugStore } from '../../store/debug' import { isEditableTarget } from '../../utils/dom' import { CAMERA_EYE_OFFSET, CHARACTER_BODY_SPAWN, CHARACTER_BODY_SPAWN_POSITION, CHARACTER_HEIGHT, } from './spawn' export interface CharacterControllerHandle { reset: () => void } const SPEED = 4 const JUMP_VELOCITY = 3 const MOUSE_SMOOTHING = 0.12 const DOLLY_UNITS_PER_PIXEL = 0.01 const CHARACTER_RADIUS = 0.25 const CHARACTER_HALF_SEGMENT = CHARACTER_HEIGHT / 2 - CHARACTER_RADIUS const GROUND_CHECK_DISTANCE = CAMERA_EYE_OFFSET + 0.08 const DEFAULT_YAW = 0 const _forward = new THREE.Vector3() const _right = new THREE.Vector3() const _dollyForward = new THREE.Vector3() const _move = new THREE.Vector3() const _euler = new THREE.Euler(0, 0, 0, 'YXZ') export const CharacterController = forwardRef( function CharacterController(_, ref) { const bodyRef = useRef>(null) const { camera, gl } = useThree() const mouseSensitivity = useDebugStore((s) => s.flyMouseSensitivity) const { rapier, world } = useRapier() const keys = useRef(new Set()) const jumpQueued = useRef(false) const rawYaw = useRef(DEFAULT_YAW) const rawPitch = useRef(0) const smoothYaw = useRef(DEFAULT_YAW) const smoothPitch = useRef(0) const touchLook = useRef<{ id: number; x: number; y: number } | null>(null) const touchMove = useRef<{ id: number; x: number; y: number } | null>(null) const [joystickPos, setJoystickPos] = useState({ x: 0, y: 0 }) useImperativeHandle(ref, () => ({ reset: () => { if (!bodyRef.current) return let spawnX = CHARACTER_BODY_SPAWN.x let spawnY = CHARACTER_BODY_SPAWN.y let spawnZ = CHARACTER_BODY_SPAWN.z let spawnYaw = DEFAULT_YAW let spawnPitch = 0 if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search) const spawnParam = params.get('spawn') if (spawnParam) { const parts = spawnParam.split(',').map(parseFloat) if (parts.length >= 3 && !parts.slice(0, 3).some(isNaN)) { spawnX = parts[0] spawnY = parts[1] - CAMERA_EYE_OFFSET spawnZ = parts[2] } if (parts.length >= 5 && !parts.slice(3, 5).some(isNaN)) { spawnYaw = parts[3] spawnPitch = parts[4] } } } bodyRef.current.setTranslation({ x: spawnX, y: spawnY, z: spawnZ }, true) bodyRef.current.setLinvel({ x: 0, y: 0, z: 0 }, true) bodyRef.current.setAngvel({ x: 0, y: 0, z: 0 }, true) rawYaw.current = spawnYaw rawPitch.current = spawnPitch smoothYaw.current = spawnYaw smoothPitch.current = spawnPitch keys.current.clear() camera.quaternion.setFromEuler(_euler.set(spawnPitch, spawnYaw, 0)) }, })) const applyDolly = useCallback((deltaY: number) => { const body = bodyRef.current if (!body) return _dollyForward.set(0, 0, -1).applyQuaternion(camera.quaternion).setY(0) if (_dollyForward.lengthSq() < 0.0001) return _dollyForward.normalize() const amount = -deltaY * DOLLY_UNITS_PER_PIXEL const position = body.translation() body.setTranslation( { x: position.x + _dollyForward.x * amount, y: position.y, z: position.z + _dollyForward.z * amount, }, true, ) body.wakeUp() }, [camera]) const applyLook = useCallback((dx: number, dy: number) => { rawYaw.current -= dx * mouseSensitivity rawPitch.current -= dy * mouseSensitivity rawPitch.current = Math.max(-Math.PI / 2.2, Math.min(Math.PI / 2.2, rawPitch.current)) }, [mouseSensitivity]) useCameraGestures({ domElement: gl.domElement, onDollyPixels: applyDolly, onTumblePixels: applyLook }) useEffect(() => { const onKey = (e: KeyboardEvent) => { if (isEditableTarget(e.target)) { keys.current.delete(e.code) return } if (e.type === 'keydown') { keys.current.add(e.code) if (e.code === 'Space' && !e.repeat) { jumpQueued.current = true e.preventDefault() } } else { keys.current.delete(e.code) } } window.addEventListener('keydown', onKey) window.addEventListener('keyup', onKey) // Touch handlers const onTouchStart = (e: TouchEvent) => { if (e.touches.length >= 2) return for (const touch of Array.from(e.changedTouches)) { const isLeft = touch.clientX < window.innerWidth / 2 if (isLeft && !touchMove.current) { touchMove.current = { id: touch.identifier, x: touch.clientX, y: touch.clientY } setJoystickPos({ x: touch.clientX, y: touch.clientY }) } else if (!isLeft && !touchLook.current) { touchLook.current = { id: touch.identifier, x: touch.clientX, y: touch.clientY } } } } const onTouchMove = (e: TouchEvent) => { if (e.touches.length >= 2) return for (const touch of Array.from(e.changedTouches)) { if (touchLook.current?.id === touch.identifier) { const dx = touch.clientX - touchLook.current.x const dy = touch.clientY - touchLook.current.y rawYaw.current -= dx * 0.004 rawPitch.current -= dy * 0.004 rawPitch.current = Math.max(-Math.PI / 2.2, Math.min(Math.PI / 2.2, rawPitch.current)) touchLook.current = { id: touch.identifier, x: touch.clientX, y: touch.clientY } } if (touchMove.current?.id === touch.identifier) { touchMove.current = { ...touchMove.current, x: touch.clientX, y: touch.clientY } } } } const onTouchEnd = (e: TouchEvent) => { for (const touch of Array.from(e.changedTouches)) { if (touchLook.current?.id === touch.identifier) touchLook.current = null if (touchMove.current?.id === touch.identifier) { touchMove.current = null setJoystickPos({ x: 0, y: 0 }) } } } gl.domElement.addEventListener('touchstart', onTouchStart, { passive: true }) gl.domElement.addEventListener('touchmove', onTouchMove, { passive: true }) gl.domElement.addEventListener('touchend', onTouchEnd, { passive: true }) return () => { window.removeEventListener('keydown', onKey) window.removeEventListener('keyup', onKey) gl.domElement.removeEventListener('touchstart', onTouchStart) gl.domElement.removeEventListener('touchmove', onTouchMove) gl.domElement.removeEventListener('touchend', onTouchEnd) } }, [gl.domElement]) useFrame((_state, delta) => { const body = bodyRef.current if (!body) return const smoothing = 1 - Math.pow(1 - MOUSE_SMOOTHING, delta * 60) smoothYaw.current += (rawYaw.current - smoothYaw.current) * smoothing smoothPitch.current += (rawPitch.current - smoothPitch.current) * smoothing _euler.set(smoothPitch.current, smoothYaw.current, 0) camera.quaternion.setFromEuler(_euler) // Compute move direction from keys + touch joystick let fwd = 0, strafe = 0 if (keys.current.has('KeyW') || keys.current.has('ArrowUp')) fwd += 1 if (keys.current.has('KeyS') || keys.current.has('ArrowDown')) fwd -= 1 if (keys.current.has('KeyA') || keys.current.has('ArrowLeft')) strafe -= 1 if (keys.current.has('KeyD') || keys.current.has('ArrowRight')) strafe += 1 if (touchMove.current) { const origin = joystickPos const dx = (touchMove.current.x - origin.x) / 50 const dy = (touchMove.current.y - origin.y) / 50 strafe += Math.max(-1, Math.min(1, dx)) fwd -= Math.max(-1, Math.min(1, dy)) } _forward.set(0, 0, -1).applyQuaternion(camera.quaternion).setY(0).normalize() _right.set(1, 0, 0).applyQuaternion(camera.quaternion).setY(0).normalize() _move.set(0, 0, 0).addScaledVector(_forward, fwd).addScaledVector(_right, strafe) if (_move.lengthSq() > 1) _move.normalize() const vel = body.linvel() let nextY = vel.y if (jumpQueued.current) { const pos = body.translation() const ray = new rapier.Ray( { x: pos.x, y: pos.y, z: pos.z }, { x: 0, y: -1, z: 0 }, ) const grounded = Boolean(world.castRay(ray, GROUND_CHECK_DISTANCE, true, undefined, undefined, undefined, body)) if (grounded) nextY = JUMP_VELOCITY jumpQueued.current = false } body.setLinvel({ x: _move.x * SPEED, y: nextY, z: _move.z * SPEED }, true) // Sync camera position to body const pos = body.translation() camera.position.set(pos.x, pos.y + CAMERA_EYE_OFFSET, pos.z) }) return ( ) })