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:
@@ -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>
|
||||
Vendored
+1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user