260 lines
9.4 KiB
JavaScript
260 lines
9.4 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Globe, Sun, CloudRain, CloudLightning, Clock, Users, MapPin, ServerOff } from 'lucide-react';
|
|
import { motion } from 'framer-motion';
|
|
import { io } from 'socket.io-client';
|
|
|
|
// 캐시된 스킨 컴포넌트
|
|
const CachedSkin = ({ uuid, name, type = 'avatar', size = 40, className }) => {
|
|
const [src, setSrc] = useState(null);
|
|
const fallbackUrl = `https://mc-heads.net/${type}/${uuid}/${size}`;
|
|
|
|
useEffect(() => {
|
|
fetch(`/link/skin/${type}/${uuid}/${size}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.url) {
|
|
setSrc(data.url);
|
|
} else {
|
|
setSrc(fallbackUrl);
|
|
}
|
|
})
|
|
.catch(() => setSrc(fallbackUrl));
|
|
}, [uuid, type, size, fallbackUrl]);
|
|
|
|
return (
|
|
<img
|
|
src={src || fallbackUrl}
|
|
alt={name}
|
|
className={className}
|
|
onError={(e) => { e.target.src = fallbackUrl; }}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// 월드 정보 페이지
|
|
const WorldsPage = ({ isMobile = false }) => {
|
|
const [worlds, setWorlds] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [serverOnline, setServerOnline] = useState(null);
|
|
const socketRef = useRef(null);
|
|
|
|
// 월드 순서 정렬 함수 (오버월드 -> 네더 -> 엔드)
|
|
const sortWorlds = (worldList) => {
|
|
const order = {
|
|
'minecraft:overworld': 0,
|
|
'minecraft:the_nether': 1,
|
|
'minecraft:the_end': 2
|
|
};
|
|
return [...worldList].sort((a, b) => {
|
|
const orderA = order[a.dimension] ?? 999;
|
|
const orderB = order[b.dimension] ?? 999;
|
|
return orderA - orderB;
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
// 소켓 연결
|
|
const socket = io('/', {
|
|
path: '/socket.io',
|
|
transports: ['websocket', 'polling']
|
|
});
|
|
socketRef.current = socket;
|
|
|
|
socket.on('status', (data) => {
|
|
setServerOnline(data.online);
|
|
if (!data.online) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
socket.on('worlds', (data) => {
|
|
setWorlds(sortWorlds(data.worlds || []));
|
|
setLoading(false);
|
|
});
|
|
|
|
// 초기 데이터 요청
|
|
socket.emit('get_worlds');
|
|
|
|
// 1초마다 갱신
|
|
const interval = setInterval(() => {
|
|
socket.emit('get_worlds');
|
|
}, 1000);
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
socket.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
// 시간 포맷팅 (틱 -> 시:분)
|
|
const formatTime = (dayTime) => {
|
|
const hours = Math.floor(dayTime / 1000);
|
|
const minutes = Math.floor((dayTime % 1000) / 16.67);
|
|
const hour24 = (hours + 6) % 24;
|
|
const ampm = hour24 >= 12 ? '오후' : '오전';
|
|
const hour12 = hour24 % 12 || 12;
|
|
return `${ampm} ${hour12}:${String(Math.floor(minutes)).padStart(2, '0')}`;
|
|
};
|
|
|
|
// 날씨 아이콘
|
|
const WeatherIcon = ({ type }) => {
|
|
switch (type) {
|
|
case 'thunderstorm':
|
|
return <CloudLightning className="text-yellow-400" size={20} />;
|
|
case 'rain':
|
|
return <CloudRain className="text-blue-400" size={20} />;
|
|
default:
|
|
return <Sun className="text-yellow-300" size={20} />;
|
|
}
|
|
};
|
|
|
|
// 차원 색상
|
|
const getDimensionStyle = (dimension) => {
|
|
if (dimension.includes('nether')) {
|
|
return {
|
|
gradient: 'from-red-500/20 to-orange-500/20',
|
|
border: 'border-red-500/30',
|
|
text: 'text-red-400'
|
|
};
|
|
}
|
|
if (dimension.includes('end')) {
|
|
return {
|
|
gradient: 'from-purple-500/20 to-pink-500/20',
|
|
border: 'border-purple-500/30',
|
|
text: 'text-purple-400'
|
|
};
|
|
}
|
|
return {
|
|
gradient: 'from-mc-green/20 to-emerald-500/20',
|
|
border: 'border-mc-green/30',
|
|
text: 'text-mc-green'
|
|
};
|
|
};
|
|
|
|
// 서버 오프라인 상태 - 전체 화면 가운데 정렬
|
|
if (serverOnline === false) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="text-center p-8"
|
|
>
|
|
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-red-500/20 to-orange-500/20 border border-red-500/30 flex items-center justify-center">
|
|
<ServerOff className="w-10 h-10 text-red-400" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-white mb-2">서버 오프라인</h2>
|
|
<p className="text-gray-400">
|
|
마인크래프트 서버가 현재 오프라인 상태입니다.<br />
|
|
서버가 시작되면 월드 정보를 확인할 수 있습니다.
|
|
</p>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen p-4 md:p-6 lg:p-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
{/* 헤더 */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl md:text-4xl font-bold text-white flex items-center gap-3">
|
|
<Globe className="text-mc-green" />
|
|
월드 정보
|
|
</h1>
|
|
</div>
|
|
|
|
{/* 월드 카드 목록 */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="w-12 h-12 border-4 border-mc-green/30 border-t-mc-green rounded-full animate-spin" />
|
|
</div>
|
|
) : worlds.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{worlds.map((world, index) => {
|
|
const style = getDimensionStyle(world.dimension);
|
|
return (
|
|
<motion.div
|
|
key={world.dimension}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: index * 0.1, duration: 0.3 }}
|
|
className={`glow-card rounded-xl p-5 bg-gradient-to-br ${style.gradient} ${style.border}`}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
{/* 상단: 월드 이름 & 날씨/시간 */}
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<h2 className={`text-xl font-bold ${style.text}`}>{world.displayName}</h2>
|
|
<div className="flex items-center gap-2 text-gray-400 text-sm mt-1">
|
|
<Users size={14} />
|
|
<span>{world.playerCount}명 접속 중</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-lg">
|
|
<WeatherIcon type={world.weather?.type} />
|
|
<span className="text-white font-medium text-sm">{world.weather?.displayName}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-lg text-white text-sm">
|
|
<Clock size={14} className="text-gray-400" />
|
|
<span className="font-medium">{formatTime(world.time?.dayTime || 0)}</span>
|
|
<span className="text-gray-500 text-xs">(Day {world.time?.day || 1})</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{/* 하단: 접속 중인 플레이어 목록 */}
|
|
{world.players && world.players.length > 0 && (
|
|
<div className="pt-3 border-t border-white/10">
|
|
<div className="flex flex-wrap gap-3">
|
|
{world.players.map((player) => (
|
|
<div
|
|
key={player.uuid}
|
|
className={`flex items-center gap-3 bg-white/5 rounded-xl ${isMobile ? 'p-2' : 'p-3'}`}
|
|
>
|
|
{/* 모바일: 머리만, PC: 전신 */}
|
|
<CachedSkin
|
|
uuid={player.uuid}
|
|
name={player.name}
|
|
type={isMobile ? 'avatar' : 'body'}
|
|
size={isMobile ? 40 : 60}
|
|
className={isMobile ? 'w-10 h-10 rounded' : 'h-16 w-auto'}
|
|
/>
|
|
<div>
|
|
<p className={`text-white font-semibold ${isMobile ? 'text-sm' : ''}`}>{player.name}</p>
|
|
<div className={`flex items-center gap-1 text-gray-400 mt-0.5 ${isMobile ? 'text-xs' : 'text-sm'}`}>
|
|
<MapPin size={isMobile ? 10 : 12} />
|
|
<span>{player.x}, {player.y}, {player.z}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{world.playerCount === 0 && (
|
|
<div className="text-gray-500 text-sm pt-3 border-t border-white/10">
|
|
현재 접속 중인 플레이어가 없습니다
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-20 text-gray-400">
|
|
<Globe size={48} className="mx-auto mb-4 opacity-50" />
|
|
<p>월드 정보를 불러올 수 없습니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default WorldsPage;
|