العودة إلى المدونة
Guide8 دقيقة قراءة

إدارة المخزون في 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، مرر البيانات الوصفية مع معلومات العنصر حتى يمكن لواجهة المستخدم عرض تفاصيل مثل أشرطة المتانة، أرقام السلسلة، وتقييمات الجودة. هذا يمنح اللاعبين ارتباطاً أعمق بعناصرهم ويدعم سيناريوهات لعب أدوار متقدمة مثل أنظمة تسجيل الأسلحة أو التحقيقات الجنائية التي تتبع رقم السلسلة إلى مالكه.

أداء 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 لأول مرة.

أنظمة التخزين والحاويات

بعيداً عن المخزون الشخصي، يحتاج اللاعبون إلى الوصول إلى التخزين الخارجي مثل صناديق السيارات، مخازن المنازل، والتخزين المشترك للمنظمات. يجب أن يكون لكل نوع حاوية حدود سعة وقواعد تحكم وصول خاصة به. تستخدم صناديق السيارات لوحة المركبة كمُعرف فريد، وتستخدم مخازن المنازل معرفات الملكية، وتستخدم مخازن الوظائف اسم الوظيفة مع فحص الدرجة. خزّن مخزونات الحاويات في جدول قاعدة بيانات مخصص منفصل عن مخزونات اللاعبين للحفاظ على كفاءة الاستعلامات:

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)

عندما يفتح اللاعب حاوية، يتم تحميل محتوياتها من قاعدة البيانات وقفلها لمنع الوصول المتزامن من عدة لاعبين. استخدم جدول قفل على الخادم يتتبع معرفات المخازن المفتوحة حالياً ومن يفتحها. حرر القفل عند إغلاق اللاعب للحاوية أو قطع الاتصال. هذا يمنع استغلال التكرار الكلاسيكي حيث يفتح لاعبان نفس الصندوق ويسحبان نفس العناصر.

مزامنة الجرد والاستمرارية

يتطلب مزامنة بيانات الجرد بين ذاكرة الخادم وقاعدة البيانات وعرض العميل تنسيقًا دقيقًا. احفظ جرد اللاعب في قاعدة البيانات بشكل دوري، وليس عند كل تغيير في عنصر واحد، لتقليل حمل الكتابة على قاعدة البيانات. فترة حفظ من 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 بحيث تؤدي تغييرات المخزون السريعة المتعددة، مثل استلام عدة عناصر من عملية تصنيع، إلى تحديث واجهة مستخدم واحدة بدلاً من تحديث لكل عنصر. هذا يلغي تأثير الوميض الذي يراه اللاعبون عند ظهور العناصر واحدة تلو الأخرى في شبكة المخزون.

مراقبة الأداء وتحسينه

راقب أداء نظام الجرد الخاص بك بتتبع المقاييس الرئيسية: متوسط وقت حفظ قاعدة البيانات، معدل معالجة الأحداث، وتكرار عرض NUI. استخدم أداة تحليل الأداء FiveM لتحديد الاختناقات في ردود الجرد. تشمل الفخاخ الشائعة للأداء التكرار على كل جرد اللاعبين في كل إطار لميزات تعتمد على القرب مثل التقاط العناصر من الأرض، تسلسل كائنات البيانات الوصفية الكبيرة بلا داع، وترك إطارات NUI نشطة عند إغلاق الجرد. بالنسبة للعناصر الأرضية، استخدم نظام شبكة مكانية يتحقق فقط من العناصر ضمن خلية اللاعب بدلاً من مسح كل عنصر مسقط على الخادم. خزّن تعريفات العناصر في جدول بحث مفهرس باسم العنصر بحيث يكون جلب خصائص العنصر عملية بحث هاش O(1) بدلاً من مسح مصفوفة O(n). أخيراً، فكر في تنفيذ ترقيم صفحات الجرد للحاويات ذات السعات الكبيرة جداً، بتحميل الفتحات المرئية فقط وجلب الصفوف الإضافية عند تمرير اللاعب، مما يحافظ على استعلامات قاعدة البيانات وعرض NUI خفيفين حتى للمستودعات التي تحتوي على مئات الفتحات.

جاهز للبدء؟

احصل على السكربتات من متجرنا، أو انضم إلى Discord للدعم والتحديثات ونظرة على ما هو قادم.