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:
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
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" />
|
||||
|
||||
@@ -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 구현 및 완료 후 상태 갱신(장수 생성 감지)
|
||||
- 화면 라우트 매핑 표 및 데이터 계약 문서화
|
||||
|
||||
Reference in New Issue
Block a user