169 lines
6.3 KiB
JavaScript
169 lines
6.3 KiB
JavaScript
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 <primitive object={gltf.scene} />;
|
|
}
|