merge: 최신 main을 지도 계절 전환에 반영한다

# Conflicts:
#	app/game-frontend/src/components/main/MapViewer.vue
This commit is contained in:
2026-08-21 16:42:44 +00:00
21 changed files with 1075 additions and 248 deletions
@@ -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 {