System wielu postaci FiveM: Sloty, przełączanie i dane
Dodaj obsługę multicharacter do swojego serwera FiveM. UI slotów, dane postaci, integracja z frameworkami i najlepsze skrypty multichar dla QBCore i ESX.
Agency Scripts
Założyciel i główny deweloper w Agency Scripts
Dlaczego systemy wielu postaci mają znaczenie
System wielu postaci umożliwia każdemu graczowi posiadanie kilku oddzielnych postaci na tym samym serwerze, z których każda ma własną tożsamość, ekwipunek, konto bankowe, pracę i kartotekę policyjną. To kluczowa funkcja poważnych serwerów roleplay, ponieważ pozwala graczom rozwijać różne wątki fabularne bez porzucania swojej głównej postaci. Lider gangu może również grać policjantem na osobnej postaci, a właściciel firmy może mieć drugą postać, która dopiero co przybyła do miasta. Bez obsługi wielu postaci gracze potrzebują multikont lub są ograniczeni do jednej ścieżki roleplay. Prawidłowe zbudowanie tego systemu wymaga szczególnej dbałości o izolację danych, projekt schematu bazy danych oraz dopracowane UI wyboru postaci, które nadaje ton całemu serwerowi.
Projektowanie schematu bazy danych
Podstawą każdego systemu wielu postaci jest schemat bazy danych. Musisz oddzielić dane na poziomie gracza od danych na poziomie postaci. Tabela gracza przechowuje identyfikator licencji, Steam hex, Discord ID i ustawienia całego konta. Tabela postaci przechowuje wszystko, co dotyczy danej postaci: imię i nazwisko, datę urodzenia, narodowość, historię, wygląd, pozycję spawnu oraz klucz obcy łączący z graczem. Każda inna tabela w bazie danych, która wcześniej odwoływała się do identyfikatora gracza, musi teraz odwoływać się do character_id zamiast tego. Obejmuje to ekwipunki, konta bankowe, pojazdy, nieruchomości, kontakty w telefonie, kartoteki kryminalne i przypisania prac. Prawidłowe przygotowanie tego schematu od samego początku zapobiega bolesnej migracji w późniejszym czasie.
-- 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
);
Zarządzanie postaciami po stronie serwera
Serwer obsługuje wszystkie operacje CRUD na postaciach: tworzenie nowych postaci, wczytywanie istniejących, zapisywanie danych postaci oraz ich usuwanie. Gdy gracz się łączy, serwer pobiera z bazy danych jego rekord gracza i wszystkie powiązane postacie. Dane te są przesyłane do klienta, aby zapełnić interfejs wyboru postaci. Tworzenie postaci sprawdza pola wejściowe, takie jak długość imienia i nazwiska, oraz zapobiega powielaniu nazw, jeśli twój serwer wymusza unikalne tożsamości. Gdy gracz wybierze postać, serwer ładuje wszystkie powiązane tabele danych, ustawia aktywne ID postaci gracza w pamięci i uruchamia proces spawnu. Kluczowym szczegółem jest upewnienie się, że tylko jedna postać na gracza może być aktywna w danym momencie, a przełączanie postaci prawidłowo zapisuje i usuwa z pamięci dane poprzedniej postaci.
-- 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)
Przebieg tworzenia postaci
Proces tworzenia postaci powinien być intuicyjny i immersyjny. Gdy gracz kliknie pusty slot postaci, NUI otwiera formularz tworzenia, w którym wpisuje imię, nazwisko, datę urodzenia, płeć oraz opcjonalnie historię postaci. Po przesłaniu formularza serwer weryfikuje dane, dodaje nowy rekord postaci i przenosi gracza do edytora wyglądu. Edytor wyglądu pozwala dostosować model peda za pomocą natywnego systemu GTA component variation, w tym rysy twarzy, włosy, ubrania i akcesoria. Po potwierdzeniu wyglądu dane skina są serializowane do JSON i zapisywane w rekordzie postaci skin kolumna. Gracz jest następnie spawnowany w świecie w domyślnej lokalizacji odradzania nowych graczy.
-- 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 wyboru postaci po stronie klienta
Ekran wyboru postaci to pierwsza rzecz, którą gracze widzą po połączeniu, dlatego musi wyglądać dopracowanie i szybko się ładować. Użyj kamery umieszczonej w ciekawym miejscu na mapie, z pedami postaci gracza wyrenderowanymi w szeregu lub w formie karuzeli. Każda karta postaci wyświetla imię i nazwisko, datę ostatniej gry, tytuł pracy i przegląd finansowy. Puste sloty pokazują ikonę plusa zachęcającą gracza do stworzenia nowej postaci. NUI komunikuje się ze skryptem klienta Lua poprzez SendNUIMessage i RegisterNUICallback, a kliencki skrypt Lua odpowiada za spawnowanie pedów, ustawienie kamery i przekazywanie wyborów na serwer. Zamroź gracza i ukryj HUD podczas wyboru, aby zapobiec interakcjom z rozgrywką przed pełnym załadowaniem postaci.
-- 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
Wybór spawnu i izolacja danych
Po wybraniu postaci gracz musi wybrać miejsce spawnu. Typowe opcje to ostatnia znana pozycja, mieszkanie lub dom, szpital, jeśli postać była powalona, albo domyślny punkt spawnu w mieście. Selektor spawnu powinien pokazywać tylko opcje istotne dla danej postaci, na przykład opcja mieszkania powinna pojawić się tylko wtedy, gdy ta postać rzeczywiście je posiada. Izolacja danych to najważniejsza kwestia architektoniczna w systemie wielu postaci. Każdy zasób na twoim serwerze przechowujący dane gracza musi używać ID postaci jako klucza, a nie licencji gracza czy ID serwera. Obejmuje to ekwipunki, dane telefonu, bankowość, własność pojazdów, nieruchomości i kartoteki kryminalne. Częstym błędem jest używanie ID source gracza z serwera jako klucza w bazie danych, co psuje się całkowicie przy zmianie postaci. Sprawdź każdy zasób na swoim serwerze i upewnij się, że wszystkie odwołują się do aktywnego ID postaci, które udostępniasz poprzez export taki jak exports['multichar']:GetCharacterId(source).
Przełączanie postaci i zarządzanie sesjami
Umożliwienie graczom zmiany postaci bez konieczności pełnego rozłączania i ponownego łączenia się to funkcja quality-of-life, którą gracze bardzo doceniają. Kiedy gracz uruchamia zmianę postaci, serwer zapisuje wszystkie dane bieżącej postaci, czyści z pamięci cały stan specyficzny dla postaci i odsyła gracza z powrotem do ekranu wyboru. Po stronie klienta oznacza to usunięcie bieżącego peda, usunięcie wszystkich blipów i markerów powiązanych z pracą lub nieruchomościami postaci, wyczyszczenie wszelkich aktywnych interfejsów NUI i zresetowanie kamery do widoku wyboru. Każdy resource na serwerze otrzymuje multichar:characterUnloaded event, aby mogły wyczyścić własny stan. W tym miejscu ujawnia się słaba izolacja danych: jeśli resource buforuje dane według source ID zamiast ID postaci, przełączanie postaci spowoduje przenikanie danych między nimi. Dokładne przetestowanie przełączania postaci jest kluczowe. Stwórz dwie postacie z różnymi pracami, ekwipunkami i stanami konta, a następnie szybko przełączaj się między nimi, aby upewnić się, że dane nie wyciekają.