diff --git a/src/App.jsx b/src/App.jsx
index 5434e37..264b37a 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -5,6 +5,7 @@ import AtmosphereScene from './AtmosphereScene';
import AvatarScene from './AvatarScene';
import { LOCATIONS, teleportTo } from './worldState';
import { adminMode, onAdminChange } from './controls/KeyInput';
+import ControlPanel from './ui/ControlPanel';
const ACTION_SEQUENCE = ['attack', 'skill', 'jump', 'fly_dodge', 'damage'];
const TELEPORT_ANIM = 'fly_dodge';
@@ -16,6 +17,9 @@ export default function App() {
const [animState, setAnimState] = useState({ name: 'fly_idle', count: 0 });
const [isAdmin, setIsAdmin] = useState(false);
const [view, setView] = useState('avatar'); // 'avatar' | 'nasa'
+ const [timeScale, setTimeScale] = useState(100);
+ const [camSpeed, setCamSpeed] = useState(0.05);
+ const [vrmModel, setVrmModel] = useState('ai.vrm');
const actionIndexRef = useRef(0);
const teleportIndexRef = useRef(0);
const countRef = useRef(0);
@@ -50,6 +54,7 @@ export default function App() {
} else if (e.code === 'KeyS') {
e.preventDefault();
playAnim('skill');
+ setVrmModel(prev => prev === 'ai.vrm' ? 'ai_mode.vrm' : 'ai.vrm');
}
}, [playAnim, view]);
@@ -90,6 +95,7 @@ export default function App() {
const doSkill = useCallback(() => {
playAnim('skill');
+ setVrmModel(prev => prev === 'ai.vrm' ? 'ai_mode.vrm' : 'ai.vrm');
}, [playAnim]);
const appStartRef = useRef(Date.now());
@@ -154,17 +160,28 @@ export default function App() {
{/* Atmosphere background */}
diff --git a/src/AtmosphereScene.jsx b/src/AtmosphereScene.jsx
index ab68529..86dbde7 100644
--- a/src/AtmosphereScene.jsx
+++ b/src/AtmosphereScene.jsx
@@ -69,11 +69,13 @@ function GoogleMaps3DTiles() {
);
}
-export default function AtmosphereScene() {
+export default function AtmosphereScene({ timeScale: timeScaleProp }) {
const { gl } = useThree();
const sunRef = useRef();
const atmosphereRef = useRef();
const dateRef = useRef(new Date(INITIAL_DATE));
+ const timeScaleRef = useRef(timeScaleProp ?? TIME_SCALE);
+ timeScaleRef.current = timeScaleProp ?? TIME_SCALE;
const [weather, setWeather] = useState(WEATHER_PRESETS[1]);
useEffect(() => {
@@ -93,7 +95,7 @@ export default function AtmosphereScene() {
useFrame((_, delta) => {
const currentDate = dateRef.current;
- currentDate.setTime(currentDate.getTime() + delta * TIME_SCALE * 1000);
+ currentDate.setTime(currentDate.getTime() + delta * timeScaleRef.current * 1000);
if (atmosphereRef.current) {
atmosphereRef.current.updateByDate(currentDate);
diff --git a/src/AvatarScene.jsx b/src/AvatarScene.jsx
index ef1cec3..a6c738c 100644
--- a/src/AvatarScene.jsx
+++ b/src/AvatarScene.jsx
@@ -31,12 +31,13 @@ const CAM_APPROACH_SPEED = 0.08; // 接近の補間速度
// --- Components ---
-function RotatingStage({ children }) {
+function RotatingStage({ children, speed }) {
const groupRef = useRef(null);
useFrame(({ clock }) => {
if (groupRef.current) {
- const angle = clock.getElapsedTime() * STAGE_ROTATE_SPEED;
+ const s = speed != null ? speed : STAGE_ROTATE_SPEED;
+ const angle = clock.getElapsedTime() * s;
groupRef.current.rotation.y = angle;
worldState.stageRotationY = angle;
}
@@ -111,7 +112,7 @@ function CameraDistanceMonitor({ onDistanceChange }) {
return null;
}
-export default function AvatarScene({ selectedAnimation: animState, onZoomOut }) {
+export default function AvatarScene({ selectedAnimation: animState, onZoomOut, camSpeed: camSpeedProp, vrmModel }) {
const selectedAnimation = animState;
const animName = animState?.name || animState;
const [effect, setEffect] = useState(null);
@@ -141,8 +142,8 @@ export default function AvatarScene({ selectedAnimation: animState, onZoomOut })
-
-
+
+
{effect && }
diff --git a/src/SkillEffects.jsx b/src/SkillEffects.jsx
index 1f0e151..78968fe 100644
--- a/src/SkillEffects.jsx
+++ b/src/SkillEffects.jsx
@@ -5,12 +5,12 @@ import * as THREE from 'three';
const SPHERE_PRESETS = {
sun: {
layers: [
- { radius: 0.3, segments: 64, size: 0.015, opacity: 0.8 },
- { radius: 0.22, segments: 48, size: 0.02, opacity: 0.5 },
- { radius: 0.38, segments: 40, size: 0.008, opacity: 0.3 },
+ { radius: 0.3, segments: 64, size: 0.015, opacity: 0.5 },
+ { radius: 0.22, segments: 48, size: 0.02, opacity: 0.3 },
+ { radius: 0.38, segments: 40, size: 0.008, opacity: 0.2 },
],
lightColor: '#ffaa22',
- lightIntensity: 3,
+ lightIntensity: 1.5,
rotateSpeed: 1.5,
pulseSpeed: 2.0,
pulseRange: 0.25,
@@ -22,11 +22,11 @@ const SPHERE_PRESETS = {
},
moon: {
layers: [
- { radius: 0.2, segments: 48, size: 0.01, opacity: 0.6 },
- { radius: 0.26, segments: 32, size: 0.006, opacity: 0.25 },
+ { radius: 0.2, segments: 48, size: 0.01, opacity: 0.4 },
+ { radius: 0.26, segments: 32, size: 0.006, opacity: 0.15 },
],
lightColor: '#aaccff',
- lightIntensity: 2,
+ lightIntensity: 1.0,
rotateSpeed: 0.8,
pulseSpeed: 1.0,
pulseRange: 0.12,
@@ -140,7 +140,7 @@ function EnergySphere({ type = 'sun', position = [0, 1.5, 0], scale = 1 }) {
/>
))}
-
+
);
}
diff --git a/src/VrmCharacter.jsx b/src/VrmCharacter.jsx
index ed60e0d..2fae135 100644
--- a/src/VrmCharacter.jsx
+++ b/src/VrmCharacter.jsx
@@ -9,7 +9,6 @@ import * as THREE from 'three';
import { keys, adminMode } from './controls/KeyInput';
const BASE_URL = import.meta.env.BASE_URL;
-const VRM_URL = `${BASE_URL}model/ai.vrm`;
export const VRMA_FILES = [
{ name: 'idle', label: '待機', file: 'idle.vrma', loop: true },
@@ -54,14 +53,15 @@ function trimClip(clip, duration, addLoopPose) {
return clip;
}
-export default function VrmCharacter({ selectedAnimation: animState }) {
+export default function VrmCharacter({ selectedAnimation: animState, vrmModel = 'ai.vrm' }) {
const selectedAnimation = animState?.name || animState;
const mixerRef = useRef(null);
const vrmRef = useRef(null);
const actionsRef = useRef({});
const currentActionRef = useRef(null);
- const gltf = useLoader(GLTFLoader, VRM_URL, (loader) => {
+ const vrmUrl = `${BASE_URL}model/${vrmModel}`;
+ const gltf = useLoader(GLTFLoader, vrmUrl, (loader) => {
loader.register((parser) => new VRMLoaderPlugin(parser));
});
diff --git a/src/ui/ControlPanel.jsx b/src/ui/ControlPanel.jsx
new file mode 100644
index 0000000..db47537
--- /dev/null
+++ b/src/ui/ControlPanel.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+
+const containerStyle = {
+ position: 'absolute',
+ top: 16,
+ right: 16,
+ zIndex: 2,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 8,
+ padding: '10px 12px',
+ background: 'rgba(0,0,0,0.3)',
+ borderRadius: '8px',
+ backdropFilter: 'blur(4px)',
+ minWidth: 140,
+};
+
+const labelStyle = {
+ color: 'rgba(255,255,255,0.7)',
+ fontSize: '11px',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+};
+
+const sliderStyle = {
+ width: '100%',
+ height: 4,
+ appearance: 'none',
+ background: 'rgba(255,255,255,0.2)',
+ borderRadius: 2,
+ outline: 'none',
+ cursor: 'pointer',
+};
+
+const btnStyle = {
+ padding: '4px 10px',
+ background: 'rgba(255,255,255,0.1)',
+ color: 'rgba(255,255,255,0.7)',
+ border: '1px solid rgba(255,255,255,0.2)',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '11px',
+ width: '100%',
+};
+
+export default function ControlPanel({ timeScale, onTimeScaleChange, camSpeed, onCamSpeedChange, onSkill }) {
+ return (
+
+
+
+ Time
+ {timeScale}x
+
+
onTimeScaleChange(Number(e.target.value))}
+ style={sliderStyle}
+ />
+
+
+
+ Camera
+ {camSpeed.toFixed(2)}
+
+
onCamSpeedChange(Number(e.target.value) / 100)}
+ style={sliderStyle}
+ />
+
+
+
+ );
+}