feat(game-ui): 토너먼트 조별 순위를 카드로 개선한다

빈 조별 슬롯에도 64px 아이콘 자리와 동일한 높이를 유지한다. 경기 지표는 장수명 옆의 의미 단위 요약으로 재배치하고 desktop/mobile Chromium 검증을 보강한다.
This commit is contained in:
2026-08-22 05:18:46 +00:00
parent d9e34b7ffb
commit cdd81d26c4
5 changed files with 283 additions and 181 deletions
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { GeneralIconSource } from '../../utils/generalIcon';
import GeneralIdentity from '../ui/GeneralIdentity.vue';
type StandingParticipant = {
name: string;
picture?: GeneralIconSource['picture'];
imageServer?: GeneralIconSource['imageServer'];
leadership: number;
strength: number;
intel: number;
win?: number;
draw?: number;
lose?: number;
gl?: number;
};
const props = defineProps<{
groupName: string;
rows: StandingParticipant[];
rowCount: number;
statLabel: string;
tournamentType: number;
}>();
const slots = computed(() =>
Array.from({ length: props.rowCount }, (_, index) => ({ rank: index + 1, participant: props.rows[index] }))
);
const statOf = (participant: StandingParticipant): number => {
if (props.tournamentType === 0) return participant.leadership + participant.strength + participant.intel;
if (props.tournamentType === 1) return participant.leadership;
if (props.tournamentType === 2) return participant.strength;
return participant.intel;
};
const gamesOf = (participant: StandingParticipant): number =>
(participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0);
const pointsOf = (participant: StandingParticipant): number => (participant.win ?? 0) * 3 + (participant.draw ?? 0);
</script>
<template>
<section class="tournament-group-card" :aria-label="`${groupName} 순위`">
<h3>{{ groupName }}</h3>
<ol class="standing-list">
<li
v-for="slot in slots"
:key="slot.rank"
class="standing-row"
:class="{ 'standing-row--empty': !slot.participant }"
:data-empty="!slot.participant"
>
<span class="standing-rank" :aria-label="`${slot.rank}위`">{{ slot.rank }}</span>
<GeneralIdentity
v-if="slot.participant"
:name="slot.participant.name"
:picture="slot.participant.picture"
:image-server="slot.participant.imageServer"
>
<template #details>
<span class="standing-summary">
<span class="standing-summary-line">
<strong>{{ statLabel }} {{ statOf(slot.participant) }}</strong>
<span>· {{ gamesOf(slot.participant) }}경기</span>
</span>
<span class="standing-summary-line standing-summary-line--record">
{{ slot.participant.win ?? 0 }} · {{ slot.participant.draw ?? 0 }} ·
{{ slot.participant.lose ?? 0 }}
</span>
<span class="standing-summary-line">
<strong>{{ pointsOf(slot.participant) }}</strong>
<span>· 득실 {{ slot.participant.gl ?? 0 }}</span>
</span>
</span>
</template>
</GeneralIdentity>
<GeneralIdentity v-else name="빈 자리" placeholder />
</li>
</ol>
</section>
</template>
<style scoped>
.tournament-group-card {
min-width: 0;
border: 1px solid #555;
text-align: left;
}
.tournament-group-card h3 {
margin: 0;
padding: 3px;
background: #000;
color: #fff;
font-size: 14px;
font-weight: 400;
text-align: center;
}
.standing-list {
margin: 0;
padding: 0;
list-style: none;
}
.standing-row {
display: grid;
min-width: 0;
min-height: 68px;
grid-template-columns: 24px minmax(0, 1fr);
align-items: center;
border-top: 1px solid #555;
}
.standing-rank {
align-self: stretch;
display: grid;
place-items: center;
border-right: 1px solid #555;
background: rgb(0 0 0 / 16%);
color: #d6cbc6;
font-variant-numeric: tabular-nums;
text-align: center;
}
.general-identity {
width: 100%;
padding-right: 5px;
justify-content: flex-start;
}
.standing-summary {
display: flex;
min-width: 0;
flex-direction: column;
color: #d8cfca;
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 14px;
white-space: nowrap;
}
.standing-summary-line {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
}
.standing-summary-line strong {
color: #fff;
font-weight: 400;
}
.standing-summary-line--record {
color: #b9d8ed;
}
.standing-row--empty .general-identity {
min-height: 64px;
}
@media (max-width: 800px) {
.standing-row {
grid-template-columns: 28px minmax(0, 1fr);
}
.standing-summary {
font-size: 12px;
line-height: 15px;
}
}
</style>
@@ -8,11 +8,13 @@ const props = withDefaults(
picture?: GeneralIconSource['picture'];
imageServer?: GeneralIconSource['imageServer'];
hideIcon?: boolean;
placeholder?: boolean;
}>(),
{
picture: null,
imageServer: 0,
hideIcon: false,
placeholder: false,
}
);
@@ -25,16 +27,25 @@ const iconUrl = computed(() =>
</script>
<template>
<span class="general-identity">
<span class="general-identity" :class="{ 'general-identity--placeholder': placeholder }">
<img
v-if="!hideIcon && name !== '-'"
v-if="!hideIcon && name !== '-' && !placeholder"
class="general-identity-icon"
:src="iconUrl"
alt=""
aria-hidden="true"
@error="useDefaultGeneralIcon"
/>
<span class="general-identity-name" :title="name">{{ name }}</span>
<span
v-else-if="!hideIcon && placeholder"
class="general-identity-icon general-identity-icon--placeholder"
aria-hidden="true"
/>
<span v-if="$slots.details" class="general-identity-copy">
<span class="general-identity-name" :title="name">{{ name }}</span>
<span class="general-identity-details"><slot name="details" /></span>
</span>
<span v-else class="general-identity-name" :title="name">{{ name }}</span>
</span>
</template>
@@ -56,10 +67,28 @@ const iconUrl = computed(() =>
background: #111;
object-fit: cover;
}
.general-identity-icon--placeholder {
background:
linear-gradient(135deg, transparent 47%, rgb(255 255 255 / 8%) 48%, rgb(255 255 255 / 8%) 52%, transparent 53%),
#17110f;
}
.general-identity-copy {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
}
.general-identity-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.general-identity-details {
min-width: 0;
}
.general-identity--placeholder {
color: #91847e;
}
</style>
+19 -149
View File
@@ -2,8 +2,8 @@
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentGroupCard from '../components/tournament/TournamentGroupCard.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
@@ -101,18 +101,6 @@ const preliminaryGroups = computed(() =>
)
);
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
const statOf = (participant: Snapshot['participants'][number] | undefined): number | '' => {
if (!participant) return '';
const type = snapshot.value?.state?.type ?? 0;
if (type === 0) return participant.leadership + participant.strength + participant.intel;
if (type === 1) return participant.leadership;
if (type === 2) return participant.strength;
return participant.intel;
};
const gamesOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
participant ? (participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0) : '';
const pointsOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
participant ? (participant.win ?? 0) * 3 + (participant.draw ?? 0) : '';
const groupFightLogsAt = (stage: 2 | 4, groupStart: 0 | 10) =>
Array.from({ length: 8 }, (_, index) =>
(snapshot.value?.matches ?? []).find(
@@ -274,51 +262,17 @@ const start = async () => {
{{ groupName }}
</button>
</div>
<section class="group-grid bg0">
<table
<section class="group-grid final-grid bg0">
<TournamentGroupCard
v-for="(group, groupIndex) in groups"
:key="groupIndex"
:group-name="groupNames[groupIndex]!"
:rows="group"
:row-count="4"
:stat-label="typeStatNames[snapshot?.state?.type ?? 0]!"
:tournament-type="snapshot?.state?.type ?? 0"
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
>
<caption>
{{
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rowIndex in 4" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
/>
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
</table>
/>
</section>
<section v-if="showFinalFightLogs" class="fight-log-grid bg0" aria-label="본선 조별 전투 로그">
<article
@@ -353,51 +307,17 @@ const start = async () => {
</button>
</div>
<section class="group-grid preliminary-grid bg0">
<table
<TournamentGroupCard
v-for="(group, groupIndex) in preliminaryGroups"
:key="`preliminary-${groupIndex}`"
:group-name="groupNames[groupIndex]!"
:rows="group"
:row-count="8"
:stat-label="typeStatNames[snapshot?.state?.type ?? 0]!"
:tournament-type="snapshot?.state?.type ?? 0"
:data-preliminary-group="groupIndex"
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
>
<caption>
{{
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
/>
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
</table>
/>
</section>
<section v-if="showPreliminaryFightLogs" class="fight-log-grid bg0" aria-label="예선 조별 전투 로그">
<article
@@ -597,41 +517,6 @@ button:not(.legacy-button):focus-visible {
gap: 8px;
padding: 8px;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
caption {
padding: 3px;
background: #000;
color: #fff;
}
th {
background: #154b2a var(--sammo-texture-green);
font-weight: 400;
}
th,
td {
height: 30px;
border: 1px solid #555;
padding: 1px 3px;
}
.group-grid th:first-child,
.group-grid td:first-child {
width: 24px;
}
.group-grid th:nth-child(2),
.group-grid td:nth-child(2) {
width: 130px;
}
.general-cell {
overflow: hidden;
text-align: left;
}
.group-grid tbody tr:has(.general-identity) td {
height: 66px;
}
.group-tabs {
display: none;
}
@@ -675,31 +560,16 @@ td {
overflow-x: auto;
padding: 6px 0;
}
.group-grid table {
.group-grid > .tournament-group-card {
display: none;
min-width: 370px;
}
.group-grid table.mobile-active {
display: table;
.group-grid > .tournament-group-card.mobile-active {
display: block;
}
.fight-log-grid {
grid-template-columns: minmax(0, 1fr);
padding: 6px 0;
}
.group-grid th,
.group-grid td {
height: 31px;
padding: 1px;
font-size: 11px;
}
.group-grid th:first-child,
.group-grid td:first-child {
width: 22px;
}
.group-grid th:nth-child(2),
.group-grid td:nth-child(2) {
width: 138px;
}
.tournament-guide {
padding: 10px;
font-size: 11px;