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
@@ -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;
@@ -0,0 +1,100 @@
export type GeneralDirectorySortKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
export type GeneralDirectorySortDirection = 'ascending' | 'descending';
export type GeneralDirectorySortCriterion = {
key: GeneralDirectorySortKey;
direction: GeneralDirectorySortDirection;
};
type TraitValue = { key: string };
export type GeneralDirectorySortable = {
name: string;
nationId: number;
leadership: number;
strength: number;
intelligence: number;
experience: number;
dedication: number;
officerLevel: number;
killturn: number;
refreshScoreTotal: number;
personality: TraitValue;
specialDomestic: TraitValue;
specialWar: TraitValue;
age: number;
npcState: number;
};
const koreanNameCollator = new Intl.Collator('ko-KR', { numeric: true, sensitivity: 'base' });
const compareString = (left: string, right: string): number => {
if (left === right) return 0;
return left < right ? -1 : 1;
};
const compareByKey = <T extends GeneralDirectorySortable>(left: T, right: T, key: GeneralDirectorySortKey): number => {
switch (key) {
case 0:
return koreanNameCollator.compare(left.name, right.name);
case 1:
return left.nationId - right.nationId;
case 2:
return left.leadership - right.leadership;
case 3:
return left.strength - right.strength;
case 4:
return left.intelligence - right.intelligence;
case 5:
case 10:
return left.experience - right.experience;
case 6:
return left.dedication - right.dedication;
case 7:
return left.officerLevel - right.officerLevel;
case 8:
return left.killturn - right.killturn;
case 9:
return left.refreshScoreTotal - right.refreshScoreTotal;
case 11:
return compareString(left.personality.key, right.personality.key);
case 12:
return compareString(left.specialDomestic.key, right.specialDomestic.key);
case 13:
return compareString(left.specialWar.key, right.specialWar.key);
case 14:
return left.age - right.age;
case 15:
return left.npcState - right.npcState;
}
};
export const advanceGeneralDirectorySort = (
current: readonly GeneralDirectorySortCriterion[],
key: GeneralDirectorySortKey
): GeneralDirectorySortCriterion[] => {
const existing = current.find((criterion) => criterion.key === key);
const remaining = current.filter((criterion) => criterion.key !== key);
if (!existing) return [{ key, direction: 'descending' }, ...remaining];
if (existing.direction === 'descending') return [{ key, direction: 'ascending' }, ...remaining];
return remaining;
};
export const sortGeneralDirectory = <T extends GeneralDirectorySortable>(
source: readonly T[],
criteria: readonly GeneralDirectorySortCriterion[]
): T[] => {
if (criteria.length === 0) return [...source];
return source
.map((general, originalIndex) => ({ general, originalIndex }))
.sort((left, right) => {
for (const criterion of criteria) {
const compared = compareByKey(left.general, right.general, criterion.key);
if (compared !== 0) return criterion.direction === 'ascending' ? compared : -compared;
}
return left.originalIndex - right.originalIndex;
})
.map(({ general }) => general);
};
@@ -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>
+41 -15
View File
@@ -1,15 +1,21 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import LegacySortControls from '../components/ui/LegacySortControls.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import type { GeneralDirectoryGeneral } from '../types/directory';
import {
advanceGeneralDirectorySort,
sortGeneralDirectory,
type GeneralDirectorySortCriterion,
type GeneralDirectorySortKey,
} from '../utils/generalDirectorySort';
import { trpc } from '../utils/trpc';
type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
const sortOptions: Array<{ value: SortKey; label: string }> = [
const sortOptions: Array<{ value: GeneralDirectorySortKey; label: string }> = [
{ value: 0, label: '이름' },
{ value: 1, label: '국가' },
{ value: 2, label: '통솔' },
{ value: 3, label: '무력' },
@@ -27,18 +33,26 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 15, label: 'NPC' },
];
const sort = ref<SortKey>(9);
const generals = ref<GeneralDirectoryGeneral[]>([]);
const selectedSort = ref<GeneralDirectorySortKey>(9);
const sourceGenerals = ref<GeneralDirectoryGeneral[]>([]);
const sortCriteria = ref<GeneralDirectorySortCriterion[]>([]);
const loading = ref(false);
const error = ref('');
const router = useRouter();
const { info: showInfoToast } = useGameFeedback();
const generals = computed(() => sortGeneralDirectory(sourceGenerals.value, sortCriteria.value));
const loadDirectory = async () => {
if (loading.value) {
showInfoToast('이미 장수 일람을 갱신하고 있습니다.');
return;
}
loading.value = true;
error.value = '';
try {
const result = await trpc.world.getGeneralDirectory.query({ sort: sort.value });
generals.value = result.generals;
const result = await trpc.world.getGeneralDirectory.query({ sort: 9 });
sourceGenerals.value = Array.isArray(result.generals) ? result.generals : [];
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.';
} finally {
@@ -47,12 +61,12 @@ const loadDirectory = async () => {
};
const updateSort = (value: number): void => {
sort.value = value as SortKey;
selectedSort.value = value as GeneralDirectorySortKey;
};
const sortByHeader = (value: number): void => {
updateSort(value);
void loadDirectory();
sortCriteria.value = advanceGeneralDirectorySort(sortCriteria.value, selectedSort.value);
};
onMounted(() => {
@@ -66,8 +80,15 @@ onMounted(() => {
<tbody>
<tr>
<td>
<br /><button class="legacy-button" type="button" @click="router.push('/')">
닫기
<br />
<button class="legacy-button" type="button" @click="router.push('/')"> 닫기</button>
<button
class="legacy-button"
type="button"
:aria-busy="loading || undefined"
@click="loadDirectory"
>
</button>
</td>
</tr>
@@ -75,11 +96,11 @@ onMounted(() => {
<td>
<LegacySortControls
control-id="viewType"
:model-value="sort"
:model-value="selectedSort"
:options="sortOptions"
:busy="loading"
@update:model-value="updateSort"
@submit="loadDirectory"
@submit="sortByHeader(selectedSort)"
/>
</td>
</tr>
@@ -87,7 +108,12 @@ onMounted(() => {
</table>
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
<GeneralDirectoryTable :generals="generals" :loading="loading" :active-sort="sort" @sort="sortByHeader" />
<GeneralDirectoryTable
:generals="generals"
:loading="loading"
:sort-criteria="sortCriteria"
@sort="sortByHeader"
/>
<table class="directory-table title-table legacy-bg0">
<tbody>
+20 -56
View File
@@ -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;