minecraft-web/frontend/src/pages/WorldsPage.jsx

234 lines
8.8 KiB
React
Raw Normal View History

2025-12-16 08:40:32 +09:00
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 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');
// 5초마다 갱신
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: 전신 */}
<img
src={isMobile
? `https://mc-heads.net/avatar/${player.name}/40`
: `https://mc-heads.net/body/${player.name}/60`
}
alt={player.name}
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;