import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { useFrame, useThree } from '@react-three/fiber' import { RigidBody, BallCollider, useRapier, type RapierRigidBody, } from '@react-three/rapier' import * as THREE from 'three' import { useButterflyInput } from './useButterflyInput' import { OrbitCamera } from './OrbitCamera' import { BoidsFlock, type BoidsFlockHandle } from './BoidsFlock' import { AmbientAudio } from './AmbientAudio' import { WindAudio } from './WindAudio' import { useButterflyStore } from './store' interface MarkerProps { posRef: React.RefObject color: number } function DebugMarker({ posRef, color }: MarkerProps) { const meshRef = useRef(null) useFrame(() => { if (meshRef.current && posRef.current) meshRef.current.position.copy(posRef.current) }) return ( ) } export interface ButterflyControllerHandle { reset: () => void } const _forward = new THREE.Vector3() const _right = new THREE.Vector3() const _move = new THREE.Vector3() const _up = new THREE.Vector3(0, 1, 0) const DEFAULT_SPAWN = new THREE.Vector3(0, 1, -0.5) export const ButterflyController = forwardRef( function ButterflyController(_props, ref) { const bodyRef = useRef(null) const flockRef = useRef(null) const { camera } = useThree() const input = useButterflyInput() const showDebug = useButterflyStore((s) => s.showDebug) const { world } = useRapier() type CharCtrl = ReturnType const controllerRef = useRef(null) useEffect(() => { const c = world.createCharacterController(0.01) c.setSlideEnabled(true) c.enableAutostep(0, 0, false) c.disableSnapToGround() c.setApplyImpulsesToDynamicBodies(true) controllerRef.current = c return () => { try { world.removeCharacterController(c) } catch { // The Rapier world may already be tearing down during route changes. } controllerRef.current = null } }, [world]) const targetPos = useRef(DEFAULT_SPAWN.clone()) const pivotPos = useRef(DEFAULT_SPAWN.clone()) const flockCentroid = useRef(new THREE.Vector3()) const cameraPosRef = useRef(new THREE.Vector3()) const moveIntensityRef = useRef(0) const angularIntensityRef = useRef(0) const prevYawRef = useRef(0) const prevPitchRef = useRef(0) const prevSeededRef = useRef(false) const reset = () => { const params = useButterflyStore.getState() targetPos.current.copy(DEFAULT_SPAWN) pivotPos.current.copy(DEFAULT_SPAWN) input.yaw.current = params.defaultYaw input.pitch.current = params.defaultPitch input.distance.current = params.defaultDistance input.targetDistance.current = params.defaultDistance input.keys.current.clear() input.touchMoveVec.current.x = 0 input.touchMoveVec.current.y = 0 bodyRef.current?.setTranslation(DEFAULT_SPAWN, true) bodyRef.current?.setLinvel({ x: 0, y: 0, z: 0 }, true) flockRef.current?.reset(targetPos.current) } useImperativeHandle(ref, () => ({ reset })) useFrame((_, dtRaw) => { const body = bodyRef.current if (!body) return const dt = Math.min(dtRaw, 0.05) const params = useButterflyStore.getState() let fwdAmt = 0 let rightAmt = 0 let upAmt = 0 let yawAmt = 0 const k = input.keys.current if (k.has('KeyW') || k.has('ArrowUp')) fwdAmt += 1 if (k.has('KeyS') || k.has('ArrowDown')) fwdAmt -= 1 if (k.has('KeyA') || k.has('ArrowLeft')) yawAmt += 1 if (k.has('KeyD') || k.has('ArrowRight')) yawAmt -= 1 if (k.has('KeyE')) upAmt += 1 if (k.has('KeyQ')) upAmt -= 1 rightAmt += input.touchMoveVec.current.x fwdAmt += input.touchMoveVec.current.y if (yawAmt !== 0) input.yaw.current += yawAmt * params.rotationSpeed * dt camera.getWorldDirection(_forward) _right.copy(_forward).cross(_up).normalize() _move .set(0, 0, 0) .addScaledVector(_forward, fwdAmt) .addScaledVector(_right, rightAmt) if (_move.lengthSq() > 1) _move.normalize() _move.multiplyScalar(params.flightSpeed) _move.y += upAmt * params.flightSpeed const cur = body.translation() const desired = { x: _move.x * dt, y: _move.y * dt, z: _move.z * dt } const controller = controllerRef.current const collider = body.collider(0) let corrected = desired if (controller && collider) { controller.computeColliderMovement(collider, desired) corrected = controller.computedMovement() } const nx = cur.x + corrected.x const ny = cur.y + corrected.y const nz = cur.z + corrected.z body.setNextKinematicTranslation({ x: nx, y: ny, z: nz }) targetPos.current.set(nx, ny, nz) cameraPosRef.current.copy(camera.position) const inputMag = Math.min( 1, Math.hypot(fwdAmt, rightAmt, upAmt) + Math.abs(yawAmt) * 0.5, ) moveIntensityRef.current = inputMag if (!prevSeededRef.current) { prevYawRef.current = input.yaw.current prevPitchRef.current = input.pitch.current prevSeededRef.current = true } const dy = input.yaw.current - prevYawRef.current const dp = input.pitch.current - prevPitchRef.current prevYawRef.current = input.yaw.current prevPitchRef.current = input.pitch.current const angSpeed = Math.hypot(dy, dp) / Math.max(dt, 1e-4) const angIntensity = Math.min(1, angSpeed / Math.max(0.0001, params.windAngularThreshold)) angularIntensityRef.current = angIntensity }) return ( <> {showDebug && ( )} {showDebug && ( <> )} ) }, )