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 (
);
}
@@ -258,7 +452,7 @@ export default function App() {
return (
-
+
{/* Layer 0: Atmosphere */}
@@ -266,9 +460,9 @@ export default function App() {
{/* Layer 1: Avatar */}