1044 lines
43 KiB
Plaintext
1044 lines
43 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="<%= lang %>">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>Match Detail | <%= botName %></title>
|
|
<meta name="description" content="View detailed match scoreboard and battle logs.">
|
|
<link rel="icon" type="image/png" href="/images/transparent_toothlessssss.png">
|
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
|
<link rel="preload" href="/Fonts/symbols_skyquake.ttf" as="font" type="font/ttf" crossorigin="anonymous">
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="/css/output.css">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" media="print" onload="this.media='all'">
|
|
|
|
<style>
|
|
@font-face {
|
|
font-family: 'skyquakesymbols';
|
|
src: url('/Fonts/symbols_skyquake.ttf') format('truetype');
|
|
font-display: swap;
|
|
}
|
|
|
|
body { background: #1b1b1b; min-height: 100vh; }
|
|
.text-accent { color: #F5F5DC; }
|
|
.text-muted { color: #90EE90; }
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%);
|
|
box-shadow: 0 4px 20px rgba(245, 245, 220, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
|
color: #1E1E1E; font-weight: 700;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.btn-primary:hover {
|
|
box-shadow: 0 8px 25px rgba(245, 245, 220, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.game-detail-container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 6rem 1rem 2rem 1rem;
|
|
min-height: calc(100vh - 200px);
|
|
}
|
|
|
|
/* Map Hero */
|
|
.map-hero {
|
|
position: relative;
|
|
border-radius: 1rem;
|
|
overflow: hidden;
|
|
margin-bottom: 2rem;
|
|
min-height: 200px;
|
|
background: rgba(30, 30, 30, 0.8);
|
|
border: 1px solid rgba(144, 238, 144, 0.15);
|
|
}
|
|
.map-hero-bg {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background-size: cover;
|
|
background-position: center;
|
|
opacity: 0.25;
|
|
}
|
|
.map-hero-overlay {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: linear-gradient(to bottom, rgba(27,27,27,0.4), rgba(27,27,27,0.95));
|
|
}
|
|
.map-hero-content {
|
|
position: relative;
|
|
z-index: 1;
|
|
padding: 2.5rem 2rem;
|
|
text-align: center;
|
|
}
|
|
.match-type-badge {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
background: rgba(144, 238, 144, 0.15);
|
|
color: #90EE90;
|
|
border: 1px solid rgba(144, 238, 144, 0.3);
|
|
margin-bottom: 1rem;
|
|
}
|
|
.match-map-name {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.match-squadrons {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 1.5rem;
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.sq-winner { color: #90EE90; text-shadow: 0 0 10px rgba(144, 238, 144, 0.3); font-family: 'skyquakesymbols', 'Inter', sans-serif; }
|
|
.sq-loser { color: rgba(255, 255, 255, 0.7); font-family: 'skyquakesymbols', 'Inter', sans-serif; }
|
|
.sq-vs { color: rgba(255, 255, 255, 0.4); font-size: 1rem; font-weight: 400; }
|
|
.match-meta {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 2rem;
|
|
font-size: 0.9rem;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
flex-wrap: wrap;
|
|
}
|
|
.match-meta-item i { margin-right: 0.4rem; color: #90EE90; }
|
|
|
|
/* Replay Video */
|
|
.video-container {
|
|
display: inline-block;
|
|
max-width: 500px;
|
|
width: 100%;
|
|
border-radius: 0.75rem;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(144, 238, 144, 0.15);
|
|
background: rgba(30, 30, 30, 0.6);
|
|
position: relative;
|
|
}
|
|
.video-container video {
|
|
width: 100%;
|
|
display: block;
|
|
}
|
|
.video-loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 4rem;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
gap: 0.75rem;
|
|
flex-direction: column;
|
|
}
|
|
.video-loading .spinner {
|
|
width: 24px; height: 24px;
|
|
border: 2px solid rgba(144, 238, 144, 0.2);
|
|
border-top-color: #90EE90;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
.video-loading .subtext {
|
|
font-size: 0.8rem;
|
|
color: rgba(255, 255, 255, 0.35);
|
|
}
|
|
.video-fallback {
|
|
padding: 2rem;
|
|
color: rgba(255, 255, 255, 0.4);
|
|
font-size: 0.9rem;
|
|
text-align: center;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 120px;
|
|
}
|
|
|
|
/* Ground / Air toggle */
|
|
.rc-mode-toggle {
|
|
display: none; /* shown via JS when air data available */
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 6px;
|
|
padding: 2px;
|
|
gap: 2px;
|
|
margin-left: auto;
|
|
margin-right: 1rem;
|
|
}
|
|
.rc-mode-toggle.visible { display: inline-flex; }
|
|
.rc-mode-btn {
|
|
padding: 4px 14px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.rc-mode-btn:hover { color: rgba(255, 255, 255, 0.75); }
|
|
.rc-mode-btn.active {
|
|
background: rgba(144, 238, 144, 0.15);
|
|
color: #90EE90;
|
|
box-shadow: 0 0 8px rgba(144, 238, 144, 0.1);
|
|
}
|
|
|
|
/* Scoreboard */
|
|
.scoreboard-section { margin-bottom: 2rem; }
|
|
.teams-detailed {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.5rem;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.teams-detailed { grid-template-columns: 1fr; }
|
|
.match-squadrons { font-size: 1.1rem; gap: 0.75rem; }
|
|
.match-map-name { font-size: 1.8rem; }
|
|
}
|
|
.team-detailed {
|
|
background: linear-gradient(135deg, rgba(62, 78, 62, 0.15) 0%, rgba(44, 44, 44, 0.15) 100%);
|
|
border-radius: 0.75rem;
|
|
padding: 1.25rem;
|
|
border: 1px solid rgba(144, 238, 144, 0.1);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.team-detailed h4 {
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
text-align: center;
|
|
font-family: 'skyquakesymbols', 'Inter', sans-serif;
|
|
}
|
|
.team-detailed.winning-team h4 { color: #90EE90; text-shadow: 0 0 10px rgba(144, 238, 144, 0.3); }
|
|
.team-detailed.losing-team h4 { color: rgba(255, 255, 255, 0.6); }
|
|
|
|
.players-table {
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border-radius: 0.5rem;
|
|
overflow: hidden;
|
|
}
|
|
.player-header {
|
|
display: grid;
|
|
grid-template-columns: 1fr 50px 50px 50px 50px 50px;
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: rgba(144, 238, 144, 0.6);
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
}
|
|
.player-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 50px 50px 50px 50px 50px;
|
|
padding: 0.45rem 0.75rem;
|
|
align-items: center;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
|
transition: background 0.15s ease;
|
|
}
|
|
.player-row:hover { background: rgba(255, 255, 255, 0.04); }
|
|
.player-identity { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
|
|
.player-info { display: flex; flex-direction: column; min-width: 0; }
|
|
.player-name {
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
color: rgb(250, 227, 200);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.player-name a {
|
|
color: inherit;
|
|
text-decoration: none;
|
|
transition: color 0.15s;
|
|
}
|
|
.player-name a:hover { color: #90EE90; text-decoration: underline; }
|
|
.player-squadron-live {
|
|
font-family: 'skyquakesymbols', monospace;
|
|
color: rgba(255, 255, 255, 0.3);
|
|
font-size: 0.8rem;
|
|
margin-right: 0.25rem;
|
|
}
|
|
.player-vehicle {
|
|
font-family: 'skyquakesymbols', 'Inter', sans-serif;
|
|
font-size: 0.7rem;
|
|
color: rgba(255, 255, 255, 0.35);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.player-vehicle.dead { color: rgba(255, 60, 60, 0.4); }
|
|
.vehicle-icon {
|
|
width: 28px; height: 28px;
|
|
object-fit: contain;
|
|
border-radius: 3px;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
padding: 2px;
|
|
filter: brightness(1.1) contrast(1.2);
|
|
flex-shrink: 0;
|
|
}
|
|
.stat-col {
|
|
text-align: center;
|
|
font-size: 0.95rem;
|
|
font-weight: 500;
|
|
color: rgba(255, 255, 255, 0.85);
|
|
}
|
|
.stat-col.stat-kill { color: rgb(60, 255, 60); }
|
|
.stat-col.stat-death { color: rgb(255, 60, 60); }
|
|
.stat-col.stat-cap { color: rgb(255, 255, 0); }
|
|
.stat-col.stat-assist { color: rgb(230, 150, 90); }
|
|
.stat-col.stat-zero { color: rgba(255, 255, 255, 0.2); }
|
|
|
|
/* Logs */
|
|
.logs-section { margin-bottom: 2rem; }
|
|
.log-card {
|
|
background: linear-gradient(135deg, rgba(62, 78, 62, 0.15) 0%, rgba(44, 44, 44, 0.15) 100%);
|
|
border-radius: 0.75rem;
|
|
border: 1px solid rgba(144, 238, 144, 0.1);
|
|
backdrop-filter: blur(10px);
|
|
margin-bottom: 1rem;
|
|
overflow: hidden;
|
|
}
|
|
.log-header {
|
|
padding: 0.75rem 1.25rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
font-weight: 600;
|
|
color: #F5F5DC;
|
|
transition: background 0.15s;
|
|
}
|
|
.log-header:hover { background: rgba(255, 255, 255, 0.03); }
|
|
.log-header > i { color: #90EE90; transition: transform 0.3s; }
|
|
.log-header.expanded > i { transform: rotate(180deg); }
|
|
.log-body {
|
|
display: none;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
padding: 0 1.25rem 1rem;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.8rem;
|
|
line-height: 1.6;
|
|
}
|
|
.log-body.show { display: block; }
|
|
.log-entry { padding: 0.15rem 0; color: rgba(255,255,255,0.4); }
|
|
.log-entry .bl-win { color: rgb(100, 255, 100); }
|
|
.log-entry .bl-lose { color: rgb(255, 100, 100); }
|
|
.log-entry .bl-time { color: rgba(255,255,255,0.25); }
|
|
.log-entry .bl-action { color: rgba(255,255,255,0.4); }
|
|
.log-entry.chat-team { color: rgba(255, 255, 255, 0.7); }
|
|
.log-entry.chat-all { color: rgba(255, 200, 100, 0.9); }
|
|
.log-entry.chat-team.chat-win { color: rgb(100, 255, 100); }
|
|
.log-entry.chat-team.chat-lose { color: rgb(255, 100, 100); }
|
|
|
|
/* Loading state */
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 6rem 2rem;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
}
|
|
.loading-state .spinner {
|
|
width: 40px; height: 40px;
|
|
border: 3px solid rgba(144, 238, 144, 0.2);
|
|
border-top-color: #90EE90;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin: 0 auto 1.5rem;
|
|
}
|
|
|
|
/* Error state */
|
|
.error-state {
|
|
text-align: center;
|
|
padding: 6rem 2rem;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
display: none;
|
|
}
|
|
.error-state i { font-size: 3rem; margin-bottom: 1rem; color: rgba(255, 60, 60, 0.5); }
|
|
|
|
/* Back link */
|
|
.back-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: #90EE90;
|
|
font-size: 0.9rem;
|
|
margin-bottom: 1.5rem;
|
|
text-decoration: none;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.back-link:hover { opacity: 0.8; }
|
|
|
|
/* ── Replay Canvas Viewer ── */
|
|
#replayViewerContainer {
|
|
width: 100%;
|
|
}
|
|
.rc-layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(190px, 230px) minmax(560px, 720px) minmax(190px, 230px);
|
|
width: min(100%, 1210px);
|
|
gap: 0.5rem;
|
|
align-items: start;
|
|
justify-content: center;
|
|
margin: 0 auto;
|
|
}
|
|
@media (max-width: 1120px) {
|
|
.rc-layout { grid-template-columns: 1fr; }
|
|
.rc-panel { max-height: 200px; }
|
|
}
|
|
.rc-panel {
|
|
background: rgba(255,255,255,0.025);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
border-radius: 0.4rem;
|
|
overflow-y: auto;
|
|
font-size: 0.78rem;
|
|
}
|
|
.rc-panel-head {
|
|
padding: 0.5rem 0.6rem 0.4rem;
|
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
position: sticky;
|
|
top: 0;
|
|
background: rgba(27,27,27,0.95);
|
|
backdrop-filter: blur(4px);
|
|
z-index: 1;
|
|
text-align: center;
|
|
}
|
|
.rc-panel-label {
|
|
font-weight: 700;
|
|
font-size: 0.72rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
.rc-clan-tag {
|
|
font-family: 'skyquakesymbols', monospace;
|
|
font-size: 0.85rem;
|
|
letter-spacing: 0;
|
|
text-transform: none;
|
|
}
|
|
.rc-panel-list { padding: 0.25rem 0; }
|
|
.rc-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
padding: 0.35rem 0.6rem;
|
|
cursor: pointer;
|
|
transition: background 0.12s, opacity 0.3s;
|
|
border-left: 2px solid transparent;
|
|
}
|
|
.rc-row:hover, .rc-row.rc-hl {
|
|
background: rgba(255,255,255,0.06);
|
|
}
|
|
.rc-panel-win .rc-row.rc-hl { border-left-color: rgba(0,200,0,0.5); }
|
|
.rc-panel-lose .rc-row.rc-hl { border-left-color: rgba(220,30,30,0.5); }
|
|
.rc-row.rc-dead { opacity: 0.4; }
|
|
.rc-row.rc-gone { opacity: 0.2; cursor: default; }
|
|
.rc-row.rc-dead:hover, .rc-row.rc-gone:hover { background: transparent; }
|
|
.rc-type-icon {
|
|
width: 28px;
|
|
height: 22px;
|
|
object-fit: contain;
|
|
flex-shrink: 0;
|
|
filter: brightness(0) invert(1);
|
|
opacity: 0.55;
|
|
}
|
|
.rc-panel-win .rc-type-icon { filter: brightness(0) invert(1) sepia(1) saturate(3) hue-rotate(80deg); opacity: 0.6; }
|
|
.rc-panel-lose .rc-type-icon { filter: brightness(0) invert(1) sepia(1) saturate(3) hue-rotate(-10deg); opacity: 0.6; }
|
|
.rc-row-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
.rc-row-name {
|
|
color: rgba(255,255,255,0.82);
|
|
font-size: 0.76rem;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.rc-row-veh {
|
|
color: rgba(255,255,255,0.32);
|
|
font-size: 0.65rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.rc-row-status {
|
|
flex-shrink: 0;
|
|
font-size: 0.6rem;
|
|
color: rgba(255,255,255,0.3);
|
|
width: 16px;
|
|
text-align: center;
|
|
}
|
|
.rc-center {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
min-width: 0;
|
|
}
|
|
.rc-canvas {
|
|
width: 100%;
|
|
height: auto;
|
|
border-radius: 0.4rem;
|
|
background: #111;
|
|
cursor: crosshair;
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
.rc-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
width: 100%;
|
|
padding: 0.45rem 0;
|
|
}
|
|
.rc-btn {
|
|
background: rgba(255,255,255,0.07);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
color: rgba(255,255,255,0.6);
|
|
padding: 0.25rem 0.45rem;
|
|
border-radius: 0.2rem;
|
|
cursor: pointer;
|
|
font-size: 0.72rem;
|
|
transition: all 0.12s;
|
|
font-family: inherit;
|
|
}
|
|
.rc-btn:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.85); }
|
|
.rc-play { font-size: 0.8rem; padding: 0.25rem 0.55rem; }
|
|
.rc-speeds { display: flex; gap: 1px; }
|
|
.rc-sp.active {
|
|
background: rgba(245,245,220,0.15);
|
|
color: #F5F5DC;
|
|
border-color: rgba(245,245,220,0.2);
|
|
}
|
|
.rc-scrub {
|
|
flex: 1;
|
|
height: 6px;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
background: linear-gradient(to right, #2a6e2a var(--rc-progress, 0%), rgba(255,255,255,0.12) var(--rc-progress, 0%));
|
|
border-radius: 3px;
|
|
outline: none;
|
|
cursor: pointer;
|
|
padding: 6px 0;
|
|
box-sizing: content-box;
|
|
background-clip: content-box;
|
|
}
|
|
.rc-scrub::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 14px; height: 14px;
|
|
border-radius: 50%;
|
|
background: #90ee90;
|
|
cursor: pointer;
|
|
box-shadow: 0 0 3px rgba(0,0,0,0.5);
|
|
}
|
|
.rc-scrub::-moz-range-thumb {
|
|
width: 14px; height: 14px;
|
|
border-radius: 50%;
|
|
background: #90ee90;
|
|
border: none;
|
|
cursor: pointer;
|
|
box-shadow: 0 0 3px rgba(0,0,0,0.5);
|
|
}
|
|
.rc-time {
|
|
font-size: 0.65rem;
|
|
color: rgba(255,255,255,0.4);
|
|
white-space: nowrap;
|
|
min-width: 65px;
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
/* ── Battle Log (glass morphism) ── */
|
|
.rc-log-wrap {
|
|
width: 100%;
|
|
margin-top: 0.4rem;
|
|
}
|
|
.rc-log {
|
|
max-height: 130px;
|
|
overflow-y: auto;
|
|
padding: 0.6rem 0.8rem;
|
|
border-radius: 0.5rem;
|
|
background: rgba(255,255,255,0.04);
|
|
backdrop-filter: blur(12px);
|
|
-webkit-backdrop-filter: blur(12px);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.04);
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgba(255,255,255,0.1) transparent;
|
|
}
|
|
.rc-log::-webkit-scrollbar { width: 4px; }
|
|
.rc-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
|
.rc-log:empty::after {
|
|
content: 'Waiting for events...';
|
|
color: rgba(255,255,255,0.15);
|
|
font-size: 0.7rem;
|
|
font-style: italic;
|
|
}
|
|
.rc-ev {
|
|
font-size: 0.72rem;
|
|
padding: 0.2rem 0;
|
|
border-bottom: 1px solid rgba(255,255,255,0.03);
|
|
animation: rcFadeIn 0.3s ease-out;
|
|
}
|
|
.rc-ev:last-child { border-bottom: none; }
|
|
.rc-ev-kill { }
|
|
.rc-ev-damage { opacity: 0.55; }
|
|
.rc-ev-time {
|
|
display: inline-block;
|
|
width: 32px;
|
|
color: rgba(255,255,255,0.25);
|
|
font-size: 0.65rem;
|
|
font-variant-numeric: tabular-nums;
|
|
margin-right: 0.4rem;
|
|
}
|
|
.rc-ev-win { color: #5cdf5c; font-weight: 500; }
|
|
.rc-ev-lose { color: #e85555; font-weight: 500; }
|
|
.rc-ev-action { color: rgba(255,255,255,0.35); font-size: 0.68rem; }
|
|
.rc-ev-weapon {
|
|
color: rgba(255,255,255,0.2);
|
|
font-size: 0.62rem;
|
|
margin-left: 0.3rem;
|
|
}
|
|
@keyframes rcFadeIn {
|
|
from { opacity: 0; transform: translateY(4px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="text-white antialiased">
|
|
<%- include('partials/nav', { activePage: 'games' }) %>
|
|
|
|
<div class="game-detail-container">
|
|
<a href="/games" class="back-link"><i class="fas fa-arrow-left"></i> <%= t('nav.games') %></a>
|
|
|
|
<!-- Loading -->
|
|
<div id="loadingState" class="loading-state">
|
|
<div class="spinner"></div>
|
|
<p><%= t('games.loadingMatch') %></p>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div id="errorState" class="error-state">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<h3 id="errorMessage"><%= t('games.matchNotFound') %></h3>
|
|
</div>
|
|
|
|
<!-- Content (hidden until loaded) -->
|
|
<div id="matchContent" style="display: none;">
|
|
<!-- Map Hero -->
|
|
<div class="map-hero" id="mapHero">
|
|
<div class="map-hero-bg" id="mapHeroBg"></div>
|
|
<div class="map-hero-overlay"></div>
|
|
<div class="map-hero-content">
|
|
<div class="match-type-badge" id="matchTypeBadge"></div>
|
|
<div class="match-map-name" id="matchMapName"></div>
|
|
<div class="match-squadrons">
|
|
<span class="sq-winner" id="sqWinner"></span>
|
|
<span class="sq-vs">VS</span>
|
|
<span class="sq-loser" id="sqLoser"></span>
|
|
</div>
|
|
<div class="match-meta" id="matchMeta"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scoreboard -->
|
|
<div class="scoreboard-section">
|
|
<div class="teams-detailed" id="scoreboardContainer"></div>
|
|
</div>
|
|
|
|
<!-- Replay Canvas Viewer (collapsible) -->
|
|
<div class="logs-section">
|
|
<div class="log-card">
|
|
<div class="log-header" onclick="toggleReplayViewer()">
|
|
<span><i class="fas fa-play-circle" style="margin-right: 0.5rem;"></i><%= t('games.replayVideo') %></span>
|
|
<div class="rc-mode-toggle" id="rcModeToggle" onclick="event.stopPropagation()">
|
|
<button class="rc-mode-btn active" data-mode="ground" onclick="switchReplayMode('ground')"><i class="fas fa-crosshairs" style="margin-right: 4px;"></i><%= t('games.modeGround') %></button>
|
|
<button class="rc-mode-btn" data-mode="air" onclick="switchReplayMode('air')"><i class="fas fa-plane" style="margin-right: 4px;"></i><%= t('games.modeAir') %></button>
|
|
</div>
|
|
<i class="fas fa-chevron-down" id="replayViewerToggleIcon"></i>
|
|
</div>
|
|
<div class="log-body" id="replayViewerBody" style="max-height: none; padding: 1rem; font-family: inherit;">
|
|
<div class="video-loading" id="replayLoading">
|
|
<div class="spinner"></div>
|
|
<span><%= t('games.loadingReplay') %></span>
|
|
</div>
|
|
<div id="replayViewerContainer" style="display: none;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs -->
|
|
<div class="logs-section" id="logsSection" style="display: none;">
|
|
<div class="log-card">
|
|
<div class="log-header" onclick="toggleLog('chatLog')">
|
|
<span><i class="fas fa-comment-dots" style="margin-right: 0.5rem;"></i><%= t('games.chatLog') %></span>
|
|
<i class="fas fa-chevron-down" id="chatLogIcon"></i>
|
|
</div>
|
|
<div class="log-body" id="chatLogBody"></div>
|
|
</div>
|
|
<div class="log-card">
|
|
<div class="log-header" onclick="toggleLog('battleLog')">
|
|
<span><i class="fas fa-crosshairs" style="margin-right: 0.5rem;"></i><%= t('games.battleLog') %></span>
|
|
<i class="fas fa-chevron-down" id="battleLogIcon"></i>
|
|
</div>
|
|
<div class="log-body" id="battleLogBody"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<%- include('partials/footer') %>
|
|
|
|
<script>
|
|
window.__lang = '<%= lang %>';
|
|
window.__i18n = <%- localeJson %>;
|
|
window.__t = function(key) {
|
|
var parts = key.split('.'), obj = window.__i18n;
|
|
for (var i = 0; i < parts.length; i++) { obj = obj && obj[parts[i]]; }
|
|
return obj !== undefined ? obj : key;
|
|
};
|
|
window.switchLanguage = function(lang) {
|
|
var next = lang || (document.documentElement.lang === 'en' ? 'ru' : 'en');
|
|
if (next === document.documentElement.lang) return;
|
|
document.cookie = 'lang=' + next + ';path=/;max-age=31536000;SameSite=Lax';
|
|
window.location.reload();
|
|
};
|
|
</script>
|
|
<script src="/js/main.js?v=3"></script>
|
|
<script src="/js/api-client.js?v=3"></script>
|
|
<script src="/js/vehicle-i18n.js"></script>
|
|
<script src="/js/header-search.js?v=2"></script>
|
|
<script src="/js/replay-canvas.js?v=13"></script>
|
|
<script>
|
|
const sessionId = '<%= sessionId %>';
|
|
const __t = window.__t;
|
|
let vehicleIconMappings = null;
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function normalizeSquadronTag(tag) {
|
|
return String(tag || '').replace(/^\[|\]$/g, '').trim();
|
|
}
|
|
|
|
function sameSquadronTag(a, b) {
|
|
const left = normalizeSquadronTag(a).toLowerCase();
|
|
const right = normalizeSquadronTag(b).toLowerCase();
|
|
return Boolean(left && right && left === right);
|
|
}
|
|
|
|
function chatTeamClass(line, replay) {
|
|
if (!line.includes('[TEAM]')) return 'chat-all';
|
|
const m = line.match(/^\[[^\]]*\]\s+\[TEAM\]\s+\[([^\]]*)\]/);
|
|
const squadron = m ? m[1] : '';
|
|
if (sameSquadronTag(squadron, replay.winning_team_squadron)) return 'chat-team chat-win';
|
|
if (sameSquadronTag(squadron, replay.losing_team_squadron)) return 'chat-team chat-lose';
|
|
return 'chat-team';
|
|
}
|
|
|
|
function buildReplayPlayerSquadronMap(replay) {
|
|
const map = new Map();
|
|
for (const team of replay.teams || []) {
|
|
const squadron = normalizeSquadronTag(team.squadron || team.squadron_tagged);
|
|
if (!squadron) continue;
|
|
for (const player of team.players || []) {
|
|
const nick = String(player.nick || '').trim();
|
|
if (nick) map.set(nick, squadron);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function addVictimSquadron(victim, playerSquadrons) {
|
|
if (/^\[[^\]]+\]\s+/.test(victim)) return victim;
|
|
const nameMatch = victim.match(/^(.+?)\s+\(/);
|
|
const victimName = nameMatch ? nameMatch[1].trim() : victim.trim();
|
|
const squadron = playerSquadrons.get(victimName);
|
|
return squadron ? `[${squadron}] ${victim}` : victim;
|
|
}
|
|
|
|
function formatBattleLogLine(line, playerSquadrons = new Map()) {
|
|
// Format: +[TIME] [SQUAD] Player (Vehicle) ACTION [SQUAD] Player (Vehicle)
|
|
// or: -[TIME] [SQUAD] Player (Vehicle) ACTION [SQUAD] Player (Vehicle)
|
|
// Parse: sign[TIME] rest
|
|
const m = line.match(/^([+-])(\[[^\]]*\])\s+(.+)$/);
|
|
if (!m) return escapeHtml(line);
|
|
const isWinnerAction = m[1] === '+';
|
|
const winCls = isWinnerAction ? 'bl-win' : 'bl-lose';
|
|
const loseCls = isWinnerAction ? 'bl-lose' : 'bl-win';
|
|
const time = m[2];
|
|
const rest = m[3];
|
|
// Split on action words
|
|
const actionMatch = rest.match(/^(.+?)\s+(destroyed|damaged|shot down|crashed|killed)\s+(?:\(([^)]*)\)\s+)?(.+)$/i);
|
|
if (!actionMatch) {
|
|
return `<span class="bl-time">${escapeHtml(m[1] + time)}</span> <span class="${winCls}">${escapeHtml(rest)}</span>`;
|
|
}
|
|
const attacker = actionMatch[1];
|
|
const action = actionMatch[2];
|
|
const actionExtra = actionMatch[3] || '';
|
|
const victim = addVictimSquadron(actionMatch[4], playerSquadrons);
|
|
const actionText = actionExtra ? `${action} (${actionExtra})` : action;
|
|
return `<span class="bl-time">${escapeHtml(m[1] + time)}</span> `
|
|
+ `<span class="${winCls}">${escapeHtml(attacker)}</span> `
|
|
+ `<span class="bl-action">${escapeHtml(actionText)}</span> `
|
|
+ `<span class="${loseCls}">${escapeHtml(victim)}</span>`;
|
|
}
|
|
|
|
function normalizeVehicleName(name) {
|
|
if (!name) return name;
|
|
// Mirror BOT/data_parser.py + server.js: drop ace-pilot prefixes
|
|
// and the (TEL) tag, but keep visible decoration glyphs (▄ ◢ ◊
|
|
// etc.) — they're country / event indicators rendered through
|
|
// the skyquake font. Only the Private Use Area gets stripped
|
|
// (truly nothing renders that legibly).
|
|
name = name.replace(/Weizman[\u2019\u0027]s\s*/g, '');
|
|
name = name.replace(/Plagis[\u2019\u0027]\s*/g, '');
|
|
name = name.replace(/\s*\(TEL\)/g, '');
|
|
name = name.replace(/[\uE000-\uF8FF]/g, '');
|
|
return name.trim();
|
|
}
|
|
|
|
function getVehicleIcon(vehicleInternal) {
|
|
if (!vehicleInternal || !vehicleIconMappings) return null;
|
|
const vehicleName = vehicleInternal.toString().toLowerCase();
|
|
if (vehicleIconMappings[vehicleName]) return vehicleIconMappings[vehicleName];
|
|
const variations = [
|
|
vehicleName, vehicleName.replace(/\s+/g, '_'), vehicleName.replace(/\s+/g, '-'),
|
|
vehicleName.replace(/_/g, '-'), vehicleName.replace(/-/g, '_'),
|
|
vehicleName.replace(/\./g, ''), vehicleName.replace(/\./g, '_'), vehicleName.replace(/[^\w\s-]/g, '')
|
|
];
|
|
for (const v of variations) { if (vehicleIconMappings[v]) return vehicleIconMappings[v]; }
|
|
return null;
|
|
}
|
|
|
|
function getMapImage(mapName) {
|
|
if (!mapName) return null;
|
|
const cleanMapName = mapName.replace(/^\s*\[[^\]]+\]\s*/, '').trim();
|
|
const fileName = cleanMapName.replace(/\s+/g, '_').replace(/[^\w\u00C0-\u024F\-_.()]/g, '').replace(/_+/g, '_').replace(/^_|_$/g, '').toLowerCase();
|
|
return `/MAPS/${fileName}.jpg`;
|
|
}
|
|
|
|
function renderTeamTable(players, isWinner) {
|
|
const sorted = [...players].sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
return sorted.map(player => {
|
|
const icon = getVehicleIcon(player.vehicle_internal || player.vehicle_new || player.vehicle || player.vehicleInternal || player.vehicleName);
|
|
const iconHtml = icon ? `<img src="${icon}" alt="" class="vehicle-icon" loading="lazy" onerror="this.style.display='none'">` : '';
|
|
const sqTag = player.squadron_name ? `<span class="player-squadron-live">[${escapeHtml(player.squadron_name)}]</span>` : '';
|
|
const dead = (parseInt(player.deaths) || 0) > 0;
|
|
const veh = normalizeVehicleName(player.vehicle_new || player.vehicle) || 'Unknown';
|
|
const uid = player.uid || player.UID;
|
|
const nameHtml = uid ? `<a href="/players/${uid}">${sqTag}${escapeHtml(player.nick || 'Unknown')}</a>` : `${sqTag}${escapeHtml(player.nick || 'Unknown')}`;
|
|
const ak = player.air_kills || 0, gk = player.ground_kills || 0;
|
|
const ast = player.assists || 0, dth = player.deaths || 0, cap = player.captures || 0;
|
|
|
|
return `<div class="player-row">
|
|
<div class="player-identity">
|
|
${iconHtml}
|
|
<div class="player-info">
|
|
<span class="player-name">${nameHtml}</span>
|
|
<span class="player-vehicle${dead || veh === 'DISCONNECTED' ? ' dead' : ''}" data-vehicle-internal="${escapeHtml(player.vehicle_internal || player.vehicleInternal || '')}">${escapeHtml(veh)}</span>
|
|
</div>
|
|
</div>
|
|
<span class="stat-col ${ak > 0 ? 'stat-kill' : 'stat-zero'}">${ak}</span>
|
|
<span class="stat-col ${gk > 0 ? 'stat-kill' : 'stat-zero'}">${gk}</span>
|
|
<span class="stat-col ${ast > 0 ? 'stat-assist' : 'stat-zero'}">${ast}</span>
|
|
<span class="stat-col ${dth > 0 ? 'stat-death' : 'stat-zero'}">${dth}</span>
|
|
<span class="stat-col ${cap > 0 ? 'stat-cap' : 'stat-zero'}">${cap}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
let replayViewerLoaded = false;
|
|
let replayViewer = null;
|
|
function toggleReplayViewer() {
|
|
const body = document.getElementById('replayViewerBody');
|
|
const icon = document.getElementById('replayViewerToggleIcon');
|
|
const header = icon.closest('.log-header');
|
|
body.classList.toggle('show');
|
|
header.classList.toggle('expanded');
|
|
if (body.classList.contains('show') && !replayViewerLoaded) {
|
|
replayViewerLoaded = true;
|
|
loadReplayViewer();
|
|
}
|
|
}
|
|
|
|
async function loadReplayViewer() {
|
|
const container = document.getElementById('replayViewerContainer');
|
|
const loading = document.getElementById('replayLoading');
|
|
try {
|
|
const resp = await fetch(`/api/match/${sessionId}/replay-canvas`);
|
|
if (!resp.ok) {
|
|
if (resp.status === 404) throw new Error('No paths data found');
|
|
let reason = '';
|
|
try {
|
|
const errJson = await resp.json();
|
|
reason = errJson && (errJson.reason || errJson.error) ? (errJson.reason || errJson.error) : '';
|
|
} catch (_) {}
|
|
throw new Error(reason || `HTTP ${resp.status}`);
|
|
}
|
|
const data = await resp.json();
|
|
if (!data.entities || !data.players) throw new Error('Invalid replay data');
|
|
loading.style.display = 'none';
|
|
container.style.display = 'block';
|
|
if (typeof ReplayCanvas === 'undefined') {
|
|
throw new Error('ReplayCanvas class not loaded');
|
|
}
|
|
replayViewer = new ReplayCanvas(container, data);
|
|
await replayViewer.init();
|
|
// Show toggle if air mode is available
|
|
if (replayViewer.hasAirMode) {
|
|
document.getElementById('rcModeToggle').classList.add('visible');
|
|
}
|
|
} catch (err) {
|
|
loading.style.display = 'block';
|
|
loading.innerHTML = '<div class="video-fallback" style="color:#ff6666;">' + (err.message || 'Unknown error') + '</div>';
|
|
}
|
|
}
|
|
|
|
function switchReplayMode(mode) {
|
|
if (!replayViewer) return;
|
|
replayViewer.setMode(mode);
|
|
document.querySelectorAll('.rc-mode-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
});
|
|
}
|
|
|
|
function toggleLog(logId) {
|
|
const body = document.getElementById(logId + 'Body');
|
|
const icon = document.getElementById(logId + 'Icon');
|
|
const header = icon.closest('.log-header');
|
|
body.classList.toggle('show');
|
|
header.classList.toggle('expanded');
|
|
}
|
|
|
|
async function loadMatchDetail() {
|
|
try {
|
|
const match = await window.apiClient.getMatch(sessionId);
|
|
|
|
// Hide loading, show content
|
|
document.getElementById('loadingState').style.display = 'none';
|
|
document.getElementById('matchContent').style.display = 'block';
|
|
|
|
// Map hero
|
|
const mapName = match.map_name || 'Unknown Map';
|
|
const cleanMapName = mapName.replace(/^\s*\[[^\]]+\]\s*/, '').trim() || 'Unknown Map';
|
|
const mapImg = getMapImage(mapName);
|
|
if (mapImg) document.getElementById('mapHeroBg').style.backgroundImage = `url('${mapImg}')`;
|
|
|
|
const TYPE_LABELS = { sqb: __t('live.squadronBattle'), rb: __t('live.randomBattle') };
|
|
document.getElementById('matchTypeBadge').textContent = TYPE_LABELS[match.game_type] || match.game_type || 'Match';
|
|
document.getElementById('matchMapName').textContent = cleanMapName;
|
|
document.getElementById('sqWinner').textContent = match.winning_tag || match.winning_squadron || 'Unknown';
|
|
document.getElementById('sqLoser').textContent = match.losing_tag || match.losing_squadron || 'Unknown';
|
|
|
|
// Meta
|
|
const endTime = match.endtime_iso ? new Date(match.endtime_iso).toISOString().replace('T', ' ').substring(0, 16) + ' UTC' : 'Unknown';
|
|
document.getElementById('matchMeta').innerHTML = `
|
|
<span class="match-meta-item"><i class="fas fa-clock"></i>${endTime}</span>
|
|
<span class="match-meta-item"><i class="fas fa-fingerprint"></i>${escapeHtml(sessionId)}</span>
|
|
`;
|
|
|
|
// Scoreboard
|
|
const winPlayers = match.winning_team?.players || [];
|
|
const losePlayers = match.losing_team?.players || [];
|
|
const headerHtml = `<div class="player-header">
|
|
<span>${__t('common.player')}</span>
|
|
<span style="text-align:center">${__t('live.air')}</span>
|
|
<span style="text-align:center">${__t('live.gnd')}</span>
|
|
<span style="text-align:center">${__t('live.ast')}</span>
|
|
<span style="text-align:center">${__t('live.dth')}</span>
|
|
<span style="text-align:center">${__t('live.cap')}</span>
|
|
</div>`;
|
|
|
|
document.getElementById('scoreboardContainer').innerHTML = `
|
|
<div class="team-detailed winning-team">
|
|
<h4><i class="fas fa-trophy" style="margin-right: 0.5rem; font-size: 0.9rem;"></i>${escapeHtml(match.winning_tag || match.winning_squadron || 'Unknown')}</h4>
|
|
<div class="players-table">${headerHtml}${renderTeamTable(winPlayers, true)}</div>
|
|
</div>
|
|
<div class="team-detailed losing-team">
|
|
<h4>${escapeHtml(match.losing_tag || match.losing_squadron || 'Unknown')}</h4>
|
|
<div class="players-table">${headerHtml}${renderTeamTable(losePlayers, false)}</div>
|
|
</div>
|
|
`;
|
|
|
|
// Replay data (chat/battle logs)
|
|
if (match.replay_available) {
|
|
loadReplayData();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load match:', error);
|
|
document.getElementById('loadingState').style.display = 'none';
|
|
document.getElementById('errorState').style.display = 'block';
|
|
}
|
|
}
|
|
|
|
async function loadReplayData() {
|
|
try {
|
|
const replay = await window.apiClient.getMatchReplay(sessionId);
|
|
if (!replay.available) return;
|
|
|
|
document.getElementById('logsSection').style.display = 'block';
|
|
|
|
// Add duration/mode to meta
|
|
const metaEl = document.getElementById('matchMeta');
|
|
if (replay.duration) {
|
|
const mins = Math.floor(replay.duration / 60);
|
|
const secs = replay.duration % 60;
|
|
metaEl.innerHTML += `<span class="match-meta-item"><i class="fas fa-hourglass-half"></i>${mins}m ${secs}s</span>`;
|
|
}
|
|
if (replay.mode) {
|
|
metaEl.innerHTML += `<span class="match-meta-item"><i class="fas fa-gamepad"></i>${escapeHtml(replay.mode)}</span>`;
|
|
}
|
|
|
|
// Chat log
|
|
const chatBody = document.getElementById('chatLogBody');
|
|
if (replay.chat_log && replay.chat_log.length > 0) {
|
|
chatBody.innerHTML = replay.chat_log.map(line => {
|
|
const cls = chatTeamClass(line, replay);
|
|
return `<div class="log-entry ${cls}">${escapeHtml(line)}</div>`;
|
|
}).join('');
|
|
} else {
|
|
chatBody.innerHTML = `<div class="log-entry" style="color: rgba(255,255,255,0.3);">${__t('games.noChatLog')}</div>`;
|
|
}
|
|
|
|
// Battle log
|
|
const battleBody = document.getElementById('battleLogBody');
|
|
if (replay.battle_log && replay.battle_log.length > 0) {
|
|
const playerSquadrons = buildReplayPlayerSquadronMap(replay);
|
|
battleBody.innerHTML = replay.battle_log.map(line => {
|
|
return `<div class="log-entry">${formatBattleLogLine(line, playerSquadrons)}</div>`;
|
|
}).join('');
|
|
} else {
|
|
battleBody.innerHTML = `<div class="log-entry" style="color: rgba(255,255,255,0.3);">${__t('games.noBattleLog')}</div>`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load replay data:', error);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
try {
|
|
const iconsRes = await window.apiClient.getVehicleIcons();
|
|
vehicleIconMappings = iconsRes.mappings || {};
|
|
} catch (e) { vehicleIconMappings = {}; }
|
|
loadMatchDetail();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|