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.

