diff --git a/src/App.jsx b/src/App.jsx index 3291cd7..3df3569 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,6 +6,7 @@ import AvatarScene from './AvatarScene'; import { LOCATIONS, teleportTo, worldState } from './worldState'; import { adminMode, onAdminChange } from './controls/KeyInput'; import ControlPanel from './ui/ControlPanel'; +import LoadingScreen from './ui/LoadingScreen'; const ACTION_SEQUENCE = ['attack', 'skill', 'jump', 'fly_dodge', 'damage']; const TELEPORT_ANIM = 'fly_dodge'; @@ -13,7 +14,70 @@ 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'; +const BASE_URL = import.meta.env.BASE_URL; +const VRM_FILES = ['ai.vrm', 'ai_mode.vrm']; + export default function App() { + const [loadProgress, setLoadProgress] = useState(0); + const [loaded, setLoaded] = useState(false); + const [fadeOut, setFadeOut] = useState(false); + + useEffect(() => { + let cancelled = false; + const total = VRM_FILES.length; + let done = 0; + + // Use XMLHttpRequest for progress tracking + populate browser cache + function loadFile(file) { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', `${BASE_URL}model/${file}`, true); + xhr.responseType = 'arraybuffer'; + xhr.onprogress = (e) => { + if (e.lengthComputable && !cancelled) { + const fileProgress = e.loaded / e.total; + setLoadProgress(((done + fileProgress) / total) * 100); + } + }; + xhr.onload = () => { + done++; + if (!cancelled) setLoadProgress((done / total) * 100); + resolve(); + }; + xhr.onerror = () => { done++; resolve(); }; + xhr.send(); + }); + } + + // Load sequentially for accurate progress + loadFile(VRM_FILES[0]) + .then(() => loadFile(VRM_FILES[1])) + .then(() => { + if (!cancelled) { + setLoadProgress(100); + // Mount app behind loading screen, let it initialize + setTimeout(() => setLoaded(true), 300); + // Then fade out loading screen after app has time to render + setTimeout(() => setFadeOut(true), 2000); + } + }); + + return () => { cancelled = true; }; + }, []); + + return ( + <> + {loaded && ( +
+ +
+ )} + + + ); +} + +function AppMain() { const [animState, setAnimState] = useState({ name: 'fly_idle', count: 0 }); const [isAdmin, setIsAdmin] = useState(false); const [view, setView] = useState('avatar'); // 'avatar' | 'nasa' @@ -29,7 +93,7 @@ export default function App() { const [scoreTimeLeft, setScoreTimeLeft] = useState(0); const scoreEndRef = useRef(0); const scoreRafRef = useRef(null); - const [crowns, setCrowns] = useState({ bronze: false, silver: false, gold: false }); + const [crowns, setCrowns] = useState({ bronze: 0, silver: 0, gold: 0 }); const langRef = useRef('en'); const volumeRef = useRef(0); const voiceIndexRef = useRef(0); @@ -201,9 +265,9 @@ export default function App() { scoreTimerRef.current = setTimeout(() => { setScore(prev => { setCrowns(c => ({ - bronze: c.bronze || prev >= 100, - silver: c.silver || prev >= 100000, - gold: c.gold || prev >= 1000000, + bronze: (prev >= 100000 && !c.bronze) ? prev : c.bronze, + silver: (prev >= 500000 && !c.silver) ? prev : c.silver, + gold: (prev >= 1000000 && !c.gold) ? prev : c.gold, })); return 0; }); @@ -392,41 +456,43 @@ export default function App() { display: 'flex', flexDirection: 'column', gap: 4, - alignItems: 'center', }}> -
- {crowns.gold && ( - gold - )} -
-
- {crowns.silver && ( - silver - )} -
-
- {crowns.bronze && ( - bronze - )} -
+ {crowns.gold > 0 && ( +
+ gold + {crowns.gold} +
+ )} + {crowns.silver > 0 && ( +
+ silver + {crowns.silver} +
+ )} + {crowns.bronze > 0 && ( +
+ bronze + {crowns.bronze} +
+ )} )} diff --git a/src/VrmCharacter.jsx b/src/VrmCharacter.jsx index 19ee8ae..3305889 100644 --- a/src/VrmCharacter.jsx +++ b/src/VrmCharacter.jsx @@ -54,6 +54,14 @@ function trimClip(clip, duration, addLoopPose) { return clip; } +// Preload both VRM models (parse + cache in useLoader) +const VRM_MODELS = ['ai.vrm', 'ai_mode.vrm']; +VRM_MODELS.forEach(model => { + useLoader.preload(GLTFLoader, `${BASE_URL}model/${model}`, (loader) => { + loader.register((parser) => new VRMLoaderPlugin(parser)); + }); +}); + export default function VrmCharacter({ selectedAnimation: animState, vrmModel = 'ai.vrm' }) { const selectedAnimation = animState?.name || animState; const mixerRef = useRef(null); diff --git a/src/ui/LoadingScreen.jsx b/src/ui/LoadingScreen.jsx new file mode 100644 index 0000000..a404f50 --- /dev/null +++ b/src/ui/LoadingScreen.jsx @@ -0,0 +1,56 @@ +import React from 'react'; + +const containerStyle = { + position: 'fixed', + top: 0, + left: 0, + width: '100vw', + height: '100vh', + background: '#000', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, + transition: 'opacity 0.8s ease', +}; + +const barOuterStyle = { + width: '240px', + height: '2px', + background: 'rgba(255,255,255,0.1)', + borderRadius: '1px', + overflow: 'hidden', + marginTop: 16, +}; + +const labelStyle = { + color: 'rgba(255,255,255,0.4)', + fontSize: '11px', + fontFamily: 'monospace', + letterSpacing: '2px', + marginTop: 10, +}; + +export default function LoadingScreen({ progress, fadeOut }) { + return ( +
+
+
+
+
+ {progress < 100 ? `${Math.floor(progress)}%` : 'READY'} +
+
+ ); +}