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

نظام الشخصيات المتعددة في FiveM: فتحات، التبديل والبيانات

إضافة دعم تعدد الشخصيات إلى خادم FiveM الخاص بك. واجهة الفتحات، بيانات الشخصيات، ارتباطات الإطار وأفضل سكربتات تعدد الشخصيات لشبكات QBCore وESX.

Agency Scripts

المؤسس والمطور الرئيسي في Agency Scripts

لماذا تهم أنظمة الشخصيات المتعددة

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

تصميم مخطط قاعدة البيانات

أساس أي نظام متعدد الشخصيات هو مخطط قاعدة البيانات. تحتاج إلى فصل بيانات مستوى اللاعب عن بيانات مستوى الشخصية. جدول اللاعب يخزن معرف الترخيص، Steam hex، معرف Discord، وإعدادات الحساب العامة. جدول الشخصيات يخزن كل شيء خاص بالشخصية: الاسم، تاريخ الميلاد، الجنسية، القصة الخلفية، المظهر، موقع الظهور، ومفتاح أجنبي يربط باللاعب. كل جدول آخر في قاعدة بياناتك كان يشير سابقًا إلى معرف لاعب يحتاج الآن إلى الإشارة إلى character_id بدلاً من ذلك. يشمل هذا الجرد، الحسابات المصرفية، المركبات، الإسكان، جهات اتصال الهاتف، السجلات الجنائية، وتعيينات الوظائف. الحصول على هذا المخطط بشكل صحيح من البداية يمنع الهجرة المؤلمة لاحقًا.

-- Database schema (MySQL)
CREATE TABLE players (
    id        INT AUTO_INCREMENT PRIMARY KEY,
    license   VARCHAR(60) NOT NULL UNIQUE,
    steam     VARCHAR(60) DEFAULT NULL,
    discord   VARCHAR(30) DEFAULT NULL,
    max_slots INT DEFAULT 3,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE characters (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    player_id   INT NOT NULL,
    slot        TINYINT NOT NULL DEFAULT 1,
    firstname   VARCHAR(50) NOT NULL,
    lastname    VARCHAR(50) NOT NULL,
    dob         DATE DEFAULT '1990-01-01',
    nationality VARCHAR(50) DEFAULT 'American',
    gender      TINYINT DEFAULT 0,
    backstory   TEXT DEFAULT NULL,
    skin        LONGTEXT DEFAULT NULL,
    job         VARCHAR(50) DEFAULT 'unemployed',
    job_grade   INT DEFAULT 0,
    cash        INT DEFAULT 500,
    bank        INT DEFAULT 5000,
    position    VARCHAR(100) DEFAULT '{"x":-269.4,"y":-955.3,"z":31.2,"heading":205.0}',
    is_dead     TINYINT DEFAULT 0,
    last_played TIMESTAMP NULL,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE,
    UNIQUE KEY unique_slot (player_id, slot)
);

CREATE TABLE character_inventories (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    character_id INT NOT NULL,
    item         VARCHAR(100) NOT NULL,
    count        INT DEFAULT 1,
    metadata     JSON DEFAULT NULL,
    slot         INT DEFAULT 1,
    FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
);

إدارة الشخصيات على جانب الخادم

يتولى الخادم جميع عمليات CRUD الخاصة بالشخصيات: إنشاء شخصيات جديدة، تحميل الشخصيات الموجودة، حفظ بيانات الشخصية، وحذف الشخصيات. عند اتصال اللاعب، يجلب الخادم سجل اللاعب وجميع الشخصيات المرتبطة به من قاعدة البيانات. تُرسل هذه البيانات إلى العميل لملء واجهة اختيار الشخصية. يتحقق إنشاء الشخصية من صحة حقول الإدخال مثل طول الاسم ويمنع الأسماء المكررة إذا كان خادمك يفرض هويات فريدة. عند اختيار اللاعب لشخصية، يحمل الخادم جميع جداول البيانات ذات الصلة، يحدد معرف الشخصية النشطة في الذاكرة، ويشغل عملية الظهور. تفصيل حاسم هو ضمان أن تكون شخصية واحدة فقط لكل لاعب نشطة في أي وقت، وأن تبديل الشخصيات يحفظ ويفرغ بيانات الشخصية السابقة بشكل صحيح.

-- server.lua
local activeCharacters = {} -- source -> characterId

RegisterNetEvent('multichar:requestCharacters', function()
    local src = source
    local license = GetPlayerIdentifierByType(src, 'license')
    if not license then return DropPlayer(src, 'No license identifier found.') end

    local player = MySQL.single.await(
        'SELECT * FROM players WHERE license = ?', {license}
    )
    if not player then
        MySQL.insert.await(
            'INSERT INTO players (license, steam, discord) VALUES (?, ?, ?)',
            {license, GetPlayerIdentifierByType(src, 'steam'), GetPlayerIdentifierByType(src, 'discord')}
        )
        player = MySQL.single.await('SELECT * FROM players WHERE license = ?', {license})
    end

    local characters = MySQL.query.await(
        'SELECT id, slot, firstname, lastname, dob, gender, job, job_grade, cash, bank, last_played FROM characters WHERE player_id = ? ORDER BY slot ASC',
        {player.id}
    )

    TriggerClientEvent('multichar:showSelection', src, characters, player.max_slots, player.id)
end)

RegisterNetEvent('multichar:selectCharacter', function(charId)
    local src = source
    local license = GetPlayerIdentifierByType(src, 'license')
    local char = MySQL.single.await(
        'SELECT c.* FROM characters c JOIN players p ON c.player_id = p.id WHERE c.id = ? AND p.license = ?',
        {charId, license}
    )
    if not char then return end

    -- Save previous character if switching
    if activeCharacters[src] then
        saveCharacter(src, activeCharacters[src])
    end

    activeCharacters[src] = charId
    MySQL.update('UPDATE characters SET last_played = NOW() WHERE id = ?', {charId})

    local pos = json.decode(char.position)
    TriggerClientEvent('multichar:spawnCharacter', src, char, pos)
end)

AddEventHandler('playerDropped', function()
    local src = source
    if activeCharacters[src] then
        saveCharacter(src, activeCharacters[src])
        activeCharacters[src] = nil
    end
end)

تدفق إنشاء الشخصية

يجب أن تكون عملية إنشاء الشخصية بديهية وغامرة. عندما ينقر اللاعب على خانة شخصية فارغة، يفتح NUI نموذج إنشاء حيث يدخل الاسم الأول، الاسم الأخير، تاريخ الميلاد، الجنس، وقصة خلفية اختيارية. بعد تقديم النموذج، يتحقق الخادم من صحة الإدخال، يُدرج سجل شخصية جديد، وينتقل باللاعب إلى محرر المظهر. يتيح محرر المظهر لهم تخصيص نموذج الشخصية باستخدام نظام تغيير المكونات الأصلي في GTA، بما في ذلك ملامح الوجه، الشعر، الملابس، والإكسسوارات. بمجرد تأكيد المظهر، تُسلسل بيانات الجلد إلى JSON وتُخزن في skin عمود. ثم يتم استدعاء اللاعب إلى العالم في موقع الاستدعاء الافتراضي للاعب الجديد.

-- Character creation (server.lua continued)
RegisterNetEvent('multichar:createCharacter', function(data, playerId)
    local src = source
    local license = GetPlayerIdentifierByType(src, 'license')

    -- Validate ownership
    local player = MySQL.single.await(
        'SELECT id, max_slots FROM players WHERE id = ? AND license = ?',
        {playerId, license}
    )
    if not player then return end

    -- Check slot availability
    local charCount = MySQL.scalar.await(
        'SELECT COUNT(*) FROM characters WHERE player_id = ?', {player.id}
    )
    if charCount >= player.max_slots then
        TriggerClientEvent('multichar:error', src, 'Maximum characters reached.')
        return
    end

    -- Validate input
    local firstname = tostring(data.firstname or ''):gsub('[^%a]', '')
    local lastname  = tostring(data.lastname or ''):gsub('[^%a]', '')
    if #firstname < 2 or #lastname < 2 then
        TriggerClientEvent('multichar:error', src, 'Name must be at least 2 characters.')
        return
    end

    local nextSlot = MySQL.scalar.await(
        'SELECT COALESCE(MAX(slot), 0) + 1 FROM characters WHERE player_id = ?',
        {player.id}
    )

    local charId = MySQL.insert.await(
        'INSERT INTO characters (player_id, slot, firstname, lastname, dob, gender, backstory) VALUES (?, ?, ?, ?, ?, ?, ?)',
        {player.id, nextSlot, firstname, lastname, data.dob or '1990-01-01', data.gender or 0, data.backstory or ''}
    )

    TriggerClientEvent('multichar:openAppearanceEditor', src, charId)
end)

واجهة NUI على جانب العميل لاختيار الشخصية

شاشة اختيار الشخصية هي أول ما يراه اللاعبون بعد الاتصال، لذا يجب أن تبدو مصقولة وتحمّل بسرعة. استخدم كاميرا موضوعة في موقع مثير للاهتمام على الخريطة، مع عرض شخصيات اللاعب في صف أو تنسيق دوار. تعرض كل بطاقة شخصية الاسم، تاريخ آخر لعب، المسمى الوظيفي، ونظرة عامة مالية. تعرض الخانات الفارغة رمز زائد يدعو اللاعب لإنشاء شخصية جديدة. يتواصل NUI مع سكريبت Lua الخاص بالعميل من خلال SendNUIMessage و RegisterNUICallback، ويتولى Lua الخاص بالعميل التعامل مع توليد الشخصيات، وإعداد الكاميرا، وتمرير الاختيارات إلى الخادم. جمد اللاعب وأخفِ الـ HUD أثناء الاختيار لمنع أي تفاعل في اللعب قبل تحميل الشخصية بالكامل.

-- client.lua
local selectionCam = nil
local previewPeds  = {}

RegisterNetEvent('multichar:showSelection', function(characters, maxSlots, playerId)
    SetNuiFocus(true, true)
    DoScreenFadeIn(500)

    -- Setup camera at selection location
    local camPos = vector3(-75.0, -818.0, 326.0)
    selectionCam = CreateCamWithParams('DEFAULT_SCRIPTED_CAMERA',
        camPos.x, camPos.y, camPos.z, -35.0, 0.0, 0.0, 60.0)
    SetCamActive(selectionCam, true)
    RenderScriptCams(true, true, 1000, true, false)

    -- Send data to NUI
    SendNUIMessage({
        action     = 'showCharacterSelect',
        characters = characters,
        maxSlots   = maxSlots,
        playerId   = playerId
    })
end)

RegisterNUICallback('selectCharacter', function(data, cb)
    SetNuiFocus(false, false)
    cleanupSelection()
    TriggerServerEvent('multichar:selectCharacter', data.id)
    cb({ok = true})
end)

RegisterNUICallback('createCharacter', function(data, cb)
    TriggerServerEvent('multichar:createCharacter', data, data.playerId)
    cb({ok = true})
end)

RegisterNUICallback('deleteCharacter', function(data, cb)
    TriggerServerEvent('multichar:deleteCharacter', data.id)
    cb({ok = true})
end)

function cleanupSelection()
    if selectionCam then
        SetCamActive(selectionCam, false)
        RenderScriptCams(false, true, 500, true, false)
        DestroyCam(selectionCam, false)
        selectionCam = nil
    end
    for _, ped in ipairs(previewPeds) do
        if DoesEntityExist(ped) then DeleteEntity(ped) end
    end
    previewPeds = {}
end

اختيار الظهور وعزل البيانات

بعد اختيار شخصية، يحتاج اللاعب إلى اختيار مكان الظهور. الخيارات الشائعة تشمل آخر موقع معروف، شقته أو منزله، المستشفى إذا كان قد سقط آخر مرة، أو نقطة ظهور المدينة الافتراضية. يجب أن يعرض محدد الظهور الخيارات ذات الصلة فقط بالشخصية، مثلاً خيار الشقة يجب أن يظهر فقط إذا كانت تلك الشخصية تملك واحدة فعلاً. عزل البيانات هو أهم اعتبار معماري في نظام متعدد الشخصيات. كل مورد على خادمك يخزن بيانات لكل لاعب يجب أن يستخدم معرف الشخصية بدلاً من ترخيص اللاعب أو معرف الخادم كمفتاح. يشمل هذا المخزونات، بيانات الهاتف، البنوك، ملكية المركبات، السكن، والسجلات الجنائية. خطأ شائع هو استخدام معرف مصدر الخادم للاعب كمفتاح قاعدة بيانات، مما يؤدي إلى تعطل كامل عند تبديل الشخصيات. قم بتدقيق كل مورد على خادمك وتأكد من أنهم جميعاً يشيرون إلى معرف الشخصية النشطة، الذي تكشفه من خلال تصدير مثل exports['multichar']:GetCharacterId(source).

تبديل الشخصية وإدارة الجلسة

السماح للاعبين بتبديل الشخصيات دون قطع الاتصال الكامل وإعادة الاتصال هو ميزة جودة حياة يقدرها اللاعبون كثيرًا. عندما يقوم اللاعب بتبديل شخصية، يحفظ الخادم جميع بيانات الشخصية الحالية، يمسح كل حالة خاصة بالشخصية من الذاكرة، ويرسل اللاعب مرة أخرى إلى شاشة الاختيار. على العميل، يعني هذا تدمير الشخصية الحالية، إزالة كل العلامات والمؤشرات المرتبطة بوظيفة الشخصية أو ممتلكاتها، مسح أي واجهات NUI نشطة، وإعادة تعيين الكاميرا إلى عرض الاختيار. يتلقى كل مورد على الخادم multichar:characterUnloaded حدث حتى يتمكنوا من تنظيف حالتهم الخاصة. هنا يظهر ضعف عزل البيانات: إذا قام مورد بتخزين البيانات حسب معرف المصدر بدلاً من معرف الشخصية، فإن تبديل الشخصيات يؤدي إلى تسرب البيانات بين الشخصيات. اختبار تبديل الشخصيات بدقة أمر ضروري. أنشئ شخصيتين بوظائف، جرد، وأرصدة بنكية مختلفة، ثم قم بالتبديل ذهابًا وإيابًا بسرعة للتأكد من عدم تسرب البيانات بينهما.

جاهز للبدء؟

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