返回博客
Tutorial3 分钟阅读

FiveM 计分板系统:玩家列表、延迟和职业

构建简洁的 FiveM 记分板。玩家数量、延迟、职业、ID 以及最佳记分板脚本,优雅替代默认 TAB 菜单。

Agency Scripts

Agency Scripts 创始人兼首席开发者

为什么要打造自定义记分板

默认 FiveM 玩家列表功能有限,缺乏角色扮演服务器所需的精致度和信息密度。自定义记分板可显示玩家名及其角色身份、职位、服务器 ID 和连接质量,所有样式匹配服务器品牌。除了美观,自定义记分板让你控制不同玩家组可见信息。普通玩家可能只见角色名和 ID,管理员则见额外字段如 Steam 标识和延迟值。自建还可添加排序、按职位过滤和实时玩家统计,帮助玩家和管理人员有效管理服务器。

资源结构与清单

记分板资源相对轻量,包含用于按键绑定处理和数据收集的客户端脚本、用于聚合玩家数据的服务器脚本,以及用于渲染视觉显示的 NUI 页面。保持资源自包含,除框架外无外部依赖,便于安装和维护。以下是资源清单和文件夹结构:

-- 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'

ui_page directive points to the NUI entry file, and the files 块确保所有NUI资源与资源包捆绑。使用 lua54 启用Lua 5.4功能,如整数除法和按位运算符,这对于更高级的计分板实现中的格式化和数据打包非常有用。

服务器端玩家数据收集

服务器负责构建包含记分板所需所有信息的完整玩家列表。服务器将所有玩家数据汇总到单一表中,并一次性发送给请求客户端,而非客户端逐个查询玩家。此方法扩展性好,因为数据聚合每次请求仅执行一次,而非每个玩家一次。载荷中包含延迟值、职业信息和玩家标识符:

-- 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)

回调会检查请求的玩家是否具有管理员权限,然后才包含诸如Steam名称和标识符等敏感信息。这防止普通玩家通过记分板收集账户信息。 GetConvar 调用检索最大玩家数量,以便记分板显示容量指示,如“32/64 玩家”。

客户端按键绑定和切换逻辑

记分板应在玩家按住特定键时打开,松开时关闭。FiveM提供 RegisterKeyMapping 用于可重新绑定按键的函数,这是优于硬编码按键检查的首选方法,因为它允许玩家通过游戏设置菜单自定义控制。使用按住显示模式,按键按下时打开NUI,松开时关闭,提供流畅且不干扰的体验:

-- 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)

+- 命令名称上的前缀会自动创建按下和释放配对。当玩家按下绑定键时, +scoreboard 命令触发。当他们松开时, -scoreboard 火灾。注意 SetNuiFocus 被调用时 false, false 因为记分板仅显示,不需要鼠标输入。若需可点击、支持排序或过滤的记分板,请传入 true, true 改用并在NUI中添加关闭按钮。

使用HTML和CSS进行NUI渲染

NUI层将玩家列表渲染为样式化表格或卡片布局。使用半透明背景覆盖游戏屏幕,但不完全阻挡视野。CSS Grid或Flexbox适合对齐数据列。对ping值进行颜色编码,方便玩家快速识别连接质量,绿色代表良好,黄色代表中等,红色代表差。以下是核心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>

为记分板设置深色半透明背景和固定定位,使其保持屏幕居中。为玩家列表添加最大高度和溢出滚动,防止200名以上玩家的服务器将记分板推离屏幕。考虑使用CSS过渡添加细微的淡入动画,使打开和关闭感觉更流畅而非突兀。

添加搜索和筛选功能

对于拥有大量玩家的服务器,搜索和过滤记分板变得必不可少。在顶部添加搜索输入,按玩家输入实时过滤玩家列表,支持按名称、服务器ID或职业头衔过滤。过滤完全在NUI端用JavaScript实现,无需服务器往返延迟。还可以添加可点击的列标题,实现按ID、名称、职业或延迟排序:

// 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 + ' shown'; } 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); }); });

排序状态保存在客户端,因此切换升序和降序即时响应。职位名称旁的值班指示点快速显示玩家是否在岗,管理员扫描列表检查各部门人员配置时尤为有用。

管理员功能及扩展信息

当管理员打开记分板时,应看到普通玩家无权访问的额外列和操作按钮。在玩家行上添加右键上下文菜单,允许管理员直接踢出、封禁、传送至或观察玩家。此举将记分板从简单显示转变为轻量级管理面板。权限检查在客户端和服务器端均实现。客户端决定是否渲染管理员 UI 元素,服务器独立验证每个管理员操作:

-- 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)

始终将管理员操作记录到数据库或 Discord webhook 以确保责任追踪。包括管理员标识、目标玩家、执行操作及时间戳。此审计记录对于解决员工间纠纷及检测滥用权限的被攻破管理员账户至关重要。

性能与优化

导致帧率下降的记分板失去意义。最常见的性能错误是每帧或每几毫秒刷新玩家数据。自动刷新间隔不应短于三到五秒,因为玩家数据如延迟和职位状态变化不够频繁,无法支持更快更新。NUI 端避免每次刷新销毁并重建整个 DOM。应采用差异更新方法,仅更新变更的行,或至少使用 innerHTML 仅对玩家列表容器应用,而非整个记分板。保持CSS简单,避免在每行使用模糊或阴影等重效果,因为这会在数百行时触发昂贵的GPU合成操作。对于200名以上的大型服务器,实现虚拟滚动,只渲染可见行及少量缓冲,保持DOM节点数量可控。请在满员服务器上测试记分板性能,因为10人时无碍的功能在200人时可能成为瓶颈。

准备好开始了吗?

在我们的商店获取脚本,或加入 Discord 获取支持、更新以及新功能预告。