feat(game-ui): 지도 계절 배경을 부드럽게 전환한다
계절 배경을 미리 decode하고 두 이미지 레이어로 480ms 전환한다. 계절이 없는 지도와 reduced-motion은 불필요한 애니메이션 없이 처리한다.
This commit is contained in:
@@ -15,6 +15,7 @@ const errorResponse = (path: string, message: string) => ({
|
|||||||
});
|
});
|
||||||
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
||||||
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_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 productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
||||||
@@ -52,6 +53,10 @@ type NavigationFixture = {
|
|||||||
refCommandCategories?: boolean;
|
refCommandCategories?: boolean;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
|
mapName?: string;
|
||||||
|
validMapImages?: boolean;
|
||||||
|
mapImageGate?: Promise<void>;
|
||||||
|
imageRequests?: string[];
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
gameIdx?: number;
|
gameIdx?: number;
|
||||||
@@ -143,6 +148,13 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof r
|
|||||||
);
|
);
|
||||||
}, invalidation);
|
}, invalidation);
|
||||||
|
|
||||||
|
const waitForMainRealtime = (page: Page) =>
|
||||||
|
expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({
|
const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({
|
||||||
category,
|
category,
|
||||||
values: [
|
values: [
|
||||||
@@ -503,9 +515,29 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
{ profile: gameProfile }
|
{ profile: gameProfile }
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.route('**/image/**', async (route) => {
|
page.on('request', (request) => {
|
||||||
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
|
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 page.route('**/events**', async (route) => {
|
||||||
await route.abort();
|
await route.abort();
|
||||||
});
|
});
|
||||||
@@ -657,7 +689,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'world.getMapLayout') {
|
if (operation === 'world.getMapLayout') {
|
||||||
return response({
|
return response({
|
||||||
mapName: 'che',
|
mapName: state.mapName ?? 'che',
|
||||||
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 200, y: 120, path: [] }],
|
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 200, y: 120, path: [] }],
|
||||||
regionMap: { 1: '하북' },
|
regionMap: { 1: '하북' },
|
||||||
levelMap: { 8: '특' },
|
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<void>((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 ({
|
test('same-account main tabs share one realtime diff and exclude a tab while sync is off', async ({
|
||||||
context,
|
context,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
|
import { useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
@@ -7,6 +7,7 @@ import MapCityBasic from './MapCityBasic.vue';
|
|||||||
import MapCityDetail from './MapCityDetail.vue';
|
import MapCityDetail from './MapCityDetail.vue';
|
||||||
import { useMapViewerStore } from '../../stores/mapViewer';
|
import { useMapViewerStore } from '../../stores/mapViewer';
|
||||||
import { buildAssetUrl } from '../../utils/mapAssets';
|
import { buildAssetUrl } from '../../utils/mapAssets';
|
||||||
|
import { resolveMapBackgroundPath, resolveMapSeason, resolveNextMapSeason } from '../../utils/mapBackground';
|
||||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||||
|
|
||||||
interface MapSummary {
|
interface MapSummary {
|
||||||
@@ -90,6 +91,45 @@ const emit = defineEmits<{
|
|||||||
const BASE_MAP_WIDTH = 700;
|
const BASE_MAP_WIDTH = 700;
|
||||||
const BASE_MAP_HEIGHT = 500;
|
const BASE_MAP_HEIGHT = 500;
|
||||||
const SMALL_MAP_SCALE = 5 / 7;
|
const SMALL_MAP_SCALE = 5 / 7;
|
||||||
|
const MAP_BACKGROUND_TRANSITION_MS = 480;
|
||||||
|
|
||||||
|
const decodedImageCache = new Map<string, Promise<void>>();
|
||||||
|
const decodedImageElements = new Map<string, HTMLImageElement>();
|
||||||
|
|
||||||
|
const preloadDecodedImage = (url: string): Promise<void> => {
|
||||||
|
const cached = decodedImageCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const pending = new Promise<void>((resolve, reject) => {
|
||||||
|
if (typeof Image === 'undefined') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.onload = () => {
|
||||||
|
if (typeof image.decode !== 'function') {
|
||||||
|
decodedImageElements.set(url, image);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void image.decode().then(() => {
|
||||||
|
decodedImageElements.set(url, image);
|
||||||
|
resolve();
|
||||||
|
}, reject);
|
||||||
|
};
|
||||||
|
image.onerror = () => reject(new Error(`map background image load failed: ${url}`));
|
||||||
|
image.src = url;
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
decodedImageCache.delete(url);
|
||||||
|
decodedImageElements.delete(url);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
decodedImageCache.set(url, pending);
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
const isWide = useMediaQuery('(min-width: 1024px)');
|
const isWide = useMediaQuery('(min-width: 1024px)');
|
||||||
const mapStore = useMapViewerStore();
|
const mapStore = useMapViewerStore();
|
||||||
@@ -101,25 +141,13 @@ const {
|
|||||||
selectedCityId: storeSelectedCityId,
|
selectedCityId: storeSelectedCityId,
|
||||||
} = storeToRefs(mapStore);
|
} = storeToRefs(mapStore);
|
||||||
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
|
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
|
||||||
|
const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||||
|
|
||||||
const mapArea = ref<HTMLElement | null>(null);
|
const mapArea = ref<HTMLElement | null>(null);
|
||||||
const mapBody = ref<HTMLElement | null>(null);
|
const mapBody = ref<HTMLElement | null>(null);
|
||||||
const { width: mapBodyWidth } = useElementSize(mapBody);
|
const { width: mapBodyWidth } = useElementSize(mapBody);
|
||||||
const { elementX, elementY } = useMouseInElement(mapArea);
|
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 => {
|
const resolveStateClass = (state: number): CityStateClass => {
|
||||||
if (state < 10) {
|
if (state < 10) {
|
||||||
return 'good';
|
return 'good';
|
||||||
@@ -239,7 +267,7 @@ const mapSeason = computed(() => {
|
|||||||
if (!props.mapData) {
|
if (!props.mapData) {
|
||||||
return 'spring';
|
return 'spring';
|
||||||
}
|
}
|
||||||
return resolveSeason(props.mapData.month);
|
return resolveMapSeason(props.mapData.month);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapTheme = computed(() => props.mapLayout?.mapName ?? 'che');
|
const mapTheme = computed(() => props.mapLayout?.mapName ?? 'che');
|
||||||
@@ -327,24 +355,14 @@ const mapSeasonClass = computed(() => {
|
|||||||
return `map-season-${mapSeason.value}`;
|
return `map-season-${mapSeason.value}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapBackgroundImage = computed(() => {
|
const mapBackground = computed(() => resolveMapBackgroundPath(mapTheme.value, mapSeason.value));
|
||||||
const theme = mapTheme.value;
|
|
||||||
const season = mapSeason.value;
|
|
||||||
|
|
||||||
if (theme === 'ludo_rathowm') {
|
const mapBackgroundImage = computed(() => resolveAsset(mapBackground.value.path));
|
||||||
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 nextSeasonBackgroundImage = computed(() => {
|
||||||
|
if (!mapBackground.value.seasonal) return null;
|
||||||
|
const nextSeason = resolveNextMapSeason(mapSeason.value);
|
||||||
|
return resolveAsset(resolveMapBackgroundPath(mapTheme.value, nextSeason).path);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapRoadImage = computed(() => {
|
const mapRoadImage = computed(() => {
|
||||||
@@ -361,16 +379,165 @@ const mapRoadImage = computed(() => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapBackgroundStyle = computed(() => ({
|
const renderedBackgroundImage = ref<string | null>(null);
|
||||||
backgroundImage: mapBackgroundImage.value ? `url('${mapBackgroundImage.value}')` : 'none',
|
const outgoingBackgroundImage = ref<string | null>(null);
|
||||||
backgroundSize: '100% 100%',
|
const renderedBackgroundElement = ref<HTMLImageElement | null>(null);
|
||||||
}));
|
const backgroundReady = ref(false);
|
||||||
|
const outgoingBackgroundVisible = ref(false);
|
||||||
|
const backgroundTransitioning = ref(false);
|
||||||
|
|
||||||
const mapRoadStyle = computed(() => ({
|
const mapRoadStyle = computed(() => ({
|
||||||
backgroundImage: mapRoadImage.value ? `url('${mapRoadImage.value}')` : 'none',
|
backgroundImage: mapRoadImage.value ? `url('${mapRoadImage.value}')` : 'none',
|
||||||
backgroundSize: '100% 100%',
|
backgroundSize: '100% 100%',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type BackgroundRequest = {
|
||||||
|
imageUrl: string;
|
||||||
|
roadUrl: string | null;
|
||||||
|
nextSeasonUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundRequest = computed<BackgroundRequest | null>(() => {
|
||||||
|
if (!props.mapData || !props.mapLayout) return null;
|
||||||
|
return {
|
||||||
|
imageUrl: mapBackgroundImage.value,
|
||||||
|
roadUrl: mapRoadImage.value,
|
||||||
|
nextSeasonUrl: nextSeasonBackgroundImage.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const backgroundRequestKey = computed(() => {
|
||||||
|
const request = backgroundRequest.value;
|
||||||
|
return request ? `${request.imageUrl}\u0000${request.roadUrl ?? ''}` : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
let pendingBackgroundRequest: BackgroundRequest | null = null;
|
||||||
|
let backgroundWorkerRunning = false;
|
||||||
|
let backgroundWorkerDisposed = false;
|
||||||
|
|
||||||
|
const waitForPaint = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timeoutId = window.setTimeout(done, 80);
|
||||||
|
window.requestAnimationFrame(() => window.requestAnimationFrame(done));
|
||||||
|
});
|
||||||
|
|
||||||
|
const waitForBackgroundTransition = () =>
|
||||||
|
new Promise<void>((resolve) => window.setTimeout(resolve, MAP_BACKGROUND_TRANSITION_MS));
|
||||||
|
|
||||||
|
const prefetchUpcomingImages = (request: BackgroundRequest) => {
|
||||||
|
if (request.nextSeasonUrl) {
|
||||||
|
void preloadDecodedImage(request.nextSeasonUrl).catch(() => undefined);
|
||||||
|
}
|
||||||
|
const nextSeasonIcon = resolveAsset(`${resolveNextMapSeason(mapSeason.value)}.gif`);
|
||||||
|
void preloadDecodedImage(nextSeasonIcon).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showBackground = async (request: BackgroundRequest) => {
|
||||||
|
const roadPromise = request.roadUrl
|
||||||
|
? preloadDecodedImage(request.roadUrl).catch(() => undefined)
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await preloadDecodedImage(request.imageUrl);
|
||||||
|
await roadPromise;
|
||||||
|
} catch {
|
||||||
|
if (!renderedBackgroundImage.value) {
|
||||||
|
// Preserve the old initial-load fallback: reveal the map and let the
|
||||||
|
// actual img make its normal request instead of leaving a skeleton.
|
||||||
|
renderedBackgroundImage.value = request.imageUrl;
|
||||||
|
backgroundReady.value = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backgroundWorkerDisposed) return;
|
||||||
|
if (pendingBackgroundRequest && pendingBackgroundRequest.imageUrl !== request.imageUrl) return;
|
||||||
|
|
||||||
|
const currentImage = renderedBackgroundImage.value;
|
||||||
|
if (!currentImage) {
|
||||||
|
renderedBackgroundImage.value = request.imageUrl;
|
||||||
|
backgroundReady.value = true;
|
||||||
|
prefetchUpcomingImages(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentImage === request.imageUrl) {
|
||||||
|
backgroundReady.value = true;
|
||||||
|
prefetchUpcomingImages(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduceMotion.value) {
|
||||||
|
renderedBackgroundImage.value = request.imageUrl;
|
||||||
|
outgoingBackgroundImage.value = null;
|
||||||
|
outgoingBackgroundVisible.value = false;
|
||||||
|
backgroundTransitioning.value = false;
|
||||||
|
prefetchUpcomingImages(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the old decoded image fully covering the new background for one
|
||||||
|
// paint, then fade only that outgoing layer. Road/city/control DOM remains.
|
||||||
|
outgoingBackgroundImage.value = currentImage;
|
||||||
|
outgoingBackgroundVisible.value = true;
|
||||||
|
backgroundTransitioning.value = false;
|
||||||
|
renderedBackgroundImage.value = request.imageUrl;
|
||||||
|
await nextTick();
|
||||||
|
try {
|
||||||
|
await renderedBackgroundElement.value?.decode();
|
||||||
|
} catch {
|
||||||
|
renderedBackgroundImage.value = currentImage;
|
||||||
|
outgoingBackgroundImage.value = null;
|
||||||
|
outgoingBackgroundVisible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await waitForPaint();
|
||||||
|
if (backgroundWorkerDisposed) return;
|
||||||
|
|
||||||
|
backgroundTransitioning.value = true;
|
||||||
|
outgoingBackgroundVisible.value = false;
|
||||||
|
await waitForBackgroundTransition();
|
||||||
|
if (backgroundWorkerDisposed) return;
|
||||||
|
|
||||||
|
outgoingBackgroundImage.value = null;
|
||||||
|
backgroundTransitioning.value = false;
|
||||||
|
prefetchUpcomingImages(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runBackgroundWorker = async () => {
|
||||||
|
if (backgroundWorkerRunning) return;
|
||||||
|
backgroundWorkerRunning = true;
|
||||||
|
try {
|
||||||
|
while (pendingBackgroundRequest && !backgroundWorkerDisposed) {
|
||||||
|
const request = pendingBackgroundRequest;
|
||||||
|
pendingBackgroundRequest = null;
|
||||||
|
await showBackground(request);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backgroundWorkerRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
backgroundRequestKey,
|
||||||
|
() => {
|
||||||
|
pendingBackgroundRequest = backgroundRequest.value;
|
||||||
|
void runBackgroundWorker();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
backgroundWorkerDisposed = true;
|
||||||
|
pendingBackgroundRequest = null;
|
||||||
|
});
|
||||||
|
|
||||||
const detailProps = computed(() =>
|
const detailProps = computed(() =>
|
||||||
effectiveDetailMode.value
|
effectiveDetailMode.value
|
||||||
? {
|
? {
|
||||||
@@ -450,7 +617,7 @@ const selectCity = (cityId: number) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.loading">
|
<div v-if="props.loading || (props.mapData && props.mapLayout && !backgroundReady)">
|
||||||
<SkeletonLines :lines="4" />
|
<SkeletonLines :lines="4" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">지도 데이터를 불러오지 못했습니다.</div>
|
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">지도 데이터를 불러오지 못했습니다.</div>
|
||||||
@@ -462,8 +629,32 @@ const selectCity = (cityId: number) => {
|
|||||||
:style="{ width: mapWidth, height: mapHeight }"
|
:style="{ width: mapWidth, height: mapHeight }"
|
||||||
@click="clearTouchPreview"
|
@click="clearTouchPreview"
|
||||||
>
|
>
|
||||||
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
|
<div class="map-layer map-bglayer1" data-map-background-layer="current">
|
||||||
<div class="map-layer map-bglayer2" />
|
<img
|
||||||
|
v-if="renderedBackgroundImage"
|
||||||
|
ref="renderedBackgroundElement"
|
||||||
|
class="map-background-image"
|
||||||
|
:src="renderedBackgroundImage"
|
||||||
|
alt=""
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="map-layer map-bglayer2"
|
||||||
|
data-map-background-layer="outgoing"
|
||||||
|
:class="{
|
||||||
|
'is-visible': outgoingBackgroundVisible,
|
||||||
|
'is-transitioning': backgroundTransitioning,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="outgoingBackgroundImage"
|
||||||
|
class="map-background-image"
|
||||||
|
:src="outgoingBackgroundImage"
|
||||||
|
alt=""
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
|
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
|
||||||
<component
|
<component
|
||||||
:is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
|
:is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
|
||||||
@@ -637,6 +828,27 @@ const selectCity = (cityId: number) => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-bglayer2 {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-bglayer2.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-bglayer2.is-transitioning {
|
||||||
|
transition: opacity 480ms ease-in-out;
|
||||||
|
will-change: opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-background-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: fill;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.map-tooltip {
|
.map-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 16;
|
z-index: 16;
|
||||||
@@ -698,4 +910,10 @@ const selectCity = (cityId: number) => {
|
|||||||
.map-empty {
|
.map-empty {
|
||||||
color: rgba(232, 221, 196, 0.6);
|
color: rgba(232, 221, 196, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.map-bglayer2.is-transitioning {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type MapSeason = 'spring' | 'summer' | 'fall' | 'winter';
|
||||||
|
|
||||||
|
const FIXED_BACKGROUND_PATHS: Readonly<Record<string, string>> = {
|
||||||
|
ludo_rathowm: 'map/ludo_rathowm/back.jpg',
|
||||||
|
chess: 'map/chess/chessboard.png',
|
||||||
|
pokemon_v1: 'map/pokemon_v1/back_pal8.png',
|
||||||
|
cr: 'map/cr/bg-fs8.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NEXT_SEASON: Readonly<Record<MapSeason, MapSeason>> = {
|
||||||
|
spring: 'summer',
|
||||||
|
summer: 'fall',
|
||||||
|
fall: 'winter',
|
||||||
|
winter: 'spring',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveMapSeason = (month: number): MapSeason => {
|
||||||
|
if (month <= 3) return 'spring';
|
||||||
|
if (month <= 6) return 'summer';
|
||||||
|
if (month <= 9) return 'fall';
|
||||||
|
return 'winter';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveNextMapSeason = (season: MapSeason): MapSeason => NEXT_SEASON[season];
|
||||||
|
|
||||||
|
export const resolveMapBackgroundPath = (theme: string, season: MapSeason): { path: string; seasonal: boolean } => {
|
||||||
|
const fixedPath = FIXED_BACKGROUND_PATHS[theme];
|
||||||
|
if (fixedPath) {
|
||||||
|
return { path: fixedPath, seasonal: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref uses the CHE seasonal backgrounds for CHE, mini-CHE variants, and
|
||||||
|
// unknown legacy-compatible themes that do not define their own backdrop.
|
||||||
|
return { path: `map/che/bg_${season}.jpg`, seasonal: true };
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
|
||||||
|
import { resolveMapBackgroundPath, resolveMapSeason, resolveNextMapSeason } from '../src/utils/mapBackground.ts';
|
||||||
|
|
||||||
|
void describe('mapBackground', () => {
|
||||||
|
void it('maps month boundaries to the four Ref seasons', () => {
|
||||||
|
assert.equal(resolveMapSeason(1), 'spring');
|
||||||
|
assert.equal(resolveMapSeason(3), 'spring');
|
||||||
|
assert.equal(resolveMapSeason(4), 'summer');
|
||||||
|
assert.equal(resolveMapSeason(6), 'summer');
|
||||||
|
assert.equal(resolveMapSeason(7), 'fall');
|
||||||
|
assert.equal(resolveMapSeason(9), 'fall');
|
||||||
|
assert.equal(resolveMapSeason(10), 'winter');
|
||||||
|
assert.equal(resolveMapSeason(12), 'winter');
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('uses seasonal CHE backgrounds for CHE and mini-CHE themes', () => {
|
||||||
|
assert.deepEqual(resolveMapBackgroundPath('che', 'summer'), {
|
||||||
|
path: 'map/che/bg_summer.jpg',
|
||||||
|
seasonal: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveMapBackgroundPath('miniche_clean', 'winter'), {
|
||||||
|
path: 'map/che/bg_winter.jpg',
|
||||||
|
seasonal: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('keeps seasonless theme backgrounds fixed across season changes', () => {
|
||||||
|
for (const [theme, expectedPath] of [
|
||||||
|
['ludo_rathowm', 'map/ludo_rathowm/back.jpg'],
|
||||||
|
['chess', 'map/chess/chessboard.png'],
|
||||||
|
['pokemon_v1', 'map/pokemon_v1/back_pal8.png'],
|
||||||
|
['cr', 'map/cr/bg-fs8.png'],
|
||||||
|
] as const) {
|
||||||
|
assert.deepEqual(resolveMapBackgroundPath(theme, 'spring'), {
|
||||||
|
path: expectedPath,
|
||||||
|
seasonal: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveMapBackgroundPath(theme, 'winter'), {
|
||||||
|
path: expectedPath,
|
||||||
|
seasonal: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('cycles the next background season across a year boundary', () => {
|
||||||
|
assert.equal(resolveNextMapSeason('spring'), 'summer');
|
||||||
|
assert.equal(resolveNextMapSeason('summer'), 'fall');
|
||||||
|
assert.equal(resolveNextMapSeason('fall'), 'winter');
|
||||||
|
assert.equal(resolveNextMapSeason('winter'), 'spring');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user