add oauth
This commit is contained in:
@@ -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",
|
||||
|
||||
634
src/App.jsx
634
src/App.jsx
@@ -4,19 +4,26 @@ import { Canvas } from '@react-three/fiber';
|
||||
import AtmosphereScene from './AtmosphereScene';
|
||||
import AvatarScene from './AvatarScene';
|
||||
import { LOCATIONS, teleportTo, worldState } from './worldState';
|
||||
import { adminMode, onAdminChange } from './controls/KeyInput';
|
||||
import { onAdminChange } from './controls/KeyInput';
|
||||
import ControlPanel from './ui/ControlPanel';
|
||||
import LoadingScreen from './ui/LoadingScreen';
|
||||
import { login as authLogin, initSession, isLoggedIn, saveScore, getHandle, logout } from './lib/auth';
|
||||
|
||||
const ACTION_SEQUENCE = ['attack', 'skill', 'jump', 'fly_dodge', 'damage'];
|
||||
const TELEPORT_ANIM = 'fly_dodge';
|
||||
const AUTO_INTERVAL_MIN = 15000;
|
||||
const AUTO_INTERVAL_MAX = 40000;
|
||||
const BURST_COOLDOWN = 10000;
|
||||
const SKILL_COOLDOWN = 3000;
|
||||
const ACTION_COOLDOWN = 3000;
|
||||
const SCORE_DURATION = 20000;
|
||||
const CROWN_THRESHOLDS = { bronze: 100000, silver: 500000, gold: 1000000 };
|
||||
const NASA_URL = 'https://eyes.nasa.gov/apps/solar-system/#/earth?featured=false&detailPanel=false&logo=false&search=false&shareButton=false&menu=false&collapseSettingsOptions=true&hideFullScreenToggle=true';
|
||||
|
||||
const BASE_URL = import.meta.env.BASE_URL;
|
||||
const VRM_FILES = ['ai.vrm', 'ai_mode.vrm'];
|
||||
|
||||
// --- Loading wrapper ---
|
||||
export default function App() {
|
||||
const [loadProgress, setLoadProgress] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
@@ -27,7 +34,6 @@ export default function App() {
|
||||
const total = VRM_FILES.length;
|
||||
let done = 0;
|
||||
|
||||
// Use XMLHttpRequest for progress tracking + populate browser cache
|
||||
function loadFile(file) {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -35,31 +41,22 @@ export default function App() {
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onprogress = (e) => {
|
||||
if (e.lengthComputable && !cancelled) {
|
||||
const fileProgress = e.loaded / e.total;
|
||||
setLoadProgress(((done + fileProgress) / total) * 100);
|
||||
setLoadProgress(((done + e.loaded / e.total) / total) * 100);
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
done++;
|
||||
if (!cancelled) setLoadProgress((done / total) * 100);
|
||||
resolve();
|
||||
};
|
||||
xhr.onload = () => { done++; if (!cancelled) setLoadProgress((done / total) * 100); resolve(); };
|
||||
xhr.onerror = () => { done++; resolve(); };
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
// Load sequentially for accurate progress
|
||||
loadFile(VRM_FILES[0])
|
||||
.then(() => loadFile(VRM_FILES[1]))
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setLoadProgress(100);
|
||||
// Mount app behind loading screen, let it initialize
|
||||
setTimeout(() => setLoaded(true), 300);
|
||||
// Then fade out loading screen after app has time to render
|
||||
setTimeout(() => setFadeOut(true), 2000);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setLoadProgress(100);
|
||||
setTimeout(() => setLoaded(true), 300);
|
||||
setTimeout(() => setFadeOut(true), 2000);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
@@ -77,10 +74,57 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
function playSound(path, volume) {
|
||||
const audio = new Audio(`${BASE_URL}${path}`);
|
||||
audio.volume = volume;
|
||||
audio.play().catch(() => {});
|
||||
return audio;
|
||||
}
|
||||
|
||||
function calcDamage(vrmModel) {
|
||||
const baseDmg = Math.floor(Math.random() * 100000) + 1;
|
||||
const modeMult = vrmModel === 'ai_mode.vrm' ? 2 : 1;
|
||||
const hour = worldState.currentHour;
|
||||
const hourMult = Math.max(1, Math.round(12 - Math.abs(hour - 12)) + 1);
|
||||
const locMult = { Tokyo: 1, Fuji: 2, Space: 3 }[worldState.currentLocation] || 1;
|
||||
return baseDmg * modeMult * hourMult * locMult;
|
||||
}
|
||||
|
||||
// --- Crown row component ---
|
||||
function CrownRow({ tier, score, filter, toggleBgm, playing }) {
|
||||
if (!score) return null;
|
||||
return (
|
||||
<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');
|
||||
}, []);
|
||||
|
||||
// Auth
|
||||
const handleLogin = useCallback(async () => {
|
||||
if (!handleInput.trim()) return;
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
await authLogin(handleInput.trim());
|
||||
} catch {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}, [handleInput]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!isLoggedIn()) return;
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const result = await saveScore(crowns);
|
||||
if (result?.handle) setSavedHandle(result.handle);
|
||||
setSaveStatus('saved');
|
||||
} catch {
|
||||
setSaveStatus('error');
|
||||
setTimeout(() => setSaveStatus(null), 2000);
|
||||
}
|
||||
}, [crowns]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
setAuthDid(null);
|
||||
setSavedHandle(null);
|
||||
setSaveStatus(null);
|
||||
}, []);
|
||||
|
||||
// --- Effects ---
|
||||
useEffect(() => onAdminChange((v) => setIsAdmin(v)), []);
|
||||
|
||||
useEffect(() => {
|
||||
initSession().then(async did => {
|
||||
if (!did) return;
|
||||
setAuthDid(did);
|
||||
const h = await getHandle();
|
||||
if (h) setSavedHandle(h);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Wind
|
||||
useEffect(() => {
|
||||
const wind = new Audio(`${BASE_URL}sound/wind.mp3`);
|
||||
wind.loop = true;
|
||||
wind.volume = volumeRef.current;
|
||||
wind.play().catch(() => {});
|
||||
windRef.current = wind;
|
||||
return () => { wind.pause(); wind.src = ''; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!windRef.current) return;
|
||||
view === 'nasa' ? windRef.current.pause() : windRef.current.play().catch(() => {});
|
||||
}, [view]);
|
||||
|
||||
// Keyboard
|
||||
const handleKey = useCallback((e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (e.code === 'Escape') { setView(v => v === 'nasa' ? 'avatar' : 'nasa'); return; }
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
const now = Date.now();
|
||||
if (now - actionCoolRef.current < ACTION_COOLDOWN) return;
|
||||
actionCoolRef.current = now;
|
||||
const idx = actionIndexRef.current;
|
||||
playAnim(ACTION_SEQUENCE[idx % ACTION_SEQUENCE.length]);
|
||||
actionIndexRef.current = idx + 1;
|
||||
} else if (e.code === 'KeyT') { e.preventDefault(); doTeleport(); }
|
||||
else if (e.code === 'KeyS') { e.preventDefault(); doSkillRef.current?.(); }
|
||||
else if (e.code === 'KeyB') { e.preventDefault(); doBurstRef.current?.(); }
|
||||
}, [playAnim, doTeleport]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [handleKey]);
|
||||
|
||||
// Auto teleport
|
||||
useEffect(() => {
|
||||
const scheduleNext = () => {
|
||||
const delay = AUTO_INTERVAL_MIN + Math.random() * (AUTO_INTERVAL_MAX - AUTO_INTERVAL_MIN);
|
||||
return setTimeout(() => {
|
||||
if (viewRef.current !== 'nasa') {
|
||||
const idx = teleportIndexRef.current;
|
||||
teleportTo(LOCATIONS[idx % LOCATIONS.length]);
|
||||
playAnim(TELEPORT_ANIM);
|
||||
teleportIndexRef.current = idx + 1;
|
||||
}
|
||||
timerRef.current = scheduleNext();
|
||||
}, delay);
|
||||
};
|
||||
const timerRef = { current: scheduleNext() };
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [playAnim]);
|
||||
|
||||
// --- Render ---
|
||||
const layerStyle = { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' };
|
||||
|
||||
const btnStyle = {
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(255,255,255,0.12)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
backdropFilter: 'blur(4px)',
|
||||
padding: '8px 16px', background: 'rgba(255,255,255,0.12)', color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.25)', borderRadius: '6px',
|
||||
cursor: 'pointer', fontSize: '13px', backdropFilter: 'blur(4px)',
|
||||
};
|
||||
|
||||
const isNasa = view === 'nasa';
|
||||
const hasCrowns = crowns.bronze || crowns.silver || crowns.gold;
|
||||
|
||||
return (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{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' }}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{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' }}
|
||||
|
||||
{/* 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 controls */}
|
||||
{/* 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
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({
|
||||
base: '/',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user