返回博客
Guide3 分钟阅读

FiveM 技能树系统:正确的升级进度

为您的FiveM服务器添加技能树。经验值、解锁、UI设计、职业关联以及最佳技能脚本,为玩家提供有意义的长期目标。

Agency Scripts

Agency Scripts 创始人兼首席开发者

为什么技能树改变了角色扮演服务器

大多数 FiveM 角色扮演服务器依赖基于职业的进阶,玩家选择职业后即可使用所有工具和能力。虽然实用,但缺乏成长感和长期投入感。技能树从根本上改变这一动态。通过引入 RPG 风格进阶,玩家通过行动获得经验,花费技能点解锁分支天赋树中的能力、特长和被动加成。修理数百辆车的技工逐渐变得更快更高效。反复开锁的罪犯变得更擅长开锁。这创造了有意义的角色发展,奖励投入,使每个角色独一无二。关键是设计一个增强角色扮演而非让服务器变成 MMO 刷级的系统,保持加成足够微妙,使技能差异补充 RP 场景而非主导。

设计技能树数据结构

设计良好的技能树始于清晰的数据结构。每棵技能树属于战斗、制作、驾驶、医疗或犯罪等类别。树内各节点代表可解锁技能。每节点有唯一标识、显示名称、描述、最大等级、每级点数消耗、必须先解锁的前置节点及实际游戏效果。将树定义存储于共享配置文件,服务器和客户端均可引用。玩家进度为单独数据结构,跟踪已解锁节点及等级,以及可用未花费技能点。将树定义与玩家进度分离,意味着只要节点标识稳定,可更新或平衡技能树而无需迁移玩家数据。

-- shared/skill_trees.lua
SkillTrees = {
    mechanic = {
        label = 'Mechanic',
        icon  = 'fa-wrench',
        nodes = {
            quickFix = {
                label    = 'Quick Fix',
                desc     = 'Reduce vehicle repair time',
                maxLevel = 5,
                cost     = 1,
                prereqs  = {},
                effect   = {type = 'repair_speed', perLevel = 0.10},
            },
            engineExpert = {
                label    = 'Engine Expert',
                desc     = 'Repair engines to higher condition',
                maxLevel = 3,
                cost     = 2,
                prereqs  = {'quickFix'},
                effect   = {type = 'engine_quality', perLevel = 0.15},
            },
            bodyworkMaster = {
                label    = 'Bodywork Master',
                desc     = 'Repair body damage more effectively',
                maxLevel = 3,
                cost     = 2,
                prereqs  = {'quickFix'},
                effect   = {type = 'body_quality', perLevel = 0.15},
            },
            turboTuner = {
                label    = 'Turbo Tuner',
                desc     = 'Unlock performance tuning abilities',
                maxLevel = 1,
                cost     = 5,
                prereqs  = {'engineExpert'},
                effect   = {type = 'unlock_tuning', perLevel = 1},
            },
        },
    },
    criminal = {
        label = 'Street Smarts',
        icon  = 'fa-mask',
        nodes = {
            lockpicking = {
                label    = 'Lockpicking',
                desc     = 'Pick locks faster with fewer failures',
                maxLevel = 5,
                cost     = 1,
                prereqs  = {},
                effect   = {type = 'lockpick_speed', perLevel = 0.12},
            },
            silentStep = {
                label    = 'Silent Step',
                desc     = 'Reduce noise while crouching',
                maxLevel = 3,
                cost     = 2,
                prereqs  = {'lockpicking'},
                effect   = {type = 'noise_reduction', perLevel = 0.20},
            },
            safecracker = {
                label    = 'Safecracker',
                desc     = 'Attempt to crack safes',
                maxLevel = 1,
                cost     = 4,
                prereqs  = {'lockpicking'},
                effect   = {type = 'unlock_safecrack', perLevel = 1},
            },
        },
    },
}

经验追踪与升级

经验应通过游戏内行为自然获得,而非通过重复刷环。按技能树类别追踪经验,例如机械工作获得机械经验,犯罪行为获得犯罪经验,依此类推。为每个等级定义经验阈值,当玩家跨越阈值时,获得一个或多个技能点用于该技能树。对重复相同行为使用递减收益,防止刷环。例如,10分钟内首次修车获得50经验,随后依次为25、10、5。此设计奖励多样化玩法,避免无脑重复。服务器应为经验授予的唯一权威,绝不信任客户端上报经验值。经验事件应由服务器端逻辑触发,确认行为确实发生后执行。

-- server/experience.lua
local XP_THRESHOLDS = {0, 100, 300, 600, 1000, 1500, 2200, 3000, 4000, 5500}

local recentActions = {}

function GrantXP(playerId, tree, amount, actionType)
    local identifier = GetPlayerIdentifier(playerId, 0)
    local now = os.time()

    -- Diminishing returns for repeated actions
    local key = identifier .. ':' .. tree .. ':' .. actionType
    if not recentActions[key] then
        recentActions[key] = {count = 0, resetAt = now + 600}
    end
    local ra = recentActions[key]
    if now > ra.resetAt then
        ra.count = 0
        ra.resetAt = now + 600
    end
    ra.count = ra.count + 1

    local multiplier = math.max(0.1, 1.0 - (ra.count - 1) * 0.25)
    local finalXP = math.floor(amount * multiplier)

    -- Update database
    MySQL.update([[
        UPDATE character_skills SET xp = xp + ?
        WHERE identifier = ? AND tree = ?
    ]], {finalXP, identifier, tree})

    -- Check for level up
    local data = MySQL.single.await([[
        SELECT xp, level, points FROM character_skills
        WHERE identifier = ? AND tree = ?
    ]], {identifier, tree})

    if data then
        local nextThreshold = XP_THRESHOLDS[data.level + 2]
        if nextThreshold and data.xp >= nextThreshold then
            local newLevel = data.level + 1
            MySQL.update([[
                UPDATE character_skills
                SET level = ?, points = points + 1
                WHERE identifier = ? AND tree = ?
            ]], {newLevel, identifier, tree})
            TriggerClientEvent('skills:levelUp', playerId, tree, newLevel)
        end
    end

    TriggerClientEvent('skills:xpGained', playerId, tree, finalXP)
end

将技能效果应用于游戏玩法

技能效果是系统对玩家变得具体的部分。每个解锁节点以可测量方式修改特定游戏机制。实现一个中央效果解析器,接受玩家标识和效果类型,然后计算所有贡献该效果的解锁节点的总加成。当其他脚本需要知道玩家的修理速度加成时,调用此解析器并返回乘数。这保持技能系统与其他资源解耦。其他脚本无需了解技能树、节点或等级;只需按类型请求加成值。对于可解锁能力如开锁或涡轮调校,解析器返回布尔值,指示玩家是否拥有所需节点。此架构便于将技能集成到现有脚本,且对方代码改动最小。

-- server/effects.lua
local playerSkillCache = {}

function GetSkillBonus(playerId, effectType)
    local identifier = GetPlayerIdentifier(playerId, 0)
    local cache = playerSkillCache[identifier]
    if not cache then return 0.0 end

    local total = 0.0
    for treeName, treeDef in pairs(SkillTrees) do
        for nodeId, nodeDef in pairs(treeDef.nodes) do
            if nodeDef.effect.type == effectType then
                local unlocked = cache[treeName] and cache[treeName][nodeId] or 0
                total = total + (nodeDef.effect.perLevel * unlocked)
            end
        end
    end
    return total
end

function HasSkillUnlock(playerId, effectType)
    return GetSkillBonus(playerId, effectType) > 0
end

-- Export for other resources
exports('GetSkillBonus', GetSkillBonus)
exports('HasSkillUnlock', HasSkillUnlock)

-- Example: another resource checking repair speed
-- local bonus = exports['skillsystem']:GetSkillBonus(source, 'repair_speed')
-- local repairTime = baseTime * (1.0 - bonus)

构建技能树 NUI

技能树的视觉呈现直接影响玩家对进度系统的参与感。构建一个NUI面板,将每棵树显示为带有节点和连接先决条件的分支图。已解锁节点应发光或脉动着色,锁定但可用的节点应稍显暗淡,未满足先决条件的节点应灰显。顶部突出显示玩家当前等级、XP进度条和可用技能点。玩家点击节点时,显示其描述、当前等级与最大等级、升级费用及每级效果加成。升级前包含确认步骤,因玩家可能想仔细规划构建。使用平滑的CSS过渡状态变化,使升级节点时有短暂闪光或缩放动画,感觉更有成就感。考虑添加重置选项,免费或需游戏内货币,方便玩家在元环境变化或想尝试不同玩法时重置构建。

平衡与防作弊考虑

平衡是任何进程系统中最难的部分。如果奖励过强,高级玩家变得无敌,新玩家感到无望;如果奖励过弱,则没人关心进程。初始时保守设定,最高等级每项技能奖励5%至15%,并监控玩家反馈。对每次技能点分配实施服务器端验证。玩家请求升级节点时,验证其拥有足够点数,满足所有前置条件,且节点未达最高等级。切勿信任客户端状态进行检查。为所有技能点交易添加日志,以便检测并回滚漏洞。限制技能分配请求频率,防止快速漏洞尝试。对于竞技场景,考虑技能上限,限制单个角色可投资的技能树数量,迫使玩家做出有意义选择,而非一人满级所有技能。这创造多样化角色构建,鼓励拥有互补技能的玩家合作。

数据库架构与迁移策略

设计数据库架构以优雅处理当前技能数据及未来树形扩展。将玩家技能分配存为每个角色每棵技能树的独立行,而非单一 JSON 块。这样查询特定技能更高效,且支持数据库级聚合分析。包含技能树名称、当前等级、总经验、可用点数及该树内节点分配的 JSON 列。未来新增技能树或节点时,现有玩家数据保持完整,新节点默认等级为零。重命名或移除节点时,通过迁移逻辑将已用点数退还至该树的可用点数池。为技能树定义版本控制,确保迁移逻辑正确应用变更。此前瞻性设计避免了服务器运行数月后技能系统演进时的痛苦数据迁移。

-- SQL schema
CREATE TABLE IF NOT EXISTS character_skills (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    identifier  VARCHAR(64) NOT NULL,
    char_slot   INT DEFAULT 1,
    tree        VARCHAR(32) NOT NULL,
    level       INT DEFAULT 0,
    xp          INT DEFAULT 0,
    points      INT DEFAULT 0,
    allocations JSON DEFAULT '{}',
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY unique_char_tree (identifier, char_slot, tree),
    INDEX idx_identifier (identifier)
);

准备好开始了吗?

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