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"
},
"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",

View File

@@ -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) {
if (cancelled) return;
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; };
@@ -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() {
// 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');
}, []);
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 = {
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 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)',
};
const isNasa = view === 'nasa';
const hasCrowns = crowns.bronze || crowns.silver || crowns.gold;
return (
<div style={{ position: 'relative', width: '100vw', height: '100vh', background: '#000' }}>
{/* NASA iframe layer - fully interactive */}
{/* NASA */}
<iframe
src={NASA_URL}
style={{
...layerStyle,
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"
style={{ ...layerStyle, 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 && (
<button
onClick={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }}
onMouseEnter={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }}
onTouchStart={() => { lastSwitchRef.current = Date.now(); setView('avatar'); }}
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',
}}
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' }}
/>
)}
{/* Atmosphere background */}
{/* Sky */}
<div style={{ ...layerStyle, zIndex: 0, opacity: isNasa ? 0 : 1, transition: 'opacity 1.5s ease' }}>
<Canvas gl={{ alpha: true, antialias: true }}>
<AtmosphereScene timeScale={timeScale} overrideDate={burstSky} overrideTimeScale={burstSky ? 8000 : undefined} />
</Canvas>
</div>
{/* Avatar layer */}
{/* Avatar */}
<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' }}>
<AvatarScene selectedAnimation={animState} onZoomOut={handleZoomOut} camSpeed={camSpeed} vrmModel={vrmModel} />
</div>
</div>
{/* Control panel */}
{/* Controls */}
{!isNasa && (
<ControlPanel
timeScale={timeScale}
onTimeScaleChange={setTimeScale}
camSpeed={camSpeed}
onCamSpeedChange={setCamSpeed}
onSkill={doSkill}
onBurst={doBurst}
lang={lang}
onLangChange={handleLangChange}
volume={volume}
onVolumeChange={handleVolumeChange}
timeScale={timeScale} onTimeScaleChange={setTimeScale}
camSpeed={camSpeed} onCamSpeedChange={setCamSpeed}
onSkill={doSkill} onBurst={doBurst}
lang={lang} onLangChange={handleLangChange}
volume={volume} onVolumeChange={handleVolumeChange}
/>
)}
{/* Score */}
{!isNasa && score > 0 && (
<div style={{
position: 'absolute',
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>
<div style={{ position: 'absolute', 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 && (
<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',
}}>
<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' }}>
{scoreTimeLeft}s
</span>
)}
</div>
)}
{/* Damage number */}
{/* Damage */}
{damageNum && (
<div style={{
position: 'absolute',
left: `${damageNum.x}%`,
top: `${damageNum.y}%`,
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',
position: 'absolute', left: `${damageNum.x}%`, top: `${damageNum.y}%`, 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}
</div>
)}
<style>{`
@keyframes dmgFade {
0% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-40px) scale(1.2); }
}
`}</style>
<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) */}
{!isNasa && (crowns.bronze || crowns.silver || crowns.gold) && (
<div style={{
position: 'fixed',
bottom: 'max(16px, env(safe-area-inset-bottom, 0px))',
right: 16,
zIndex: 100,
display: 'flex',
flexDirection: 'column',
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' }}
/>
{/* Crown panel */}
{!isNasa && (hasCrowns || isAdmin) && (
<div style={{ position: 'fixed', bottom: 'max(16px, env(safe-area-inset-bottom, 0px))', right: 16, zIndex: 100, display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* Auth button */}
{!authDid ? (
<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' }}
onClick={() => setShowLogin(!showLogin)}>
<img src={`${BASE_URL}icon/bluesky.svg`} alt="login" 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' }}>login</span>
</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,
}}>
<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 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' }}
onClick={handleSave}>
<img src={`${BASE_URL}icon/bluesky.svg`} alt="save" 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' }}>
{saveStatus === 'saving' ? 'saving...' : saveStatus === 'saved' ? 'saved!' : saveStatus === 'error' ? 'error' : 'save'}
</span>
</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 && (
<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={doSkill}>Skill</button>
<span style={{ color: '#0f0', fontSize: '12px', marginLeft: 4 }}>ADMIN</span>
</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({
base: '/',
plugins: [react()],
server: {
host: '127.0.0.1',
},
})