feat: add current match killfeed overlay and player stats

This commit is contained in:
devRaGonSa
2026-05-21 18:59:44 +02:00
parent 8a6af3e869
commit 4d7acb6fc5
9 changed files with 1000 additions and 27 deletions

View File

@@ -589,6 +589,118 @@
gap: 14px;
}
.current-match-killfeed {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(5, minmax(0, 1fr));
grid-auto-columns: minmax(0, 1fr);
gap: 10px 12px;
min-height: 250px;
max-height: 320px;
padding: 16px;
overflow: hidden;
border: 1px solid rgba(210, 182, 118, 0.26);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(210, 182, 118, 0.08) 1px, transparent 1px),
linear-gradient(180deg, rgba(183, 201, 125, 0.06) 1px, transparent 1px),
linear-gradient(180deg, rgba(12, 18, 11, 0.96), rgba(7, 10, 7, 0.98));
background-size: 33.333% 100%, 100% 62px, auto;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 18px 38px rgba(0, 0, 0, 0.22);
}
.current-match-killfeed__row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 7px 9px;
overflow: hidden;
border: 1px solid rgba(159, 168, 141, 0.16);
border-radius: 6px;
color: var(--text);
background: rgba(24, 31, 21, 0.86);
animation: current-match-killfeed-enter 180ms ease-out;
}
.current-match-killfeed__row.is-teamkill {
border-color: rgba(210, 182, 118, 0.42);
background: rgba(73, 49, 27, 0.72);
}
.current-match-killfeed__player {
min-width: 0;
overflow: hidden;
font-size: 0.84rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-match-killfeed__player--killer {
color: var(--accent-strong);
}
.current-match-killfeed__player--victim {
color: var(--text-soft);
}
.current-match-killfeed__weapon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 34px;
min-height: 24px;
padding: 0 6px;
border: 1px solid rgba(183, 201, 125, 0.24);
border-radius: 4px;
color: var(--text);
background: rgba(7, 10, 7, 0.8);
font-size: 0.68rem;
font-weight: 800;
line-height: 1;
}
.current-match-killfeed__weapon em {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.current-match-killfeed__teamkill {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 5px;
border: 1px solid rgba(210, 182, 118, 0.36);
border-radius: 4px;
color: var(--accent-warm);
font-size: 0.62rem;
font-weight: 800;
}
@keyframes current-match-killfeed-enter {
from {
opacity: 0;
transform: translateX(8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.historical-comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
@@ -806,6 +918,38 @@
.historical-table {
min-width: 540px;
}
.current-match-killfeed {
grid-template-rows: repeat(8, minmax(0, 1fr));
min-height: 312px;
max-height: 380px;
background-size: 50% 100%, 100% 48px, auto;
}
.current-match-killfeed__row {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
}
.current-match-killfeed__teamkill {
grid-column: 1 / -1;
justify-self: end;
}
}
@media (max-width: 480px) {
.current-match-killfeed {
grid-auto-flow: row;
grid-template-rows: none;
min-height: 0;
max-height: none;
background-size: 100% 48px, 100% 48px, auto;
}
}
@media (prefers-reduced-motion: reduce) {
.current-match-killfeed__row {
animation: none;
}
}
.historical-match-card--clean {

View File

@@ -7,6 +7,13 @@ const CURRENT_MATCH_SCOREBOARDS = Object.freeze({
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es",
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443",
});
const CURRENT_MATCH_KILL_FEED_LIMIT = 15;
const CURRENT_MATCH_WEAPONS = Object.freeze({
"m1 garand": { label: "M1 Garand", glyph: "M1" },
mp40: { label: "MP40", glyph: "MP" },
"m1a1 thompson": { label: "M1A1 Thompson", glyph: "TH" },
unknown: { label: "Arma desconocida", glyph: "?" },
});
document.addEventListener("DOMContentLoaded", () => {
const params = new URLSearchParams(window.location.search);
@@ -20,6 +27,7 @@ document.addEventListener("DOMContentLoaded", () => {
state: document.getElementById("current-match-state"),
grid: document.getElementById("current-match-grid"),
feedTitle: document.getElementById("current-match-feed-title"),
playersTitle: document.getElementById("current-match-players-title"),
mapHero: document.getElementById("current-match-map-hero"),
mapImage: document.getElementById("current-match-map-image"),
mapPlaceholder: document.getElementById("current-match-map-placeholder"),
@@ -34,6 +42,7 @@ document.addEventListener("DOMContentLoaded", () => {
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
const killFeedState = initializeKillFeed(nodes);
const playerStatsState = initializePlayerStats(nodes);
let refreshInFlight = false;
const refresh = async () => {
if (refreshInFlight) {
@@ -44,6 +53,7 @@ document.addEventListener("DOMContentLoaded", () => {
await Promise.allSettled([
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }),
]);
} finally {
refreshInFlight = false;
@@ -71,7 +81,7 @@ async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
try {
const payload = await fetchJson(
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=30`,
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}`,
);
renderKillFeed(payload?.data || {}, nodes, killFeedState);
} catch (error) {
@@ -79,6 +89,17 @@ async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }
}
}
async function loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }) {
try {
const payload = await fetchJson(
`${backendBaseUrl}/api/current-match/players?server=${encodeURIComponent(serverSlug)}`,
);
renderPlayerStats(payload?.data || {}, nodes, playerStatsState);
} catch (error) {
setState(nodes.playerStatsState, "No se pudieron actualizar las estadisticas en vivo.", true);
}
}
function renderCurrentMatch(data, nodes) {
const rawServerName = data.server_name || data.server_slug || "Servidor no disponible";
const serverName = formatServerDisplayName(data, rawServerName);
@@ -117,7 +138,11 @@ function initializeKillFeed(nodes) {
<p class="historical-state" id="current-match-feed-state" aria-live="polite">
Cargando feed de combate...
</p>
<div class="historical-match-list" id="current-match-feed-list"></div>
<section
class="current-match-killfeed"
id="current-match-feed-list"
aria-label="Bajas recientes en la partida actual"
></section>
`,
);
}
@@ -125,6 +150,27 @@ function initializeKillFeed(nodes) {
nodes.feedList = document.getElementById("current-match-feed-list");
return {
byId: new Map(),
visibleSignature: "",
};
}
function initializePlayerStats(nodes) {
const shell = nodes.playersTitle?.closest(".panel__shell");
if (shell) {
shell.insertAdjacentHTML(
"beforeend",
`
<p class="historical-state" id="current-match-player-stats-state" aria-live="polite">
Cargando estadisticas en vivo...
</p>
<div class="historical-table-shell" id="current-match-player-stats-shell" hidden></div>
`,
);
}
nodes.playerStatsState = document.getElementById("current-match-player-stats-state");
nodes.playerStatsShell = document.getElementById("current-match-player-stats-shell");
return {
visibleSignature: "",
};
}
@@ -138,13 +184,21 @@ function renderKillFeed(data, nodes, state) {
state.byId.set(event.event_id, event);
}
});
const events = [...state.byId.values()].sort(compareKillFeedEvents).slice(0, 30);
const events = [...state.byId.values()]
.sort(compareKillFeedEvents)
.slice(0, CURRENT_MATCH_KILL_FEED_LIMIT);
if (events.length === 0) {
nodes.feedList.innerHTML = "";
setState(nodes.feedState, "Todavia no se han detectado bajas en esta partida.");
state.visibleSignature = "";
setState(nodes.feedState, formatKillFeedCoverage(data.scope));
return;
}
nodes.feedList.innerHTML = events.map(renderKillFeedRow).join("");
const visualEvents = [...events].reverse();
const visibleSignature = visualEvents.map((event) => event.event_id).join("|");
if (visibleSignature !== state.visibleSignature) {
nodes.feedList.innerHTML = visualEvents.map(renderKillFeedRow).join("");
state.visibleSignature = visibleSignature;
}
nodes.feedState.textContent = formatKillFeedCoverage(data.scope);
nodes.feedState.classList.remove("historical-state--error");
}
@@ -159,35 +213,124 @@ function compareKillFeedEvents(left, right) {
}
function renderKillFeedRow(event) {
const weapon = resolveKillFeedWeapon(event.weapon);
const teamkillBadge = event.is_teamkill
? '<span class="status-chip status-chip--fallback">TK</span>'
? '<span class="current-match-killfeed__teamkill">TK</span>'
: "";
const eventTime = formatEventTime(event);
return `
<article class="historical-match-card">
<div class="historical-match-card__top">
<div class="historical-match-card__title">
<p>${escapeHtml(eventTime)}</p>
<strong>${escapeHtml(event.killer_name || "Jugador no disponible")}</strong>
</div>
${teamkillBadge}
</div>
<p>
${escapeHtml(event.weapon || "Arma no disponible")}
-> ${escapeHtml(event.victim_name || "Objetivo no disponible")}
</p>
<article class="current-match-killfeed__row${event.is_teamkill ? " is-teamkill" : ""}">
<strong class="current-match-killfeed__player current-match-killfeed__player--killer">
${escapeHtml(event.killer_name || "Jugador no disponible")}
</strong>
<span
class="current-match-killfeed__weapon"
title="${escapeHtml(weapon.label)}"
aria-label="${escapeHtml(weapon.label)}"
>
<span aria-hidden="true">${escapeHtml(weapon.glyph)}</span>
<em>${escapeHtml(weapon.label)}</em>
</span>
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
${escapeHtml(event.victim_name || "Objetivo no disponible")}
</span>
${teamkillBadge}
</article>
`;
}
function formatEventTime(event) {
const timestamp = formatTimestamp(event.event_timestamp);
if (timestamp !== "No disponible") {
return timestamp;
function resolveKillFeedWeapon(value) {
const key = normalizeLookupText(value);
return CURRENT_MATCH_WEAPONS[key] || {
label: String(value || CURRENT_MATCH_WEAPONS.unknown.label),
glyph: CURRENT_MATCH_WEAPONS.unknown.glyph,
};
}
function renderPlayerStats(data, nodes, state) {
const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : [];
if (items.length === 0) {
state.visibleSignature = "";
nodes.playerStatsShell.innerHTML = "";
nodes.playerStatsShell.hidden = true;
setState(
nodes.playerStatsState,
"Todavia no hay estadisticas fiables de jugadores para esta partida.",
);
return;
}
return Number.isFinite(Number(event.server_time))
? `Tiempo servidor ${Number(event.server_time)}`
: "Tiempo no disponible";
const signature = items
.map((item) =>
[
item.player_name,
item.kills,
item.deaths,
item.teamkills,
item.deaths_by_teamkill,
item.favorite_weapon,
item.last_seen_at,
].join(":"),
)
.join("|");
if (signature !== state.visibleSignature) {
nodes.playerStatsShell.innerHTML = renderPlayerStatsTable(items);
state.visibleSignature = signature;
}
nodes.playerStatsShell.hidden = false;
setState(nodes.playerStatsState, "Estadisticas parciales derivadas de eventos recientes.");
}
function sortPlayerStats(items) {
return [...items].sort(
(left, right) =>
toStatNumber(right.kills) - toStatNumber(left.kills) ||
toStatNumber(left.deaths) - toStatNumber(right.deaths) ||
String(left.player_name || "").localeCompare(String(right.player_name || ""), "es", {
sensitivity: "base",
}),
);
}
function renderPlayerStatsTable(items) {
return `
<table class="historical-table historical-table--players">
<thead>
<tr>
<th>Jugador</th>
<th>Equipo</th>
<th>Bajas</th>
<th>Muertes</th>
<th>TK</th>
<th>Muertes TK</th>
<th>Arma frecuente</th>
</tr>
</thead>
<tbody>
${items.map(renderPlayerStatsRow).join("")}
</tbody>
</table>
`;
}
function renderPlayerStatsRow(item) {
return `
<tr>
<td>${escapeHtml(item.player_name || "Jugador no disponible")}</td>
<td>${escapeHtml(item.team || "No disponible")}</td>
<td>${escapeHtml(formatStatNumber(item.kills))}</td>
<td>${escapeHtml(formatStatNumber(item.deaths))}</td>
<td>${escapeHtml(formatStatNumber(item.teamkills))}</td>
<td>${escapeHtml(formatStatNumber(item.deaths_by_teamkill))}</td>
<td>${escapeHtml(item.favorite_weapon || "No disponible")}</td>
</tr>
`;
}
function toStatNumber(value) {
return Number.isFinite(Number(value)) ? Number(value) : 0;
}
function formatStatNumber(value) {
return Number.isFinite(Number(value)) ? String(Number(value)) : "0";
}
function renderCompactMeta(label, value) {
@@ -410,6 +553,9 @@ function formatKillFeedCoverage(scope) {
if (scope === "recent-admin-log-window") {
return "Cobertura parcial desde AdminLog reciente.";
}
if (scope === "no-current-match-events") {
return "Sin bajas recientes asociadas a la partida actual.";
}
return "Todavia no se han detectado bajas en esta partida.";
}