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 даже для складов с сотнями слотов.