1
0

add vrm mode

This commit is contained in:
2026-03-07 14:35:30 +09:00
parent d1929396ba
commit f60961a963
6 changed files with 125 additions and 20 deletions

View File

@@ -5,6 +5,7 @@ import AtmosphereScene from './AtmosphereScene';
import AvatarScene from './AvatarScene'; import AvatarScene from './AvatarScene';
import { LOCATIONS, teleportTo } from './worldState'; import { LOCATIONS, teleportTo } from './worldState';
import { adminMode, onAdminChange } from './controls/KeyInput'; import { adminMode, onAdminChange } from './controls/KeyInput';
import ControlPanel from './ui/ControlPanel';
const ACTION_SEQUENCE = ['attack', 'skill', 'jump', 'fly_dodge', 'damage']; const ACTION_SEQUENCE = ['attack', 'skill', 'jump', 'fly_dodge', 'damage'];
const TELEPORT_ANIM = 'fly_dodge'; const TELEPORT_ANIM = 'fly_dodge';
@@ -16,6 +17,9 @@ export default function App() {
const [animState, setAnimState] = useState({ name: 'fly_idle', count: 0 }); const [animState, setAnimState] = useState({ name: 'fly_idle', count: 0 });
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [view, setView] = useState('avatar'); // 'avatar' | 'nasa' const [view, setView] = useState('avatar'); // 'avatar' | 'nasa'
const [timeScale, setTimeScale] = useState(100);
const [camSpeed, setCamSpeed] = useState(0.05);
const [vrmModel, setVrmModel] = useState('ai.vrm');
const actionIndexRef = useRef(0); const actionIndexRef = useRef(0);
const teleportIndexRef = useRef(0); const teleportIndexRef = useRef(0);
const countRef = useRef(0); const countRef = useRef(0);
@@ -50,6 +54,7 @@ export default function App() {
} else if (e.code === 'KeyS') { } else if (e.code === 'KeyS') {
e.preventDefault(); e.preventDefault();
playAnim('skill'); playAnim('skill');
setVrmModel(prev => prev === 'ai.vrm' ? 'ai_mode.vrm' : 'ai.vrm');
} }
}, [playAnim, view]); }, [playAnim, view]);
@@ -90,6 +95,7 @@ export default function App() {
const doSkill = useCallback(() => { const doSkill = useCallback(() => {
playAnim('skill'); playAnim('skill');
setVrmModel(prev => prev === 'ai.vrm' ? 'ai_mode.vrm' : 'ai.vrm');
}, [playAnim]); }, [playAnim]);
const appStartRef = useRef(Date.now()); const appStartRef = useRef(Date.now());
@@ -154,17 +160,28 @@ export default function App() {
{/* Atmosphere background */} {/* Atmosphere background */}
<div style={{ ...layerStyle, zIndex: 0, opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}> <div style={{ ...layerStyle, zIndex: 0, opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}>
<Canvas gl={{ alpha: true, antialias: true }}> <Canvas gl={{ alpha: true, antialias: true }}>
<AtmosphereScene /> <AtmosphereScene timeScale={timeScale} />
</Canvas> </Canvas>
</div> </div>
{/* Avatar layer */} {/* Avatar layer */}
<div style={{ ...layerStyle, zIndex: 1, pointerEvents: 'none', opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}> <div style={{ ...layerStyle, zIndex: 1, pointerEvents: 'none', opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}>
<div style={{ width: '100%', height: '100%', pointerEvents: isNasa ? 'none' : 'auto' }}> <div style={{ width: '100%', height: '100%', pointerEvents: isNasa ? 'none' : 'auto' }}>
<AvatarScene selectedAnimation={animState} onZoomOut={handleZoomOut} /> <AvatarScene selectedAnimation={animState} onZoomOut={handleZoomOut} camSpeed={camSpeed} vrmModel={vrmModel} />
</div> </div>
</div> </div>
{/* Control panel */}
{!isNasa && (
<ControlPanel
timeScale={timeScale}
onTimeScaleChange={setTimeScale}
camSpeed={camSpeed}
onCamSpeedChange={setCamSpeed}
onSkill={doSkill}
/>
)}
{/* Admin controls */} {/* Admin controls */}
{isAdmin && !isNasa && ( {isAdmin && !isNasa && (
<div style={{ position: 'absolute', top: 16, left: 16, zIndex: 2, display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ position: 'absolute', top: 16, left: 16, zIndex: 2, display: 'flex', gap: 8, alignItems: 'center' }}>

View File

@@ -69,11 +69,13 @@ function GoogleMaps3DTiles() {
); );
} }
export default function AtmosphereScene() { export default function AtmosphereScene({ timeScale: timeScaleProp }) {
const { gl } = useThree(); const { gl } = useThree();
const sunRef = useRef(); const sunRef = useRef();
const atmosphereRef = useRef(); const atmosphereRef = useRef();
const dateRef = useRef(new Date(INITIAL_DATE)); const dateRef = useRef(new Date(INITIAL_DATE));
const timeScaleRef = useRef(timeScaleProp ?? TIME_SCALE);
timeScaleRef.current = timeScaleProp ?? TIME_SCALE;
const [weather, setWeather] = useState(WEATHER_PRESETS[1]); const [weather, setWeather] = useState(WEATHER_PRESETS[1]);
useEffect(() => { useEffect(() => {
@@ -93,7 +95,7 @@ export default function AtmosphereScene() {
useFrame((_, delta) => { useFrame((_, delta) => {
const currentDate = dateRef.current; const currentDate = dateRef.current;
currentDate.setTime(currentDate.getTime() + delta * TIME_SCALE * 1000); currentDate.setTime(currentDate.getTime() + delta * timeScaleRef.current * 1000);
if (atmosphereRef.current) { if (atmosphereRef.current) {
atmosphereRef.current.updateByDate(currentDate); atmosphereRef.current.updateByDate(currentDate);

View File

@@ -31,12 +31,13 @@ const CAM_APPROACH_SPEED = 0.08; // 接近の補間速度
// --- Components --- // --- Components ---
function RotatingStage({ children }) { function RotatingStage({ children, speed }) {
const groupRef = useRef(null); const groupRef = useRef(null);
useFrame(({ clock }) => { useFrame(({ clock }) => {
if (groupRef.current) { if (groupRef.current) {
const angle = clock.getElapsedTime() * STAGE_ROTATE_SPEED; const s = speed != null ? speed : STAGE_ROTATE_SPEED;
const angle = clock.getElapsedTime() * s;
groupRef.current.rotation.y = angle; groupRef.current.rotation.y = angle;
worldState.stageRotationY = angle; worldState.stageRotationY = angle;
} }
@@ -111,7 +112,7 @@ function CameraDistanceMonitor({ onDistanceChange }) {
return null; return null;
} }
export default function AvatarScene({ selectedAnimation: animState, onZoomOut }) { export default function AvatarScene({ selectedAnimation: animState, onZoomOut, camSpeed: camSpeedProp, vrmModel }) {
const selectedAnimation = animState; const selectedAnimation = animState;
const animName = animState?.name || animState; const animName = animState?.name || animState;
const [effect, setEffect] = useState(null); const [effect, setEffect] = useState(null);
@@ -141,8 +142,8 @@ export default function AvatarScene({ selectedAnimation: animState, onZoomOut })
<ambientLight intensity={1.0} /> <ambientLight intensity={1.0} />
<spotLight position={[0, 2, -2]} intensity={3} color="#ffdcb4" /> <spotLight position={[0, 2, -2]} intensity={3} color="#ffdcb4" />
<Suspense fallback={null}> <Suspense fallback={null}>
<RotatingStage> <RotatingStage speed={camSpeedProp}>
<VrmCharacter selectedAnimation={selectedAnimation} /> <VrmCharacter key={vrmModel} selectedAnimation={selectedAnimation} vrmModel={vrmModel} />
{effect && <EnergySphere type={effect.type} position={[0, 1.0, 0]} scale={6} />} {effect && <EnergySphere type={effect.type} position={[0, 1.0, 0]} scale={6} />}
</RotatingStage> </RotatingStage>
</Suspense> </Suspense>

View File

@@ -5,12 +5,12 @@ import * as THREE from 'three';
const SPHERE_PRESETS = { const SPHERE_PRESETS = {
sun: { sun: {
layers: [ layers: [
{ radius: 0.3, segments: 64, size: 0.015, opacity: 0.8 }, { radius: 0.3, segments: 64, size: 0.015, opacity: 0.5 },
{ radius: 0.22, segments: 48, size: 0.02, opacity: 0.5 }, { radius: 0.22, segments: 48, size: 0.02, opacity: 0.3 },
{ radius: 0.38, segments: 40, size: 0.008, opacity: 0.3 }, { radius: 0.38, segments: 40, size: 0.008, opacity: 0.2 },
], ],
lightColor: '#ffaa22', lightColor: '#ffaa22',
lightIntensity: 3, lightIntensity: 1.5,
rotateSpeed: 1.5, rotateSpeed: 1.5,
pulseSpeed: 2.0, pulseSpeed: 2.0,
pulseRange: 0.25, pulseRange: 0.25,
@@ -22,11 +22,11 @@ const SPHERE_PRESETS = {
}, },
moon: { moon: {
layers: [ layers: [
{ radius: 0.2, segments: 48, size: 0.01, opacity: 0.6 }, { radius: 0.2, segments: 48, size: 0.01, opacity: 0.4 },
{ radius: 0.26, segments: 32, size: 0.006, opacity: 0.25 }, { radius: 0.26, segments: 32, size: 0.006, opacity: 0.15 },
], ],
lightColor: '#aaccff', lightColor: '#aaccff',
lightIntensity: 2, lightIntensity: 1.0,
rotateSpeed: 0.8, rotateSpeed: 0.8,
pulseSpeed: 1.0, pulseSpeed: 1.0,
pulseRange: 0.12, pulseRange: 0.12,
@@ -140,7 +140,7 @@ function EnergySphere({ type = 'sun', position = [0, 1.5, 0], scale = 1 }) {
/> />
</group> </group>
))} ))}
<pointLight position={[0, 0, 0]} intensity={preset.lightIntensity} color={preset.lightColor} distance={5} /> <pointLight position={[0, 0, 0]} intensity={preset.lightIntensity} color={preset.lightColor} distance={3} />
</group> </group>
); );
} }

View File

@@ -9,7 +9,6 @@ import * as THREE from 'three';
import { keys, adminMode } from './controls/KeyInput'; import { keys, adminMode } from './controls/KeyInput';
const BASE_URL = import.meta.env.BASE_URL; const BASE_URL = import.meta.env.BASE_URL;
const VRM_URL = `${BASE_URL}model/ai.vrm`;
export const VRMA_FILES = [ export const VRMA_FILES = [
{ name: 'idle', label: '待機', file: 'idle.vrma', loop: true }, { name: 'idle', label: '待機', file: 'idle.vrma', loop: true },
@@ -54,14 +53,15 @@ function trimClip(clip, duration, addLoopPose) {
return clip; return clip;
} }
export default function VrmCharacter({ selectedAnimation: animState }) { export default function VrmCharacter({ selectedAnimation: animState, vrmModel = 'ai.vrm' }) {
const selectedAnimation = animState?.name || animState; const selectedAnimation = animState?.name || animState;
const mixerRef = useRef(null); const mixerRef = useRef(null);
const vrmRef = useRef(null); const vrmRef = useRef(null);
const actionsRef = useRef({}); const actionsRef = useRef({});
const currentActionRef = useRef(null); const currentActionRef = useRef(null);
const gltf = useLoader(GLTFLoader, VRM_URL, (loader) => { const vrmUrl = `${BASE_URL}model/${vrmModel}`;
const gltf = useLoader(GLTFLoader, vrmUrl, (loader) => {
loader.register((parser) => new VRMLoaderPlugin(parser)); loader.register((parser) => new VRMLoaderPlugin(parser));
}); });

85
src/ui/ControlPanel.jsx Normal file
View File

@@ -0,0 +1,85 @@
import React from 'react';
const containerStyle = {
position: 'absolute',
top: 16,
right: 16,
zIndex: 2,
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: '10px 12px',
background: 'rgba(0,0,0,0.3)',
borderRadius: '8px',
backdropFilter: 'blur(4px)',
minWidth: 140,
};
const labelStyle = {
color: 'rgba(255,255,255,0.7)',
fontSize: '11px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
};
const sliderStyle = {
width: '100%',
height: 4,
appearance: 'none',
background: 'rgba(255,255,255,0.2)',
borderRadius: 2,
outline: 'none',
cursor: 'pointer',
};
const btnStyle = {
padding: '4px 10px',
background: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.7)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '11px',
width: '100%',
};
export default function ControlPanel({ timeScale, onTimeScaleChange, camSpeed, onCamSpeedChange, onSkill }) {
return (
<div style={containerStyle}>
<div>
<div style={labelStyle}>
<span>Time</span>
<span>{timeScale}x</span>
</div>
<input
type="range"
min={0}
max={5000}
step={50}
value={timeScale}
onChange={(e) => onTimeScaleChange(Number(e.target.value))}
style={sliderStyle}
/>
</div>
<div>
<div style={labelStyle}>
<span>Camera</span>
<span>{camSpeed.toFixed(2)}</span>
</div>
<input
type="range"
min={1}
max={20}
step={1}
value={camSpeed * 100}
onChange={(e) => onCamSpeedChange(Number(e.target.value) / 100)}
style={sliderStyle}
/>
</div>
<button style={btnStyle} onClick={() => onSkill?.()}>
Skill
</button>
</div>
);
}