feat: 로그 항목 및 에너지 상태 추가

This commit is contained in:
2026-01-23 17:42:27 +00:00
parent 8ea709d7e9
commit f540808514
4 changed files with 173 additions and 24 deletions
@@ -58,6 +58,24 @@ const zMatch = z.object({
defenderId: z.number().int().positive(),
winnerId: z.number().int().positive().optional(),
log: z.array(z.string()).optional(),
logEntries: z
.array(
z.object({
phase: z.number().int().min(0),
attackerEnergy: z.number().int(),
defenderEnergy: z.number().int(),
attackerDamage: z.number().int(),
defenderDamage: z.number().int(),
text: z.string(),
})
)
.optional(),
lastEnergy: z
.object({
attacker: z.number().int(),
defender: z.number().int(),
})
.optional(),
});
const zBetEntry = z.object({
+12
View File
@@ -35,6 +35,18 @@ export interface TournamentMatchEntry {
defenderId: number;
winnerId?: number;
log?: string[];
logEntries?: Array<{
phase: number;
attackerEnergy: number;
defenderEnergy: number;
attackerDamage: number;
defenderDamage: number;
text: string;
}>;
lastEnergy?: {
attacker: number;
defender: number;
};
}
export interface TournamentBetEntry {
+6
View File
@@ -227,10 +227,16 @@ const applyBattle = async (
baseSeed,
});
const lastLogEntry = result.logEntries[result.logEntries.length - 1] ?? null;
const updatedMatch: TournamentMatchEntry = {
...target,
winnerId: result.winnerId ?? undefined,
log: result.log,
logEntries: result.logEntries,
lastEnergy: lastLogEntry
? { attacker: lastLogEntry.attackerEnergy, defender: lastLogEntry.defenderEnergy }
: undefined,
};
const nextMatches = matches.map((entry) => (entry.id === target.id ? updatedMatch : entry));
+137 -24
View File
@@ -4,7 +4,6 @@ import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
type TournamentSnapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
type TournamentBettingSummary = Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>>;
@@ -138,30 +137,66 @@ const currentMatch = computed(() => {
return matches[fallbackIndex] ?? matches.find((match) => !match.winnerId) ?? matches[0];
});
const extractEnergy = (logs?: string[]) => {
if (!logs || logs.length === 0) {
const currentEnergy = computed(() => {
const match = currentMatch.value;
if (!match) {
return null;
}
const line = [...logs].reverse().find((entry) => entry.includes('合')) ?? null;
if (!line) {
return null;
if (match.lastEnergy) {
return match.lastEnergy;
}
const values: number[] = [];
const regex = /<C>(\d+)<\/>/g;
let match: RegExpExecArray | null = null;
while ((match = regex.exec(line)) !== null) {
values.push(Number(match[1]));
const lastEntry = match.logEntries?.[match.logEntries.length - 1];
if (lastEntry) {
return { attacker: lastEntry.attackerEnergy, defender: lastEntry.defenderEnergy };
}
if (values.length < 2) {
return null;
}
return { attacker: values[0], defender: values[1] };
};
return null;
});
const currentEnergy = computed(() => extractEnergy(currentMatch.value?.log));
const formattedLogs = computed(() =>
(currentMatch.value?.log ?? []).map((entry) => formatLog(entry))
);
const currentEnergyStats = computed(() => {
const match = currentMatch.value;
if (!match) {
return null;
}
const lastEnergy = currentEnergy.value;
if (!lastEnergy) {
return null;
}
let maxAttacker = lastEnergy.attacker;
let maxDefender = lastEnergy.defender;
if (match.logEntries && match.logEntries.length > 0) {
for (const entry of match.logEntries) {
if (entry.attackerEnergy > maxAttacker) {
maxAttacker = entry.attackerEnergy;
}
if (entry.defenderEnergy > maxDefender) {
maxDefender = entry.defenderEnergy;
}
}
}
return {
attacker: lastEnergy.attacker,
defender: lastEnergy.defender,
maxAttacker: Math.max(1, maxAttacker),
maxDefender: Math.max(1, maxDefender),
};
});
const attackerEnergyPercent = computed(() => {
const stats = currentEnergyStats.value;
if (!stats) {
return 0;
}
return Math.max(0, Math.min(100, (stats.attacker / stats.maxAttacker) * 100));
});
const defenderEnergyPercent = computed(() => {
const stats = currentEnergyStats.value;
if (!stats) {
return 0;
}
return Math.max(0, Math.min(100, (stats.defender / stats.maxDefender) * 100));
});
const formattedLogs = computed(() => currentMatch.value?.log ?? []);
const finalists = computed(() => {
const matches = stageMatches(7);
@@ -228,8 +263,8 @@ const toggleJoin = async () => {
}
};
const bettingTotals = computed(() => bettingSummary.value?.totals ?? {});
const bettingMine = computed(() => bettingSummary.value?.myTotals ?? {});
const bettingTotals = computed<Record<number, number>>(() => bettingSummary.value?.totals ?? {});
const bettingMine = computed<Record<number, number>>(() => bettingSummary.value?.myTotals ?? {});
const totalBetAmount = computed(() => bettingSummary.value?.totalAmount ?? 0);
const myBetAmount = computed(() => bettingSummary.value?.myAmount ?? 0);
</script>
@@ -293,12 +328,28 @@ const myBetAmount = computed(() => bettingSummary.value?.myAmount ?? 0);
<div class="match-meta">
남은 체력: {{ currentEnergy?.attacker ?? '-' }} / {{ currentEnergy?.defender ?? '-' }}
</div>
<div v-if="currentEnergyStats" class="energy-bars">
<div class="energy-row">
<span class="energy-label">공격</span>
<div class="energy-track">
<div class="energy-fill attacker" :style="{ width: `${attackerEnergyPercent}%` }" />
</div>
<span class="energy-value">{{ currentEnergyStats.attacker }}</span>
</div>
<div class="energy-row">
<span class="energy-label">수비</span>
<div class="energy-track">
<div class="energy-fill defender" :style="{ width: `${defenderEnergyPercent}%` }" />
</div>
<span class="energy-value">{{ currentEnergyStats.defender }}</span>
</div>
</div>
<div class="log-box">
<div
v-for="(entry, idx) in formattedLogs"
:key="idx"
class="log-line"
v-html="entry"
v-text="entry"
/>
</div>
</div>
@@ -433,12 +484,28 @@ const myBetAmount = computed(() => bettingSummary.value?.myAmount ?? 0);
<div class="match-meta">
남은 체력: {{ currentEnergy?.attacker ?? '-' }} / {{ currentEnergy?.defender ?? '-' }}
</div>
<div v-if="currentEnergyStats" class="energy-bars">
<div class="energy-row">
<span class="energy-label">공격</span>
<div class="energy-track">
<div class="energy-fill attacker" :style="{ width: `${attackerEnergyPercent}%` }" />
</div>
<span class="energy-value">{{ currentEnergyStats.attacker }}</span>
</div>
<div class="energy-row">
<span class="energy-label">수비</span>
<div class="energy-track">
<div class="energy-fill defender" :style="{ width: `${defenderEnergyPercent}%` }" />
</div>
<span class="energy-value">{{ currentEnergyStats.defender }}</span>
</div>
</div>
<div class="log-box">
<div
v-for="(entry, idx) in formattedLogs"
:key="idx"
class="log-line"
v-html="entry"
v-text="entry"
/>
</div>
</div>
@@ -684,6 +751,52 @@ const myBetAmount = computed(() => bettingSummary.value?.myAmount ?? 0);
color: rgba(232, 221, 196, 0.7);
}
.energy-bars {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 6px;
}
.energy-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
font-size: 0.8rem;
}
.energy-label {
width: 36px;
color: rgba(232, 221, 196, 0.7);
}
.energy-track {
height: 8px;
background: rgba(201, 164, 90, 0.15);
border-radius: 999px;
overflow: hidden;
}
.energy-fill {
height: 100%;
border-radius: 999px;
}
.energy-fill.attacker {
background: linear-gradient(90deg, rgba(245, 184, 101, 0.9), rgba(245, 184, 101, 0.4));
}
.energy-fill.defender {
background: linear-gradient(90deg, rgba(120, 170, 255, 0.9), rgba(120, 170, 255, 0.4));
}
.energy-value {
width: 40px;
text-align: right;
color: rgba(232, 221, 196, 0.8);
}
.log-box {
padding: 8px;
border: 1px solid rgba(201, 164, 90, 0.2);