>
Guide 2026-05-01

Développer un système de concessionnaire automobile FiveM

OntelMonke

OntelMonke

Administrateur et développeur chez Agency Scripts

Architecture de concession automobile

Un système de concessionnaire automobile est l'un des principaux moteurs économiques sur tout serveur de jeu de rôle FiveM, servant de passerelle par laquelle les joueurs acquièrent leurs véhicules. Un concessionnaire bien conçu va au-delà d'un simple menu d'achat, offrant des fonctionnalités telles que des aperçus de véhicules dans une salle d'exposition, des essais routiers, des options de financement, des échanges et un suivi des ventes pour les employés du concessionnaire. L'architecture se divise en un système de catalogue qui définit les véhicules disponibles avec les prix et les catégories, un affichage dans la salle d'exposition qui permet aux joueurs d'inspecter les véhicules avant de les acheter, un moteur de transaction qui gère les achats et le financement, et une couche de gestion des employés pour les concessionnaires gérés par les joueurs. Chaque couche communique via des événements validés par le serveur pour empêcher la manipulation des prix et l'apparition non autorisée de véhicules.

Catalogue de véhicules et système de catégories

Le catalogue de véhicules définit chaque voiture disponible à l'achat, organisée en catégories pour une navigation facile. Chaque entrée comprend le nom de l'apparition, l'étiquette d'affichage, le prix, la catégorie et des métadonnées facultatives telles que la vitesse maximale et le nombre de sièges pour l'affichage de la salle d'exposition. Stockez le catalogue dans un fichier de configuration partagé auquel le client et le serveur font référence, garantissant ainsi que la validation des prix s'effectue côté serveur tandis que le client utilise les mêmes données pour le rendu. Voici une définition de catalogue structuré :

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

Le tableau de recherche de prix Config.VehiclePrices permet la validation des prix O(1) sur le serveur, empêchant les clients d'envoyer des prix manipulés. Validez toujours le nom du modèle par rapport à ce tableau avant de procéder à un achat. Pensez à charger les prix des véhicules à partir d'une table de base de données au lieu d'un fichier de configuration si tu souhaites que les administrateurs du serveur ajustent les prix via un panneau d'administration sans redémarrer le serveur.

Système de prévisualisation en salle d'exposition

La salle d'exposition permet aux joueurs d'inspecter les véhicules dans un environnement contrôlé avant de s'engager dans un achat. Générez un aperçu du véhicule dans une position désignée de la salle d'exposition, appliquez une caméra que le joueur peut faire pivoter autour du véhicule et affichez les statistiques à côté du modèle. Le véhicule d'aperçu ne doit pas être interactif et disparaître lorsque le joueur ferme le menu ou sélectionne un autre véhicule. Voici la logique de prévisualisation côté client :

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

Autorisez la rotation de la caméra via le mouvement de la souris ou les commandes du clavier lorsque la salle d'exposition est ouverte. L'approche de la caméra orbitale permet aux joueurs de visualiser le véhicule sous tous les angles sans avoir besoin de le contourner. Ajoutez la personnalisation des couleurs du véhicule à l'aperçu afin que les joueurs puissent voir la peinture de leur choix avant d'acheter, ce qui réduit les remords et les demandes d'assistance de l'acheteur.

Traitement des achats et des transactions

Les achats de véhicules doivent être entièrement traités côté serveur pour empêcher toute exploitation. Le serveur valide que le joueur peut se permettre le véhicule, déduit le paiement, génère une plaque d'immatriculation unique, crée l'enregistrement du véhicule dans la base de données et informe le client de générer le véhicule acheté. Prend en charge à la fois les achats en espèces et les virements bancaires, et intègre éventuellement ton système bancaire pour les virements électroniques :

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

Le GenerateUniquePlate La fonction utilise une boucle de nouvelle tentative pour garantir l’unicité des plaques dans l’ensemble de la base de données. Même si les collisions sont statistiquement rares avec 8 caractères alphanumériques, le contrôle garantit une sécurité absolue. Enregistrez chaque vente dans un tableau séparé à des fins d'examen administratif et d'analyse de l'économie du serveur, en suivant les véhicules les plus populaires et le flux de trésorerie total via le concessionnaire.

Système d'essai routier

Les essais routiers permettent aux joueurs de découvrir un véhicule avant de l'acheter, ce qui est particulièrement important pour les véhicules coûteux. Générez un véhicule temporaire avec une minuterie et une limite géographique, renvoyant automatiquement le joueur au concessionnaire lorsque le temps expire ou s'il quitte la zone autorisée. Marquez le véhicule d'essai afin qu'il ne puisse pas être stocké dans un garage ou modifié, empêchant ainsi les joueurs d'exploiter le système pour obtenir des véhicules gratuits :

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

La limite géographique empêche les joueurs de conduire le véhicule d'essai sur la carte et de l'abandonner. Réglez le rayon sur une distance raisonnable qui permet une conduite significative dans les rues voisines tout en gardant le véhicule récupérable. La plaque "TESTDRVE" sert d'indicateur visuel aux autres joueurs que le véhicule est temporaire.

Plans de financement et de paiement

Pour les véhicules coûteux, proposez une option de financement où les joueurs effectuent un acompte puis effectuent des versements échelonnés. Suivez le prêt dans un tableau de base de données avec le solde restant, le calendrier de paiement et le taux d'intérêt. Si un joueur manque ses paiements, le véhicule peut être signalé pour reprise de possession. Cette fonctionnalité ajoute de la profondeur économique et rend les véhicules haut de gamme accessibles aux joueurs qui n'ont pas accumulé suffisamment d'argent pour un achat complet. Implémentez le contrôle des versements en tant que tâche récurrente côté serveur qui s'exécute quotidiennement pendant le jeu, en déduisant les paiements du compte bancaire du joueur et en l'informant de chaque débit. Si le solde bancaire est insuffisant, incrémentez un compteur de paiements manqués et émettez un avertissement. Après un nombre configurable de paiements manqués, marquez le véhicule pour reprise de possession où il est retiré du garage du joueur et remis à l'inventaire du concessionnaire.

Suivi des ventes et des commissions des employés

Les concessions gérées par les joueurs ont besoin d'outils pour gérer le personnel de vente, suivre les performances et répartir les commissions. Lorsqu'un employé d'un concessionnaire facilite une vente, il gagne un pourcentage configurable du prix du véhicule sous forme de commission. Suivez le nombre de ventes de chaque employé, les revenus totaux générés et les commissions gagnées dans un tableau de base de données. Créez un tableau de bord des employés accessible via la concession NUI qui affiche des statistiques personnelles, l'historique des ventes récentes et un classement comparant les performances de l'équipe de vente. Le propriétaire ou le gestionnaire de la concession doit avoir accès à un panneau d'administration pour ajuster les taux de commission, ajouter ou supprimer des employés et consulter les rapports de ventes globaux. Cela transforme la concession d'une interaction statique entre PNJ en une entreprise dynamique avec de réelles responsabilités de gestion et des incitations compétitives.

Mesures d’optimisation et anti-exploit

Les scripts des concessionnaires sont confrontés à des vecteurs d'exploitation courants qui nécessitent une atténuation proactive. Le plus critique est la manipulation des prix, où un client modifié envoie une demande d'achat avec un prix inférieur. Validez toujours les prix côté serveur par rapport au catalogue et ne faites jamais confiance aux valeurs déclarées par le client. Événements d’achat à taux limité pour empêcher les achats rapides qui pourraient dupliquer des véhicules ou créer des conditions de concurrence dans les bases de données. Pour l'aperçu de la salle d'exposition, assurez-tu que l'aperçu du véhicule est créé avec false pour le paramètre réseau afin qu'il n'existe que localement et ne puisse pas être saisi ou volé par d'autres joueurs. Nettoyer les véhicules d'aperçu dans le onResourceStop gestionnaire pour empêcher les entités orphelines si la ressource est redémarrée. Pour les serveurs avec plusieurs concessionnaires, mettez en cache le catalogue de véhicules en mémoire et ne le rechargez que lorsqu'un administrateur déclenche une commande d'actualisation, évitant ainsi les lectures répétées du fichier de configuration à chaque ouverture du NUI. Surveillez les journaux d'achat pour détecter des anomalies, comme le même joueur achetant des dizaines de véhicules en succession rapide, ce qui peut indiquer un exploit ou une duplication d'argent qui justifie une enquête.

Partager cet article

Prêt à améliorer votre serveur ?

Découvrez nos scripts FiveM premium dans la boutique Agency Scripts ou rejoignez notre communauté Discord pour le support et les mises à jour.