Wróć do bloga
Guide8 min czytania

Zarządzanie ekwipunkiem w FiveM: profesjonalne wskazówki dla właścicieli serwerów

Wznieś konfigurację ekwipunku FiveM na wyższy poziom. Konfiguracja wag, wielkości stosów, grafiki przedmiotów, wygląd schowków oraz wskazówki dotyczące optymalizacji stosowane przez czołowe serwery roleplay.

Agency Scripts

Założyciel i główny deweloper w Agency Scripts

Dlaczego architektura ekwipunku ma znaczenie

System ekwipunku to fundament każdego serwera roleplay FiveM. Każda interakcja gracza z przedmiotami, od podniesienia broni po przekazanie komuś klucza, przechodzi przez ekwipunek. Źle zaprojektowany ekwipunek prowadzi do exploitów duplikowania przedmiotów, problemów z desynchronizacją i sfrustrowanych graczy tracących swój ekwipunek. Najbardziej solidne systemy ekwipunku opierają się na modelu server-authoritative, w którym klient wyświetla tylko to, co przekazuje mu serwer, nigdy nie ufając klientowi w kwestii zgłaszania własnego stanu. Ta decyzja architektoniczna eliminuje większość exploitów na duplikowanie, które nękają serwery z ekwipunkami ufającymi klientowi. Planując swój ekwipunek, pomyśl najpierw o przepływie danych: serwer posiada prawdę, klient ją renderuje, a każda mutacja przechodzi przez zweryfikowane zdarzenie serwerowe.

Systemy wagowe a slotowe

Wybór między systemem ekwipunku opartym na wadze a opartym na slotach zasadniczo kształtuje wrażenia graczy. W systemie opartym na slotach każdy slot mieści jeden typ przedmiotu z maksymalnym limitem stosu, a całkowita liczba slotów określa pojemność. W systemie opartym na wadze każdy przedmiot ma określoną wagę, a gracz ma maksymalny udźwig. Wiele nowoczesnych frameworków łączy oba podejścia, używając slotów do organizacji, ale ograniczając całkowitą pojemność wagą. Oto przykład definicji przedmiotu łączącej wagę i sloty:

-- 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,
    },
}

Ten weight pole jest przechowywane w gramach dla zachowania precyzji, a stackSize określa, ile sztuk danego przedmiotu mieści się w jednym slocie. Gdy gracz próbuje podnieść przedmiot, zweryfikuj zarówno dostępność slotu, jak i to, czy łączna waga nie przekroczy maksimum. Ta podwójna walidacja uniemożliwia graczom noszenie nierealistycznych ilości ciężkich przedmiotów, nawet jeśli mają wolne sloty.

Walidacja i ochrona anti-exploit po stronie serwera

Każda akcja w ekwipunku musi zostać zweryfikowana na serwerze, zanim wejdzie w życie. Gdy gracz przeciąga przedmiot ze slotu 3 do slotu 7, klient wysyła żądanie przeniesienia, a serwer sprawdza, czy slot źródłowy rzeczywiście zawiera ten przedmiot, czy slot docelowy może go przyjąć i czy ilości się zgadzają. Nigdy nie pozwalaj klientowi na określanie liczby przedmiotów ani tworzenie przedmiotów z niczego. Oto bezpieczny serwerowy handler przenoszenia:

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)

Zwróć uwagę na DropPlayer wywołania dla ewidentnie niemożliwych operacji. Rejestrowanie tych zdarzeń w osobnej tabeli audytu pomaga zidentyfikować próby exploitów i ich wzorce. Rozważ także wdrożenie rate limitingu dla zdarzeń ekwipunku, ponieważ zwykli gracze rzadko wykonują więcej niż kilka operacji na ekwipunku na sekundę, podczas gdy automatyczne exploity często wysyłają setki żądań w krótkim czasie.

Metadane przedmiotów i unikalne przedmioty

Metadane przekształcają zwykłe przedmioty w bogate, unikalne obiekty. Broń może zawierać swój numer seryjny, wytrzymałość i dołączone modyfikacje. Telefon może przechowywać przypisany numer oraz referencję do listy kontaktów. Przedmioty spożywcze mogą mieć znacznik czasu wygaśnięcia. Metadane są przechowywane jako tabela Lua zserializowana do JSON w bazie danych, przypisana do każdej instancji przedmiotu. Kluczowa różnica dotyczy przedmiotów stackowalnych, które współdzielą te same metadane, oraz przedmiotów unikalnych, z których każdy otrzymuje własną tabelę metadanych. Oto jak utworzyć broń z pełnymi metadanymi:

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

Wyświetlając przedmioty z metadanymi w NUI, przekaż metadane wraz z informacjami o przedmiocie, aby UI mogło pokazywać szczegóły, takie jak paski wytrzymałości, numery seryjne i oceny jakości. Zapewnia to graczom głębszą więź z ich przedmiotami i wspiera zaawansowane scenariusze roleplay, takie jak systemy rejestracji broni lub śledztwa kryminalistyczne łączące numer seryjny z właścicielem.

Wydajność Drag-and-Drop NUI

UI ekwipunku to jeden z najbardziej wrażliwych na wydajność elementów NUI, ponieważ gracze wchodzą z nim w interakcję bez przerwy. Unikaj ponownego renderowania całej siatki ekwipunku przy każdej aktualizacji. Zamiast tego zastosuj podejście oparte na wirtualnym DOM lub ukierunkowane aktualizacje elementów, które modyfikują tylko zmienione sloty. Kiedy gracz przeciąga przedmiot, obsłuż przeciąganie w całości w JavaScript za pomocą zdarzeń myszy, zamiast wysyłać aktualizacje pozycji do klienta Lua w trakcie przeciągania. Wyślij tylko ostateczny wynik upuszczenia jako pojedynczy NUI callback. Oto wydajny wzorzec obsługi przeciągania:

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

Do renderowania wizualnego użyj CSS Grid dla układu slotów i unikaj ciężkich animacji CSS na przedmiotach w ekwipunku, ponieważ gracze mogą mieć widoczne dziesiątki slotów jednocześnie. Sprite'y graficzne dla ikon przedmiotów ładują się szybciej niż pojedyncze pliki obrazów i zmniejszają liczbę żądań HTTP przy pierwszym otwarciu NUI.

Systemy stashy i kontenerów

Poza osobistym ekwipunkiem gracze potrzebują dostępu do zewnętrznych schowków, takich jak bagażniki pojazdów, schowki domowe i współdzielone magazyny organizacji. Każdy typ kontenera powinien mieć własne limity pojemności i reguły kontroli dostępu. Bagażniki pojazdów używają tablicy rejestracyjnej jako unikalnego identyfikatora, schowki domowe używają ID nieruchomości, a schowki frakcyjne nazwy pracy w połączeniu ze sprawdzeniem rangi. Przechowuj ekwipunki kontenerów w dedykowanej tabeli bazy danych, oddzielonej od ekwipunków graczy, aby zachować wydajność zapytań:

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)

Gdy gracz otwiera pojemnik, załaduj jego zawartość z bazy danych i zablokuj go, aby zapobiec jednoczesnemu dostępowi przez wielu graczy. Użyj tabeli blokad po stronie serwera, która śledzi, które stash ID są aktualnie otwarte i przez kogo. Zwolnij blokadę, gdy gracz zamknie pojemnik lub rozłączy się. Zapobiega to klasycznemu exploitowi duplikacji, w którym dwóch graczy otwiera ten sam bagażnik i obaj wyciągają te same przedmioty.

Synchronizacja i trwałość ekwipunku

Synchronizacja danych ekwipunku między pamięcią serwera, bazą danych a widokiem klienta wymaga starannej koordynacji. Zapisuj ekwipunek gracza do bazy danych okresowo, a nie przy każdej pojedynczej zmianie przedmiotu, aby zmniejszyć obciążenie bazy zapisem. Interwał zapisu od 30 do 60 sekund sprawdza się na większości serwerów. Dodatkowo zawsze zapisuj dane przy rozłączeniu gracza i wyłączeniu serwera za pomocą playerDropped event i handler wyłączenia. Zaimplementuj system flagi dirty, który zapisuje do bazy danych tylko wtedy, gdy ekwipunek rzeczywiście uległ zmianie od ostatniego zapisu:

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)

Po stronie client-side grupuj aktualizacje NUI, aby seria szybkich zmian w ekwipunku, takich jak otrzymanie kilku przedmiotów z craftingu, skutkowała pojedynczym odświeżeniem UI zamiast osobnym dla każdego przedmiotu. Eliminuje to efekt migotania, który widzą gracze, gdy przedmioty pojawiają się pojedynczo w siatce ekwipunku.

Monitorowanie i optymalizacja wydajności

Monitoruj wydajność systemu ekwipunku, śledząc kluczowe wskaźniki: średni czas zapisu do bazy danych, tempo przetwarzania zdarzeń i częstotliwość renderowania NUI. Użyj profilera FiveM do identyfikacji wąskich gardeł w callbackach ekwipunku. Typowe pułapki wydajnościowe to iterowanie po ekwipunkach wszystkich graczy w każdej klatce przy funkcjach opartych na odległości, takich jak podnoszenie przedmiotów z ziemi, niepotrzebna serializacja dużych obiektów metadanych oraz pozostawianie aktywnych ramek NUI przy zamkniętym ekwipunku. W przypadku przedmiotów leżących na ziemi użyj systemu siatki przestrzennej, który sprawdza tylko przedmioty w komórce gracza, zamiast skanować każdy upuszczony przedmiot na serwerze. Buforuj definicje przedmiotów w tabeli wyszukiwania indeksowanej po nazwie przedmiotu, aby pobieranie właściwości było wyszukiwaniem hasha o złożoności O(1), a nie przeszukiwaniem tablicy o złożoności O(n). Na koniec rozważ wdrożenie paginacji ekwipunku dla kontenerów o bardzo dużej pojemności, ładując tylko widoczne sloty i pobierając kolejne rzędy podczas przewijania przez gracza, co odciąża zarówno zapytania do bazy danych, jak i renderowanie NUI, nawet w magazynach z setkami slotów.

Gotowy, aby zacząć?

Kup skrypty w naszym sklepie lub dołącz do Discorda, aby uzyskać wsparcie, aktualizacje i sprawdzić nadchodzące nowości.