From 1d473cf70477f74833830d20a21e2989eeda59cc Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 15:25:14 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A7=80=EB=8F=84=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EB=B6=80=EB=B3=84=20=EC=84=A0=ED=83=9D=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=EC=9D=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 지도는 선택 테두리를 숨기고 명령 대상 지도는 터치 첫 탭에 도시와 국가를 선택하도록 분리한다. 현재 도시는 반복 점멸 대신 고정 고대비 이중 링으로 강조한다. --- .../e2e/commandArguments.spec.ts | 96 ++++++++++++++++++- app/game-frontend/e2e/inGameInfo.spec.ts | 7 ++ .../src/components/main/MapCityBasic.vue | 14 ++- .../src/components/main/MapCityDetail.vue | 10 +- .../src/components/main/MapViewer.vue | 11 ++- app/game-frontend/src/views/MainView.vue | 14 ++- 6 files changed, 140 insertions(+), 12 deletions(-) diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index d9ae12af..068bb584 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { touchDrag } from './touchDrag.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); @@ -1403,6 +1403,32 @@ test('enters general and nation command arguments and sends exact values', async await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('/'); await expect(page.getByTestId('current-city-marker')).toHaveCount(0); + const mainMap = page.locator('[data-main-target="map"]'); + const currentMainCity = mainMap.locator('.city-base.mine'); + await expect(currentMainCity).toHaveAttribute('aria-label', '업, 현재 도시'); + const currentCityHighlight = await currentMainCity.locator('.city-filler.my-city').evaluate((element) => { + const style = getComputedStyle(element); + return { + outlineColor: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + animationName: style.animationName, + boxShadow: style.boxShadow, + }; + }); + expect(currentCityHighlight).toMatchObject({ + outlineColor: 'rgb(211, 47, 47)', + outlineStyle: 'solid', + outlineWidth: '2px', + animationName: 'none', + }); + expect(currentCityHighlight.boxShadow).toContain('211, 47, 47'); + await mainMap.screenshot({ path: test.info().outputPath('main-map-current-city-static-highlight-desktop.png') }); + await mainMap.locator('.city-base').nth(1).click(); + await expect(page).toHaveURL(/\/current-city\?cityId=2$/u); + await page.goBack(); + await expect(page).toHaveURL(/\/$/u); + await expect(page.locator('[data-main-target="map"] .city-base.selected')).toHaveCount(0); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); @@ -1464,6 +1490,12 @@ test('enters general and nation command arguments and sends exact values', async await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업'); await expect(mapCities.nth(0)).toHaveClass(/mine/); await expect(mapCities.nth(1)).toHaveClass(/selected/); + expect( + await mapCities + .nth(1) + .locator('.city-icon') + .evaluate((element) => getComputedStyle(element).boxShadow) + ).toContain('255, 235, 150'); await mapCities.nth(1).hover(); expect(await mapCities.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); await mapCities.nth(1).focus(); @@ -1480,7 +1512,7 @@ test('enters general and nation command arguments and sends exact values', async '【허창】에 화계실행' ); - await page.goto('/che/chief-center'); + await page.goto(gamePath('/chief-center')); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); const chiefPicker = page.getByTestId('command-picker'); await chiefPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); @@ -1836,6 +1868,66 @@ test('uses the map to choose a nation target in the chief command window', async await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true }); }); +test('touch command maps select city and nation on the first tap without changing navigation mode', async ({ + browser, +}, testInfo) => { + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile command map contract'); + } + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: 390, height: 844 }, + screen: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + + try { + await install(mobilePage); + await mobilePage.addInitScript(() => localStorage.setItem('sam.toggleSingleTap', 'no')); + await mobilePage.goto('/'); + + const mainCurrentCity = mobilePage.locator('[data-main-target="map"] .city-base.mine'); + await expect(mainCurrentCity).toHaveAttribute('aria-label', '업, 현재 도시'); + await expect(mobilePage.locator('[data-main-target="map"] .city-base.selected')).toHaveCount(0); + await mobilePage.locator('[data-main-target="map"]').screenshot({ + path: testInfo.outputPath('main-map-current-city-static-highlight-mobile.png'), + }); + + await mobilePage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + await mobilePage.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); + let form = mobilePage.getByTestId('command-argument-form'); + let commandMap = form.getByTestId('command-argument-map'); + await expect(commandMap.locator('.map-toggle-single-tap')).toHaveCount(0); + await commandMap.locator('.city-base').nth(1).tap(); + await expect(form.locator('#command-arg-destCityId')).toHaveValue('2'); + await expect(commandMap.locator('.city-base').nth(1)).toHaveClass(/selected/); + await expect(mobilePage).toHaveURL(/\/$/u); + + await mobilePage.goto(gamePath('/chief-center')); + await mobilePage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = mobilePage.getByTestId('command-picker'); + await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click(); + await picker.getByRole('button', { name: /선전포고/ }).click(); + form = picker.getByTestId('command-argument-form'); + commandMap = form.getByTestId('command-argument-map'); + await expect(commandMap.locator('.map-toggle-single-tap')).toHaveCount(0); + await commandMap.locator('.city-base').nth(1).tap(); + await expect(form.locator('#command-arg-destNationId')).toHaveValue('2'); + await expect(commandMap.locator('.city-base').nth(1)).toHaveClass(/selected/); + await expect(mobilePage).toHaveURL(new RegExp(`${gamePath('/chief-center')}$`, 'u')); + expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no'); + + await mobilePage.screenshot({ path: testInfo.outputPath('command-map-first-tap-selection-mobile.png') }); + } finally { + await context.close(); + } +}); + test('shows a map and target details for every city or nation argument chief command except assignment', async ({ page, }) => { diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 6702bb7b..9ef6d6c5 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -481,6 +481,13 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업'); await desktopCity.click(); await expect(page).toHaveURL(/\/current-city\?cityId=1$/u); + await page.goBack(); + await expect(page).toHaveURL(/\/global-info$/u); + const retainedSelection = page.locator('.city-base').first(); + await expect(retainedSelection).toHaveClass(/selected/); + expect( + await retainedSelection.locator('.city-icon').evaluate((element) => getComputedStyle(element).boxShadow) + ).toContain('255, 235, 150'); const configuredBaseUrl = testInfo.project.use.baseURL; if (typeof configuredBaseUrl !== 'string') { diff --git a/app/game-frontend/src/components/main/MapCityBasic.vue b/app/game-frontend/src/components/main/MapCityBasic.vue index 73122b41..cc606f5a 100644 --- a/app/game-frontend/src/components/main/MapCityBasic.vue +++ b/app/game-frontend/src/components/main/MapCityBasic.vue @@ -68,6 +68,7 @@ const touchend = (event: TouchEvent) => { :to=" props.selectOnly || props.readonly ? undefined : { name: 'current-city', query: { cityId: props.city.id } } " + :aria-label="props.city.isMyCity ? `${props.city.name}, 현재 도시` : props.city.name" :class="[ `state-${props.city.stateClass}`, { @@ -125,6 +126,7 @@ const touchend = (event: TouchEvent) => { } .city-dot { + position: relative; border: 1px solid rgba(232, 221, 196, 0.6); display: flex; align-items: center; @@ -138,8 +140,16 @@ const touchend = (event: TouchEvent) => { background: rgba(232, 221, 196, 0.9); } -.map-city.mine .city-dot { - box-shadow: 0 0 0 2px rgba(201, 164, 90, 0.6); +.map-city.mine .city-dot::after { + position: absolute; + inset: -4px; + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0.95); + border-radius: 2px; + outline: 2px solid rgb(211, 47, 47); + box-shadow: 0 0 6px 2px rgba(211, 47, 47, 0.72); + content: ''; + pointer-events: none; } .map-city.selected .city-dot { diff --git a/app/game-frontend/src/components/main/MapCityDetail.vue b/app/game-frontend/src/components/main/MapCityDetail.vue index 0df6a85c..e3d8f407 100644 --- a/app/game-frontend/src/components/main/MapCityDetail.vue +++ b/app/game-frontend/src/components/main/MapCityDetail.vue @@ -184,6 +184,7 @@ const cityStateStyle = computed(() => ({ :to=" props.selectOnly || props.readonly ? undefined : { name: 'current-city', query: { cityId: props.city.id } } " + :aria-label="props.city.isMyCity ? `${props.city.name}, 현재 도시` : props.city.name" :class="[ { mine: props.city.isMyCity, @@ -256,11 +257,16 @@ const cityStateStyle = computed(() => ({ .city-filler { position: absolute; inset: -2px; + box-sizing: border-box; pointer-events: none; } -.city-base.mine .city-icon { - box-shadow: 0 0 0 1px rgba(201, 164, 90, 0.7); +.city-filler.my-city { + inset: -4px; + border: 1px solid rgba(255, 255, 255, 0.95); + border-radius: 2px; + outline: 2px solid rgb(211, 47, 47); + box-shadow: 0 0 6px 2px rgba(211, 47, 47, 0.72); } .city-base.selected .city-icon { diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index 4761af49..c9c01be9 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -72,12 +72,14 @@ const props = withDefaults( detailMode?: boolean; fitContainer?: boolean; showCurrentCityMarker?: boolean; + showSelectionBorder?: boolean; readonly?: boolean; }>(), { // Vue casts an absent Boolean prop to false unless undefined is an explicit default. detailMode: undefined, selectedCityId: undefined, + showSelectionBorder: true, } ); @@ -176,6 +178,7 @@ const effectiveDetailMode = computed(() => props.detailMode ?? storeDetailMode.v const effectiveSelectedCityId = computed(() => props.selectedCityId === undefined ? storeSelectedCityId.value : props.selectedCityId ); +const isSelectionMap = computed(() => props.selectedCityId !== undefined); const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`); @@ -213,7 +216,7 @@ const cityViews = computed(() => { y, isCapital: nation?.capitalCityId === layoutCity.id, isMyCity: props.mapData?.myCity === layoutCity.id, - selected: effectiveSelectedCityId.value === layoutCity.id, + selected: props.showSelectionBorder && effectiveSelectedCityId.value === layoutCity.id, }; }); }); @@ -417,7 +420,7 @@ const touchCity = (cityId: number, event: TouchEvent) => { if (touchPreviewCityId.value !== cityId) { touchPreviewCityId.value = cityId; setHoveredCity(cityId); - if (!singleTapNavigation.value) { + if (!isSelectionMap.value && !singleTapNavigation.value) { event.preventDefault(); } } @@ -469,7 +472,7 @@ const selectCity = (cityId: number) => { :city="city" :map-scale="mapScale" :show-name="showCityName" - :select-only="props.selectedCityId !== undefined" + :select-only="isSelectionMap" :readonly="props.readonly" v-bind="detailProps" @hover="setHoveredCity" @@ -497,7 +500,7 @@ const selectCity = (cityId: number) => { 도시명 표기 {{ showCityName ? '끄기' : '켜기' }}