返回博客
Guide7 分钟阅读

FiveM 声望系统:统计、技能与进阶

为FiveM添加声望和技能进阶。属性公式、解锁、UI条以及让玩家更长时间投入的最佳声望脚本。

Agency Scripts

Agency Scripts 创始人兼首席开发者

为什么声望系统改变角色扮演

大多数 FiveM 服务器将角色进阶视为二元状态:有职业或无职业。缺乏成长、精通或获得的身份感。声望与技能系统根本改变这一点,为玩家提供可衡量的进阶,奖励投入时间和完成的活动。技工修理第百辆车并解锁高级引擎调校时,这成就感真实存在。罪犯积累足够街头声望以使用高级抢劫装备时,有明显的进阶感推动游戏。本文将从零构建完整声望与技能框架,涵盖经验计算、技能树、声望等级、可解锁能力及保持终局玩家参与的声望系统。

玩家技能的数据库架构

任何进度系统的基础是设计良好的数据库模式。你需要表来跟踪个人技能经验值、每个派系或活动的声望分数以及解锁的能力。模式应足够规范化以便查询,但又足够非规范化以避免每次技能检测时进行昂贵的连接。我们使用一个单一的 player_skills 该表使用市民ID和技能名称的复合键,以及一个单独的 player_reputation 用于派系声望的表。此分离保持技能(个人能力)与声誉(NPC和派系对你的看法)区分开来,从而实现更细致的游戏互动。

-- SQL schema
CREATE TABLE IF NOT EXISTS player_skills (
    citizenid VARCHAR(50) NOT NULL,
    skill_name VARCHAR(50) NOT NULL,
    xp INT DEFAULT 0,
    level INT DEFAULT 1,
    prestige INT DEFAULT 0,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (citizenid, skill_name)
);

CREATE TABLE IF NOT EXISTS player_reputation (
    citizenid VARCHAR(50) NOT NULL,
    faction VARCHAR(50) NOT NULL,
    reputation INT DEFAULT 0,
    tier VARCHAR(20) DEFAULT 'neutral',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (citizenid, faction)
);

经验值计算和升级曲线

一个平坦的经验曲线,即每级需要相同经验,感觉不够有成就感,因为早期等级升级过快,而后期等级感觉无差别。最佳方法是使用多项式曲线,每级所需经验逐渐增加,但增长不至于使最高等级无法达到。常用公式是 requiredXP = baseXP * (level ^ exponent) 基数为 100,指数为 1.5,创建平滑曲线。等级 1 需要 100 XP,等级 10 约需 3,162 XP,等级 50 约需 35,355 XP。这保持了早期进度的快速感,同时使高级别成为需要专注游戏才能达到的真正成就。

-- shared/skills_config.lua
SkillsConfig = {}

SkillsConfig.BaseXP = 100
SkillsConfig.Exponent = 1.5
SkillsConfig.MaxLevel = 50

function SkillsConfig.GetRequiredXP(level)
    return math.floor(SkillsConfig.BaseXP * (level ^ SkillsConfig.Exponent))
end

function SkillsConfig.GetTotalXPForLevel(targetLevel)
    local total = 0
    for i = 1, targetLevel - 1 do
        total = total + SkillsConfig.GetRequiredXP(i)
    end
    return total
end

-- Skill definitions with XP sources
SkillsConfig.Skills = {
    driving = {
        label = 'Driving',
        icon = 'fa-car',
        xpSources = {
            { action = 'distance_driven', xpPer = 1, unit = 'per 500m' },
            { action = 'race_won', xpPer = 150, unit = 'per win' },
            { action = 'no_crash_streak', xpPer = 50, unit = 'per 5min' },
        }
    },
    shooting = {
        label = 'Marksmanship',
        icon = 'fa-crosshairs',
        xpSources = {
            { action = 'headshot', xpPer = 25, unit = 'per kill' },
            { action = 'bodyshot', xpPer = 10, unit = 'per kill' },
            { action = 'range_practice', xpPer = 5, unit = 'per target' },
        }
    },
    mechanic = {
        label = 'Mechanic',
        icon = 'fa-wrench',
        xpSources = {
            { action = 'vehicle_repaired', xpPer = 30, unit = 'per repair' },
            { action = 'engine_swap', xpPer = 100, unit = 'per swap' },
            { action = 'custom_tune', xpPer = 75, unit = 'per tune' },
        }
    },
    cooking = {
        label = 'Cooking',
        icon = 'fa-utensils',
        xpSources = {
            { action = 'meal_cooked', xpPer = 20, unit = 'per meal' },
            { action = 'recipe_discovered', xpPer = 50, unit = 'per recipe' },
        }
    },
    stamina = {
        label = 'Stamina',
        icon = 'fa-running',
        xpSources = {
            { action = 'distance_sprinted', xpPer = 1, unit = 'per 200m' },
            { action = 'swimming', xpPer = 2, unit = 'per 100m' },
        }
    },
}

服务器端经验值管理

所有经验值修改必须在服务器端进行以防止利用。服务器验证每个经验值授予请求,检查速率限制以防止刷屏,应用任何激活的倍数,并将结果持久化到数据库。关键设计决策是每次经验变化是否保存或批量写入。每次微小经验增长保存会严重影响数据库,尤其是被动技能如驾驶距离。解决方案是内存缓存定期刷新数据库,并对重要事件如升级立即保存。这既保证了玩家的实时准确性,也保持数据库负载可控。

-- server/skills_manager.lua
local playerCache = {}
local SAVE_INTERVAL = 60000 -- flush to DB every 60 seconds

-- Load player skills from DB on join
AddEventHandler('playerJoining', function()
    local src = source
    local citizenid = GetCitizenId(src)
    if not citizenid then return end

    local skills = MySQL.query.await('SELECT * FROM player_skills WHERE citizenid = ?', {citizenid})
    playerCache[citizenid] = {}
    for _, row in ipairs(skills or {}) do
        playerCache[citizenid][row.skill_name] = {
            xp = row.xp,
            level = row.level,
            prestige = row.prestige,
            dirty = false
        }
    end
end)

function AddSkillXP(citizenid, skillName, amount)
    if not SkillsConfig.Skills[skillName] then return end
    if not playerCache[citizenid] then return end

    local skill = playerCache[citizenid][skillName]
    if not skill then
        skill = { xp = 0, level = 1, prestige = 0, dirty = false }
        playerCache[citizenid][skillName] = skill
    end

    -- Apply prestige multiplier (each prestige = +5% XP)
    local multiplier = 1.0 + (skill.prestige * 0.05)
    local finalXP = math.floor(amount * multiplier)

    skill.xp = skill.xp + finalXP
    skill.dirty = true

    -- Check for level up
    local required = SkillsConfig.GetRequiredXP(skill.level)
    while skill.xp >= required and skill.level < SkillsConfig.MaxLevel do
        skill.xp = skill.xp - required
        skill.level = skill.level + 1
        required = SkillsConfig.GetRequiredXP(skill.level)

        -- Notify player of level up
        local src = GetPlayerByCitizenId(citizenid)
        if src then
            TriggerClientEvent('skills:levelUp', src, skillName, skill.level)
        end

        -- Immediate save on level up
        SaveSkill(citizenid, skillName, skill)
    end

    return skill
end

exports('AddSkillXP', AddSkillXP)

技能树与可解锁能力

技能树在原始升级基础上增加策略层。不同于所有20级机械师完全相同,技能树允许他们专精。一名机械师可能投资点数于引擎性能,解锁涡轮套件,另一名专注车身,获得专属喷漆和涂装。每级获得一点技能点,可在分支树中使用。树结构采用简单的父子关系,解锁子节点需父节点已解锁且达到最低技能等级,防止玩家跳跃至最强能力而忽略基础。

-- shared/skill_trees.lua
SkillTrees = {
    mechanic = {
        { id = 'basic_repair', label = 'Basic Repair', reqLevel = 1, parent = nil,
          effect = { type = 'speed', value = 1.0 }, desc = 'Standard repair speed' },
        { id = 'fast_repair', label = 'Quick Hands', reqLevel = 5, parent = 'basic_repair',
          effect = { type = 'speed', value = 1.3 }, desc = '30% faster repairs' },
        { id = 'engine_diag', label = 'Engine Diagnostics', reqLevel = 10, parent = 'basic_repair',
          effect = { type = 'unlock', value = 'engine_scan' }, desc = 'Scan engine health remotely' },
        { id = 'turbo_kit', label = 'Turbo Installation', reqLevel = 20, parent = 'engine_diag',
          effect = { type = 'unlock', value = 'install_turbo' }, desc = 'Install turbo kits on vehicles' },
        { id = 'master_tune', label = 'Master Tuner', reqLevel = 35, parent = 'turbo_kit',
          effect = { type = 'unlock', value = 'advanced_tune' }, desc = 'Access to advanced ECU tuning' },
        { id = 'body_expert', label = 'Body Expert', reqLevel = 10, parent = 'basic_repair',
          effect = { type = 'unlock', value = 'custom_paint' }, desc = 'Unlock exclusive paint options' },
        { id = 'livery_master', label = 'Livery Master', reqLevel = 25, parent = 'body_expert',
          effect = { type = 'unlock', value = 'custom_livery' }, desc = 'Create and apply custom liveries' },
    },
}

-- server: unlock a skill tree node
function UnlockNode(citizenid, skillName, nodeId)
    local tree = SkillTrees[skillName]
    if not tree then return false, 'No tree for skill' end

    local node = nil
    for _, n in ipairs(tree) do
        if n.id == nodeId then node = n break end
    end
    if not node then return false, 'Node not found' end

    local skill = GetPlayerSkill(citizenid, skillName)
    if not skill or skill.level < node.reqLevel then
        return false, 'Level too low'
    end

    if node.parent then
        local parentUnlocked = IsNodeUnlocked(citizenid, skillName, node.parent)
        if not parentUnlocked then return false, 'Parent node locked' end
    end

    MySQL.insert('INSERT INTO player_skill_nodes (citizenid, skill_name, node_id) VALUES (?, ?, ?)',
        {citizenid, skillName, nodeId})

    return true
end

声望等级与派系声望

声望跟踪组织和派系对玩家的看法,独立于纯技能。玩家可能是驾驶专家,但因犯罪行为在警察派系中声望极低。声望分为多个等级,从敌对到中立再到受尊敬,每个等级解锁不同的对话选项、任务访问和商店价格。提升一个派系的声望可能会降低对立派系的声望,形成有意义的权衡。例如,完成毒品配送会提升卡特尔声望,但降低警察声望。该系统鼓励玩家做出定义角色的选择,而不是同时最大化所有派系。

-- shared/reputation_config.lua
ReputationConfig = {
    tiers = {
        { name = 'hostile',   minRep = -1000, color = '#ef4444' },
        { name = 'unfriendly', minRep = -500, color = '#f97316' },
        { name = 'neutral',   minRep = 0,     color = '#94a3b8' },
        { name = 'friendly',  minRep = 500,   color = '#22c55e' },
        { name = 'honored',   minRep = 1500,  color = '#3b82f6' },
        { name = 'revered',   minRep = 3000,  color = '#a855f7' },
    },
    factions = {
        police   = { label = 'LSPD', opposing = {'cartel', 'gang_ballas'} },
        ems      = { label = 'EMS',  opposing = {} },
        cartel   = { label = 'Madrazo Cartel', opposing = {'police'} },
        mechanic = { label = 'LS Customs', opposing = {} },
        gang_ballas = { label = 'Ballas', opposing = {'police', 'gang_families'} },
        gang_families = { label = 'Families', opposing = {'gang_ballas'} },
    },
    opposingPenalty = 0.5, -- lose 50% of gained rep from opposing factions
}

-- server: modify reputation with faction cascading
function ModifyReputation(citizenid, faction, amount)
    local config = ReputationConfig.factions[faction]
    if not config then return end

    -- Apply to primary faction
    AdjustRep(citizenid, faction, amount)

    -- Penalize opposing factions
    if amount > 0 then
        for _, opposing in ipairs(config.opposing) do
            local penalty = math.floor(amount * ReputationConfig.opposingPenalty)
            AdjustRep(citizenid, opposing, -penalty)
        end
    end
end

声望系统

当玩家在某项技能达到最高等级后,他们需要理由继续参与该活动。声望系统通过允许玩家将技能重置为1级以换取永久加成来实现:一个显示在名字旁的外观声望徽章,该技能每个声望等级5%的经验加成,以及声望专属解锁内容,如独特车辆改装或稀有制作配方。声望计数无限,但每次声望所需时间更长,因为经验加成使早期等级变得轻松,而曲线在高等级仍然存在。UI中在技能名称旁以罗马数字或星星显示声望计数,清晰展示玩家的投入。

-- server: prestige a maxed skill
function PrestigeSkill(citizenid, skillName)
    local skill = playerCache[citizenid] and playerCache[citizenid][skillName]
    if not skill then return false, 'Skill not found' end
    if skill.level < SkillsConfig.MaxLevel then return false, 'Not max level' end

    -- Reset level and XP, increment prestige
    skill.level = 1
    skill.xp = 0
    skill.prestige = skill.prestige + 1
    skill.dirty = true

    -- Save immediately
    SaveSkill(citizenid, skillName, skill)

    -- Grant prestige reward
    local src = GetPlayerByCitizenId(citizenid)
    if src then
        TriggerClientEvent('skills:prestige', src, skillName, skill.prestige)
        -- Unlock prestige-specific items
        if skill.prestige == 1 then
            exports.ox_inventory:AddItem(src, 'prestige_badge_'..skillName, 1)
        elseif skill.prestige == 5 then
            exports.ox_inventory:AddItem(src, 'gold_tool_'..skillName, 1)
        end
    end

    return true, skill.prestige
end

RegisterNetEvent('skills:requestPrestige', function(skillName)
    local src = source
    local citizenid = GetCitizenId(src)
    local success, result = PrestigeSkill(citizenid, skillName)
    if success then
        lib.notify(src, { title = 'Prestige!', description = ('Prestige %d achieved for %s'):format(result, skillName), type = 'success' })
    else
        lib.notify(src, { title = 'Cannot Prestige', description = result, type = 'error' })
    end
end)

客户端UI和被动经验追踪

客户端处理驾驶距离和冲刺等活动的被动经验值跟踪,定期向服务器发送更新。常见错误是每移动一米发送事件,导致网络拥堵。应在本地变量累计距离,每30秒发送一次批量更新。UI方面,构建一个通过命令或快捷键访问的技能面板,显示所有技能的当前等级、经验进度条、声望计数和技能树。使用React或Vue等现代框架的NUI进行树状图可视化,锁定节点显示为灰色,解锁节点带有发光效果。经验进度条在获得经验时应平滑动画,提供令人满意的视觉反馈,加强进展循环,激励玩家持续发展角色。

准备好开始了吗?

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