返回博客
Guide3 分钟阅读

FiveM 汽车经销商系统:正确编写经销商

构建逼真的 FiveM 汽车经销商。试驾、展厅设置、NPC 和玩家运营选项,以及适用于 QBCore 和 ESX 的最佳经销商脚本。

Agency Scripts

Agency Scripts 创始人兼首席开发者

车辆经销商架构

汽车经销系统是任何 FiveM 角色扮演服务器的主要经济驱动力之一,是玩家获取车辆的入口。设计良好的经销商不仅提供简单购买菜单,还具备展厅车辆预览、试驾、融资选项、以旧换新及销售跟踪等功能。架构分为车辆目录系统(定义车辆及价格分类)、展厅展示(供玩家购买前查看)、交易引擎(处理购买和融资)及员工管理层(玩家经营的经销商)。各层通过服务器验证事件通信,防止价格操控和非法生成车辆。

车辆目录和分类系统

车辆目录定义了所有可购买车辆,按类别组织便于浏览。每条目包括生成名称、显示标签、价格、类别及可选元数据,如最高速度和座位数,用于展厅显示。目录存储在客户端和服务器共享的配置文件中,确保价格验证在服务器端进行,客户端使用相同数据渲染。以下是结构化目录定义:

Config.VehicleCatalog = {
    categories = {
        {id = 'sedan', label = 'Sedans', icon = 'fa-car'},
        {id = 'sport', label = 'Sports Cars', icon = 'fa-flag-checkered'},
        {id = 'suv', label = 'SUVs & Trucks', icon = 'fa-truck'},
        {id = 'muscle', label = 'Muscle Cars', icon = 'fa-bolt'},
        {id = 'motorcycle', label = 'Motorcycles', icon = 'fa-motorcycle'},
    },
    vehicles = {
        -- Sedans
        {model = 'sultan', label = 'Karin Sultan', price = 24500,
         category = 'sedan', seats = 4, testDrive = true},
        {model = 'schafter2', label = 'Benefactor Schafter', price = 38000,
         category = 'sedan', seats = 4, testDrive = true},
        -- Sports
        {model = 'elegy2', label = 'Annis Elegy RH8', price = 95000,
         category = 'sport', seats = 2, testDrive = true},
        {model = 'comet2', label = 'Pfister Comet', price = 110000,
         category = 'sport', seats = 2, testDrive = true},
        -- SUVs
        {model = 'baller', label = 'Gallivanter Baller', price = 55000,
         category = 'suv', seats = 4, testDrive = true},
        -- Muscle
        {model = 'dominator', label = 'Vapid Dominator', price = 42000,
         category = 'muscle', seats = 2, testDrive = true},
        -- Motorcycles
        {model = 'bati', label = 'Pegassi Bati 801', price = 18000,
         category = 'motorcycle', seats = 2, testDrive = true},
    },
}

-- Build lookup table for fast server-side price validation
Config.VehiclePrices = {}
for _, v in ipairs(Config.VehicleCatalog.vehicles) do
    Config.VehiclePrices[v.model] = v.price
end

价格查询表 Config.VehiclePrices 在服务器上启用O(1)价格验证,防止客户端发送被篡改的价格。处理购买前务必根据此表验证模型名称。如果希望服务器管理员通过管理面板调整价格而无需重启服务器,考虑从数据库表加载车辆价格,而非配置文件。

展厅预览系统

展厅让玩家在受控环境中检查车辆,然后再决定购买。在指定的展厅位置生成预览车辆,应用玩家可围绕车辆旋转的摄像机,并在模型旁显示属性。预览车辆应不可交互,且在玩家关闭菜单或选择其他车辆时销毁。以下是客户端预览逻辑:

-- client/showroom.lua
local previewVehicle = nil
local previewCam = nil
local camAngle = 0.0

function ShowVehiclePreview(modelName)
    -- Clean up previous preview
    DestroyPreview()

    local model = GetHashKey(modelName)
    RequestModel(model)
    while not HasModelLoaded(model) do Wait(10) end

    local showroomPos = Config.ShowroomPosition -- vector4
    previewVehicle = CreateVehicle(model, showroomPos.x, showroomPos.y,
        showroomPos.z, showroomPos.w, false, false)

    SetEntityInvincible(previewVehicle, true)
    SetVehicleDoorsLocked(previewVehicle, 2)
    FreezeEntityPosition(previewVehicle, true)
    SetVehicleOnGroundProperly(previewVehicle)
    SetModelAsNoLongerNeeded(model)

    -- Create orbiting camera
    previewCam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
    UpdateCameraPosition()
    SetCamActive(previewCam, true)
    RenderScriptCams(true, true, 500, true, true)
end

function UpdateCameraPosition()
    if not previewCam or not previewVehicle then return end
    local vehPos = GetEntityCoords(previewVehicle)
    local radius = 6.0
    local height = 2.0
    local rad = math.rad(camAngle)
    local camX = vehPos.x + radius * math.cos(rad)
    local camY = vehPos.y + radius * math.sin(rad)
    SetCamCoord(previewCam, camX, camY, vehPos.z + height)
    PointCamAtEntity(previewCam, previewVehicle, 0.0, 0.0, 0.0, true)
end

function DestroyPreview()
    if previewVehicle then
        DeleteEntity(previewVehicle)
        previewVehicle = nil
    end
    if previewCam then
        SetCamActive(previewCam, false)
        RenderScriptCams(false, true, 500, true, true)
        DestroyCam(previewCam, false)
        previewCam = nil
    end
end

允许在展厅开启时通过鼠标移动或键盘控制旋转摄像机。轨道摄像机方式让玩家无需绕行即可从各角度查看车辆。为预览添加车辆颜色自定义,玩家可在购买前查看理想的喷漆效果,减少买家后悔和客服请求。

购买与交易处理

车辆购买必须完全在服务器端处理以防止被利用。服务器验证玩家是否有能力购买车辆,扣除付款,生成唯一车牌,在数据库中创建车辆记录,并通知客户端生成购买的车辆。支持全现金购买和银行转账,并可选择集成您的银行系统进行电汇:

RegisterNetEvent('dealer:server:purchaseVehicle', function(modelName, paymentType)
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    -- Validate model exists and get price
    local price = Config.VehiclePrices[modelName]
    if not price then
        TriggerClientEvent('QBCore:Notify', src, 'Vehicle not available', 'error')
        return
    end

    -- Check payment
    local moneyType = paymentType == 'bank' and 'bank' or 'cash'
    if Player.PlayerData.money[moneyType] < price then
        TriggerClientEvent('QBCore:Notify', src, 'Insufficient funds', 'error')
        return
    end

    -- Generate unique plate
    local plate = GenerateUniquePlate()

    -- Process payment
    Player.Functions.RemoveMoney(moneyType, price, 'vehicle-purchase-' .. modelName)

    -- Create vehicle record
    local vehicleHash = GetHashKey(modelName)
    MySQL.insert([[
        INSERT INTO player_vehicles
        (citizenid, vehicle, hash, plate, garage, state, fuel, engine, body)
        VALUES (?, ?, ?, ?, ?, 1, 100, 1000.0, 1000.0)
    ]], {
        Player.PlayerData.citizenid,
        modelName,
        tostring(vehicleHash),
        plate,
        'pillboxgarage',
    })

    -- Log transaction
    MySQL.insert([[
        INSERT INTO vehicle_sales (citizenid, vehicle, plate, price, sold_at)
        VALUES (?, ?, ?, ?, NOW())
    ]], {Player.PlayerData.citizenid, modelName, plate, price})

    TriggerClientEvent('QBCore:Notify', src,
        'Vehicle purchased! Plate: ' .. plate, 'success')
    TriggerClientEvent('dealer:client:vehiclePurchased', src, modelName, plate)
end)

function GenerateUniquePlate()
    local plate
    repeat
        plate = ''
        local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
        for i = 1, 8 do
            local idx = math.random(1, #chars)
            plate = plate .. chars:sub(idx, idx)
        end
        local exists = MySQL.scalar.await(
            'SELECT 1 FROM player_vehicles WHERE plate = ?', {plate}
        )
    until not exists
    return plate
end

GenerateUniquePlate function 使用重试循环保证车牌在整个数据库中的唯一性。虽然8位字母数字字符的碰撞概率极低,但此检查确保绝对安全。将每笔销售记录在单独表中以供管理审核和服务器经济分析,跟踪最受欢迎的车辆及经销商的总现金流。

试驾系统

试驾让玩家在购买前体验车辆,尤其对昂贵车辆重要。生成临时车辆,设定计时器和地理边界,时间到或离开允许区域时自动将玩家送回经销商。标记试驾车辆,禁止存入车库或改装,防止玩家利用系统获取免费车辆:

-- Client: test drive logic
local testDriveVehicle = nil
local testDriveTimer = 0
local testDriveActive = false

function StartTestDrive(modelName, duration)
    local model = GetHashKey(modelName)
    RequestModel(model)
    while not HasModelLoaded(model) do Wait(10) end

    local spawnPos = Config.TestDriveSpawn
    testDriveVehicle = CreateVehicle(model, spawnPos.x, spawnPos.y,
        spawnPos.z, spawnPos.w, true, false)

    SetVehicleNumberPlateText(testDriveVehicle, 'TESTDRVE')
    TaskWarpPedIntoVehicle(PlayerPedId(), testDriveVehicle, -1)
    SetModelAsNoLongerNeeded(model)

    testDriveTimer = duration
    testDriveActive = true

    -- Timer and boundary check thread
    CreateThread(function()
        while testDriveActive and testDriveTimer > 0 do
            Wait(1000)
            testDriveTimer = testDriveTimer - 1

            -- Show remaining time
            SendNUIMessage({
                action = 'updateTestDrive',
                timeLeft = testDriveTimer
            })

            -- Check boundary
            local playerPos = GetEntityCoords(PlayerPedId())
            local dealerPos = Config.DealerLocation
            if #(playerPos - dealerPos) > Config.TestDriveRadius then
                QBCore.Functions.Notify('Too far from dealer, returning...', 'error')
                EndTestDrive()
                return
            end
        end

        if testDriveActive then
            EndTestDrive()
        end
    end)
end

function EndTestDrive()
    testDriveActive = false
    if testDriveVehicle and DoesEntityExist(testDriveVehicle) then
        DeleteEntity(testDriveVehicle)
        testDriveVehicle = nil
    end

    local returnPos = Config.DealerLocation
    SetEntityCoords(PlayerPedId(), returnPos.x, returnPos.y, returnPos.z)
    QBCore.Functions.Notify('Test drive ended', 'info')
end

地理边界防止玩家将测试车辆开遍地图后遗弃。设置合理半径,允许通过附近街道进行有意义的驾驶,同时保持车辆可回收。"TESTDRVE" 车牌作为视觉指示,告知其他玩家该车辆为临时车辆。

融资与付款计划

对于昂贵车辆,提供分期付款选项,玩家支付首付后分期付款。通过数据库表跟踪贷款余额、付款计划和利率。若玩家未按时付款,车辆可被标记为回收。此功能增加经济深度,使高端车辆对现金不足的玩家也可负担。分期检查作为服务器端定时任务每日运行,自动从玩家银行账户扣款并通知。余额不足时增加未付款计数并发出警告,超过设定次数后标记车辆回收,车辆从玩家车库移除并返还经销商库存。

员工销售与佣金跟踪

玩家经营的经销商需要管理销售人员、跟踪业绩和分配佣金的工具。当经销商员工促成销售时,他们会获得车辆价格的可配置百分比作为佣金。在数据库表中跟踪每位员工的销售数量、总收入和佣金。创建一个通过经销商NUI访问的员工仪表盘,显示个人统计数据、近期销售历史和销售团队绩效排行榜。经销商所有者或经理等级应能访问管理面板,用于调整佣金率、添加或移除员工以及查看汇总销售报告。这将经销商从静态NPC交互转变为具有真实管理职责和竞争激励的动态玩家企业。

优化与防作弊措施

经销商脚本面临常见的漏洞利用向量,需要主动防范。最关键的是价格操控,即修改客户端发送低价购买请求。始终在服务器端验证价格,确保与目录一致,绝不信任客户端报告的数值。对购买事件进行速率限制,防止快速连续购买导致车辆复制或数据库竞态条件。对于展厅预览,确保预览车辆是用 false 用于网络参数,使其仅本地存在,其他玩家无法进入或盗取。在中清理预览车辆 onResourceStop handler 防止资源重启时出现孤立实体。对于拥有多个经销商的服务器,将车辆目录缓存于内存,仅在管理员触发刷新命令时重新加载,避免每次打开 NUI 时重复读取配置文件。监控购买日志异常,如同一玩家短时间内购买数十辆车,可能表明利用漏洞或资金复制,需调查。

准备好开始了吗?

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