Назад к блогу
Guide8 мин чтения

FiveM Inventory Management: Профессиональные советы для владельцев серверов

Улучшите свою систему инвентаря 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 с перетаскиванием

Интерфейс инвентаря , один из самых чувствительных к производительности элементов 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-анимаций для предметов инвентаря, так как у игроков может быть одновременно видимо десятки слотов. Спрайты изображений для иконок предметов загружаются быстрее, чем отдельные файлы, и уменьшают количество HTTP-запросов при первом открытии NUI.

Системы тайников и контейнеров

Помимо личного инвентаря, игрокам нужен доступ к внешнему хранилищу, такому как багажники автомобилей, тайники в домах и общие хранилища организаций. Каждый тип контейнера должен иметь свои ограничения по вместимости и правила доступа. Багажники автомобилей используют номерной знак как уникальный идентификатор, тайники домов , 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 обработчик события и обработчик завершения работы. Реализуйте систему dirty flag, которая записывает в базу данных только тогда, когда инвентарь действительно изменился с момента последнего сохранения:

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, чтобы несколько быстрых изменений инвентаря, таких как получение нескольких предметов в результате крафта, приводили к одному обновлению интерфейса, а не к обновлению на каждый предмет. Это устраняет мерцание, которое игроки видят, когда предметы появляются по одному в сетке инвентаря.

Мониторинг и оптимизация производительности

Отслеживайте производительность системы инвентаря, контролируя ключевые показатели: среднее время сохранения в базе данных, скорость обработки событий и частоту рендеринга NUI. Используйте FiveM profiler для выявления узких мест в обратных вызовах инвентаря. Частые ошибки производительности включают перебор всех инвентарей игроков каждый кадр для функций, основанных на близости, таких как подбор предметов с земли, ненужную сериализацию больших объектов метаданных и оставление активных NUI-кадров при закрытом инвентаре. Для предметов на земле используйте систему пространственной сетки, которая проверяет только предметы в ячейке игрока, а не сканирует все сброшенные предметы на сервере. Кэшируйте определения предметов в таблице поиска с индексом по имени предмета, чтобы получение свойств было операцией O(1) хэш-поиска, а не O(n) перебора массива. Наконец, рассмотрите возможность реализации пагинации инвентаря для контейнеров с очень большой вместимостью, загружая только видимые слоты и подгружая дополнительные строки по мере прокрутки игроком, что сохраняет лёгкость запросов к базе данных и рендеринга NUI даже для складов с сотнями слотов.

Готовы начать?

Возьмите скрипты в нашем магазине или заходите в Discord за поддержкой, обновлениями и анонсами.