返回博客
Tutorial3 分钟阅读

FiveM 拖车系统:专业拖车工作脚本

在 FiveM 中运行拖车工作。NPC 请求、扣押逻辑、平板动画以及适用于机械师和警察集成的最佳拖车脚本。

Agency Scripts

Agency Scripts 创始人兼首席开发者

拖车系统概述

拖车和扣押系统在FiveM服务器上构建完整工作生态,赋予玩家与执法、机械师及其他司机自然互动的平民职业路径。系统包含多个互联组件:管理上下班的工作管理层,向可用司机派发拖车请求的调度系统,模拟真实拖车的车辆附加机制,存放扣押和遗弃车辆的扣押场,以及管理取车费用的收费结构。设计良好时,拖车工作成为服务器最具社交性的角色之一,因为每次呼叫都涉及与需要帮助的玩家直接互动。

工作设置和值班系统

首先在你的框架的职业系统中定义拖车工作。玩家应能在拖车场位置打卡,领取公司拖车,并开始接受调度呼叫。值班系统应跟踪活跃的拖车司机,以便调度系统知道谁可用。玩家打卡时,从预定义的拖车车型列表中生成一辆平板拖车并分配给该玩家。打卡下班时,公司车辆应归还或删除。将玩家的值班状态存储在服务器端,以确保其正确持久化并可被调度系统查询:

Config.TowJob = {
    jobName = 'tow',
    clockInLocation = vector3(409.09, -1622.57, 29.29),
    vehicleSpawn = vector4(405.87, -1631.14, 29.29, 228.35),
    towVehicles = {
        {model = 'flatbed', label = 'Flatbed Truck', rank = 0},
        {model = 'towtruck', label = 'Tow Truck (Hook)', rank = 1},
        {model = 'towtruck2', label = 'Heavy Tow Truck', rank = 2},
    },
    impoundLot = vector3(409.59, -1637.87, 29.29),
    impoundReturn = vector4(403.12, -1640.23, 29.29, 138.92),
    payPerTow = {min = 350, max = 750},
    payPerImpound = 500,
}

RegisterNetEvent('tow:server:clockIn', function()
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    if Player.PlayerData.job.name ~= Config.TowJob.jobName then
        TriggerClientEvent('QBCore:Notify', src, 'You are not a tow driver', 'error')
        return
    end

    Player.Functions.SetJobDuty(true)
    TowDrivers[src] = {
        onDuty = true,
        currentCall = nil,
        vehicleNet = nil,
        towedVehicle = nil,
    }

    TriggerClientEvent('QBCore:Notify', src, 'You are now on duty', 'success')
    TriggerClientEvent('tow:client:spawnVehicle', src)
end)

车辆附加机制

拖车系统的核心技术挑战是以看起来自然且驾驶时行为正确的方式将一辆车连接到另一辆车。FiveM 提供了 AttachEntityToEntity native 用于此目的,但需要为每个拖车模型仔细校准偏移位置。平板拖车方法适用于大多数车辆:被拖车辆放置在平板平台上,带有适当偏移,使其自然地坐落在车床上。对于钩式拖车,被拖车辆的前轮被抬起,后轮仍接触地面。每种连接方式需要不同的偏移向量和旋转值。通过游戏内微调这些值,直到车辆正确放置且不穿透拖车:

function AttachVehicleToFlatbed(towTruck, targetVehicle)
    local towModel = GetEntityModel(towTruck)

    -- Offsets calibrated for the flatbed model
    local offsets = {
        ['flatbed'] = {x = 0.0, y = -0.5, z = 1.1, rx = 0.0, ry = 0.0, rz = 0.0},
    }

    local offset = offsets['flatbed']
    if not offset then return false end

    -- Freeze target vehicle physics
    SetEntityCollision(targetVehicle, false, false)
    FreezeEntityPosition(targetVehicle, true)

    -- Attach to flatbed
    AttachEntityToEntity(
        targetVehicle, towTruck,
        0, -- bone index
        offset.x, offset.y, offset.z,
        offset.rx, offset.ry, offset.rz,
        false, false, false, false, 0, true
    )

    -- Store attachment state
    TriggerServerEvent('tow:server:vehicleAttached', VehToNet(towTruck), VehToNet(targetVehicle))
    return true
end

function DetachVehicleFromFlatbed(towTruck, targetVehicle)
    DetachEntity(targetVehicle, true, true)
    SetEntityCollision(targetVehicle, true, true)
    FreezeEntityPosition(targetVehicle, false)

    -- Place vehicle on ground behind the truck
    local truckCoords = GetEntityCoords(towTruck)
    local truckHeading = GetEntityHeading(towTruck)
    local behind = GetOffsetFromEntityInWorldCoords(towTruck, 0.0, -12.0, 0.0)

    SetEntityCoords(targetVehicle, behind.x, behind.y, behind.z)
    SetEntityHeading(targetVehicle, truckHeading)
    SetVehicleOnGroundProperly(targetVehicle)

    TriggerServerEvent('tow:server:vehicleDetached', VehToNet(towTruck))
end

一个重要细节是禁用附加车辆的碰撞以防止物理故障。当两辆车均启用碰撞且相互附加时,游戏引擎可能产生剧烈抖动或将车辆弹射至空中。冻结目标车辆位置并在附加时禁用其碰撞可完全消除这些问题。

调度和拖车请求系统

调度系统连接需要拖车的玩家与可用拖车司机。玩家可通过手机、机械店NPC或地图周围的路边呼叫箱请求拖车。请求到达时,服务器检查可用拖车司机并将呼叫路由给最近的司机。若无司机值班,提供备用NPC拖车服务,收费更高,延迟后将车辆传送至扣押场。调度通知应包含请求者位置、车辆型号及拖车原因,如故障、事故或非法停车。拖车司机可选择接受或拒绝呼叫,拒绝的呼叫将转给下一个最近司机:

RegisterNetEvent('tow:server:requestTow', function(reason)
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    local playerCoords = GetEntityCoords(GetPlayerPed(src))
    local vehicle = GetVehiclePedIsIn(GetPlayerPed(src), true)
    local vehicleModel = vehicle ~= 0 and GetDisplayNameFromVehicleModel(GetEntityModel(vehicle)) or 'Unknown'

    -- Find nearest available tow driver
    local nearestDriver = nil
    local nearestDist = math.huge

    for driverId, data in pairs(TowDrivers) do
        if data.onDuty and not data.currentCall then
            local driverCoords = GetEntityCoords(GetPlayerPed(driverId))
            local dist = #(playerCoords - driverCoords)
            if dist < nearestDist then
                nearestDist = dist
                nearestDriver = driverId
            end
        end
    end

    if nearestDriver then
        TowDrivers[nearestDriver].currentCall = {
            requesterId = src,
            coords = playerCoords,
            vehicle = vehicleModel,
            reason = reason,
            timestamp = os.time(),
        }

        TriggerClientEvent('tow:client:incomingCall', nearestDriver, {
            coords = playerCoords,
            vehicle = vehicleModel,
            reason = reason,
            playerName = Player.PlayerData.charinfo.firstname,
        })

        TriggerClientEvent('QBCore:Notify', src, 'Tow truck dispatched. ETA based on driver location.', 'success')
    else
        TriggerClientEvent('QBCore:Notify', src, 'No tow drivers available. NPC service will arrive shortly.', 'info')
        StartNPCTowService(src, playerCoords, vehicle)
    end
end)

扣押场管理

扣押场是存放被拖走和扣押车辆的地方,直到车主取回。设计扣押数据库以跟踪当前扣押的车辆、扣押者、扣押时间及释放所需费用。警察应能在交通拦截或追捕后直接扣押车辆,这类扣押费用更高或强制保留时间更长。扣押场界面应显示玩家被扣押车辆列表,包含车辆型号、车牌号、扣押日期、原因及释放费用。玩家在扣押场柜台支付费用,车辆在附近指定归还点生成:

CREATE TABLE IF NOT EXISTS impounded_vehicles (
    id INT AUTO_INCREMENT PRIMARY KEY,
    plate VARCHAR(10) NOT NULL,
    citizenid VARCHAR(50) NOT NULL,
    vehicle_data LONGTEXT NOT NULL,
    impound_reason ENUM('tow', 'police', 'abandoned', 'parking') DEFAULT 'tow',
    impound_fee INT DEFAULT 250,
    impounded_by VARCHAR(50) DEFAULT NULL,
    impounded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    hold_until TIMESTAMP DEFAULT NULL,
    released_at TIMESTAMP DEFAULT NULL,
    INDEX idx_plate (plate),
    INDEX idx_citizen (citizenid),
    INDEX idx_released (released_at)
);

-- Server-side: Impound a vehicle
RegisterNetEvent('tow:server:impoundVehicle', function(vehicleNet, reason)
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    local vehicle = NetworkGetEntityFromNetworkId(vehicleNet)
    if not DoesEntityExist(vehicle) then return end

    local plate = GetVehicleNumberPlateText(vehicle)
    local vehicleData = QBCore.Functions.GetVehicleProperties(vehicle)
    local fee = Config.ImpoundFees[reason] or 250

    MySQL.insert('INSERT INTO impounded_vehicles (plate, citizenid, vehicle_data, impound_reason, impound_fee, impounded_by) VALUES (?, ?, ?, ?, ?, ?)', {
        plate:trim(),
        GetVehicleOwner(plate),
        json.encode(vehicleData),
        reason,
        fee,
        Player.PlayerData.citizenid,
    })

    DeleteEntity(vehicle)
    local pay = math.random(Config.TowJob.payPerImpound - 100, Config.TowJob.payPerImpound + 100)
    Player.Functions.AddMoney('bank', pay, 'tow-impound')
    TriggerClientEvent('QBCore:Notify', src, 'Vehicle impounded. Earned $' .. pay, 'success')
end)

自动停车执法

添加一个自动检测停放在限制区域车辆的系统,并在可配置的宽限期后标记其拖车。定义消防栓、公交站、警察局和医院急诊入口周围的限制停车区。当玩家在限制区停车并离开时,启动计时器。如果车辆在宽限期后仍无人看管,生成拖车请求并通知可用拖车司机。此系统为拖车司机创造自然工作机会,无需玩家手动呼叫服务。系统仅标记玩家拥有的车辆,因为NPC车辆由游戏引擎管理并自然消失。包括通知车辆所有者其车辆已被标记拖车,给予他们返回移动车辆的机会。

收入与进展

将拖车工作结构化为一个进阶系统,奖励持续工作。新的拖车司机从零级开始,使用基本的平板拖车,并根据完成的拖车数量获得基础报酬。随着完成拖车数量的积累,他们升级并解锁能够处理卡车和公交车等大型车辆的更重型拖车,获得支付更高的警察扣押合同,并拥有可自定义的个人拖车。跟踪完成的拖车总数、拖车时驾驶的距离和对调度呼叫的平均响应时间等统计数据。在通过NPC或手机应用访问的工作仪表板中显示这些统计数据。考虑实现一个声誉系统,持续快速响应且完成工作时不损坏车辆的拖车司机可获得工资奖金倍增,而忽视呼叫或拖车过程中损坏车辆的司机则面临工资处罚。这创造了一个自然的质量激励,确保拖车服务可靠且专业,惠及整个服务器社区。

处理边缘情况和网络

拖车系统存在多个需要谨慎处理的棘手边缘情况。当拖车司机在拖车过程中断线时,服务器应检测断线并安全分离及放置被拖车辆,避免车辆悬浮或穿透地图。如果车辆所有者在车辆被拖车时登录,他们应在车辆列表中看到车辆被标记为正在被拖,并显示拖车司机信息。谨慎处理车辆实体所有权,因为附加原生函数仅在附加客户端拥有两个实体的所有权时可靠工作。使用 NetworkRequestControlOfEntity 确保拖车司机的客户端控制两辆车后再尝试连接。为实现多人同步,将连接状态广播给所有附近玩家,使他们在屏幕上看到被拖车辆处于正确位置。状态包非常适合此用途,因为它们会自动复制给所有实体范围内的客户端。

准备好开始了吗?

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