Sistema de marcador para FiveM: lista de jugadores, ping y trabajos
Crea un marcador claro para FiveM. Número de jugadores, ping, trabajos, ID y los mejores scripts para sustituir con estilo el menú TAB predeterminado.
Agency Scripts
Fundador y desarrollador principal en Agency Scripts
Por qué crear un scoreboard personalizado
La lista de jugadores predeterminada de FiveM es funcional, pero le falta el acabado y la cantidad de información que necesitan los servidores de roleplay. Un scoreboard personalizado te permite mostrar los nombres de los jugadores junto con su identidad dentro del personaje, puesto de trabajo, ID del servidor y calidad de conexión, todo con un estilo que combine con la imagen de tu servidor. Más allá de la estética, un scoreboard personalizado te permite controlar qué información pueden ver los distintos grupos de jugadores. Los jugadores normales podrían ver los nombres de los personajes y sus ID, mientras que los administradores ven campos adicionales, como los identificadores de Steam y los valores de ping. Crear el tuyo también te permite añadir funciones como ordenar, filtrar por trabajo y mostrar estadísticas de jugadores en tiempo real, lo que ayuda tanto a los jugadores como al personal a gestionar el servidor de forma eficaz.
Estructura del recurso y manifest
Un recurso de scoreboard es relativamente ligero: consta de un script de cliente para gestionar las teclas y recopilar datos, un script de servidor para reunir los datos de los jugadores y una página NUI para mostrar la interfaz visual. Mantener el recurso independiente, sin dependencias externas aparte de tu framework, facilita su instalación y mantenimiento. Esta es la estructura de carpetas y el manifest del recurso:
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
description 'Custom Scoreboard System'
author 'Agency Scripts'
version '1.0.0'
ui_page 'html/index.html'
client_scripts {
'client/main.lua',
}
server_scripts {
'server/main.lua',
}
files {
'html/index.html',
'html/style.css',
'html/script.js',
}
lua54 'yes'
El ui_page apunta al archivo de entrada de NUI, y el files bloque garantiza que todos los recursos de NUI estén incluidos en el recurso. Usar lua54 activa las funciones de Lua 5.4, como la división entera y los operadores bitwise, que pueden ser útiles para dar formato y empaquetar datos en implementaciones de scoreboard más avanzadas.
Recopilación de datos de jugadores en el servidor
El servidor se encarga de crear la lista completa de jugadores con toda la información que necesita mostrar el scoreboard. En lugar de que el cliente consulte a cada jugador por separado, el servidor reúne todos los datos en una sola tabla y los envía al cliente que los solicita en un único paquete. Este enfoque escala bien porque los datos se recopilan una vez por solicitud, en lugar de una vez por jugador. Incluye en el paquete los valores de ping, la información del trabajo y los identificadores de los jugadores:
-- server/main.lua
local QBCore = exports['qb-core']:GetCoreObject()
QBCore.Functions.CreateCallback('scoreboard:server:getPlayers', function(source, cb)
local players = {}
local srcPlayer = QBCore.Functions.GetPlayer(source)
local isAdmin = srcPlayer and IsPlayerAceAllowed(source, 'admin')
for _, playerId in ipairs(GetPlayers()) do
local Player = QBCore.Functions.GetPlayer(tonumber(playerId))
if Player then
local playerData = {
serverId = playerId,
name = Player.PlayerData.charinfo.firstname .. ' ' ..
Player.PlayerData.charinfo.lastname,
job = Player.PlayerData.job.label or 'Unemployed',
jobGrade = Player.PlayerData.job.grade.name or '',
onDuty = Player.PlayerData.job.onduty,
ping = GetPlayerPing(playerId),
}
-- Only include sensitive data for admins
if isAdmin then
playerData.steamName = GetPlayerName(playerId)
playerData.identifiers = GetPlayerIdentifiers(playerId)
end
table.insert(players, playerData)
end
end
-- Sort by server ID
table.sort(players, function(a, b)
return tonumber(a.serverId) < tonumber(b.serverId)
end)
cb(players, #players, GetConvar('sv_maxclients', '64'))
end)
El callback comprueba si el jugador que realiza la solicitud tiene permisos de administrador antes de incluir información sensible, como los nombres e identificadores de Steam. Así se evita que los jugadores normales recopilen información de las cuentas mediante el scoreboard. La GetConvar llamada obtiene el número máximo de jugadores para que el scoreboard pueda mostrar un indicador de capacidad como "32/64 jugadores".
Lógica de teclas y alternancia en el cliente
El scoreboard debe abrirse cuando el jugador mantiene pulsada una tecla específica y cerrarse cuando la suelta. FiveM proporciona la función RegisterKeyMapping para reasignar teclas, que es la opción recomendada frente a comprobar teclas codificadas directamente, porque permite que los jugadores personalicen sus controles desde el menú de ajustes del juego. Usa un patrón de mantener pulsado para mostrar: abre la NUI al pulsar la tecla y ciérrala al soltarla, para ofrecer una experiencia fluida y discreta:
-- client/main.lua
local QBCore = exports['qb-core']:GetCoreObject()
local isOpen = false
local refreshInterval = 5000 -- ms between data refreshes while open
RegisterKeyMapping('+scoreboard', 'Open Scoreboard', 'keyboard', 'HOME')
RegisterCommand('+scoreboard', function()
if isOpen then return end
isOpen = true
RefreshAndShow()
end, false)
RegisterCommand('-scoreboard', function()
if not isOpen then return end
isOpen = false
SetNuiFocus(false, false)
SendNUIMessage({action = 'hide'})
end, false)
function RefreshAndShow()
QBCore.Functions.TriggerCallback('scoreboard:server:getPlayers',
function(players, count, maxPlayers)
SendNUIMessage({
action = 'show',
players = players,
playerCount = count,
maxPlayers = maxPlayers,
serverName = 'My Roleplay Server',
})
end
)
end
-- Auto-refresh while scoreboard is open
CreateThread(function()
while true do
Wait(refreshInterval)
if isOpen then
RefreshAndShow()
end
end
end)
El + y - Los prefijos en los nombres de los comandos crean automáticamente un par de pulsación y liberación. Cuando el jugador pulsa la tecla asignada, se ejecuta el comando +scoreboard . Cuando la suelta, se ejecuta -scoreboard . Observa que SetNuiFocus se llama con false, false porque el scoreboard solo muestra información y no necesita entrada del ratón. Si quieres un scoreboard en el que se pueda hacer clic para ordenar o filtrar, pasa true, true en su lugar y añade un botón para cerrarlo en la NUI.
Renderizado de NUI con HTML y CSS
La capa NUI muestra la lista de jugadores como una tabla con estilo o como tarjetas. Usa un fondo semitransparente que se superponga a la pantalla del juego sin bloquear por completo la vista. CSS Grid o Flexbox funcionan bien para alinear las columnas de datos. Asigna colores a los valores de ping para que los jugadores puedan identificar rápidamente la calidad de la conexión: verde para un ping bueno, amarillo para uno moderado y rojo para uno malo. Esta es la estructura básica de la NUI:
<!-- html/index.html -->
<div id="scoreboard" class="hidden">
<div class="sb-header">
<h2 id="server-name"></h2>
<span id="player-count"></span>
</div>
<div class="sb-columns">
<span>ID</span>
<span>Name</span>
<span>Job</span>
<span>Ping</span>
</div>
<div id="player-list"></div>
</div>
<script>
window.addEventListener('message', (event) => {
const data = event.data;
if (data.action === 'show') {
const sb = document.getElementById('scoreboard');
sb.classList.remove('hidden');
document.getElementById('server-name').textContent = data.serverName;
document.getElementById('player-count').textContent =
data.playerCount + '/' + data.maxPlayers + ' Players';
const list = document.getElementById('player-list');
list.innerHTML = data.players.map(p => {
const pingClass = p.ping < 80 ? 'ping-good' :
p.ping < 150 ? 'ping-warn' : 'ping-bad';
return `<div class="sb-row">
<span class="sb-id">${p.serverId}</span>
<span class="sb-name">${p.name}</span>
<span class="sb-job">${p.job}</span>
<span class="sb-ping ${pingClass}">${p.ping}ms</span>
</div>`;
}).join('');
}
if (data.action === 'hide') {
document.getElementById('scoreboard').classList.add('hidden');
}
});
</script>
Dale estilo al scoreboard con un fondo oscuro semitransparente y una posición fija para que permanezca centrado en la pantalla. Añade una altura máxima y desplazamiento al listado de jugadores para que los servidores con 200 jugadores o más no hagan que el scoreboard se salga de la pantalla. Considera añadir una animación sutil de aparición gradual con transiciones CSS para que la apertura y el cierre se sientan refinados en lugar de bruscos.
Añadir funciones de búsqueda y filtrado
En servidores con muchos jugadores, buscar y filtrar el scoreboard se vuelve imprescindible. Añade un campo de búsqueda en la parte superior que filtre la lista de jugadores por nombre, ID del servidor o puesto de trabajo a medida que el jugador escribe. Implementa el filtro por completo en JavaScript, en el lado de la NUI, para evitar retrasos de ida y vuelta al servidor. También puedes añadir encabezados de columna en los que se pueda hacer clic para ordenar por ID, nombre, trabajo o ping:
// html/script.js - Search and sort logic
let currentPlayers = [];
let sortField = 'serverId';
let sortAsc = true;
function renderPlayers(filter = '') {
let filtered = currentPlayers;
if (filter) {
const term = filter.toLowerCase();
filtered = currentPlayers.filter(p =>
p.name.toLowerCase().includes(term) ||
p.serverId.toString().includes(term) ||
p.job.toLowerCase().includes(term)
);
}
filtered.sort((a, b) => {
let valA = a[sortField];
let valB = b[sortField];
if (typeof valA === 'string') {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return sortAsc ? -1 : 1;
if (valA > valB) return sortAsc ? 1 : -1;
return 0;
});
const list = document.getElementById('player-list');
list.innerHTML = filtered.map(p => {
const pingClass = p.ping < 80 ? 'ping-good' :
p.ping < 150 ? 'ping-warn' : 'ping-bad';
const dutyDot = p.onDuty
? ''
: '';
return `
${p.serverId}
${p.name}
${dutyDot}${p.job}
${p.ping}ms
`;
}).join('');
document.getElementById('filtered-count').textContent =
filtered.length + ' mostrados';
}
document.querySelectorAll('.sb-sort').forEach(btn => {
btn.addEventListener('click', () => {
const field = btn.dataset.field;
if (sortField === field) {
sortAsc = !sortAsc;
} else {
sortField = field;
sortAsc = true;
}
renderPlayers(document.getElementById('sb-search').value);
});
});
El estado del orden se mantiene en el cliente, por lo que cambiar entre orden ascendente y descendente es instantáneo. El punto indicador de servicio junto al nombre del trabajo muestra rápidamente si un jugador está de servicio, algo especialmente útil para que los administradores revisen la lista y comprueben los niveles de personal de los distintos departamentos.
Funciones de administración e información ampliada
Cuando un administrador abre la tabla de puntuaciones, debería ver columnas adicionales y botones de acción a los que los jugadores normales no tienen acceso. Añade un menú contextual con clic derecho en las filas de jugadores que permita a los administradores expulsar, bloquear, teletransportarse hasta un jugador o entrar en modo espectador directamente desde la tabla de puntuaciones. Así, la tabla pasa de ser una simple pantalla a convertirse en un panel de administración ligero. Implementa la comprobación de permisos tanto en el cliente como en el servidor. El cliente comprueba si debe mostrar elementos de la interfaz de administración, y el servidor valida cada acción administrativa de forma independiente:
-- Admin action handler (server)
RegisterNetEvent('scoreboard:server:adminAction', function(targetId, action)
local src = source
if not IsPlayerAceAllowed(src, 'admin') then
DropPlayer(src, 'Unauthorized admin action')
return
end
local target = tonumber(targetId)
if not target or not GetPlayerName(target) then return end
if action == 'kick' then
DropPlayer(target, 'Kicked by admin')
elseif action == 'teleport' then
local targetPed = GetPlayerPed(target)
local coords = GetEntityCoords(targetPed)
TriggerClientEvent('scoreboard:client:teleport', src, coords)
elseif action == 'spectate' then
TriggerClientEvent('scoreboard:client:spectate', src, target)
elseif action == 'freeze' then
local targetPed = GetPlayerPed(target)
FreezeEntityPosition(targetPed, true)
SetTimeout(30000, function()
if DoesEntityExist(targetPed) then
FreezeEntityPosition(targetPed, false)
end
end)
end
end)
Registra siempre las acciones administrativas en una base de datos o en un webhook de Discord para garantizar la rendición de cuentas. Incluye el identificador del administrador, el jugador objetivo, la acción realizada y una marca de tiempo. Este registro de auditoría es esencial para resolver disputas entre miembros del equipo y detectar cuentas de administrador vulneradas que puedan estar abusando de sus privilegios.
Rendimiento y optimización
Una tabla de puntuaciones que provoca caídas de fotogramas incumple su propósito. El error de rendimiento más común es actualizar los datos de los jugadores en cada fotograma o cada pocos milisegundos. El intervalo de actualización automática no debería ser inferior a tres o cinco segundos, ya que los datos de los jugadores, como el ping y el estado laboral, no cambian lo bastante rápido como para justificar actualizaciones más frecuentes. En el lado de NUI, evita destruir y recrear todo el DOM en cada actualización. En su lugar, usa un método de comparación que actualice solo las filas que hayan cambiado o, como mínimo, usa innerHTML solo en el contenedor de la lista de jugadores, en lugar de en toda la tabla de puntuaciones. Mantén el CSS simple y evita efectos pesados como blur o box-shadow en cada fila, ya que provocan operaciones costosas de composición de GPU cuando se multiplican por cientos de filas. En servidores muy grandes, con 200 jugadores o más, implementa el desplazamiento virtual para mostrar solo las filas visibles y un pequeño búfer, manteniendo bajo control la cantidad de nodos del DOM independientemente del número de jugadores conectados. Prueba la tabla de puntuaciones en un servidor lleno para medir el impacto real en el rendimiento, ya que una función que va bien con 10 jugadores puede convertirse en un cuello de botella con 200.