返回博客
Guide3 分钟阅读

FiveM多角色系统:槽位、切换与数据

为您的FiveM服务器添加多角色支持。角色槽UI、角色数据、框架关联以及适用于QBCore和ESX的最佳多角色脚本。

Agency Scripts

Agency Scripts 创始人兼首席开发者

为什么多角色系统很重要

多角色系统允许每位玩家在同一服务器拥有多个独立角色,每个角色拥有独立身份、库存、银行账户、职业和犯罪记录。这是严肃角色扮演服务器的基石功能,允许玩家探索不同故事线而不放弃主角。帮派头目也能用另一个角色扮演警察,或商人拥有第二个刚到城市的新角色。无多角色支持,玩家需使用备用账号或被锁定单一路径。正确构建此系统需关注数据隔离、数据库架构设计和精致的角色选择UI,奠定整个服务器体验基调。

数据库架构设计

任何多角色系统的基础是数据库模式。你需要将玩家级数据与角色级数据分开。玩家表存储许可证标识符、Steam 十六进制、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通过 SendNUIMessageRegisterNUICallback,客户端Lua负责ped生成、摄像机设置以及将选择转发到服务器。在选择期间冻结玩家并隐藏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而非玩家的license或服务器ID作为键。这包括库存、电话数据、银行、车辆所有权、住房和犯罪记录。常见错误是使用玩家的服务器源ID作为数据库键,这在切换角色时会完全失效。审计服务器上的每个资源,确保它们都引用活动角色ID,您可以通过类似的导出暴露该ID。 exports['multichar']:GetCharacterId(source).

角色切换与会话管理

允许玩家在不完全断开连接重连的情况下切换角色是一项提升体验的功能,玩家非常喜欢。当玩家触发角色切换时,服务器保存当前角色所有数据,清除所有角色相关状态,并将玩家送回选择界面。客户端则销毁当前模型,移除与角色工作或财产相关的所有标记和指示器,关闭所有活动的 NUI 界面,并将摄像机重置到选择视图。服务器上的每个资源都会接收到 multichar:characterUnloaded 事件,以便它们清理自身状态。这正是数据隔离不良暴露的地方:如果资源按来源ID缓存数据而非角色ID,切换角色时会导致数据泄漏。彻底测试角色切换至关重要。创建两个拥有不同职业、库存和银行余额的角色,然后快速切换,确认数据不会相互泄漏。

准备好开始了吗?

在我们的商店获取脚本,或加入 Discord 获取支持、更新以及新功能预告。