init
This commit is contained in:
33
.github/workflows/cf-pages.yml
vendored
Normal file
33
.github/workflows/cf-pages.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: cf pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 25
|
||||||
|
- run: |
|
||||||
|
npm i
|
||||||
|
git clone https://syu:${GITEA_TOKEN}@git.syui.ai/ai/vrma
|
||||||
|
cp -rf ./vrma/* public/
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
uses: cloudflare/wrangler-action@v3
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
wranglerVersion: '4'
|
||||||
|
command: pages deploy dist --project-name=${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.DS_Store
|
||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
package-lock.json
|
||||||
|
.claude
|
||||||
|
/claude.md
|
||||||
|
/CLAUDE.md
|
||||||
|
/public/animation
|
||||||
|
/public/model
|
||||||
|
/public/audio
|
||||||
|
/public/favicon.png
|
||||||
17
index.html
Normal file
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<title>aivrm</title>
|
||||||
|
<style>
|
||||||
|
html, body, #root { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "aivrm",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pixiv/three-vrm": "^3.4.4",
|
||||||
|
"@pixiv/three-vrm-animation": "^3.4.4",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.4.0",
|
||||||
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
|
"@takram/three-atmosphere": "^0.16.0",
|
||||||
|
"@takram/three-clouds": "^0.6.0",
|
||||||
|
"3d-tiles-renderer": "^0.4.18",
|
||||||
|
"react": "^19.0.0-rc.1",
|
||||||
|
"react-dom": "^19.0.0-rc.1",
|
||||||
|
"three": "^0.181.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^7.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/CNAME
Normal file
1
public/CNAME
Normal file
@@ -0,0 +1 @@
|
|||||||
|
vrm.syui.ai
|
||||||
174
src/App.jsx
Normal file
174
src/App.jsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Canvas } from '@react-three/fiber';
|
||||||
|
|
||||||
|
import AtmosphereScene from './AtmosphereScene';
|
||||||
|
import AvatarScene from './AvatarScene';
|
||||||
|
import { LOCATIONS, teleportTo } from './worldState';
|
||||||
|
import { adminMode, onAdminChange } from './controls/KeyInput';
|
||||||
|
|
||||||
|
const ACTION_SEQUENCE = ['attack', 'skill', 'jump', 'fly_dodge', 'damage'];
|
||||||
|
const TELEPORT_ANIM = 'fly_dodge';
|
||||||
|
const AUTO_INTERVAL_MIN = 15000;
|
||||||
|
const AUTO_INTERVAL_MAX = 40000;
|
||||||
|
const NASA_URL = 'https://eyes.nasa.gov/apps/solar-system/#/earth?featured=false&detailPanel=false&logo=false&search=false&shareButton=false&menu=false&collapseSettingsOptions=true&hideFullScreenToggle=true&locked=true&hideExternalLinks=true&lighting=flood';
|
||||||
|
|
||||||
|
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 actionIndexRef = useRef(0);
|
||||||
|
const teleportIndexRef = useRef(0);
|
||||||
|
const countRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return onAdminChange((v) => setIsAdmin(v));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playAnim = useCallback((name) => {
|
||||||
|
countRef.current += 1;
|
||||||
|
setAnimState({ name, count: countRef.current });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKey = useCallback((e) => {
|
||||||
|
if (e.code === 'Escape') {
|
||||||
|
setView(view === 'nasa' ? 'avatar' : 'nasa');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = actionIndexRef.current;
|
||||||
|
const anim = ACTION_SEQUENCE[idx % ACTION_SEQUENCE.length];
|
||||||
|
playAnim(anim);
|
||||||
|
actionIndexRef.current = idx + 1;
|
||||||
|
} else if (e.code === 'KeyT') {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = teleportIndexRef.current;
|
||||||
|
const loc = LOCATIONS[idx % LOCATIONS.length];
|
||||||
|
playAnim(TELEPORT_ANIM);
|
||||||
|
teleportTo(loc);
|
||||||
|
teleportIndexRef.current = idx + 1;
|
||||||
|
} else if (e.code === 'KeyS') {
|
||||||
|
e.preventDefault();
|
||||||
|
playAnim('skill');
|
||||||
|
}
|
||||||
|
}, [playAnim, view]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [handleKey]);
|
||||||
|
|
||||||
|
const viewRef = useRef(view);
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scheduleNext = () => {
|
||||||
|
const delay = AUTO_INTERVAL_MIN + Math.random() * (AUTO_INTERVAL_MAX - AUTO_INTERVAL_MIN);
|
||||||
|
return setTimeout(() => {
|
||||||
|
// NASAビュー中はスキップ
|
||||||
|
if (viewRef.current !== 'nasa') {
|
||||||
|
const idx = teleportIndexRef.current;
|
||||||
|
const loc = LOCATIONS[idx % LOCATIONS.length];
|
||||||
|
playAnim(TELEPORT_ANIM);
|
||||||
|
teleportTo(loc);
|
||||||
|
teleportIndexRef.current = idx + 1;
|
||||||
|
}
|
||||||
|
timerRef.current = scheduleNext();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
const timerRef = { current: scheduleNext() };
|
||||||
|
return () => clearTimeout(timerRef.current);
|
||||||
|
}, [playAnim]);
|
||||||
|
|
||||||
|
const doTeleport = useCallback(() => {
|
||||||
|
const idx = teleportIndexRef.current;
|
||||||
|
const loc = LOCATIONS[idx % LOCATIONS.length];
|
||||||
|
playAnim(TELEPORT_ANIM);
|
||||||
|
teleportTo(loc);
|
||||||
|
teleportIndexRef.current = idx + 1;
|
||||||
|
}, [playAnim]);
|
||||||
|
|
||||||
|
const doSkill = useCallback(() => {
|
||||||
|
playAnim('skill');
|
||||||
|
}, [playAnim]);
|
||||||
|
|
||||||
|
const appStartRef = useRef(Date.now());
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
// 起動後15秒は切り替え無効
|
||||||
|
if (Date.now() - appStartRef.current < 15000) return;
|
||||||
|
setView('nasa');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const layerStyle = { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' };
|
||||||
|
|
||||||
|
const btnStyle = {
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'rgba(255,255,255,0.12)',
|
||||||
|
color: '#fff',
|
||||||
|
border: '1px solid rgba(255,255,255,0.25)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNasa = view === 'nasa';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: '100vw', height: '100vh', background: '#000' }}>
|
||||||
|
{/* NASA iframe layer - fully interactive */}
|
||||||
|
<iframe
|
||||||
|
src={NASA_URL}
|
||||||
|
style={{
|
||||||
|
...layerStyle,
|
||||||
|
zIndex: isNasa ? 10 : -1,
|
||||||
|
border: 'none',
|
||||||
|
opacity: isNasa ? 1 : 0,
|
||||||
|
transition: 'opacity 1.5s ease',
|
||||||
|
pointerEvents: isNasa ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
title="NASA Solar System"
|
||||||
|
allowFullScreen
|
||||||
|
allow="accelerometer; autoplay; encrypted-media; gyroscope"
|
||||||
|
/>
|
||||||
|
{/* NASA center button */}
|
||||||
|
{isNasa && (
|
||||||
|
<button
|
||||||
|
onClick={() => setView('avatar')}
|
||||||
|
onMouseEnter={() => setView('avatar')}
|
||||||
|
onTouchStart={() => setView('avatar')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '50%', left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)', zIndex: 100,
|
||||||
|
width: 40, height: 40, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
cursor: 'pointer', pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Atmosphere background */}
|
||||||
|
<div style={{ ...layerStyle, zIndex: 0, opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}>
|
||||||
|
<Canvas gl={{ alpha: true, antialias: true }}>
|
||||||
|
<AtmosphereScene />
|
||||||
|
</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} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin controls */}
|
||||||
|
{isAdmin && !isNasa && (
|
||||||
|
<div style={{ position: 'absolute', top: 16, left: 16, zIndex: 2, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button style={btnStyle} onClick={doTeleport}>Teleport</button>
|
||||||
|
<button style={btnStyle} onClick={doSkill}>Skill</button>
|
||||||
|
<span style={{ color: '#0f0', fontSize: '12px', marginLeft: 4 }}>ADMIN</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/AtmosphereScene.jsx
Normal file
128
src/AtmosphereScene.jsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { PerspectiveCamera } from '@react-three/drei';
|
||||||
|
import { EffectComposer, ToneMapping } from '@react-three/postprocessing';
|
||||||
|
import { ToneMappingMode } from 'postprocessing';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
import { AerialPerspective, Atmosphere } from '@takram/three-atmosphere/r3f';
|
||||||
|
import { Clouds, CloudLayer } from '@takram/three-clouds/r3f';
|
||||||
|
|
||||||
|
import { TilesPlugin, TilesRenderer } from '3d-tiles-renderer/r3f';
|
||||||
|
import {
|
||||||
|
GLTFExtensionsPlugin,
|
||||||
|
GoogleCloudAuthPlugin,
|
||||||
|
TileCompressionPlugin,
|
||||||
|
TilesFadePlugin,
|
||||||
|
UpdateOnChangePlugin,
|
||||||
|
} from '3d-tiles-renderer/plugins';
|
||||||
|
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
|
||||||
|
|
||||||
|
import { FollowCamera } from './controls/CameraRig';
|
||||||
|
|
||||||
|
const TIME_SCALE = 100;
|
||||||
|
const INITIAL_DATE = new Date('2024-06-21T12:00:00');
|
||||||
|
const WEATHER_INTERVAL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
const WEATHER_PRESETS = [
|
||||||
|
{
|
||||||
|
name: 'Clear',
|
||||||
|
coverage: 0.05,
|
||||||
|
layers: [
|
||||||
|
{ channel: 'r', altitude: 2000, height: 500, densityScale: 0.05 },
|
||||||
|
{ channel: 'b', altitude: 7500, height: 500, densityScale: 0.05 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sunny',
|
||||||
|
coverage: 0.2,
|
||||||
|
layers: [
|
||||||
|
{ channel: 'r', altitude: 1500, height: 500, densityScale: 0.3 },
|
||||||
|
{ channel: 'b', altitude: 7500, height: 500, densityScale: 0.15 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cloudy',
|
||||||
|
coverage: 0.4,
|
||||||
|
layers: [
|
||||||
|
{ channel: 'r', altitude: 1500, height: 500, densityScale: 0.4 },
|
||||||
|
{ channel: 'g', altitude: 2000, height: 800, densityScale: 0.3 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const dracoLoader = new DRACOLoader();
|
||||||
|
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
|
||||||
|
|
||||||
|
function GoogleMaps3DTiles() {
|
||||||
|
const apiKey = import.meta.env.VITE_GOOGLE_MAP_API_KEY;
|
||||||
|
if (!apiKey) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TilesRenderer url={`https://tile.googleapis.com/v1/3dtiles/root.json?key=${apiKey}`}>
|
||||||
|
<TilesPlugin plugin={GoogleCloudAuthPlugin} args={{ apiToken: apiKey }} />
|
||||||
|
<TilesPlugin plugin={GLTFExtensionsPlugin} dracoLoader={dracoLoader} />
|
||||||
|
<TilesPlugin plugin={TileCompressionPlugin} />
|
||||||
|
<TilesPlugin plugin={UpdateOnChangePlugin} />
|
||||||
|
<TilesPlugin plugin={TilesFadePlugin} />
|
||||||
|
</TilesRenderer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AtmosphereScene() {
|
||||||
|
const { gl } = useThree();
|
||||||
|
const sunRef = useRef();
|
||||||
|
const atmosphereRef = useRef();
|
||||||
|
const dateRef = useRef(new Date(INITIAL_DATE));
|
||||||
|
const [weather, setWeather] = useState(WEATHER_PRESETS[1]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gl.toneMapping = THREE.NoToneMapping;
|
||||||
|
gl.toneMappingExposure = 10.0;
|
||||||
|
}, [gl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setWeather(prev => {
|
||||||
|
const others = WEATHER_PRESETS.filter(w => w.name !== prev.name);
|
||||||
|
return others[Math.floor(Math.random() * others.length)];
|
||||||
|
});
|
||||||
|
}, WEATHER_INTERVAL);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const currentDate = dateRef.current;
|
||||||
|
currentDate.setTime(currentDate.getTime() + delta * TIME_SCALE * 1000);
|
||||||
|
|
||||||
|
if (atmosphereRef.current) {
|
||||||
|
atmosphereRef.current.updateByDate(currentDate);
|
||||||
|
const sunDirection = atmosphereRef.current.sunDirection;
|
||||||
|
if (sunRef.current && sunDirection) {
|
||||||
|
sunRef.current.position.copy(sunDirection);
|
||||||
|
sunRef.current.intensity = sunDirection.y < -0.1 ? 0.1 : 3.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PerspectiveCamera makeDefault near={10} far={10000000} fov={45} />
|
||||||
|
<FollowCamera />
|
||||||
|
<directionalLight ref={sunRef} position={[0, 1, 0]} intensity={3.0} castShadow />
|
||||||
|
<Atmosphere ref={atmosphereRef}>
|
||||||
|
<GoogleMaps3DTiles />
|
||||||
|
<EffectComposer multisampling={0} disableNormalPass={false}>
|
||||||
|
<Clouds disableDefaultLayers coverage={weather.coverage}>
|
||||||
|
{weather.layers.map((layer, i) => (
|
||||||
|
<CloudLayer key={i} channel={layer.channel} altitude={layer.altitude}
|
||||||
|
height={layer.height} densityScale={layer.densityScale} shapeAmount={0.5} />
|
||||||
|
))}
|
||||||
|
</Clouds>
|
||||||
|
<AerialPerspective sky />
|
||||||
|
<ToneMapping mode={ToneMappingMode.AGX} />
|
||||||
|
</EffectComposer>
|
||||||
|
</Atmosphere>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/AvatarScene.jsx
Normal file
171
src/AvatarScene.jsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { Suspense, useRef, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { OrbitControls, PerspectiveCamera } from '@react-three/drei';
|
||||||
|
|
||||||
|
import VrmCharacter from './VrmCharacter';
|
||||||
|
import EnergySphere from './SkillEffects';
|
||||||
|
import { CameraSync } from './controls/CameraRig';
|
||||||
|
import { worldState } from './worldState';
|
||||||
|
|
||||||
|
// --- Settings ---
|
||||||
|
|
||||||
|
const STAGE_ROTATE_SPEED = 0.02; // キャラ周回速度
|
||||||
|
|
||||||
|
const BREATH_NEAR = 2.5; // カメラ最近距離
|
||||||
|
const BREATH_FAR = 5.0; // カメラ最遠距離
|
||||||
|
const BREATH_SPEED = 0.35; // 寄り引き速度
|
||||||
|
const BREATH_HEIGHT_RATIO = 0.4; // 距離に対する上下移動の割合
|
||||||
|
const BREATH_BASE_Y = 1.5; // カメラ基準Y位置
|
||||||
|
const BREATH_LERP = 0.02; // 補間の滑らかさ (0-1)
|
||||||
|
|
||||||
|
const ORBIT_TARGET_Y = 1.2; // 注視点の高さ
|
||||||
|
const ORBIT_MIN_DISTANCE = 1.5; // 手動操作の最小距離
|
||||||
|
const ORBIT_MAX_DISTANCE = 15.0; // 手動操作の最大距離
|
||||||
|
const ORBIT_MIN_POLAR = 0.015; // 上方向の制限 (0=真上, 0.5=水平) ※PI倍
|
||||||
|
const ORBIT_MAX_POLAR = 0.55; // 下方向の制限 (0.5=水平, 1=真下) ※PI倍
|
||||||
|
|
||||||
|
const CAM_FOV = 40; // 視野角
|
||||||
|
const CAM_INITIAL_POS = [0, 15, 80]; // カメラ初期位置 (遠くから開始)
|
||||||
|
const CAM_TARGET_POS = [0, 1.5, 3]; // カメラ最終位置
|
||||||
|
const CAM_APPROACH_SPEED = 0.08; // 接近の補間速度
|
||||||
|
|
||||||
|
// --- Components ---
|
||||||
|
|
||||||
|
function RotatingStage({ children }) {
|
||||||
|
const groupRef = useRef(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
const angle = clock.getElapsedTime() * STAGE_ROTATE_SPEED;
|
||||||
|
groupRef.current.rotation.y = angle;
|
||||||
|
worldState.stageRotationY = angle;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return <group ref={groupRef}>{children}</group>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// マウス操作検知用の共有状態
|
||||||
|
const userInteracting = { active: false, timeout: null };
|
||||||
|
|
||||||
|
function CameraBreathing() {
|
||||||
|
const arrivedRef = useRef(false);
|
||||||
|
|
||||||
|
useFrame(({ camera, clock }) => {
|
||||||
|
if (!arrivedRef.current) {
|
||||||
|
camera.position.x += (CAM_TARGET_POS[0] - camera.position.x) * CAM_APPROACH_SPEED;
|
||||||
|
camera.position.y += (CAM_TARGET_POS[1] - camera.position.y) * CAM_APPROACH_SPEED;
|
||||||
|
camera.position.z += (CAM_TARGET_POS[2] - camera.position.z) * CAM_APPROACH_SPEED;
|
||||||
|
const dist = Math.abs(camera.position.z - CAM_TARGET_POS[2]);
|
||||||
|
if (dist < 0.1) arrivedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// マウス操作中は止める
|
||||||
|
if (userInteracting.active) return;
|
||||||
|
|
||||||
|
const t = clock.getElapsedTime() * BREATH_SPEED;
|
||||||
|
const range = (BREATH_FAR - BREATH_NEAR) / 2;
|
||||||
|
const center = BREATH_NEAR + range;
|
||||||
|
const dist = center + Math.sin(t) * range;
|
||||||
|
const targetY = BREATH_BASE_Y + dist * BREATH_HEIGHT_RATIO;
|
||||||
|
const targetZ = dist;
|
||||||
|
camera.position.y += (targetY - camera.position.y) * BREATH_LERP;
|
||||||
|
camera.position.z += (targetZ - camera.position.z) * BREATH_LERP;
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrbitInteractionDetector({ controlsRef }) {
|
||||||
|
useFrame(() => {
|
||||||
|
if (!controlsRef.current) return;
|
||||||
|
const c = controlsRef.current;
|
||||||
|
// OrbitControlsのドラッグ/ズーム中を検知
|
||||||
|
if (c.getState && c.getState() !== 0) {
|
||||||
|
userInteracting.active = true;
|
||||||
|
clearTimeout(userInteracting.timeout);
|
||||||
|
userInteracting.timeout = setTimeout(() => { userInteracting.active = false; }, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILL_DURATION = 1500;
|
||||||
|
const ZOOM_OUT_THRESHOLD = 9.0; // この距離を超えるとNASAビューへ
|
||||||
|
|
||||||
|
function CameraDistanceMonitor({ onDistanceChange }) {
|
||||||
|
const readyRef = useRef(false);
|
||||||
|
const startTime = useRef(null);
|
||||||
|
useFrame(({ camera, clock }) => {
|
||||||
|
if (!startTime.current) startTime.current = clock.getElapsedTime();
|
||||||
|
const elapsed = clock.getElapsedTime() - startTime.current;
|
||||||
|
// 最初の10秒は無視(接近演出中)
|
||||||
|
if (elapsed < 10) return;
|
||||||
|
const dist = camera.position.length();
|
||||||
|
if (!readyRef.current) {
|
||||||
|
if (dist < ZOOM_OUT_THRESHOLD + 1) readyRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDistanceChange?.(dist);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AvatarScene({ selectedAnimation: animState, onZoomOut }) {
|
||||||
|
const selectedAnimation = animState;
|
||||||
|
const animName = animState?.name || animState;
|
||||||
|
const [effect, setEffect] = useState(null);
|
||||||
|
const controlsRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (animName === 'skill') {
|
||||||
|
setEffect({ type: 'sun' });
|
||||||
|
const timer = setTimeout(() => setEffect(null), SKILL_DURATION);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else if (animName === 'fly_dodge') {
|
||||||
|
setEffect({ type: 'moon' });
|
||||||
|
const timer = setTimeout(() => setEffect(null), SKILL_DURATION);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [animState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Canvas gl={{ alpha: true, antialias: true }}>
|
||||||
|
<PerspectiveCamera makeDefault position={CAM_INITIAL_POS} fov={CAM_FOV} />
|
||||||
|
<CameraSync />
|
||||||
|
<CameraBreathing />
|
||||||
|
<CameraDistanceMonitor onDistanceChange={(d) => {
|
||||||
|
if (d > ZOOM_OUT_THRESHOLD) onZoomOut?.();
|
||||||
|
}} />
|
||||||
|
<directionalLight position={[-1, 1, 1]} intensity={1.5} />
|
||||||
|
<ambientLight intensity={1.0} />
|
||||||
|
<spotLight position={[0, 2, -2]} intensity={3} color="#ffdcb4" />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RotatingStage>
|
||||||
|
<VrmCharacter selectedAnimation={selectedAnimation} />
|
||||||
|
{effect && <EnergySphere type={effect.type} position={[0, 1.0, 0]} scale={6} />}
|
||||||
|
</RotatingStage>
|
||||||
|
</Suspense>
|
||||||
|
<OrbitInteractionDetector controlsRef={controlsRef} />
|
||||||
|
<OrbitControls
|
||||||
|
ref={controlsRef}
|
||||||
|
target={[0, ORBIT_TARGET_Y, 0]}
|
||||||
|
minDistance={ORBIT_MIN_DISTANCE}
|
||||||
|
maxDistance={ORBIT_MAX_DISTANCE}
|
||||||
|
enablePan={false}
|
||||||
|
enableZoom={true}
|
||||||
|
zoomSpeed={1.5}
|
||||||
|
rotateSpeed={0.3}
|
||||||
|
minPolarAngle={Math.PI * ORBIT_MIN_POLAR}
|
||||||
|
maxPolarAngle={Math.PI * ORBIT_MAX_POLAR}
|
||||||
|
onStart={() => {
|
||||||
|
userInteracting.active = true;
|
||||||
|
clearTimeout(userInteracting.timeout);
|
||||||
|
}}
|
||||||
|
onEnd={() => {
|
||||||
|
userInteracting.timeout = setTimeout(() => { userInteracting.active = false; }, 2000);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/SkillEffects.jsx
Normal file
149
src/SkillEffects.jsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useRef, useMemo } from 'react';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
lightColor: '#ffaa22',
|
||||||
|
lightIntensity: 3,
|
||||||
|
rotateSpeed: 1.5,
|
||||||
|
pulseSpeed: 2.0,
|
||||||
|
pulseRange: 0.25,
|
||||||
|
colorFn: (normDist, layerIdx) => {
|
||||||
|
if (layerIdx === 0) return [1.0, 0.7 + normDist * 0.3, 0.1];
|
||||||
|
if (layerIdx === 1) return [1.0, 0.4, 0.05];
|
||||||
|
return [1.0, 0.9, 0.5];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moon: {
|
||||||
|
layers: [
|
||||||
|
{ radius: 0.2, segments: 48, size: 0.01, opacity: 0.6 },
|
||||||
|
{ radius: 0.26, segments: 32, size: 0.006, opacity: 0.25 },
|
||||||
|
],
|
||||||
|
lightColor: '#aaccff',
|
||||||
|
lightIntensity: 2,
|
||||||
|
rotateSpeed: 0.8,
|
||||||
|
pulseSpeed: 1.0,
|
||||||
|
pulseRange: 0.12,
|
||||||
|
colorFn: (normDist, layerIdx) => {
|
||||||
|
if (layerIdx === 0) return [0.7, 0.8, 1.0];
|
||||||
|
return [0.5, 0.6, 1.0];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
earth: {
|
||||||
|
layers: [
|
||||||
|
{ radius: 0.25, segments: 56, size: 0.012, opacity: 0.7 },
|
||||||
|
{ radius: 0.32, segments: 36, size: 0.007, opacity: 0.25 },
|
||||||
|
],
|
||||||
|
lightColor: '#44aaff',
|
||||||
|
lightIntensity: 2.5,
|
||||||
|
rotateSpeed: 1.0,
|
||||||
|
pulseSpeed: 1.5,
|
||||||
|
pulseRange: 0.18,
|
||||||
|
colorFn: (normDist, layerIdx) => {
|
||||||
|
if (layerIdx === 0) return [0.2, 0.6 + normDist * 0.4, 1.0];
|
||||||
|
return [0.1, 0.8, 0.6];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
neutron: {
|
||||||
|
layers: [
|
||||||
|
{ radius: 0.15, segments: 72, size: 0.008, opacity: 0.9 },
|
||||||
|
{ radius: 0.1, segments: 48, size: 0.015, opacity: 0.6 },
|
||||||
|
{ radius: 0.22, segments: 32, size: 0.005, opacity: 0.3 },
|
||||||
|
],
|
||||||
|
lightColor: '#8844ff',
|
||||||
|
lightIntensity: 4,
|
||||||
|
rotateSpeed: 3.0,
|
||||||
|
pulseSpeed: 3.0,
|
||||||
|
pulseRange: 0.35,
|
||||||
|
colorFn: (normDist, layerIdx) => {
|
||||||
|
if (layerIdx === 0) return [0.9, 0.3, 1.0];
|
||||||
|
if (layerIdx === 1) return [1.0, 1.0, 1.0];
|
||||||
|
return [0.5, 0.1, 1.0];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function ParticleLayer({ radius, segments, size, opacity, colorFn, layerIdx }) {
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const geo = new THREE.SphereGeometry(radius, segments, segments);
|
||||||
|
const posArr = new Float32Array(geo.attributes.position.array);
|
||||||
|
const colArr = new Float32Array(posArr.length);
|
||||||
|
for (let i = 0; i < posArr.length; i += 3) {
|
||||||
|
const x = posArr[i], y = posArr[i + 1], z = posArr[i + 2];
|
||||||
|
const dist = Math.sqrt(x * x + y * y + z * z);
|
||||||
|
const normDist = dist / (radius || 1);
|
||||||
|
const [r, g, b] = colorFn(normDist, layerIdx);
|
||||||
|
colArr[i] = r;
|
||||||
|
colArr[i + 1] = g;
|
||||||
|
colArr[i + 2] = b;
|
||||||
|
}
|
||||||
|
geo.dispose();
|
||||||
|
return { positions: posArr, colors: colArr };
|
||||||
|
}, [radius, segments, colorFn, layerIdx]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<points>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" array={data.positions} count={data.positions.length / 3} itemSize={3} />
|
||||||
|
<bufferAttribute attach="attributes-color" array={data.colors} count={data.colors.length / 3} itemSize={3} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial
|
||||||
|
transparent
|
||||||
|
depthWrite={false}
|
||||||
|
vertexColors
|
||||||
|
opacity={opacity}
|
||||||
|
size={size}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
/>
|
||||||
|
</points>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnergySphere({ type = 'sun', position = [0, 1.5, 0], scale = 1 }) {
|
||||||
|
const groupRef = useRef();
|
||||||
|
const layerRefs = useRef([]);
|
||||||
|
const preset = SPHERE_PRESETS[type] || SPHERE_PRESETS.sun;
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const pulse = 1 + Math.sin(t * preset.pulseSpeed) * preset.pulseRange;
|
||||||
|
groupRef.current.scale.setScalar(scale * pulse);
|
||||||
|
|
||||||
|
layerRefs.current.forEach((ref, i) => {
|
||||||
|
if (!ref) return;
|
||||||
|
const speed = preset.rotateSpeed * (1 + i * 0.4);
|
||||||
|
const dir = i % 2 === 0 ? 1 : -1;
|
||||||
|
ref.rotation.y = t * speed * dir;
|
||||||
|
ref.rotation.x = Math.sin(t * speed * 0.3 + i) * 0.4;
|
||||||
|
ref.rotation.z = Math.cos(t * speed * 0.2 + i * 2) * 0.3;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={position} ref={groupRef}>
|
||||||
|
{preset.layers.map((layer, i) => (
|
||||||
|
<group key={i} ref={el => layerRefs.current[i] = el}>
|
||||||
|
<ParticleLayer
|
||||||
|
radius={layer.radius}
|
||||||
|
segments={layer.segments}
|
||||||
|
size={layer.size}
|
||||||
|
opacity={layer.opacity}
|
||||||
|
colorFn={preset.colorFn}
|
||||||
|
layerIdx={i}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
<pointLight position={[0, 0, 0]} intensity={preset.lightIntensity} color={preset.lightColor} distance={5} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EnergySphere, SPHERE_PRESETS };
|
||||||
|
export default EnergySphere;
|
||||||
160
src/VrmCharacter.jsx
Normal file
160
src/VrmCharacter.jsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { 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, Quaternion } from 'three';
|
||||||
|
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 },
|
||||||
|
{ name: 'fly_idle', label: '飛行待機', file: 'fly_idle.vrma', loop: true },
|
||||||
|
{ name: 'fly', label: '飛行', file: 'fly.vrma', loop: true },
|
||||||
|
{ name: 'fly_stop', label: '飛行停止', file: 'fly_stop.vrma', loop: false },
|
||||||
|
{ name: 'fly_dodge', label: '回避', file: 'fly_dodge.vrma', loop: false },
|
||||||
|
{ name: 'jump', label: 'ジャンプ', file: 'jump.vrma', loop: false },
|
||||||
|
{ name: 'attack', label: '攻撃', file: 'attack.vrma', loop: false },
|
||||||
|
{ name: 'damage', label: 'ダメージ', file: 'damage.vrma', loop: false },
|
||||||
|
{ name: 'skill', label: 'スキル', file: 'skill_end.vrma', loop: false },
|
||||||
|
{ name: 'skill_loop', label: 'スキル継続', file: 'skill_loop.vrma', loop: true },
|
||||||
|
{ name: 'skill_end', label: 'スキル終了', file: 'skill_end.vrma', loop: false },
|
||||||
|
];
|
||||||
|
const VRMA_URLS = VRMA_FILES.map(v => `${BASE_URL}animation/${v.file}`);
|
||||||
|
|
||||||
|
const TRIM_DURATION_LOOP = 3.4; // ループアニメーションの長さ
|
||||||
|
const TRIM_DURATION_ONCE = 2.0; // 技(oneshot)アニメーションの長さ
|
||||||
|
|
||||||
|
function trimClip(clip, duration, addLoopPose) {
|
||||||
|
const fade = 0.3;
|
||||||
|
const trimEnd = duration + (addLoopPose ? fade : 0);
|
||||||
|
clip.tracks.forEach(track => {
|
||||||
|
const times = track.times;
|
||||||
|
const valStride = track.values.length / times.length;
|
||||||
|
let cutIndex = times.length;
|
||||||
|
for (let j = 0; j < times.length; j++) {
|
||||||
|
if (times[j] > trimEnd) { cutIndex = j; break; }
|
||||||
|
}
|
||||||
|
const newTimes = Array.from(times.slice(0, cutIndex));
|
||||||
|
const newValues = Array.from(track.values.slice(0, cutIndex * valStride));
|
||||||
|
if (addLoopPose) {
|
||||||
|
newTimes.push(duration);
|
||||||
|
for (let v = 0; v < valStride; v++) {
|
||||||
|
newValues.push(track.values[v]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
track.times = new Float32Array(newTimes);
|
||||||
|
track.values = new Float32Array(newValues);
|
||||||
|
});
|
||||||
|
clip.duration = duration;
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VrmCharacter({ selectedAnimation: animState }) {
|
||||||
|
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) => {
|
||||||
|
loader.register((parser) => new VRMLoaderPlugin(parser));
|
||||||
|
});
|
||||||
|
|
||||||
|
const vrmaGltfs = useLoader(GLTFLoader, VRMA_URLS, (loader) => {
|
||||||
|
loader.register((parser) => new VRMAnimationLoaderPlugin(parser));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vrm = gltf.userData.vrm;
|
||||||
|
vrmRef.current = vrm;
|
||||||
|
VRMUtils.removeUnnecessaryJoints(vrm.scene);
|
||||||
|
vrm.humanoid.resetPose();
|
||||||
|
vrm.scene.rotation.y = Math.PI;
|
||||||
|
|
||||||
|
const mixer = new AnimationMixer(vrm.scene);
|
||||||
|
mixerRef.current = mixer;
|
||||||
|
|
||||||
|
const actions = {};
|
||||||
|
vrmaGltfs.forEach((vrmaGltf, i) => {
|
||||||
|
const anim = vrmaGltf.userData.vrmAnimations?.[0];
|
||||||
|
if (anim) {
|
||||||
|
const clip = createVRMAnimationClip(anim, vrm);
|
||||||
|
const meta = VRMA_FILES[i];
|
||||||
|
if (meta.loop) {
|
||||||
|
actions[meta.name] = mixer.clipAction(trimClip(clip, TRIM_DURATION_LOOP, true));
|
||||||
|
} else {
|
||||||
|
const action = mixer.clipAction(trimClip(clip, TRIM_DURATION_ONCE, false));
|
||||||
|
action.setLoop(THREE.LoopOnce);
|
||||||
|
action.clampWhenFinished = true;
|
||||||
|
actions[meta.name] = action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionsRef.current = actions;
|
||||||
|
|
||||||
|
const defaultAnim = 'fly_idle';
|
||||||
|
if (actions[defaultAnim]) {
|
||||||
|
actions[defaultAnim].setLoop(THREE.LoopRepeat).play();
|
||||||
|
currentActionRef.current = defaultAnim;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mixer.stopAllAction();
|
||||||
|
};
|
||||||
|
}, [gltf, vrmaGltfs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedAnimation || !actionsRef.current[selectedAnimation]) return;
|
||||||
|
const actions = actionsRef.current;
|
||||||
|
const meta = VRMA_FILES.find(v => v.name === selectedAnimation);
|
||||||
|
|
||||||
|
const current = currentActionRef.current;
|
||||||
|
if (current && actions[current]) {
|
||||||
|
actions[current].fadeOut(0.3);
|
||||||
|
}
|
||||||
|
const action = actions[selectedAnimation].reset().fadeIn(0.3).play();
|
||||||
|
if (meta?.loop) {
|
||||||
|
action.setLoop(THREE.LoopRepeat);
|
||||||
|
}
|
||||||
|
currentActionRef.current = selectedAnimation;
|
||||||
|
}, [animState]);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
mixerRef.current?.update(delta);
|
||||||
|
vrmRef.current?.update(delta);
|
||||||
|
|
||||||
|
// oneshot animation finished -> return to fly_idle
|
||||||
|
const current = currentActionRef.current;
|
||||||
|
const actions = actionsRef.current;
|
||||||
|
const meta = VRMA_FILES.find(v => v.name === current);
|
||||||
|
if (meta && !meta.loop && actions[current]) {
|
||||||
|
const action = actions[current];
|
||||||
|
if (!action.isRunning() || action.time >= action.getClip().duration - 0.1) {
|
||||||
|
action.stop();
|
||||||
|
actions.fly_idle?.reset().setLoop(THREE.LoopRepeat).fadeIn(0.3).play();
|
||||||
|
currentActionRef.current = 'fly_idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin WASD movement -> fly / fly_idle switching
|
||||||
|
if (adminMode && actions.fly && actions.fly_idle) {
|
||||||
|
const isMoving = keys.w;
|
||||||
|
if (isMoving && current !== 'fly') {
|
||||||
|
if (actions[current]) actions[current].stop();
|
||||||
|
actions.fly.reset().setLoop(THREE.LoopRepeat).fadeIn(0.3).play();
|
||||||
|
currentActionRef.current = 'fly';
|
||||||
|
} else if (!isMoving && current === 'fly') {
|
||||||
|
actions.fly.stop();
|
||||||
|
actions.fly_idle.reset().setLoop(THREE.LoopRepeat).fadeIn(0.3).play();
|
||||||
|
currentActionRef.current = 'fly_idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return <primitive object={gltf.scene} />;
|
||||||
|
}
|
||||||
57
src/controls/CameraRig.jsx
Normal file
57
src/controls/CameraRig.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { Vector3, Quaternion } from 'three';
|
||||||
|
import { worldState, getSurfaceBasis, getSurfaceBasisCopy, getEllipsoidRadius } from '../worldState';
|
||||||
|
import { keys, adminMode } from './KeyInput';
|
||||||
|
|
||||||
|
// Pre-allocated temp objects
|
||||||
|
const _moveDir = new Vector3();
|
||||||
|
const _upVec = new Vector3();
|
||||||
|
const _stageQuat = new Quaternion();
|
||||||
|
const _basis = new Quaternion();
|
||||||
|
const _axisY = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
export function CameraSync() {
|
||||||
|
const { camera } = useThree();
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
worldState.quaternion.copy(camera.quaternion);
|
||||||
|
|
||||||
|
if (adminMode) {
|
||||||
|
_basis.copy(getSurfaceBasis(worldState.position));
|
||||||
|
const speed = worldState.speed * (keys.Shift ? 2.0 : 1.0) * delta;
|
||||||
|
|
||||||
|
_moveDir.set(0, 0, 0);
|
||||||
|
if (keys.w) _moveDir.z += 1;
|
||||||
|
|
||||||
|
_upVec.copy(worldState.position).normalize();
|
||||||
|
if (keys.e) worldState.position.addScaledVector(_upVec, speed);
|
||||||
|
if (keys.q) worldState.position.addScaledVector(_upVec, -speed);
|
||||||
|
|
||||||
|
if (_moveDir.lengthSq() > 0) {
|
||||||
|
_moveDir.normalize();
|
||||||
|
_moveDir.applyQuaternion(camera.quaternion);
|
||||||
|
_moveDir.applyQuaternion(_basis);
|
||||||
|
worldState.position.addScaledVector(_moveDir, speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minAltitude = getEllipsoidRadius(worldState.position) + 10;
|
||||||
|
if (worldState.position.length() < minAltitude) {
|
||||||
|
worldState.position.setLength(minAltitude);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowCamera() {
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!worldState.position) return;
|
||||||
|
_basis.copy(getSurfaceBasis(worldState.position));
|
||||||
|
state.camera.position.copy(worldState.position);
|
||||||
|
_stageQuat.setFromAxisAngle(_axisY, worldState.stageRotationY);
|
||||||
|
state.camera.quaternion.copy(_basis).multiply(worldState.quaternion).multiply(_stageQuat);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
32
src/controls/KeyInput.js
Normal file
32
src/controls/KeyInput.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const keys = { w: false, a: false, s: false, d: false, q: false, e: false, Shift: false };
|
||||||
|
|
||||||
|
// adminモード: Aキーで切り替え、WASD移動が有効になる
|
||||||
|
export let adminMode = false;
|
||||||
|
|
||||||
|
const adminListeners = [];
|
||||||
|
export function onAdminChange(fn) {
|
||||||
|
adminListeners.push(fn);
|
||||||
|
return () => {
|
||||||
|
const idx = adminListeners.indexOf(fn);
|
||||||
|
if (idx >= 0) adminListeners.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'KeyA' && !e.repeat) {
|
||||||
|
adminMode = !adminMode;
|
||||||
|
adminListeners.forEach(fn => fn(adminMode));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!adminMode) return;
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key in keys) keys[key] = true;
|
||||||
|
if (e.key === 'Shift') keys.Shift = true;
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', (e) => {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key in keys) keys[key] = false;
|
||||||
|
if (e.key === 'Shift') keys.Shift = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
9
src/main.jsx
Normal file
9
src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
25
src/ui/AnimationUI.jsx
Normal file
25
src/ui/AnimationUI.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { VRMA_FILES } from '../VrmCharacter';
|
||||||
|
|
||||||
|
const btnStyle = (active) => ({
|
||||||
|
padding: '6px 14px',
|
||||||
|
background: active ? 'rgba(80, 160, 255, 0.7)' : 'rgba(0, 0, 0, 0.6)',
|
||||||
|
color: 'white',
|
||||||
|
border: active ? '1px solid rgba(80, 160, 255, 0.9)' : '1px solid rgba(255, 255, 255, 0.3)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AnimationUI({ selectedAnimation, onSelect }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', bottom: 20, left: 20, zIndex: 100, display: 'flex', gap: 6, flexWrap: 'wrap', maxWidth: '80vw' }}>
|
||||||
|
{VRMA_FILES.map((v) => (
|
||||||
|
<button key={v.name} onClick={() => onSelect(v.name)} style={btnStyle(selectedAnimation === v.name)}>
|
||||||
|
{v.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/ui/LocationUI.jsx
Normal file
25
src/ui/LocationUI.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LOCATIONS, teleportTo } from '../worldState';
|
||||||
|
|
||||||
|
const btnStyle = {
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LocationUI() {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', top: 20, left: 20, zIndex: 100, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{LOCATIONS.map((loc) => (
|
||||||
|
<button key={loc.name} onClick={() => teleportTo(loc)} style={btnStyle}>
|
||||||
|
{loc.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/worldState.js
Normal file
71
src/worldState.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Vector3, Quaternion } from 'three';
|
||||||
|
import { Geodetic, PointOfView, radians } from '@takram/three-geospatial';
|
||||||
|
|
||||||
|
const EARTH_RADIUS = 6378137;
|
||||||
|
|
||||||
|
export const worldState = {
|
||||||
|
position: new Vector3(0, EARTH_RADIUS + 100000, 0),
|
||||||
|
quaternion: new Quaternion(),
|
||||||
|
speed: 1000.0,
|
||||||
|
stageRotationY: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-allocated temp objects (avoid GC in useFrame)
|
||||||
|
const _up = new Vector3();
|
||||||
|
const _quat = new Quaternion();
|
||||||
|
const _unitY = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
export function getSurfaceBasis(position) {
|
||||||
|
if (!position) return _quat.identity();
|
||||||
|
_up.copy(position).normalize();
|
||||||
|
if (_up.lengthSq() < 0.1) return _quat.identity();
|
||||||
|
return _quat.setFromUnitVectors(_unitY, _up);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-mutating version for cases that need a copy
|
||||||
|
const _basisOut = new Quaternion();
|
||||||
|
export function getSurfaceBasisCopy(position) {
|
||||||
|
getSurfaceBasis(position);
|
||||||
|
return _basisOut.copy(_quat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEllipsoidRadius(position) {
|
||||||
|
const a = 6378137.0;
|
||||||
|
const b = 6356752.314245;
|
||||||
|
const r = position.length();
|
||||||
|
if (r < 100) return a;
|
||||||
|
const sinPhi = position.z / r;
|
||||||
|
const cosPhi = Math.sqrt(1 - sinPhi * sinPhi);
|
||||||
|
const aCos = a * cosPhi;
|
||||||
|
const bSin = b * sinPhi;
|
||||||
|
return Math.sqrt(
|
||||||
|
((a * aCos) ** 2 + (b * bSin) ** 2) / (aCos ** 2 + bSin ** 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCATIONS = [
|
||||||
|
{ name: 'Tokyo', longitude: 139.7671, latitude: 35.6812, heading: 180, pitch: -5, distance: 1100 },
|
||||||
|
{ name: 'Fuji', longitude: 138.7278, latitude: 35.3206, heading: 0, pitch: -10, distance: 4000 },
|
||||||
|
{ name: 'Space', longitude: 139.7671, latitude: 35.6812, heading: 0, pitch: -90, distance: 100000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function teleportTo(location) {
|
||||||
|
const { longitude, latitude, heading, pitch, distance } = location;
|
||||||
|
const position = new Vector3();
|
||||||
|
const globalQuaternion = new Quaternion();
|
||||||
|
const up = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
const truePosition = new Geodetic(radians(longitude), radians(latitude), distance).toECEF();
|
||||||
|
|
||||||
|
new PointOfView(distance, radians(heading), radians(pitch)).decompose(
|
||||||
|
new Geodetic(radians(longitude), radians(latitude)).toECEF(),
|
||||||
|
position,
|
||||||
|
globalQuaternion,
|
||||||
|
up
|
||||||
|
);
|
||||||
|
|
||||||
|
worldState.position.copy(truePosition);
|
||||||
|
|
||||||
|
const basis = getSurfaceBasisCopy(truePosition);
|
||||||
|
worldState.quaternion.copy(basis.invert().multiply(globalQuaternion));
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/',
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user