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 ( +
+ {tier} + {score} + play 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 */}