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