>
Guía 2026-05-01

Desarrollo de concesionarios y tiendas de vehículos para FiveM

OntelMonke

OntelMonke

Admin y desarrollador de Agency Scripts

Arquitectura del concesionario de vehículos

Un sistema de concesionario es uno de los motores económicos principales de cualquier servidor de rol de FiveM, ya que es la puerta por la que los jugadores adquieren sus vehículos. Un concesionario bien diseñado va más allá de un simple menú de compra: ofrece presentaciones en showroom, pruebas de conducción, opciones de financiación, intercambios y seguimiento de ventas para los empleados. La arquitectura se divide en un sistema de catálogo que define los vehículos disponibles con precios y categorías, un showroom para inspeccionar los coches antes de comprarlos, un motor de transacciones que gestiona compras y financiaciones, y una capa de gestión de empleados para concesionarios llevados por jugadores. Cada capa se comunica mediante eventos validados en el servidor para evitar manipulación de precios y spawn de vehículos no autorizado.

Catálogo y sistema de categorías

El catálogo de vehículos define todos los coches disponibles para comprar, organizados por categorías para facilitar la navegación. Cada entrada incluye el spawn name, etiqueta visible, precio, categoría y metadatos opcionales como velocidad punta y número de plazas para el showroom. Guarda el catálogo en un archivo de configuración compartido que referencien tanto cliente como servidor, asegurando que la validación de precios ocurra en el servidor mientras el cliente usa los mismos datos para renderizar. Aquí tienes una definición de catálogo estructurada:

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},
        -- Deportivos
        {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},
        -- Motos
        {model = 'bati', label = 'Pegassi Bati 801', price = 18000,
         category = 'motorcycle', seats = 2, testDrive = true},
    },
}

-- Construye una tabla de búsqueda para validar precios rápido en servidor
Config.VehiclePrices = {}
for _, v in ipairs(Config.VehicleCatalog.vehicles) do
    Config.VehiclePrices[v.model] = v.price
end

La tabla de búsqueda de precios Config.VehiclePrices permite una validación O(1) en el servidor, evitando que los clientes envíen precios manipulados. Valida siempre el nombre del modelo contra esta tabla antes de procesar una compra. Plantéate cargar los precios desde una tabla de base de datos en vez de un archivo de configuración si quieres que los administradores los ajusten desde un panel admin sin reiniciar el servidor.

Sistema de previsualización del showroom

El showroom permite a los jugadores inspeccionar los vehículos en un entorno controlado antes de comprometerse con la compra. Spawnea un vehículo de preview en una posición designada del showroom, aplica una cámara que el jugador pueda rotar alrededor del coche y muestra las stats junto al modelo. El vehículo de preview debe ser no interactuable y se despawnea cuando el jugador cierra el menú o elige otro coche. Esta es la lógica de preview en el cliente:

-- client/showroom.lua
local previewVehicle = nil
local previewCam = nil
local camAngle = 0.0

function ShowVehiclePreview(modelName)
    -- Limpia preview anterior
    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)

    -- Crea cámara orbital
    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

Permite rotar la cámara con el ratón o el teclado mientras el showroom esté abierto. El enfoque orbital deja a los jugadores ver el coche desde todos los ángulos sin tener que caminar a su alrededor. Añade personalización de color en el preview para que los jugadores vean la pintura que eligen antes de comprar, lo que reduce el arrepentimiento tras la compra y las peticiones de soporte.

Procesado de compra y transacciones

Las compras de vehículos deben procesarse íntegramente en el servidor para evitar abusos. El servidor valida que el jugador puede permitirse el vehículo, descuenta el pago, genera una matrícula única, crea el registro del coche en la base de datos y notifica al cliente para que spawnee el vehículo comprado. Admite tanto pagos totales en efectivo como transferencias bancarias y, si quieres, intégralo con tu sistema de banca para transferencias electrónicas:

RegisterNetEvent('dealer:server:purchaseVehicle', function(modelName, paymentType)
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    -- Valida que el modelo existe y obtiene el precio
    local price = Config.VehiclePrices[modelName]
    if not price then
        TriggerClientEvent('QBCore:Notify', src, 'Vehicle not available', 'error')
        return
    end

    -- Comprueba el pago
    local moneyType = paymentType == 'bank' and 'bank' or 'cash'
    if Player.PlayerData.money[moneyType] < price then
        TriggerClientEvent('QBCore:Notify', src, 'Insufficient funds', 'error')
        return
    end

    -- Genera matrícula única
    local plate = GenerateUniquePlate()

    -- Procesa el pago
    Player.Functions.RemoveMoney(moneyType, price, 'vehicle-purchase-' .. modelName)

    -- Crea el registro del vehículo
    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',
    })

    -- Registra la transacción
    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

La función GenerateUniquePlate usa un bucle de reintento para garantizar la unicidad de la matrícula en toda la base de datos. Aunque las colisiones con 8 caracteres alfanuméricos son estadísticamente raras, la comprobación asegura absoluta seguridad. Registra cada venta en una tabla separada para revisiones administrativas y analítica de economía del servidor, llevando cuenta de qué vehículos son los más populares y del flujo total de efectivo por el concesionario.

Sistema de prueba de conducción

Las pruebas de conducción dejan al jugador sentir el vehículo antes de comprarlo, algo especialmente importante para coches caros. Spawnea un vehículo temporal con un temporizador y un límite geográfico, devolviendo automáticamente al jugador al concesionario cuando expire el tiempo o salga de la zona permitida. Marca el vehículo de prueba para que no se pueda guardar en garaje ni modificar, evitando que los jugadores exploten el sistema para conseguir coches gratis:

-- Cliente: lógica de la prueba de conducción
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

    -- Hilo del temporizador y comprobación del límite
    CreateThread(function()
        while testDriveActive and testDriveTimer > 0 do
            Wait(1000)
            testDriveTimer = testDriveTimer - 1

            -- Muestra el tiempo restante
            SendNUIMessage({
                action = 'updateTestDrive',
                timeLeft = testDriveTimer
            })

            -- Comprueba el límite
            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

El límite geográfico evita que los jugadores se lleven el coche de prueba por todo el mapa y lo abandonen. Ajusta el radio a una distancia razonable que permita un paseo real por las calles cercanas manteniendo el vehículo recuperable. La matrícula "TESTDRVE" actúa como indicador visual para el resto de jugadores de que el coche es temporal.

Financiación y planes de pago

Para vehículos caros, ofrece una opción de financiación donde el jugador da una entrada y paga cuotas a lo largo del tiempo. Registra el préstamo en una tabla de base de datos con el saldo restante, calendario de pagos y tasa de interés. Si el jugador se retrasa, el vehículo puede marcarse para embargo. Esta función añade profundidad económica y hace accesibles los vehículos de alta gama a jugadores que no han acumulado suficiente efectivo para una compra completa. Implementa la comprobación de cuotas como un trabajo recurrente en servidor que se ejecute a diario en tiempo de juego, descontando del banco del jugador y notificando cada cobro. Si el saldo es insuficiente, incrementa un contador de impagos y envía un aviso. Tras un número configurable de impagos, marca el vehículo para embargo, retirándolo del garaje y devolviéndolo al inventario del concesionario.

Ventas de empleados y seguimiento de comisiones

Los concesionarios llevados por jugadores necesitan herramientas para gestionar al personal de ventas, medir el rendimiento y repartir comisiones. Cuando un empleado facilita una venta, gana un porcentaje configurable del precio como comisión. Registra en una tabla las ventas totales, la facturación generada y la comisión ganada por cada empleado. Crea un panel accesible desde la NUI del concesionario que muestre estadísticas personales, historial de ventas reciente y una tabla de clasificación entre el equipo. El rango de dueño o encargado debería tener acceso a un panel admin para ajustar comisiones, añadir o quitar empleados y ver informes agregados. Así el concesionario pasa de una interacción NPC estática a un negocio dinámico con responsabilidades reales de gestión e incentivos competitivos.

Optimización y medidas antiexploit

Los scripts de concesionario sufren vectores de exploit comunes que conviene mitigar de forma proactiva. El más crítico es la manipulación de precios, donde un cliente modificado envía una petición de compra con precio más bajo. Valida siempre los precios en el servidor contra el catálogo y no confíes en valores reportados por el cliente. Aplica rate limiting a los eventos de compra para evitar compras en ráfaga que podrían duplicar vehículos o crear condiciones de carrera en la base de datos. Para la previsualización del showroom, crea el vehículo de preview con false en el parámetro de red para que solo exista localmente y no pueda ser entrado ni robado por otros jugadores. Limpia los vehículos de preview en el handler de onResourceStop para evitar entidades huérfanas al reiniciar el recurso. En servidores con varios concesionarios, cachea el catálogo en memoria y recárgalo solo cuando un admin dispare un comando de refresh, evitando leer el archivo de configuración en cada apertura de NUI. Monitoriza los logs de compras buscando anomalías como un mismo jugador comprando decenas de vehículos seguidos, lo que puede indicar un exploit o duplicación de dinero que merezca investigación.

Compartir este artículo

¿Listo para mejorar tu servidor?

Echa un vistazo a nuestros scripts premium de FiveM en la tienda de Agency Scripts o únete a nuestra comunidad de Discord para soporte y novedades.