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 { LOCATIONS, teleportTo } from './worldState';
import { adminMode, onAdminChange } from './controls/KeyInput';
import ControlPanel from './ui/ControlPanel';
const ACTION_SEQUENCE = ['attack', 'skill', 'jump', 'fly_dodge', 'damage'];
const TELEPORT_ANIM = 'fly_dodge';
@@ -16,6 +17,9 @@ export default function App() {
const [animState, setAnimState] = useState({ name: 'fly_idle', count: 0 });
const [isAdmin, setIsAdmin] = useState(false);
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 teleportIndexRef = useRef(0);
const countRef = useRef(0);
@@ -50,6 +54,7 @@ export default function App() {
} else if (e.code === 'KeyS') {
e.preventDefault();
playAnim('skill');
setVrmModel(prev => prev === 'ai.vrm' ? 'ai_mode.vrm' : 'ai.vrm');
}
}, [playAnim, view]);
@@ -90,6 +95,7 @@ export default function App() {
const doSkill = useCallback(() => {
playAnim('skill');
setVrmModel(prev => prev === 'ai.vrm' ? 'ai_mode.vrm' : 'ai.vrm');
}, [playAnim]);
const appStartRef = useRef(Date.now());
@@ -154,17 +160,28 @@ export default function App() {
{/* Atmosphere background */}
<div style={{ ...layerStyle, zIndex: 0, opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}>
<Canvas gl={{ alpha: true, antialias: true }}>
<AtmosphereScene />
<AtmosphereScene timeScale={timeScale} />
</Canvas>
</div>
{/* Avatar layer */}
<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' }}>
<AvatarScene selectedAnimation={animState} onZoomOut={handleZoomOut} />
<AvatarScene selectedAnimation={animState} onZoomOut={handleZoomOut} camSpeed={camSpeed} vrmModel={vrmModel} />
</div>
</div>
{/* Control panel */}
{!isNasa && (
<ControlPanel
timeScale={timeScale}
onTimeScaleChange={setTimeScale}
camSpeed={camSpeed}
onCamSpeedChange={setCamSpeed}
onSkill={doSkill}
/>
)}
{/* Admin controls */}
{isAdmin && !isNasa && (
<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 sunRef = useRef();
const atmosphereRef = useRef();
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]);
useEffect(() => {
@@ -93,7 +95,7 @@ export default function AtmosphereScene() {
useFrame((_, delta) => {
const currentDate = dateRef.current;
currentDate.setTime(currentDate.getTime() + delta * TIME_SCALE * 1000);
currentDate.setTime(currentDate.getTime() + delta * timeScaleRef.current * 1000);
if (atmosphereRef.current) {
atmosphereRef.current.updateByDate(currentDate);

View File

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

View File

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

View File

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