import { useEffect, useRef } from 'react'; import { useFrame, useLoader, useThree } from '@react-three/fiber'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; import { createVRMAnimationClip, VRMAnimationLoaderPlugin } from '@pixiv/three-vrm-animation'; import { AnimationMixer, Vector3, Quaternion } from 'three'; import * as THREE from 'three'; import { keys, adminMode } from './controls/KeyInput'; const BASE_URL = import.meta.env.BASE_URL; export const VRMA_FILES = [ { name: 'idle', label: '待機', file: 'idle.vrma', loop: true }, { name: 'fly_idle', label: '飛行待機', file: 'fly_idle.vrma', loop: true }, { name: 'fly', label: '飛行', file: 'fly.vrma', loop: true }, { name: 'fly_stop', label: '飛行停止', file: 'fly_stop.vrma', loop: false }, { name: 'fly_dodge', label: '回避', file: 'fly_dodge.vrma', loop: false }, { name: 'jump', label: 'ジャンプ', file: 'jump.vrma', loop: false }, { name: 'attack', label: '攻撃', file: 'attack.vrma', loop: false }, { name: 'damage', label: 'ダメージ', file: 'damage.vrma', loop: false }, { name: 'skill', label: 'スキル', file: 'skill_end.vrma', loop: false }, { name: 'skill_loop', label: 'スキル継続', file: 'skill_loop.vrma', loop: true }, { name: 'skill_end', label: 'スキル終了', file: 'skill_end.vrma', loop: false }, ]; const VRMA_URLS = VRMA_FILES.map(v => `${BASE_URL}animation/${v.file}`); const TRIM_DURATION_LOOP = 3.4; // ループアニメーションの長さ const TRIM_DURATION_ONCE = 2.0; // 技(oneshot)アニメーションの長さ function trimClip(clip, duration, addLoopPose) { const fade = 0.3; const trimEnd = duration + (addLoopPose ? fade : 0); clip.tracks.forEach(track => { const times = track.times; const valStride = track.values.length / times.length; let cutIndex = times.length; for (let j = 0; j < times.length; j++) { if (times[j] > trimEnd) { cutIndex = j; break; } } const newTimes = Array.from(times.slice(0, cutIndex)); const newValues = Array.from(track.values.slice(0, cutIndex * valStride)); if (addLoopPose) { newTimes.push(duration); for (let v = 0; v < valStride; v++) { newValues.push(track.values[v]); } } track.times = new Float32Array(newTimes); track.values = new Float32Array(newValues); }); clip.duration = duration; return clip; } export default function VrmCharacter({ selectedAnimation: animState, vrmModel = 'ai.vrm' }) { const selectedAnimation = animState?.name || animState; const mixerRef = useRef(null); const vrmRef = useRef(null); const actionsRef = useRef({}); const currentActionRef = useRef(null); const vrmUrl = `${BASE_URL}model/${vrmModel}`; const gltf = useLoader(GLTFLoader, vrmUrl, (loader) => { loader.register((parser) => new VRMLoaderPlugin(parser)); }); const vrmaGltfs = useLoader(GLTFLoader, VRMA_URLS, (loader) => { loader.register((parser) => new VRMAnimationLoaderPlugin(parser)); }); useEffect(() => { const vrm = gltf.userData.vrm; vrmRef.current = vrm; VRMUtils.removeUnnecessaryJoints(vrm.scene); // SpringBone: centerをRotatingStage(親group)に設定し、回転に追従させる const sbm = vrm.springBoneManager; if (sbm) { const stageGroup = vrm.scene.parent; sbm.joints?.forEach(joint => { if (stageGroup) joint.center = stageGroup; }); } vrm.humanoid.resetPose(); vrm.scene.rotation.y = Math.PI; const mixer = new AnimationMixer(vrm.scene); mixerRef.current = mixer; const actions = {}; vrmaGltfs.forEach((vrmaGltf, i) => { const anim = vrmaGltf.userData.vrmAnimations?.[0]; if (anim) { const clip = createVRMAnimationClip(anim, vrm); const meta = VRMA_FILES[i]; if (meta.loop) { actions[meta.name] = mixer.clipAction(trimClip(clip, TRIM_DURATION_LOOP, true)); } else { const action = mixer.clipAction(trimClip(clip, TRIM_DURATION_ONCE, false)); action.setLoop(THREE.LoopOnce); action.clampWhenFinished = true; actions[meta.name] = action; } } }); actionsRef.current = actions; const defaultAnim = 'fly_idle'; if (actions[defaultAnim]) { actions[defaultAnim].setLoop(THREE.LoopRepeat).play(); currentActionRef.current = defaultAnim; } return () => { mixer.stopAllAction(); }; }, [gltf, vrmaGltfs]); useEffect(() => { if (!selectedAnimation || !actionsRef.current[selectedAnimation]) return; const actions = actionsRef.current; const meta = VRMA_FILES.find(v => v.name === selectedAnimation); const current = currentActionRef.current; if (current && actions[current]) { actions[current].fadeOut(0.3); } const action = actions[selectedAnimation].reset().fadeIn(0.3).play(); if (meta?.loop) { action.setLoop(THREE.LoopRepeat); } currentActionRef.current = selectedAnimation; }, [animState]); useFrame((_, delta) => { mixerRef.current?.update(delta); vrmRef.current?.update(delta); // oneshot animation finished -> return to fly_idle const current = currentActionRef.current; const actions = actionsRef.current; const meta = VRMA_FILES.find(v => v.name === current); if (meta && !meta.loop && actions[current]) { const action = actions[current]; if (!action.isRunning() || action.time >= action.getClip().duration - 0.1) { action.fadeOut(0.3); actions.fly_idle?.reset().setLoop(THREE.LoopRepeat).fadeIn(0.3).play(); currentActionRef.current = 'fly_idle'; } } // admin WASD movement -> fly / fly_idle switching if (adminMode && actions.fly && actions.fly_idle) { const isMoving = keys.w; if (isMoving && current !== 'fly') { if (actions[current]) actions[current].stop(); actions.fly.reset().setLoop(THREE.LoopRepeat).fadeIn(0.3).play(); currentActionRef.current = 'fly'; } else if (!isMoving && current === 'fly') { actions.fly.stop(); actions.fly_idle.reset().setLoop(THREE.LoopRepeat).fadeIn(0.3).play(); currentActionRef.current = 'fly_idle'; } } }); return ; }