diff --git a/.gitignore b/.gitignore index a20bc9948..f71d4f638 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/*.js */node_modules */public/models */dist/models +dist diff --git a/atmosphere/public/fly_c.vrma b/atmosphere/public/fly_c.vrma new file mode 100644 index 000000000..a05a7a9da Binary files /dev/null and b/atmosphere/public/fly_c.vrma differ diff --git a/atmosphere/public/fly_idle.vrma b/atmosphere/public/fly_idle.vrma new file mode 100644 index 000000000..722c3821c Binary files /dev/null and b/atmosphere/public/fly_idle.vrma differ diff --git a/atmosphere/public/fly_stop.vrma b/atmosphere/public/fly_stop.vrma new file mode 100644 index 000000000..635b18f90 Binary files /dev/null and b/atmosphere/public/fly_stop.vrma differ diff --git a/atmosphere/src/App.jsx b/atmosphere/src/App.jsx index fc833c678..3f3a54ac3 100644 --- a/atmosphere/src/App.jsx +++ b/atmosphere/src/App.jsx @@ -3,7 +3,7 @@ import { Canvas, 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, MathUtils } from 'three'; +import { AnimationMixer, Vector3, MathUtils, Quaternion, Euler } from 'three'; import { OrbitControls, PerspectiveCamera } from '@react-three/drei'; import { EffectComposer, ToneMapping } from '@react-three/postprocessing'; import { ToneMappingMode } from 'postprocessing'; @@ -23,7 +23,7 @@ const EARTH_RADIUS = 6378137; const WEATHER_INTERVAL = 5 * 60 * 1000; // 時間の進行速度 (倍率) -const TIME_SCALE = 100; +const TIME_SCALE = 100; // 初期時刻: 正午 (12:00) const INITIAL_DATE = new Date('2024-06-21T12:00:00'); @@ -32,33 +32,70 @@ const INITIAL_DATE = new Date('2024-06-21T12:00:00'); const WEATHER_PRESETS = [ { name: 'Clear (快晴)', - coverage: 0.1, + coverage: 0.1, layers: [ { channel: 'r', altitude: 1500, height: 500, densityScale: 0.0 }, { channel: 'g', altitude: 2500, height: 800, densityScale: 0.0 }, - { channel: 'b', altitude: 7500, height: 500, densityScale: 0.1 }, + { channel: 'b', altitude: 7500, height: 500, densityScale: 0.1 }, ] }, { name: 'Sunny (晴れ)', - coverage: 0.4, + coverage: 0.4, layers: [ - { channel: 'r', altitude: 1500, height: 500, densityScale: 0.4 }, + { channel: 'r', altitude: 1500, height: 500, densityScale: 0.4 }, { channel: 'g', altitude: 2500, height: 800, densityScale: 0.0 }, { channel: 'b', altitude: 7500, height: 500, densityScale: 0.2 }, ] }, { name: 'Cloudy (曇り)', - coverage: 0.75, + coverage: 0.75, layers: [ { channel: 'r', altitude: 1500, height: 500, densityScale: 0.6 }, - { channel: 'g', altitude: 2000, height: 1000, densityScale: 0.5 }, + { channel: 'g', altitude: 2000, height: 1000, densityScale: 0.5 }, { channel: 'b', altitude: 7500, height: 500, densityScale: 0.0 }, ] } ]; +// --- Shared State for Synchronization --- +// 複数のCanvas間で状態を共有するための簡易ストア +const worldState = { + // 世界座標 (Atmosphere内での位置) + position: new Vector3(0, EARTH_RADIUS + 2000, 0), + // カメラの回転 (AvatarLayerのカメラ回転をAtmosphereに同期) + quaternion: new Quaternion(), + // 移動速度 (m/s) + speed: 5000.0, +}; + +// キー入力管理 +const keys = { + w: false, + a: false, + s: false, + d: false, + Shift: false, + Space: false, +}; + +// キーイベントリスナーの設定 +if (typeof window !== 'undefined') { + window.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + if (keys.hasOwnProperty(key)) keys[key] = true; + if (e.key === 'Shift') keys.Shift = true; + if (e.key === ' ') keys.Space = true; + }); + window.addEventListener('keyup', (e) => { + const key = e.key.toLowerCase(); + if (keys.hasOwnProperty(key)) keys[key] = false; + if (e.key === 'Shift') keys.Shift = false; + if (e.key === ' ') keys.Space = false; + }); +} + // --------------------------------------------------------- // Scene Components (Inside Canvas) // --------------------------------------------------------- @@ -66,15 +103,15 @@ const WEATHER_PRESETS = [ // Canvasの内側で動作するメインシーンコンポーネント function AtmosphereScene() { const { gl } = useThree(); - const sunRef = useRef(); - const atmosphereRef = useRef(); + const sunRef = useRef(); + const atmosphereRef = useRef(); const dateRef = useRef(new Date(INITIAL_DATE)); const [weather, setWeather] = useState(WEATHER_PRESETS[1]); // 1. 露出設定 (Canvas内なのでuseThreeが使える) useEffect(() => { gl.toneMapping = THREE.NoToneMapping; - gl.toneMappingExposure = 10.0; + gl.toneMappingExposure = 10.0; }, [gl]); // 2. 天候の定期変更 @@ -110,29 +147,30 @@ function AtmosphereScene() { const sunY = Math.sin(sunAngle); if (hours < 6 || hours > 18) { - sunRef.current.position.set(0, -1, 0); - sunRef.current.intensity = 0.0; + sunRef.current.position.set(0, -1, 0); + sunRef.current.intensity = 0.0; } else { - sunRef.current.position.set(sunX, sunY, 0.2); - sunRef.current.intensity = MathUtils.lerp(0.5, 3.0, sunY); + sunRef.current.position.set(sunX, sunY, 0.2); + sunRef.current.intensity = MathUtils.lerp(0.5, 3.0, sunY); } } }); return ( <> - + - @@ -140,7 +178,7 @@ function AtmosphereScene() { {weather.layers.map((layer, index) => ( - - - ); } -function FlyOverCamera() { +// Atmosphere側のカメラをShared Stateに同期させる +function FollowCamera() { useFrame((state) => { - const t = state.clock.getElapsedTime() * 0.05; - const altitude = 2000; - const radius = 5000; - - state.camera.position.x = Math.sin(t) * radius; - state.camera.position.z = Math.cos(t) * radius; - state.camera.position.y = EARTH_RADIUS + altitude; - - const lookAtTarget = new Vector3( - Math.sin(t + 0.1) * radius, - EARTH_RADIUS + altitude, - Math.cos(t + 0.1) * radius - ); - state.camera.lookAt(lookAtTarget); + // Shared Stateの位置と回転を適用 + state.camera.position.copy(worldState.position); + state.camera.quaternion.copy(worldState.quaternion); }); return null; } @@ -186,12 +212,20 @@ function FlyOverCamera() { function VrmCharacter() { const mixerRef = useRef(null); const vrmRef = useRef(null); + const { camera } = useThree(); + const actionsRef = useRef({}); + const currentActionRef = useRef(null); const gltf = useLoader(GLTFLoader, VRM_URL, (loader) => { loader.register((parser) => new VRMLoaderPlugin(parser)); }); - - const vrma = useLoader(GLTFLoader, VRMA_URL, (loader) => { + + // Load all animations + const [vrmaFly, vrmaFlyStop, vrmaFlyIdle] = useLoader(GLTFLoader, [ + `${BASE_URL}fly.vrma`, + `${BASE_URL}fly_stop.vrma`, + `${BASE_URL}fly_idle.vrma` + ], (loader) => { loader.register((parser) => new VRMAnimationLoaderPlugin(parser)); }); @@ -200,28 +234,188 @@ function VrmCharacter() { vrmRef.current = vrm; VRMUtils.removeUnnecessaryJoints(vrm.scene); vrm.humanoid.resetPose(); - vrm.scene.rotation.y = Math.PI; + vrm.scene.rotation.y = Math.PI; - if (vrma.userData.vrmAnimations?.[0]) { - const clip = createVRMAnimationClip(vrma.userData.vrmAnimations[0], vrm); - mixerRef.current = new AnimationMixer(vrm.scene); - mixerRef.current.clipAction(clip).play(); + // Setup Animation Mixer + const mixer = new AnimationMixer(vrm.scene); + mixerRef.current = mixer; + + // Helper to create action + const createAction = (vrma, name) => { + if (vrma.userData.vrmAnimations?.[0]) { + const clip = createVRMAnimationClip(vrma.userData.vrmAnimations[0], vrm); + const action = mixer.clipAction(clip); + action.name = name; + return action; + } + return null; + }; + + const actionFly = createAction(vrmaFly, 'fly'); + const actionFlyStop = createAction(vrmaFlyStop, 'fly_stop'); + const actionFlyIdle = createAction(vrmaFlyIdle, 'fly_idle'); + + actionsRef.current = { + fly: actionFly, + fly_stop: actionFlyStop, + fly_idle: actionFlyIdle, + }; + + // Configure Loop Modes + if (actionFlyStop) { + actionFlyStop.setLoop(THREE.LoopOnce); + actionFlyStop.clampWhenFinished = true; } - }, [gltf, vrma]); + + // Initial Animation (Idle: fly_idle) + if (actionFlyIdle) { + actionFlyIdle.play(); + currentActionRef.current = 'fly_idle'; + } else if (actionFly) { + actionFly.play(); + currentActionRef.current = 'fly'; + } + + // Mixer Event Listener for 'finished' (for fly_stop -> fly_idle) + const onFinished = (e) => { + if (e.action === actionFlyStop) { + // fly_stop finished, crossfade to fly_idle + if (actionsRef.current.fly_idle) { + actionFlyStop.fadeOut(0.5); + actionsRef.current.fly_idle.reset().fadeIn(0.5).play(); + currentActionRef.current = 'fly_idle'; + } + } + }; + mixer.addEventListener('finished', onFinished); + + return () => { + mixer.removeEventListener('finished', onFinished); + }; + }, [gltf, vrmaFly, vrmaFlyStop, vrmaFlyIdle]); useFrame((state, delta) => { mixerRef.current?.update(delta); vrmRef.current?.update(delta); + + // Animation State Logic + const isMoving = keys.w || keys.s || keys.a || keys.d; + const actions = actionsRef.current; + const current = currentActionRef.current; + + if (actions.fly && actions.fly_stop && actions.fly_idle) { + if (isMoving) { + // If moving and not flying, transition to fly + if (current !== 'fly') { + const prevAction = actions[current]; + if (prevAction) prevAction.fadeOut(0.5); + + actions.fly.reset().fadeIn(0.5).play(); + currentActionRef.current = 'fly'; + } + } else { + // If stopped moving and currently flying, transition to fly_stop (stop anim) + if (current === 'fly') { + actions.fly.fadeOut(0.5); + actions.fly_stop.reset().fadeIn(0.5).play(); + currentActionRef.current = 'fly_stop'; + } + // If currently fly_stop, wait for it to finish (handled by event listener) + // If currently fly_idle, stay there. + } + } + + // Character Rotation Logic + if (vrmRef.current) { + const vrmNode = vrmRef.current.scene; + const moveDir = new Vector3(0, 0, 0); + + if (keys.w) moveDir.z -= 1; + if (keys.s) moveDir.z += 1; + if (keys.a) moveDir.x -= 1; + if (keys.d) moveDir.x += 1; + + if (moveDir.lengthSq() > 0) { + moveDir.normalize(); + + const cameraQuaternion = camera.quaternion.clone(); + moveDir.applyQuaternion(cameraQuaternion); + + // Create a target rotation looking at the movement direction + // Model faces -Z, so we want -Z to point to movement. + // lookAt points +Z to target. So we want +Z to point AWAY from movement. + const targetPos = vrmNode.position.clone().sub(moveDir); + const dummy = new THREE.Object3D(); + dummy.position.copy(vrmNode.position); + dummy.lookAt(targetPos); + + // Smoothly rotate towards target + vrmNode.quaternion.slerp(dummy.quaternion, 10.0 * delta); + } + } }); return ; } +// Avatar側のカメラ操作と移動ロジック +function CameraSync() { + const { camera } = useThree(); + const vec = new Vector3(); + const euler = new Euler(0, 0, 0, 'YXZ'); + + // Dynamic Zoom State + const zoomOffset = useRef(0); + const MAX_ZOOM_OFFSET = 10.0; + const ZOOM_SPEED = 2.0; + + useFrame((state, delta) => { + // 1. カメラの回転をShared Stateに同期 + worldState.quaternion.copy(camera.quaternion); + + // 2. WASD移動ロジック + const isMoving = keys.w || keys.s || keys.a || keys.d; + const speed = worldState.speed * (keys.Shift ? 2.0 : 1.0) * delta; + + // 前後 (W/S) + if (keys.w) { + vec.set(0, 0, -1).applyQuaternion(camera.quaternion); + worldState.position.addScaledVector(vec, speed); + } + if (keys.s) { + vec.set(0, 0, 1).applyQuaternion(camera.quaternion); + worldState.position.addScaledVector(vec, speed); + } + + // 左右 (A/D) + if (keys.a) { + vec.set(-1, 0, 0).applyQuaternion(camera.quaternion); + worldState.position.addScaledVector(vec, speed); + } + if (keys.d) { + vec.set(1, 0, 0).applyQuaternion(camera.quaternion); + worldState.position.addScaledVector(vec, speed); + } + + // 3. Dynamic Zoom Logic + const targetZoom = isMoving ? MAX_ZOOM_OFFSET : 0; + // Smoothly interpolate current zoom offset towards target + const diff = targetZoom - zoomOffset.current; + const step = diff * ZOOM_SPEED * delta; + zoomOffset.current += step; + + camera.translateZ(step); + }); + + return null; +} + function AvatarLayer() { return ( - + + @@ -230,7 +424,7 @@ function AvatarLayer() { - + ); } @@ -258,7 +452,7 @@ export default function App() { return (
- + {/* Layer 0: Atmosphere */}
@@ -266,9 +460,9 @@ export default function App() { {/* Layer 1: Avatar */}
-
- -
+
+ +
diff --git a/atmosphere/vite.config.ts b/atmosphere/vite.config.ts index 84bddafac..9329eced3 100644 --- a/atmosphere/vite.config.ts +++ b/atmosphere/vite.config.ts @@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], base: '/', + //base: '/pkg/atmosphere/', })