From f1e7f18d1e14281287be81ee1532badf136e2440 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 16:39:29 +0000 Subject: [PATCH] =?UTF-8?q?feat(game-ui):=20=EC=A7=80=EB=8F=84=20=EA=B3=84?= =?UTF-8?q?=EC=A0=88=20=EB=B0=B0=EA=B2=BD=EC=9D=84=20=EB=B6=80=EB=93=9C?= =?UTF-8?q?=EB=9F=BD=EA=B2=8C=20=EC=A0=84=ED=99=98=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계절 배경을 미리 decode하고 두 이미지 레이어로 480ms 전환한다. 계절이 없는 지도와 reduced-motion은 불필요한 애니메이션 없이 처리한다. --- app/game-frontend/e2e/mainNavigation.spec.ts | 295 +++++++++++++++++- .../src/components/main/MapViewer.vue | 294 ++++++++++++++--- app/game-frontend/src/utils/mapBackground.ts | 35 +++ app/game-frontend/test/mapBackground.test.ts | 53 ++++ 4 files changed, 636 insertions(+), 41 deletions(-) create mode 100644 app/game-frontend/src/utils/mapBackground.ts create mode 100644 app/game-frontend/test/mapBackground.test.ts diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 2f5f31ea..014575a8 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -15,6 +15,7 @@ const errorResponse = (path: string, message: string) => ({ }); const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR; const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR; +const mapSeasonArtifactRoot = process.env.MAP_SEASON_ARTIFACT_DIR; const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; @@ -52,6 +53,10 @@ type NavigationFixture = { refCommandCategories?: boolean; currentYear?: number; currentMonth?: number; + mapName?: string; + validMapImages?: boolean; + mapImageGate?: Promise; + imageRequests?: string[]; serverId?: string; profile?: string; gameIdx?: number; @@ -143,6 +148,13 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType + expect + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) + .toBe(true); + const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({ category, values: [ @@ -503,9 +515,29 @@ const installFixture = async (page: Page, state: NavigationFixture) => { { profile: gameProfile } ); - await page.route('**/image/**', async (route) => { - await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }); + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname.includes('/image/') || url.hostname === 'sam-image.hided.net') { + (state.imageRequests ??= []).push(url.pathname); + } }); + + const handleImageRoute = async (route: Route) => { + await state.mapImageGate; + if (state.validMapImages) { + const transparentPixel = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEAQH/69aQ6wAAAABJRU5ErkJggg==', + 'base64' + ); + await route.fulfill({ status: 200, contentType: 'image/png', body: transparentPixel }); + return; + } + await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }); + }; + if (process.env.SAMMO_E2E_REAL_MAP_ASSETS !== '1') { + await page.route('**/image/**', handleImageRoute); + await page.route('https://sam-image.hided.net/game/**', handleImageRoute); + } await page.route('**/events**', async (route) => { await route.abort(); }); @@ -657,7 +689,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { } if (operation === 'world.getMapLayout') { return response({ - mapName: 'che', + mapName: state.mapName ?? 'che', cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 200, y: 120, path: [] }], regionMap: { 1: '하북' }, levelMap: { 8: '특' }, @@ -4182,6 +4214,263 @@ test('global activity, world history, and a month boundary refresh their visible ); }); +test('seasonal map decodes the next background and crossfades it without remounting map content', async ({ page }) => { + const useRealAssets = process.env.SAMMO_E2E_REAL_MAP_ASSETS === '1'; + let releaseInitialImages: () => void = () => undefined; + const initialImageGate = new Promise((resolveGate) => { + releaseInitialImages = resolveGate; + }); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + currentMonth: 3, + validMapImages: true, + mapImageGate: useRealAssets ? undefined : initialImageGate, + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + await waitForMainRealtime(page); + + const map = page.locator('[data-main-target="map"] .map-viewer').first(); + if (!useRealAssets) { + await expect(map.locator('.skeleton-line')).toHaveCount(4); + await expect(map.locator('.map-area')).toHaveCount(0); + releaseInitialImages(); + state.mapImageGate = undefined; + } + + const currentLayer = map.locator('[data-map-background-layer="current"]'); + const outgoingLayer = map.locator('[data-map-background-layer="outgoing"]'); + await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u); + await expect(map.locator('.map-area')).toBeVisible(); + await expect + .poll(() => state.imageRequests?.some((url) => url.endsWith('/game/map/che/bg_summer.jpg')) ?? false) + .toBe(true); + + const initialGeometry = await map.locator('.map-area').evaluate((area) => { + const rect = area.getBoundingClientRect(); + const road = area.querySelector('.map-bgroad'); + const city = area.querySelector('.city-base'); + Object.defineProperty(window, '__mapSeasonTransitionProbe', { + configurable: true, + value: { area, road, city }, + }); + return { width: rect.width, height: rect.height }; + }); + expect(initialGeometry).toEqual({ width: 700, height: 500 }); + + if (mapSeasonArtifactRoot) { + await mkdir(mapSeasonArtifactRoot, { recursive: true }); + await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-spring-initial.png') }); + } + + state.currentMonth = 4; + await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true })); + await expect(map).toContainText('185年 4月'); + await expect(outgoingLayer).toHaveClass(/is-transitioning/u); + await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_summer\.jpg/u); + await expect(outgoingLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u); + await page.waitForTimeout(180); + + const midpointOpacity = Number.parseFloat( + await outgoingLayer.evaluate((element) => getComputedStyle(element).opacity) + ); + expect(midpointOpacity).toBeGreaterThan(0); + expect(midpointOpacity).toBeLessThan(1); + if (mapSeasonArtifactRoot) { + await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-spring-to-summer-midpoint.png') }); + } + + await expect(outgoingLayer).not.toHaveClass(/is-transitioning/u); + await expect(outgoingLayer.locator('img')).toHaveCount(0); + const finalState = await map.locator('.map-area').evaluate((area) => { + const probe = ( + window as unknown as { + __mapSeasonTransitionProbe: { area: Element; road: Element | null; city: Element | null }; + } + ).__mapSeasonTransitionProbe; + const rect = area.getBoundingClientRect(); + return { + areaMounted: probe.area === area, + roadMounted: probe.road === area.querySelector('.map-bgroad'), + cityMounted: probe.city === area.querySelector('.city-base'), + width: rect.width, + height: rect.height, + }; + }); + expect(finalState).toEqual({ + areaMounted: true, + roadMounted: true, + cityMounted: true, + width: 700, + height: 500, + }); + if (mapSeasonArtifactRoot) { + await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-summer-complete.png') }); + } + + await page.setViewportSize({ width: 500, height: 900 }); + await expect + .poll(() => + map.locator('.map-area').evaluate((area) => { + const rect = area.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }) + ) + .toEqual({ width: 500, height: 357.140625 }); + + state.currentMonth = 7; + await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true })); + await expect(map).toContainText('185年 7月'); + await expect(outgoingLayer).toHaveClass(/is-transitioning/u); + await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_fall\.jpg/u); + await expect(outgoingLayer.locator('img')).toHaveAttribute('src', /bg_summer\.jpg/u); + await expect(outgoingLayer).not.toHaveClass(/is-transitioning/u); + await expect(outgoingLayer.locator('img')).toHaveCount(0); + if (mapSeasonArtifactRoot) { + await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-fall-mobile-complete.png') }); + } +}); + +test('reduced-motion map swaps the decoded seasonal background without a fade', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + currentMonth: 3, + validMapImages: true, + }; + await page.emulateMedia({ reducedMotion: 'reduce' }); + await installRealtimeHarness(page); + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + await waitForMainRealtime(page); + + const map = page.locator('[data-main-target="map"] .map-viewer').first(); + const currentLayer = map.locator('[data-map-background-layer="current"]'); + const outgoingLayer = map.locator('[data-map-background-layer="outgoing"]'); + await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u); + await outgoingLayer.evaluate((outgoing) => { + let transitionCount = 0; + const observer = new MutationObserver(() => { + if (outgoing.classList.contains('is-transitioning')) transitionCount += 1; + }); + observer.observe(outgoing, { attributes: true, attributeFilter: ['class'] }); + Object.defineProperty(window, '__reducedMotionMapProbe', { + configurable: true, + value: { + observer, + get transitionCount() { + return transitionCount; + }, + }, + }); + }); + + state.currentMonth = 4; + await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true })); + await expect(map).toContainText('185年 4月'); + await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_summer\.jpg/u); + await page.waitForTimeout(100); + await expect(outgoingLayer).not.toHaveClass(/is-transitioning/u); + await expect(outgoingLayer.locator('img')).toHaveCount(0); + expect( + await outgoingLayer.evaluate(() => { + const probe = ( + window as unknown as { + __reducedMotionMapProbe: { observer: MutationObserver; transitionCount: number }; + } + ).__reducedMotionMapProbe; + probe.observer.disconnect(); + return probe.transitionCount; + }) + ).toBe(0); +}); + +test('seasonless map keeps its fixed background and does not start a month-boundary crossfade', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + currentMonth: 3, + mapName: 'ludo_rathowm', + validMapImages: true, + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + await waitForMainRealtime(page); + + const map = page.locator('[data-main-target="map"] .map-viewer').first(); + const currentLayer = map.locator('[data-map-background-layer="current"]'); + const outgoingLayer = map.locator('[data-map-background-layer="outgoing"]'); + await expect(currentLayer.locator('img')).toHaveAttribute('src', /map\/ludo_rathowm\/back\.jpg/u); + await map.locator('.map-area').evaluate((area) => { + const outgoing = area.querySelector('[data-map-background-layer="outgoing"]'); + let transitionCount = 0; + const observer = new MutationObserver(() => { + if (outgoing?.classList.contains('is-transitioning')) transitionCount += 1; + }); + if (outgoing) observer.observe(outgoing, { attributes: true, attributeFilter: ['class'] }); + Object.defineProperty(window, '__seasonlessMapProbe', { + configurable: true, + value: { + area, + outgoing, + observer, + get transitionCount() { + return transitionCount; + }, + }, + }); + }); + + state.currentMonth = 4; + await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true })); + await expect(map).toContainText('185年 4月'); + await page.waitForTimeout(650); + + await expect(currentLayer.locator('img')).toHaveAttribute('src', /map\/ludo_rathowm\/back\.jpg/u); + await expect(outgoingLayer).not.toHaveClass(/is-transitioning/u); + await expect(outgoingLayer.locator('img')).toHaveCount(0); + expect(state.imageRequests?.some((url) => url.includes('/game/map/che/bg_summer.jpg')) ?? false).toBe(false); + expect( + await map.locator('.map-area').evaluate((area) => { + const probe = ( + window as unknown as { + __seasonlessMapProbe: { + area: Element; + observer: MutationObserver; + transitionCount: number; + }; + } + ).__seasonlessMapProbe; + probe.observer.disconnect(); + return { areaMounted: probe.area === area, transitionCount: probe.transitionCount }; + }) + ).toEqual({ areaMounted: true, transitionCount: 0 }); + if (mapSeasonArtifactRoot) { + await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'seasonless-ludo-complete.png') }); + } +}); + test('same-account main tabs share one realtime diff and exclude a tab while sync is off', async ({ context, page, diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index c9c01be9..b6f314de 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -1,5 +1,5 @@