feat(game-ui): port Ref progress bars

This commit is contained in:
2026-08-11 00:40:38 +00:00
parent 3b5ae0594f
commit 322a8533fb
18 changed files with 1161 additions and 89 deletions
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { ratioPercent } from '../../utils/legacyProgress';
interface CityInfo {
id: number;
@@ -7,11 +10,19 @@ interface CityInfo {
level: number;
nationId: number;
population: number;
populationMax: number;
agriculture: number;
agricultureMax: number;
commerce: number;
commerceMax: number;
security: number;
securityMax: number;
trust: number;
trade: number | null;
defence: number;
defenceMax: number;
wall: number;
wallMax: number;
supplyState: number;
frontState: number;
}
@@ -20,6 +31,31 @@ const props = defineProps<{
city: CityInfo | null;
loading: boolean;
}>();
const metrics = computed(() => {
const city = props.city;
if (!city) return [];
const paired = (label: string, current: number, maximum: number) => ({
label,
percent: ratioPercent(current, maximum),
text: `${current.toLocaleString()} / ${maximum.toLocaleString()}`,
});
const tradeText = city.trade ? `${city.trade}%` : '상인 없음';
return [
paired('주민', city.population, city.populationMax),
{
label: '민심',
percent: ratioPercent(city.trust, 100),
text: city.trust.toLocaleString(undefined, { maximumFractionDigits: 1 }),
},
paired('농업', city.agriculture, city.agricultureMax),
paired('상업', city.commerce, city.commerceMax),
paired('치안', city.security, city.securityMax),
paired('수비', city.defence, city.defenceMax),
paired('성벽', city.wall, city.wallMax),
{ label: '시세', percent: city.trade ? ratioPercent(city.trade - 95, 10) : 0, text: tradeText },
];
});
</script>
<template>
@@ -30,17 +66,26 @@ const props = defineProps<{
<div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div>
<div v-else class="city-body">
<div class="title">
{{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }}
{{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }} · 보급
{{ props.city.supplyState }} · 전방 {{ props.city.frontState }}
</div>
<div class="grid">
<span>인구</span><strong>{{ props.city.population.toLocaleString() }}</strong> <span>농업</span
><strong>{{ props.city.agriculture.toLocaleString() }}</strong> <span>상업</span
><strong>{{ props.city.commerce.toLocaleString() }}</strong> <span>치안</span
><strong>{{ props.city.security.toLocaleString() }}</strong> <span>수비</span
><strong>{{ props.city.defence.toLocaleString() }}</strong> <span>성벽</span
><strong>{{ props.city.wall.toLocaleString() }}</strong> <span>보급</span
><strong>{{ props.city.supplyState }}</strong> <span>전방</span
><strong>{{ props.city.frontState }}</strong>
<div class="progress-grid">
<div
v-for="metric of metrics"
:key="metric.label"
class="city-progress"
:data-city-progress="metric.label"
>
<span class="city-progress__label">{{ metric.label }}</span>
<div class="city-progress__body">
<LegacyProgressBar
:height="7"
:percent="metric.percent"
:label="`${metric.label} ${metric.text}`"
/>
<span class="city-progress__text">{{ metric.text }}</span>
</div>
</div>
</div>
</div>
</div>
@@ -48,36 +93,60 @@ const props = defineProps<{
<style scoped>
.title {
min-height: 24px;
padding: 2px 6px;
box-sizing: border-box;
min-height: 20px;
padding: 1px 6px;
border-bottom: 1px solid #666;
background: #173d27;
text-align: center;
font-size: 12px;
font-weight: 600;
}
.grid {
.progress-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
font-size: 12px;
}
.grid > * {
min-height: 23px;
box-sizing: border-box;
.city-progress {
display: grid;
grid-template-columns: 1fr 2fr;
min-width: 0;
min-height: 31px;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 5px;
}
.grid > span {
.city-progress__label {
display: flex;
align-items: center;
justify-content: center;
background: rgb(20 75 42 / 70%);
text-align: center;
}
.grid > strong {
text-align: right;
font-weight: 400;
.city-progress__body {
display: grid;
min-width: 0;
align-content: center;
padding: 1px 3px;
}
.city-progress__text {
overflow: hidden;
line-height: 14px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
@media (max-width: 939.98px) {
.progress-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { dexProgress, legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
interface GeneralStats {
leadership: number;
@@ -7,6 +10,14 @@ interface GeneralStats {
intelligence: number;
}
interface GeneralProgression {
experienceLevel: number;
dedicationLevel: number;
statExperience?: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit?: number;
dex: number[];
}
interface GeneralInfo {
id: number;
name: string;
@@ -25,13 +36,47 @@ interface GeneralInfo {
turnTime?: string;
crewTypeId?: number;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: { experienceLevel: number; dedicationLevel: number; dex: number[] };
progression?: GeneralProgression;
}
const props = defineProps<{
general: GeneralInfo | null;
loading: boolean;
}>();
const statRows = computed(() => {
const general = props.general;
if (!general) return [];
const limit = general.progression?.statUpgradeLimit ?? 30;
const accumulated = general.progression?.statExperience;
return [
{
key: 'leadership',
label: '통솔',
value: general.stats.leadership,
accumulated: accumulated?.leadership ?? 0,
},
{ key: 'strength', label: '무력', value: general.stats.strength, accumulated: accumulated?.strength ?? 0 },
{
key: 'intelligence',
label: '지력',
value: general.stats.intelligence,
accumulated: accumulated?.intelligence ?? 0,
},
].map((entry) => ({ ...entry, limit, percent: ratioPercent(entry.accumulated, limit) }));
});
const dexRows = computed(() => {
const dex = props.general?.progression?.dex ?? [];
return ['보병', '궁병', '기병', '귀병', '차병'].map((label, index) => {
const value = dex[index] ?? 0;
return { label, value, progress: dexProgress(value) };
});
});
const experiencePercent = computed(() =>
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
);
</script>
<template>
@@ -42,70 +87,182 @@ const props = defineProps<{
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body">
<div class="general-title">
{{ props.general.name }} · 관직 {{ props.general.officerLevel }} · {{ props.general.age ?? '-' }}
{{ props.general.name }} · 관직 {{ props.general.officerLevel }} · {{ props.general.age ?? '-' }} ·
다음
{{ props.general.turnTime?.slice(11, 16) ?? '-' }}
</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>
</template>
</div>
<div class="legacy-grid">
<span>통솔</span><strong>{{ props.general.stats.leadership }}</strong> <span>무력</span
><strong>{{ props.general.stats.strength }}</strong> <span>지력</span
><strong>{{ props.general.stats.intelligence }}</strong> <span>자금</span
><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
<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
>Lv {{ props.general.progression?.experienceLevel ?? 0 }} ({{ props.general.experience }})</strong
>
<span>계급</span
><strong
>Lv {{ props.general.progression?.dedicationLevel ?? 0 }} ({{ props.general.dedication }})</strong
>
<span>병종</span><strong>{{ props.general.crewTypeId || '-' }}</strong> <span>성격</span
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
><strong>{{ props.general.crewTypeId || '-' }}</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.turnTime?.slice(11, 16) ?? '-' }}</strong>
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span
><strong>Lv {{ props.general.progression?.dedicationLevel ?? 0 }}</strong> <span>공헌</span
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
</div>
<div class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<div class="bar-cell" data-experience-progress>
<LegacyProgressBar
:percent="experiencePercent"
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
/>
</div>
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
</div>
<div class="dex-title"> </div>
<div v-for="row of dexRows" :key="row.label" class="dex-row" :data-dex-progress="row.label">
<span class="cell-label">{{ row.label }}</span>
<strong :style="{ color: row.progress.color }">{{ row.progress.name }}</strong>
<span class="dex-value">{{ (row.value / 1_000).toFixed(1) }}K</span>
<div class="dex-bars">
<LegacyProgressBar
:percent="row.progress.overallPercent"
:label="`${row.label} 전체 ${row.value.toLocaleString()} / 1,275,975 (EX+)`"
/>
<LegacyProgressBar
:height="7"
variant="grade"
:percent="row.progress.gradePercent"
:label="
row.progress.nextName
? `${row.label} ${row.progress.name}에서 ${row.progress.nextName}까지 ${row.progress.remaining.toLocaleString()} 남음`
: `${row.label} EX+ 달성`
"
/>
</div>
</div>
<div class="dex">숙련도 {{ props.general.progression?.dex?.join(' / ') ?? '0 / 0 / 0 / 0 / 0' }}</div>
</div>
</div>
</template>
<style scoped>
.general-title {
min-height: 24px;
padding: 2px 6px;
min-height: 20px;
padding: 1px 6px;
border-bottom: 1px solid #777;
background: #173d27;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.legacy-grid {
.stat-progress-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
font-size: 12px;
}
.stat-progress-grid > *,
.legacy-grid > * {
min-height: 22px;
box-sizing: border-box;
min-height: 22px;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 4px;
overflow: hidden;
white-space: nowrap;
}
.legacy-grid > span {
.cell-label,
.legacy-grid > span,
.dex-title {
background: rgb(20 75 42 / 70%);
text-align: center;
}
.stat-progress-grid > strong,
.legacy-grid > strong {
text-align: right;
font-weight: 400;
}
.dex {
padding: 3px 6px;
.bar-cell {
display: grid;
align-content: center;
padding: 0 1px;
}
.legacy-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
font-size: 12px;
color: #ddd;
}
.experience-row {
display: grid;
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
min-height: 22px;
border-bottom: 1px solid #666;
font-size: 12px;
}
.experience-row > * {
display: grid;
align-content: center;
box-sizing: border-box;
border-right: 1px solid #666;
padding: 1px 4px;
text-align: center;
}
.dex-title {
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
font-weight: 700;
line-height: 20px;
}
.dex-row {
display: grid;
grid-template-columns: 64px 40px 60px minmax(0, 1fr);
min-height: 26px;
border-bottom: 1px solid #666;
font-size: 12px;
}
.dex-row > * {
display: grid;
align-content: center;
box-sizing: border-box;
border-right: 1px solid #666;
padding: 0 3px;
text-align: center;
}
.dex-row > strong {
font-weight: 400;
}
.dex-value {
text-align: right;
}
.dex-bars {
gap: 1px;
padding: 1px;
}
.empty {
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { computed } from 'vue';
import { dexProgress, legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import LegacyProgressBar from './LegacyProgressBar.vue';
type GeneralProgress = {
stats: { leadership: number; strength: number; intelligence: number };
experience: number;
progression: {
experienceLevel: number;
statExperience: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit: number;
dex: number[];
};
};
const props = defineProps<{ general: GeneralProgress }>();
const statRows = computed(() =>
[
['leadership', '통솔', props.general.stats.leadership, props.general.progression.statExperience.leadership],
['strength', '무력', props.general.stats.strength, props.general.progression.statExperience.strength],
[
'intelligence',
'지력',
props.general.stats.intelligence,
props.general.progression.statExperience.intelligence,
],
].map(([key, label, value, accumulated]) => ({
key: String(key),
label: String(label),
value: Number(value),
accumulated: Number(accumulated),
percent: ratioPercent(Number(accumulated), props.general.progression.statUpgradeLimit),
}))
);
const dexRows = computed(() =>
['보병', '궁병', '기병', '귀병', '차병'].map((label, index) => {
const value = props.general.progression.dex[index] ?? 0;
return { label, value, progress: dexProgress(value) };
})
);
const experiencePercent = computed(() =>
legacyExperiencePercent(props.general.experience, props.general.progression.experienceLevel)
);
</script>
<template>
<div class="legacy-general-progress">
<div class="stat-grid">
<template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${props.general.progression.statUpgradeLimit}`"
/>
</template>
</div>
<div class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression.experienceLevel }}</strong>
<LegacyProgressBar
:percent="experiencePercent"
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
/>
</div>
<div class="dex-title"> </div>
<div v-for="row of dexRows" :key="row.label" class="dex-row" :data-dex-progress="row.label">
<span class="cell-label">{{ row.label }}</span>
<strong :style="{ color: row.progress.color }">{{ row.progress.name }}</strong>
<span>{{ (row.value / 1_000).toFixed(1) }}K</span>
<div class="dex-bars">
<LegacyProgressBar
:percent="row.progress.overallPercent"
:label="`${row.label} 전체 ${row.value.toLocaleString()} / 1,275,975 (EX+)`"
/>
<LegacyProgressBar
:height="7"
variant="grade"
:percent="row.progress.gradePercent"
:label="
row.progress.nextName
? `${row.label} ${row.progress.name}에서 ${row.progress.nextName}까지 ${row.progress.remaining.toLocaleString()} 남음`
: `${row.label} EX+ 달성`
"
/>
</div>
</div>
</div>
</template>
<style scoped>
.legacy-general-progress {
box-sizing: border-box;
width: 100%;
max-width: 100%;
overflow: hidden;
border-top: 1px solid #666;
font-size: 12px;
}
.stat-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) minmax(42px, 1fr));
}
.stat-grid > *,
.experience-row > *,
.dex-row > * {
box-sizing: border-box;
min-width: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 1px 3px;
text-align: center;
}
.stat-grid > .legacy-progress,
.experience-row > .legacy-progress {
align-self: center;
padding: 0;
}
.cell-label,
.dex-title {
background: rgb(20 75 42 / 70%);
}
.experience-row {
display: grid;
min-width: 0;
grid-template-columns: 40px 45px minmax(0, 1fr);
min-height: 22px;
}
.dex-title {
min-height: 20px;
border-bottom: 1px solid #666;
font-weight: 700;
line-height: 20px;
text-align: center;
}
.dex-row {
display: grid;
min-width: 0;
grid-template-columns: 54px 40px 58px minmax(0, 1fr);
min-height: 26px;
}
.dex-bars {
display: grid;
gap: 1px;
padding: 1px;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
import { clampPercent } from '../../utils/legacyProgress';
const props = withDefaults(
defineProps<{
percent: number;
height?: 7 | 10;
label?: string;
variant?: 'overall' | 'grade';
}>(),
{ height: 10, label: undefined, variant: 'overall' }
);
const imageHeight = computed(() => props.height - 2);
const normalizedPercent = computed(() => clampPercent(props.percent));
const fallbackLabel = computed(
() => `${normalizedPercent.value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`
);
const assetRoot = configuredGameAssetUrl();
</script>
<template>
<div
class="legacy-progress"
:class="`legacy-progress--${props.variant}`"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="Number(normalizedPercent.toFixed(1))"
:aria-label="props.label ?? fallbackLabel"
:title="props.label ?? fallbackLabel"
:style="{
height: `${props.height + 2}px`,
backgroundImage: `url('${assetRoot}/pr${imageHeight}.gif')`,
}"
>
<span
class="legacy-progress__fill"
:style="{
width: `${normalizedPercent}%`,
backgroundImage: `url('${assetRoot}/pb${imageHeight}.gif')`,
}"
/>
</div>
</template>
<style scoped>
.legacy-progress {
position: relative;
box-sizing: border-box;
width: 100%;
overflow: hidden;
border-top: 1px solid #888;
border-bottom: 1px solid #333;
background-position: left center;
background-repeat: repeat-x;
}
.legacy-progress__fill {
position: absolute;
inset: 0 auto 0 0;
background-position: left center;
background-repeat: repeat-x;
}
.legacy-progress--grade {
opacity: 0.82;
}
</style>