merge: 최신 main을 장수 일람 안정 정렬에 반영한다
This commit is contained in:
@@ -475,6 +475,27 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await go(page, 'global-info');
|
||||
|
||||
const desktopOptionsTrigger = page.getByRole('button', { name: '지도 옵션' });
|
||||
await expect(desktopOptionsTrigger).toBeVisible();
|
||||
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(52, 92, 133)');
|
||||
await desktopOptionsTrigger.hover();
|
||||
await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(40, 73, 105)');
|
||||
await expect(page.getByRole('button', { name: '도시명 표기 끄기' })).not.toBeVisible();
|
||||
await desktopOptionsTrigger.click();
|
||||
const cityNameToggle = page.getByRole('button', { name: '도시명 표기 끄기' });
|
||||
await expect(cityNameToggle).toBeVisible();
|
||||
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.locator('.map-options-menu .map-toggle')).toHaveCount(1);
|
||||
await page.keyboard.press('Tab');
|
||||
await desktopOptionsTrigger.focus();
|
||||
await expect(desktopOptionsTrigger).toHaveCSS('outline-style', 'solid');
|
||||
await page.screenshot({ path: testInfo.outputPath('desktop-map-options-open.png'), fullPage: true });
|
||||
await cityNameToggle.click();
|
||||
await expect(page.locator('.map-area .city-name')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
const desktopCity = page.locator('.city-base').first();
|
||||
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
|
||||
await desktopCity.hover();
|
||||
@@ -508,20 +529,48 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
|
||||
await install(mobilePage);
|
||||
await go(mobilePage, 'global-info');
|
||||
|
||||
const mobileOptionsTrigger = mobilePage.getByRole('button', { name: '지도 옵션' });
|
||||
await expect(mobileOptionsTrigger).toBeVisible();
|
||||
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await mobileOptionsTrigger.tap();
|
||||
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(mobilePage.locator('.map-options-menu .map-toggle')).toHaveCount(2);
|
||||
|
||||
const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' });
|
||||
await expect(twoTapButton).toBeVisible();
|
||||
await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false');
|
||||
const controlGeometry = await twoTapButton.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const controlGeometry = await mobilePage.locator('.map-controls').evaluate((element) => {
|
||||
const triggerRect = element.querySelector('.map-options-trigger')!.getBoundingClientRect();
|
||||
const menuRect = element.querySelector('.map-options-menu')!.getBoundingClientRect();
|
||||
const optionRects = Array.from(element.querySelectorAll('.map-options-menu .map-toggle')).map((option) => {
|
||||
const rect = option.getBoundingClientRect();
|
||||
return { top: rect.top, bottom: rect.bottom };
|
||||
});
|
||||
const mapRect = element.closest('.map-area')?.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const optionStyle = getComputedStyle(element.querySelector('.map-toggle')!);
|
||||
return {
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
mapRight: mapRect?.right,
|
||||
mapBottom: mapRect?.bottom,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
trigger: {
|
||||
right: triggerRect.right,
|
||||
bottom: triggerRect.bottom,
|
||||
top: triggerRect.top,
|
||||
},
|
||||
menu: {
|
||||
left: menuRect.left,
|
||||
right: menuRect.right,
|
||||
top: menuRect.top,
|
||||
bottom: menuRect.bottom,
|
||||
},
|
||||
optionRects,
|
||||
map: mapRect
|
||||
? {
|
||||
left: mapRect.left,
|
||||
right: mapRect.right,
|
||||
top: mapRect.top,
|
||||
bottom: mapRect.bottom,
|
||||
}
|
||||
: null,
|
||||
fontSize: optionStyle.fontSize,
|
||||
lineHeight: optionStyle.lineHeight,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *'))
|
||||
@@ -543,8 +592,20 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
|
||||
overflowing: [],
|
||||
});
|
||||
expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1);
|
||||
expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1);
|
||||
expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1);
|
||||
expect(controlGeometry.map).not.toBeNull();
|
||||
expect(controlGeometry.map!.right - controlGeometry.trigger.right).toBeCloseTo(4, 1);
|
||||
expect(controlGeometry.map!.bottom - controlGeometry.trigger.bottom).toBeCloseTo(4, 1);
|
||||
expect(controlGeometry.menu.left).toBeGreaterThanOrEqual(controlGeometry.map!.left);
|
||||
expect(controlGeometry.menu.right).toBeLessThanOrEqual(controlGeometry.map!.right);
|
||||
expect(controlGeometry.menu.top).toBeGreaterThanOrEqual(controlGeometry.map!.top);
|
||||
expect(controlGeometry.menu.bottom).toBeLessThan(controlGeometry.trigger.top);
|
||||
expect(controlGeometry.optionRects).toHaveLength(2);
|
||||
expect(controlGeometry.optionRects[0]!.bottom).toBeLessThanOrEqual(controlGeometry.optionRects[1]!.top);
|
||||
expect(controlGeometry.optionRects[1]!.bottom).toBeLessThanOrEqual(controlGeometry.menu.bottom);
|
||||
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-options-open.png'), fullPage: true });
|
||||
|
||||
await mobilePage.locator('.map-area').tap({ position: { x: 10, y: 10 } });
|
||||
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
const mobileCities = mobilePage.locator('.city-base');
|
||||
await mobileCities.nth(0).tap();
|
||||
@@ -560,17 +621,19 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
|
||||
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u);
|
||||
|
||||
await go(mobilePage, 'global-info');
|
||||
await mobilePage.getByRole('button', { name: '지도 옵션' }).tap();
|
||||
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click();
|
||||
const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' });
|
||||
await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes');
|
||||
|
||||
await mobilePage.reload();
|
||||
await expect(singleTapButton).toBeVisible();
|
||||
await expect(singleTapButton).not.toBeVisible();
|
||||
await mobilePage.locator('.city-base').nth(2).tap();
|
||||
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u);
|
||||
|
||||
await go(mobilePage, 'global-info');
|
||||
await mobilePage.getByRole('button', { name: '지도 옵션' }).tap();
|
||||
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click();
|
||||
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
|
||||
await mobilePage.locator('.city-base').first().tap();
|
||||
|
||||
@@ -102,6 +102,9 @@ const myGeneral = (state: FixtureState) => ({
|
||||
dedication: 200,
|
||||
age: 30,
|
||||
turnTime: '2026-01-01 00:10:00',
|
||||
recentWar: '2026-01-01 00:00:00',
|
||||
defenceTrain: 80,
|
||||
killTurn: 6,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
crewTypeInfo: state.richMyInfo
|
||||
@@ -277,7 +280,7 @@ const battleCenter = (state: FixtureState) => ({
|
||||
cityId: 1,
|
||||
turnTime: '2026-01-01 00:10:00',
|
||||
recentWar: '2026-01-01 00:00:00',
|
||||
warnum: 3,
|
||||
warnum: 8,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
@@ -288,6 +291,8 @@ const battleCenter = (state: FixtureState) => ({
|
||||
train: 80,
|
||||
atmos: 90,
|
||||
age: 30,
|
||||
defenceTrain: 80,
|
||||
killTurn: 6,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
|
||||
@@ -301,7 +306,8 @@ const battleCenter = (state: FixtureState) => ({
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
},
|
||||
battleStats: { kills: 1, deaths: 2, fire: 0, killCrew: 300, deathCrew: 100, dex: [] },
|
||||
serviceYears: 4,
|
||||
battleStats: { kills: 5, deaths: 3, fire: 12, killCrew: 12_345, deathCrew: 6_789, dex: [] },
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
@@ -323,6 +329,8 @@ const battleCenter = (state: FixtureState) => ({
|
||||
train: 60,
|
||||
atmos: 60,
|
||||
age: 20,
|
||||
defenceTrain: 80,
|
||||
killTurn: 4,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
|
||||
@@ -336,6 +344,7 @@ const battleCenter = (state: FixtureState) => ({
|
||||
statUpgradeLimit: 20,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
},
|
||||
serviceYears: 1,
|
||||
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [] },
|
||||
},
|
||||
],
|
||||
@@ -1213,6 +1222,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('my-page');
|
||||
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
|
||||
await expect(page.locator('.general-table')).toHaveAttribute('data-general-information-panel', '');
|
||||
const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
|
||||
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
|
||||
{ width: 64, height: 64 },
|
||||
@@ -1220,11 +1230,32 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
]);
|
||||
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
|
||||
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('전투 8 · 계략 12 · 사관 4년');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('승률 62.50% · 승리 5 · 패배 3');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('살상률 181.84% · 사살 12,345 · 피살 6,789');
|
||||
await expect(page.locator('.general-table')).toContainText('병종보병');
|
||||
await expect(page.locator('.general-table')).toContainText('삭턴6 턴');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('계략12');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('사살12,345');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('피살6,789');
|
||||
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
|
||||
await expect(page.locator('.legacy-general-details')).toHaveCount(0);
|
||||
await expect(page.locator('.battle-general-extra > span')).toHaveText([
|
||||
'명성',
|
||||
'계급',
|
||||
'전투',
|
||||
'승리',
|
||||
'패배',
|
||||
'계략',
|
||||
'사관',
|
||||
'사살',
|
||||
'피살',
|
||||
'승률',
|
||||
'살상률',
|
||||
'최근 전투',
|
||||
]);
|
||||
await expect(page.locator('.item-group')).toContainText('명마');
|
||||
await expect(page.locator('#container')).not.toContainText('che_');
|
||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||
@@ -2006,6 +2037,26 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
await expect(page.locator('.battle-general-card')).toContainText('병종보병');
|
||||
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
|
||||
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
|
||||
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-information-panel', '');
|
||||
await expect(page.locator('.battle-general-card')).toContainText('삭턴6 턴');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
|
||||
await expect(page.locator('.battle-general-extra > span')).toHaveText([
|
||||
'명성',
|
||||
'계급',
|
||||
'전투',
|
||||
'승리',
|
||||
'패배',
|
||||
'계략',
|
||||
'사관',
|
||||
'사살',
|
||||
'피살',
|
||||
'승률',
|
||||
'살상률',
|
||||
'최근 전투',
|
||||
]);
|
||||
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
|
||||
expect(battleImages).toHaveLength(2);
|
||||
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
|
||||
|
||||
@@ -22,6 +22,7 @@ interface GeneralProgression {
|
||||
dedicationText?: string;
|
||||
statExperience?: { leadership: number; strength: number; intelligence: number };
|
||||
statUpgradeLimit?: number;
|
||||
dex?: number[];
|
||||
}
|
||||
|
||||
interface ItemDisplayNames {
|
||||
@@ -65,7 +66,7 @@ interface GeneralRefreshScore {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface GeneralInfo {
|
||||
export interface GeneralBasicCardData {
|
||||
id: number;
|
||||
name: string;
|
||||
picture?: string | null;
|
||||
@@ -107,7 +108,7 @@ interface GeneralInfo {
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
general: GeneralInfo | null;
|
||||
general: GeneralBasicCardData | null;
|
||||
loading: boolean;
|
||||
nationColor?: string | null;
|
||||
defenceText?: string | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ export type GeneralBattleSummaryData = {
|
||||
wins?: number | null;
|
||||
losses?: number | null;
|
||||
strategies?: number | null;
|
||||
serviceYears?: number | null;
|
||||
killCrew?: number | null;
|
||||
deathCrew?: number | null;
|
||||
winRate?: number | null;
|
||||
@@ -30,7 +31,7 @@ const props = withDefaults(
|
||||
const numberText = (value: number | null | undefined): string =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ko-KR') : '-';
|
||||
|
||||
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(1)}%`;
|
||||
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(2)}%`;
|
||||
|
||||
const winRate = computed(() => {
|
||||
if (typeof props.summary.winRate === 'number' && Number.isFinite(props.summary.winRate)) {
|
||||
@@ -39,7 +40,7 @@ const winRate = computed(() => {
|
||||
const battles = props.summary.warnum;
|
||||
const wins = props.summary.wins;
|
||||
if (typeof battles !== 'number' || battles <= 0 || typeof wins !== 'number') return '-';
|
||||
return `${((wins / battles) * 100).toFixed(1)}%`;
|
||||
return `${((wins / battles) * 100).toFixed(2)}%`;
|
||||
});
|
||||
|
||||
const killRate = computed(() => {
|
||||
@@ -49,7 +50,7 @@ const killRate = computed(() => {
|
||||
const killed = props.summary.killCrew;
|
||||
const lost = props.summary.deathCrew;
|
||||
if (typeof killed !== 'number' || typeof lost !== 'number' || lost <= 0) return '-';
|
||||
return `${((killed / lost) * 100).toFixed(1)}%`;
|
||||
return `${((killed / lost) * 100).toFixed(2)}%`;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -64,8 +65,15 @@ const killRate = computed(() => {
|
||||
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null">회</template></strong>
|
||||
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
|
||||
><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span
|
||||
><strong>{{ numberText(summary.strategies) }}</strong> <span>사살</span
|
||||
><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
|
||||
><strong>{{ numberText(summary.strategies) }}</strong>
|
||||
<template v-if="summary.serviceYears !== undefined">
|
||||
<span>사관</span
|
||||
><strong
|
||||
>{{ numberText(summary.serviceYears)
|
||||
}}<template v-if="summary.serviceYears != null">년</template></strong
|
||||
>
|
||||
</template>
|
||||
<span>사살</span><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
|
||||
><strong>{{ numberText(summary.deathCrew) }}</strong>
|
||||
<template v-if="showWinRate">
|
||||
<span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import GeneralBasicCard, { type GeneralBasicCardData } from './GeneralBasicCard.vue';
|
||||
import GeneralBattleSummary, { type GeneralBattleSummaryData } from './GeneralBattleSummary.vue';
|
||||
import LegacyGeneralProgress from '../ui/LegacyGeneralProgress.vue';
|
||||
|
||||
type BasicProgression = NonNullable<GeneralBasicCardData['progression']>;
|
||||
|
||||
export type GeneralInformationPanelData = GeneralBasicCardData & {
|
||||
progression: BasicProgression & {
|
||||
statExperience: NonNullable<BasicProgression['statExperience']>;
|
||||
statUpgradeLimit: number;
|
||||
dex: number[];
|
||||
};
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
general: GeneralInformationPanelData | null;
|
||||
summary: GeneralBattleSummaryData | null;
|
||||
loading: boolean;
|
||||
nationColor?: string | null;
|
||||
defenceText?: string | null;
|
||||
killTurn?: number | null;
|
||||
remainingMinutes?: number | null;
|
||||
troopText?: string | null;
|
||||
penaltyText?: string | number | null;
|
||||
}>(),
|
||||
{
|
||||
nationColor: '#173d27',
|
||||
defenceText: null,
|
||||
killTurn: null,
|
||||
remainingMinutes: null,
|
||||
troopText: null,
|
||||
penaltyText: null,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GeneralBasicCard
|
||||
data-general-information-panel
|
||||
:general="props.general"
|
||||
:loading="props.loading"
|
||||
:nation-color="props.nationColor"
|
||||
:defence-text="props.defenceText"
|
||||
:kill-turn="props.killTurn"
|
||||
:remaining-minutes="props.remainingMinutes"
|
||||
:troop-text="props.troopText"
|
||||
:penalty-text="props.penaltyText"
|
||||
>
|
||||
<template v-if="props.general" #details>
|
||||
<GeneralBattleSummary v-if="props.summary" :summary="props.summary" show-win-rate />
|
||||
<LegacyGeneralProgress :general="props.general" :show-primary="false" />
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, useId } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
|
||||
import { onClickOutside, useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import MapCityBasic from './MapCityBasic.vue';
|
||||
import MapCityDetail from './MapCityDetail.vue';
|
||||
@@ -104,6 +104,9 @@ const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
|
||||
|
||||
const mapArea = ref<HTMLElement | null>(null);
|
||||
const mapBody = ref<HTMLElement | null>(null);
|
||||
const mapControls = ref<HTMLElement | null>(null);
|
||||
const mapOptionsOpen = ref(false);
|
||||
const mapOptionsMenuId = `map-options-${useId()}`;
|
||||
const { width: mapBodyWidth } = useElementSize(mapBody);
|
||||
const { elementX, elementY } = useMouseInElement(mapArea);
|
||||
|
||||
@@ -416,6 +419,16 @@ const clearTouchPreview = () => {
|
||||
setHoveredCity(null);
|
||||
};
|
||||
|
||||
const closeMapOptions = () => {
|
||||
mapOptionsOpen.value = false;
|
||||
};
|
||||
|
||||
const toggleMapOptions = () => {
|
||||
mapOptionsOpen.value = !mapOptionsOpen.value;
|
||||
};
|
||||
|
||||
onClickOutside(mapControls, closeMapOptions);
|
||||
|
||||
const touchCity = (cityId: number, event: TouchEvent) => {
|
||||
if (touchPreviewCityId.value !== cityId) {
|
||||
touchPreviewCityId.value = cityId;
|
||||
@@ -460,7 +473,10 @@ const selectCity = (cityId: number) => {
|
||||
class="map-area"
|
||||
:class="[mapThemeClass, mapSeasonClass]"
|
||||
:style="{ width: mapWidth, height: mapHeight }"
|
||||
@click="clearTouchPreview"
|
||||
@click="
|
||||
clearTouchPreview();
|
||||
closeMapOptions();
|
||||
"
|
||||
>
|
||||
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
|
||||
<div class="map-layer map-bglayer2" />
|
||||
@@ -495,18 +511,43 @@ const selectCity = (cityId: number) => {
|
||||
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
|
||||
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
||||
</div>
|
||||
<div class="map-controls">
|
||||
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName">
|
||||
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasTouchInput && !isSelectionMap && !props.readonly"
|
||||
class="map-toggle map-toggle-single-tap"
|
||||
:class="{ active: singleTapNavigation }"
|
||||
:aria-pressed="singleTapNavigation"
|
||||
@click.stop="toggleSingleTapNavigation"
|
||||
<div ref="mapControls" class="map-controls" @keydown.esc.stop="closeMapOptions">
|
||||
<div
|
||||
v-show="mapOptionsOpen"
|
||||
:id="mapOptionsMenuId"
|
||||
class="map-options-menu"
|
||||
role="group"
|
||||
aria-label="지도 옵션 메뉴"
|
||||
@click.stop
|
||||
>
|
||||
두번 탭 해 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
|
||||
<button
|
||||
class="map-toggle"
|
||||
:class="{ active: showCityName }"
|
||||
:aria-pressed="showCityName"
|
||||
@click="mapStore.toggleCityName"
|
||||
>
|
||||
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasTouchInput && !isSelectionMap && !props.readonly"
|
||||
class="map-toggle map-toggle-single-tap"
|
||||
:class="{ active: singleTapNavigation }"
|
||||
:aria-pressed="singleTapNavigation"
|
||||
@click="toggleSingleTapNavigation"
|
||||
>
|
||||
두번 탭 해 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="map-options-trigger"
|
||||
aria-label="지도 옵션"
|
||||
title="지도 옵션"
|
||||
:aria-controls="mapOptionsMenuId"
|
||||
:aria-expanded="mapOptionsOpen"
|
||||
@click.stop="toggleMapOptions"
|
||||
>
|
||||
<span aria-hidden="true">⚙</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -591,14 +632,68 @@ const selectCity = (cityId: number) => {
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
inset: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-options-trigger {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 2px;
|
||||
padding: 0;
|
||||
background: #345c85;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.map-options-trigger:hover,
|
||||
.map-options-trigger:focus-visible,
|
||||
.map-options-trigger[aria-expanded='true'] {
|
||||
border-color: #b7cadc;
|
||||
background: #284969;
|
||||
}
|
||||
|
||||
.map-options-trigger:active {
|
||||
background: #1f3a54;
|
||||
}
|
||||
|
||||
.map-options-trigger:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.map-options-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
max-height: calc(100% - 32px);
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
align-items: stretch;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 2px;
|
||||
padding: 3px;
|
||||
background: rgba(11, 11, 11, 0.94);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.map-toggle {
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 2px;
|
||||
padding: 3px 7px;
|
||||
@@ -607,6 +702,12 @@ const selectCity = (cityId: number) => {
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-toggle + .map-toggle {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.map-toggle.active {
|
||||
|
||||
@@ -3,9 +3,7 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue';
|
||||
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
|
||||
import {
|
||||
GENERAL_RECORD_TYPES,
|
||||
@@ -273,30 +271,29 @@ onMounted(() => {
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="장수 정보">
|
||||
<GeneralBasicCard
|
||||
<GeneralInformationPanel
|
||||
class="battle-general-card"
|
||||
:general="selectedGeneral"
|
||||
:summary="
|
||||
selectedGeneral
|
||||
? {
|
||||
available: true,
|
||||
experience: selectedGeneral.experience,
|
||||
dedicationText: selectedGeneral.progression.dedicationText,
|
||||
warnum: selectedGeneral.warnum,
|
||||
wins: selectedGeneral.battleStats.kills,
|
||||
losses: selectedGeneral.battleStats.deaths,
|
||||
strategies: selectedGeneral.battleStats.fire,
|
||||
serviceYears: selectedGeneral.serviceYears,
|
||||
killCrew: selectedGeneral.battleStats.killCrew,
|
||||
deathCrew: selectedGeneral.battleStats.deathCrew,
|
||||
recentWar: selectedGeneral.recentWar,
|
||||
}
|
||||
: null
|
||||
"
|
||||
:loading="loading"
|
||||
:nation-color="data?.nation.color"
|
||||
>
|
||||
<template v-if="selectedGeneral" #details>
|
||||
<GeneralBattleSummary
|
||||
:summary="{
|
||||
available: true,
|
||||
experience: selectedGeneral.experience,
|
||||
dedicationText: selectedGeneral.progression.dedicationText,
|
||||
warnum: selectedGeneral.warnum,
|
||||
wins: selectedGeneral.battleStats.kills,
|
||||
losses: selectedGeneral.battleStats.deaths,
|
||||
strategies: selectedGeneral.battleStats.fire,
|
||||
killCrew: selectedGeneral.battleStats.killCrew,
|
||||
deathCrew: selectedGeneral.battleStats.deathCrew,
|
||||
recentWar: selectedGeneral.recentWar,
|
||||
}"
|
||||
/>
|
||||
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
/>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
|
||||
import {
|
||||
@@ -136,9 +135,6 @@ const statusLine = computed(() =>
|
||||
|
||||
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
|
||||
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
|
||||
const numberText = (value: number): string => value.toLocaleString('ko-KR');
|
||||
const percentText = (numerator: number, denominator: number): string =>
|
||||
`${((numerator / Math.max(denominator, 1)) * 100).toFixed(2)}%`;
|
||||
const noDefencePenaltyWaived = computed(() => {
|
||||
const environment = asRecord(world.value?.config.environment);
|
||||
return isDefenceTrainPenaltyWaivedByScenarioEffect(
|
||||
@@ -416,55 +412,31 @@ onMounted(() => {
|
||||
<section class="top-grid">
|
||||
<div class="general-column">
|
||||
<div class="section-title sky">장수 정보</div>
|
||||
<GeneralBasicCard
|
||||
<GeneralInformationPanel
|
||||
class="general-table"
|
||||
:general="data?.general ?? null"
|
||||
:summary="
|
||||
data
|
||||
? {
|
||||
available: true,
|
||||
experience: data.general.experience,
|
||||
dedicationText: data.general.progression?.dedicationText,
|
||||
warnum: data.general.records.battles,
|
||||
wins: data.general.records.wins,
|
||||
losses: data.general.records.losses,
|
||||
strategies: data.general.records.strategies,
|
||||
serviceYears: data.general.records.serviceYears,
|
||||
killCrew: data.general.records.killedCrew,
|
||||
deathCrew: data.general.records.lostCrew,
|
||||
recentWar: data.general.recentWar,
|
||||
}
|
||||
: null
|
||||
"
|
||||
:loading="loading"
|
||||
:nation-color="data?.nation?.color"
|
||||
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 함(훈사${form.defence_train})`"
|
||||
:penalty-text="penalties.length || '-'"
|
||||
>
|
||||
<template v-if="data" #details>
|
||||
<div class="legacy-general-details">
|
||||
<div>
|
||||
명망
|
||||
<strong
|
||||
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
|
||||
data.general.experience
|
||||
}})</strong
|
||||
>
|
||||
· 계급
|
||||
<strong
|
||||
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
|
||||
data.general.dedication
|
||||
}})</strong
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
전투 {{ numberText(data.general.records.battles) }} · 계략
|
||||
{{ numberText(data.general.records.strategies) }} · 사관
|
||||
{{ numberText(data.general.records.serviceYears) }}년
|
||||
</div>
|
||||
<div>
|
||||
승률 {{ percentText(data.general.records.wins, data.general.records.battles) }} · 승리
|
||||
{{ numberText(data.general.records.wins) }} · 패배
|
||||
{{ numberText(data.general.records.losses) }}
|
||||
</div>
|
||||
<div>
|
||||
살상률
|
||||
{{ percentText(data.general.records.killedCrew, data.general.records.lostCrew) }} · 사살
|
||||
{{ numberText(data.general.records.killedCrew) }} · 피살
|
||||
{{ numberText(data.general.records.lostCrew) }}
|
||||
</div>
|
||||
<div>
|
||||
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
|
||||
{{ data.general.crewTypeName ?? '-' }} · 내정특기
|
||||
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="data.general" :show-primary="false" />
|
||||
</div>
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-column">
|
||||
@@ -875,14 +847,6 @@ button:disabled {
|
||||
.legacy-general-info-compat {
|
||||
display: none;
|
||||
}
|
||||
.legacy-general-details {
|
||||
background: #172a52 var(--sammo-texture-blue);
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.legacy-general-details > div {
|
||||
border-top: 1px solid #557;
|
||||
}
|
||||
.legacy-credit {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user