feat(game-ui): redesign settings and tournament layouts

This commit is contained in:
2026-08-13 15:58:37 +00:00
parent 0e0c82afd6
commit ab2360d4d9
12 changed files with 823 additions and 160 deletions
+7
View File
@@ -39,6 +39,13 @@ body {
min-width: 500px;
}
/* These redesigned identity/tournament screens own a true handheld layout. */
#app:has(.responsive-settings-page),
#app:has(#tournament-container),
#app:has(#tournament-betting-container) {
min-width: 320px;
}
body:has(.battle-page),
body:has(.chief-page),
body:has(.global-page),
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import GeneralIdentity from '../ui/GeneralIdentity.vue';
import {
buildTournamentBracket,
type TournamentBracketMatch,
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
:class="{ advanced: bracket.champion.advanced }"
:data-general-id="bracket.champion.id ?? undefined"
>
{{ bracket.champion.name }}
<GeneralIdentity
:name="bracket.champion.name"
:picture="bracket.champion.picture"
:image-server="bracket.champion.imageServer"
:icon-size="24"
/>
</span>
</div>
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="22"
/>
</span>
</div>
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="20"
/>
</span>
</div>
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
@@ -183,9 +199,17 @@ const odds = (id: number | null) => {
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
class="mobile-bracket-name"
:class="{ advanced: slot.advanced }"
:style="{ left: `${mobileX[columnIndex]}px`, top: `${mobileY(columnIndex, slotIndex)}px` }"
:style="{
left: `${(mobileX[columnIndex]! / 390) * 100}%`,
top: `${mobileY(columnIndex, slotIndex)}px`,
}"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="18"
/>
</span>
</template>
</div>
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
white-space: nowrap;
}
.bracket-canvas {
width: 2000px;
min-width: 2000px;
width: 100%;
min-width: 1000px;
max-width: 1200px;
margin: 0 auto;
}
.mobile-bracket {
position: relative;
display: none;
width: 390px;
width: 100%;
max-width: 390px;
height: 544px;
margin: 0 auto;
}
.mobile-bracket svg {
position: absolute;
inset: 0;
width: 390px;
width: 100%;
height: 544px;
}
.mobile-connector {
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
.mobile-bracket-name {
position: absolute;
z-index: 1;
width: 64px;
width: clamp(58px, 18vw, 72px);
overflow: hidden;
transform: translate(-50%, -50%);
border: 1px solid #555;
background: rgb(58 33 24 / 92%);
color: #fff;
font-size: 12px;
line-height: 22px;
min-height: 26px;
padding: 2px;
font-size: 11px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
}
.bracket-name {
overflow: hidden;
padding: 0 3px;
padding: 2px 3px;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
}
@media (max-width: 800px) {
.tournament-bracket {
width: 100vw;
max-width: 100vw;
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.bracket-canvas {
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import { resolveGeneralIconUrl, useDefaultGeneralIcon, type GeneralIconSource } from '../../utils/generalIcon';
const props = withDefaults(
defineProps<{
name: string;
picture?: GeneralIconSource['picture'];
imageServer?: GeneralIconSource['imageServer'];
iconSize?: number;
hideIcon?: boolean;
}>(),
{
picture: null,
imageServer: 0,
iconSize: 28,
hideIcon: false,
}
);
const iconUrl = computed(() =>
resolveGeneralIconUrl({
picture: props.picture,
imageServer: props.imageServer,
})
);
const identityStyle = computed(() => ({ '--general-identity-icon-size': `${props.iconSize}px` }));
</script>
<template>
<span class="general-identity" :style="identityStyle">
<img
v-if="!hideIcon && name !== '-'"
class="general-identity-icon"
:src="iconUrl"
alt=""
aria-hidden="true"
@error="useDefaultGeneralIcon"
/>
<span class="general-identity-name">{{ name }}</span>
</span>
</template>
<style scoped>
.general-identity {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 5px;
vertical-align: middle;
}
.general-identity-icon {
width: var(--general-identity-icon-size);
height: var(--general-identity-icon-size);
flex: 0 0 var(--general-identity-icon-size);
border: 1px solid rgb(255 255 255 / 28%);
background: #111;
object-fit: cover;
}
.general-identity-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,6 +1,8 @@
export interface TournamentBracketParticipant {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
}
export interface TournamentBracketMatch {
@@ -15,6 +17,8 @@ export interface TournamentBracketMatch {
export interface TournamentBracketSlot {
id: number | null;
name: string;
picture: string | null;
imageServer: number;
advanced: boolean;
}
@@ -31,7 +35,13 @@ export interface TournamentBracketModel {
top16: TournamentBracketRound;
}
const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false });
const emptySlot = (): TournamentBracketSlot => ({
id: null,
name: '-',
picture: null,
imageServer: 0,
advanced: false,
});
export const buildTournamentBracket = (
participants: TournamentBracketParticipant[],
@@ -39,19 +49,24 @@ export const buildTournamentBracket = (
winnerId?: number
): TournamentBracketModel => {
const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
const nameOf = (id: number | null): string =>
id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`);
const participantOf = (id: number | null): TournamentBracketParticipant | null =>
id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` });
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
const roundMatches = matches
.filter((match) => match.stage === stage)
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
[match.attackerId, match.defenderId].map((id) => ({
id,
name: nameOf(id),
advanced: match.winnerId === id,
}))
[match.attackerId, match.defenderId].map((id) => {
const participant = participantOf(id);
return {
id,
name: participant?.name ?? '-',
picture: participant?.picture ?? null,
imageServer: participant?.imageServer ?? 0,
advanced: match.winnerId === id,
};
})
);
while (slots.length < slotCount) {
slots.push(emptySlot());
@@ -65,7 +80,9 @@ export const buildTournamentBracket = (
return {
champion: {
id: resolvedWinnerId,
name: nameOf(resolvedWinnerId),
name: participantOf(resolvedWinnerId)?.name ?? '-',
picture: participantOf(resolvedWinnerId)?.picture ?? null,
imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0,
advanced: resolvedWinnerId !== null,
},
final,
+194 -72
View File
@@ -2,6 +2,7 @@
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -13,6 +14,7 @@ const loading = ref(false);
const error = ref<string | null>(null);
const message = ref<string | null>(null);
const amounts = ref<Record<number, number>>({});
const activeRankingPrefix = ref('tt');
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [
'경기 없음',
@@ -58,7 +60,13 @@ const final16Ids = computed(() =>
const candidates = computed(() =>
Array.from({ length: 16 }, (_, index) => {
const id = final16Ids.value[index] ?? 0;
return { id, name: id ? (participantMap.value.get(id)?.name ?? `#${id}`) : '-' };
const participant = id ? participantMap.value.get(id) : null;
return {
id,
name: id ? (participant?.name ?? `#${id}`) : '-',
picture: participant?.picture ?? null,
imageServer: participant?.imageServer ?? 0,
};
})
);
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
@@ -132,58 +140,43 @@ const placeBet = async (targetId: number) => {
:bet-totals="betTotals"
:total-bet="totalAmount"
:show-legend="false"
force-desktop
/>
<section class="candidate-table bg0">
<div class="candidate-row names">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span>
<div class="candidate-grid">
<article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
<GeneralIdentity
:name="candidate.name"
:picture="candidate.picture"
:image-server="candidate.imageServer"
:icon-size="36"
/>
<div class="candidate-return">
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
<span aria-hidden="true">×</span>
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
<span aria-hidden="true">=</span>
<strong class="return-color">{{ expected(candidate.id) }}</strong>
</div>
<div v-if="bettingOpen" class="candidate-actions">
<select
v-model.number="amounts[candidate.id]"
:aria-label="`${candidate.name} 베팅 금액`"
:disabled="!candidate.id"
>
<option :value="10">금10</option>
<option :value="20">금20</option>
<option :value="50">금50</option>
<option :value="100">금100</option>
<option :value="200">금200</option>
<option :value="500">금500</option>
<option :value="1000">최대</option>
</select>
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
</div>
</article>
</div>
<div class="candidate-row ratios">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
ratio(candidate.id)
}}</span>
</div>
<div class="candidate-row multiply">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">×</span>
</div>
<div class="candidate-row labels">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name"></span>
</div>
<div class="candidate-row expected">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
expected(candidate.id)
}}</span>
</div>
<div v-if="bettingOpen" class="candidate-row selects">
<select
v-for="candidate in candidates"
:key="candidate.id || candidate.name"
v-model.number="amounts[candidate.id]"
:aria-label="`${candidate.name} 베팅 금액`"
:disabled="!candidate.id"
>
<option :value="10">금10</option>
<option :value="20">금20</option>
<option :value="50">금50</option>
<option :value="100">금100</option>
<option :value="200">금200</option>
<option :value="500">금500</option>
<option :value="1000">최대</option>
</select>
</div>
<div v-if="bettingOpen" class="candidate-row buttons">
<button
v-for="candidate in candidates"
:key="candidate.id || candidate.name"
type="button"
:disabled="!candidate.id"
@click="placeBet(candidate.id)"
>
베팅!
</button>
</div>
<p>
<p class="candidate-help">
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
<span class="return-color">적중시 환수금</span><br />
<span class="ratio-color">( 베팅후 500 이하일땐 베팅이 불가능합니다. )</span>
@@ -204,8 +197,26 @@ const placeBet = async (targetId: number) => {
<section class="ranking-placeholder bg0">
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
</section>
<div class="ranking-tabs bg0" role="tablist" aria-label="토너먼트 랭킹 종목 선택">
<button
v-for="section in rankings"
:key="`ranking-tab-${section.prefix}`"
type="button"
role="tab"
:aria-selected="activeRankingPrefix === section.prefix"
:class="{ active: activeRankingPrefix === section.prefix }"
@click="activeRankingPrefix = section.prefix"
>
{{ section.title.replaceAll(' ', '') }}
</button>
</div>
<section class="ranking-grid bg0">
<table v-for="section in rankings" :key="section.prefix" class="ranking-table">
<table
v-for="section in rankings"
:key="section.prefix"
class="ranking-table"
:class="{ 'mobile-active': activeRankingPrefix === section.prefix }"
>
<thead>
<tr>
<th colspan="9">{{ section.title }}</th>
@@ -225,7 +236,14 @@ const placeBet = async (targetId: number) => {
<tbody>
<tr v-for="entry in section.entries" :key="entry.generalId">
<td>{{ entry.rank }}</td>
<td>{{ entry.name }}</td>
<td class="ranking-general">
<GeneralIdentity
:name="entry.name"
:picture="entry.picture"
:image-server="entry.imageServer"
:icon-size="24"
/>
</td>
<td>{{ entry.stat }}</td>
<td>{{ entry.games }}</td>
<td>{{ entry.win }}</td>
@@ -259,9 +277,10 @@ const placeBet = async (targetId: number) => {
<style scoped>
.betting-page {
width: 1125px;
height: 1346px;
overflow: hidden;
width: 100%;
max-width: 1200px;
min-width: 0;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
@@ -270,8 +289,8 @@ const placeBet = async (targetId: number) => {
text-align: center;
}
.betting-bracket :deep(.bracket-canvas) {
width: 1125px;
min-width: 1125px;
width: 100%;
min-width: 1000px;
}
.betting-bracket :deep(.bracket-round),
.betting-bracket :deep(.connector-row) {
@@ -353,20 +372,34 @@ const placeBet = async (targetId: number) => {
}
.candidate-table {
border: 1px solid gray;
padding: 10px 0;
font-size: 10px;
padding: 10px;
font-size: 12px;
}
.candidate-row {
.candidate-grid {
display: grid;
grid-template-columns: repeat(16, 70px);
align-items: center;
min-height: 10px;
line-height: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.names {
min-height: 14px;
.candidate-card {
min-width: 0;
padding: 8px;
border: 1px solid #5b504b;
background: rgb(0 0 0 / 26%);
text-align: left;
}
.candidate-return {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
gap: 4px;
margin: 8px 0;
text-align: center;
font-variant-numeric: tabular-nums;
}
.candidate-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 64px;
gap: 6px;
}
.ratios,
.ratio-color {
color: skyblue;
}
@@ -378,7 +411,7 @@ const placeBet = async (targetId: number) => {
color: orange;
}
select,
.buttons button {
.candidate-actions button {
width: 100%;
min-height: 27px;
padding: 2px 1px;
@@ -412,7 +445,7 @@ select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.candidate-table p {
.candidate-help {
min-height: 20px;
margin: 8px 0 0;
font-size: 18px;
@@ -431,11 +464,13 @@ select:disabled {
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(4, 280px);
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
}
.ranking-table {
width: 280px;
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 12px;
@@ -443,7 +478,7 @@ select:disabled {
}
.ranking-table th,
.ranking-table td {
height: 14px;
height: 28px;
padding: 1px;
border: 1px solid #555;
}
@@ -457,12 +492,20 @@ select:disabled {
.ranking-table .bg1 {
background: #213b52;
}
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) {
max-width: 80px;
width: 130px;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ranking-general {
text-align: left;
}
.ranking-tabs {
display: none;
}
.guide {
padding: 10px;
text-align: left;
@@ -470,4 +513,83 @@ select:disabled {
.error {
color: #ff8080;
}
@media (max-width: 800px) {
.betting-page {
max-width: 100%;
font-size: 13px;
}
.title {
height: auto;
min-height: 55px;
}
.state {
font-size: 18px;
}
.section-title,
.ranking-title {
font-size: 20px;
}
.candidate-grid {
grid-template-columns: 1fr;
}
.candidate-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
align-items: center;
gap: 8px 12px;
}
.candidate-return {
margin: 0;
}
.candidate-actions {
grid-column: 1 / -1;
}
.candidate-help {
font-size: 14px;
line-height: 18px;
}
.ranking-placeholder {
display: none;
}
.ranking-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
padding: 8px;
}
.ranking-tabs button {
height: 36px;
margin: 0;
border-radius: 3px;
}
.ranking-tabs button.active {
border-color: #f39c12;
background: #8a5b13;
}
.ranking-grid {
display: block;
overflow-x: auto;
padding: 0;
}
.ranking-table {
display: none;
min-width: 390px;
font-size: 11px;
}
.ranking-table.mobile-active {
display: table;
}
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) {
width: 112px;
max-width: 112px;
}
.guide,
.betting-footer {
padding: 10px;
}
.betting-footer small {
white-space: normal;
}
}
</style>
+73 -8
View File
@@ -167,6 +167,7 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
]
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
@@ -360,7 +361,7 @@ onMounted(() => {
</script>
<template>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
@@ -544,6 +545,16 @@ onMounted(() => {
<span v-if="data.iconChangeAvailableAt" class="hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span>
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
<img
:src="resolveGeneralIconUrl(selectedIcon)"
width="48"
height="48"
alt=""
@error="useDefaultGeneralIcon"
/>
<strong>{{ data.general.name }}</strong>
</div>
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
@@ -678,8 +689,7 @@ onMounted(() => {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
height: 1257.5px;
min-width: 0;
min-height: 0;
margin: 0 auto;
padding: 0;
@@ -798,8 +808,9 @@ button:disabled {
}
.portrait-cell {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
@@ -945,6 +956,21 @@ dt {
gap: 6px;
margin: 6px 0;
}
.selected-general-icon {
display: flex;
max-width: 260px;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
padding: 6px 10px;
border: 1px solid #666;
background: rgb(23 42 82 / 70%);
}
.selected-general-icon img {
flex: 0 0 48px;
object-fit: cover;
}
.general-icon-choice {
display: flex;
align-items: center;
@@ -952,16 +978,55 @@ dt {
}
@media (max-width: 991px) {
.legacy-page {
width: 500px;
height: 1798.34px;
width: 100%;
max-width: 100%;
}
.my-page-mobile-scroll-spacer {
display: block;
height: 100px;
display: none;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.title-row {
height: auto;
min-height: 54px;
}
.general-table {
grid-template-columns: minmax(142px, 38%) minmax(0, 1fr);
}
.portrait-cell {
padding: 8px 6px;
}
.portrait-image {
flex: 0 0 52px;
width: 52px;
height: 52px;
}
dl > div {
grid-template-columns: 62px minmax(0, 1fr);
}
dt,
dd {
padding: 2px 3px;
}
.settings-column {
padding: 10px 12px;
}
.screen-mode-row {
grid-template-columns: 1fr;
gap: 6px;
}
.button-group {
overflow-x: auto;
}
.item-group {
grid-template-columns: repeat(2, 1fr);
}
.custom-css textarea {
width: 100%;
}
}
</style>
+186 -38
View File
@@ -2,6 +2,7 @@
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc';
import { resolveTournamentStageName } from '../utils/tournamentStatus';
@@ -14,8 +15,11 @@ const loading = ref(false);
const error = ref<string | null>(null);
const actionMessage = ref<string | null>(null);
const adminEnabled = ref(false);
const activeFinalGroup = ref(0);
const activePreliminaryGroup = ref(0);
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const typeStatNames = ['종합', '통솔', '무력', '지력'];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => {
@@ -64,6 +68,26 @@ const groups = computed(() =>
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
)
);
const preliminaryGroups = computed(() =>
Array.from({ length: 8 }, (_, index) =>
(snapshot.value?.participants ?? [])
.filter((participant) => participant.groupId === index)
.sort((a, b) => (a.seedRank ?? 99) - (b.seedRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
)
);
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 currentMatch = computed(() => {
const state = snapshot.value?.state;
if (!state || state.stage < 7 || state.stage > 10) return null;
@@ -154,7 +178,6 @@ const start = async () => {
:winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals"
:total-bet="totalBet"
force-desktop
/>
<section v-if="currentMatch" class="fight bg0">
@@ -163,18 +186,35 @@ const start = async () => {
</section>
<section class="section-title groups-title bg2">조별 본선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="본선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`final-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activeFinalGroup === groupIndex"
:class="{ active: activeFinalGroup === groupIndex }"
@click="activeFinalGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid bg0">
<table v-for="(group, groupIndex) in groups" :key="groupIndex">
<table
v-for="(group, groupIndex) in groups"
:key="groupIndex"
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
>
<caption>
{{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex]
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
@@ -186,26 +226,21 @@ const start = async () => {
<tbody>
<tr v-for="rowIndex in 4" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td>
<td>
{{
group[rowIndex - 1]
? (group[rowIndex - 1]!.win ?? 0) +
(group[rowIndex - 1]!.draw ?? 0) +
(group[rowIndex - 1]!.lose ?? 0)
: ''
}}
<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"
:icon-size="24"
/>
</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>
{{
group[rowIndex - 1]
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
: ''
}}
</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
@@ -213,18 +248,35 @@ const start = async () => {
</section>
<section class="section-title groups-title bg2">조별 예선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="예선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`preliminary-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activePreliminaryGroup === groupIndex"
:class="{ active: activePreliminaryGroup === groupIndex }"
@click="activePreliminaryGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid preliminary-grid bg0">
<table v-for="groupIndex in 8" :key="`preliminary-${groupIndex}`">
<table
v-for="(group, groupIndex) in preliminaryGroups"
:key="`preliminary-${groupIndex}`"
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
>
<caption>
{{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1]
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
@@ -236,14 +288,22 @@ const start = async () => {
<tbody>
<tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></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"
:icon-size="24"
/>
</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>
@@ -291,9 +351,10 @@ const start = async () => {
<style scoped>
.legacy-page {
width: 2009px;
height: 1059px;
overflow: hidden;
width: 100%;
max-width: 1200px;
min-width: 0;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
@@ -420,13 +481,15 @@ button:focus-visible {
}
.group-grid {
display: grid;
grid-template-columns: repeat(8, 250px);
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
}
table {
width: 250px;
width: 100%;
border-collapse: collapse;
table-layout: auto;
table-layout: fixed;
}
caption {
padding: 3px;
@@ -439,14 +502,99 @@ th {
}
th,
td {
height: 17px;
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: 92px;
}
.general-cell {
overflow: hidden;
}
.group-tabs {
display: none;
}
.admin-row {
text-align: left;
}
.error-row {
color: #ff8080;
}
@media (max-width: 800px) {
.legacy-page {
max-width: 100%;
font-size: 13px;
}
.legacy-title {
height: auto;
min-height: 55px;
}
.state-row {
font-size: 18px;
}
.section-title {
font-size: 20px;
}
.group-tabs {
display: grid;
grid-template-columns: repeat(8, minmax(44px, 1fr));
overflow-x: auto;
padding: 6px;
gap: 4px;
}
.group-tabs button {
min-width: 44px;
height: 34px;
margin: 0;
border-radius: 3px;
}
.group-tabs button.active {
border-color: #f39c12;
background: #8a5b13;
color: #fff;
}
.group-grid {
display: block;
overflow-x: auto;
padding: 6px 0;
}
.group-grid table {
display: none;
min-width: 370px;
}
.group-grid table.mobile-active {
display: table;
}
.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: 108px;
}
.tournament-guide {
padding: 10px;
font-size: 11px;
line-height: 16px;
}
.tournament-footer {
padding: 10px 0 0;
}
.tournament-footer small {
white-space: normal;
}
}
</style>