Назад к блогу
Guide8 мин чтения

Система автосалона 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 функция использует цикл повторных попыток для гарантии уникальности номеров по всей базе данных. Хотя коллизии статистически редки при 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 обработчик для предотвращения оставшихся сущностей, если ресурс перезапускается. Для серверов с несколькими автосалонами кешируйте каталог транспортных средств в памяти и перезагружайте его только при команде обновления от администратора, избегая повторного чтения конфигурационных файлов при каждом открытии NUI. Отслеживайте логи покупок на предмет аномалий, таких как один и тот же игрок, покупающий десятки автомобилей подряд, что может указывать на эксплойт или дублирование денег, требующее расследования.

Готовы начать?

Возьмите скрипты в нашем магазине или заходите в Discord за поддержкой, обновлениями и анонсами.