feat: implement public map features including caching, API integration, and UI components
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface MapSummary {
|
||||
year: number;
|
||||
month: number;
|
||||
cityList: [number, number, number, number, number, number][];
|
||||
nationList: [number, string, string, number][];
|
||||
}
|
||||
|
||||
interface MapLayoutCity {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
region: number;
|
||||
x: number;
|
||||
y: number;
|
||||
path: number[];
|
||||
}
|
||||
|
||||
interface MapLayout {
|
||||
mapName: string;
|
||||
cityList: MapLayoutCity[];
|
||||
}
|
||||
|
||||
interface CityDot {
|
||||
id: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
isCapital: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
mapData: MapSummary;
|
||||
mapLayout: MapLayout;
|
||||
}>();
|
||||
|
||||
const BASE_MAP_WIDTH = 700;
|
||||
const BASE_MAP_HEIGHT = 500;
|
||||
const MAP_SCALE = 0.45;
|
||||
|
||||
const mapWidth = computed(() => `${BASE_MAP_WIDTH * MAP_SCALE}px`);
|
||||
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * MAP_SCALE}px`);
|
||||
|
||||
const nationById = computed(() => {
|
||||
const map = new Map<number, { name: string; color: string; capitalCityId: number }>();
|
||||
for (const nation of props.mapData.nationList) {
|
||||
const [id, name, color, capitalCityId] = nation;
|
||||
map.set(id, {
|
||||
name,
|
||||
color,
|
||||
capitalCityId,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const dynamicCityById = computed(() => {
|
||||
const map = new Map<number, [number, number, number, number, number]>();
|
||||
for (const entry of props.mapData.cityList) {
|
||||
const [id, level, state, nationId, region, supplyFlag] = entry;
|
||||
map.set(id, [level, state, nationId, region, supplyFlag]);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const cityDots = computed<CityDot[]>(() => {
|
||||
return props.mapLayout.cityList.map((layoutCity) => {
|
||||
const dynamic = dynamicCityById.value.get(layoutCity.id);
|
||||
const [, , nationId = 0] = dynamic ?? [];
|
||||
const nation = nationById.value.get(nationId);
|
||||
return {
|
||||
id: layoutCity.id,
|
||||
name: layoutCity.name,
|
||||
x: layoutCity.x * MAP_SCALE,
|
||||
y: layoutCity.y * MAP_SCALE,
|
||||
color: nation?.color ?? '#666666',
|
||||
isCapital: nation?.capitalCityId === layoutCity.id,
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-preview">
|
||||
<div class="map-preview-header">
|
||||
<span class="map-preview-title">{{ props.mapLayout.mapName }}</span>
|
||||
<span class="map-preview-date">{{ props.mapData.year }}년 {{ props.mapData.month }}월</span>
|
||||
</div>
|
||||
<div class="map-preview-body" :style="{ width: mapWidth, height: mapHeight }">
|
||||
<div
|
||||
v-for="city in cityDots"
|
||||
:key="city.id"
|
||||
class="city-dot"
|
||||
:class="{ capital: city.isCapital }"
|
||||
:title="city.name"
|
||||
:style="{ left: `${city.x}px`, top: `${city.y}px`, backgroundColor: city.color }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.map-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.map-preview-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.map-preview-body {
|
||||
position: relative;
|
||||
border: 1px dashed rgba(201, 164, 90, 0.4);
|
||||
background: rgba(8, 8, 8, 0.7);
|
||||
}
|
||||
|
||||
.city-dot {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.city-dot.capital {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
box-shadow: 0 0 6px rgba(255, 221, 164, 0.7);
|
||||
border-color: rgba(255, 221, 164, 0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import MapPreview from '../components/MapPreview.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { createGameTrpc } from '../utils/gameTrpc';
|
||||
import type { GameRouter } from '../utils/gameTrpc';
|
||||
@@ -13,12 +14,19 @@ type GameRouterOutput = inferRouterOutputs<GameRouter>;
|
||||
type MeOutput = GatewayRouterOutput['me'];
|
||||
type LobbyProfile = GatewayRouterOutput['lobby']['profiles'][number];
|
||||
type LobbyInfo = GameRouterOutput['lobby']['info'];
|
||||
type PublicMap = GameRouterOutput['public']['getCachedMap'];
|
||||
type PublicMapLayout = GameRouterOutput['public']['getMapLayout'];
|
||||
type MapPreviewBundle = {
|
||||
mapData: PublicMap;
|
||||
mapLayout: PublicMapLayout;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const me = ref<MeOutput>(null);
|
||||
const notice = ref('');
|
||||
const profiles = ref<LobbyProfile[]>([]);
|
||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -31,18 +39,32 @@ onMounted(async () => {
|
||||
notice.value = await trpc.lobby.notice.query();
|
||||
profiles.value = await trpc.lobby.profiles.query();
|
||||
|
||||
// Fetch details for each profile
|
||||
for (const profile of profiles.value) {
|
||||
if (profile.status === 'RUNNING' || profile.status === 'PREOPEN') {
|
||||
try {
|
||||
const gameTrpc = createGameTrpc(profile.apiPort);
|
||||
const info = await gameTrpc.lobby.info.query();
|
||||
profileDetails.value[profile.profileName] = info;
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch info for ${profile.profileName}`, e);
|
||||
}
|
||||
const detailTasks = profiles.value.map(async (profile) => {
|
||||
if (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const gameTrpc = createGameTrpc(profile.apiPort);
|
||||
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
|
||||
gameTrpc.lobby.info.query(),
|
||||
gameTrpc.public.getMapLayout.query(),
|
||||
gameTrpc.public.getCachedMap.query(),
|
||||
]);
|
||||
|
||||
if (infoResult.status === 'fulfilled') {
|
||||
profileDetails.value[profile.profileName] = infoResult.value;
|
||||
} else {
|
||||
console.error(`Failed to fetch info for ${profile.profileName}`, infoResult.reason);
|
||||
}
|
||||
|
||||
if (layoutResult.status === 'fulfilled' && mapResult.status === 'fulfilled') {
|
||||
profileMapPreviews.value[profile.profileName] = {
|
||||
mapLayout: layoutResult.value,
|
||||
mapData: mapResult.value,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(detailTasks);
|
||||
} catch (e) {
|
||||
console.error('Failed to load lobby', e);
|
||||
}
|
||||
@@ -221,6 +243,46 @@ const handleLogout = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
|
||||
<div
|
||||
class="bg-zinc-800 px-6 py-2 text-center font-bold text-white border-b border-zinc-700 tracking-widest"
|
||||
>
|
||||
공개 지도 미리보기
|
||||
</div>
|
||||
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="profile in profiles"
|
||||
:key="profile.profileName"
|
||||
class="border border-zinc-800 rounded bg-zinc-950/50 p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs text-zinc-400 mb-2">
|
||||
<span class="font-semibold" :style="{ color: profile.color }">
|
||||
{{ profile.korName }}섭
|
||||
</span>
|
||||
<span>{{ profile.status }}</span>
|
||||
</div>
|
||||
<div v-if="profile.status === 'RUNNING' || profile.status === 'PREOPEN'">
|
||||
<div v-if="profileMapPreviews[profile.profileName]">
|
||||
<MapPreview
|
||||
:map-data="profileMapPreviews[profile.profileName]!.mapData"
|
||||
:map-layout="profileMapPreviews[profile.profileName]!.mapLayout"
|
||||
/>
|
||||
<div v-if="profileDetails[profile.profileName]" class="text-xs text-zinc-400 mt-2">
|
||||
유저 {{ profileDetails[profile.profileName]?.userCnt ?? '-' }} /
|
||||
{{ profileDetails[profile.profileName]?.maxUserCnt ?? '-' }} ·
|
||||
{{ profileDetails[profile.profileName]?.nationCnt ?? '-' }}국 ·
|
||||
{{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}분 턴
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">
|
||||
지도를 불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-600 py-8 text-center">- 폐 쇄 중 -</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Management -->
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user