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:
2026-01-16 17:32:45 +00:00
parent 5af2799662
commit 3ea81e4532
13 changed files with 1019 additions and 90 deletions
@@ -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);