add oauth
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
640
src/App.jsx
640
src/App.jsx
@@ -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
126
src/lib/auth.js
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user