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
+338
View File
@@ -0,0 +1,338 @@
import fs from 'node:fs/promises';
import path from 'node:path';
export interface MapLayoutCity {
id: number;
name: string;
level: number;
region: number;
x: number;
y: number;
path: number[];
}
export interface MapLayout {
mapName: string;
cityList: MapLayoutCity[];
regionMap: Record<number, string>;
levelMap: Record<number, string>;
}
interface ParsedCityConst {
initCity?: unknown[];
regionMap?: Record<string, unknown>;
levelMap?: Record<string, unknown>;
}
const LEGACY_SCENARIO_ROOT = path.resolve(process.cwd(), 'legacy/hwe/scenario');
const LEGACY_MAP_ROOT = path.resolve(LEGACY_SCENARIO_ROOT, 'map');
const LEGACY_CITY_CONST = path.resolve(process.cwd(), 'legacy/hwe/sammo/CityConstBase.php');
const layoutCache = new Map<string, MapLayout>();
const stripComments = (value: string): string =>
value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
const extractPhpArray = (source: string, marker: string): string | null => {
const idx = source.indexOf(marker);
if (idx < 0) {
return null;
}
const start = source.indexOf('[', idx);
if (start < 0) {
return null;
}
let depth = 0;
let inString = false;
let stringChar = '';
for (let i = start; i < source.length; i += 1) {
const char = source[i];
if (inString) {
if (char === stringChar && source[i - 1] !== '\\') {
inString = false;
}
continue;
}
if (char === '"' || char === "'") {
inString = true;
stringChar = char;
continue;
}
if (char === '[') {
depth += 1;
continue;
}
if (char === ']') {
depth -= 1;
if (depth === 0) {
return source.slice(start, i + 1);
}
}
}
return null;
};
const parsePhpArray = (input: string): unknown => {
let index = 0;
const skipWhitespace = () => {
while (index < input.length && /\s/.test(input[index] ?? '')) {
index += 1;
}
};
const parseString = () => {
const quote = input[index];
index += 1;
let value = '';
while (index < input.length) {
const char = input[index];
if (char === quote && input[index - 1] !== '\\') {
index += 1;
return value;
}
value += char;
index += 1;
}
return value;
};
const parseNumber = () => {
let raw = '';
while (index < input.length && /[0-9.+\-]/.test(input[index] ?? '')) {
raw += input[index];
index += 1;
}
return Number(raw);
};
const parseValue = (): unknown => {
skipWhitespace();
const char = input[index];
if (!char) {
return null;
}
if (char === '[') {
return parseArray();
}
if (char === '"' || char === "'") {
return parseString();
}
if (/[0-9.+\-]/.test(char)) {
return parseNumber();
}
if (input.startsWith('true', index)) {
index += 4;
return true;
}
if (input.startsWith('false', index)) {
index += 5;
return false;
}
if (input.startsWith('null', index)) {
index += 4;
return null;
}
return null;
};
const parseArray = (): unknown => {
const output: unknown[] = [];
const objectOutput: Record<string, unknown> = {};
let hasKeyed = false;
index += 1;
while (index < input.length) {
skipWhitespace();
if (input[index] === ']') {
index += 1;
break;
}
const keyOrValue = parseValue();
skipWhitespace();
if (input.slice(index, index + 2) === '=>') {
hasKeyed = true;
index += 2;
const value = parseValue();
objectOutput[String(keyOrValue)] = value;
} else if (keyOrValue !== null) {
output.push(keyOrValue);
}
skipWhitespace();
if (input[index] === ',') {
index += 1;
}
}
if (hasKeyed) {
return objectOutput;
}
return output;
};
return parseValue();
};
const parseCityConstFile = async (filePath: string): Promise<ParsedCityConst> => {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const source = stripComments(raw);
const initCityRaw = extractPhpArray(source, '$initCity');
const regionMapRaw = extractPhpArray(source, '$regionMap');
const levelMapRaw = extractPhpArray(source, '$levelMap');
return {
initCity: initCityRaw ? (parsePhpArray(initCityRaw) as unknown[]) : undefined,
regionMap: regionMapRaw ? (parsePhpArray(regionMapRaw) as Record<string, unknown>) : undefined,
levelMap: levelMapRaw ? (parsePhpArray(levelMapRaw) as Record<string, unknown>) : undefined,
};
} catch {
return {};
}
};
const resolveScenarioFile = async (scenario: string): Promise<string> => {
const normalized = scenario.replace(/\.json$/i, '');
const candidates = [
`${normalized}.json`,
`scenario_${normalized}.json`,
'default.json',
];
for (const candidate of candidates) {
const fullPath = path.join(LEGACY_SCENARIO_ROOT, candidate);
try {
await fs.access(fullPath);
return fullPath;
} catch {
continue;
}
}
return path.join(LEGACY_SCENARIO_ROOT, 'default.json');
};
const resolveMapName = async (scenario: string): Promise<string> => {
const scenarioPath = await resolveScenarioFile(scenario);
try {
const raw = await fs.readFile(scenarioPath, 'utf-8');
const parsed = JSON.parse(raw) as { map?: { mapName?: string } };
return parsed.map?.mapName ?? 'che';
} catch {
return 'che';
}
};
const buildLookupMap = (raw: Record<string, unknown> | undefined) => {
const idToName: Record<number, string> = {};
const nameToId: Record<string, number> = {};
if (!raw) {
return { idToName, nameToId };
}
for (const [key, value] of Object.entries(raw)) {
const numericKey = Number(key);
if (typeof value === 'string' && Number.isFinite(numericKey)) {
idToName[numericKey] = value;
continue;
}
if (typeof value === 'number') {
nameToId[key] = value;
}
}
return { idToName, nameToId };
};
const normalizeInitCity = (
initCity: unknown[],
levelMap: ReturnType<typeof buildLookupMap>,
regionMap: ReturnType<typeof buildLookupMap>
): MapLayoutCity[] => {
const rows = initCity.filter(Array.isArray) as unknown[][];
const nameToId = new Map<string, number>();
for (const row of rows) {
if (typeof row[0] === 'number' && typeof row[1] === 'string') {
nameToId.set(row[1], row[0]);
}
}
return rows
.map((row) => {
const [id, name, levelLabel, _pop, _agri, _comm, _secu, _def, _wall, regionLabel, x, y, path] = row;
if (typeof id !== 'number' || typeof name !== 'string') {
return null;
}
const levelValue =
typeof levelLabel === 'number'
? levelLabel
: typeof levelLabel === 'string'
? levelMap.nameToId[levelLabel] ?? Number(levelLabel)
: 0;
const regionValue =
typeof regionLabel === 'number'
? regionLabel
: typeof regionLabel === 'string'
? regionMap.nameToId[regionLabel] ?? Number(regionLabel)
: 0;
const pathNames = Array.isArray(path) ? (path as string[]) : [];
const pathIds = pathNames
.map((pathName) => nameToId.get(pathName))
.filter((value): value is number => typeof value === 'number');
return {
id,
name,
level: Number.isFinite(levelValue) ? levelValue : 0,
region: Number.isFinite(regionValue) ? regionValue : 0,
x: typeof x === 'number' ? x : 0,
y: typeof y === 'number' ? y : 0,
path: pathIds,
} satisfies MapLayoutCity;
})
.filter((value): value is MapLayoutCity => value !== null);
};
export const loadMapLayout = async (scenario: string): Promise<MapLayout> => {
const mapName = await resolveMapName(scenario);
const cached = layoutCache.get(mapName);
if (cached) {
return cached;
}
const base = await parseCityConstFile(LEGACY_CITY_CONST);
const mapPath = path.join(LEGACY_MAP_ROOT, `${mapName}.php`);
const map = await parseCityConstFile(mapPath);
const regionMapRaw = {
...(base.regionMap ?? {}),
...(map.regionMap ?? {}),
};
const levelMapRaw = {
...(base.levelMap ?? {}),
...(map.levelMap ?? {}),
};
const regionMap = buildLookupMap(regionMapRaw);
const levelMap = buildLookupMap(levelMapRaw);
const initCity = map.initCity ?? base.initCity ?? [];
const cityList = normalizeInitCity(initCity, levelMap, regionMap);
const layout: MapLayout = {
mapName,
cityList,
regionMap: regionMap.idToName,
levelMap: levelMap.idToName,
};
layoutCache.set(mapName, layout);
return layout;
};
+4
View File
@@ -31,6 +31,7 @@ import {
import { buildBattleSimJobPayload } from './battleSim/environment.js';
import { zBattleSimJobId, zBattleSimRequest } from './battleSim/schema.js';
import { loadWorldMap } from './maps/worldMap.js';
import { loadMapLayout } from './maps/mapLayout.js';
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -168,6 +169,9 @@ export const appRouter = router({
const state = await ctx.db.worldState.findFirst();
return state ? toWorldStateSnapshot(state) : null;
}),
getMapLayout: procedure.query(async ({ ctx }) => {
return loadMapLayout(ctx.profile.scenario);
}),
getMap: procedure
.input(
z.object({
@@ -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" />
+7 -1
View File
@@ -72,6 +72,7 @@
- UI 데이터 구성은 client-driven을 기본으로 하되 숨겨야 할 정보는 서버에서 제거
- 최소 정보 공개용 public API는 서버 캐시(10분)와 함께 제공
- 한국인 사용자 대상이며, 다국어 지원은 고려하지 않음.
- 지도 레이아웃(도시명/좌표)은 시나리오 기준으로 고정되므로 프로필 단위 캐시 가능
## Implementation Phases
@@ -126,13 +127,18 @@
- 메인 화면 스켈레톤: 지도/명령/장수/도시/국가/메시지 패널 + 반응형 레이아웃 + 실시간 토글 UI
- API 보강: 게임 API에 `general.me` 추가 (메인 화면 컨텍스트 제공)
- MapViewer 1차 이식: 지도 토글/툴팁/도시 마커/디테일 모드와 Pinia 상태 연결
- 지도 레이아웃 API: 시나리오 기반 도시명/좌표 제공 + MapViewer 연동 완료
- 지도 선택 연동: 클릭 시 선택 도시 패널/명령 패널에 연결
- 레거시 맵 렌더링 보강: 테마/계절 배경, 도로 레이어, 성/이벤트 아이콘, 상태색 로직 이식
- 지도 아이콘 베이스 경로: `VITE_GAME_ASSET_URL`로 레거시 이미지 경로 주입
## Next Frontend Tasks
- 게이트웨이 로그인/프로필 선택 플로우 정리 (토큰 전달 방식, 자동 로그인, 쿠키 기반 전환 고려)
- 공개(Public) 화면 구현: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람
- 실시간 업데이트(SSE) 연결 설계 및 메인 화면 토글과 연동
- MapViewer 데이터 보강: 도시명/좌표 제공 API 및 레거시 맵 렌더링 이식
- MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식
- 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑
- CommandSelectForm/MessagePanel 이식 마무리(예약/전송 플로우 연결)
- Join/빙의 UI 구현 및 완료 후 상태 갱신(장수 생성 감지)
- 화면 라우트 매핑 표 및 데이터 계약 문서화