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 && (
-

- )}
-
-
- {crowns.silver && (
-

- )}
-
-
- {crowns.bronze && (
-

- )}
-
+ {crowns.gold > 0 && (
+
+

+
{crowns.gold}
+
+ )}
+ {crowns.silver > 0 && (
+
+

+
{crowns.silver}
+
+ )}
+ {crowns.bronze > 0 && (
+
+

+
{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'}
+
+
+ );
+}