import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useFrame } from '@react-three/fiber' import { useGLTF, useTexture, PositionalAudio } from '@react-three/drei' import { SkeletonUtils } from 'three-stdlib' import * as THREE from 'three' import { useButterflyStore } from './store' import { useAudioReady } from '../audio/useAudioReady' import { useAudioStore } from '../../store/audio' const GLB_URL = '/butterfly/butterfly-loop.glb' const TEX_URLS = [ '/butterfly/butterfly1.jpg', '/butterfly/butterfly2.jpg', '/butterfly/butterfly4.jpg', '/butterfly/butterfly5.jpg', ] const FLAP_URLS = Array.from({ length: 8 }, (_, i) => `/butterfly/sfx/moth-flap-${i + 1}.wav`) const MODEL_SCALE = 0.25 const SPEED_SMOOTH = 6 // 1/seconds, exponential damping for measured speed interface Props { visible?: boolean withAudio?: boolean intensityRef?: React.RefObject } export const Butterfly = forwardRef(function Butterfly( { visible = true, withAudio = false, intensityRef }, ref, ) { const { scene, animations } = useGLTF(GLB_URL) const [scale] = useState(MODEL_SCALE * (Math.random() * 0.25 + 0.75)) const [textureUrl] = useState(() => TEX_URLS[Math.floor(Math.random() * TEX_URLS.length)]) const texture = useTexture(textureUrl) const groupRef = useRef(null) useImperativeHandle(ref, () => groupRef.current as THREE.Group) const [flapUrl] = useState(() => FLAP_URLS[Math.floor(Math.random() * FLAP_URLS.length)]) const [sinePhase] = useState(() => Math.random() * Math.PI * 2) const audioRef = useRef(null) const audioReady = useAudioReady() const flapMixRef = useRef(0) const tRef = useRef(0) const lastPos = useRef(new THREE.Vector3()) const smoothedSpeed = useRef(0) const seededRef = useRef(false) const { clone, mixer, speedMul, material } = useMemo(() => { const cloned = SkeletonUtils.clone(scene) as THREE.Group const mat = new THREE.MeshStandardMaterial({ map: texture, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide, roughness: 0.25, }) cloned.traverse((c) => { const mesh = c as THREE.Mesh if (mesh.isMesh) mesh.material = mat }) const m = new THREE.AnimationMixer(cloned) if (animations.length > 0) { const clip = animations[0] const action = m.clipAction(clip) action.setLoop(THREE.LoopRepeat, Infinity) action.clampWhenFinished = false action.play() m.update(Math.random() * clip.duration) } return { clone: cloned, mixer: m, speedMul: 0.7 + Math.random() * 0.6, material: mat } }, [scene, animations, texture]) useEffect(() => () => { mixer.stopAllAction() material.dispose() }, [material, mixer]) useFrame((_, dtRaw) => { const dt = Math.min(dtRaw, 0.05) const g = groupRef.current if (g) { if (!seededRef.current) { lastPos.current.copy(g.position) seededRef.current = true } const inst = g.position.distanceTo(lastPos.current) / Math.max(dt, 1e-4) lastPos.current.copy(g.position) const a = 1 - Math.exp(-SPEED_SMOOTH * dt) smoothedSpeed.current += (inst - smoothedSpeed.current) * a } const p = useButterflyStore.getState() const flapMul = p.flapSpeedBase + p.flapSpeedVelScale * smoothedSpeed.current mixer.update(dt * speedMul * flapMul) if (audioRef.current) { tRef.current += dt const intensity = Math.max(0, Math.min(1, intensityRef?.current ?? 0)) const k = 1 - Math.exp(-p.flapMoveLerp * dt) flapMixRef.current += (intensity - flapMixRef.current) * k const sine = 0.5 + 0.5 * Math.sin(tRef.current * p.flapSineFreq + sinePhase) const env = 1 - p.flapSineDepth + p.flapSineDepth * sine const muted = useAudioStore.getState().muted audioRef.current.setVolume(muted ? 0 : flapMixRef.current * env * p.flapVolume) audioRef.current.setRefDistance(p.flapRefDistance) } }) return ( {audioReady && withAudio && ( )} ) }) useGLTF.preload(GLB_URL) TEX_URLS.forEach((url) => useTexture.preload(url))