diff --git a/package.json b/package.json
index bb6faef..702bd0d 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@atproto/api": "^0.19.3",
+ "@atproto/oauth-client-browser": "^0.3.41",
"@pixiv/three-vrm": "^3.4.4",
"@pixiv/three-vrm-animation": "^3.4.4",
"@react-three/drei": "^10.7.7",
diff --git a/src/App.jsx b/src/App.jsx
index e5b03c5..00460bd 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -4,19 +4,26 @@ import { Canvas } from '@react-three/fiber';
import AtmosphereScene from './AtmosphereScene';
import AvatarScene from './AvatarScene';
import { LOCATIONS, teleportTo, worldState } from './worldState';
-import { adminMode, onAdminChange } from './controls/KeyInput';
+import { onAdminChange } from './controls/KeyInput';
import ControlPanel from './ui/ControlPanel';
import LoadingScreen from './ui/LoadingScreen';
+import { login as authLogin, initSession, isLoggedIn, saveScore, getHandle, logout } from './lib/auth';
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 BURST_COOLDOWN = 10000;
+const SKILL_COOLDOWN = 3000;
+const ACTION_COOLDOWN = 3000;
+const SCORE_DURATION = 20000;
+const CROWN_THRESHOLDS = { bronze: 100000, silver: 500000, gold: 1000000 };
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'];
+// --- Loading wrapper ---
export default function App() {
const [loadProgress, setLoadProgress] = useState(0);
const [loaded, setLoaded] = useState(false);
@@ -27,7 +34,6 @@ export default function App() {
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();
@@ -35,31 +41,22 @@ export default function App() {
xhr.responseType = 'arraybuffer';
xhr.onprogress = (e) => {
if (e.lengthComputable && !cancelled) {
- const fileProgress = e.loaded / e.total;
- setLoadProgress(((done + fileProgress) / total) * 100);
+ setLoadProgress(((done + e.loaded / e.total) / total) * 100);
}
};
- xhr.onload = () => {
- done++;
- if (!cancelled) setLoadProgress((done / total) * 100);
- resolve();
- };
+ 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);
- }
+ if (cancelled) return;
+ setLoadProgress(100);
+ setTimeout(() => setLoaded(true), 300);
+ setTimeout(() => setFadeOut(true), 2000);
});
return () => { cancelled = true; };
@@ -77,10 +74,57 @@ export default function App() {
);
}
+// --- Helpers ---
+function playSound(path, volume) {
+ const audio = new Audio(`${BASE_URL}${path}`);
+ audio.volume = volume;
+ audio.play().catch(() => {});
+ return audio;
+}
+
+function calcDamage(vrmModel) {
+ const baseDmg = Math.floor(Math.random() * 100000) + 1;
+ const modeMult = vrmModel === 'ai_mode.vrm' ? 2 : 1;
+ const hour = worldState.currentHour;
+ const hourMult = Math.max(1, Math.round(12 - Math.abs(hour - 12)) + 1);
+ const locMult = { Tokyo: 1, Fuji: 2, Space: 3 }[worldState.currentLocation] || 1;
+ return baseDmg * modeMult * hourMult * locMult;
+}
+
+// --- Crown row component ---
+function CrownRow({ tier, score, filter, toggleBgm, playing }) {
+ if (!score) return null;
+ return (
+
+

+
{score}
+

toggleBgm(tier)}
+ style={{ width: 14, height: 14, filter: 'brightness(0) invert(1)', opacity: playing ? 1 : 0.4, cursor: 'pointer' }}
+ />
+
+ );
+}
+
+const CROWN_FILTERS = {
+ gold: 'brightness(0) invert(1) sepia(1) saturate(10) hue-rotate(330deg)',
+ silver: 'brightness(0) invert(1) brightness(0.9)',
+ bronze: 'brightness(0) invert(1) sepia(1) saturate(10) hue-rotate(90deg)',
+};
+
+// --- Main app ---
function AppMain() {
+ // State
const [animState, setAnimState] = useState({ name: 'fly_idle', count: 0 });
const [isAdmin, setIsAdmin] = useState(false);
- const [view, setView] = useState('avatar'); // 'avatar' | 'nasa'
+ const [view, setView] = useState('avatar');
const [timeScale, setTimeScale] = useState(100);
const [camSpeed, setCamSpeed] = useState(0.05);
const [vrmModel, setVrmModel] = useState('ai.vrm');
@@ -89,53 +133,48 @@ function AppMain() {
const [burstSky, setBurstSky] = useState(null);
const [damageNum, setDamageNum] = useState(null);
const [score, setScore] = useState(0);
- const scoreTimerRef = useRef(null);
const [scoreTimeLeft, setScoreTimeLeft] = useState(0);
- const scoreEndRef = useRef(0);
- const scoreRafRef = useRef(null);
const [crowns, setCrowns] = useState({ bronze: 0, silver: 0, gold: 0 });
const [playingBgm, setPlayingBgm] = useState(null);
- const bgmRef = useRef(null);
- const toggleBgm = useCallback((tier) => {
- if (bgmRef.current) {
- bgmRef.current.pause();
- bgmRef.current = null;
- if (playingBgm === tier) { setPlayingBgm(null); return; }
- }
- if (volumeRef.current < 0.5) { handleVolumeChange(0.5); }
- const audio = new Audio(`${BASE_URL}music/${tier}.mp3`);
- audio.volume = volumeRef.current;
- audio.loop = true;
- audio.play().catch(() => {});
- audio.onended = () => setPlayingBgm(null);
- bgmRef.current = audio;
- setPlayingBgm(tier);
- }, [playingBgm]);
+ // Auth state
+ const [authDid, setAuthDid] = useState(null);
+ const [authLoading, setAuthLoading] = useState(false);
+ const [showLogin, setShowLogin] = useState(false);
+ const [handleInput, setHandleInput] = useState('');
+ const [saveStatus, setSaveStatus] = useState(null);
+ const [savedHandle, setSavedHandle] = useState(null);
+
+ // Refs
const langRef = useRef('en');
const volumeRef = useRef(0);
- const voiceIndexRef = useRef(0);
- const voicePattern = ['normal','normal','normal','normal','normal','normal','normal','normal','skill','skill'];
+ const bgmRef = useRef(null);
+ const windRef = useRef(null);
+ const countRef = useRef(0);
const actionIndexRef = useRef(0);
const teleportIndexRef = useRef(0);
- const countRef = useRef(0);
+ const actionCoolRef = useRef(0);
+ const burstCoolRef = useRef(0);
+ const skillCoolRef = useRef(0);
+ const scoreTimerRef = useRef(null);
+ const scoreEndRef = useRef(0);
+ const scoreRafRef = useRef(null);
+ const viewRef = useRef(view);
+ const appStartRef = useRef(Date.now());
+ const lastSwitchRef = useRef(0);
+ const doSkillRef = useRef(null);
+ const doBurstRef = useRef(null);
- useEffect(() => {
- return onAdminChange((v) => setIsAdmin(v));
- }, []);
+ viewRef.current = view;
- // Background wind sound loop
- const windRef = useRef(null);
- useEffect(() => {
- const wind = new Audio(`${import.meta.env.BASE_URL}sound/wind.mp3`);
- wind.loop = true;
- wind.volume = volumeRef.current;
- wind.play().catch(() => {});
- windRef.current = wind;
- return () => { wind.pause(); wind.src = ''; };
+ // --- Callbacks ---
+ const playAnim = useCallback((name) => {
+ countRef.current += 1;
+ setAnimState({ name, count: countRef.current });
}, []);
const handleLangChange = useCallback((v) => { setLang(v); langRef.current = v; }, []);
+
const handleVolumeChange = useCallback((v) => {
setVolume(v);
volumeRef.current = v;
@@ -143,88 +182,21 @@ function AppMain() {
if (bgmRef.current) bgmRef.current.volume = v;
}, []);
- useEffect(() => {
- if (!windRef.current) return;
- if (view === 'nasa') {
- windRef.current.pause();
- } else {
- windRef.current.play().catch(() => {});
+ const toggleBgm = useCallback((tier) => {
+ if (bgmRef.current) {
+ bgmRef.current.pause();
+ bgmRef.current = null;
+ if (playingBgm === tier) { setPlayingBgm(null); return; }
}
- }, [view]);
-
- const playSkillVoice = useCallback(() => {
- const type = Math.random() < 1/6 ? 'skill' : 'normal';
- const suffix = langRef.current === 'en' ? '_en' : '';
- const file = `${import.meta.env.BASE_URL}voice/ai/${type}_1${suffix}.mp3`;
- const audio = new Audio(file);
+ if (volumeRef.current < 0.5) handleVolumeChange(0.5);
+ const audio = new Audio(`${BASE_URL}music/${tier}.mp3`);
audio.volume = volumeRef.current;
+ audio.loop = true;
audio.play().catch(() => {});
- }, []);
-
- const playAnim = useCallback((name) => {
- countRef.current += 1;
- setAnimState({ name, count: countRef.current });
- }, []);
-
- const actionCoolRef = useRef(0);
- const doSkillRef = useRef(null);
-
- const handleKey = useCallback((e) => {
- if (e.code === 'Escape') {
- setView(view === 'nasa' ? 'avatar' : 'nasa');
- return;
- }
- if (e.code === 'Space') {
- e.preventDefault();
- const now = Date.now();
- if (now - actionCoolRef.current < 3000) return;
- actionCoolRef.current = now;
- 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();
- doSkillRef.current?.();
- } else if (e.code === 'KeyB') {
- e.preventDefault();
- doBurstRef.current?.();
- }
- }, [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]);
+ audio.onended = () => setPlayingBgm(null);
+ bgmRef.current = audio;
+ setPlayingBgm(tier);
+ }, [playingBgm, handleVolumeChange]);
const doTeleport = useCallback(() => {
const idx = teleportIndexRef.current;
@@ -234,48 +206,31 @@ function AppMain() {
teleportIndexRef.current = idx + 1;
}, [playAnim]);
- const burstCoolRef = useRef(0);
- const doBurstRef = useRef(null);
const doBurst = useCallback(() => {
const now = Date.now();
- if (now - burstCoolRef.current < 10000) return;
+ if (now - burstCoolRef.current < BURST_COOLDOWN) return;
burstCoolRef.current = now;
playAnim('burst');
- // Sky: sunset → night during burst
+
+ // Sky effect
setBurstSky(new Date('2024-06-21T17:30:00'));
- if (windRef.current) { windRef.current.pause(); }
- const burstSound = new Audio(`${import.meta.env.BASE_URL}sound/burst.mp3`);
- burstSound.volume = volumeRef.current;
- burstSound.play().catch(() => {});
+ if (windRef.current) windRef.current.pause();
+ playSound('sound/burst.mp3', volumeRef.current);
setTimeout(() => {
setBurstSky(null);
- if (windRef.current) { windRef.current.play().catch(() => {}); }
+ if (windRef.current) windRef.current.play().catch(() => {});
}, 4500);
- // Damage number - show during blackhole phase
+
+ // Damage + score
setTimeout(() => {
- const burstEnd = new Audio(`${BASE_URL}sound/burst_end.mp3`);
- burstEnd.volume = volumeRef.current;
- burstEnd.play().catch(() => {});
- const baseDmg = Math.floor(Math.random() * 100000) + 1;
- // x2 if ai_mode
- const modeMult = vrmModel === 'ai_mode.vrm' ? 2 : 1;
- // x1-x12 based on sun position (noon=x12, midnight=x1)
- const hour = worldState.currentHour;
- const hourMult = Math.max(1, Math.round(12 - Math.abs(hour - 12)) + 1);
- // x1-x3 based on location
- const locMult = { Tokyo: 1, Fuji: 2, Space: 3 }[worldState.currentLocation] || 1;
- const dmg = baseDmg * modeMult * hourMult * locMult;
- const x = 40 + Math.random() * 20;
- const y = 35 + Math.random() * 20;
- setDamageNum({ value: dmg, x, y });
+ playSound('sound/burst_end.mp3', volumeRef.current);
+ const dmg = calcDamage(vrmModel);
+ setDamageNum({ value: dmg, x: 40 + Math.random() * 20, y: 35 + Math.random() * 20 });
setTimeout(() => setDamageNum(null), 1500);
- // Score system
+
setScore(prev => {
- const newScore = prev + dmg;
- // Reset timer: 10s after first damage
- // Only start timer on first damage
if (!scoreTimerRef.current) {
- scoreEndRef.current = Date.now() + 20000;
+ scoreEndRef.current = Date.now() + SCORE_DURATION;
let lastLeft = -1;
const tick = () => {
const left = Math.max(0, Math.ceil((scoreEndRef.current - Date.now()) / 1000));
@@ -284,261 +239,286 @@ function AppMain() {
};
tick();
scoreTimerRef.current = setTimeout(() => {
- setScore(prev => {
+ setScore(s => {
setCrowns(c => ({
- bronze: (prev >= 100000 && !c.bronze) ? prev : c.bronze,
- silver: (prev >= 500000 && !c.silver) ? prev : c.silver,
- gold: (prev >= 1000000 && !c.gold) ? prev : c.gold,
+ bronze: (s >= CROWN_THRESHOLDS.bronze && !c.bronze) ? s : c.bronze,
+ silver: (s >= CROWN_THRESHOLDS.silver && !c.silver) ? s : c.silver,
+ gold: (s >= CROWN_THRESHOLDS.gold && !c.gold) ? s : c.gold,
}));
return 0;
});
setScoreTimeLeft(0);
scoreTimerRef.current = null;
- }, 20000);
+ }, SCORE_DURATION);
}
- return newScore;
+ return prev + dmg;
});
}, 2500);
+
+ // Voice
const suffix = langRef.current === 'en' ? '_en' : '';
- const burstType = Math.random() < 1/6 ? 'burst' : 'normal';
- const file = `${import.meta.env.BASE_URL}voice/ai/${burstType}_1${suffix}.mp3`;
- const audio = new Audio(file);
- audio.volume = volumeRef.current;
- audio.play().catch(() => {});
- }, [playAnim]);
+ const voiceType = Math.random() < 1 / 6 ? 'burst' : 'normal';
+ playSound(`voice/ai/${voiceType}_1${suffix}.mp3`, volumeRef.current);
+ }, [playAnim, vrmModel]);
doBurstRef.current = doBurst;
- const skillCoolRef = useRef(0);
const doSkill = useCallback(() => {
const now = Date.now();
- if (now - skillCoolRef.current < 3000) return;
+ if (now - skillCoolRef.current < SKILL_COOLDOWN) return;
skillCoolRef.current = now;
playAnim('skill');
- playSkillVoice();
+ const suffix = langRef.current === 'en' ? '_en' : '';
+ const voiceType = Math.random() < 1 / 6 ? 'skill' : 'normal';
+ playSound(`voice/ai/${voiceType}_1${suffix}.mp3`, volumeRef.current);
setVrmModel(prev => prev === 'ai.vrm' ? 'ai_mode.vrm' : 'ai.vrm');
- }, [playAnim, playSkillVoice]);
+ }, [playAnim]);
doSkillRef.current = doSkill;
- const appStartRef = useRef(Date.now());
- const lastSwitchRef = useRef(0);
const handleZoomOut = useCallback(() => {
const now = Date.now();
- // 起動後15秒、または切り替え後5秒はブロック
- if (now - appStartRef.current < 15000) return;
- if (now - lastSwitchRef.current < 5000) return;
+ if (now - appStartRef.current < 15000 || now - lastSwitchRef.current < 5000) return;
lastSwitchRef.current = now;
setView('nasa');
}, []);
+ // Auth
+ const handleLogin = useCallback(async () => {
+ if (!handleInput.trim()) return;
+ setAuthLoading(true);
+ try {
+ await authLogin(handleInput.trim());
+ } catch {
+ setAuthLoading(false);
+ }
+ }, [handleInput]);
+
+ const handleSave = useCallback(async () => {
+ if (!isLoggedIn()) return;
+ setSaveStatus('saving');
+ try {
+ const result = await saveScore(crowns);
+ if (result?.handle) setSavedHandle(result.handle);
+ setSaveStatus('saved');
+ } catch {
+ setSaveStatus('error');
+ setTimeout(() => setSaveStatus(null), 2000);
+ }
+ }, [crowns]);
+
+ const handleLogout = useCallback(async () => {
+ await logout();
+ setAuthDid(null);
+ setSavedHandle(null);
+ setSaveStatus(null);
+ }, []);
+
+ // --- Effects ---
+ useEffect(() => onAdminChange((v) => setIsAdmin(v)), []);
+
+ useEffect(() => {
+ initSession().then(async did => {
+ if (!did) return;
+ setAuthDid(did);
+ const h = await getHandle();
+ if (h) setSavedHandle(h);
+ }).catch(() => {});
+ }, []);
+
+ // Wind
+ useEffect(() => {
+ const wind = new Audio(`${BASE_URL}sound/wind.mp3`);
+ wind.loop = true;
+ wind.volume = volumeRef.current;
+ wind.play().catch(() => {});
+ windRef.current = wind;
+ return () => { wind.pause(); wind.src = ''; };
+ }, []);
+
+ useEffect(() => {
+ if (!windRef.current) return;
+ view === 'nasa' ? windRef.current.pause() : windRef.current.play().catch(() => {});
+ }, [view]);
+
+ // Keyboard
+ const handleKey = useCallback((e) => {
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
+ if (e.code === 'Escape') { setView(v => v === 'nasa' ? 'avatar' : 'nasa'); return; }
+ if (e.code === 'Space') {
+ e.preventDefault();
+ const now = Date.now();
+ if (now - actionCoolRef.current < ACTION_COOLDOWN) return;
+ actionCoolRef.current = now;
+ const idx = actionIndexRef.current;
+ playAnim(ACTION_SEQUENCE[idx % ACTION_SEQUENCE.length]);
+ actionIndexRef.current = idx + 1;
+ } else if (e.code === 'KeyT') { e.preventDefault(); doTeleport(); }
+ else if (e.code === 'KeyS') { e.preventDefault(); doSkillRef.current?.(); }
+ else if (e.code === 'KeyB') { e.preventDefault(); doBurstRef.current?.(); }
+ }, [playAnim, doTeleport]);
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [handleKey]);
+
+ // Auto teleport
+ useEffect(() => {
+ const scheduleNext = () => {
+ const delay = AUTO_INTERVAL_MIN + Math.random() * (AUTO_INTERVAL_MAX - AUTO_INTERVAL_MIN);
+ return setTimeout(() => {
+ if (viewRef.current !== 'nasa') {
+ const idx = teleportIndexRef.current;
+ teleportTo(LOCATIONS[idx % LOCATIONS.length]);
+ playAnim(TELEPORT_ANIM);
+ teleportIndexRef.current = idx + 1;
+ }
+ timerRef.current = scheduleNext();
+ }, delay);
+ };
+ const timerRef = { current: scheduleNext() };
+ return () => clearTimeout(timerRef.current);
+ }, [playAnim]);
+
+ // --- Render ---
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)',
+ 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';
+ const hasCrowns = crowns.bronze || crowns.silver || crowns.gold;
return (
- {/* NASA iframe layer - fully interactive */}
+ {/* NASA */}
- {/* NASA center button */}
{isNasa && (