Zurück zum Blog
Guide8 Min. Lesezeit

FiveM-Mehrcharaktersystem: Plätze, Wechsel und Daten

Ergänze deinen FiveM-Server um mehrere Charaktere. Mit Platzübersicht, Charakterdaten und Framework-Anbindung für QBCore und ESX.

Agency Scripts

Gründer und leitender Entwickler bei Agency Scripts

Warum Mehrcharaktersysteme wichtig sind

Mit einem Mehrcharaktersystem kann jeder Spieler mehrere separate Charaktere auf demselben Server haben, jeweils mit eigener Identität, eigenem Inventar, Bankkonto, Beruf und Strafregister. Das ist eine zentrale Funktion für ernsthafte Roleplay-Server, denn sie ermöglicht es Spielern, verschiedene Handlungsstränge zu erleben, ohne ihren Hauptcharakter aufgeben zu müssen. Ein Gang-Anführer kann außerdem auf einem separaten Charakter als Polizist spielen, oder ein Geschäftsinhaber kann einen zweiten Charakter haben, der neu in die Stadt kommt. Ohne Unterstützung für mehrere Charaktere benötigen Spieler entweder alternative Accounts oder sind auf einen einzigen Roleplay-Weg festgelegt. Für eine korrekte Umsetzung sind eine sorgfältige Trennung der Daten, ein durchdachtes Datenbankschema und eine ausgereifte Charakterauswahl-Oberfläche erforderlich, die den Ton für das gesamte Servererlebnis vorgibt.

Datenbankschema entwerfen

Die Grundlage jedes Mehrcharaktersystems ist das Datenbankschema. Du musst Daten auf Spielerebene von Daten auf Charakterebene trennen. Die Spielertabelle speichert die Lizenzkennung, den Steam-Hex, die Discord-ID und kontoweite Einstellungen. Die Charaktertabelle speichert alles, was zu einem bestimmten Charakter gehört: Name, Geburtsdatum, Nationalität, Hintergrundgeschichte, Aussehen, Spawn-Position und einen Fremdschlüssel zum zugehörigen Spieler. Jede andere Tabelle in deiner Datenbank, die bisher auf eine Spielerkennung verwiesen hat, muss stattdessen auf eine character_id verweisen. Dazu gehören Inventare, Bankkonten, Fahrzeuge, Wohnungen, Telefonkontakte, Strafregister und Jobzuweisungen. Wenn du das Schema gleich von Anfang an richtig aufsetzt, ersparst du dir später eine aufwendige Migration.

-- 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
);

Charakterverwaltung auf dem Server

Der Server verarbeitet alle CRUD-Operationen für Charaktere: neue Charaktere erstellen, vorhandene laden, Charakterdaten speichern und Charaktere löschen. Wenn sich ein Spieler verbindet, ruft der Server seinen Spielerdatensatz und alle zugehörigen Charaktere aus der Datenbank ab. Diese Daten werden an den Client gesendet, um die Charakterauswahl-Oberfläche zu befüllen. Bei der Charaktererstellung werden Eingaben wie die Namenslänge überprüft und doppelte Namen verhindert, falls dein Server eindeutige Identitäten voraussetzt. Wenn ein Spieler einen Charakter auswählt, lädt der Server alle zugehörigen Datentabellen, setzt die ID des aktiven Charakters für den Spieler im Arbeitsspeicher und startet den Spawn-Vorgang. Wichtig ist, dass pro Spieler immer nur ein Charakter aktiv sein kann und dass beim Wechsel zu einem anderen Charakter die Daten des vorherigen Charakters ordnungsgemäß gespeichert und entladen werden.

-- 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)

Ablauf der Charaktererstellung

Die Charaktererstellung sollte intuitiv und immersiv sein. Wenn ein Spieler auf einen leeren Charakterplatz klickt, öffnet sich die NUI mit einem Erstellungsformular, in dem er Vorname, Nachname, Geburtsdatum, Geschlecht und optional eine Hintergrundgeschichte eingibt. Nach dem Absenden überprüft der Server die Eingaben, fügt einen neuen Charakterdatensatz ein und leitet den Spieler zum Editor für das Aussehen weiter. Dort kann der Spieler sein Ped-Modell mithilfe des nativen Komponentensystems von GTA anpassen, darunter Gesichtszüge, Haare, Kleidung und Accessoires. Sobald er sein Aussehen bestätigt, werden die Skin-Daten in JSON serialisiert und in der skin Spalte des Charakters gespeichert. Anschließend wird der Spieler am standardmäßigen Spawn-Punkt für neue Spieler in der Spielwelt gespawnt.

-- 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)

Clientseitige NUI für die Charakterauswahl

Der Charakterauswahlbildschirm ist das Erste, was Spieler nach dem Verbinden sehen, daher muss er hochwertig aussehen und schnell laden. Platziere eine Kamera an einem interessanten Ort auf der Karte und stelle die Charakter-Peds der Spieler in einer Reihe oder als Karussell dar. Jede Charakterkarte zeigt den Namen, das Datum des letzten Spiels, den Beruf und eine Finanzübersicht. Leere Plätze zeigen ein Plus-Symbol, über das der Spieler einen neuen Charakter erstellen kann. Die NUI kommuniziert über SendNUIMessage und RegisterNUICallbackmit dem Lua-Skript des Clients. Dieses verwaltet das Spawnen der Peds, die Kameraeinrichtung und leitet die Auswahl an den Server weiter. Friere den Spieler ein und blende das HUD während der Auswahl aus, damit keine Spielinteraktionen stattfinden, bevor ein Charakter vollständig geladen ist.

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

Spawn-Auswahl und Datentrennung

Nach der Auswahl eines Charakters muss der Spieler seinen Spawn-Ort auswählen. Übliche Optionen sind die zuletzt bekannte Position, die Wohnung oder das Haus, das Krankenhaus, falls der Spieler zuletzt außer Gefecht gesetzt wurde, oder ein standardmäßiger Spawn-Punkt in der Stadt. Der Spawn-Auswahldialog sollte nur Optionen anzeigen, die für den jeweiligen Charakter relevant sind. Eine Wohnungsoption sollte beispielsweise nur erscheinen, wenn der Charakter tatsächlich eine Wohnung besitzt. Die Datentrennung ist die wichtigste architektonische Herausforderung eines Mehrcharaktersystems. Jede Ressource auf deinem Server, die spielerbezogene Daten speichert, muss als Schlüssel die Charakter-ID statt der Lizenz- oder Server-ID des Spielers verwenden. Dazu gehören Inventare, Telefondaten, Bankgeschäfte, Fahrzeugbesitz, Wohnungen und Strafregister. Ein häufiger Fehler ist, die Server-Source-ID des Spielers als Datenbankschlüssel zu verwenden. Das funktioniert beim Wechsel zwischen Charakteren nicht mehr. Überprüfe alle Ressourcen auf deinem Server und stelle sicher, dass sie alle auf die ID des aktiven Charakters verweisen, die du über einen Export wie exports['multichar']:GetCharacterId(source).

Charakterwechsel und Sitzungsverwaltung

Spielern den Wechsel zu einem anderen Charakter zu ermöglichen, ohne dass sie die Verbindung vollständig trennen und erneut herstellen müssen, ist eine Komfortfunktion, die Spieler sehr zu schätzen wissen. Wenn ein Spieler einen Charakterwechsel auslöst, speichert der Server alle Daten des aktuellen Charakters, entfernt alle charakterbezogenen Zustände aus dem Arbeitsspeicher und schickt den Spieler zurück zum Auswahlbildschirm. Auf dem Client bedeutet das, das aktuelle Ped zu entfernen, alle Blips und Marker zu löschen, die mit dem Beruf oder den Besitztümern des Charakters verbunden sind, alle aktiven NUI-Oberflächen zu schließen und die Kamera auf die Auswahlansicht zurückzusetzen. Jede Ressource auf dem Server erhält ein multichar:characterUnloaded Ereignis, damit sie ihren eigenen Zustand bereinigen können. Hier zeigt sich eine mangelhafte Datentrennung: Wenn eine Resource Daten anhand der Source-ID statt der Charakter-ID zwischenspeichert, können beim Charakterwechsel Daten zwischen Charakteren vermischt werden. Es ist wichtig, den Charakterwechsel gründlich zu testen. Erstelle zwei Charaktere mit unterschiedlichen Jobs, Inventaren und Kontoständen und wechsle dann schnell zwischen ihnen hin und her, um sicherzustellen, dass keine Daten durchsickern.

Bereit loszulegen?

Hol dir die Scripts in unserem Shop, oder komm auf den Discord für Support, Updates und einen Blick auf das, was kommt.