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 时重复读取配置文件。监控购买日志异常,如同一玩家短时间内购买数十辆车,可能表明利用漏洞或资金复制,需调查。