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
@@ -20,9 +20,17 @@ interface TurnCommandTable {
nation: TurnCommandGroup[];
}
interface SelectedCityInfo {
id: number;
name: string;
nationName: string;
regionName: string;
}
const props = defineProps<{
commandTable: TurnCommandTable | null;
loading: boolean;
selectedCity: SelectedCityInfo | null;
}>();
const activeCategory = ref('');
@@ -34,6 +42,15 @@ const handleSelect = (commandKey: string) => {
<template>
<div class="command-panel">
<div class="command-selection">
<div class="label">선택 도시</div>
<div class="value">
<span v-if="props.selectedCity">
{{ props.selectedCity.name }} · {{ props.selectedCity.nationName }} · {{ props.selectedCity.regionName }}
</span>
<span v-else>선택된 도시 없음</span>
</div>
</div>
<CommandSelectForm
:command-table="props.commandTable"
:loading="props.loading"
@@ -54,6 +71,19 @@ const handleSelect = (commandKey: string) => {
gap: 12px;
}
.command-selection {
border: 1px solid rgba(201, 164, 90, 0.35);
padding: 6px 8px;
font-size: 0.75rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.command-selection .label {
color: rgba(232, 221, 196, 0.6);
}
.command-placeholder {
border: 1px dashed rgba(201, 164, 90, 0.3);
padding: 8px;
@@ -1,40 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue';
interface MapCityView {
id: number;
name: string;
level: number;
state: number;
nationId: number;
stateClass: 'good' | 'bad' | 'war' | 'wrong';
nationName: string;
color: string;
x: number;
y: number;
isCapital: boolean;
isMyCity: boolean;
supply: boolean;
selected: boolean;
}
const props = defineProps<{
city: MapCityView;
showName: boolean;
mapScale: number;
}>();
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
(event: 'select', cityId: number): void;
}>();
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
const stateSize = computed(() => 8 * props.mapScale);
const stateOffset = computed(() => -6 * props.mapScale);
</script>
<template>
<div
class="map-city"
:class="{ mine: props.city.isMyCity }"
:class="[
`state-${props.city.stateClass}`,
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
]"
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color }">
<div
class="city-dot"
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
>
<span v-if="props.city.isCapital" class="capital" />
</div>
<div
v-if="props.city.state > 0"
class="city-state"
:class="`state-${props.city.stateClass}`"
:style="{ width: `${stateSize}px`, height: `${stateSize}px`, left: `${stateOffset}px`, top: `${stateOffset}px` }"
/>
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
</div>
</template>
@@ -52,17 +74,16 @@ const emit = defineEmits<{
}
.city-dot {
width: 12px;
height: 12px;
border: 1px solid rgba(232, 221, 196, 0.6);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.capital {
width: 6px;
height: 6px;
width: 5px;
height: 5px;
background: rgba(232, 221, 196, 0.9);
}
@@ -70,6 +91,47 @@ const emit = defineEmits<{
box-shadow: 0 0 0 2px rgba(201, 164, 90, 0.6);
}
.map-city.selected .city-dot {
box-shadow: 0 0 0 2px rgba(255, 235, 150, 0.9);
}
.map-city.supply-off {
opacity: 0.6;
}
.map-city.state-good .city-dot {
border-color: rgba(120, 220, 120, 0.9);
}
.map-city.state-bad .city-dot {
border-color: rgba(240, 190, 90, 0.9);
}
.map-city.state-war .city-dot {
border-color: rgba(240, 90, 90, 0.9);
}
.map-city.state-wrong .city-dot {
border-color: rgba(150, 150, 150, 0.8);
}
.city-state {
position: absolute;
background: rgba(232, 221, 196, 0.8);
}
.city-state.state-war {
background: rgba(240, 90, 90, 0.9);
}
.city-state.state-bad {
background: rgba(240, 190, 90, 0.9);
}
.city-state.state-good {
background: rgba(90, 160, 255, 0.9);
}
.city-name {
white-space: nowrap;
}
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
interface MapCityView {
id: number;
name: string;
level: number;
levelName: string;
state: number;
stateClass: 'good' | 'bad' | 'war' | 'wrong';
nationId: number;
nationName: string;
color: string;
@@ -11,81 +16,238 @@ interface MapCityView {
y: number;
isCapital: boolean;
isMyCity: boolean;
supply: boolean;
selected: boolean;
}
type DetailSize = {
bgWidth: number;
bgHeight: number;
iconWidth: number;
iconHeight: number;
flagRight: number;
flagTop: number;
};
const DETAIL_SIZES: DetailSize[] = [
{ bgWidth: 48, bgHeight: 45, iconWidth: 16, iconHeight: 15, flagRight: -8, flagTop: -4 },
{ bgWidth: 60, bgHeight: 42, iconWidth: 20, iconHeight: 14, flagRight: -8, flagTop: -4 },
{ bgWidth: 42, bgHeight: 42, iconWidth: 14, iconHeight: 14, flagRight: -8, flagTop: -4 },
{ bgWidth: 60, bgHeight: 45, iconWidth: 20, iconHeight: 15, flagRight: -6, flagTop: -3 },
{ bgWidth: 72, bgHeight: 48, iconWidth: 24, iconHeight: 16, flagRight: -6, flagTop: -4 },
{ bgWidth: 78, bgHeight: 54, iconWidth: 26, iconHeight: 18, flagRight: -6, flagTop: -4 },
{ bgWidth: 84, bgHeight: 60, iconWidth: 28, iconHeight: 20, flagRight: -6, flagTop: -4 },
{ bgWidth: 96, bgHeight: 72, iconWidth: 32, iconHeight: 24, flagRight: -6, flagTop: -3 },
];
const props = defineProps<{
city: MapCityView;
showName: boolean;
imageBaseUrl: string;
themeName: string;
mapScale: number;
}>();
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
(event: 'select', cityId: number): void;
}>();
const colorToken = computed(() => normalizeColorToken(props.city.color));
const detailSize = computed(() => {
const index = Math.min(Math.max(props.city.level, 1), DETAIL_SIZES.length) - 1;
const base = DETAIL_SIZES[index];
const scale = props.mapScale;
return {
bgWidth: base.bgWidth * scale,
bgHeight: base.bgHeight * scale,
iconWidth: base.iconWidth * scale,
iconHeight: base.iconHeight * scale,
flagRight: base.flagRight * scale,
flagTop: base.flagTop * scale,
};
});
const baseSize = computed(() => ({
width: 40 * props.mapScale,
height: 30 * props.mapScale,
}));
const cityBaseStyle = computed(() => ({
left: `${props.city.x}px`,
top: `${props.city.y}px`,
width: `${baseSize.value.width}px`,
height: `${baseSize.value.height}px`,
}));
const cityBgStyle = computed(() => {
if (!colorToken.value || props.city.nationId <= 0) {
return null;
}
const style: Record<string, string> = {
width: `${detailSize.value.bgWidth}px`,
height: `${detailSize.value.bgHeight}px`,
};
if (props.themeName === 'cr') {
style.backgroundColor = props.city.color;
style.opacity = '0.5';
} else {
style.backgroundImage = `url('${buildAssetUrl(props.imageBaseUrl, `b${colorToken.value}.png`)}')`;
}
return style;
});
const castleIcon = computed(() => buildAssetUrl(props.imageBaseUrl, `cast_${props.city.level}.gif`));
const stateIcon = computed(() =>
props.city.state > 0 ? buildAssetUrl(props.imageBaseUrl, `event${props.city.state}.gif`) : null
);
const flagIcon = computed(() => {
if (props.city.nationId <= 0 || !colorToken.value) {
return null;
}
const prefix = props.city.supply ? 'f' : 'd';
return buildAssetUrl(props.imageBaseUrl, `${prefix}${colorToken.value}.gif`);
});
const capitalIcon = computed(() => buildAssetUrl(props.imageBaseUrl, 'event51.gif'));
const cityBgWrapperStyle = computed(() => ({
left: '50%',
top: '50%',
marginLeft: `${-detailSize.value.bgWidth / 2}px`,
marginTop: `${-detailSize.value.bgHeight / 2}px`,
}));
const cityIconStyle = computed(() => ({
width: `${detailSize.value.iconWidth}px`,
height: `${detailSize.value.iconHeight}px`,
}));
const cityFlagStyle = computed(() => ({
right: `${detailSize.value.flagRight}px`,
top: `${detailSize.value.flagTop}px`,
width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`,
}));
const capitalIconStyle = computed(() => ({
width: `${10 * props.mapScale}px`,
height: `${10 * props.mapScale}px`,
}));
const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`,
top: `${6 * props.mapScale}px`,
}));
</script>
<template>
<div
class="map-city detail"
:class="{ mine: props.city.isMyCity }"
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
class="city-base"
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
>
<div class="city-card">
<div class="header">
<span class="dot" :style="{ backgroundColor: props.city.color }" />
<span class="name">{{ props.city.name }}</span>
<span v-if="props.city.isCapital" class="capital">수도</span>
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
<div class="city-img">
<img class="city-icon" :src="castleIcon" :style="cityIconStyle" />
<div class="city-filler" :class="{ 'my-city': props.city.isMyCity }" />
<div v-if="flagIcon" class="city-flag" :style="cityFlagStyle">
<img :src="flagIcon" />
<div v-if="props.city.isCapital" class="city-capital" :style="capitalIconStyle">
<img :src="capitalIcon" />
</div>
</div>
<div class="meta">Lv {{ props.city.level }} · {{ props.city.nationName }}</div>
<span v-if="props.showName" class="city-name">{{ props.city.name }}</span>
</div>
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" />
</div>
</div>
</template>
<style scoped>
.map-city.detail {
.city-base {
position: absolute;
transform: translate(-50%, -50%);
font-size: 0.65rem;
}
.city-card {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
padding: 4px 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.header {
display: flex;
align-items: center;
gap: 4px;
}
.dot {
width: 10px;
height: 10px;
border: 1px solid rgba(232, 221, 196, 0.6);
}
.name {
font-weight: 600;
color: rgba(232, 221, 196, 0.9);
}
.capital {
font-size: 0.6rem;
color: rgba(232, 221, 196, 0.7);
.city-bg {
position: absolute;
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.meta {
color: rgba(232, 221, 196, 0.6);
.city-img {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.map-city.detail.mine .city-card {
.city-icon {
display: block;
}
.city-filler {
position: absolute;
inset: -2px;
pointer-events: none;
}
.city-base.mine .city-icon {
box-shadow: 0 0 0 1px rgba(201, 164, 90, 0.7);
}
.city-base.selected .city-icon {
box-shadow: 0 0 0 2px rgba(255, 235, 150, 0.9);
}
.city-base.supply-off {
opacity: 0.6;
}
.city-flag {
position: absolute;
}
.city-flag img,
.city-state img,
.city-capital img {
width: 100%;
height: 100%;
}
.city-capital {
position: absolute;
top: 0;
right: -1px;
}
.city-name {
position: absolute;
left: 70%;
bottom: -10px;
background: rgba(0, 0, 0, 0.5);
white-space: nowrap;
font-size: 0.6rem;
color: rgba(232, 221, 196, 0.9);
}
.city-state {
position: absolute;
left: 0;
}
</style>
@@ -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);
@@ -0,0 +1,85 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
interface SelectedCityInfo {
id: number;
name: string;
nationName: string;
nationColor: string;
regionName: string;
levelName: string;
state: number;
supply: boolean;
isCapital: boolean;
isMyCity: boolean;
}
const props = defineProps<{
city: SelectedCityInfo | null;
loading: boolean;
}>();
</script>
<template>
<div class="selected-city">
<div v-if="props.loading">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="!props.city" class="empty">
지도를 클릭하면 도시 정보가 표시됩니다.
</div>
<div v-else class="city-body">
<div class="title">
<span class="flag" :style="{ backgroundColor: props.city.nationColor }" />
<span>{{ props.city.name }}</span>
<span v-if="props.city.isCapital" class="tag">수도</span>
<span v-if="props.city.isMyCity" class="tag"> 도시</span>
</div>
<div class="meta">
<div>국가 {{ props.city.nationName }}</div>
<div>지역 {{ props.city.regionName }}</div>
<div>규모 {{ props.city.levelName }}</div>
<div>상태 {{ props.city.state }}</div>
<div>보급 {{ props.city.supply ? 'O' : 'X' }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.selected-city {
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.flag {
width: 12px;
height: 12px;
border: 1px solid rgba(232, 221, 196, 0.6);
}
.tag {
font-size: 0.65rem;
padding: 2px 4px;
border: 1px solid rgba(201, 164, 90, 0.4);
}
.meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: 4px;
font-size: 0.75rem;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
</style>
+1
View File
@@ -9,6 +9,7 @@ declare module '*.vue' {
interface ImportMetaEnv {
readonly VITE_GATEWAY_API_URL?: string;
readonly VITE_GAME_API_URL?: string;
readonly VITE_GAME_ASSET_URL?: string;
readonly VITE_GAME_PROFILE?: string;
}
+44 -1
View File
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
import { trpc } from '../utils/trpc';
import { useMapViewerStore } from './mapViewer';
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
@@ -17,6 +18,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type GeneralContext = Awaited<ReturnType<typeof trpc.general.me.query>>;
type LobbyInfo = Awaited<ReturnType<typeof trpc.lobby.info.query>>;
type WorldMapResult = Awaited<ReturnType<typeof trpc.world.getMap.query>>;
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
@@ -28,6 +30,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const generalContext = ref<GeneralContext | null>(null);
const lobbyInfo = ref<LobbyInfo | null>(null);
const worldMap = ref<WorldMapResult | null>(null);
const mapLayout = ref<MapLayout | null>(null);
const commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null);
@@ -39,6 +42,41 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const nation = computed(() => generalContext.value?.nation ?? null);
const generalId = computed(() => general.value?.id ?? null);
const nationId = computed(() => nation.value?.id ?? null);
const mapViewer = useMapViewerStore();
const selectedCity = computed(() => {
const layout = mapLayout.value;
const map = worldMap.value;
const selectedId = mapViewer.selectedCityId;
if (!layout || !map || !selectedId) {
return null;
}
const layoutCity = layout.cityList.find((city) => city.id === selectedId);
const mapEntry = map.cityList.find((entry) => entry[0] === selectedId);
if (!layoutCity || !mapEntry) {
return null;
}
const [, , state, nationIdValue, region, supplyFlag] = mapEntry;
const nationEntry = map.nationList.find((nationEntry) => nationEntry[0] === nationIdValue);
const regionName = layout.regionMap[region] ?? '-';
const levelName = layout.levelMap[layoutCity.level] ?? '-';
return {
id: layoutCity.id,
name: layoutCity.name,
level: layoutCity.level,
levelName,
region,
regionName,
nationId: nationIdValue,
nationName: nationEntry?.[1] ?? '무주',
nationColor: nationEntry?.[2] ?? '#444444',
state,
supply: supplyFlag > 0,
isCapital: nationEntry?.[3] === layoutCity.id,
isMyCity: map.myCity === layoutCity.id,
} as const;
});
const mailboxOptions = computed(() => {
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
@@ -90,13 +128,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
const id = context.general.id;
const [lobby, map, commands, messageData] = await Promise.all([
const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query();
const [layout, lobby, map, commands, messageData] = await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
]);
mapLayout.value = layout;
lobbyInfo.value = lobby;
worldMap.value = map;
commandTable.value = commands;
@@ -186,6 +227,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
nation,
lobbyInfo,
worldMap,
mapLayout,
selectedCity,
commandTable,
messages,
messageDraftText,
@@ -4,6 +4,7 @@ interface MapViewerState {
showCityName: boolean;
detailMode: boolean;
hoveredCityId: number | null;
selectedCityId: number | null;
}
export const useMapViewerStore = defineStore('mapViewer', {
@@ -11,6 +12,7 @@ export const useMapViewerStore = defineStore('mapViewer', {
showCityName: true,
detailMode: false,
hoveredCityId: null,
selectedCityId: null,
}),
actions: {
toggleCityName() {
@@ -22,5 +24,8 @@ export const useMapViewerStore = defineStore('mapViewer', {
setHoveredCity(cityId: number | null) {
this.hoveredCityId = cityId;
},
setSelectedCity(cityId: number | null) {
this.selectedCityId = cityId;
},
},
});
+21
View File
@@ -0,0 +1,21 @@
const normalizeBase = (value: string | undefined | null): string => {
const base = (value ?? '').trim();
return base.replace(/\/+$/, '');
};
export const buildAssetUrl = (base: string | undefined | null, path: string): string => {
const normalizedBase = normalizeBase(base);
const normalizedPath = path.replace(/^\/+/, '');
if (!normalizedBase) {
return `/${normalizedPath}`;
}
return `${normalizedBase}/${normalizedPath}`;
};
export const normalizeColorToken = (color: string | undefined | null): string | null => {
if (!color) {
return null;
}
const cleaned = color.trim().replace(/^#/, '').toUpperCase();
return cleaned.length > 0 ? cleaned : null;
};
+13 -4
View File
@@ -10,6 +10,7 @@ import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import MessagePanel from '../components/main/MessagePanel.vue';
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
import { useSessionStore } from '../stores/session';
import { useMainDashboardStore } from '../stores/mainDashboard';
@@ -38,6 +39,8 @@ const {
nation,
lobbyInfo,
worldMap,
mapLayout,
selectedCity,
commandTable,
messages,
messageDraftText,
@@ -101,13 +104,16 @@ watch(
<div class="mobile-panel" v-if="mobileTab === 'map'">
<PanelCard title="지도">
<MapViewer :map-data="worldMap" :loading="loading" />
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
</PanelCard>
<PanelCard title="선택 도시">
<SelectedCityPanel :city="selectedCity" :loading="loading" />
</PanelCard>
</div>
<div class="mobile-panel" v-if="mobileTab === 'commands'">
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
<CommandListPanel :command-table="commandTable" :loading="loading" />
<CommandListPanel :command-table="commandTable" :loading="loading" :selected-city="selectedCity" />
</PanelCard>
</div>
@@ -163,7 +169,10 @@ watch(
<section v-else class="layout-desktop">
<div class="stack">
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황">
<MapViewer :map-data="worldMap" :loading="loading" />
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
</PanelCard>
<PanelCard title="선택 도시">
<SelectedCityPanel :city="selectedCity" :loading="loading" />
</PanelCard>
<PanelCard title="중원 정세">
<SkeletonLines v-if="loading" :lines="3" />
@@ -191,7 +200,7 @@ watch(
<div class="stack">
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
<CommandListPanel :command-table="commandTable" :loading="loading" />
<CommandListPanel :command-table="commandTable" :loading="loading" :selected-city="selectedCity" />
</PanelCard>
<PanelCard title="장수 스탯">
<GeneralBasicCard :general="general" :loading="loading" />