merge: 최신 main을 지도 계절 전환에 반영한다
# Conflicts: # app/game-frontend/src/components/main/MapViewer.vue
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
description?: string | null;
|
||||
testId?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="directory-tooltip"
|
||||
:class="{ 'directory-tooltip--enabled': description }"
|
||||
:tabindex="description ? 0 : undefined"
|
||||
:data-directory-tooltip="testId"
|
||||
>
|
||||
<slot />
|
||||
<span v-if="description" class="directory-tooltip__content" role="tooltip">
|
||||
<strong>{{ title }}</strong>
|
||||
<span>{{ description }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.directory-tooltip {
|
||||
position: relative;
|
||||
display: inline;
|
||||
min-width: 0;
|
||||
}
|
||||
.directory-tooltip--enabled {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted rgb(150 210 255 / 85%);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.directory-tooltip--enabled:focus-visible {
|
||||
border-radius: 2px;
|
||||
outline: 1px solid #6fc7ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.directory-tooltip__content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 5px);
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(280px, calc(100vw - 16px));
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid #8c8c8c;
|
||||
border-radius: 3px;
|
||||
padding: 7px 9px;
|
||||
background: #101010;
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
|
||||
color: #f5f5f5;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.directory-tooltip__content strong,
|
||||
.directory-tooltip__content span {
|
||||
display: block;
|
||||
}
|
||||
.directory-tooltip__content strong {
|
||||
margin-bottom: 4px;
|
||||
color: #7fd4ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.directory-tooltip--enabled:hover > .directory-tooltip__content,
|
||||
.directory-tooltip--enabled:focus > .directory-tooltip__content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.directory-tooltip__content {
|
||||
position: fixed;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import DirectoryTooltip from './DirectoryTooltip.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: number;
|
||||
injury: number;
|
||||
bonus?: number;
|
||||
testId?: string;
|
||||
}>(),
|
||||
{ bonus: 0, testId: undefined }
|
||||
);
|
||||
|
||||
const displayedValue = computed(() =>
|
||||
props.injury > 0 ? Math.trunc((props.value * (100 - props.injury)) / 100) : props.value
|
||||
);
|
||||
const injuryDescription = computed(() =>
|
||||
props.injury > 0
|
||||
? `부상 ${props.injury}% · 원래 ${props.label} ${props.value} → 적용 ${displayedValue.value}`
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryTooltip :title="`${label} 부상`" :description="injuryDescription" :test-id="testId">
|
||||
<span :class="{ wounded: injury > 0 }">{{ displayedValue }}</span>
|
||||
</DirectoryTooltip>
|
||||
<span v-if="bonus > 0" class="leadership-bonus">+{{ bonus }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.leadership-bonus {
|
||||
color: cyan;
|
||||
}
|
||||
</style>
|
||||
@@ -3,13 +3,14 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/genera
|
||||
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
||||
import { getNpcColor } from '../../utils/npcColor';
|
||||
import type { GeneralDirectoryGeneral } from '../../types/directory';
|
||||
import type { GeneralDirectorySortCriterion, GeneralDirectorySortKey } from '../../utils/generalDirectorySort';
|
||||
import DirectoryTooltip from './DirectoryTooltip.vue';
|
||||
import GeneralDirectoryStat from './GeneralDirectoryStat.vue';
|
||||
|
||||
type SortDirection = 'ascending' | 'descending';
|
||||
type Header = {
|
||||
label: string;
|
||||
sort?: number;
|
||||
direction?: SortDirection;
|
||||
title?: string;
|
||||
sort?: GeneralDirectorySortKey;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -17,12 +18,12 @@ const props = withDefaults(
|
||||
generals: GeneralDirectoryGeneral[];
|
||||
loading?: boolean;
|
||||
layout?: 'responsive' | 'card';
|
||||
activeSort?: number;
|
||||
sortCriteria?: readonly GeneralDirectorySortCriterion[];
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
layout: 'responsive',
|
||||
activeSort: undefined,
|
||||
sortCriteria: () => [],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -30,26 +31,43 @@ const emit = defineEmits<{ sort: [value: number] }>();
|
||||
|
||||
const headers: ReadonlyArray<Header> = [
|
||||
{ label: '얼 굴' },
|
||||
{ label: '이 름' },
|
||||
{ label: '연령', sort: 14, direction: 'descending' },
|
||||
{ label: '성격', sort: 11, direction: 'descending' },
|
||||
{ label: '이 름', sort: 0 },
|
||||
{ label: '연령', sort: 14 },
|
||||
{ label: '성격', sort: 11 },
|
||||
{ label: '특기' },
|
||||
{ label: '레 벨', sort: 10, direction: 'descending' },
|
||||
{ label: '국 가', sort: 1, direction: 'ascending' },
|
||||
{ label: '명 성', sort: 5, direction: 'descending' },
|
||||
{ label: '계 급', sort: 6, direction: 'descending' },
|
||||
{ label: '관 직', sort: 7, direction: 'descending' },
|
||||
{ label: '통솔', sort: 2, direction: 'descending' },
|
||||
{ label: '무력', sort: 3, direction: 'descending' },
|
||||
{ label: '지력', sort: 4, direction: 'descending' },
|
||||
{ label: '삭턴', sort: 8, direction: 'ascending' },
|
||||
{ label: '벌점', sort: 9, direction: 'descending' },
|
||||
{ label: '레 벨', sort: 10 },
|
||||
{ label: '국 가', sort: 1 },
|
||||
{ label: '명 성', sort: 5 },
|
||||
{ label: '계 급', sort: 6 },
|
||||
{ label: '관 직', sort: 7 },
|
||||
{ label: '통솔', sort: 2 },
|
||||
{ label: '무력', sort: 3 },
|
||||
{ label: '지력', sort: 4 },
|
||||
{ label: '삭턴', sort: 8 },
|
||||
{ label: '벌점', sort: 9 },
|
||||
];
|
||||
|
||||
const criterionIndex = (header: Header): number =>
|
||||
header.sort === undefined ? -1 : props.sortCriteria.findIndex(({ key }) => key === header.sort);
|
||||
const criterionFor = (header: Header): GeneralDirectorySortCriterion | undefined => {
|
||||
const index = criterionIndex(header);
|
||||
return index < 0 ? undefined : props.sortCriteria[index];
|
||||
};
|
||||
const ariaSort = (header: Header): SortDirection | undefined =>
|
||||
header.sort === props.activeSort ? header.direction : undefined;
|
||||
|
||||
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
|
||||
criterionIndex(header) === 0 ? criterionFor(header)?.direction : undefined;
|
||||
const sortIndicator = (header: Header): string => {
|
||||
const index = criterionIndex(header);
|
||||
if (index < 0) return '↕';
|
||||
const arrow = criterionFor(header)?.direction === 'ascending' ? '▲' : '▼';
|
||||
return props.sortCriteria.length > 1 ? `${arrow}${index + 1}` : arrow;
|
||||
};
|
||||
const nextSortAction = (header: Header): string => {
|
||||
const direction = criterionFor(header)?.direction;
|
||||
if (!direction) return '내림차순';
|
||||
return direction === 'descending' ? '오름차순' : '정렬 해제';
|
||||
};
|
||||
const sortHelp = (header: Header): string =>
|
||||
`${header.label.replaceAll(' ', '')} ${nextSortAction(header)}. 같은 값은 이전 정렬 순서를 유지합니다.`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,17 +99,14 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
:aria-sort="ariaSort(header)"
|
||||
>
|
||||
<button
|
||||
v-if="header.sort !== undefined && activeSort !== undefined"
|
||||
v-if="header.sort !== undefined"
|
||||
class="legacy-sort-header"
|
||||
type="button"
|
||||
:aria-label="`${header.label.replaceAll(' ', '')} 기준 정렬`"
|
||||
:title="header.title ?? `${header.label.replaceAll(' ', '')} 기준 정렬`"
|
||||
:aria-label="sortHelp(header)"
|
||||
:title="sortHelp(header)"
|
||||
@click="emit('sort', header.sort)"
|
||||
>
|
||||
{{ header.label
|
||||
}}<span class="legacy-sort-indicator">{{
|
||||
header.sort === activeSort ? (header.direction === 'ascending' ? '▲' : '▼') : '↕'
|
||||
}}</span>
|
||||
{{ header.label }}<span class="legacy-sort-indicator">{{ sortIndicator(header) }}</span>
|
||||
</button>
|
||||
<template v-else>{{ header.label }}</template>
|
||||
</th>
|
||||
@@ -132,11 +147,30 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
</td>
|
||||
<td class="center">{{ general.age }}세</td>
|
||||
<td class="center">
|
||||
<span :title="general.personality.info">{{ general.personality.name }}</span>
|
||||
<DirectoryTooltip
|
||||
:title="`성격 · ${general.personality.name}`"
|
||||
:description="general.personality.info"
|
||||
:test-id="`personality-${general.id}`"
|
||||
>
|
||||
{{ general.personality.name }}
|
||||
</DirectoryTooltip>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> /
|
||||
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span>
|
||||
<DirectoryTooltip
|
||||
:title="`내정 특기 · ${general.specialDomestic.name}`"
|
||||
:description="general.specialDomestic.info"
|
||||
:test-id="`special-domestic-${general.id}`"
|
||||
>
|
||||
{{ general.specialDomestic.name }}
|
||||
</DirectoryTooltip>
|
||||
/
|
||||
<DirectoryTooltip
|
||||
:title="`전투 특기 · ${general.specialWar.name}`"
|
||||
:description="general.specialWar.info"
|
||||
:test-id="`special-war-${general.id}`"
|
||||
>
|
||||
{{ general.specialWar.name }}
|
||||
</DirectoryTooltip>
|
||||
</td>
|
||||
<td class="center">Lv {{ general.experienceLevel }}</td>
|
||||
<td class="center">{{ general.nationName }}</td>
|
||||
@@ -144,22 +178,29 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
<td class="center">{{ general.dedicationText }}</td>
|
||||
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership
|
||||
}}</span
|
||||
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
>
|
||||
<GeneralDirectoryStat
|
||||
label="통솔"
|
||||
:value="general.leadership"
|
||||
:injury="general.injury"
|
||||
:bonus="general.leadershipBonus"
|
||||
:test-id="`injury-leadership-${general.id}`"
|
||||
/>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
|
||||
}}</span>
|
||||
<GeneralDirectoryStat
|
||||
label="무력"
|
||||
:value="general.strength"
|
||||
:injury="general.injury"
|
||||
:test-id="`injury-strength-${general.id}`"
|
||||
/>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence
|
||||
}}</span>
|
||||
<GeneralDirectoryStat
|
||||
label="지력"
|
||||
:value="general.intelligence"
|
||||
:injury="general.injury"
|
||||
:test-id="`injury-intelligence-${general.id}`"
|
||||
/>
|
||||
</td>
|
||||
<td class="center">{{ general.killturn }}</td>
|
||||
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
|
||||
@@ -207,13 +248,33 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">성격</span>
|
||||
<span :title="general.personality.info">{{ general.personality.name }}</span>
|
||||
<DirectoryTooltip
|
||||
:title="`성격 · ${general.personality.name}`"
|
||||
:description="general.personality.info"
|
||||
:test-id="`card-personality-${general.id}`"
|
||||
>
|
||||
{{ general.personality.name }}
|
||||
</DirectoryTooltip>
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">특기</span>
|
||||
<span :title="`${general.specialDomestic.info} / ${general.specialWar.info}`"
|
||||
>{{ general.specialDomestic.name }} / {{ general.specialWar.name }}</span
|
||||
>
|
||||
<span>
|
||||
<DirectoryTooltip
|
||||
:title="`내정 특기 · ${general.specialDomestic.name}`"
|
||||
:description="general.specialDomestic.info"
|
||||
:test-id="`card-special-domestic-${general.id}`"
|
||||
>
|
||||
{{ general.specialDomestic.name }}
|
||||
</DirectoryTooltip>
|
||||
/
|
||||
<DirectoryTooltip
|
||||
:title="`전투 특기 · ${general.specialWar.name}`"
|
||||
:description="general.specialWar.info"
|
||||
:test-id="`card-special-war-${general.id}`"
|
||||
>
|
||||
{{ general.specialWar.name }}
|
||||
</DirectoryTooltip>
|
||||
</span>
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span>
|
||||
@@ -233,26 +294,31 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">통솔</span>
|
||||
<span>
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership
|
||||
}}</span
|
||||
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
>
|
||||
</span>
|
||||
<GeneralDirectoryStat
|
||||
label="통솔"
|
||||
:value="general.leadership"
|
||||
:injury="general.injury"
|
||||
:bonus="general.leadershipBonus"
|
||||
:test-id="`card-injury-leadership-${general.id}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">무력</span>
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
|
||||
}}</span>
|
||||
<GeneralDirectoryStat
|
||||
label="무력"
|
||||
:value="general.strength"
|
||||
:injury="general.injury"
|
||||
:test-id="`card-injury-strength-${general.id}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">지력</span>
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence
|
||||
}}</span>
|
||||
<GeneralDirectoryStat
|
||||
label="지력"
|
||||
:value="general.intelligence"
|
||||
:injury="general.injury"
|
||||
:test-id="`card-injury-intelligence-${general.id}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="general-card-field penalty-field">
|
||||
<span class="field-label">벌점</span>
|
||||
@@ -298,12 +364,6 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.leadership-bonus {
|
||||
color: cyan;
|
||||
}
|
||||
.loading-cell {
|
||||
height: 64px;
|
||||
text-align: center;
|
||||
|
||||
@@ -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, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } 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';
|
||||
@@ -145,6 +145,9 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
|
||||
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);
|
||||
|
||||
@@ -583,6 +586,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;
|
||||
@@ -627,7 +640,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" data-map-background-layer="current">
|
||||
<img
|
||||
@@ -686,18 +702,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>
|
||||
@@ -782,14 +823,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;
|
||||
@@ -798,6 +893,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 {
|
||||
|
||||
@@ -113,7 +113,7 @@ watch(
|
||||
background: #101010;
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
|
||||
color: #f5f5f5;
|
||||
font-family: Pretendard, sans-serif;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
|
||||
Reference in New Issue
Block a user