Wróć do bloga
Guide8 min czytania

System salonu samochodowego FiveM: jak prawidłowo oskryptować salony

Stwórz realistyczny salon samochodowy w FiveM. Jazdy próbne, konfiguracja showroomu, warianty z NPC i prowadzone przez graczy oraz najlepsze skrypty salonu dla QBCore i ESX.

Agency Scripts

Założyciel i główny deweloper w Agency Scripts

Architektura salonu pojazdów

System salonu samochodowego to jeden z głównych motorów gospodarki na każdym serwerze roleplay w FiveM, stanowiący bramę, przez którą gracze zdobywają swoje pojazdy. Dobrze zaprojektowany salon wykracza poza proste menu zakupu, oferując funkcje takie jak podgląd pojazdów w showroomie, jazdy próbne, opcje finansowania, pozostawienie auta w rozliczeniu (trade-in) oraz śledzenie sprzedaży dla pracowników salonu. Architektura dzieli się na system katalogu definiujący dostępne pojazdy wraz z cenami i kategoriami, ekspozycję w showroomie pozwalającą graczom obejrzeć pojazdy przed zakupem, silnik transakcyjny obsługujący zakupy i finansowanie oraz warstwę zarządzania pracownikami dla salonów prowadzonych przez graczy. Każda warstwa komunikuje się za pośrednictwem eventów walidowanych przez serwer, aby zapobiec manipulacji cenami i nieautoryzowanemu spawnowaniu pojazdów.

Katalog pojazdów i system kategorii

Katalog pojazdów definiuje każde auto dostępne do zakupu, uporządkowane w kategorie dla łatwego przeglądania. Każdy wpis zawiera nazwę spawnu, etykietę wyświetlaną, cenę, kategorię oraz opcjonalne metadane, takie jak prędkość maksymalna i liczba miejsc do prezentacji w salonie. Przechowuj katalog we wspólnym pliku konfiguracyjnym, do którego odwołują się zarówno client, jak i server, co zapewnia walidację cen po stronie serwera, podczas gdy klient korzysta z tych samych danych do renderowania. Oto ustrukturyzowana definicja katalogu:

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

Tabela wyszukiwania cen Config.VehiclePrices umożliwia walidację cen w czasie O(1) po stronie serwera, uniemożliwiając klientom przesyłanie zmanipulowanych cen. Zawsze weryfikuj nazwę modelu z tą tabelą przed przetworzeniem zakupu. Rozważ ładowanie cen pojazdów z tabeli bazy danych zamiast z pliku konfiguracyjnego, jeśli chcesz, aby administratorzy serwera mogli dostosowywać ceny przez panel administratora bez restartowania serwera.

System podglądu showroomu

Showroom pozwala graczom obejrzeć pojazdy w kontrolowanym otoczeniu przed podjęciem decyzji o zakupie. Zresp pojazd podglądowy na wyznaczonej pozycji showroomu, ustaw kamerę, którą gracz może obracać wokół pojazdu, i wyświetl statystyki obok modelu. Pojazd podglądowy powinien być pozbawiony możliwości interakcji i usuwany po zamknięciu menu przez gracza lub wybraniu innego pojazdu. Oto logika podglądu po stronie klienta:

-- 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

Zezwalaj na obrót kamery za pomocą ruchów myszy lub sterowania z klawiatury, gdy showroom jest otwarty. Podejście z kamerą orbitalną pozwala graczom oglądać pojazd pod każdym kątem bez konieczności chodzenia wokół niego. Dodaj personalizację kolorów pojazdu do podglądu, aby gracze mogli zobaczyć pożądany lakier przed zakupem, co zmniejsza niezadowolenie po zakupie i liczbę zgłoszeń do supportu.

Zakup i przetwarzanie transakcji

Zakupy pojazdów muszą być w całości przetwarzane po stronie serwera, aby zapobiec exploitom. Serwer weryfikuje, czy gracza stać na pojazd, pobiera płatność, generuje unikalną tablicę rejestracyjną, tworzy wpis pojazdu w bazie danych i informuje klienta o konieczności zespawnowania zakupionego pojazdu. Obsługuj zarówno płatności gotówką, jak i przelewy bankowe, a opcjonalnie zintegruj całość z systemem bankowości w celu realizacji przelewów:

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

Ten GenerateUniquePlate funkcja używa pętli ponawiania, aby zagwarantować unikalność tablic rejestracyjnych w całej bazie danych. Choć kolizje są statystycznie rzadkie przy 8 znakach alfanumerycznych, sprawdzenie to zapewnia absolutne bezpieczeństwo. Zapisuj każdą sprzedaż w osobnej tabeli do wglądu administracji i analizy ekonomii serwera, śledząc, które pojazdy są najpopularniejsze oraz całkowity przepływ gotówki przez salon.

System jazd próbnych

Jazdy próbne pozwalają graczom przetestować pojazd przed zakupem, co jest szczególnie ważne w przypadku drogich pojazdów. Zespawnuj tymczasowy pojazd z timerem i granicą geograficzną, automatycznie odsyłając gracza do salonu, gdy skończy się czas lub opuści dozwolony obszar. Oznacz pojazd do jazdy próbnej, aby nie można go było zapisać w garażu ani zmodyfikować, uniemożliwiając graczom wykorzystanie luki do zdobycia darmowych pojazdów:

-- 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

Granica geograficzna zapobiega przejechaniu pojazdem testowym przez całą mapę i porzuceniu go. Ustaw promień na rozsądną odległość, która pozwala na swobodną przejażdżkę po okolicznych ulicach, jednocześnie umożliwiając odzyskanie pojazdu. Tablica "TESTDRVE" służy jako wizualny wskaźnik dla innych graczy, że pojazd jest tymczasowy.

Finansowanie i plany płatności

W przypadku drogich pojazdów zaoferuj opcję finansowania, w której gracze wpłacają zaliczkę, a następnie płacą raty w czasie. Zapisuj pożyczkę w tabeli bazy danych z pozostałym saldem, harmonogramem płatności i stopą procentową. Jeśli gracz spóźnia się z płatnościami, pojazd może zostać oznaczony do konfiskaty. Funkcja ta dodaje głębi ekonomicznej i sprawia, że ekskluzywne pojazdy są dostępne dla graczy, którzy nie zgromadzili wystarczającej ilości gotówki na pełny zakup. Zaimplementuj sprawdzanie rat jako cykliczne zadanie server-side, które działa codziennie w czasie gry, pobierając płatności z konta bankowego gracza i powiadamiając go o każdej opłacie. Jeśli stan konta jest niewystarczający, zwiększ licznik pominiętych płatności i wyślij ostrzeżenie. Po konfigurowalnej liczbie pominiętych płatności oznacz pojazd do konfiskaty, usuwając go z garażu gracza i zwracając do asortymentu dealera.

Śledzenie sprzedaży pracowników i prowizji

Salony samochodowe prowadzone przez graczy potrzebują narzędzi do zarządzania personelem sprzedaży, śledzenia wyników i rozdzielania prowizji. Gdy pracownik salonu sfinalizuje sprzedaż, otrzymuje konfigurowalny procent ceny pojazdu jako prowizję. Śledź liczbę sprzedaży każdego pracownika, wygenerowany łączny przychód oraz zarobioną prowizję w tabeli bazy danych. Stwórz panel pracownika dostępny przez NUI salonu, który pokazuje statystyki osobiste, historię ostatnich sprzedaży oraz tabelę wyników porównującą efektywność zespołu sprzedaży. Właściciel salonu lub ranga menedżera powinni mieć dostęp do panelu administracyjnego do dostosowywania stawek prowizji, dodawania lub usuwania pracowników i przeglądania zbiorczych raportów sprzedaży. Zmienia to salon ze statycznej interakcji z NPC w dynamiczny biznes graczy z realnymi obowiązkami zarządczymi i motywującą rywalizacją.

Optymalizacja i zabezpieczenia przed exploitami

Skrypty salonów samochodowych mierzą się z typowymi wektorami exploitów, które wymagają aktywnego zabezpieczenia. Najważniejszym z nich jest manipulacja ceną, gdzie zmodyfikowany klient wysyła żądanie zakupu z niższą kwotą. Zawsze weryfikuj ceny po stronie serwera na podstawie katalogu i nigdy nie ufaj wartościom przesyłanym przez klienta. Stosuj rate-limit dla zdarzeń zakupu, aby zapobiec masowemu kupowaniu, które mogłoby zduplikować pojazdy lub wywołać wyścigi (race conditions) w bazie danych. W przypadku podglądu w salonie upewnij się, że pojazd testowy jest tworzony z false dla parametru sieciowego, aby istniał tylko lokalnie i nie mógł zostać zajęty ani skradziony przez innych graczy. Posprzątaj pojazdy podglądu w onResourceStop handler, aby zapobiec powstawaniu osieroconych encji w przypadku zrestartowania zasobu. Na serwerach z wieloma salonami samochodowymi buforuj katalog pojazdów w pamięci i przeładowuj go tylko wtedy, gdy administrator wywoła komendę odświeżenia, co pozwala uniknąć wielokrotnego odczytywania pliku konfiguracyjnego przy każdym otwarciu NUI. Monitoruj logi zakupów pod kątem anomalii, takich jak ten sam gracz kupujący dziesiątki pojazdów w krótkich odstępach czasu, co może wskazywać na exploit lub duplikowanie pieniędzy wymagające zbadania.

Pasujący skrypt

Gotowe na twój serwer, aktualizacje w zestawie.

Gotowy, aby zacząć?

Kup skrypty w naszym sklepie lub dołącz do Discorda, aby uzyskać wsparcie, aktualizacje i sprawdzić nadchodzące nowości.