From f60961a9635099d5394a28ad06787483288b8dad Mon Sep 17 00:00:00 2001 From: syui Date: Sat, 7 Mar 2026 14:35:30 +0900 Subject: [PATCH] add vrm mode --- src/App.jsx | 21 +++++++++- src/AtmosphereScene.jsx | 6 ++- src/AvatarScene.jsx | 11 +++--- src/SkillEffects.jsx | 16 ++++---- src/VrmCharacter.jsx | 6 +-- src/ui/ControlPanel.jsx | 85 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 src/ui/ControlPanel.jsx diff --git a/src/App.jsx b/src/App.jsx index 5434e37..264b37a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 */}
- +
{/* Avatar layer */}
- +
+ {/* Control panel */} + {!isNasa && ( + + )} + {/* Admin controls */} {isAdmin && !isNasa && (
diff --git a/src/AtmosphereScene.jsx b/src/AtmosphereScene.jsx index ab68529..86dbde7 100644 --- a/src/AtmosphereScene.jsx +++ b/src/AtmosphereScene.jsx @@ -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); diff --git a/src/AvatarScene.jsx b/src/AvatarScene.jsx index ef1cec3..a6c738c 100644 --- a/src/AvatarScene.jsx +++ b/src/AvatarScene.jsx @@ -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 }) - - + + {effect && } diff --git a/src/SkillEffects.jsx b/src/SkillEffects.jsx index 1f0e151..78968fe 100644 --- a/src/SkillEffects.jsx +++ b/src/SkillEffects.jsx @@ -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 }) { /> ))} - + ); } diff --git a/src/VrmCharacter.jsx b/src/VrmCharacter.jsx index ed60e0d..2fae135 100644 --- a/src/VrmCharacter.jsx +++ b/src/VrmCharacter.jsx @@ -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)); }); diff --git a/src/ui/ControlPanel.jsx b/src/ui/ControlPanel.jsx new file mode 100644 index 0000000..db47537 --- /dev/null +++ b/src/ui/ControlPanel.jsx @@ -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 ( +
+
+
+ Time + {timeScale}x +
+ onTimeScaleChange(Number(e.target.value))} + style={sliderStyle} + /> +
+
+
+ Camera + {camSpeed.toFixed(2)} +
+ onCamSpeedChange(Number(e.target.value) / 100)} + style={sliderStyle} + /> +
+ +
+ ); +}