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.