返回博客
Guide4 分钟阅读

FiveM 制作系统:配方、工作站与进度

构建深度 FiveM 制作系统。配方逻辑、工作台、技能进阶以及适用于 QBCore、ESX 和 ox_inventory 的最佳制作脚本。

Agency Scripts

Agency Scripts 创始人兼首席开发者

为什么制作系统促进玩家参与

制作系统为玩家提供采集资源、探索地图、与其他玩家交易互动及投入时间打造实物的理由。没有制作,物品只能来自无限库存的商店或管理员生成,扁平化经济并剥夺玩家自主权。设计良好的制作系统构建供应链:一名玩家采矿原料,另一名精炼,第三名组装成品。这种相互依赖产生有机的角色扮演互动和经济活动。武器制作为犯罪地下世界增添风险与回报层次,合法制作路径如烹饪、裁缝或电子维修提供正当收入来源。本指南将构建完整制作框架,包含配方、工作台、技能进阶和蓝图发现。

设计配方系统

配方是任何制作系统的核心数据结构。每个配方定义所需输入物品、产出物品、制作时间、所需技能等级,以及可选的工作台类型。将配方存储在客户端和服务器都能访问的共享配置文件中,因为客户端需要配方数据来显示制作界面,服务器需要验证制作尝试。使用以唯一配方 ID 索引的扁平表以实现快速查找。包含一个 category 字段用于在UI中组织配方,且 successChance 字段可被玩家技能等级修改,为高阶制作增加风险因素。

-- shared/config.lua (accessible by both client and server)
Config = {}

Config.Recipes = {
    -- Weapons
    pistol_craft = {
        label      = 'Craft Pistol',
        category   = 'weapons',
        result     = {item = 'weapon_pistol', count = 1},
        ingredients = {
            {item = 'steel_plate',    count = 3},
            {item = 'weapon_spring',  count = 2},
            {item = 'rubber_grip',    count = 1},
            {item = 'gun_oil',        count = 1},
        },
        duration    = 15000,  -- 15 seconds
        skillReq    = 50,     -- crafting skill level
        workbench   = 'weapons_bench',
        successBase = 0.75,   -- 75% base success
        xpReward    = 25,
    },
    -- Electronics
    radio_craft = {
        label      = 'Assemble Radio',
        category   = 'electronics',
        result     = {item = 'radio', count = 1},
        ingredients = {
            {item = 'electronic_parts', count = 2},
            {item = 'copper_wire',      count = 3},
            {item = 'plastic_casing',   count = 1},
        },
        duration    = 8000,
        skillReq    = 10,
        workbench   = 'electronics_bench',
        successBase = 0.90,
        xpReward    = 10,
    },
    -- Cooking
    sandwich_craft = {
        label      = 'Make Sandwich',
        category   = 'cooking',
        result     = {item = 'sandwich', count = 2},
        ingredients = {
            {item = 'bread',   count = 2},
            {item = 'cheese',  count = 1},
            {item = 'lettuce', count = 1},
        },
        duration    = 3000,
        skillReq    = 0,
        workbench   = 'kitchen',
        successBase = 1.0,
        xpReward    = 3,
    },
}

Config.Workbenches = {
    weapons_bench = {
        label  = 'Weapons Workbench',
        model  = 'prop_tool_bench02',
        coords = {
            {x = 1083.5, y = -1975.3, z = 31.5, h = 140.0},
        },
    },
    electronics_bench = {
        label  = 'Electronics Station',
        model  = 'prop_cs_server_drive',
        coords = {
            {x = 732.1, y = -1073.8, z = 22.2, h = 90.0},
        },
    },
    kitchen = {
        label  = 'Kitchen',
        model  = 'prop_cooker_03',
        coords = {
            {x = -1192.3, y = -896.1, z = 13.9, h = 35.0},
        },
    },
}

服务器端制作逻辑

所有制作验证必须在服务器端进行以防止利用。服务器检查玩家背包中是否有所需材料,是否满足技能要求,是否靠近正确的工作台类型,且当前未处于制作过程。验证通过后,移除材料,启动计时器,根据基础成功率和技能等级计算成功概率,成功则授予制作物品,失败则返还部分材料。绝不信任客户端报告制作结果。客户端仅发送制作特定配方ID的请求,服务器独立验证所有条件。这防止了修改客户端声称制作物品但未消耗材料的物品复制漏洞。

-- server.lua
local craftingPlayers = {} -- track who is currently crafting
local playerSkills = {}    -- cache of player crafting XP

RegisterNetEvent('crafting:attempt', function(recipeId)
    local src = source
    if craftingPlayers[src] then
        TriggerClientEvent('notifications:show', src, 'Error', 'Already crafting.', 'error')
        return
    end

    local recipe = Config.Recipes[recipeId]
    if not recipe then return end

    -- Check skill requirement
    local skill = GetPlayerCraftingSkill(src)
    if skill < recipe.skillReq then
        TriggerClientEvent('notifications:show', src,
            'Error', 'Requires crafting level ' .. recipe.skillReq .. '.', 'error')
        return
    end

    -- Check ingredients
    for _, ing in ipairs(recipe.ingredients) do
        local count = exports.ox_inventory:GetItemCount(src, ing.item)
        if count < ing.count then
            TriggerClientEvent('notifications:show', src,
                'Error', 'Missing materials.', 'error')
            return
        end
    end

    -- Remove ingredients
    for _, ing in ipairs(recipe.ingredients) do
        exports.ox_inventory:RemoveItem(src, ing.item, ing.count)
    end

    craftingPlayers[src] = true
    TriggerClientEvent('crafting:startProgress', src, recipe.duration, recipe.label)

    -- Wait for crafting duration
    SetTimeout(recipe.duration, function()
        craftingPlayers[src] = nil

        -- Calculate success chance (skill bonus caps at +20%)
        local skillBonus = math.min((skill - recipe.skillReq) * 0.005, 0.20)
        local finalChance = math.min(recipe.successBase + skillBonus, 1.0)

        if math.random() <= finalChance then
            exports.ox_inventory:AddItem(src, recipe.result.item, recipe.result.count)
            AddCraftingXP(src, recipe.xpReward)
            TriggerClientEvent('notifications:show', src,
                'Success', 'Crafted: ' .. recipe.label, 'success')
        else
            -- Return 50% of materials on failure
            for _, ing in ipairs(recipe.ingredients) do
                local refund = math.floor(ing.count * 0.5)
                if refund > 0 then
                    exports.ox_inventory:AddItem(src, ing.item, refund)
                end
            end
            TriggerClientEvent('notifications:show', src,
                'Failed', 'Crafting failed. Some materials recovered.', 'error')
        end
    end)
end)

技能进阶与经验

技能进阶系统为投入制作的玩家增加长期目标和进步感。每次成功制作都会奖励经验点,累计提升技能等级。更高技能等级解锁高级配方并提升困难制作成功率。将技能数据按角色存储于数据库,确保数据持久且绑定角色而非玩家账号。定义明确里程碑:0-10级允许基础烹饪和维修,11-30级开放电子产品和基础工具,31-60级启用武器组件和高级制造,61-100级解锁稀有和传奇配方。在制作界面显示当前技能等级和进度条,让玩家随时了解距离下一级的距离。

-- Skill system (server.lua continued)
local skillCache = {}

function GetPlayerCraftingSkill(src)
    if skillCache[src] then return skillCache[src].level end
    return 0
end

function AddCraftingXP(src, amount)
    if not skillCache[src] then
        skillCache[src] = {xp = 0, level = 0}
    end

    skillCache[src].xp = skillCache[src].xp + amount
    local newLevel = CalculateLevel(skillCache[src].xp)

    if newLevel > skillCache[src].level then
        skillCache[src].level = newLevel
        TriggerClientEvent('notifications:show', src,
            'Level Up!', 'Crafting level: ' .. newLevel, 'success', 6000)
        -- Check for newly unlocked recipes
        local unlocked = GetNewlyUnlockedRecipes(newLevel, skillCache[src].level)
        for _, recipe in ipairs(unlocked) do
            TriggerClientEvent('notifications:show', src,
                'Recipe Unlocked', recipe.label .. ' is now available.', 'info', 5000)
        end
    end

    skillCache[src].level = newLevel
    SaveCraftingSkill(src)
end

function CalculateLevel(xp)
    -- Each level requires progressively more XP
    -- Level 1 = 50xp, Level 2 = 150xp, Level 10 = 2750xp, etc.
    local level = 0
    local required = 50
    local remaining = xp
    while remaining >= required and level < 100 do
        remaining = remaining - required
        level = level + 1
        required = math.floor(required * 1.15)
    end
    return level
end

function SaveCraftingSkill(src)
    local charId = exports['multichar']:GetCharacterId(src)
    if not charId or not skillCache[src] then return end
    MySQL.update(
        'INSERT INTO character_skills (character_id, skill, xp, level) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE xp = ?, level = ?',
        {charId, 'crafting', skillCache[src].xp, skillCache[src].level, skillCache[src].xp, skillCache[src].level}
    )
end

工作台交互和客户端 UI

工作台是游戏世界中的物理位置,玩家可以在那里制作物品。每种工作台类型支持特定类别的配方:武器工作台用于枪械,厨房用于食物,电子站用于小工具。在客户端,生成一个标记或使用如 ox_target 的目标系统,在每个工作台位置创建交互点。当玩家交互时,检查其接近度,根据工作台类型和玩家技能等级筛选可用配方,并打开制作界面。界面应显示每个配方的名称、所需材料及当前库存数量、成功概率、制作时间和技能经验奖励。高亮显示玩家拥有所有所需材料的配方,缺少材料的配方则显示为暗淡。在制作过程中,播放适当的动画,显示进度条,并禁用玩家移动,以防止玩家在制作中途走开造成的漏洞。

-- client.lua
local isCrafting = false

-- Setup workbench interaction points
CreateThread(function()
    for benchType, bench in pairs(Config.Workbenches) do
        for _, loc in ipairs(bench.coords) do
            exports.ox_target:addSphereZone({
                coords = vector3(loc.x, loc.y, loc.z),
                radius = 1.5,
                options = {
                    {
                        label = 'Use ' .. bench.label,
                        icon  = 'fas fa-tools',
                        onSelect = function()
                            OpenCraftingMenu(benchType)
                        end,
                        canInteract = function()
                            return not isCrafting
                        end
                    }
                }
            })
        end
    end
end)

function OpenCraftingMenu(benchType)
    local recipes = {}
    for id, recipe in pairs(Config.Recipes) do
        if recipe.workbench == benchType then
            recipes[#recipes + 1] = {
                id          = id,
                label       = recipe.label,
                category    = recipe.category,
                ingredients = recipe.ingredients,
                duration    = recipe.duration,
                skillReq    = recipe.skillReq,
                successBase = recipe.successBase,
                xpReward    = recipe.xpReward,
                result      = recipe.result,
            }
        end
    end
    -- Send to NUI or use a menu library
    SendNUIMessage({action = 'openCrafting', recipes = recipes, benchType = benchType})
    SetNuiFocus(true, true)
end

RegisterNetEvent('crafting:startProgress', function(duration, label)
    isCrafting = true
    local ped = PlayerPedId()
    FreezeEntityPosition(ped, true)

    -- Play crafting animation
    RequestAnimDict('mini@repair')
    while not HasAnimDictLoaded('mini@repair') do Wait(10) end
    TaskPlayAnim(ped, 'mini@repair', 'fixing_a_player', 8.0, -8.0, -1, 1, 0, false, false, false)

    -- Progress bar via ox_lib or custom NUI
    exports.ox_lib:progressBar({
        duration = duration,
        label    = label,
        useWhileDead  = false,
        canCancel     = false,
        disable = {move = true, car = true, combat = true},
    })

    ClearPedTasks(ped)
    FreezeEntityPosition(ped, false)
    isCrafting = false
end)

生产链与材料采集

生产链通过要求玩家将原材料经过多个阶段加工成最终产品来增加深度。铁矿石必须在熔炉中熔炼成钢锭,然后在金属加工厂压制成钢板,最后在武器工作台制作枪械。每一步需要不同的工作台位置,可能还需要不同技能。此链条设计鼓励专业化,一些玩家专注采矿和冶炼,另一些专注组装。材料采集点应分布在地图各处,使用区域让玩家执行采集动作,如采矿岩石节点、采摘植物或从废品场搜集电子元件。采集节点应设有重生计时器,使资源有限,玩家争夺访问权,推动基于领地的角色扮演冲突。

蓝图发现与稀有配方

并非所有配方一开始都可用。蓝图发现通过将高级配方隐藏在战利品掉落、任务完成、NPC购买或随机世界事件后,增加探索和进展激励。当玩家发现蓝图时,它会添加到存储在数据库中的个人配方列表中。蓝图可作为可交易物品,形成二级市场,使稀有配方成为有价值的商品。例如,军用武器蓝图可能仅从特定抢劫奖励中掉落,而传奇烹饪配方可从仅在特定时间出现的隐藏NPC处购买。制作UI根据玩家解锁的蓝图与完整配方列表对比,仅显示可用配方,营造每个新蓝图都是有意义解锁、扩展制作能力的进展感。

-- Blueprint system (server.lua)
RegisterNetEvent('crafting:useBlueprint', function(blueprintItem)
    local src = source
    local charId = exports['multichar']:GetCharacterId(src)
    if not charId then return end

    -- Validate the player has the blueprint item
    local count = exports.ox_inventory:GetItemCount(src, blueprintItem)
    if count < 1 then return end

    -- Map blueprint items to recipe IDs
    local blueprintMap = {
        blueprint_pistol     = 'pistol_craft',
        blueprint_radio      = 'radio_craft',
        blueprint_armor      = 'armor_craft',
        blueprint_lockpick   = 'adv_lockpick_craft',
    }

    local recipeId = blueprintMap[blueprintItem]
    if not recipeId then return end

    -- Check if already unlocked
    local exists = MySQL.scalar.await(
        'SELECT 1 FROM character_blueprints WHERE character_id = ? AND recipe_id = ?',
        {charId, recipeId}
    )
    if exists then
        TriggerClientEvent('notifications:show', src,
            'Info', 'You already know this recipe.', 'info')
        return
    end

    -- Consume blueprint and unlock recipe
    exports.ox_inventory:RemoveItem(src, blueprintItem, 1)
    MySQL.insert(
        'INSERT INTO character_blueprints (character_id, recipe_id) VALUES (?, ?)',
        {charId, recipeId}
    )

    local recipe = Config.Recipes[recipeId]
    TriggerClientEvent('notifications:show', src,
        'Blueprint Learned', 'You can now craft: ' .. (recipe and recipe.label or recipeId), 'success', 6000)
end)

准备好开始了吗?

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