1
0

add oauth

This commit is contained in:
2026-03-08 16:44:09 +09:00
parent b662aed0fb
commit bfa04c8286
4 changed files with 438 additions and 327 deletions

View File

@@ -8,6 +8,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.19.3",
"@atproto/oauth-client-browser": "^0.3.41",
"@pixiv/three-vrm": "^3.4.4", "@pixiv/three-vrm": "^3.4.4",
"@pixiv/three-vrm-animation": "^3.4.4", "@pixiv/three-vrm-animation": "^3.4.4",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",

View File

@@ -4,19 +4,26 @@ import { Canvas } from '@react-three/fiber';
import AtmosphereScene from './AtmosphereScene'; import AtmosphereScene from './AtmosphereScene';
import AvatarScene from './AvatarScene'; import AvatarScene from './AvatarScene';
import { LOCATIONS, teleportTo, worldState } from './worldState'; import { LOCATIONS, teleportTo, worldState } from './worldState';
import { adminMode, onAdminChange } from './controls/KeyInput'; import { onAdminChange } from './controls/KeyInput';
import ControlPanel from './ui/ControlPanel'; import ControlPanel from './ui/ControlPanel';
import LoadingScreen from './ui/LoadingScreen'; 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 ACTION_SEQUENCE = ['attack', 'skill', 'jump', 'fly_dodge', 'damage'];
const TELEPORT_ANIM = 'fly_dodge'; const TELEPORT_ANIM = 'fly_dodge';
const AUTO_INTERVAL_MIN = 15000; const AUTO_INTERVAL_MIN = 15000;
const AUTO_INTERVAL_MAX = 40000; 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 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 BASE_URL = import.meta.env.BASE_URL;
const VRM_FILES = ['ai.vrm', 'ai_mode.vrm']; const VRM_FILES = ['ai.vrm', 'ai_mode.vrm'];
// --- Loading wrapper ---
export default function App() { export default function App() {
const [loadProgress, setLoadProgress] = useState(0); const [loadProgress, setLoadProgress] = useState(0);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
@@ -27,7 +34,6 @@ export default function App() {
const total = VRM_FILES.length; const total = VRM_FILES.length;
let done = 0; let done = 0;
// Use XMLHttpRequest for progress tracking + populate browser cache
function loadFile(file) { function loadFile(file) {
return new Promise((resolve) => { return new Promise((resolve) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@@ -35,31 +41,22 @@ export default function App() {
xhr.responseType = 'arraybuffer'; xhr.responseType = 'arraybuffer';
xhr.onprogress = (e) => { xhr.onprogress = (e) => {
if (e.lengthComputable && !cancelled) { if (e.lengthComputable && !cancelled) {
const fileProgress = e.loaded / e.total; setLoadProgress(((done + e.loaded / e.total) / total) * 100);
setLoadProgress(((done + fileProgress) / total) * 100);
} }
}; };
xhr.onload = () => { xhr.onload = () => { done++; if (!cancelled) setLoadProgress((done / total) * 100); resolve(); };
done++;
if (!cancelled) setLoadProgress((done / total) * 100);
resolve();
};
xhr.onerror = () => { done++; resolve(); }; xhr.onerror = () => { done++; resolve(); };
xhr.send(); xhr.send();
}); });
} }
// Load sequentially for accurate progress
loadFile(VRM_FILES[0]) loadFile(VRM_FILES[0])
.then(() => loadFile(VRM_FILES[1])) .then(() => loadFile(VRM_FILES[1]))
.then(() => { .then(() => {
if (!cancelled) { if (cancelled) return;
setLoadProgress(100); setLoadProgress(100);
// Mount app behind loading screen, let it initialize
setTimeout(() => setLoaded(true), 300); setTimeout(() => setLoaded(true), 300);
// Then fade out loading screen after app has time to render
setTimeout(() => setFadeOut(true), 2000); setTimeout(() => setFadeOut(true), 2000);
}
}); });
return () => { cancelled = true; }; 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 (
<div style={{
padding: '4px 8px',
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '4px',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<img src={`${BASE_URL}icon/crown.svg`} alt={tier} style={{ width: 16, height: 16, filter }} />
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px', fontFamily: 'monospace' }}>{score}</span>
<img
src={`${BASE_URL}icon/play.svg`} alt="play"
onClick={() => toggleBgm(tier)}
style={{ width: 14, height: 14, filter: 'brightness(0) invert(1)', opacity: playing ? 1 : 0.4, cursor: 'pointer' }}
/>
</div>
);
}
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() { function AppMain() {
// State
const [animState, setAnimState] = useState({ name: 'fly_idle', count: 0 }); const [animState, setAnimState] = useState({ name: 'fly_idle', count: 0 });
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [view, setView] = useState('avatar'); // 'avatar' | 'nasa' const [view, setView] = useState('avatar');
const [timeScale, setTimeScale] = useState(100); const [timeScale, setTimeScale] = useState(100);
const [camSpeed, setCamSpeed] = useState(0.05); const [camSpeed, setCamSpeed] = useState(0.05);
const [vrmModel, setVrmModel] = useState('ai.vrm'); const [vrmModel, setVrmModel] = useState('ai.vrm');
@@ -89,53 +133,48 @@ function AppMain() {
const [burstSky, setBurstSky] = useState(null); const [burstSky, setBurstSky] = useState(null);
const [damageNum, setDamageNum] = useState(null); const [damageNum, setDamageNum] = useState(null);
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const scoreTimerRef = useRef(null);
const [scoreTimeLeft, setScoreTimeLeft] = useState(0); const [scoreTimeLeft, setScoreTimeLeft] = useState(0);
const scoreEndRef = useRef(0);
const scoreRafRef = useRef(null);
const [crowns, setCrowns] = useState({ bronze: 0, silver: 0, gold: 0 }); const [crowns, setCrowns] = useState({ bronze: 0, silver: 0, gold: 0 });
const [playingBgm, setPlayingBgm] = useState(null); const [playingBgm, setPlayingBgm] = useState(null);
const bgmRef = useRef(null);
const toggleBgm = useCallback((tier) => { // Auth state
if (bgmRef.current) { const [authDid, setAuthDid] = useState(null);
bgmRef.current.pause(); const [authLoading, setAuthLoading] = useState(false);
bgmRef.current = null; const [showLogin, setShowLogin] = useState(false);
if (playingBgm === tier) { setPlayingBgm(null); return; } const [handleInput, setHandleInput] = useState('');
} const [saveStatus, setSaveStatus] = useState(null);
if (volumeRef.current < 0.5) { handleVolumeChange(0.5); } const [savedHandle, setSavedHandle] = useState(null);
const audio = new Audio(`${BASE_URL}music/${tier}.mp3`);
audio.volume = volumeRef.current; // Refs
audio.loop = true;
audio.play().catch(() => {});
audio.onended = () => setPlayingBgm(null);
bgmRef.current = audio;
setPlayingBgm(tier);
}, [playingBgm]);
const langRef = useRef('en'); const langRef = useRef('en');
const volumeRef = useRef(0); const volumeRef = useRef(0);
const voiceIndexRef = useRef(0); const bgmRef = useRef(null);
const voicePattern = ['normal','normal','normal','normal','normal','normal','normal','normal','skill','skill']; const windRef = useRef(null);
const countRef = useRef(0);
const actionIndexRef = useRef(0); const actionIndexRef = useRef(0);
const teleportIndexRef = 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(() => { viewRef.current = view;
return onAdminChange((v) => setIsAdmin(v));
}, []);
// Background wind sound loop // --- Callbacks ---
const windRef = useRef(null); const playAnim = useCallback((name) => {
useEffect(() => { countRef.current += 1;
const wind = new Audio(`${import.meta.env.BASE_URL}sound/wind.mp3`); setAnimState({ name, count: countRef.current });
wind.loop = true;
wind.volume = volumeRef.current;
wind.play().catch(() => {});
windRef.current = wind;
return () => { wind.pause(); wind.src = ''; };
}, []); }, []);
const handleLangChange = useCallback((v) => { setLang(v); langRef.current = v; }, []); const handleLangChange = useCallback((v) => { setLang(v); langRef.current = v; }, []);
const handleVolumeChange = useCallback((v) => { const handleVolumeChange = useCallback((v) => {
setVolume(v); setVolume(v);
volumeRef.current = v; volumeRef.current = v;
@@ -143,88 +182,21 @@ function AppMain() {
if (bgmRef.current) bgmRef.current.volume = v; if (bgmRef.current) bgmRef.current.volume = v;
}, []); }, []);
useEffect(() => { const toggleBgm = useCallback((tier) => {
if (!windRef.current) return; if (bgmRef.current) {
if (view === 'nasa') { bgmRef.current.pause();
windRef.current.pause(); bgmRef.current = null;
} else { if (playingBgm === tier) { setPlayingBgm(null); return; }
windRef.current.play().catch(() => {});
} }
}, [view]); if (volumeRef.current < 0.5) handleVolumeChange(0.5);
const audio = new Audio(`${BASE_URL}music/${tier}.mp3`);
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);
audio.volume = volumeRef.current; audio.volume = volumeRef.current;
audio.loop = true;
audio.play().catch(() => {}); audio.play().catch(() => {});
}, []); audio.onended = () => setPlayingBgm(null);
bgmRef.current = audio;
const playAnim = useCallback((name) => { setPlayingBgm(tier);
countRef.current += 1; }, [playingBgm, handleVolumeChange]);
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]);
const doTeleport = useCallback(() => { const doTeleport = useCallback(() => {
const idx = teleportIndexRef.current; const idx = teleportIndexRef.current;
@@ -234,48 +206,31 @@ function AppMain() {
teleportIndexRef.current = idx + 1; teleportIndexRef.current = idx + 1;
}, [playAnim]); }, [playAnim]);
const burstCoolRef = useRef(0);
const doBurstRef = useRef(null);
const doBurst = useCallback(() => { const doBurst = useCallback(() => {
const now = Date.now(); const now = Date.now();
if (now - burstCoolRef.current < 10000) return; if (now - burstCoolRef.current < BURST_COOLDOWN) return;
burstCoolRef.current = now; burstCoolRef.current = now;
playAnim('burst'); playAnim('burst');
// Sky: sunset → night during burst
// Sky effect
setBurstSky(new Date('2024-06-21T17:30:00')); setBurstSky(new Date('2024-06-21T17:30:00'));
if (windRef.current) { windRef.current.pause(); } if (windRef.current) windRef.current.pause();
const burstSound = new Audio(`${import.meta.env.BASE_URL}sound/burst.mp3`); playSound('sound/burst.mp3', volumeRef.current);
burstSound.volume = volumeRef.current;
burstSound.play().catch(() => {});
setTimeout(() => { setTimeout(() => {
setBurstSky(null); setBurstSky(null);
if (windRef.current) { windRef.current.play().catch(() => {}); } if (windRef.current) windRef.current.play().catch(() => {});
}, 4500); }, 4500);
// Damage number - show during blackhole phase
// Damage + score
setTimeout(() => { setTimeout(() => {
const burstEnd = new Audio(`${BASE_URL}sound/burst_end.mp3`); playSound('sound/burst_end.mp3', volumeRef.current);
burstEnd.volume = volumeRef.current; const dmg = calcDamage(vrmModel);
burstEnd.play().catch(() => {}); setDamageNum({ value: dmg, x: 40 + Math.random() * 20, y: 35 + Math.random() * 20 });
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 });
setTimeout(() => setDamageNum(null), 1500); setTimeout(() => setDamageNum(null), 1500);
// Score system
setScore(prev => { setScore(prev => {
const newScore = prev + dmg;
// Reset timer: 10s after first damage
// Only start timer on first damage
if (!scoreTimerRef.current) { if (!scoreTimerRef.current) {
scoreEndRef.current = Date.now() + 20000; scoreEndRef.current = Date.now() + SCORE_DURATION;
let lastLeft = -1; let lastLeft = -1;
const tick = () => { const tick = () => {
const left = Math.max(0, Math.ceil((scoreEndRef.current - Date.now()) / 1000)); const left = Math.max(0, Math.ceil((scoreEndRef.current - Date.now()) / 1000));
@@ -284,261 +239,286 @@ function AppMain() {
}; };
tick(); tick();
scoreTimerRef.current = setTimeout(() => { scoreTimerRef.current = setTimeout(() => {
setScore(prev => { setScore(s => {
setCrowns(c => ({ setCrowns(c => ({
bronze: (prev >= 100000 && !c.bronze) ? prev : c.bronze, bronze: (s >= CROWN_THRESHOLDS.bronze && !c.bronze) ? s : c.bronze,
silver: (prev >= 500000 && !c.silver) ? prev : c.silver, silver: (s >= CROWN_THRESHOLDS.silver && !c.silver) ? s : c.silver,
gold: (prev >= 1000000 && !c.gold) ? prev : c.gold, gold: (s >= CROWN_THRESHOLDS.gold && !c.gold) ? s : c.gold,
})); }));
return 0; return 0;
}); });
setScoreTimeLeft(0); setScoreTimeLeft(0);
scoreTimerRef.current = null; scoreTimerRef.current = null;
}, 20000); }, SCORE_DURATION);
} }
return newScore; return prev + dmg;
}); });
}, 2500); }, 2500);
// Voice
const suffix = langRef.current === 'en' ? '_en' : ''; const suffix = langRef.current === 'en' ? '_en' : '';
const burstType = Math.random() < 1/6 ? 'burst' : 'normal'; const voiceType = Math.random() < 1 / 6 ? 'burst' : 'normal';
const file = `${import.meta.env.BASE_URL}voice/ai/${burstType}_1${suffix}.mp3`; playSound(`voice/ai/${voiceType}_1${suffix}.mp3`, volumeRef.current);
const audio = new Audio(file); }, [playAnim, vrmModel]);
audio.volume = volumeRef.current;
audio.play().catch(() => {});
}, [playAnim]);
doBurstRef.current = doBurst; doBurstRef.current = doBurst;
const skillCoolRef = useRef(0);
const doSkill = useCallback(() => { const doSkill = useCallback(() => {
const now = Date.now(); const now = Date.now();
if (now - skillCoolRef.current < 3000) return; if (now - skillCoolRef.current < SKILL_COOLDOWN) return;
skillCoolRef.current = now; skillCoolRef.current = now;
playAnim('skill'); 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'); setVrmModel(prev => prev === 'ai.vrm' ? 'ai_mode.vrm' : 'ai.vrm');
}, [playAnim, playSkillVoice]); }, [playAnim]);
doSkillRef.current = doSkill; doSkillRef.current = doSkill;
const appStartRef = useRef(Date.now());
const lastSwitchRef = useRef(0);
const handleZoomOut = useCallback(() => { const handleZoomOut = useCallback(() => {
const now = Date.now(); const now = Date.now();
// 起動後15秒、または切り替え後5秒はブロック if (now - appStartRef.current < 15000 || now - lastSwitchRef.current < 5000) return;
if (now - appStartRef.current < 15000) return;
if (now - lastSwitchRef.current < 5000) return;
lastSwitchRef.current = now; lastSwitchRef.current = now;
setView('nasa'); setView('nasa');
}, []); }, []);
const layerStyle = { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }; // Auth
const handleLogin = useCallback(async () => {
if (!handleInput.trim()) return;
setAuthLoading(true);
try {
await authLogin(handleInput.trim());
} catch {
setAuthLoading(false);
}
}, [handleInput]);
const btnStyle = { const handleSave = useCallback(async () => {
padding: '8px 16px', if (!isLoggedIn()) return;
background: 'rgba(255,255,255,0.12)', setSaveStatus('saving');
color: '#fff', try {
border: '1px solid rgba(255,255,255,0.25)', const result = await saveScore(crowns);
borderRadius: '6px', if (result?.handle) setSavedHandle(result.handle);
cursor: 'pointer', setSaveStatus('saved');
fontSize: '13px', } catch {
backdropFilter: 'blur(4px)', 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)',
};
const isNasa = view === 'nasa'; const isNasa = view === 'nasa';
const hasCrowns = crowns.bronze || crowns.silver || crowns.gold;
return ( return (
<div style={{ position: 'relative', width: '100vw', height: '100vh', background: '#000' }}> <div style={{ position: 'relative', width: '100vw', height: '100vh', background: '#000' }}>
{/* NASA iframe layer - fully interactive */} {/* NASA */}
<iframe <iframe
src={NASA_URL} src={NASA_URL}
style={{ style={{ ...layerStyle, zIndex: isNasa ? 10 : -1, border: 'none', opacity: isNasa ? 1 : 0, transition: 'opacity 1.5s ease', pointerEvents: isNasa ? 'auto' : 'none' }}
...layerStyle, title="NASA Solar System" allowFullScreen allow="accelerometer; autoplay; encrypted-media; gyroscope"
zIndex: isNasa ? 10 : -1,
border: 'none',
opacity: isNasa ? 1 : 0,
transition: 'opacity 1.5s ease',
pointerEvents: isNasa ? 'auto' : 'none',
}}
title="NASA Solar System"
allowFullScreen
allow="accelerometer; autoplay; encrypted-media; gyroscope"
/> />
{/* NASA center button */}
{isNasa && ( {isNasa && (
<button <button
onClick={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }} onClick={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }}
onMouseEnter={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }} onMouseEnter={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }}
onTouchStart={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }} onTouchStart={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }}
style={{ style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 100, width: 40, height: 40, borderRadius: '50%', background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.15)', cursor: 'pointer', pointerEvents: 'auto' }}
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)', zIndex: 100,
width: 40, height: 40, borderRadius: '50%',
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.15)',
cursor: 'pointer', pointerEvents: 'auto',
}}
/> />
)} )}
{/* Atmosphere background */} {/* Sky */}
<div style={{ ...layerStyle, zIndex: 0, opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}> <div style={{ ...layerStyle, zIndex: 0, opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}>
<Canvas gl={{ alpha: true, antialias: true }}> <Canvas gl={{ alpha: true, antialias: true }}>
<AtmosphereScene timeScale={timeScale} overrideDate={burstSky} overrideTimeScale={burstSky ? 8000 : undefined} /> <AtmosphereScene timeScale={timeScale} overrideDate={burstSky} overrideTimeScale={burstSky ? 8000 : undefined} />
</Canvas> </Canvas>
</div> </div>
{/* Avatar layer */} {/* Avatar */}
<div style={{ ...layerStyle, zIndex: 1, pointerEvents: 'none', opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}> <div style={{ ...layerStyle, zIndex: 1, pointerEvents: 'none', opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}>
<div style={{ width: '100%', height: '100%', pointerEvents: isNasa ? 'none' : 'auto' }}> <div style={{ width: '100%', height: '100%', pointerEvents: isNasa ? 'none' : 'auto' }}>
<AvatarScene selectedAnimation={animState} onZoomOut={handleZoomOut} camSpeed={camSpeed} vrmModel={vrmModel} /> <AvatarScene selectedAnimation={animState} onZoomOut={handleZoomOut} camSpeed={camSpeed} vrmModel={vrmModel} />
</div> </div>
</div> </div>
{/* Control panel */} {/* Controls */}
{!isNasa && ( {!isNasa && (
<ControlPanel <ControlPanel
timeScale={timeScale} timeScale={timeScale} onTimeScaleChange={setTimeScale}
onTimeScaleChange={setTimeScale} camSpeed={camSpeed} onCamSpeedChange={setCamSpeed}
camSpeed={camSpeed} onSkill={doSkill} onBurst={doBurst}
onCamSpeedChange={setCamSpeed} lang={lang} onLangChange={handleLangChange}
onSkill={doSkill} volume={volume} onVolumeChange={handleVolumeChange}
onBurst={doBurst}
lang={lang}
onLangChange={handleLangChange}
volume={volume}
onVolumeChange={handleVolumeChange}
/> />
)} )}
{/* Score */} {/* Score */}
{!isNasa && score > 0 && ( {!isNasa && score > 0 && (
<div style={{ <div style={{ position: 'absolute', top: 16, left: 16, zIndex: 3, display: 'flex', alignItems: 'center', gap: 8, pointerEvents: 'none' }}>
position: 'absolute', <span style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', fontFamily: 'monospace' }}>SCORE {score}</span>
top: 16,
left: 16,
zIndex: 3,
display: 'flex',
alignItems: 'center',
gap: 8,
pointerEvents: 'none',
}}>
<span style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px',
fontFamily: 'monospace',
}}>
SCORE {score}
</span>
{scoreTimeLeft > 0 && ( {scoreTimeLeft > 0 && (
<span style={{ <span style={{ padding: '2px 8px', background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px', color: 'rgba(255,255,255,0.5)', fontSize: '12px', fontFamily: 'monospace' }}>
padding: '2px 8px',
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '4px',
color: 'rgba(255, 255, 255, 0.5)',
fontSize: '12px',
fontFamily: 'monospace',
}}>
{scoreTimeLeft}s {scoreTimeLeft}s
</span> </span>
)} )}
</div> </div>
)} )}
{/* Damage number */} {/* Damage */}
{damageNum && ( {damageNum && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute', left: `${damageNum.x}%`, top: `${damageNum.y}%`, zIndex: 50,
left: `${damageNum.x}%`, color: 'rgba(255,220,50,0.75)', fontSize: '48px', fontWeight: 'bold', fontFamily: 'monospace',
top: `${damageNum.y}%`, textShadow: '0 0 10px rgba(255,180,0,0.5)', pointerEvents: 'none', animation: 'dmgFade 1.5s ease-out forwards',
zIndex: 50,
color: 'rgba(255, 220, 50, 0.75)',
fontSize: '48px',
fontWeight: 'bold',
fontFamily: 'monospace',
textShadow: '0 0 10px rgba(255, 180, 0, 0.5)',
pointerEvents: 'none',
animation: 'dmgFade 1.5s ease-out forwards',
}}> }}>
{damageNum.value} {damageNum.value}
</div> </div>
)} )}
<style>{` <style>{`@keyframes dmgFade { 0% { opacity:1; transform:translateY(0) scale(1); } 100% { opacity:0; transform:translateY(-40px) scale(1.2); } }`}</style>
@keyframes dmgFade {
0% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-40px) scale(1.2); }
}
`}</style>
{/* Clear crowns - gold(top), silver, bronze(bottom) */} {/* Crown panel */}
{!isNasa && (crowns.bronze || crowns.silver || crowns.gold) && ( {!isNasa && (hasCrowns || isAdmin) && (
<div style={{ <div style={{ position: 'fixed', bottom: 'max(16px, env(safe-area-inset-bottom, 0px))', right: 16, zIndex: 100, display: 'flex', flexDirection: 'column', gap: 4 }}>
position: 'fixed', {/* Auth button */}
bottom: 'max(16px, env(safe-area-inset-bottom, 0px))', {!authDid ? (
right: 16, <div style={{ padding: '4px 8px', background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
zIndex: 100, onClick={() => setShowLogin(!showLogin)}>
display: 'flex', <img src={`${BASE_URL}icon/bluesky.svg`} alt="login" style={{ width: 14, height: 14, filter: 'brightness(0) invert(1)', opacity: 0.6 }} />
flexDirection: 'column', <span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px', fontFamily: 'monospace' }}>login</span>
gap: 4,
}}>
{crowns.gold > 0 && (
<div style={{
padding: '4px 8px',
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '4px',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<img src={`${import.meta.env.BASE_URL}icon/crown.svg`} alt="gold" style={{ width: 16, height: 16, filter: 'brightness(0) invert(1) sepia(1) saturate(10) hue-rotate(330deg)' }} />
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px', fontFamily: 'monospace' }}>{crowns.gold}</span>
<img
src={`${import.meta.env.BASE_URL}icon/play.svg`} alt="play"
onClick={() => toggleBgm('gold')}
style={{ width: 14, height: 14, filter: 'brightness(0) invert(1)', opacity: playingBgm === 'gold' ? 1 : 0.4, cursor: 'pointer' }}
/>
</div> </div>
)} ) : (
{crowns.silver > 0 && ( <div style={{ padding: '4px 8px', background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
<div style={{ onClick={handleSave}>
padding: '4px 8px', <img src={`${BASE_URL}icon/bluesky.svg`} alt="save" style={{ width: 14, height: 14, filter: 'brightness(0) invert(1)', opacity: 0.6 }} />
background: 'rgba(255,255,255,0.1)', <span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px', fontFamily: 'monospace' }}>
border: '1px solid rgba(255,255,255,0.2)', {saveStatus === 'saving' ? 'saving...' : saveStatus === 'saved' ? 'saved!' : saveStatus === 'error' ? 'error' : 'save'}
borderRadius: '4px', </span>
display: 'flex', alignItems: 'center', gap: 6,
}}>
<img src={`${import.meta.env.BASE_URL}icon/crown.svg`} alt="silver" style={{ width: 16, height: 16, filter: 'brightness(0) invert(1) brightness(0.9)' }} />
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px', fontFamily: 'monospace' }}>{crowns.silver}</span>
<img
src={`${import.meta.env.BASE_URL}icon/play.svg`} alt="play"
onClick={() => toggleBgm('silver')}
style={{ width: 14, height: 14, filter: 'brightness(0) invert(1)', opacity: playingBgm === 'silver' ? 1 : 0.4, cursor: 'pointer' }}
/>
</div>
)}
{crowns.bronze > 0 && (
<div style={{
padding: '4px 8px',
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '4px',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<img src={`${import.meta.env.BASE_URL}icon/crown.svg`} alt="bronze" style={{ width: 16, height: 16, filter: 'brightness(0) invert(1) sepia(1) saturate(10) hue-rotate(90deg)' }} />
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px', fontFamily: 'monospace' }}>{crowns.bronze}</span>
<img
src={`${import.meta.env.BASE_URL}icon/play.svg`} alt="play"
onClick={() => toggleBgm('bronze')}
style={{ width: 14, height: 14, filter: 'brightness(0) invert(1)', opacity: playingBgm === 'bronze' ? 1 : 0.4, cursor: 'pointer' }}
/>
</div>
)}
</div> </div>
)} )}
{/* Admin controls */} {/* Handle link + logout */}
{authDid && savedHandle && (
<a href={`https://syui.ai/@${savedHandle}/at/vrm`} target="_blank" rel="noopener noreferrer"
style={{ padding: '4px 8px', display: 'flex', alignItems: 'center', gap: 6, textDecoration: 'none' }}>
<img src={`${BASE_URL}icon/at.svg`} alt="at" style={{ width: 14, height: 14, filter: 'brightness(0) invert(1)', opacity: 0.6 }} />
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px', fontFamily: 'monospace' }}>{savedHandle}</span>
</a>
)}
{authDid && (
<div style={{ padding: '4px 8px', background: 'rgba(255,0,0,0.15)', border: '1px solid rgba(255,0,0,0.3)', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
onClick={handleLogout}>
<span style={{ color: 'rgba(255,100,100,0.6)', fontSize: '10px', fontFamily: 'monospace' }}>logout</span>
</div>
)}
{/* Login dialog */}
{showLogin && !authDid && (
<div style={{ padding: '8px', background: 'rgba(0,0,0,0.8)', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '6px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<input
type="text" placeholder="handle.bsky.social" value={handleInput}
onChange={e => setHandleInput(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleLogin(); }}
style={{ background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px', color: '#fff', fontSize: '11px', fontFamily: 'monospace', padding: '4px 8px', outline: 'none' }}
/>
<button onClick={handleLogin} disabled={authLoading}
style={{ padding: '4px 8px', background: 'rgba(0,120,215,0.6)', color: '#fff', border: 'none', borderRadius: '4px', cursor: authLoading ? 'wait' : 'pointer', fontSize: '11px', fontFamily: 'monospace' }}>
{authLoading ? 'redirecting...' : 'login'}
</button>
</div>
)}
{/* Crowns */}
{['gold', 'silver', 'bronze'].map(tier => (
<CrownRow key={tier} tier={tier} score={crowns[tier]} filter={CROWN_FILTERS[tier]}
toggleBgm={toggleBgm} playing={playingBgm === tier} />
))}
</div>
)}
{/* Admin */}
{isAdmin && !isNasa && ( {isAdmin && !isNasa && (
<div style={{ position: 'absolute', top: 16, left: 16, zIndex: 2, display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ position: 'absolute', top: 16, left: 16, zIndex: 2, display: 'flex', gap: 8, alignItems: 'center' }}>
<img src={`${BASE_URL}icon/ai.svg`} alt="admin" style={{ width: 24, height: 24, opacity: 0.6 }} />
<button style={btnStyle} onClick={doTeleport}>Teleport</button> <button style={btnStyle} onClick={doTeleport}>Teleport</button>
<button style={btnStyle} onClick={doSkill}>Skill</button>
<span style={{ color: '#0f0', fontSize: '12px', marginLeft: 4 }}>ADMIN</span>
</div> </div>
)} )}
</div> </div>

126
src/lib/auth.js Normal file
View File

@@ -0,0 +1,126 @@
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'
let oauthClient = null
let agent = null
let sessionDid = null
const COLLECTION = 'ai.syui.vrm'
function getClientId() {
const host = window.location.host
if (host.includes('localhost') || host.includes('127.0.0.1')) {
const port = window.location.port || '5173'
const redirectUri = `http://127.0.0.1:${port}/`
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
}
return `${window.location.origin}/client-metadata.json`
}
async function initOAuthClient() {
if (oauthClient) return oauthClient
const client = await BrowserOAuthClient.load({
clientId: getClientId(),
handleResolver: 'https://bsky.social',
plcDirectoryUrl: 'https://plc.directory',
})
oauthClient = client
return client
}
export async function login(handle) {
const client = await initOAuthClient()
await client.signIn(handle, { scope: 'atproto transition:generic' })
}
export async function initSession() {
try {
const client = await initOAuthClient()
const result = await client.init()
if (!result?.session) return null
sessionDid = result.session.did
agent = new Agent(result.session)
// Clean up OAuth params from URL
const params = new URLSearchParams(window.location.search)
if (params.has('code') || params.has('state') || params.has('iss')) {
window.history.replaceState({}, '', window.location.pathname)
}
return sessionDid
} catch (e) {
console.error('OAuth init error:', e)
oauthClient = null
return null
}
}
export function isLoggedIn() { return sessionDid !== null }
export function getAgent() { return agent }
export function getDid() { return sessionDid }
export async function logout() {
sessionDid = null
agent = null
oauthClient = null
sessionStorage.clear()
localStorage.clear()
try {
const databases = await indexedDB.databases()
for (const db of databases) { if (db.name) indexedDB.deleteDatabase(db.name) }
} catch { /* not supported */ }
}
export async function getHandle() {
if (!agent) return null
try {
const profile = await agent.getProfile({ actor: sessionDid })
return profile.data.handle
} catch { return null }
}
export async function saveScore(crowns) {
if (!agent) return null
const now = new Date().toISOString()
const CROWN_MAP = { gold: 1, silver: 2, bronze: 3 }
const item = Object.entries(CROWN_MAP)
.filter(([tier]) => crowns[tier])
.map(([tier, id]) => ({ id, cp: crowns[tier], cid: `${tier}_${Date.now()}` }))
if (item.length === 0) {
item.push({ id: 0, cp: 0, cid: `none_${Date.now()}` })
}
// Fetch existing record to preserve cids and createdAt
let existingItems = []
let existingCreatedAt = null
try {
const existing = await agent.com.atproto.repo.getRecord({
repo: agent.assertDid, collection: COLLECTION, rkey: 'self',
})
if (existing.data.value?.item) existingItems = existing.data.value.item
if (existing.data.value?.createdAt) existingCreatedAt = existing.data.value.createdAt
} catch { /* No existing record */ }
// Merge: keep existing cid for same id
const merged = item.map(newItem => {
const existing = existingItems.find(e => e.id === newItem.id)
return existing || newItem
})
const result = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: COLLECTION,
rkey: 'self',
record: {
$type: COLLECTION,
item: merged,
createdAt: existingCreatedAt || now,
updatedAt: now,
},
})
const handle = await getHandle()
return { uri: result.data.uri, cid: result.data.cid, handle }
}

View File

@@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
base: '/', base: '/',
plugins: [react()], plugins: [react()],
server: {
host: '127.0.0.1',
},
}) })