1
0
Files
vrm/src/VrmCharacter.jsx
2026-03-07 16:57:46 +09:00

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} />;
}