FiveM Multicharacter System: Слоты, переключение и данные
Добавьте поддержку мультиперсонажей на ваш сервер FiveM. Интерфейс слотов, данные персонажей, интеграция с фреймворками и лучшие мультиперсонажные скрипты для QBCore и ESX.
Agency Scripts
Основатель и ведущий разработчик Agency Scripts
Почему важны мульти-персонажные системы
Система мультиперсонажей позволяет каждому игроку иметь несколько отдельных персонажей на одном сервере, каждый со своей личностью, инвентарём, банковским счётом, работой и криминальным прошлым. Это ключевая функция серьёзных ролевых серверов, позволяющая игрокам исследовать разные сюжетные линии без отказа от основного персонажа. Лидер банды может также играть полицейского на другом персонаже, или владелец бизнеса иметь второго персонажа , новичка в городе. Без поддержки мультиперсонажей игрокам нужны альтернативные аккаунты или они заперты в одном ролевом пути. Правильная реализация требует тщательного внимания к изоляции данных, дизайну схемы базы данных и отточенному UI выбора персонажа, задающему тон всему серверному опыту.
Проектирование схемы базы данных
Основа любой системы мультиперсонажей , это схема базы данных. Нужно разделять данные на уровне игрока и данные на уровне персонажа. Таблица игроков хранит лицензионный идентификатор, Steam hex, Discord ID и настройки аккаунта. Таблица персонажей хранит всё, что относится к конкретному персонажу: имя, дату рождения, национальность, предысторию, внешний вид, позицию спавна и внешний ключ, ссылающийся на игрока. Все остальные таблицы в базе данных, которые ранее ссылались на идентификатор игрока, теперь должны ссылаться на 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 персонажей: создание новых, загрузку существующих, сохранение данных и удаление. При подключении игрока сервер получает его запись и все связанные персонажи из базы данных. Эти данные отправляются клиенту для заполнения UI выбора персонажа. Создание персонажа проверяет поля ввода, например длину имени, и предотвращает дублирование имён, если сервер требует уникальности. При выборе персонажа сервер загружает все связанные таблицы данных, устанавливает активный ID персонажа в памяти и запускает процесс спавна. Важно, чтобы у игрока был активен только один персонаж одновременно, а переключение корректно сохраняло и выгружало данные предыдущего.
-- 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
Выбор спавна и изоляция данных
После выбора персонажа игрок должен выбрать место спавна. Распространённые варианты включают последнее известное положение, их квартиру или дом, больницу, если персонаж был недавно без сознания, или стандартную точку спавна в городе. Селектор спавна должен показывать только релевантные варианты для персонажа, например, опция квартиры должна появляться только если персонаж действительно владеет ей. Изоляция данных , самый важный архитектурный аспект в системе с несколькими персонажами. Каждый ресурс на вашем сервере, который хранит данные по игроку, должен использовать ID персонажа, а не лицензию игрока или серверный ID в качестве ключа. Это касается инвентарей, данных телефона, банковских счетов, владения транспортом, жилья и криминальных записей. Частая ошибка , использовать серверный source ID игрока как ключ базы данных, что полностью ломается при смене персонажей. Проверьте каждый ресурс на сервере и убедитесь, что все они ссылаются на активный ID персонажа, который вы предоставляете через экспорт, например exports['multichar']:GetCharacterId(source).
Переключение персонажей и управление сессиями
Разрешение игрокам переключаться между персонажами без полного отключения и повторного подключения , это удобная функция, которую игроки очень ценят. Когда игрок инициирует смену персонажа, сервер сохраняет все данные текущего персонажа, очищает все данные, связанные с персонажем, из памяти и отправляет игрока обратно на экран выбора. На клиенте это означает уничтожение текущего педа, удаление всех меток и маркеров, связанных с работой или имуществом персонажа, закрытие всех активных NUI интерфейсов и сброс камеры к виду выбора. Каждый ресурс на сервере получает multichar:characterUnloaded событием, чтобы они могли очистить свое собственное состояние. Здесь проявляется плохая изоляция данных: если ресурс кэширует данные по source ID вместо character ID, переключение персонажей приведет к утечке данных между персонажами. Тщательное тестирование переключения персонажей обязательно. Создайте двух персонажей с разными профессиями, инвентарями и банковскими балансами, затем быстро переключайтесь между ними, чтобы убедиться, что данные не пересекаются.