1
0
This commit is contained in:
2026-03-07 03:35:29 +09:00
commit efc513be15
18 changed files with 1103 additions and 0 deletions

33
.github/workflows/cf-pages.yml vendored Normal file
View 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
View 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

4
README.md Normal file
View File

@@ -0,0 +1,4 @@
## vrma
> ue5 -> glb -> blendder -> vrma

17
index.html Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
vrm.syui.ai

174
src/App.jsx Normal file
View 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
View 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
View 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
View 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
View 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} />;
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/',
plugins: [react()],
})