refactor(game-ui): share Ref-style general panel

This commit is contained in:
2026-08-13 15:47:34 +00:00
parent 19162824e7
commit 7a90fba7f3
6 changed files with 433 additions and 325 deletions
+43 -7
View File
@@ -1,6 +1,6 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } });
@@ -20,6 +20,23 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
]);
};
const readGeneralPanelImages = async (panel: Locator) =>
panel.evaluate((element) =>
[...element.querySelectorAll<HTMLElement>('.general-image')].map((image) => {
const rect = image.getBoundingClientRect();
const style = getComputedStyle(image);
return {
label: image.getAttribute('aria-label'),
width: rect.width,
height: rect.height,
backgroundImage: style.backgroundImage,
backgroundSize: style.backgroundSize,
pointerEvents: style.pointerEvents,
userSelect: style.userSelect,
};
})
);
type FixtureState = {
permission: 'head' | 'member';
myset: number;
@@ -545,9 +562,15 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
const generalCard = page.locator('.general-card');
await expect(generalCard).toContainText('성격안전');
await expect(generalCard).toContainText('전투특기신산');
await expect(generalCard).toContainText('내정특기상재');
await expect(generalCard).toContainText('특기상재 / 신산');
await expect(generalCard).not.toContainText('che_');
await expect(generalCard).toHaveAttribute('data-general-basic-card', '');
const mainImages = await readGeneralPanelImages(generalCard);
expect(mainImages).toHaveLength(2);
expect(mainImages[0]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
expect(mainImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(mainImages[1]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
expect(mainImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
const geometry = await nationCard.evaluate((element) => {
const rect = element.getBoundingClientRect();
@@ -587,9 +610,9 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
await expect(nationCard).toContainText('국가 등급주자사');
const generalCard = page.locator('.general-card');
await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부');
await expect(generalCard.locator('.general-title')).toContainText('검증장수 간의대부 | 건강 】');
await expect(generalCard).toContainText('병종보병');
await expect(generalCard).toContainText('계급29품관');
await expect(generalCard).toContainText('계급 29품관');
const cityCard = page.locator('.city-card');
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
@@ -651,6 +674,14 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
{ width: 64, height: 64 },
{ width: 64, height: 64 },
]);
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('.item-group')).toContainText('명마');
@@ -1109,10 +1140,15 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
await page.getByRole('button', { name: '다음 ▶' }).click();
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
await expect(page.locator('.battle-general-name')).toContainText('검증장수 (간의대부)');
await expect(page.locator('.battle-general-name')).toContainText('검증장수 간의대부 | 건강 】');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.battle-general-extra')).toContainText('병종보병');
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', '');
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
expect(battleImages).toHaveLength(2);
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
expect(
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
interface GeneralStats {
leadership: number;
@@ -19,9 +22,18 @@ interface GeneralProgression {
statUpgradeLimit?: number;
}
interface ItemDisplayNames {
horse?: string | null;
weapon?: string | null;
book?: string | null;
item?: string | null;
}
interface GeneralInfo {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
npcState: number;
officerLevel: number;
officerLevelText: string;
@@ -35,17 +47,36 @@ interface GeneralInfo {
experience: number;
dedication: number;
age?: number;
turnTime?: string;
turnTime?: string | null;
troopId?: number;
crewTypeId?: number;
crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression;
itemNames?: ItemDisplayNames;
equipmentNames?: ItemDisplayNames;
}
const props = defineProps<{
general: GeneralInfo | null;
loading: boolean;
}>();
const props = withDefaults(
defineProps<{
general: GeneralInfo | 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,
}
);
const statRows = computed(() => {
const general = props.general;
@@ -72,137 +103,320 @@ const statRows = computed(() => {
const experiencePercent = computed(() =>
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
);
const itemNames = computed<ItemDisplayNames>(() => props.general?.itemNames ?? props.general?.equipmentNames ?? {});
const generalIconBackground = computed(() => resolveGeneralIconBackgroundImage(props.general ?? {}));
const crewTypeIconBackground = computed(() => {
const crewTypeId = props.general?.crewTypeId;
if (crewTypeId === undefined || !Number.isFinite(crewTypeId)) {
return `url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
}
const crewTypeUrl = `${configuredGameAssetUrl()}/crewtype${Math.trunc(crewTypeId)}.png`;
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
});
const injuryInfo = computed(() => {
const injury = props.general?.injury ?? 0;
if (injury > 60) return { text: '위독', color: '#ff4d4f' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ff9f1a' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
});
const isBrightColor = (color: string): boolean => {
const normalized = /^#[0-9a-f]{6}$/iu.test(color) ? color.slice(1) : '173d27';
const red = Number.parseInt(normalized.slice(0, 2), 16);
const green = Number.parseInt(normalized.slice(2, 4), 16);
const blue = Number.parseInt(normalized.slice(4, 6), 16);
return (red * 299 + green * 587 + blue * 114) / 1000 >= 150;
};
const titleStyle = computed(() => {
const backgroundColor = props.nationColor || '#173d27';
return {
backgroundColor,
color: isBrightColor(backgroundColor) ? '#000000' : '#ffffff',
};
});
const ageColor = computed(() => {
const age = props.general?.age;
if (age === undefined) return '#ffffff';
if (age < 53) return '#32cd32';
if (age < 70) return '#ffff00';
return '#ff4d4f';
});
const displayTroop = computed(() => props.troopText ?? (props.general?.troopId ? String(props.general.troopId) : '-'));
const displayPenalty = computed(() => {
const penalty = props.penaltyText ?? '-';
const dedication = props.general?.progression?.dedicationText ?? '무품관';
return `${penalty} · 계급 ${dedication}`;
});
const displayDefence = computed(() => props.defenceText ?? '-');
const specialText = computed(() => {
const traits = props.general?.traits;
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
});
</script>
<template>
<div class="general-card">
<div v-if="props.loading">
<div class="general-card" data-general-basic-card>
<div v-if="props.loading" class="general-loading">
<SkeletonLines :lines="5" />
</div>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body">
<div class="general-title">
{{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }} ·
다음
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<template v-else>
<div class="general-basic-grid general-body">
<span
class="general-image general-icon"
role="img"
:aria-label="`${props.general.name} 초상`"
:style="{ backgroundImage: generalIconBackground }"
/>
<div class="general-title battle-general-name" :style="titleStyle">
{{ props.general.name }} {{ props.general.officerLevelText }} |
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 다음
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<div class="stat-progress-grid">
<template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
<div class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</div>
<strong class="stat-value">
<span>{{ stat.value }}</span>
<span class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</span>
</strong>
</template>
</div>
<div class="legacy-grid">
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
><strong>{{ props.general.crewTypeName ?? '-' }}</strong> <span>성격</span
><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span
><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span
><strong>{{ props.general.progression?.dedicationText ?? '무품관' }}</strong> <span>공헌</span
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
</div>
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
<div class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<div class="bar-cell" data-experience-progress>
<span
class="general-image general-crew-type-icon"
role="img"
:aria-label="`${props.general.crewTypeName ?? '병종'} 이미지`"
:style="{ backgroundImage: crewTypeIconBackground }"
/>
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
<span class="cell-label level-label">Lv</span>
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<span class="experience-bar" data-experience-progress>
<LegacyProgressBar
:percent="experiencePercent"
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
/>
</div>
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
</span>
<span class="cell-label age-label">연령</span>
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}</strong>
<span class="cell-label defence-label">수비</span>
<strong class="defence-value">{{ displayDefence }}</strong>
<span class="cell-label kill-label">삭턴</span>
<strong class="kill-value">{{ props.killTurn === null ? '-' : `${props.killTurn}` }}</strong>
<span class="cell-label execute-label">실행</span>
<strong class="execute-value">{{
props.remainingMinutes === null ? '-' : `${props.remainingMinutes}분 남음`
}}</strong>
<span class="cell-label troop-label">부대</span>
<strong class="troop-value">{{ displayTroop }}</strong>
<span class="cell-label penalty-label">벌점</span>
<strong class="penalty-value">{{ displayPenalty }}</strong>
</div>
</div>
<slot name="details" />
</template>
</div>
</template>
<style scoped>
.general-title {
.general-card {
box-sizing: border-box;
height: 20px;
min-height: 20px;
padding: 1px 6px;
border-bottom: 1px solid #777;
background: #173d27;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.stat-progress-grid {
display: grid;
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
grid-auto-rows: 21px;
font-size: 12px;
}
.stat-progress-grid > *,
.legacy-grid > * {
box-sizing: border-box;
height: 21px;
min-height: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 4px;
width: 100%;
min-width: 0;
overflow: hidden;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
color: #fff;
font-size: 12px;
}
.general-basic-grid {
display: grid;
box-sizing: border-box;
width: 100%;
min-width: 0;
grid-template-columns: 64px repeat(3, minmax(30px, 2fr) minmax(60px, 5fr));
grid-template-rows: repeat(9, calc(64px / 3));
border-right: 1px solid #777;
border-bottom: 1px solid #777;
text-align: center;
}
.general-basic-grid > * {
box-sizing: border-box;
min-width: 0;
min-height: 0;
border-top: 1px solid #777;
border-left: 1px solid #777;
padding: 1px 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-label,
.legacy-grid > span {
background: rgb(20 75 42 / 70%);
.general-basic-grid > strong {
font-weight: 500;
text-align: center;
}
.stat-progress-grid > strong,
.legacy-grid > strong {
text-align: right;
font-weight: 400;
.cell-label {
background-color: rgb(20 75 42 / 70%);
}
.bar-cell {
.general-image {
display: block;
width: 64px;
height: 64px;
padding: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.general-icon {
grid-column: 1;
grid-row: 1 / 4;
}
.general-title {
grid-column: 2 / 8;
grid-row: 1;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.stat-value {
display: grid;
grid-template-columns: minmax(22px, auto) minmax(26px, 1fr);
align-items: center;
gap: 2px;
}
.bar-cell,
.experience-bar {
display: grid;
align-content: center;
padding: 0 1px;
}
.legacy-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: 21px;
font-size: 12px;
.general-crew-type-icon {
grid-column: 1;
grid-row: 4 / 7;
}
.experience-row {
display: grid;
box-sizing: border-box;
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
height: 20px;
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
.level-label {
grid-column: 1;
grid-row: 7;
}
.experience-row > * {
display: grid;
align-content: center;
box-sizing: border-box;
border-right: 1px solid #666;
padding: 1px 4px;
text-align: center;
.level-value {
grid-column: 2;
grid-row: 7;
}
.experience-bar {
grid-column: 3 / 6;
grid-row: 7;
}
.age-label {
grid-column: 6;
grid-row: 7;
}
.age-value {
grid-column: 7;
grid-row: 7;
}
.defence-label {
grid-column: 1;
grid-row: 8;
}
.defence-value {
grid-column: 2 / 4;
grid-row: 8;
}
.kill-label {
grid-column: 4;
grid-row: 8;
}
.kill-value {
grid-column: 5;
grid-row: 8;
}
.execute-label {
grid-column: 6;
grid-row: 8;
}
.execute-value {
grid-column: 7;
grid-row: 8;
}
.troop-label {
grid-column: 1;
grid-row: 9;
}
.troop-value {
grid-column: 2 / 4;
grid-row: 9;
}
.penalty-label {
grid-column: 4;
grid-row: 9;
}
.penalty-value {
grid-column: 5 / 8;
grid-row: 9;
}
.general-loading,
.empty {
min-height: 192px;
padding: 8px;
}
.empty {
@@ -15,7 +15,9 @@ type GeneralProgress = {
};
};
const props = defineProps<{ general: GeneralProgress }>();
const props = withDefaults(defineProps<{ general: GeneralProgress; showPrimary?: boolean }>(), {
showPrimary: true,
});
const statRows = computed(() =>
[
@@ -50,7 +52,7 @@ const experiencePercent = computed(() =>
<template>
<div class="legacy-general-progress">
<div class="stat-grid">
<div v-if="props.showPrimary" class="stat-grid">
<template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
@@ -60,7 +62,7 @@ const experiencePercent = computed(() =>
/>
</template>
</div>
<div class="experience-row">
<div v-if="props.showPrimary" class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression.experienceLevel }}</strong>
<LegacyProgressBar
@@ -4,10 +4,10 @@ import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number];
@@ -136,8 +136,6 @@ const formatGeneralLabel = (general: GeneralEntry): string => {
return `${name} (${time})`;
};
const generalImageUrl = (general: GeneralEntry): string => resolveGeneralIconUrl(general);
let logRequestId = 0;
const loadLogs = async (generalId: number) => {
@@ -266,47 +264,29 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
</div>
<span
class="battle-general-portrait"
role="img"
:aria-label="`${selectedGeneral.name} 초상`"
:style="{ backgroundImage: `url(${generalImageUrl(selectedGeneral)})` }"
/>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
<div class="battle-general-extra">
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<span>나이</span><strong>{{ selectedGeneral.age }}</strong> <span>병종</span
><strong>{{ selectedGeneral.crewTypeName }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
<span>피살</span
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
<span>전투 특기</span><strong>{{ selectedGeneral.traits.specialWar }}</strong>
<span>내정 특기</span><strong>{{ selectedGeneral.traits.specialDomestic }}</strong>
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" />
</div>
<GeneralBasicCard
class="battle-general-card"
:general="selectedGeneral"
:loading="loading"
:nation-color="data?.nation.color"
>
<template v-if="selectedGeneral" #details>
<div class="battle-general-extra">
<span>명성</span
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<span>전투</span><strong>{{ selectedGeneral.warnum }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span
><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
<span>피살</span
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
<span>최근 전투</span><strong>{{ selectedGeneral.recentWar || '-' }}</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template>
</GeneralBasicCard>
<div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
@@ -375,39 +355,7 @@ onMounted(() => {
gap: 4px;
}
.battle-general-card {
min-height: 292px;
position: relative;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
}
.battle-general-portrait {
display: block;
width: 64px;
height: 80px;
float: left;
background-position: center;
background-size: cover;
}
.battle-general-name {
min-height: 24px;
padding: 2px 6px;
text-align: center;
border-bottom: 1px solid #777;
background: rgba(220, 220, 220, 0.85);
color: #111;
font-weight: 700;
}
.battle-general-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-extra {
clear: both;
display: grid;
grid-template-columns: repeat(6, 1fr);
}
@@ -433,24 +381,6 @@ onMounted(() => {
white-space: nowrap;
}
.battle-general-grid > * {
min-height: 24px;
padding: 2px 5px;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
}
.battle-general-grid > span {
background-color: rgba(20, 75, 42, 0.7);
color: #fff;
text-align: center;
}
.battle-general-grid > strong {
text-align: right;
font-weight: 500;
}
.log-grid {
display: contents;
}
+14 -2
View File
@@ -241,7 +241,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
<GeneralBasicCard
:general="general"
:loading="loading"
:nation-color="nation?.color"
:troop-text="general?.troopId ? String(general.troopId) : '-'"
/>
</PanelCard>
<PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" />
@@ -356,7 +361,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
<GeneralBasicCard
:general="general"
:loading="loading"
:nation-color="nation?.color"
:troop-text="general?.troopId ? String(general.troopId) : '-'"
/>
</PanelCard>
<MainNationMenu
class="nation-menu-middle"
@@ -605,12 +615,14 @@ button {
.layout-desktop > [data-main-target='nation'] {
grid-column: 1 / 6;
grid-row: 3;
align-self: stretch;
min-height: 193px;
}
.layout-desktop > [data-main-target='general'] {
grid-column: 6 / 11;
grid-row: 3;
align-self: stretch;
min-height: 193px;
}
+37 -123
View File
@@ -7,6 +7,7 @@ import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
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 { useGameFeedback } from '../composables/useGameFeedback';
const SCREEN_MODE_KEY = 'sam.screenMode';
@@ -374,91 +375,43 @@ onMounted(() => {
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<span
class="portrait-image"
role="img"
:style="{ backgroundImage: `url(${resolveGeneralIconUrl(data.general)})` }"
></span>
<strong>{{ data.general.name }}</strong>
</div>
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
<GeneralBasicCard
class="general-table"
:general="data?.general ?? null"
:loading="loading"
:nation-color="data?.nation?.color"
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
: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>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</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>
<div>
<dt>무력</dt>
<dd>{{ data.general.stats.strength }}</dd>
</div>
<div>
<dt>지력</dt>
<dd>{{ data.general.stats.intelligence }}</dd>
</div>
<div>
<dt>소속</dt>
<dd>{{ data.nation?.name ?? '재야' }}</dd>
</div>
<div>
<dt>도시</dt>
<dd>{{ data.city?.name ?? '-' }}</dd>
</div>
<div>
<dt>/</dt>
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
</div>
<div>
<dt>병력</dt>
<dd>{{ data.general.crew }}</dd>
</div>
<div>
<dt>훈련/사기</dt>
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
</div>
<div>
<dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div>
<div>
<dt>성격/특기</dt>
<dd>
{{ data.general.traits?.personal ?? '-' }} /
{{ data.general.traits?.specialWar ?? '-' }}
</dd>
</div>
<div>
<dt>나이/다음턴</dt>
<dd>{{ data.general.age ?? '-' }} / {{ data.general.turnTime?.slice(11, 16) ?? '-' }}</dd>
</div>
</dl>
</div>
<div v-if="data" 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>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div>
<div>
병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
</div>
<LegacyGeneralProgress :general="data.general" />
</div>
</template>
</GeneralBasicCard>
</div>
<div class="settings-column">
@@ -786,28 +739,6 @@ button:disabled {
.sky {
color: skyblue;
}
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
}
.portrait-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-image {
display: block;
width: 64px;
height: 64px;
background-position: center;
background-size: cover;
}
.legacy-general-info-compat {
display: none;
}
@@ -824,23 +755,6 @@ button:disabled {
overflow: hidden;
white-space: nowrap;
}
dl {
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: 80px 1fr;
border-bottom: 1px solid #777;
}
dt,
dd {
margin: 0;
padding: 2px 5px;
border-right: 1px solid #777;
}
dt {
color: #aaa;
}
.settings-column {
padding: 10px 18px;
}