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.