Sistema mult personaje para FiveM: espacios, cambio y datos
Añade varios personajes a tu servidor de FiveM. Interfaz de espacios, datos y compatibilidad con QBCore y ESX.
Agency Scripts
Fundador y desarrollador principal en Agency Scripts
Por qué importan los sistemas multpersonaje
Un sistema de multpersonaje permite que cada jugador tenga varios personajes independientes en el mismo servidor, cada uno con su propia identidad, inventario, cuenta bancaria, trabajo y antecedentes penales. Esta es una función fundamental de los servidores de roleplay serios porque permite a los jugadores explorar distintas historias sin abandonar a su personaje principal. Un líder de banda también puede jugar como policía con otro personaje, o un empresario puede tener un segundo personaje recién llegado a la ciudad. Sin compatibilidad con varios personajes, los jugadores necesitan cuentas alternativas o quedan limitados a un único camino de roleplay. Crear este sistema correctamente requiere prestar mucha atención al aislamiento de datos, al diseño del esquema de la base de datos y a una interfaz de selección de personajes bien pulida que establezca el tono de toda la experiencia del servidor.
Diseño del esquema de la base de datos
La base de cualquier sistema multpersonaje es el esquema de la base de datos. Debes separar los datos del jugador de los datos del personaje. La tabla de jugadores guarda el identificador de licencia, el Steam hex, el ID de Discord y la configuración de toda la cuenta. La tabla de personajes guarda todo lo específico de cada personaje: nombre, fecha de nacimiento, nacionalidad, historia, apariencia, posición de aparición y una clave externa que lo vincula al jugador. Todas las demás tablas de tu base de datos que antes hacían referencia al identificador del jugador ahora deben hacer referencia a un character_id en su lugar. Esto incluye inventarios, cuentas bancarias, vehículos, propiedades, contactos del teléfono, antecedentes penales y asignaciones de trabajo. Definir correctamente este esquema desde el principio evita migraciones complicadas más adelante.
-- 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
);
Gestión de personajes en el servidor
El servidor gestiona todas las operaciones CRUD de personajes: crear personajes nuevos, cargar los existentes, guardar los datos de los personajes y eliminarlos. Cuando un jugador se conecta, el servidor obtiene su registro de jugador y todos los personajes asociados desde la base de datos. Estos datos se envían al cliente para completar la interfaz de selección de personajes. La creación de personajes valida campos como la longitud del nombre y evita nombres duplicados si tu servidor exige identidades únicas. Cuando un jugador selecciona un personaje, el servidor carga todas las tablas de datos relacionadas, establece en memoria el ID del personaje activo del jugador e inicia el proceso de aparición. Un detalle fundamental es garantizar que solo haya un personaje activo por jugador en cada momento y que, al cambiar de personaje, se guarden y descarguen correctamente los datos del personaje anterior.
-- 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)
Flujo de creación de personajes
El proceso de creación de personajes debe ser intuitivo e inmersivo. Cuando un jugador hace clic en un espacio vacío para un personaje, NUI abre un formulario de creación donde introduce su nombre, apellido, fecha de nacimiento, género y, opcionalmente, una historia de trasfondo. Después de enviar el formulario, el servidor valida los datos, inserta un nuevo registro de personaje y pasa al jugador al editor de apariencia. El editor de apariencia permite personalizar el modelo de ped mediante el sistema nativo de variaciones de componentes de GTA, incluidos los rasgos faciales, el pelo, la ropa y los accesorios. Una vez que confirma su apariencia, los datos de la skin se serializan en JSON y se guardan en la skin columna. Después, el jugador aparece en el mundo en la ubicación predeterminada de aparición para jugadores nuevos.
-- 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 del cliente para la selección de personajes
La pantalla de selección de personajes es lo primero que ven los jugadores después de conectarse, así que debe tener un acabado cuidado y cargarse rápidamente. Usa una cámara situada en un lugar interesante del mapa y muestra los peds de los personajes del jugador en una fila o en formato de carrusel. Cada tarjeta de personaje muestra el nombre, la fecha de la última sesión, el puesto de trabajo y un resumen financiero. Los espacios vacíos muestran un icono de más que invita al jugador a crear un personaje nuevo. NUI se comunica con el script Lua del cliente mediante SendNUIMessage y RegisterNUICallbacky el Lua del cliente gestiona la aparición de los peds, la configuración de la cámara y el envío de las selecciones al servidor. Congela al jugador y oculta el HUD durante la selección para evitar cualquier interacción con el juego antes de que el personaje se haya cargado por completo.
-- 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
Selección de aparición y aislamiento de datos
Después de seleccionar un personaje, el jugador debe elegir dónde aparecer. Entre las opciones habituales están su última ubicación conocida, su apartamento o casa, el hospital si quedó inconsciente la última vez, o un punto de aparición predeterminado de la ciudad. El selector de aparición solo debe mostrar las opciones pertinentes para el personaje. Por ejemplo, la opción del apartamento solo debe aparecer si ese personaje realmente tiene uno. El aislamiento de datos es la preocupación arquitectónica más importante en un sistema mult personaje. Todos los recursos de tu servidor que almacenen datos por jugador deben usar el ID del personaje como clave, en lugar de la licencia del jugador o su ID de servidor. Esto incluye inventarios, datos del teléfono, banca, propiedad de vehículos, viviendas y antecedentes penales. Un error habitual es usar el ID de origen del jugador en el servidor como clave de base de datos, lo que deja de funcionar por completo al cambiar de personaje. Audita todos los recursos de tu servidor y asegúrate de que todos usen el ID del personaje activo, que expones mediante un export como exports['multichar']:GetCharacterId(source).
Cambio de personaje y gestión de sesiones
Permitir que los jugadores cambien de personaje sin desconectarse y volver a conectarse por completo es una mejora de calidad de vida que agradecen mucho. Cuando un jugador inicia un cambio de personaje, el servidor guarda todos los datos del personaje actual, borra de la memoria todo el estado específico del personaje y devuelve al jugador a la pantalla de selección. En el cliente, esto implica destruir el ped actual, eliminar todos los blips y marcadores vinculados al trabajo o las propiedades del personaje, cerrar las interfaces NUI activas y restablecer la cámara a la vista de selección. Todos los recursos del servidor reciben un evento multichar:characterUnloaded para que puedan limpiar su propio estado. Es aquí donde queda en evidencia un aislamiento de datos deficiente: si un recurso almacena datos en caché por ID de origen en lugar de por ID de personaje, al cambiar de personaje los datos se mezclarán entre personajes. Es esencial probar a fondo el cambio de personaje. Crea dos personajes con trabajos, inventarios y saldos bancarios distintos, y luego cambia de uno a otro rápidamente para confirmar que no haya fugas de datos entre ellos.