1283 lines
38 KiB
Lua
1283 lines
38 KiB
Lua
-----------------------------------------
|
||
-- FD DRONE SCRIPT - CLIENT <!-- FD Drone System Nov 2025 - Ferious Dev-->
|
||
-----------------------------------------
|
||
|
||
local DroneState = {
|
||
active = false,
|
||
drone = nil,
|
||
baseVeh = nil,
|
||
basePos = nil,
|
||
batteryEnd = 0
|
||
}
|
||
|
||
-- Global single-drone state (from server)
|
||
local GlobalDroneActive = false
|
||
local GlobalDroneNetId = 0
|
||
local GlobalTruckNetId = 0
|
||
|
||
-- Framework / job
|
||
local ESX, QBCore
|
||
local PlayerJob = nil
|
||
|
||
-- Spotlight
|
||
local spotlightEnabled = false
|
||
local spotlightPitch = Config.Spotlight.DefaultPitch
|
||
local spotlightYaw = Config.Spotlight.DefaultYaw
|
||
local spotlightDirty = false
|
||
local lastSpotlightState = false
|
||
local remoteSpotlights = {}
|
||
|
||
-- ANPR
|
||
local anprAutoEnabled = false
|
||
local anprCurrent = {
|
||
hasTarget = false,
|
||
plate = "",
|
||
model = "",
|
||
distance = 0.0
|
||
}
|
||
|
||
-- Viewer
|
||
local feedActive = false
|
||
local feedCam = nil
|
||
local feedDrone = nil
|
||
|
||
-------------------------------------------------
|
||
-- UTIL
|
||
-------------------------------------------------
|
||
|
||
local function hash(model) return joaat(model) end
|
||
|
||
local function RequestModelSync(modelHash)
|
||
if not HasModelLoaded(modelHash) then
|
||
RequestModel(modelHash)
|
||
local timeout = GetGameTimer() + 5000
|
||
while not HasModelLoaded(modelHash) do
|
||
if GetGameTimer() > timeout then return false end
|
||
Wait(0)
|
||
end
|
||
end
|
||
return true
|
||
end
|
||
|
||
local ALLOWED_HASHES = {}
|
||
for _, name in ipairs(Config.AllowedCars) do
|
||
table.insert(ALLOWED_HASHES, hash(name))
|
||
end
|
||
local DRONE_HASH = hash(Config.DroneModel)
|
||
|
||
local function IsAllowedVehicle(veh)
|
||
if veh == 0 or not DoesEntityExist(veh) then return false end
|
||
local model = GetEntityModel(veh)
|
||
for _, h in ipairs(ALLOWED_HASHES) do
|
||
if model == h then return true end
|
||
end
|
||
return false
|
||
end
|
||
|
||
local function FormatVehicleName(modelHash)
|
||
local label = GetDisplayNameFromVehicleModel(modelHash)
|
||
if label and label ~= "" then
|
||
local text = GetLabelText(label)
|
||
if text and text ~= "NULL" then return text end
|
||
return label
|
||
end
|
||
return "Unknown"
|
||
end
|
||
|
||
local function RotationToDirection(rot)
|
||
local radX = math.rad(rot.x)
|
||
local radZ = math.rad(rot.z)
|
||
local cosX = math.cos(radX)
|
||
return vector3(
|
||
-math.sin(radZ) * cosX,
|
||
math.cos(radZ) * cosX,
|
||
math.sin(radX)
|
||
)
|
||
end
|
||
|
||
local function GetVehicleInCamera(maxDistance)
|
||
local camPos = GetGameplayCamCoord()
|
||
local rot = GetGameplayCamRot(2)
|
||
local dir = RotationToDirection(rot)
|
||
|
||
local dest = camPos + dir * maxDistance
|
||
|
||
local rayHandle = StartShapeTestRay(
|
||
camPos.x, camPos.y, camPos.z,
|
||
dest.x, dest.y, dest.z,
|
||
10, -- vehicles only
|
||
PlayerPedId(),
|
||
7
|
||
)
|
||
|
||
local _, hit, _, _, ent = GetShapeTestResult(rayHandle)
|
||
|
||
if hit == 1 and ent ~= 0 and IsEntityAVehicle(ent) then
|
||
local vpos = GetEntityCoords(ent)
|
||
local dpos = DroneState.drone and GetEntityCoords(DroneState.drone) or camPos
|
||
local dist = #(vpos - dpos)
|
||
return ent, dist
|
||
end
|
||
return nil, 0.0
|
||
end
|
||
|
||
local function LoadAnimDict(dict)
|
||
if HasAnimDictLoaded(dict) then return true end
|
||
RequestAnimDict(dict)
|
||
local timeout = GetGameTimer() + 5000
|
||
while not HasAnimDictLoaded(dict) do
|
||
if GetGameTimer() > timeout then return false end
|
||
Wait(0)
|
||
end
|
||
return true
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- FRAMEWORK / JOB DETECTION (ESX -> QB)
|
||
-------------------------------------------------
|
||
|
||
local function RefreshJob()
|
||
PlayerJob = nil
|
||
|
||
if ESX and ESX.PlayerData and ESX.PlayerData.job then
|
||
PlayerJob = ESX.PlayerData.job.name
|
||
return
|
||
end
|
||
|
||
if QBCore and QBCore.Functions and QBCore.Functions.GetPlayerData then
|
||
local pdata = QBCore.Functions.GetPlayerData()
|
||
if pdata and pdata.job then
|
||
PlayerJob = pdata.job.name
|
||
end
|
||
end
|
||
end
|
||
|
||
CreateThread(function()
|
||
-- Try ESX
|
||
pcall(function()
|
||
TriggerEvent("esx:getSharedObject", function(obj) ESX = obj end)
|
||
end)
|
||
|
||
-- Try QBCore
|
||
pcall(function()
|
||
if not QBCore and exports["qb-core"] then
|
||
QBCore = exports["qb-core"]:GetCoreObject()
|
||
end
|
||
end)
|
||
|
||
Wait(2000)
|
||
RefreshJob()
|
||
end)
|
||
|
||
-- ESX events
|
||
RegisterNetEvent("esx:playerLoaded", function(xPlayer)
|
||
ESX = ESX or {}
|
||
ESX.PlayerData = xPlayer
|
||
RefreshJob()
|
||
end)
|
||
|
||
RegisterNetEvent("esx:setJob", function(job)
|
||
if ESX and ESX.PlayerData then
|
||
ESX.PlayerData.job = job
|
||
end
|
||
RefreshJob()
|
||
end)
|
||
|
||
-- QB events
|
||
RegisterNetEvent("QBCore:Client:OnPlayerLoaded", function()
|
||
RefreshJob()
|
||
end)
|
||
|
||
RegisterNetEvent("QBCore:Client:OnJobUpdate", function(job)
|
||
if job and job.name then
|
||
PlayerJob = job.name
|
||
else
|
||
RefreshJob()
|
||
end
|
||
end)
|
||
|
||
local function IsPlayerLEO()
|
||
if not Config.Viewer.EnableJobCheck then return true end
|
||
if not PlayerJob then return false end
|
||
|
||
for _, j in ipairs(Config.Viewer.AllowedJobs or {}) do
|
||
if PlayerJob == j then return true end
|
||
end
|
||
return false
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- GLOBAL ACTIVE DRONE STATE (FROM SERVER)
|
||
-------------------------------------------------
|
||
|
||
RegisterNetEvent("fd_drone:updateActiveState", function(isActive, truckNet, droneNet)
|
||
GlobalDroneActive = isActive or false
|
||
GlobalTruckNetId = truckNet or 0
|
||
GlobalDroneNetId = droneNet or 0
|
||
|
||
if not GlobalDroneActive and feedActive then
|
||
RenderScriptCams(false, true, 300, true, true)
|
||
if feedCam then DestroyCam(feedCam, false) end
|
||
feedActive = false
|
||
feedCam = nil
|
||
feedDrone = nil
|
||
end
|
||
end)
|
||
|
||
CreateThread(function()
|
||
Wait(1500)
|
||
TriggerServerEvent("fd_drone:requestActiveState")
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- SPOTLIGHT HELPERS
|
||
-------------------------------------------------
|
||
|
||
local function GetSpotlightDirection(drone, pitch, yaw)
|
||
local heading = GetEntityHeading(drone) + yaw
|
||
local rh = math.rad(heading)
|
||
local rp = math.rad(pitch)
|
||
|
||
return vector3(
|
||
-math.sin(rh) * math.cos(rp),
|
||
math.cos(rh) * math.cos(rp),
|
||
math.sin(rp)
|
||
)
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- DRONE STATE RESET
|
||
-------------------------------------------------
|
||
|
||
local function ResetDroneState()
|
||
DroneState.active = false
|
||
DroneState.drone = nil
|
||
DroneState.baseVeh = nil
|
||
DroneState.basePos = nil
|
||
DroneState.batteryEnd = 0
|
||
|
||
spotlightEnabled = false
|
||
spotlightDirty = false
|
||
lastSpotlightState = false
|
||
remoteSpotlights = {}
|
||
|
||
anprAutoEnabled = false
|
||
anprCurrent.hasTarget = false
|
||
anprCurrent.plate = ""
|
||
anprCurrent.model = ""
|
||
anprCurrent.distance = 0.0
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- FIND TRUCK BEHIND PLAYER
|
||
-------------------------------------------------
|
||
|
||
local function GetPlayerVehicleBehind()
|
||
local ped = PlayerPedId()
|
||
local pedPos = GetEntityCoords(ped)
|
||
|
||
local handle, veh = FindFirstVehicle()
|
||
local success
|
||
repeat
|
||
if IsAllowedVehicle(veh) then
|
||
local trunkPos = GetOffsetFromEntityInWorldCoords(
|
||
veh,
|
||
Config.TrunkOffset.x,
|
||
Config.TrunkOffset.y,
|
||
Config.TrunkOffset.z
|
||
)
|
||
if #(pedPos - trunkPos) <= Config.TrunkRadius then
|
||
EndFindVehicle(handle)
|
||
return veh, trunkPos
|
||
end
|
||
end
|
||
success, veh = FindNextVehicle(handle)
|
||
until not success
|
||
|
||
EndFindVehicle(handle)
|
||
return nil, nil
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- ANIMATION SEQUENCES (FAST DEPLOY / RETRIEVE)
|
||
-------------------------------------------------
|
||
|
||
-- Auto-step TO 1m behind the trunk offset (AS2)
|
||
local function DoTrunkApproachAndFace(baseVeh)
|
||
local ped = PlayerPedId()
|
||
|
||
-- trunk offset point
|
||
local trunkPos = GetOffsetFromEntityInWorldCoords(
|
||
baseVeh,
|
||
Config.TrunkOffset.x,
|
||
Config.TrunkOffset.y,
|
||
Config.TrunkOffset.z
|
||
)
|
||
|
||
-- 1 meter further back along vehicle rear (AS2)
|
||
local behindPos = GetOffsetFromEntityInWorldCoords(
|
||
baseVeh,
|
||
Config.TrunkOffset.x,
|
||
Config.TrunkOffset.y - 1.0,
|
||
Config.TrunkOffset.z
|
||
)
|
||
|
||
-- Walk toward behindPos
|
||
TaskGoStraightToCoord(ped, behindPos.x, behindPos.y, behindPos.z, 1.0, -1, GetEntityHeading(baseVeh), 0.1)
|
||
local timeout = GetGameTimer() + 4000
|
||
while #(GetEntityCoords(ped) - behindPos) > 0.7 and GetGameTimer() < timeout do
|
||
Wait(0)
|
||
end
|
||
|
||
ClearPedTasksImmediately(ped)
|
||
SetEntityCoordsNoOffset(ped, behindPos.x, behindPos.y, behindPos.z, false, false, false)
|
||
SetEntityHeading(ped, GetEntityHeading(baseVeh))
|
||
end
|
||
|
||
-- FAST DEPLOY
|
||
local function PlayDeployAnimationSequence(baseVeh)
|
||
local ped = PlayerPedId()
|
||
DoTrunkApproachAndFace(baseVeh)
|
||
|
||
-- Open trunk & show prop
|
||
SetVehicleDoorOpen(baseVeh, 5, false, false)
|
||
SetVehicleExtra(baseVeh, 7, false) -- show extra_7
|
||
|
||
LoadAnimDict("random@domestic")
|
||
TaskPlayAnim(ped, "random@domestic", "pickup_low", 4.0, -4.0, 1200, 0, 0.0, false, false, false)
|
||
|
||
-- NUI deploy sound
|
||
SendNUIMessage({ action = "playSound", sound = "deploy", volume = 0.9 })
|
||
|
||
Wait(750)
|
||
|
||
-- Hide prop (drone taken)
|
||
SetVehicleExtra(baseVeh, 7, true)
|
||
end
|
||
|
||
-- FAST RETRIEVE
|
||
local function PlayRetrieveAnimationSequence(baseVeh)
|
||
if not DoesEntityExist(baseVeh) then return end
|
||
|
||
local ped = PlayerPedId()
|
||
DoTrunkApproachAndFace(baseVeh)
|
||
|
||
SetVehicleDoorOpen(baseVeh, 5, false, false)
|
||
SetVehicleExtra(baseVeh, 7, true) -- ensure hidden at start
|
||
|
||
LoadAnimDict("random@domestic")
|
||
TaskPlayAnim(ped, "random@domestic", "pickup_low", 4.0, -4.0, 1400, 0, 0.0, false, false, false)
|
||
|
||
-- NUI pickup sound
|
||
SendNUIMessage({ action = "playSound", sound = "pickup", volume = 0.9 })
|
||
|
||
Wait(900)
|
||
|
||
-- Show prop back in trunk
|
||
SetVehicleExtra(baseVeh, 7, false)
|
||
|
||
Wait(200)
|
||
ClearPedTasks(ped)
|
||
SetVehicleDoorShut(baseVeh, 5, false)
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- SPAWN DRONE
|
||
-------------------------------------------------
|
||
|
||
local function SpawnDroneFromVehicle(baseVeh)
|
||
if DroneState.active then
|
||
Config.Notify("~y~Drone already active.")
|
||
return
|
||
end
|
||
|
||
if GlobalDroneActive then
|
||
Config.Notify("~r~Another drone is already deployed.")
|
||
return
|
||
end
|
||
|
||
if not RequestModelSync(DRONE_HASH) then
|
||
Config.Notify("~r~Failed to load drone model.")
|
||
return
|
||
end
|
||
|
||
local spawnPos = GetOffsetFromEntityInWorldCoords(
|
||
baseVeh,
|
||
Config.DroneSpawnOffset.x,
|
||
Config.DroneSpawnOffset.y,
|
||
Config.DroneSpawnOffset.z
|
||
)
|
||
|
||
local drone = CreateVehicle(
|
||
DRONE_HASH,
|
||
spawnPos.x, spawnPos.y, spawnPos.z,
|
||
GetEntityHeading(baseVeh),
|
||
true, false
|
||
)
|
||
|
||
if drone == 0 then
|
||
Config.Notify("~r~Drone spawn failed.")
|
||
return
|
||
end
|
||
|
||
SetVehicleOnGroundProperly(drone)
|
||
|
||
local droneNet = NetworkGetNetworkIdFromEntity(drone)
|
||
local truckNet = NetworkGetNetworkIdFromEntity(baseVeh)
|
||
if droneNet ~= 0 then
|
||
SetNetworkIdCanMigrate(droneNet, true)
|
||
SetNetworkIdExistsOnAllMachines(droneNet, true)
|
||
end
|
||
|
||
-- Close trunk after deploy
|
||
SetVehicleDoorShut(baseVeh, 5, false)
|
||
|
||
TaskWarpPedIntoVehicle(PlayerPedId(), drone, -1)
|
||
Wait(200)
|
||
|
||
DroneState.active = true
|
||
DroneState.drone = drone
|
||
DroneState.baseVeh = baseVeh
|
||
DroneState.basePos = GetEntityCoords(baseVeh)
|
||
DroneState.batteryEnd = GetGameTimer() + Config.BatterySeconds * 1000
|
||
|
||
spotlightEnabled = false
|
||
spotlightDirty = true
|
||
lastSpotlightState = false
|
||
remoteSpotlights = {}
|
||
|
||
anprAutoEnabled = false
|
||
anprCurrent.hasTarget = false
|
||
|
||
TriggerServerEvent("fd_drone:setActiveState", true, truckNet, droneNet)
|
||
|
||
Config.Notify("~g~Drone deployed.")
|
||
end
|
||
|
||
local function StartDroneDeployFlow(baseVeh)
|
||
if DroneState.active then
|
||
Config.Notify("~y~Drone already active.")
|
||
return
|
||
end
|
||
|
||
if GlobalDroneActive then
|
||
Config.Notify("~r~Another drone is already deployed.")
|
||
return
|
||
end
|
||
|
||
PlayDeployAnimationSequence(baseVeh)
|
||
SpawnDroneFromVehicle(baseVeh)
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- RETURN DRONE (SAFE)
|
||
-------------------------------------------------
|
||
|
||
local function ReturnDrone(toBase)
|
||
if not DroneState.active or not DoesEntityExist(DroneState.drone) then
|
||
ResetDroneState()
|
||
TriggerServerEvent("fd_drone:setActiveState", false, 0, 0)
|
||
return
|
||
end
|
||
|
||
local ped = PlayerPedId()
|
||
local drone = DroneState.drone
|
||
local base = DroneState.baseVeh
|
||
|
||
local destPos
|
||
|
||
if toBase and base ~= nil and DoesEntityExist(base) then
|
||
destPos = GetOffsetFromEntityInWorldCoords(
|
||
base,
|
||
Config.TrunkOffset.x,
|
||
Config.TrunkOffset.y - 0.2,
|
||
Config.TrunkOffset.z
|
||
)
|
||
else
|
||
destPos = GetEntityCoords(drone) + vector3(0.0, 0.0, 1.0)
|
||
end
|
||
|
||
-- Turn off spotlight network-wide
|
||
if DoesEntityExist(drone) then
|
||
local id = NetworkGetNetworkIdFromEntity(drone)
|
||
TriggerServerEvent("fd_drone:syncSpotlight", id, { enabled = false })
|
||
end
|
||
|
||
spotlightEnabled = false
|
||
remoteSpotlights = {}
|
||
|
||
anprAutoEnabled = false
|
||
anprCurrent.hasTarget = false
|
||
|
||
SetPedCoordsKeepVehicle(ped, destPos.x, destPos.y, destPos.z)
|
||
ClearPedTasksImmediately(ped)
|
||
|
||
DeleteVehicle(drone)
|
||
ResetDroneState()
|
||
TriggerServerEvent("fd_drone:setActiveState", false, 0, 0)
|
||
|
||
if toBase and base ~= nil and DoesEntityExist(base) then
|
||
PlayRetrieveAnimationSequence(base)
|
||
else
|
||
Config.Notify("~g~Drone recovered.")
|
||
end
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- TRUNK E PROMPT
|
||
-------------------------------------------------
|
||
|
||
CreateThread(function()
|
||
while true do
|
||
Wait(0)
|
||
|
||
if not DroneState.active and not GlobalDroneActive then
|
||
local veh, pos = GetPlayerVehicleBehind()
|
||
|
||
if veh then
|
||
SetTextFont(0)
|
||
SetTextScale(0.35,0.35)
|
||
SetTextColour(255,255,255,220)
|
||
SetTextCentre(true)
|
||
SetDrawOrigin(pos.x,pos.y,pos.z+0.3,0)
|
||
BeginTextCommandDisplayText("STRING")
|
||
AddTextComponentSubstringPlayerName("~g~E~w~ - Deploy Drone")
|
||
EndTextCommandDisplayText(0.0,0.0)
|
||
ClearDrawOrigin()
|
||
|
||
if IsControlJustPressed(0, Config.KeyLaunch) then
|
||
StartDroneDeployFlow(veh)
|
||
end
|
||
else
|
||
Wait(250)
|
||
end
|
||
elseif DroneState.active then
|
||
if IsControlJustPressed(0, Config.KeyReturn) then
|
||
ReturnDrone(true)
|
||
end
|
||
else
|
||
Wait(250)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- PREVENT F EXIT
|
||
-------------------------------------------------
|
||
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and DoesEntityExist(DroneState.drone) then
|
||
DisableControlAction(0, 75, true)
|
||
DisableControlAction(27, 75, true)
|
||
if IsDisabledControlJustPressed(0, 75) then
|
||
Config.Notify("~y~Drone manually exited.")
|
||
ReturnDrone(true)
|
||
end
|
||
Wait(0)
|
||
else
|
||
Wait(300)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- BATTERY & RANGE CHECK
|
||
-------------------------------------------------
|
||
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and DoesEntityExist(DroneState.drone) then
|
||
local now = GetGameTimer()
|
||
if now >= DroneState.batteryEnd then
|
||
Config.Notify("~r~Battery dead — returning.")
|
||
ReturnDrone(true)
|
||
end
|
||
|
||
local pos = GetEntityCoords(DroneState.drone)
|
||
local dist = #(pos - DroneState.basePos)
|
||
|
||
if dist >= Config.MaxRange * Config.RangeHardKick then
|
||
Config.Notify("~r~Drone lost (range).")
|
||
ReturnDrone(true)
|
||
elseif dist >= Config.MaxRange * Config.RangeSoftWarn then
|
||
Config.Notify("~y~Drone nearing range limit.")
|
||
end
|
||
|
||
Wait(500)
|
||
else
|
||
Wait(800)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- VISION MODES (L)
|
||
-------------------------------------------------
|
||
|
||
local visionMode = 0
|
||
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and DoesEntityExist(DroneState.drone) then
|
||
if IsControlJustPressed(0, 182) then -- L
|
||
visionMode = (visionMode + 1) % 3
|
||
|
||
if visionMode == 0 then
|
||
SetNightvision(false)
|
||
SetSeethrough(false)
|
||
Config.Notify("Vision: Normal")
|
||
elseif visionMode == 1 then
|
||
SetNightvision(true)
|
||
SetSeethrough(false)
|
||
Config.Notify("Vision: Night")
|
||
elseif visionMode == 2 then
|
||
SetNightvision(false)
|
||
SetSeethrough(true)
|
||
Config.Notify("Vision: Thermal")
|
||
end
|
||
end
|
||
Wait(0)
|
||
else
|
||
Wait(400)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- HUD (BATTERY / ALT / SIGNAL / SPOTLIGHT / ANPR)
|
||
-------------------------------------------------
|
||
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and DoesEntityExist(DroneState.drone) then
|
||
local drone = DroneState.drone
|
||
local pos = GetEntityCoords(drone)
|
||
local now = GetGameTimer()
|
||
|
||
local secs = math.floor((DroneState.batteryEnd - now)/1000)
|
||
if secs < 0 then secs = 0 end
|
||
|
||
local _, ground = GetGroundZFor_3dCoord(pos.x,pos.y,pos.z,0)
|
||
local alt = math.floor(pos.z - (ground or pos.z))
|
||
|
||
local dist = #(pos - DroneState.basePos)
|
||
local bars = (dist > Config.MaxRange*0.9 and 1)
|
||
or (dist > Config.MaxRange*0.7 and 2)
|
||
or (dist > Config.MaxRange*0.4 and 3) or 4
|
||
|
||
local x = 0.015
|
||
local y = 0.63
|
||
|
||
-- ANPR (above battery)
|
||
if anprAutoEnabled then
|
||
SetTextFont(4)
|
||
SetTextScale(0.40,0.40)
|
||
SetTextOutline()
|
||
SetTextColour(0,200,255,255)
|
||
BeginTextCommandDisplayText("STRING")
|
||
if anprCurrent.hasTarget then
|
||
AddTextComponentString(
|
||
string.format(
|
||
"ANPR: ~w~%s (%s) [%.1fm]",
|
||
anprCurrent.plate,
|
||
anprCurrent.model,
|
||
anprCurrent.distance
|
||
)
|
||
)
|
||
else
|
||
AddTextComponentString("ANPR: ~w~Scanning...")
|
||
end
|
||
EndTextCommandDisplayText(x, y - 0.030)
|
||
end
|
||
|
||
-- Battery
|
||
SetTextFont(4)
|
||
SetTextScale(0.40,0.40)
|
||
SetTextOutline()
|
||
SetTextColour(secs < 120 and 255 or 0,
|
||
secs < 120 and 0 or 255,
|
||
0,255)
|
||
BeginTextCommandDisplayText("STRING")
|
||
AddTextComponentString("Battery: ~w~"..secs.."s")
|
||
EndTextCommandDisplayText(x,y)
|
||
|
||
-- Altitude
|
||
SetTextFont(4)
|
||
SetTextScale(0.55,0.55)
|
||
SetTextOutline()
|
||
SetTextColour(0,255,255,255)
|
||
BeginTextCommandDisplayText("STRING")
|
||
AddTextComponentString("Alt: ~w~"..alt.."m")
|
||
EndTextCommandDisplayText(x,y+0.035)
|
||
|
||
-- Spotlight indicator
|
||
if spotlightEnabled then
|
||
SetTextFont(4)
|
||
SetTextScale(0.40,0.40)
|
||
SetTextOutline()
|
||
SetTextColour(0,255,0,255)
|
||
BeginTextCommandDisplayText("STRING")
|
||
AddTextComponentString("Spotlight: ~g~ON")
|
||
EndTextCommandDisplayText(x,y+0.070)
|
||
end
|
||
|
||
-- Signal bars
|
||
for i=1,4 do
|
||
local px = x + (i*0.010)
|
||
local py = y + 0.145 - (i*0.006)
|
||
local ph = 0.018 + (i*0.006)
|
||
if i <= bars then
|
||
DrawRect(px,py,0.006,ph,0,255,0,255)
|
||
else
|
||
DrawRect(px,py,0.006,ph,80,80,80,150)
|
||
end
|
||
end
|
||
|
||
Wait(0)
|
||
else
|
||
Wait(600)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- SPOTLIGHT CONTROLS (H + ARROWS + PAGEUP/PAGEDOWN)
|
||
-------------------------------------------------
|
||
|
||
-- Toggle spotlight
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active then
|
||
if IsControlJustPressed(0, Config.Spotlight.EnabledKey) then
|
||
spotlightEnabled = not spotlightEnabled
|
||
spotlightDirty = true
|
||
Config.Notify("Spotlight: "..(spotlightEnabled and "~g~ON" or "~r~OFF"))
|
||
end
|
||
Wait(0)
|
||
else
|
||
Wait(300)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-- Rotate spotlight (vehicle control group = 2)
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and spotlightEnabled and DoesEntityExist(DroneState.drone) then
|
||
local changed = false
|
||
local spd = Config.Spotlight.RotationSpeed
|
||
|
||
if IsControlPressed(2, Config.Spotlight.RotateLeft) then
|
||
spotlightYaw = math.min(Config.Spotlight.MaxYawRight, spotlightYaw + spd)
|
||
changed = true
|
||
end
|
||
if IsControlPressed(2, Config.Spotlight.RotateRight) then
|
||
spotlightYaw = math.max(Config.Spotlight.MaxYawLeft, spotlightYaw - spd)
|
||
changed = true
|
||
end
|
||
if IsControlPressed(2, Config.Spotlight.RotateUp) then
|
||
spotlightPitch = math.min(Config.Spotlight.MaxPitchUp, spotlightPitch + spd)
|
||
changed = true
|
||
end
|
||
if IsControlPressed(2, Config.Spotlight.RotateDown) then
|
||
spotlightPitch = math.max(Config.Spotlight.MaxPitchDown, spotlightPitch - spd)
|
||
changed = true
|
||
end
|
||
|
||
if changed then spotlightDirty = true end
|
||
Wait(0)
|
||
else
|
||
Wait(300)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-- Adjust cone width (PageUp/PageDown)
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and spotlightEnabled then
|
||
local changed = false
|
||
|
||
if IsControlPressed(0, Config.Spotlight.AdjustWider) then
|
||
Config.Spotlight.LightRadius = math.min(25.0, Config.Spotlight.LightRadius + Config.Spotlight.AdjustStep)
|
||
Config.Spotlight.LightIntensity = math.max(5.0, Config.Spotlight.LightIntensity - 0.3)
|
||
changed = true
|
||
end
|
||
|
||
if IsControlPressed(0, Config.Spotlight.AdjustNarrow) then
|
||
Config.Spotlight.LightRadius = math.max(1.0, Config.Spotlight.LightRadius - Config.Spotlight.AdjustStep)
|
||
Config.Spotlight.LightIntensity = math.min(60.0, Config.Spotlight.LightIntensity + 0.3)
|
||
changed = true
|
||
end
|
||
|
||
if changed then
|
||
spotlightDirty = true
|
||
end
|
||
|
||
Wait(0)
|
||
else
|
||
Wait(300)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-- Local spotlight draw
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and spotlightEnabled and DoesEntityExist(DroneState.drone) then
|
||
local drone = DroneState.drone
|
||
local pos = GetEntityCoords(drone)
|
||
local dir = GetSpotlightDirection(drone, spotlightPitch, spotlightYaw)
|
||
|
||
DrawSpotLight(
|
||
pos.x,pos.y,pos.z,
|
||
dir.x,dir.y,dir.z,
|
||
255,255,255,
|
||
Config.Spotlight.LightRange,
|
||
Config.Spotlight.LightIntensity,
|
||
Config.Spotlight.LightFalloff,
|
||
Config.Spotlight.LightRadius,
|
||
1.0
|
||
)
|
||
Wait(0)
|
||
else
|
||
Wait(400)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-- Network sync sender
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and DoesEntityExist(DroneState.drone) then
|
||
if spotlightDirty or (spotlightEnabled ~= lastSpotlightState) then
|
||
local id = NetworkGetNetworkIdFromEntity(DroneState.drone)
|
||
TriggerServerEvent("fd_drone:syncSpotlight", id, {
|
||
enabled = spotlightEnabled,
|
||
pitch = spotlightPitch,
|
||
yaw = spotlightYaw,
|
||
radius = Config.Spotlight.LightRadius,
|
||
intensity = Config.Spotlight.LightIntensity
|
||
})
|
||
spotlightDirty = false
|
||
lastSpotlightState = spotlightEnabled
|
||
end
|
||
Wait(80)
|
||
else
|
||
spotlightDirty = false
|
||
Wait(400)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-- Network sync receiver (remote spotlights)
|
||
RegisterNetEvent("fd_drone:applySpotlight", function(src, netId, data)
|
||
if type(netId) ~= "number" then return end
|
||
|
||
if DroneState.active and DoesEntityExist(DroneState.drone) then
|
||
local my = NetworkGetNetworkIdFromEntity(DroneState.drone)
|
||
if my == netId then return end
|
||
end
|
||
|
||
if not data.enabled then
|
||
remoteSpotlights[netId] = nil
|
||
return
|
||
end
|
||
|
||
remoteSpotlights[netId] = {
|
||
pitch = data.pitch,
|
||
yaw = data.yaw,
|
||
radius = data.radius,
|
||
intensity = data.intensity
|
||
}
|
||
end)
|
||
|
||
-- Draw remote spotlights
|
||
CreateThread(function()
|
||
while true do
|
||
if next(remoteSpotlights) ~= nil then
|
||
for netId, s in pairs(remoteSpotlights) do
|
||
local veh = NetToVeh(netId)
|
||
if veh ~= 0 and DoesEntityExist(veh) then
|
||
local pos = GetEntityCoords(veh)
|
||
local dir = GetSpotlightDirection(veh, s.pitch, s.yaw)
|
||
DrawSpotLight(
|
||
pos.x,pos.y,pos.z,
|
||
dir.x,dir.y,dir.z,
|
||
255,255,255,
|
||
Config.Spotlight.LightRange,
|
||
s.intensity,
|
||
Config.Spotlight.LightFalloff,
|
||
s.radius,
|
||
1.0
|
||
)
|
||
else
|
||
remoteSpotlights[netId] = nil
|
||
end
|
||
end
|
||
Wait(0)
|
||
else
|
||
Wait(500)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- ANPR COMMAND (/droneplateread)
|
||
-------------------------------------------------
|
||
|
||
RegisterCommand(Config.ANPR.CommandName, function()
|
||
if not DroneState.active then
|
||
Config.Notify("~r~No active drone.")
|
||
return
|
||
end
|
||
|
||
local veh, dist = GetVehicleInCamera(Config.ANPR.MaxDistance)
|
||
if not veh then
|
||
Config.Notify("~y~No vehicle in sight.")
|
||
return
|
||
end
|
||
|
||
local rawPlate = GetVehicleNumberPlateText(veh) or "UNKNOWN"
|
||
local plate = rawPlate:gsub("^%s*(.-)%s*$", "%1")
|
||
local modelName = FormatVehicleName(GetEntityModel(veh))
|
||
|
||
Config.Notify(string.format(
|
||
"[DRONE ANPR] Plate: ~g~%s~w~ | Model: ~g~%s~w~ | Dist: ~g~%.1fm",
|
||
plate, modelName, dist
|
||
))
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- AUTO ANPR TOGGLE (O) + LOOP
|
||
-------------------------------------------------
|
||
|
||
local function ToggleAutoANPR()
|
||
if not DroneState.active then
|
||
Config.Notify("~r~No active drone.")
|
||
return
|
||
end
|
||
|
||
anprAutoEnabled = not anprAutoEnabled
|
||
if anprAutoEnabled then
|
||
Config.Notify("~g~Auto ANPR: ON")
|
||
else
|
||
Config.Notify("~r~Auto ANPR: OFF")
|
||
anprCurrent.hasTarget = false
|
||
anprCurrent.plate = ""
|
||
anprCurrent.model = ""
|
||
anprCurrent.distance = 0.0
|
||
end
|
||
end
|
||
|
||
RegisterCommand("fd_drone_toggle_auto_anpr", function()
|
||
ToggleAutoANPR()
|
||
end, false)
|
||
|
||
RegisterKeyMapping(
|
||
"fd_drone_toggle_auto_anpr",
|
||
"Drone Auto ANPR Toggle",
|
||
"keyboard",
|
||
"o"
|
||
)
|
||
|
||
CreateThread(function()
|
||
while true do
|
||
if DroneState.active and anprAutoEnabled then
|
||
local veh, dist = GetVehicleInCamera(Config.ANPR.MaxDistance)
|
||
if veh then
|
||
local raw = GetVehicleNumberPlateText(veh) or "UNKNOWN"
|
||
local plate = raw:gsub("^%s*(.-)%s*$","%1")
|
||
anprCurrent.hasTarget = true
|
||
anprCurrent.plate = plate
|
||
anprCurrent.model = FormatVehicleName(GetEntityModel(veh))
|
||
anprCurrent.distance = dist
|
||
else
|
||
anprCurrent.hasTarget = false
|
||
end
|
||
Wait(Config.ANPR.ScanIntervalMs)
|
||
else
|
||
Wait(500)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- DRONE FEED VIEWER (K) - LEO + NEAR TRUCK ONLY
|
||
-------------------------------------------------
|
||
|
||
local function StopFeedViewer()
|
||
if feedActive then
|
||
RenderScriptCams(false, true, 300, true, true)
|
||
if feedCam then DestroyCam(feedCam, false) end
|
||
feedActive = false
|
||
feedCam = nil
|
||
feedDrone = nil
|
||
Config.Notify("~r~Drone feed closed.")
|
||
end
|
||
end
|
||
|
||
local function StartFeedViewer()
|
||
-- Operator cannot view their own feed
|
||
if DroneState.active then
|
||
Config.Notify("~y~Viewer disabled for drone operator.")
|
||
return
|
||
end
|
||
|
||
if not GlobalDroneActive or GlobalDroneNetId == 0 then
|
||
Config.Notify("~y~No active drone to view.")
|
||
return
|
||
end
|
||
|
||
if not IsPlayerLEO() then
|
||
Config.Notify("~r~Access restricted to LEO.")
|
||
return
|
||
end
|
||
|
||
local truck = NetToVeh(GlobalTruckNetId)
|
||
if truck == 0 or not DoesEntityExist(truck) then
|
||
Config.Notify("~r~Drone vehicle not available.")
|
||
return
|
||
end
|
||
|
||
local pedPos = GetEntityCoords(PlayerPedId())
|
||
local truckPos = GetEntityCoords(truck)
|
||
local dist = #(pedPos - truckPos)
|
||
local maxDist = Config.Viewer.MaxDistance or 8.0
|
||
|
||
if dist > maxDist then
|
||
Config.Notify(string.format("~y~Move closer to the drone vehicle (~g~%.0fm~w~ max).", maxDist))
|
||
return
|
||
end
|
||
|
||
local droneVeh = NetToVeh(GlobalDroneNetId)
|
||
if droneVeh == 0 or not DoesEntityExist(droneVeh) then
|
||
Config.Notify("~r~Drone not streamed in.")
|
||
return
|
||
end
|
||
|
||
feedDrone = droneVeh
|
||
feedCam = CreateCam("DEFAULT_SCRIPTED_CAMERA", true)
|
||
|
||
AttachCamToEntity(feedCam, feedDrone, 0.0, 0.0, 0.5, true)
|
||
local rot = GetEntityRotation(feedDrone, 2)
|
||
SetCamRot(feedCam, rot.x, rot.y, rot.z, 2)
|
||
SetCamFov(feedCam, 60.0)
|
||
|
||
RenderScriptCams(true, false, 0, true, true)
|
||
|
||
feedActive = true
|
||
Config.Notify("~g~Drone feed viewer: LIVE")
|
||
end
|
||
|
||
RegisterCommand("fd_drone_feed", function()
|
||
if feedActive then
|
||
StopFeedViewer()
|
||
else
|
||
StartFeedViewer()
|
||
end
|
||
end, false)
|
||
|
||
RegisterKeyMapping(
|
||
"fd_drone_feed",
|
||
"Drone Feed Viewer",
|
||
"keyboard",
|
||
"k"
|
||
)
|
||
|
||
CreateThread(function()
|
||
while true do
|
||
if feedActive and feedCam and DoesCamExist(feedCam) and feedDrone and DoesEntityExist(feedDrone) then
|
||
local rot = GetEntityRotation(feedDrone, 2)
|
||
SetCamRot(feedCam, rot.x, rot.y, rot.z, 2)
|
||
|
||
SetTextFont(4)
|
||
SetTextScale(0.35,0.35)
|
||
SetTextOutline()
|
||
SetTextColour(0,255,0,255)
|
||
BeginTextCommandDisplayText("STRING")
|
||
AddTextComponentString("DRONE FEED ~r~LIVE")
|
||
EndTextCommandDisplayText(0.78,0.06)
|
||
|
||
SetTextFont(0)
|
||
SetTextScale(0.30,0.30)
|
||
SetTextOutline()
|
||
SetTextColour(255,255,255,200)
|
||
BeginTextCommandDisplayText("STRING")
|
||
AddTextComponentString("Press ~g~K~w~ to close")
|
||
EndTextCommandDisplayText(0.78,0.09)
|
||
|
||
Wait(0)
|
||
else
|
||
if feedActive then
|
||
StopFeedViewer()
|
||
end
|
||
Wait(500)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-------------------------------------------------
|
||
-- LED SYSTEM (DYNAMIC "FAKE" LED LIGHTS)
|
||
-- Single belly LED, tactical white / blue / red
|
||
-- Distance + day/night scaled
|
||
-------------------------------------------------
|
||
|
||
-- Helper: get the current drone entity for LED drawing
|
||
local function GetLEDDroneEntity()
|
||
-- Local operator has priority
|
||
if DroneState.active and DoesEntityExist(DroneState.drone) then
|
||
return DroneState.drone, true
|
||
end
|
||
|
||
-- Otherwise use global (for viewers / bystanders)
|
||
if GlobalDroneActive and GlobalDroneNetId ~= 0 then
|
||
local veh = NetToVeh(GlobalDroneNetId)
|
||
if veh ~= 0 and DoesEntityExist(veh) then
|
||
return veh, false
|
||
end
|
||
end
|
||
|
||
return nil, false
|
||
end
|
||
|
||
local function IsNightTime()
|
||
local h = GetClockHours()
|
||
return (h >= 20 or h < 6) -- 8pm–6am = night
|
||
end
|
||
|
||
-- Simple blink helper (0 or 1)
|
||
local function BlinkPhase(nowMs, period, duty)
|
||
local phase = nowMs % period
|
||
return (phase / period) < duty and 1.0 or 0.0
|
||
end
|
||
|
||
CreateThread(function()
|
||
-- Single belly LED
|
||
local ledOffset = vector3(0.0, 0.0, -0.08)
|
||
|
||
while true do
|
||
local drone, isLocal = GetLEDDroneEntity()
|
||
if drone then
|
||
local pedPos = GetEntityCoords(PlayerPedId())
|
||
local dPos = GetEntityCoords(drone)
|
||
local dist = #(pedPos - dPos)
|
||
|
||
-- Distance-based intensity (mode C)
|
||
local distFactor
|
||
if dist <= 50.0 then
|
||
distFactor = 1.0
|
||
elseif dist <= 150.0 then
|
||
distFactor = 1.0 - ((dist - 50.0) / 100.0)
|
||
else
|
||
distFactor = 0.0
|
||
end
|
||
|
||
if distFactor > 0.0 then
|
||
local nowMs = GetGameTimer()
|
||
local isNight = IsNightTime()
|
||
|
||
-- Base intensity: much lower now
|
||
local baseNight = 4.0 -- decent at night, not overpowering
|
||
local baseDay = 0.35 -- almost invisible in full daylight
|
||
local baseIntensity = isNight and baseNight or baseDay
|
||
|
||
local r,g,b = 255,255,255
|
||
local blinkFactor = 0.0
|
||
local showLED = false
|
||
|
||
if isLocal and DroneState.active and DoesEntityExist(DroneState.drone) then
|
||
showLED = true
|
||
|
||
-- Battery state
|
||
local secs = 0
|
||
if DroneState.batteryEnd > 0 then
|
||
secs = math.floor((DroneState.batteryEnd - nowMs)/1000)
|
||
if secs < 0 then secs = 0 end
|
||
end
|
||
|
||
local lowThreshold = math.floor(Config.BatterySeconds * 0.20)
|
||
local criticalThreshold = math.floor(Config.BatterySeconds * 0.10)
|
||
|
||
local isCritical = (secs > 0 and secs <= criticalThreshold)
|
||
local isLow = (secs > criticalThreshold and secs <= lowThreshold)
|
||
|
||
if isCritical then
|
||
-- Critical: fast red blink
|
||
r,g,b = 255, 0, 0
|
||
blinkFactor = BlinkPhase(nowMs, 350, 0.45) -- quick
|
||
elseif isLow then
|
||
-- Low battery: slower red blink
|
||
r,g,b = 255, 0, 0
|
||
blinkFactor = BlinkPhase(nowMs, 900, 0.50)
|
||
else
|
||
-- Normal ops: tactical logic
|
||
if spotlightEnabled then
|
||
-- Spotlight priority: steady bright white
|
||
r,g,b = 255,255,255
|
||
blinkFactor = 1.0
|
||
elseif anprAutoEnabled then
|
||
-- ANPR: steady soft blue
|
||
r,g,b = 0, 160, 255
|
||
blinkFactor = 0.35 -- dimmer blue
|
||
else
|
||
-- Normal flight: white double-strobe (FAA-style)
|
||
-- flash (100ms) → gap (80ms) → flash (100ms) → pause (~800ms)
|
||
local period = 1200
|
||
local phase = nowMs % period
|
||
|
||
if phase < 100 then
|
||
blinkFactor = 1.0
|
||
elseif phase < 180 then
|
||
blinkFactor = 0.0
|
||
elseif phase < 280 then
|
||
blinkFactor = 1.0
|
||
else
|
||
blinkFactor = 0.0
|
||
end
|
||
|
||
r,g,b = 255,255,255
|
||
end
|
||
end
|
||
else
|
||
-- Non-local clients: simple, dim white strobe when drone active
|
||
if GlobalDroneActive then
|
||
showLED = true
|
||
r,g,b = 230, 230, 255
|
||
baseIntensity = isNight and 3.0 or 0.75 -- slightly dimmer for remote
|
||
local period = 1200
|
||
local phase = nowMs % period
|
||
if phase < 100 or (phase > 180 and phase < 280) then
|
||
blinkFactor = 1.0
|
||
else
|
||
blinkFactor = 0.0
|
||
end
|
||
end
|
||
end
|
||
|
||
if showLED and blinkFactor > 0.0 and baseIntensity > 0.0 then
|
||
local tinyRadius = 0.6 -- very small LED radius
|
||
local finalIntensity = (baseIntensity * 0.35) * distFactor * blinkFactor
|
||
|
||
local wPos = GetOffsetFromEntityInWorldCoords(
|
||
drone,
|
||
ledOffset.x, ledOffset.y, ledOffset.z
|
||
)
|
||
|
||
DrawLightWithRange(
|
||
wPos.x, wPos.y, wPos.z,
|
||
r, g, b,
|
||
tinyRadius, -- small LED pool
|
||
finalIntensity -- scaled brightness
|
||
)
|
||
end
|
||
end
|
||
|
||
Wait(0)
|
||
else
|
||
Wait(600)
|
||
end
|
||
end
|
||
end)
|