返回博客
Guide3 分钟阅读

FiveM 库存管理:服务器拥有者专业技巧

提升你的FiveM库存设置。重量配置、堆叠大小、物品图像、储藏设计及顶级角色扮演服务器的调优技巧。

Agency Scripts

Agency Scripts 创始人兼首席开发者

为什么库存架构很重要

库存系统是任何 FiveM 角色扮演服务器的支柱。玩家与物品的每次交互,从拾取武器到交钥匙,都通过库存进行。设计不良的库存导致物品复制漏洞、不同步问题和玩家丢失装备。最稳健的库存系统遵循服务器权威模型,客户端仅显示服务器告知的内容,绝不信任客户端报告自身状态。仅此架构决策就消除了困扰客户端信任库存服务器的大多数复制漏洞。规划库存时,首先考虑数据流:服务器拥有真相,客户端渲染,所有变更均通过验证的服务器事件。

基于重量与基于槽位的系统

在基于重量和基于格子的库存系统之间选择,根本上影响玩家体验。在基于格子的系统中,每个格子只能放一种物品,且有最大堆叠数,总格子数定义携带容量。在基于重量的系统中,每个物品有重量值,玩家有最大携带重量。许多现代框架结合两者,使用格子进行组织,但总容量受重量限制。以下是一个结合重量和格子的物品定义示例:

-- Shared item definitions (items.lua)
QBCore.Shared.Items = {
    ['water_bottle'] = {
        name = 'water_bottle',
        label = 'Water Bottle',
        weight = 500,        -- grams
        type = 'item',
        image = 'water_bottle.png',
        unique = false,
        useable = true,
        shouldClose = true,
        description = 'A refreshing bottle of water',
        stackSize = 10,      -- max per slot
    },
    ['lockpick'] = {
        name = 'lockpick',
        label = 'Lockpick',
        weight = 200,
        type = 'item',
        image = 'lockpick.png',
        unique = false,
        useable = true,
        shouldClose = true,
        description = 'Used to pick locks',
        stackSize = 5,
    },
}

weight 字段以克为单位存储以保证精度,且 stackSize 控制单个槽位中该物品的最大数量。当玩家尝试拾取物品时,验证槽位是否可用且总重量不会超过最大值。此双重验证防止玩家即使有空槽也携带不现实的大量重物品。

服务器端验证和防作弊

每个库存操作必须在服务器端验证后才能生效。当玩家将物品从槽位3拖到槽位7时,客户端发送移动请求,服务器验证源槽位确实包含该物品,目标槽位可接受该物品,且数量一致。绝不允许客户端指定物品数量或凭空创建物品。以下是一个安全的服务器端移动处理器:

RegisterNetEvent('inventory:server:moveItem', function(fromSlot, toSlot, fromAmount)
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    local fromItem = Player.PlayerData.items[fromSlot]
    if not fromItem then
        -- Source slot is empty, possible exploit attempt
        DropPlayer(src, 'Invalid inventory operation')
        return
    end

    if fromAmount > fromItem.amount or fromAmount < 1 then
        DropPlayer(src, 'Invalid inventory amount')
        return
    end

    local toItem = Player.PlayerData.items[toSlot]

    if toItem and toItem.name == fromItem.name and not fromItem.unique then
        -- Stack items together
        local maxStack = QBCore.Shared.Items[fromItem.name].stackSize or 50
        local canStack = maxStack - toItem.amount
        local moveAmount = math.min(fromAmount, canStack)

        if moveAmount > 0 then
            toItem.amount = toItem.amount + moveAmount
            fromItem.amount = fromItem.amount - moveAmount
            if fromItem.amount <= 0 then
                Player.PlayerData.items[fromSlot] = nil
            end
        end
    else
        -- Swap items between slots
        Player.PlayerData.items[toSlot] = fromItem
        Player.PlayerData.items[fromSlot] = toItem
    end

    Player.Functions.SetPlayerData('items', Player.PlayerData.items)
end)

注意 DropPlayer 调用用于明显不可能的操作。将这些事件记录到单独的审计表有助于识别利用尝试和模式。考虑对库存事件实施速率限制,因为合法玩家很少每秒执行超过几次库存操作,而自动化利用通常会快速发送数百个请求。

物品元数据和唯一物品

元数据将简单物品转化为丰富且独特的对象。武器可携带序列号、耐久度及附加改装。电话可存储分配号码和联系人列表引用。食物可有过期时间戳。元数据以 Lua 表序列化为 JSON 存储于数据库,附加于每个物品实例。关键区别在于可堆叠物品共享相同元数据,独特物品各自拥有元数据表。以下是创建带完整元数据武器的方法:

-- Creating a weapon with metadata
function CreateWeaponItem(src, weaponName, serial)
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return false end

    local metadata = {
        serial = serial or GenerateSerial(),
        durability = 100.0,
        ammo = 0,
        attachments = {},
        registered = false,
        registeredTo = nil,
        quality = math.random(85, 100),
        created = os.time(),
    }

    return Player.Functions.AddItem(weaponName, 1, nil, metadata)
end

function GenerateSerial()
    local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    local serial = ''
    for i = 1, 10 do
        local idx = math.random(1, #chars)
        serial = serial .. chars:sub(idx, idx)
    end
    return serial
end

在 NUI 中显示带元数据的物品时,连同物品信息传递元数据,使 UI 能展示耐久条、序列号和质量评级等细节。这增强玩家对物品的连接感,支持高级角色扮演场景,如武器登记系统或法医调查追踪序列号至所有者。

拖放NUI性能

库存 UI 是性能敏感度极高的 NUI 元素之一,因为玩家不断与之交互。避免每次更新时重新渲染整个库存网格。改用虚拟 DOM 方法或针对性元素更新,仅修改发生变化的槽位。玩家拖动物品时,完全使用 JavaScript 的鼠标事件处理拖动,而非在拖动过程中向 Lua 客户端发送位置更新。仅在最终放置时发送单次 NUI 回调。以下是高性能拖动处理模式:

// Inventory NUI - performant drag and drop
let draggedItem = null;
let dragElement = null;

document.addEventListener('mousedown', (e) => {
    const slot = e.target.closest('.inv-slot[data-has-item="true"]');
    if (!slot) return;

    draggedItem = {
        slot: parseInt(slot.dataset.slot),
        item: JSON.parse(slot.dataset.itemInfo),
    };

    dragElement = slot.cloneNode(true);
    dragElement.classList.add('dragging-ghost');
    dragElement.style.position = 'fixed';
    dragElement.style.pointerEvents = 'none';
    dragElement.style.zIndex = '9999';
    document.body.appendChild(dragElement);

    moveDragElement(e.clientX, e.clientY);
});

document.addEventListener('mousemove', (e) => {
    if (!dragElement) return;
    moveDragElement(e.clientX, e.clientY);
});

document.addEventListener('mouseup', (e) => {
    if (!draggedItem) return;

    const targetSlot = e.target.closest('.inv-slot');
    if (targetSlot) {
        const toSlot = parseInt(targetSlot.dataset.slot);
        // Send only the final result to Lua
        fetch(`https://${GetParentResourceName()}/moveItem`, {
            method: 'POST',
            body: JSON.stringify({
                fromSlot: draggedItem.slot,
                toSlot: toSlot,
                amount: draggedItem.item.amount,
            }),
        });
    }

    if (dragElement) dragElement.remove();
    draggedItem = null;
    dragElement = null;
});

对于视觉渲染,使用CSS Grid进行槽位布局,避免对库存物品使用复杂CSS动画,因为玩家可能同时看到数十个槽位。物品图标使用图片精灵比单独图片文件加载更快,减少NUI首次打开时的HTTP请求。

储物与容器系统

除了个人库存,玩家需要访问外部存储,如车辆后备箱、房屋藏匿处和共享组织存储。每种容器类型应有自己的容量限制和访问控制规则。车辆后备箱使用车牌作为唯一标识,房屋藏匿处使用物业ID,工作藏匿处使用工作名称结合等级检查。将容器库存存储在独立于玩家库存的数据库表中,以保持查询效率:

CREATE TABLE IF NOT EXISTS stash_items (
    id INT AUTO_INCREMENT PRIMARY KEY,
    stash_id VARCHAR(100) NOT NULL,
    slot INT NOT NULL,
    item_name VARCHAR(50) NOT NULL,
    amount INT DEFAULT 1,
    metadata LONGTEXT DEFAULT '{}',
    UNIQUE KEY unique_stash_slot (stash_id, slot),
    INDEX idx_stash_id (stash_id)
);

-- Example stash_id values:
-- 'trunk_ABC123'        (vehicle trunk by plate)
-- 'house_42'            (house stash by property id)
-- 'police_evidence_1'   (job stash with identifier)

当玩家打开容器时,从数据库加载其内容并锁定,防止多玩家同时访问。使用服务器端锁表跟踪当前开启的储藏 ID 及持有者。玩家关闭容器或断线时释放锁定。此机制防止经典复制漏洞,即两名玩家同时打开同一后备箱并各自取出相同物品。

库存同步与持久化

在服务器内存、数据库和客户端显示之间同步库存数据需要仔细协调。定期将玩家库存保存到数据库,而不是每次物品变动时保存,以减少数据库写入负载。大多数服务器保存间隔为 30 到 60 秒效果良好。此外,始终在玩家断开连接和服务器关闭时使用保存命令。 playerDropped 事件和关闭处理程序。实现脏标记系统,仅在库存自上次保存后实际更改时写入数据库:

local inventoryDirty = {}

-- Mark inventory as needing save
function MarkDirty(citizenid)
    inventoryDirty[citizenid] = true
end

-- Periodic save loop
CreateThread(function()
    while true do
        Wait(30000) -- 30 seconds
        for citizenid, dirty in pairs(inventoryDirty) do
            if dirty then
                local Player = QBCore.Functions.GetPlayerByCitizenId(citizenid)
                if Player then
                    SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
                end
                inventoryDirty[citizenid] = nil
            end
        end
    end
end)

AddEventHandler('playerDropped', function()
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if Player then
        local citizenid = Player.PlayerData.citizenid
        if inventoryDirty[citizenid] then
            SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
            inventoryDirty[citizenid] = nil
        end
    end
end)

对于客户端,批量处理NUI更新,使多次快速的库存变更(如从制作操作中获得多个物品)只触发一次UI刷新,而非每个物品刷新一次。这样消除玩家在库存格子中物品逐个出现时的闪烁效果。

性能监控与优化

通过跟踪关键指标监控库存系统性能:平均数据库保存时间、事件处理速率和NUI渲染频率。使用FiveM分析器识别库存回调瓶颈。常见性能陷阱包括每帧遍历所有玩家库存以实现基于距离的地面物品拾取、不必要地序列化大型元数据对象,以及关闭库存时仍保持NUI帧活动。对地面物品使用空间网格系统,仅检查玩家所在单元内物品,避免扫描服务器上所有掉落物。将物品定义缓存于按物品名索引的查找表,实现O(1)哈希查找,避免O(n)数组扫描。最后,考虑为容量极大的容器实现库存分页,仅加载可见槽位,玩家滚动时加载额外行,保持数据库查询和NUI渲染轻量,即使仓库有数百个槽位。

准备好开始了吗?

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