Назад к блогу
Guide7 мин чтения

FiveM Multicharacter System: Слоты, переключение и данные

Добавьте поддержку мультиперсонажей на ваш сервер FiveM. Интерфейс слотов, данные персонажей, интеграция с фреймворками и лучшие мультиперсонажные скрипты для QBCore и ESX.

Agency Scripts

Основатель и ведущий разработчик Agency Scripts

Почему важны мульти-персонажные системы

Система мультиперсонажей позволяет каждому игроку иметь несколько отдельных персонажей на одном сервере, каждый со своей личностью, инвентарём, банковским счётом, работой и криминальным прошлым. Это ключевая функция серьёзных ролевых серверов, позволяющая игрокам исследовать разные сюжетные линии без отказа от основного персонажа. Лидер банды может также играть полицейского на другом персонаже, или владелец бизнеса иметь второго персонажа , новичка в городе. Без поддержки мультиперсонажей игрокам нужны альтернативные аккаунты или они заперты в одном ролевом пути. Правильная реализация требует тщательного внимания к изоляции данных, дизайну схемы базы данных и отточенному UI выбора персонажа, задающему тон всему серверному опыту.

Проектирование схемы базы данных

Основа любой системы мультиперсонажей , это схема базы данных. Нужно разделять данные на уровне игрока и данные на уровне персонажа. Таблица игроков хранит лицензионный идентификатор, Steam hex, Discord ID и настройки аккаунта. Таблица персонажей хранит всё, что относится к конкретному персонажу: имя, дату рождения, национальность, предысторию, внешний вид, позицию спавна и внешний ключ, ссылающийся на игрока. Все остальные таблицы в базе данных, которые ранее ссылались на идентификатор игрока, теперь должны ссылаться на character_id вместо этого. Это включает инвентари, банковские счета, транспортные средства, жильё, контакты в телефоне, криминальные записи и назначения на работу. Правильное определение этой схемы с самого начала предотвращает болезненную миграцию позже.

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

Управление персонажами на стороне сервера

Сервер обрабатывает все операции CRUD персонажей: создание новых, загрузку существующих, сохранение данных и удаление. При подключении игрока сервер получает его запись и все связанные персонажи из базы данных. Эти данные отправляются клиенту для заполнения UI выбора персонажа. Создание персонажа проверяет поля ввода, например длину имени, и предотвращает дублирование имён, если сервер требует уникальности. При выборе персонажа сервер загружает все связанные таблицы данных, устанавливает активный ID персонажа в памяти и запускает процесс спавна. Важно, чтобы у игрока был активен только один персонаж одновременно, а переключение корректно сохраняло и выгружало данные предыдущего.

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

Процесс создания персонажа

Процесс создания персонажа должен быть интуитивным и погружающим. Когда игрок кликает на пустой слот персонажа, NUI открывает форму создания, где вводятся имя, фамилия, дата рождения, пол и опционально предыстория. После отправки сервер проверяет данные, создаёт новую запись персонажа и переводит игрока в редактор внешности. Редактор позволяет настроить модель педа с помощью нативной системы вариаций GTA, включая черты лица, волосы, одежду и аксессуары. После подтверждения внешности данные скина сериализуются в JSON и сохраняются в персонаже skin колонка. Затем игрок появляется в мире в стандартном месте спавна для новых игроков.

-- 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 для выбора персонажа

Экран выбора персонажа , первое, что видят игроки после подключения, поэтому он должен выглядеть отточенно и загружаться быстро. Используйте камеру, расположенную в интересном месте на карте, с персонажами игрока, отображаемыми в виде линейки или карусели. Каждая карточка персонажа показывает имя, дату последнего входа, должность и финансовый обзор. Пустые слоты отображают иконку плюса, приглашающую создать нового персонажа. NUI взаимодействует с клиентским Lua скриптом через SendNUIMessage и RegisterNUICallback и клиентский Lua обрабатывает спаун педи, настройку камеры и передачу выборов на сервер. Заморозьте игрока и скройте HUD во время выбора, чтобы предотвратить любое взаимодействие с игрой до полной загрузки персонажа.

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

Выбор спавна и изоляция данных

После выбора персонажа игрок должен выбрать место спавна. Распространённые варианты включают последнее известное положение, их квартиру или дом, больницу, если персонаж был недавно без сознания, или стандартную точку спавна в городе. Селектор спавна должен показывать только релевантные варианты для персонажа, например, опция квартиры должна появляться только если персонаж действительно владеет ей. Изоляция данных , самый важный архитектурный аспект в системе с несколькими персонажами. Каждый ресурс на вашем сервере, который хранит данные по игроку, должен использовать ID персонажа, а не лицензию игрока или серверный ID в качестве ключа. Это касается инвентарей, данных телефона, банковских счетов, владения транспортом, жилья и криминальных записей. Частая ошибка , использовать серверный source ID игрока как ключ базы данных, что полностью ломается при смене персонажей. Проверьте каждый ресурс на сервере и убедитесь, что все они ссылаются на активный ID персонажа, который вы предоставляете через экспорт, например exports['multichar']:GetCharacterId(source).

Переключение персонажей и управление сессиями

Разрешение игрокам переключаться между персонажами без полного отключения и повторного подключения , это удобная функция, которую игроки очень ценят. Когда игрок инициирует смену персонажа, сервер сохраняет все данные текущего персонажа, очищает все данные, связанные с персонажем, из памяти и отправляет игрока обратно на экран выбора. На клиенте это означает уничтожение текущего педа, удаление всех меток и маркеров, связанных с работой или имуществом персонажа, закрытие всех активных NUI интерфейсов и сброс камеры к виду выбора. Каждый ресурс на сервере получает multichar:characterUnloaded событием, чтобы они могли очистить свое собственное состояние. Здесь проявляется плохая изоляция данных: если ресурс кэширует данные по source ID вместо character ID, переключение персонажей приведет к утечке данных между персонажами. Тщательное тестирование переключения персонажей обязательно. Создайте двух персонажей с разными профессиями, инвентарями и банковскими балансами, затем быстро переключайтесь между ними, чтобы убедиться, что данные не пересекаются.

Готовы начать?

Возьмите скрипты в нашем магазине или заходите в Discord за поддержкой, обновлениями и анонсами.