feat: add Ref city hover details to public maps
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { loadMapDefinitionByName as loadRuntimeMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
|
||||
import {
|
||||
loadMapDefinitionByName as loadRuntimeMapDefinitionByName,
|
||||
loadRegionDisplayMapByName as loadRuntimeRegionDisplayMapByName,
|
||||
} from '@sammo-ts/game-engine/scenario/mapLoader.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic';
|
||||
|
||||
const mapCache = new Map<string, MapDefinition>();
|
||||
@@ -15,3 +18,6 @@ export const loadMapDefinitionByName = async (mapName: string): Promise<MapDefin
|
||||
mapCache.set(mapName, map);
|
||||
return map;
|
||||
};
|
||||
|
||||
export const loadRegionDisplayMapByName = (mapName: string): Promise<Record<number, string>> =>
|
||||
loadRuntimeRegionDisplayMapByName(mapName);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||
import type { ScenarioDefinition } from '@sammo-ts/logic';
|
||||
|
||||
import { loadMapDefinitionByName } from './mapDefinition.js';
|
||||
import { loadMapDefinitionByName, loadRegionDisplayMapByName } from './mapDefinition.js';
|
||||
|
||||
export interface MapLayoutCity {
|
||||
id: number;
|
||||
@@ -23,10 +23,22 @@ export interface MapLayout {
|
||||
export interface MapLayoutLoaderOptions {
|
||||
loadScenario?: (scenarioId: number) => Promise<ScenarioDefinition>;
|
||||
loadMap?: typeof loadMapDefinitionByName;
|
||||
loadRegionMap?: typeof loadRegionDisplayMapByName;
|
||||
}
|
||||
|
||||
const layoutCache = new Map<string, MapLayout>();
|
||||
|
||||
const CITY_LEVEL_MAP: Record<number, string> = {
|
||||
1: '수',
|
||||
2: '진',
|
||||
3: '관',
|
||||
4: '이',
|
||||
5: '소',
|
||||
6: '중',
|
||||
7: '대',
|
||||
8: '특',
|
||||
};
|
||||
|
||||
const parseScenarioId = (scenario: string): number | null => {
|
||||
const normalized = scenario.replace(/^scenario_/i, '').replace(/\.json$/i, '');
|
||||
if (!/^\d+$/.test(normalized)) {
|
||||
@@ -56,13 +68,16 @@ const resolveMapName = async (
|
||||
|
||||
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
|
||||
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
|
||||
const useCache = !options.loadScenario && !options.loadMap;
|
||||
const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap;
|
||||
const cached = useCache ? layoutCache.get(mapName) : undefined;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const map = await (options.loadMap ?? loadMapDefinitionByName)(mapName);
|
||||
const [map, regionMap] = await Promise.all([
|
||||
(options.loadMap ?? loadMapDefinitionByName)(mapName),
|
||||
(options.loadRegionMap ?? loadRegionDisplayMapByName)(mapName),
|
||||
]);
|
||||
const layout: MapLayout = {
|
||||
mapName,
|
||||
cityList: map.cities.map((city) => ({
|
||||
@@ -74,8 +89,8 @@ export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOp
|
||||
y: city.position.y,
|
||||
path: [...city.connections],
|
||||
})),
|
||||
regionMap: {},
|
||||
levelMap: {},
|
||||
regionMap,
|
||||
levelMap: CITY_LEVEL_MAP,
|
||||
};
|
||||
|
||||
if (useCache) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadRegionDisplayMapByName } from '../src/maps/mapDefinition.js';
|
||||
import { loadMapLayout } from '../src/maps/mapLayout.js';
|
||||
|
||||
describe('map layout resource adapter', () => {
|
||||
it('loads the Ref theme region labels from the shared resource', async () => {
|
||||
await expect(loadRegionDisplayMapByName('che')).resolves.toMatchObject({ 1: '하북', 8: '동이' });
|
||||
await expect(loadRegionDisplayMapByName('chess')).resolves.toMatchObject({ 1: '킹', 7: '빈칸' });
|
||||
});
|
||||
|
||||
it('resolves the scenario map through the shared runtime loaders', async () => {
|
||||
const loadScenario = vi.fn().mockResolvedValue({
|
||||
config: { environment: { mapName: 'custom-map' } },
|
||||
@@ -20,7 +26,9 @@ describe('map layout resource adapter', () => {
|
||||
],
|
||||
});
|
||||
|
||||
await expect(loadMapLayout('scenario_2601.json', { loadScenario, loadMap })).resolves.toEqual({
|
||||
const loadRegionMap = vi.fn().mockResolvedValue({ 2: '테스트권역' });
|
||||
|
||||
await expect(loadMapLayout('scenario_2601.json', { loadScenario, loadMap, loadRegionMap })).resolves.toEqual({
|
||||
mapName: 'custom-map',
|
||||
cityList: [
|
||||
{
|
||||
@@ -33,11 +41,12 @@ describe('map layout resource adapter', () => {
|
||||
path: [8],
|
||||
},
|
||||
],
|
||||
regionMap: {},
|
||||
levelMap: {},
|
||||
regionMap: { 2: '테스트권역' },
|
||||
levelMap: { 1: '수', 2: '진', 3: '관', 4: '이', 5: '소', 6: '중', 7: '대', 8: '특' },
|
||||
});
|
||||
expect(loadScenario).toHaveBeenCalledWith(2601);
|
||||
expect(loadMap).toHaveBeenCalledWith('custom-map');
|
||||
expect(loadRegionMap).toHaveBeenCalledWith('custom-map');
|
||||
});
|
||||
|
||||
it('retains the che fallback for unknown preserved scenarios', async () => {
|
||||
@@ -47,6 +56,7 @@ describe('map layout resource adapter', () => {
|
||||
loadMapLayout('custom-runtime', {
|
||||
loadScenario: vi.fn(),
|
||||
loadMap,
|
||||
loadRegionMap: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
).resolves.toMatchObject({ mapName: 'che' });
|
||||
expect(loadMap).toHaveBeenCalledWith('che');
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { MapDefinitionSchema, type MapDefinition } from '@sammo-ts/logic';
|
||||
import { MapDefinitionSchema, RegionMapSchema, type MapDefinition } from '@sammo-ts/logic';
|
||||
|
||||
import { resolveWorkspaceRoot } from '../paths.js';
|
||||
|
||||
const REPO_ROOT = resolveWorkspaceRoot();
|
||||
const DEFAULT_MAP_ROOT = path.resolve(REPO_ROOT, 'resources', 'map');
|
||||
const DEFAULT_REGION_MAP_PATH = path.resolve(DEFAULT_MAP_ROOT, 'region_map.json');
|
||||
|
||||
export interface MapLoaderOptions {
|
||||
mapRoot?: string;
|
||||
filePrefix?: string;
|
||||
}
|
||||
|
||||
export interface RegionMapLoaderOptions {
|
||||
regionMapPath?: string;
|
||||
}
|
||||
|
||||
const readJsonFile = async (filePath: string): Promise<unknown> => {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as unknown;
|
||||
@@ -34,3 +39,17 @@ export const loadMapDefinitionByName = async (mapName: string, options?: MapLoad
|
||||
const mapPath = resolveMapDefinitionPath(mapName, options);
|
||||
return loadMapDefinition(mapPath);
|
||||
};
|
||||
|
||||
export const loadRegionDisplayMapByName = async (
|
||||
mapName: string,
|
||||
options?: RegionMapLoaderOptions
|
||||
): Promise<Record<number, string>> => {
|
||||
const raw = await readJsonFile(options?.regionMapPath ?? DEFAULT_REGION_MAP_PATH);
|
||||
const regionMaps = RegionMapSchema.parse(raw);
|
||||
const selected = regionMaps[mapName] ?? {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(selected)
|
||||
.map(([key, value]) => [Number(key), value] as const)
|
||||
.filter(([key]) => Number.isSafeInteger(key))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -223,9 +223,7 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
body: rectOf(mapBody),
|
||||
road: rectOf(mapBody.querySelector('[data-testid="map-preview-road"]')),
|
||||
largeCastle: rectOf(mapBody.querySelector('[data-testid="map-preview-castle"]')),
|
||||
largeNationBackground: rectOf(
|
||||
mapBody.querySelector('[data-testid="map-preview-city-background"]')
|
||||
),
|
||||
largeNationBackground: rectOf(mapBody.querySelector('[data-testid="map-preview-city-background"]')),
|
||||
};
|
||||
});
|
||||
expect(mapGeometry).toEqual({
|
||||
@@ -253,6 +251,31 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
.toEqual([700, 500]);
|
||||
}
|
||||
|
||||
const firstCity = panel.getByTestId('map-preview-city').first();
|
||||
await expect(firstCity).not.toHaveAttribute('title');
|
||||
await firstCity.hover();
|
||||
const cityTooltip = panel.getByTestId('map-preview-city-tooltip');
|
||||
await expect(cityTooltip).toBeVisible();
|
||||
await expect(cityTooltip.locator('.tooltip-city-name')).toHaveText('【중원|특】낙양');
|
||||
await expect(cityTooltip.locator('.tooltip-nation-name')).toHaveText('위');
|
||||
const tooltipGeometry = await cityTooltip.evaluate((tooltip) => {
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
const style = getComputedStyle(tooltip);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
backgroundColor: style.backgroundColor,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
});
|
||||
expect(tooltipGeometry.width).toBeGreaterThanOrEqual(120);
|
||||
expect(tooltipGeometry.height).toBe(32);
|
||||
expect(tooltipGeometry.backgroundColor).toBe('rgb(30, 164, 255)');
|
||||
expect(tooltipGeometry.fontSize).toBe('14px');
|
||||
expect(tooltipGeometry.lineHeight).toBe('15px');
|
||||
await page.screenshot({ path: testInfo.outputPath('public-map-city-hover-desktop.png'), fullPage: true });
|
||||
|
||||
await hweTab.hover();
|
||||
await expect(hweTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(panel).toContainText('유저 22 / 500');
|
||||
@@ -276,10 +299,12 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
expect(geometry.width).toBeLessThanOrEqual(992);
|
||||
expect(geometry.borderLeft).toBe('1px solid rgb(63, 63, 70)');
|
||||
expect(geometry.borderTopWidth).toBe('0px');
|
||||
expect(geometry.backgroundColor).toBe('rgba(9, 9, 11, 0.498)');
|
||||
expect(geometry.backgroundColor).toBe('rgba(9, 9, 11, 0.5)');
|
||||
await page.screenshot({ path: testInfo.outputPath('public-map-tabs-desktop.png'), fullPage: true });
|
||||
await testInfo.attach('public-map-tabs-desktop-geometry', {
|
||||
body: Buffer.from(`${JSON.stringify({ panel: geometry, map: mapGeometry }, null, 2)}\n`),
|
||||
body: Buffer.from(
|
||||
`${JSON.stringify({ panel: geometry, map: mapGeometry, tooltip: tooltipGeometry }, null, 2)}\n`
|
||||
),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
});
|
||||
@@ -307,6 +332,24 @@ test.describe('touch navigation', () => {
|
||||
expect(mapBox?.width).toBeGreaterThan(280);
|
||||
expect(mapBox?.width).toBeLessThanOrEqual(366);
|
||||
expect(mapBox?.height).toBeCloseTo((mapBox?.width ?? 0) * (5 / 7), 0);
|
||||
const rightCity = panel.getByTestId('map-preview-city').nth(1);
|
||||
await rightCity.hover();
|
||||
const tooltip = panel.getByTestId('map-preview-city-tooltip');
|
||||
await expect(tooltip).toContainText('【중원|수】허창');
|
||||
await expect(tooltip).toContainText('촉');
|
||||
const tooltipBounds = await tooltip.evaluate((element) => {
|
||||
const tooltipRect = element.getBoundingClientRect();
|
||||
const mapRect = element.parentElement?.getBoundingClientRect();
|
||||
if (!mapRect) throw new Error('expected map preview body');
|
||||
return {
|
||||
left: tooltipRect.left - mapRect.left,
|
||||
right: mapRect.right - tooltipRect.right,
|
||||
width: tooltipRect.width,
|
||||
};
|
||||
});
|
||||
expect(tooltipBounds.left).toBeGreaterThanOrEqual(0);
|
||||
expect(tooltipBounds.right).toBeGreaterThanOrEqual(0);
|
||||
expect(tooltipBounds.width).toBeGreaterThanOrEqual(120);
|
||||
await page.screenshot({ path: testInfo.outputPath('public-map-tabs-mobile.png'), fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { configuredGameAssetUrl } from '../utils/imageAssets';
|
||||
|
||||
interface MapSummary {
|
||||
@@ -22,6 +22,8 @@ interface MapLayoutCity {
|
||||
interface MapLayout {
|
||||
mapName: string;
|
||||
cityList: MapLayoutCity[];
|
||||
regionMap: Record<number, string>;
|
||||
levelMap: Record<number, string>;
|
||||
}
|
||||
|
||||
type DetailSize = {
|
||||
@@ -37,8 +39,11 @@ interface CityPreview {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
levelName: string;
|
||||
regionName: string;
|
||||
state: number;
|
||||
nationId: number;
|
||||
nationName: string;
|
||||
color: string;
|
||||
colorToken: string | null;
|
||||
supply: boolean;
|
||||
@@ -63,6 +68,8 @@ const BASE_MAP_WIDTH = 700;
|
||||
const BASE_MAP_HEIGHT = 500;
|
||||
const CITY_BASE_WIDTH = 40;
|
||||
const CITY_BASE_HEIGHT = 30;
|
||||
const TOOLTIP_MIN_WIDTH = 120;
|
||||
const TOOLTIP_CURSOR_OFFSET = 10;
|
||||
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 },
|
||||
@@ -118,9 +125,9 @@ const mapRoad = computed(() => {
|
||||
});
|
||||
|
||||
const nationById = computed(() => {
|
||||
const map = new Map<number, { color: string; capitalCityId: number }>();
|
||||
for (const [id, , color, capitalCityId] of props.mapData.nationList) {
|
||||
map.set(id, { color, capitalCityId });
|
||||
const map = new Map<number, { name: string; color: string; capitalCityId: number }>();
|
||||
for (const [id, name, color, capitalCityId] of props.mapData.nationList) {
|
||||
map.set(id, { name, color, capitalCityId });
|
||||
}
|
||||
return map;
|
||||
});
|
||||
@@ -136,15 +143,19 @@ const dynamicCityById = computed(() => {
|
||||
const cities = computed<CityPreview[]>(() =>
|
||||
props.mapLayout.cityList.map((layoutCity) => {
|
||||
const dynamic = dynamicCityById.value.get(layoutCity.id);
|
||||
const [level = layoutCity.level, state = 0, nationId = 0, , supplyFlag = 0] = dynamic ?? [];
|
||||
const [level = layoutCity.level, state = 0, nationId = 0, region = layoutCity.region, supplyFlag = 0] =
|
||||
dynamic ?? [];
|
||||
const nation = nationById.value.get(nationId);
|
||||
const color = nation?.color ?? '#ffffff';
|
||||
return {
|
||||
id: layoutCity.id,
|
||||
name: layoutCity.name,
|
||||
level,
|
||||
levelName: props.mapLayout.levelMap[level] ?? '-',
|
||||
regionName: props.mapLayout.regionMap[region] ?? '-',
|
||||
state,
|
||||
nationId,
|
||||
nationName: nation?.name ?? '',
|
||||
color,
|
||||
colorToken: normalizeColorToken(color),
|
||||
supply: supplyFlag > 0,
|
||||
@@ -156,6 +167,44 @@ const cities = computed<CityPreview[]>(() =>
|
||||
})
|
||||
);
|
||||
|
||||
const mapBody = ref<HTMLElement | null>(null);
|
||||
const tooltipElement = ref<HTMLElement | null>(null);
|
||||
const hoveredCityId = ref<number | null>(null);
|
||||
const pointerPosition = ref({ x: 0, y: 0 });
|
||||
const hoveredCity = computed(() => cities.value.find((city) => city.id === hoveredCityId.value) ?? null);
|
||||
const hoveredCityTitle = computed(() => {
|
||||
const city = hoveredCity.value;
|
||||
return city ? `【${city.regionName}|${city.levelName}】${city.name}` : '';
|
||||
});
|
||||
const tooltipPosition = computed(() => {
|
||||
const mapWidth = mapBody.value?.clientWidth ?? BASE_MAP_WIDTH;
|
||||
const tooltipWidth = tooltipElement.value?.offsetWidth ?? TOOLTIP_MIN_WIDTH;
|
||||
const { x, y } = pointerPosition.value;
|
||||
const left = x + tooltipWidth + TOOLTIP_CURSOR_OFFSET > mapWidth ? x - tooltipWidth - 5 : x + TOOLTIP_CURSOR_OFFSET;
|
||||
return {
|
||||
left: `${Math.max(0, left)}px`,
|
||||
top: `${y + 30}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const updatePointerPosition = (event: PointerEvent): void => {
|
||||
const rect = mapBody.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
pointerPosition.value = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const showCityTooltip = (cityId: number, event: PointerEvent): void => {
|
||||
hoveredCityId.value = cityId;
|
||||
updatePointerPosition(event);
|
||||
};
|
||||
|
||||
const hideCityTooltip = (): void => {
|
||||
hoveredCityId.value = null;
|
||||
};
|
||||
|
||||
const cityBaseStyle = (city: CityPreview) => ({
|
||||
left: percentOf(city.x, BASE_MAP_WIDTH),
|
||||
top: percentOf(city.y, BASE_MAP_HEIGHT),
|
||||
@@ -231,7 +280,7 @@ const stateClass = (state: number): string => {
|
||||
<span class="map-preview-title">{{ props.mapLayout.mapName }}</span>
|
||||
<span class="map-preview-date">{{ props.mapData.year }}년 {{ props.mapData.month }}월</span>
|
||||
</div>
|
||||
<div class="map-preview-body" :style="{ backgroundImage: `url('${mapBackground}')` }">
|
||||
<div ref="mapBody" class="map-preview-body" :style="{ backgroundImage: `url('${mapBackground}')` }">
|
||||
<div
|
||||
v-if="mapRoad"
|
||||
class="map-preview-road"
|
||||
@@ -243,9 +292,11 @@ const stateClass = (state: number): string => {
|
||||
:key="city.id"
|
||||
class="city-base"
|
||||
:class="[`city-level-${city.level}`, { capital: city.isCapital }]"
|
||||
:title="city.name"
|
||||
:style="cityBaseStyle(city)"
|
||||
data-testid="map-preview-city"
|
||||
@pointerenter="showCityTooltip(city.id, $event)"
|
||||
@pointermove="updatePointerPosition"
|
||||
@pointerleave="hideCityTooltip"
|
||||
>
|
||||
<template v-if="props.mode === 'detail'">
|
||||
<div
|
||||
@@ -263,10 +314,7 @@ const stateClass = (state: number): string => {
|
||||
data-testid="map-preview-castle"
|
||||
/>
|
||||
<div v-if="city.nationId > 0 && city.colorToken" class="city-flag" :style="flagStyle(city)">
|
||||
<img
|
||||
:src="assetUrl(`${city.supply ? 'f' : 'd'}${city.colorToken}.gif`)"
|
||||
alt=""
|
||||
/>
|
||||
<img :src="assetUrl(`${city.supply ? 'f' : 'd'}${city.colorToken}.gif`)" alt="" />
|
||||
<img v-if="city.isCapital" class="capital-image" :src="assetUrl('event51.gif')" alt="" />
|
||||
</div>
|
||||
<span class="city-name" :style="cityNameStyle(city)">{{ city.name }}</span>
|
||||
@@ -289,6 +337,17 @@ const stateClass = (state: number): string => {
|
||||
<span class="city-name" :style="basicCityNameStyle(city)">{{ city.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hoveredCity"
|
||||
ref="tooltipElement"
|
||||
class="map-preview-tooltip"
|
||||
:style="tooltipPosition"
|
||||
role="tooltip"
|
||||
data-testid="map-preview-city-tooltip"
|
||||
>
|
||||
<div class="tooltip-city-name">{{ hoveredCityTitle }}</div>
|
||||
<div class="tooltip-nation-name">{{ hoveredCity.nationName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -332,6 +391,29 @@ const stateClass = (state: number): string => {
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.map-preview-tooltip {
|
||||
position: absolute;
|
||||
z-index: 16;
|
||||
min-width: 120px;
|
||||
border: 1px solid gray;
|
||||
background: rgb(30, 164, 255);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 15px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tooltip-city-name,
|
||||
.tooltip-nation-name {
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.tooltip-nation-name {
|
||||
border-top: 1px solid gray;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.city-base {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
||||
Reference in New Issue
Block a user