import { useEffect, useMemo, useRef } from "react"; import { useFrame, useThree } from "@react-three/fiber"; import { CapsuleCollider, RigidBody, useRapier, type RapierRigidBody, } from "@react-three/rapier"; import { Object3D, Quaternion, Vector3 } from "three"; import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { classById } from "../app/characterCatalog"; import { useShellStore } from "../app/shellStore"; import type { CharacterProfile } from "../app/types"; import { CharacterModel } from "../avatar/CharacterModel"; import { ProceduralAvatar } from "../avatar/ProceduralAvatar"; import { clearJumpRequest, consumeJumpRequest, consumeMouseLook, readInputSnapshot, } from "../game/inputManager"; import { useCombatStore } from "../game/combatStore"; import type { CharacterCombatAnimationEvent } from "../game/combatAnimation"; import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID, } from "../game/equipment"; import type { InventoryItem } from "../game/lootTypes"; import { cameraRelativeMovement, cameraYawAfterHorizontalLook, clampCameraPitch, DEFAULT_CAMERA_PITCH, planarDistance, wrapAngle, } from "../game/inputMath"; import { isBelowDungeonRecoveryPlane, isBelowManastormRecoveryPlane, } from "../game/playerRecovery"; import { PLAYER_GROUND_PROBE_DISTANCE, PLAYER_JUMP_VELOCITY, bufferPlayerJump, cancelBufferedPlayerJump, createCharacterVerticalPresentationTiming, createPlayerJumpTiming, isWalkableGroundHit, updateCharacterVerticalPresentation, updatePlayerJumpTiming, type CharacterVerticalMotion, } from "../game/playerJump"; import { PLAYER_CHARACTER_CONTROLLER_OFFSET, PLAYER_GROUND_STICK_VELOCITY, airbornePlayerVerticalVelocity, configurePlayerCharacterController, playerFrameDeltaSeconds, } from "../game/playerMovement"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { useGameStore } from "../game/store"; const PLAYER_RADIUS = 0.36; const PLAYER_CAPSULE_HALF_HEIGHT = 0.48; const PLAYER_CENTER_HEIGHT = PLAYER_RADIUS + PLAYER_CAPSULE_HALF_HEIGHT; const MOVE_SPEED = 4.8; const CAMERA_DISTANCE = 5.8; const CAMERA_FOCUS_OFFSET = 0.45; const CAMERA_COLLISION_RADIUS = 0.24; const CAMERA_WALL_GAP = 0.08; const CAMERA_PULLBACK_DAMPING = 7; const AVATAR_HIDE_DISTANCE = 0.6; interface OrbitState { yaw: number; pitch: number; } interface PlayerRigRefs { bodyRef: React.RefObject; orbitRef: React.MutableRefObject; avatarRef: React.RefObject; } interface PlayerControllerProps extends PlayerRigRefs { identity: CharacterProfile | null; equipmentItems: readonly InventoryItem[]; movingRef: React.MutableRefObject; verticalMotionRef: React.MutableRefObject; animationEventRef: React.MutableRefObject; } function placeAtActiveSpawn(body: RapierRigidBody, orbit: OrbitState): void { const spawn = useGameStore.getState().activeSpawn; const translation = { x: spawn.footPosition[0], y: spawn.footPosition[1] + PLAYER_CENTER_HEIGHT, z: spawn.footPosition[2], }; body.setTranslation(translation, true); body.setNextKinematicTranslation(translation); body.setLinvel({ x: 0, y: 0, z: 0 }, true); body.setAngvel({ x: 0, y: 0, z: 0 }, true); orbit.yaw = spawn.yaw; orbit.pitch = DEFAULT_CAMERA_PITCH; } function PlayerController({ bodyRef, orbitRef, avatarRef, identity, equipmentItems, movingRef, verticalMotionRef, animationEventRef, }: PlayerControllerProps) { const paused = useGameStore((state) => state.paused); const mapOpen = useGameStore((state) => state.mapOpen); const companionOpen = useGameStore((state) => state.companionOpen); const resetRevision = useGameStore((state) => state.resetRevision); const reportAccumulator = useRef(0); const activeDungeonId = useGameStore((state) => state.activeDungeonId); const gameMode = useGameStore((state) => state.gameMode); const controlledUntil = useCombatStore((state) => state.controlledUntil); const spawn = useGameStore((state) => state.activeSpawn); const dungeon = requireDungeonDefinition(activeDungeonId); const lastReported = useRef<[number, number, number]>([...spawn.footPosition]); const { rapier, world } = useRapier(); const groundRay = useMemo( () => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }), [rapier], ); const characterController = useMemo( () => configurePlayerCharacterController( world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET), ), [world], ); const jumpTimingRef = useRef(createPlayerJumpTiming()); const verticalPresentationTimingRef = useRef(createCharacterVerticalPresentationTiming()); const verticalVelocityRef = useRef(0); useEffect(() => () => { world.removeCharacterController(characterController); }, [characterController, world]); useEffect(() => { if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current); lastReported.current = [...spawn.footPosition]; clearJumpRequest(); jumpTimingRef.current = createPlayerJumpTiming(performance.now()); verticalPresentationTimingRef.current = createCharacterVerticalPresentationTiming(); verticalVelocityRef.current = 0; verticalMotionRef.current = "grounded"; }, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]); useFrame((_, delta) => { const body = bodyRef.current; if (!body) return; const snapshot = readInputSnapshot(); const [mouseX, mouseY] = consumeMouseLook(); const orbit = orbitRef.current; // Never let a malformed browser/controller event poison the persistent // orbit state. A NaN camera matrix makes the scene look like it froze on // the dark-green clear color even though the render loop is still alive. if (!Number.isFinite(orbit.yaw)) orbit.yaw = spawn.yaw; if (!Number.isFinite(orbit.pitch)) orbit.pitch = DEFAULT_CAMERA_PITCH; if (!paused && !mapOpen && !companionOpen) { // Read this flag imperatively inside the frame so beginning or ending a // right-mouse drag updates the DOM HUD without reconciling the Rapier // player subtree. if (useGameStore.getState().cameraLookActive) { orbit.yaw = cameraYawAfterHorizontalLook(orbit.yaw, mouseX * 0.0021); orbit.pitch = clampCameraPitch(orbit.pitch + mouseY * 0.0017); } orbit.yaw = cameraYawAfterHorizontalLook(orbit.yaw, snapshot.lookX * delta * 2.25); orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55); } const translation = body.translation(); if (![translation.x, translation.y, translation.z].every(Number.isFinite)) { placeAtActiveSpawn(body, orbit); return; } const nowMs = performance.now(); groundRay.origin.x = translation.x; groundRay.origin.y = translation.y; groundRay.origin.z = translation.z; const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE; const groundHit = world.castRayAndGetNormal( groundRay, groundProbeLength, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, undefined, undefined, body, ); const groundedBeforeMove = verticalVelocityRef.current <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength); const requestedAtMs = consumeJumpRequest(); const gameplayBlocked = paused || mapOpen || companionOpen || useCombatStore.getState().health <= 0 || controlledUntil > Date.now(); if (gameplayBlocked) { cancelBufferedPlayerJump(jumpTimingRef.current); movingRef.current = false; verticalMotionRef.current = updateCharacterVerticalPresentation( verticalPresentationTimingRef.current, groundedBeforeMove, verticalVelocityRef.current, nowMs, false, ); body.setNextKinematicTranslation(translation); return; } if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs); const shouldJump = updatePlayerJumpTiming( jumpTimingRef.current, nowMs, groundedBeforeMove, verticalVelocityRef.current, ); const movementDeltaSeconds = playerFrameDeltaSeconds(delta); let verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : groundedBeforeMove ? PLAYER_GROUND_STICK_VELOCITY : airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds); const [worldX, worldZ] = cameraRelativeMovement( snapshot.moveX, snapshot.moveForward, orbit.yaw, ); if (body.numColliders() === 0) return; characterController.computeColliderMovement( body.collider(0), { x: worldX * MOVE_SPEED * movementDeltaSeconds, y: verticalVelocity * movementDeltaSeconds, z: worldZ * MOVE_SPEED * movementDeltaSeconds, }, rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, ); const computedMovement = characterController.computedMovement(); const nextTranslation = { x: translation.x + computedMovement.x, y: translation.y + computedMovement.y, z: translation.z + computedMovement.z, }; body.setNextKinematicTranslation(nextTranslation); const grounded = !shouldJump && characterController.computedGrounded(); if (grounded && verticalVelocity <= 0) verticalVelocity = 0; verticalVelocityRef.current = verticalVelocity; verticalMotionRef.current = updateCharacterVerticalPresentation( verticalPresentationTimingRef.current, grounded, verticalVelocity, nowMs, shouldJump, ); const moving = Math.hypot(worldX, worldZ) > 0.05; movingRef.current = moving; if (avatarRef.current && moving) { const desiredYaw = Math.atan2(worldX, worldZ); const difference = wrapAngle(desiredYaw - avatarRef.current.rotation.y); avatarRef.current.rotation.y += difference * (1 - Math.exp(-14 * delta)); } const activeManastormPackage = gameMode === "manastorm" ? activeManastormStageAssetPackage() : null; const belowRecoveryPlane = activeManastormPackage ? isBelowManastormRecoveryPlane( nextTranslation.y, activeManastormPackage.anchors.map((anchor) => anchor.position[1]), dungeon.bounds, ) : isBelowDungeonRecoveryPlane(nextTranslation.y, dungeon.bounds); if (belowRecoveryPlane) { useGameStore.getState().resetAtActiveSpawn(); return; } reportAccumulator.current += delta; if (reportAccumulator.current >= 0.1) { reportAccumulator.current = 0; const footPosition: [number, number, number] = [ nextTranslation.x, nextTranslation.y - PLAYER_CENTER_HEIGHT, nextTranslation.z, ]; const distanceDelta = planarDistance(lastReported.current, footPosition); useGameStore.getState().updatePlayerSnapshot({ position: footPosition, yaw: orbit.yaw, distanceDelta, grounded, }); lastReported.current = footPosition; } }); return ( } /> ); } function ThirdPersonCamera({ bodyRef, orbitRef, avatarRef, }: PlayerRigRefs) { const { camera } = useThree(); const { rapier, world } = useRapier(); const desired = useMemo(() => new Vector3(), []); const safeDesired = useMemo(() => new Vector3(), []); const boomDirection = useMemo(() => new Vector3(), []); const target = useMemo(() => new Vector3(), []); const lookAt = useMemo(() => new Vector3(), []); const cameraShape = useMemo( () => new rapier.Ball(CAMERA_COLLISION_RADIUS), [rapier], ); const shapeRotation = useMemo(() => new Quaternion(), []); const boomDistance = useRef(CAMERA_DISTANCE); useFrame((_, delta) => { const body = bodyRef.current; if (!body) return; const translation = body.translation(); if (![translation.x, translation.y, translation.z].every(Number.isFinite)) return; const orbit = orbitRef.current; if (!Number.isFinite(orbit.yaw)) orbit.yaw = requireDungeonDefinition(useGameStore.getState().activeDungeonId).entrance.yaw; if (!Number.isFinite(orbit.pitch)) orbit.pitch = DEFAULT_CAMERA_PITCH; const { yaw, pitch } = orbit; const horizontalDistance = CAMERA_DISTANCE * Math.cos(pitch); target.set(translation.x, translation.y + CAMERA_FOCUS_OFFSET, translation.z); desired.set( target.x - Math.sin(yaw) * horizontalDistance, target.y + 0.8 + CAMERA_DISTANCE * Math.sin(pitch), target.z - Math.cos(yaw) * horizontalDistance, ); boomDirection.copy(desired).sub(target); const fullDistance = boomDirection.length(); if (!Number.isFinite(fullDistance) || fullDistance <= 1e-5) return; boomDirection.multiplyScalar(1 / fullDistance); // Cast a small sphere instead of a single ray. This behaves like a camera // spring arm and keeps the whole near-camera volume out of walls, pillars, // and low ceilings. The player's own capsule is explicitly ignored. const hit = world.castShape( target, shapeRotation, boomDirection, cameraShape, CAMERA_WALL_GAP, fullDistance, false, rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, undefined, undefined, body, ); const timeOfImpact = hit?.time_of_impact; const allowedDistance = typeof timeOfImpact === "number" && Number.isFinite(timeOfImpact) ? Math.max(0, Math.min(fullDistance, timeOfImpact)) : fullDistance; if (!Number.isFinite(boomDistance.current)) boomDistance.current = allowedDistance; // Pull in immediately so no smoothing frame can cross a wall. Ease back // out after the obstruction clears to avoid a distracting camera pop. if (allowedDistance < boomDistance.current) { boomDistance.current = allowedDistance; } else { const pullbackAlpha = 1 - Math.exp(-CAMERA_PULLBACK_DAMPING * Math.min(delta, 0.05)); boomDistance.current += (allowedDistance - boomDistance.current) * pullbackAlpha; } if (avatarRef.current) { avatarRef.current.visible = boomDistance.current >= AVATAR_HIDE_DISTANCE; } safeDesired.copy(target).addScaledVector(boomDirection, boomDistance.current); camera.position.copy(safeDesired); lookAt.set( target.x + Math.sin(yaw) * 1.25, target.y + 0.08, target.z + Math.cos(yaw) * 1.25, ); camera.lookAt(lookAt); // Keep the default frame priority. A positive priority takes ownership of // R3F's render loop and requires this callback to call gl.render() itself. }); return null; } export function PlayerRig() { const activeCharacter = useShellStore((state) => state.activeCharacter); const animationEvent = useCombatStore((state) => state.playerAnimationEvent); const inventory = useCombatStore((state) => state.inventory); const equipment = useCombatStore((state) => state.equipment); const equipmentItems = useMemo( () => equipmentItemsForOwner( equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, ), [equipment, inventory], ); const bodyRef = useRef(null); const avatarRef = useRef(null); const movingRef = useRef(false); const verticalMotionRef = useRef("grounded"); const animationEventRef = useRef(animationEvent); animationEventRef.current = animationEvent; const activeDungeonId = useGameStore((state) => state.activeDungeonId); const spawn = useGameStore((state) => state.activeSpawn); const orbitRef = useRef({ yaw: spawn.yaw, pitch: DEFAULT_CAMERA_PITCH, }); return ( <> ); }