feat: enhance MapViewer with city selection and layout integration
- Added support for city selection in MapViewer, allowing users to click on cities to view details. - Integrated map layout data to improve city rendering and positioning. - Introduced SelectedCityPanel to display information about the selected city. - Updated map asset handling with utility functions for building asset URLs. - Enhanced state management in mapViewer store to track selected city. - Improved responsiveness and styling of map components. - Added new API endpoint for loading map layouts from legacy data.
This commit is contained in:
@@ -6,44 +6,103 @@ import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import MapCityBasic from './MapCityBasic.vue';
|
||||
import MapCityDetail from './MapCityDetail.vue';
|
||||
import { useMapViewerStore } from '../../stores/mapViewer';
|
||||
import { buildAssetUrl } from '../../utils/mapAssets';
|
||||
|
||||
interface MapSummary {
|
||||
year: number;
|
||||
month: number;
|
||||
startYear: number;
|
||||
cityList: [number, number, number, number, number, number][];
|
||||
nationList: [number, string, string, number][];
|
||||
myCity?: number | null;
|
||||
myNation?: number | null;
|
||||
}
|
||||
|
||||
interface MapLayoutCity {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
region: number;
|
||||
x: number;
|
||||
y: number;
|
||||
path: number[];
|
||||
}
|
||||
|
||||
interface MapLayout {
|
||||
mapName: string;
|
||||
cityList: MapLayoutCity[];
|
||||
regionMap: Record<number, string>;
|
||||
levelMap: Record<number, string>;
|
||||
}
|
||||
|
||||
type CityStateClass = 'good' | 'bad' | 'war' | 'wrong';
|
||||
|
||||
interface CityView {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
levelName: string;
|
||||
state: number;
|
||||
stateClass: CityStateClass;
|
||||
nationId: number;
|
||||
nationName: string;
|
||||
color: string;
|
||||
region: number;
|
||||
regionName: string;
|
||||
supply: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
isCapital: boolean;
|
||||
isMyCity: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
mapData: MapSummary | null;
|
||||
mapLayout: MapLayout | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const BASE_MAP_WIDTH = 700;
|
||||
const BASE_MAP_HEIGHT = 500;
|
||||
const SMALL_MAP_SCALE = 5 / 7;
|
||||
|
||||
const isWide = useMediaQuery('(min-width: 1024px)');
|
||||
const mapStore = useMapViewerStore();
|
||||
const { showCityName, detailMode, hoveredCityId } = storeToRefs(mapStore);
|
||||
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore);
|
||||
|
||||
const mapArea = ref<HTMLElement | null>(null);
|
||||
const { elementX, elementY } = useMouseInElement(mapArea);
|
||||
|
||||
const resolveSeason = (month: number): string => {
|
||||
if (month <= 3) {
|
||||
return 'spring';
|
||||
}
|
||||
if (month <= 6) {
|
||||
return 'summer';
|
||||
}
|
||||
if (month <= 9) {
|
||||
return 'fall';
|
||||
}
|
||||
return 'winter';
|
||||
};
|
||||
|
||||
const resolveStateClass = (state: number): CityStateClass => {
|
||||
if (state < 10) {
|
||||
return 'good';
|
||||
}
|
||||
if (state < 40) {
|
||||
return 'bad';
|
||||
}
|
||||
if (state < 50) {
|
||||
return 'war';
|
||||
}
|
||||
return 'wrong';
|
||||
};
|
||||
|
||||
const assetBaseUrl = computed(() => import.meta.env.VITE_GAME_ASSET_URL ?? '');
|
||||
const resolveAsset = (path: string) => buildAssetUrl(assetBaseUrl.value, path);
|
||||
|
||||
const nationById = computed(() => {
|
||||
const map = new Map<number, { name: string; color: string; capitalCityId: number }>();
|
||||
if (!props.mapData) {
|
||||
@@ -60,40 +119,69 @@ const nationById = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const cityViews = computed<CityView[]>(() => {
|
||||
const dynamicCityById = computed(() => {
|
||||
const map = new Map<number, [number, number, number, number, number]>();
|
||||
if (!props.mapData) {
|
||||
return map;
|
||||
}
|
||||
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 mapScale = computed(() => (isWide.value ? 1 : SMALL_MAP_SCALE));
|
||||
|
||||
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
|
||||
|
||||
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
|
||||
|
||||
const cityViews = computed<CityView[]>(() => {
|
||||
if (!props.mapData || !props.mapLayout) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const columns = isWide.value ? 12 : 8;
|
||||
const spacing = isWide.value ? 46 : 36;
|
||||
const scale = mapScale.value;
|
||||
|
||||
return props.mapData.cityList.map((entry, index) => {
|
||||
const [id, level, state, nationId, region, supplyFlag] = entry;
|
||||
return props.mapLayout.cityList.map((layoutCity) => {
|
||||
const dynamic = dynamicCityById.value.get(layoutCity.id);
|
||||
const [, state = 0, nationId = 0, region = layoutCity.region, supplyFlag = 0] = dynamic ?? [];
|
||||
const nation = nationById.value.get(nationId);
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
const x = column * spacing + 20 + (region % 3) * 6;
|
||||
const y = row * spacing + 20 + (region % 4) * 4;
|
||||
const x = layoutCity.x * scale;
|
||||
const y = layoutCity.y * scale;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: `도시 ${id}`,
|
||||
level,
|
||||
id: layoutCity.id,
|
||||
name: layoutCity.name,
|
||||
level: layoutCity.level,
|
||||
levelName: props.mapLayout?.levelMap?.[layoutCity.level] ?? '-',
|
||||
state,
|
||||
stateClass: resolveStateClass(state),
|
||||
nationId,
|
||||
nationName: nation?.name ?? '무주',
|
||||
color: nation?.color ?? '#444444',
|
||||
color: nation?.color ?? '#ffffff',
|
||||
region,
|
||||
regionName: props.mapLayout?.regionMap?.[region] ?? '-',
|
||||
supply: supplyFlag > 0,
|
||||
x,
|
||||
y,
|
||||
isCapital: nation?.capitalCityId === id,
|
||||
isMyCity: props.mapData?.myCity === id,
|
||||
isCapital: nation?.capitalCityId === layoutCity.id,
|
||||
isMyCity: props.mapData?.myCity === layoutCity.id,
|
||||
selected: selectedCityId.value === layoutCity.id,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const mapSeason = computed(() => {
|
||||
if (!props.mapData) {
|
||||
return 'spring';
|
||||
}
|
||||
return resolveSeason(props.mapData.month);
|
||||
});
|
||||
|
||||
const mapTheme = computed(() => props.mapLayout?.mapName ?? 'che');
|
||||
|
||||
const mapSummary = computed(() => {
|
||||
if (!props.mapData) {
|
||||
return '';
|
||||
@@ -101,16 +189,67 @@ const mapSummary = computed(() => {
|
||||
return `${props.mapData.year}년 ${props.mapData.month}월`;
|
||||
});
|
||||
|
||||
const mapHeight = computed(() => {
|
||||
if (!cityViews.value.length) {
|
||||
return '240px';
|
||||
}
|
||||
const columns = isWide.value ? 12 : 8;
|
||||
const rows = Math.ceil(cityViews.value.length / columns);
|
||||
const spacing = isWide.value ? 46 : 36;
|
||||
return `${rows * spacing + 40}px`;
|
||||
const mapThemeClass = computed(() => {
|
||||
return `map-theme-${mapTheme.value}`;
|
||||
});
|
||||
|
||||
const mapSeasonClass = computed(() => {
|
||||
return `map-season-${mapSeason.value}`;
|
||||
});
|
||||
|
||||
const mapBackgroundImage = computed(() => {
|
||||
const theme = mapTheme.value;
|
||||
const season = mapSeason.value;
|
||||
|
||||
if (theme === 'ludo_rathowm') {
|
||||
return resolveAsset('map/ludo_rathowm/back.jpg');
|
||||
}
|
||||
if (theme === 'chess') {
|
||||
return resolveAsset('map/chess/chessboard.png');
|
||||
}
|
||||
if (theme === 'pokemon_v1') {
|
||||
return resolveAsset('map/pokemon_v1/back_pal8.png');
|
||||
}
|
||||
if (theme === 'cr') {
|
||||
return resolveAsset('map/cr/bg-fs8.png');
|
||||
}
|
||||
|
||||
return resolveAsset(`map/che/bg_${season}.jpg`);
|
||||
});
|
||||
|
||||
const mapRoadImage = computed(() => {
|
||||
const theme = mapTheme.value;
|
||||
if (theme === 'che') {
|
||||
return resolveAsset('map/che/che_road.png');
|
||||
}
|
||||
if (theme === 'miniche' || theme === 'miniche_b' || theme === 'miniche_clean') {
|
||||
return resolveAsset('map/che/miniche_road.png');
|
||||
}
|
||||
if (theme === 'ludo_rathowm') {
|
||||
return resolveAsset('map/ludo_rathowm/road.png');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const mapBackgroundStyle = computed(() => ({
|
||||
backgroundImage: mapBackgroundImage.value ? `url('${mapBackgroundImage.value}')` : 'none',
|
||||
backgroundSize: '100% 100%',
|
||||
}));
|
||||
|
||||
const mapRoadStyle = computed(() => ({
|
||||
backgroundImage: mapRoadImage.value ? `url('${mapRoadImage.value}')` : 'none',
|
||||
backgroundSize: '100% 100%',
|
||||
}));
|
||||
|
||||
const detailProps = computed(() =>
|
||||
detailMode.value
|
||||
? {
|
||||
imageBaseUrl: assetBaseUrl.value,
|
||||
themeName: mapTheme.value,
|
||||
}
|
||||
: {}
|
||||
);
|
||||
|
||||
const hoveredCity = computed(() => {
|
||||
if (!hoveredCityId.value) {
|
||||
return null;
|
||||
@@ -121,6 +260,10 @@ const hoveredCity = computed(() => {
|
||||
const setHoveredCity = (cityId: number | null) => {
|
||||
mapStore.setHoveredCity(cityId);
|
||||
};
|
||||
|
||||
const selectCity = (cityId: number) => {
|
||||
mapStore.setSelectedCity(cityId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,32 +282,49 @@ const setHoveredCity = (cityId: number | null) => {
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div v-else-if="!props.mapData" class="map-empty">
|
||||
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">
|
||||
지도 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
<div v-else class="map-body">
|
||||
<div ref="mapArea" class="map-area" :style="{ height: mapHeight }">
|
||||
<div class="map-placeholder">지도 렌더러 이식 중</div>
|
||||
<div
|
||||
ref="mapArea"
|
||||
class="map-area"
|
||||
:class="[mapThemeClass, mapSeasonClass]"
|
||||
:style="{ width: mapWidth, height: mapHeight }"
|
||||
>
|
||||
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
|
||||
<div class="map-layer map-bglayer2" />
|
||||
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
|
||||
<component
|
||||
:is="detailMode ? MapCityDetail : MapCityBasic"
|
||||
v-for="city in cityViews"
|
||||
:key="city.id"
|
||||
:city="city"
|
||||
:map-scale="mapScale"
|
||||
:show-name="showCityName"
|
||||
v-bind="detailProps"
|
||||
@hover="setHoveredCity"
|
||||
@leave="setHoveredCity(null)"
|
||||
@select="selectCity"
|
||||
/>
|
||||
<div v-if="hoveredCity" class="map-tooltip" :style="{ left: `${elementX + 16}px`, top: `${elementY + 16}px` }">
|
||||
<div
|
||||
v-if="hoveredCity"
|
||||
class="map-tooltip"
|
||||
:style="{ left: `${elementX + 16}px`, top: `${elementY + 16}px` }"
|
||||
>
|
||||
<div class="tooltip-title">{{ hoveredCity.name }}</div>
|
||||
<div class="tooltip-body">{{ hoveredCity.nationName }} · Lv {{ hoveredCity.level }}</div>
|
||||
<div class="tooltip-body">
|
||||
{{ hoveredCity.nationName }} · {{ hoveredCity.regionName }} · {{ hoveredCity.levelName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-meta">
|
||||
<span>도시 {{ props.mapData.cityList.length }}</span>
|
||||
<span>세력 {{ props.mapData.nationList.length }}</span>
|
||||
<span>테마 {{ props.mapLayout.mapName }}</span>
|
||||
</div>
|
||||
<div class="map-footnote">
|
||||
도시명/좌표 데이터는 추후 서버 API로 치환 예정
|
||||
좌표/도시명은 시나리오 맵 레이아웃을 기준으로 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,21 +369,23 @@ const setHoveredCity = (cityId: number | null) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.map-area {
|
||||
position: relative;
|
||||
border: 1px dashed rgba(201, 164, 90, 0.4);
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
background: #0b0b0b;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
.map-layer {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
inset: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-position: left top;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-tooltip {
|
||||
@@ -245,6 +407,7 @@ const setHoveredCity = (cityId: number | null) => {
|
||||
|
||||
.map-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
|
||||
Reference in New Issue
Block a user