diff --git a/vrp/cfg/modules.lua b/vrp/cfg/modules.lua
index bfbe848..4e7be5b 100644
--- a/vrp/cfg/modules.lua
+++ b/vrp/cfg/modules.lua
@@ -15,6 +15,7 @@ local modules = {
weapon = true,
user = true,
identity = true,
+ vehicle = true,
logs = true -- discord logs
}
diff --git a/vrp/cfg/vehicles.lua b/vrp/cfg/vehicles.lua
new file mode 100644
index 0000000..ec8235c
--- /dev/null
+++ b/vrp/cfg/vehicles.lua
@@ -0,0 +1,94 @@
+local cfg = {}
+
+cfg.vehicle_update_interval = 15 -- seconds
+cfg.vehicle_check_interval = 15 -- seconds, re-own/respawn task
+
+
+-- list of all purchasable vehicles
+-- model = spawn code
+-- name = display name
+-- price = purchase price
+-- category = as per GetVehicleClass() (see FiveM docs)
+-- type = as per IsThisModelACar()/IsThisModelABike(), etc.
+-- shop = single shop key or array of shop keys
+cfg.vehicles = {
+ { model = 'alpha', name = 'Alpha', price = 53000, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'banshee', name = 'Banshee', price = 56000, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'bestiagts', name = 'Bestia GTS', price = 37000, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'buffalo', name = 'Buffalo', price = 18750, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'buffalo2', name = 'Buffalo S', price = 24500, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'carbonizzare', name = 'Carbonizzare', brand = 'Grotti', price = 155000, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'comet2', name = 'Comet', brand = 'Pfister', price = 130000, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'comet3', name = 'Comet Retro Custom', brand = 'Pfister', price = 175000, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'comet4', name = 'Comet Safari', brand = 'Pfister', price = 110000, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'comet5', name = 'Comet SR', brand = 'Pfister', price = 155000, category = 'sports', type = 'automobile', shop = 'pdm' },
+ { model = 'coquette', name = 'Coquette', brand = 'Invetero', price = 145000, category = 'sports', type = 'automobile', shop = 'pdm' },
+
+
+
+ { model = "blista", name = "Blista", price = 100, category = "compacts", type = "automobile", shop = {"pdm","luxury"} },
+ { model = "buzzard", name = "Buzzard Attack", price = 100, category = "helicopter", type = "aircraft", shop = "aircraft" },
+ { model = "dinghy", name = "Dinghy", price = 100, category = "boats", type = "boat", shop = "marina" }
+ -- add your own entries below
+}
+
+-- configuration for each vehicle shop
+-- key = shop identifier (matches cfg.vehicles.shop)
+-- showroom_location = vec3(x,y,z)
+-- preview = vec4(x,y,z,heading)
+-- purchaseSpawn = vec4(x,y,z,heading)
+-- blip = { id = blipId, color = blipColor }
+-- marker = { id = markerId, scale = { x,y,z }, color = { r,g,b,a } }
+cfg.vehicleshops = {
+ pdm = {
+ shop_name = "Luxury Car Dealership",
+ showroom_location = vec3(-54.94, -1111.50, 26.44),
+ preview = vec4(-60.0, -1110.0, 26.4, 120.0),
+ purchaseSpawn = {
+ vec4(-61.744976043701,-1117.8952636719,26.432458877563, 10), -- Location where purchased vehicles will spawn
+ vec4(-56.407440185547,-1116.4392089844,26.434999465942, 10)
+
+ },
+ blip = { id = 326, color = 69 },
+ marker = { id = 1, scale = {1.5,1.5,1.0}, color = {255,215,0,100} }
+ },
+ aircraft = {
+ shop_name = "LSIA Hangar",
+ showroom_location = vec3(-970.63586425781,-2999.8103027344,13.945083618164),
+ preview = vec4(-1345.1208496094,-2722.6687011719,13.944955825806, 330.0),
+ purchaseSpawn = {
+ vec4(-979.73876953125,-2997.0693359375,13.945078849792,0), -- x,y,z,heading
+ vec4(-974.91973876953,-2986.9838867188,13.945068359375, 0),
+ vec4(-984.21917724609,-3004.3366699219,13.945056915283,0)
+ },
+ blip = { id = 90, color = 38 },
+ marker = { id = 1, scale = {2.0,2.0,1.0}, color = {0,191,255,120} }
+ }
+ }
+
+-- where players can sell back vehicles
+cfg.sellvehicle = {
+ cars = {
+ name = "Sell Cars",
+ type = "automobile",
+ sellPrice = 75, -- players get 75% of original price
+ blip = { id = 369, color = 25 },
+ marker = { id = 1, scale = {1.5, 1.5, 1.0}, color = {0, 128, 255, 100} },
+ coords = {
+ vec3(-61.744976043701,-1117.8952636719,26.432458877563)
+ }
+ },
+
+ aircraft = {
+ name = "Sell Aircraft",
+ type = "aircraft",
+ sellPrice = 50, -- players get 50% of original price
+ blip = { id = 370, color = 38 },
+ marker = { id = 1, scale = {2.0, 2.0, 1.0}, color = {0, 191, 255, 120} },
+ coords = {
+ vec3(-965.33026123047,-3004.2521972656,13.9426612854)
+ }
+ }
+ }
+
+return cfg
diff --git a/vrp/client/vehicle.lua b/vrp/client/vehicle.lua
new file mode 100644
index 0000000..49b7f41
--- /dev/null
+++ b/vrp/client/vehicle.lua
@@ -0,0 +1,469 @@
+-- https://github.com/ImagicTheCat/vRP
+-- MIT license (see LICENSE or vrp/vRPShared.lua)
+
+if not vRP.modules.vehicle then return end
+
+local Vehicle = class("Vehicle", vRP.Extension)
+
+-- METHODS
+
+function Vehicle:__construct()
+ vRP.Extension.__construct(self)
+
+ -- init decorators
+ DecorRegister("vRP.owner", 3)
+
+ self.vehicles = {} -- map of vehicle model => veh id (owned vehicles)
+ self.hash_models = {} -- map of hash => model
+
+ self.update_interval = 30 -- seconds
+ self.check_interval = 15 -- seconds
+ self.respawn_radius = 200
+
+ self.state_ready = false -- flag, if true will try to re-own/spawn periodically out vehicles
+
+ self.out_vehicles = {} -- map of vehicle model => {cstate, position, rotation}, unloaded out vehicles to spawn
+
+ -- task: save vehicle states
+ Citizen.CreateThread(function()
+ while true do
+ Citizen.Wait(self.update_interval*1000)
+
+ if self.state_ready then
+ local states = {}
+
+ for model, veh in pairs(self.vehicles) do
+ if IsEntityAVehicle(veh) then
+ local state = self:getVehicleState(model)
+ state.position = {table.unpack(GetEntityCoords(veh, true))}
+ state.rotation = {GetEntityQuaternion(veh)}
+
+ states[model] = state
+
+ if self.out_vehicles[model] then -- update out vehicle state data
+ self.out_vehicles[model] = {state, state.position, state.rotation}
+ end
+ end
+ end
+
+ self.remote._updateVehicleStates(states)
+ vRP.EXT.PlayerState.remote._update({ in_owned_vehicle = self:getInOwnedVehicleModel() or false})
+ end
+ end
+ end)
+
+ -- task: vehicles check
+ Citizen.CreateThread(function()
+ while true do
+ Citizen.Wait(self.check_interval*1000)
+
+ if self.state_ready then
+ self:cleanupVehicles()
+ self:tryOwnVehicles() -- get back network lost vehicles
+ self:trySpawnOutVehicles()
+ end
+ end
+ end)
+end
+
+local custom = {}
+
+-- veh: vehicle game id
+-- return owner character id and model or nil if not managed by vRP
+-- Refactored getVehicleInfo function
+function Vehicle:getVehicleInfo(veh)
+ if DecorExistOn(veh, "vRP.owner") then
+ local model = self.hash_models[GetEntityModel(veh)]
+ if model then
+ return DecorGetInt(veh, "vRP.owner"), model
+ end
+ end
+end
+
+-- spawn vehicle
+-- will be placed on ground properly
+-- one vehicle per model allowed at the same time
+--
+-- state: (optional) vehicle state (client)
+-- position: (optional) {x,y,z}, if not passed the vehicle will be spawned on the player (and will be put inside the vehicle)
+-- rotation: (optional) quaternion {x,y,z,w}, if passed with the position, will be applied to the vehicle entity
+function Vehicle:spawnVehicle(model, state, position, rotation)
+ self:despawnVehicle(model)
+
+ -- Load vehicle model
+ local mhash = GetHashKey(model)
+ RequestModel(mhash)
+ local i = 0
+ while not HasModelLoaded(mhash) and i < 10000 do
+ Citizen.Wait(10)
+ i = i + 1
+ end
+
+ if HasModelLoaded(mhash) then
+ local ped = GetPlayerPed(-1)
+ local x, y, z
+
+ if position and position.x and position.y and position.z then
+ x, y, z = position.x, position.y, position.z
+ else
+ x, y, z = table.unpack(GetEntityCoords(ped))
+ end
+
+ local nveh = CreateVehicle(mhash, x, y, z + 0.5, 0.0, true, false)
+
+ -- Set rotation and heading if provided
+ if rotation then
+ SetEntityQuaternion(nveh, table.unpack(rotation))
+ else
+ SetEntityHeading(nveh, GetEntityHeading(ped))
+ end
+
+ -- Finalize vehicle setup
+ SetVehicleOnGroundProperly(nveh)
+ SetEntityInvincible(nveh, false)
+
+ -- Put the player inside the vehicle if no position is provided
+ if not position then
+ SetPedIntoVehicle(ped, nveh, -1)
+ end
+
+ -- Set vehicle plate and ownership
+ local plateText = custom.plate_txt or ("LS " .. vRP.EXT.Identity.registration)
+ SetVehicleNumberPlateText(nveh, plateText)
+ SetEntityAsMissionEntity(nveh, true, true)
+ SetVehicleHasBeenOwnedByPlayer(nveh, true)
+ DecorSetInt(nveh, "vRP.owner", vRP.EXT.Base.cid)
+ self.vehicles[model] = nveh
+
+ -- Set vehicle state if provided
+ if state then
+ self:setVehicleState(nveh, state)
+ end
+
+ -- Mark the model as no longer needed and trigger event
+ SetModelAsNoLongerNeeded(mhash)
+ vRP:triggerEvent("VehicleVehicleSpawn", model)
+ end
+end
+
+
+-- return true if despawned
+function Vehicle:despawnVehicle(model)
+ local veh = self.vehicles[model]
+ if veh then
+ -- Before despawning, save its state
+ self:saveVehicleState()
+
+ vRP:triggerEvent("VehicleVehicleDespawn", model)
+
+ -- remove vehicle
+ SetVehicleHasBeenOwnedByPlayer(veh,false)
+ SetEntityAsMissionEntity(veh, false, true)
+ SetVehicleAsNoLongerNeeded(Citizen.PointerValueIntInitialized(veh))
+ Citizen.InvokeNative(0xEA386986E786A54F, Citizen.PointerValueIntInitialized(veh))
+ self.vehicles[model] = nil
+
+ return true
+ end
+end
+
+function Vehicle:despawnVehicles()
+ for model in pairs(self.vehicles) do
+ self:despawnVehicle(model)
+ end
+end
+
+-- get all game vehicles
+-- return list of veh
+function Vehicle:getAllVehicles()
+ local vehs = {}
+ local it, veh = FindFirstVehicle()
+ if veh then table.insert(vehs, veh) end
+ local ok
+ repeat
+ ok, veh = FindNextVehicle(it)
+ if ok and veh then table.insert(vehs, veh) end
+ until not ok
+ EndFindVehicle(it)
+
+ return vehs
+end
+
+-- try re-own vehicles
+function Vehicle:tryOwnVehicles()
+ for _, veh in pairs(self:getAllVehicles()) do
+ local cid, model = self:getVehicleInfo(veh)
+ if cid and vRP.EXT.Base.cid == cid then -- owned
+ local old_veh = self.vehicles[model]
+ if old_veh and IsEntityAVehicle(old_veh) then -- still valid
+ if old_veh ~= veh then -- remove this new one
+ SetVehicleHasBeenOwnedByPlayer(veh,false)
+ SetEntityAsMissionEntity(veh, false, true)
+ SetVehicleAsNoLongerNeeded(Citizen.PointerValueIntInitialized(veh))
+ Citizen.InvokeNative(0xEA386986E786A54F, Citizen.PointerValueIntInitialized(veh))
+ end
+ else -- no valid old veh
+ self.vehicles[model] = veh -- re-own
+ end
+ end
+ end
+end
+
+function Vehicle:trySpawnOutVehicles()
+ if not self.respawn_radius then return end -- early check to prevent nil compare
+
+ local x,y,z = vRP.EXT.Base:getPosition()
+
+ for model, data in pairs(self.out_vehicles) do
+ if not self.vehicles[model] then
+ local vx,vy,vz = table.unpack(data[2])
+ local distance = GetDistanceBetweenCoords(x,y,z,vx,vy,vz,true)
+
+ if distance <= self.respawn_radius then
+ self:spawnVehicle(model, data[1], data[2], data[3])
+ end
+ end
+ end
+end
+
+
+-- cleanup invalid owned vehicles
+function Vehicle:cleanupVehicles()
+ for model, veh in pairs(self.vehicles) do
+ if not IsEntityAVehicle(veh) then
+ self.vehicles[model] = nil
+ end
+ end
+end
+
+-- return model or nil if not in owned vehicle
+function Vehicle:getInOwnedVehicleModel()
+ local veh = GetVehiclePedIsIn(GetPlayerPed(-1),false)
+ local cid, model = self:getVehicleInfo(veh)
+ if cid and cid == vRP.EXT.Base.cid then
+ return model
+ end
+end
+
+-- VEHICLE STATE
+
+function Vehicle:getVehicleCustomization(veh)
+
+ custom.colours = {GetVehicleColours(veh)}
+ custom.extra_colours = {GetVehicleExtraColours(veh)}
+ custom.plate_index = GetVehicleNumberPlateTextIndex(veh)
+ custom.plate_txt = GetVehicleNumberPlateText(veh)
+ custom.wheel_type = GetVehicleWheelType(veh)
+ custom.window_tint = GetVehicleWindowTint(veh)
+ custom.livery = GetVehicleLivery(veh)
+ custom.neons = {}
+ for i=0,3 do
+ custom.neons[i] = IsVehicleNeonLightEnabled(veh, i)
+ end
+ custom.neon_colour = {GetVehicleNeonLightsColour(veh)}
+ custom.tyre_smoke_color = {GetVehicleTyreSmokeColor(veh)}
+
+ custom.mods = {}
+ for i=0,49 do
+ custom.mods[i] = GetVehicleMod(veh, i)
+ end
+
+ custom.turbo_enabled = IsToggleModOn(veh, 18)
+ custom.smoke_enabled = IsToggleModOn(veh, 20)
+ custom.xenon_enabled = IsToggleModOn(veh, 22)
+
+ return custom
+end
+
+-- partial update per property
+-- Refactored setVehicleCustomization function
+function Vehicle:setVehicleCustomization(veh, custom)
+ if not veh then
+ veh = GetVehiclePedIsIn(GetPlayerPed(-1), false)
+ end
+ SetVehicleModKit(veh, 0)
+
+ -- Apply each customization if it's provided
+ if custom.colours then SetVehicleColours(veh, table.unpack(custom.colours)) end
+ if custom.extra_colours then SetVehicleExtraColours(veh, table.unpack(custom.extra_colours)) end
+ if custom.plate_index then SetVehicleNumberPlateTextIndex(veh, custom.plate_index) end
+ if custom.plate_txt then SetVehicleNumberPlateText(veh, custom.plate_txt) end
+ if custom.wheel_type then SetVehicleWheelType(veh, custom.wheel_type) end
+ if custom.window_tint then SetVehicleWindowTint(veh, custom.window_tint) end
+ if custom.livery then SetVehicleLivery(veh, custom.livery) end
+
+ -- Neons
+ if custom.neons then
+ for i = 0, 3 do
+ SetVehicleNeonLightEnabled(veh, i, custom.neons[i])
+ end
+ end
+ if custom.neon_colour then SetVehicleNeonLightsColour(veh, table.unpack(custom.neon_colour)) end
+ if custom.tyre_smoke_color then SetVehicleTyreSmokeColor(veh, table.unpack(custom.tyre_smoke_color)) end
+
+ -- Mods
+ if custom.mods then
+ for i, mod in pairs(custom.mods) do
+ SetVehicleMod(veh, i, mod, false)
+ end
+ end
+
+ -- Toggle mods if enabled
+ if custom.turbo_enabled ~= nil then ToggleVehicleMod(veh, 18, custom.turbo_enabled) end
+ if custom.smoke_enabled ~= nil then ToggleVehicleMod(veh, 20, custom.smoke_enabled) end
+ if custom.xenon_enabled ~= nil then ToggleVehicleMod(veh, 22, custom.xenon_enabled) end
+end
+
+
+function Vehicle:getVehicleState(veh)
+ local state = {
+ customization = self:getVehicleCustomization(veh),
+ condition = {
+ health = GetEntityHealth(veh),
+ engine_health = GetVehicleEngineHealth(veh),
+ petrol_tank_health = GetVehiclePetrolTankHealth(veh),
+ dirt_level = GetVehicleDirtLevel(veh)
+ }
+ }
+
+ state.condition.windows = {}
+ for i=0,7 do
+ state.condition.windows[i] = IsVehicleWindowIntact(veh, i)
+ end
+
+ state.condition.tyres = {}
+ for i=0,7 do
+ local tyre_state = 2 -- 2: fine, 1: burst, 0: completely burst
+ if IsVehicleTyreBurst(veh, i, true) then
+ tyre_state = 0
+ elseif IsVehicleTyreBurst(veh, i, false) then
+ tyre_state = 1
+ end
+
+ state.condition.tyres[i] = tyre_state
+ end
+
+ state.condition.doors = {}
+ for i=0,5 do
+ state.condition.doors[i] = not IsVehicleDoorDamaged(veh, i)
+ end
+
+ state.locked = (GetVehicleDoorLockStatus(veh) >= 2)
+
+ return state
+end
+
+-- partial update per property
+function Vehicle:setVehicleState(veh, state)
+ -- apply state
+ if state.customization then
+ self:setVehicleCustomization(veh, state.customization)
+ end
+
+ if state.condition then
+ if state.condition.health then
+ SetEntityHealth(veh, state.condition.health)
+ end
+
+ if state.condition.engine_health then
+ SetVehicleEngineHealth(veh, state.condition.engine_health)
+ end
+
+ if state.condition.petrol_tank_health then
+ SetVehiclePetrolTankHealth(veh, state.condition.petrol_tank_health)
+ end
+
+ if state.condition.dirt_level then
+ SetVehicleDirtLevel(veh, state.condition.dirt_level)
+ end
+
+ if state.condition.windows then
+ for i, window_state in pairs(state.condition.windows) do
+ if not window_state then
+ SmashVehicleWindow(veh, i)
+ end
+ end
+ end
+
+ if state.condition.tyres then
+ for i, tyre_state in pairs(state.condition.tyres) do
+ if tyre_state < 2 then
+ SetVehicleTyreBurst(veh, i, (tyre_state == 1), 1000.01)
+ end
+ end
+ end
+
+ if state.condition.doors then
+ for i, door_state in pairs(state.condition.doors) do
+ if not door_state then
+ SetVehicleDoorBroken(veh, i, true)
+ end
+ end
+ end
+ end
+
+ if state.locked ~= nil then
+ if state.locked then -- lock
+ SetVehicleDoorsLocked(veh,2)
+ SetVehicleDoorsLockedForAllPlayers(veh, true)
+ else -- unlock
+ SetVehicleDoorsLockedForAllPlayers(veh, false)
+ SetVehicleDoorsLocked(veh,1)
+ SetVehicleDoorsLockedForPlayer(veh, PlayerId(), false)
+ end
+ end
+end
+
+
+-- TUNNEL
+Vehicle.tunnel = {}
+
+function Vehicle.tunnel:setConfig(update_interval, check_interval, respawn_radius)
+ self.update_interval = update_interval
+ self.check_interval = check_interval
+ self.respawn_radius = respawn_radius
+end
+
+function Vehicle.tunnel:setStateReady(state)
+ self.state_ready = state
+end
+
+function Vehicle.tunnel:registerModels(models)
+ -- generate models hashes
+ for model in pairs(models) do
+ local hash = GetHashKey(model)
+ if hash then
+ self.hash_models[hash] = model
+ end
+ end
+end
+
+function Vehicle.tunnel:setOutVehicles(out_vehicles)
+ for model, data in pairs(out_vehicles) do
+ self.out_vehicles[model] = data
+ end
+end
+
+function Vehicle.tunnel:removeOutVehicles(out_vehicles)
+ for model in pairs(out_vehicles) do
+ self.out_vehicles[model] = nil
+ end
+end
+
+function Vehicle.tunnel:clearOutVehicles()
+ self.out_vehicles = {}
+end
+
+Vehicle.tunnel.spawnVehicle = Vehicle.spawnVehicle
+Vehicle.tunnel.despawnVehicle = Vehicle.despawnVehicle
+Vehicle.tunnel.despawnVehicles = Vehicle.despawnVehicles
+
+Vehicle.tunnel.tryOwnVehicles = Vehicle.tryOwnVehicles
+Vehicle.tunnel.trySpawnOutVehicles = Vehicle.trySpawnOutVehicles
+Vehicle.tunnel.cleanupVehicles = Vehicle.cleanupVehicles
+Vehicle.tunnel.getInOwnedVehicleModel = Vehicle.getInOwnedVehicleModel
+
+Vehicle.tunnel.setVehicleCustomization = Vehicle.setVehicleCustomization
+
+
+vRP:registerExtension(Vehicle)
\ No newline at end of file
diff --git a/vrp/modules/vehicle.lua b/vrp/modules/vehicle.lua
new file mode 100644
index 0000000..64931e0
--- /dev/null
+++ b/vrp/modules/vehicle.lua
@@ -0,0 +1,377 @@
+-- https://github.com/ImagicTheCat/vRP
+-- MIT license (see LICENSE or vrp/vRPShared.lua)
+
+if not vRP.modules.vehicle then return end
+
+-- A basic Vehicle implementation
+local Vehicle = class("Vehicle", vRP.Extension)
+
+-- SUBCLASS
+Vehicle.User = class("User")
+
+-- Get owned vehicles
+-- Return map of id => {state, price, model}
+function Vehicle.User:getVehicles()
+ return self.cdata.vehicles
+end
+
+-- Simplified send_out_vehicles function with better early returns and clearer structure
+local function send_out_vehicles(self, user)
+ local out_vehicles = {}
+
+ -- Get all vehicles from the user
+ for model, state in pairs(user:getVehicles()) do
+ if state == 0 then -- Only 'out' vehicles
+ local vstate = user:getVehicleState(model)
+ if vstate.position then
+ -- Build vehicle state and position information
+ out_vehicles[model] = {
+ customization = vstate.customization,
+ condition = vstate.condition,
+ locked = vstate.locked,
+ position = vstate.position,
+ rotation = vstate.rotation
+ }
+ end
+ end
+ end
+
+ -- Send out the vehicles via remote
+ self.remote._setOutVehicles(user.source, out_vehicles)
+end
+
+-- detect which vehicle shop area the user is in
+function Vehicle:getUserVehicleShop(user)
+ local shops = self.cfg.vehicleshops
+ for shop_key, shop_data in pairs(shops) do
+ local area_id = "vRP:vehicleshop:" .. shop_key
+ if user:inArea(area_id) then
+ return shop_key, shop_data.shop_name or shop_key
+ end
+ end
+ return nil, "Unknown Shop"
+end
+
+function Vehicle:getVehicleByModel(model)
+ for _, veh in ipairs(self.cfg.vehicles) do
+ if veh.model == model then return veh end
+ end
+ return nil
+end
+
+-- Improved function
+function Vehicle.User:getVehicleState(model)
+ if not self.vehicle_states then
+ self.vehicle_states = {}
+ end
+ local state = self.vehicle_states[model]
+ if not state then
+ local sdata = vRP:getCData(self.cid, "vRP:vehicle_state:"..model)
+ if sdata and #sdata > 0 then
+ state = msgpack.unpack(sdata)
+ else
+ state = {}
+ end
+ self.vehicle_states[model] = state
+ end
+
+ return state
+end
+
+
+-- Improved vehicle spawning and buying
+local function menu_garage_buy(self)
+ local function m_buy(menu, model)
+ local user = menu.user
+ local uvehicles = user:getVehicles()
+ local veh = menu.data.vehicles[model]
+
+ if not veh then
+ return vRP.EXT.Base.remote._notify(user.source, "Vehicle not found.")
+ end
+
+
+ if not user:tryPayment(veh.price) then
+ return vRP.EXT.Base.remote._notify(user.source, "Not enough money")
+ end
+
+
+ local shop_key = menu.data.shop_key
+ local shop_cfg = self.cfg.vehicleshops[shop_key]
+ if not shop_cfg or not shop_cfg.purchaseSpawn or #shop_cfg.purchaseSpawn == 0 then
+ return vRP.EXT.Base.remote._notify(user.source, "Vehicle shop configuration error.")
+ end
+
+
+ local spawn = shop_cfg.purchaseSpawn[math.random(#shop_cfg.purchaseSpawn)]
+ if not spawn or spawn.x == 0 then
+ return vRP.EXT.Base.remote._notify(user.source, "No valid spawn point configured.")
+ end
+
+
+ vRP.EXT.Base.remote.teleport(user.source, spawn.x, spawn.y, spawn.z, spawn.w)
+
+ local vstate = user:getVehicleState(model)
+ local state = {
+ customization = vstate.customization,
+ condition = vstate.condition,
+ locked = vstate.locked
+ }
+
+
+ uvehicles[model] = 0
+ self.remote._spawnVehicle(user.source, model, state)
+ self.remote._setOutVehicles(user.source, { [model] = {} })
+
+ vRP.EXT.Base.remote._notify(user.source, "Purchased for $" .. veh.price)
+ user:actualizeMenu()
+ user:closeMenu(menu)
+ end
+
+
+ vRP.EXT.GUI:registerMenuBuilder("garage.buy", function(menu)
+ local user = menu.user
+ local uvehicles = user:getVehicles()
+ local shop_key, shop_name = self:getUserVehicleShop(user)
+ local shop_vehicles = {}
+
+
+ menu.title = shop_name or "Vehicle Shop"
+ menu.css.header_color = "rgba(255,125,0,0.75)"
+ menu.data.shop_key = shop_key
+
+
+ if shop_key then
+ for _, veh in ipairs(self.cfg.vehicles) do
+ if veh.shop == shop_key or (type(veh.shop) == "table" and table.contains(veh.shop, shop_key)) then
+ shop_vehicles[veh.model] = {
+ name = veh.name,
+ price = veh.price,
+ category = veh.category,
+ vtype = veh.type
+ }
+ end
+ end
+ end
+
+ menu.data.vehicles = shop_vehicles
+
+
+ for model, veh in pairs(shop_vehicles) do
+ if not uvehicles[model] then
+ menu:addOption(veh.name, m_buy, string.format("Price: $%d\n
Category: %s\n
Type: %s", veh.price, veh.category, veh.vtype), model)
+ end
+ end
+ end)
+end
+
+-- Helper function to check if a value exists in a table
+function table.contains(tbl, val)
+ for _, v in pairs(tbl) do
+ if v == val then return true end
+ end
+ return false
+end
+
+local function menu_garage_sell(self)
+ local function m_sell(menu, model)
+ local user = menu.user
+ local uvehicles = user:getVehicles()
+ local veh = self:getVehicleByModel(model)
+
+ if veh and uvehicles[model] then
+ local zone_type = menu.data.zone_type
+ local sell_cfg = self.cfg.sellvehicle[zone_type]
+
+ if sell_cfg then
+ local price = math.floor((veh.price or 0) * (sell_cfg.sellPrice / 100))
+ uvehicles[model] = nil
+ user:addWallet(price)
+ vRP.EXT.Base.remote._notify(user.source, "Vehicle sold for $" .. price)
+ user:actualizeMenu()
+ else
+ vRP.EXT.Base.remote._notify(user.source, "Invalid sell zone.")
+ end
+ else
+ vRP.EXT.Base.remote._notify(user.source, "You do not own this vehicle.")
+ end
+ end
+
+ vRP.EXT.GUI:registerMenuBuilder("garage.sell", function(menu)
+ local user = menu.user
+ local uvehicles = user:getVehicles()
+ local zone_type = menu.data.zone_type or "cars"
+ local sell_cfg = self.cfg.sellvehicle[zone_type]
+
+ if not sell_cfg then return end -- safety check
+
+ menu.title = sell_cfg.name or "Sell Vehicle"
+ menu.css.header_color = "rgba(200, 30, 30, 0.75)"
+ menu.data.zone_type = zone_type
+
+ for model, data in pairs(uvehicles) do
+ local veh = self:getVehicleByModel(model)
+
+ if veh and veh.type == sell_cfg.type then
+ local price = math.floor((veh.price or 0) * (sell_cfg.sellPrice / 100))
+ menu:addOption(veh.name, m_sell, string.format("Model: %s\n
Original Price: $%d\n
Sell Price: $%d", model, veh.price or 0, price), model)
+
+ end
+ end
+ end)
+end
+
+
+function Vehicle:__construct()
+ vRP.Extension.__construct(self)
+ self.cfg = module("cfg/vehicles")
+
+ self.models = {}
+ menu_garage_buy(self)
+ menu_garage_sell(self)
+end
+
+-- EVENT HANDLERS --
+Vehicle.event = {}
+
+function Vehicle.event:playerSpawn(user, first_spawn)
+ if first_spawn then
+ self.remote._setConfig(user.source,
+ self.cfg.vehicle_update_interval,
+ self.cfg.vehicle_check_interval,
+ self.cfg.vehicle_respawn_radius
+ )
+ self.remote._registerModels(user.source, self.models)
+
+ -- setup each shop’s blip + area
+ for shop_key, shop_data in pairs(self.cfg.vehicleshops) do
+ local p = shop_data.showroom_location
+ if p then
+ local enterArea = function(u)
+ user:openMenu("garage.buy")
+ end
+ local leaveArea = function(u)
+ user:closeMenu("Vehicle Shop")
+ end
+
+ local blip_id = (shop_data.blip and shop_data.blip.id) or 326
+ local blip_color = (shop_data.blip and shop_data.blip.color) or 69
+ local marker_id = (shop_data.marker and shop_data.marker.id) or 1
+
+ local poi = {
+ "PoI",
+ {
+ blip_id = blip_id,
+ blip_color = blip_color,
+ title = shop_data.shop_name or shop_key,
+ marker_id = marker_id,
+ pos = { p.x, p.y, p.z - 1 }
+ }
+ }
+
+ vRP.EXT.Map.remote._addEntity(user.source, poi[1], poi[2])
+ user:setArea(
+ "vRP:vehicleshop:"..shop_key,
+ p.x, p.y, p.z,
+ 1.5, 2.0,
+ enterArea, leaveArea
+ )
+ end
+ end
+ -- setup each sell vehicle zone based on cfg
+ for zone_type, zone in pairs(self.cfg.sellvehicle) do
+ for i, pos in ipairs(zone.coords) do
+ local area_id = "vRP:sellvehicle:" .. zone_type .. ":" .. i
+
+ local enterArea = function(u)
+ user:openMenu("garage.sell", { zone_type = zone_type })
+ end
+ local leaveArea = function(u)
+ user:closeMenu("Sell Vehicle")
+ end
+
+ local poi = {
+ "PoI",
+ {
+ blip_id = zone.blip.id,
+ blip_color = zone.blip.color,
+ title = zone.name,
+ marker_id = zone.marker.id,
+ pos = { pos.x, pos.y, pos.z - 1 },
+ }
+ }
+
+ vRP.EXT.Map.remote._addEntity(user.source, poi[1], poi[2])
+ user:setArea(area_id, pos.x, pos.y, pos.z, 1.5, 2.0, enterArea, leaveArea)
+ end
+ end
+ end
+end
+
+
+function Vehicle.event:characterLoad(user)
+ if not user.cdata.vehicles then
+ user.cdata.vehicles = {}
+ end
+
+ user.vehicle_states = {}
+
+ send_out_vehicles(self, user)
+end
+
+function Vehicle.event:characterUnload(user)
+ self.remote._setStateReady(user.source, false)
+
+ -- save vehicle states
+ for model, state in pairs(user.vehicle_states) do
+ vRP:setCData(user.cid, "vRP:vehicle_state:"..model, msgpack.pack(state))
+ end
+
+ -- despawn vehicles
+ self.remote._despawnVehicles(user.source)
+ self.remote._clearOutVehicles(user.source)
+end
+
+
+function Vehicle.event:save()
+ for _, user in pairs(vRP.users) do
+ for model, state in pairs(user.vehicle_states) do
+ vRP:setCData(user.cid, "vRP:vehicle_state:"..model, msgpack.pack(state))
+ end
+ end
+end
+
+function Vehicle.event:playerStateLoaded(user)
+ self.remote._tryOwnVehicles(user.source)
+ self.remote.trySpawnOutVehicles(user.source)
+ if user.cdata.state.in_owned_vehicle then
+ self.remote._putInOwnedVehicle(user.source, user.cdata.state.in_owned_vehicle)
+ end
+ self.remote._setStateReady(user.source, true)
+end
+
+-- TUNNEL
+Vehicle.tunnel = {}
+
+function Vehicle.tunnel:updateVehicleStates(states)
+ local user = vRP.users_by_source[source]
+
+ if user then
+ for model, state in pairs(states) do
+ if user.cdata.vehicles[model] then -- has model
+ local vstate = user:getVehicleState(model)
+ if state.customization then
+ vstate.customization = state.customization
+ end
+ if state.condition then
+ vstate.condition = state.condition
+ end
+ if state.position then vstate.position = state.position end
+ if state.rotation then vstate.rotation = state.rotation end
+ if state.locked ~= nil then vstate.locked = state.locked end
+ end
+ end
+ end
+end
+
+vRP:registerExtension(Vehicle)