Volver al blog
Tutorial10 min de lectura

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.

¿Listo para empezar?

Consigue los scripts en nuestra tienda, o únete al Discord para soporte, novedades y un adelanto de lo que viene.