Wróć do bloga
Guide7 min czytania

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ą.

Gotowy, aby zacząć?

Kup skrypty w naszym sklepie lub dołącz do Discorda, aby uzyskać wsparcie, aktualizacje i sprawdzić nadchodzące nowości.