Files
Elite-Gaming-FiveM/resources/radio/client/positions.lua
T
2026-03-28 17:31:40 -07:00

549 lines
24 KiB
Lua

-- ┌──────────────────────────────────────────────────────────────┐
-- │ Tommy's Radio - Vehicle Radio Prop System │
-- │ Attaches the AFX1500 radio prop to configured vehicles. │
-- │ Admins with dispatch NAC access can position props │
-- │ in-game — no config edits or restarts required. │
-- │ Documentation: https://docs.timmygstudios.com/docs/tommys-radio │
-- └──────────────────────────────────────────────────────────────┘
-- ══════════════════════════════════════════════════════════════════
-- SERVER
-- ══════════════════════════════════════════════════════════════════
if IsDuplicityVersion() then
local vehicleProps = {}
--- Loads saved prop positions from the JSON data file.
local function loadVehicleProps()
local raw = LoadResourceFile(GetCurrentResourceName(), "client/prop_locations.json")
if raw and raw ~= "" then
vehicleProps = json.decode(raw) or {}
end
end
--- Writes the current prop positions to the JSON data file.
local function saveVehiclePropsToDisk()
SaveResourceFile(
GetCurrentResourceName(),
"client/prop_locations.json",
json.encode(vehicleProps),
-1
)
end
--- Returns true if the player's NAC ID matches the dispatch NAC ID
--- (same permission gate as SGN alerts / trunked control frequency).
local function hasDispatchAccess(playerId)
local nacId = Config.getUserNacId(playerId)
return nacId and tostring(nacId) == tostring(Config.dispatchNacId)
end
--- Loads prop positions from external vehicle resources listed in
--- Config.ExternalVehResources. Each resource declares its JSON file
--- via tRadioProp 'filename.json' in its fxmanifest.lua.
--- Local saves (from /radioattach) take priority — external entries
--- never overwrite positions already in vehicleProps.
local function loadExternalVehicleProps()
if not Config.ExternalVehResources then return end
for _, resName in ipairs(Config.ExternalVehResources) do
local state = GetResourceState(resName)
if state == "started" or state == "starting" then
local filename = GetResourceMetadata(resName, "tRadioProp", 0)
if filename and filename ~= "" then
local raw = LoadResourceFile(resName, filename)
if raw and raw ~= "" then
local props = json.decode(raw)
if props then
local count = 0
for model, data in pairs(props) do
if not vehicleProps[model] then
vehicleProps[model] = data
count = count + 1
end
end
if count > 0 then
log("Loaded " .. count .. " external radio prop(s) from '" .. resName .. "' (" .. filename .. ")", 3)
end
else
log("Failed to parse tRadioProp JSON from resource '" .. resName .. "'", 1)
end
else
log("Could not read tRadioProp file '" .. filename .. "' from resource '" .. resName .. "'", 1)
end
else
log("Resource '" .. resName .. "' listed in ExternalVehResources but has no tRadioProp metadata", 1)
end
else
log("External prop resource '" .. resName .. "' is not started (state: " .. tostring(state) .. ")", 1)
end
end
end
-- Load existing data on resource start
loadVehicleProps()
loadExternalVehicleProps()
-- Pick up external vehicle resources that start after the radio resource
AddEventHandler("onResourceStart", function(resName)
if not Config.ExternalVehResources then return end
for _, name in ipairs(Config.ExternalVehResources) do
if name == resName then
local filename = GetResourceMetadata(resName, "tRadioProp", 0)
if filename and filename ~= "" then
local raw = LoadResourceFile(resName, filename)
if raw and raw ~= "" then
local props = json.decode(raw)
if props then
local count = 0
for model, data in pairs(props) do
if not vehicleProps[model] then
vehicleProps[model] = data
count = count + 1
end
end
if count > 0 then
log("Late-loaded " .. count .. " external radio prop(s) from '" .. resName .. "'", 3)
TriggerClientEvent("radio:receiveVehicleProps", -1, vehicleProps)
end
end
end
end
break
end
end
end)
-- ── Client requests the full prop table ──
RegisterNetEvent("radio:requestVehicleProps", function()
TriggerClientEvent("radio:receiveVehicleProps", source, vehicleProps)
end)
-- ── Save a prop position (permission-gated) ──
RegisterNetEvent("radio:saveVehicleProp", function(modelName, data)
local src = source
if not hasDispatchAccess(src) then
TriggerClientEvent("radio:vehiclePropNotify", src, "~r~Access denied~w~ — dispatch NAC ID required.")
log("Player " .. src .. " tried to save vehicle prop without dispatch access", 1)
return
end
vehicleProps[modelName] = data
saveVehiclePropsToDisk()
TriggerClientEvent("radio:receiveVehicleProps", -1, vehicleProps)
TriggerClientEvent("radio:vehiclePropNotify", src, "~g~Saved~w~ radio prop position for ~b~" .. modelName:upper())
log("Player " .. src .. " saved radio prop position for '" .. modelName .. "'", 3)
end)
-- ── Remove a prop position (permission-gated) ──
RegisterNetEvent("radio:removeVehicleProp", function(modelName)
local src = source
if not hasDispatchAccess(src) then
TriggerClientEvent("radio:vehiclePropNotify", src, "~r~Access denied~w~ — dispatch NAC ID required.")
log("Player " .. src .. " tried to remove vehicle prop without dispatch access", 1)
return
end
if not vehicleProps[modelName] then
TriggerClientEvent("radio:vehiclePropNotify", src, "~y~No saved position~w~ for ~b~" .. modelName:upper())
return
end
vehicleProps[modelName] = nil
saveVehiclePropsToDisk()
TriggerClientEvent("radio:receiveVehicleProps", -1, vehicleProps)
TriggerClientEvent("radio:vehiclePropNotify", src, "~g~Removed~w~ radio prop for ~b~" .. modelName:upper())
log("Player " .. src .. " removed radio prop for '" .. modelName .. "'", 3)
end)
return -- Server-side stops here
end
-- ══════════════════════════════════════════════════════════════════
-- CLIENT
-- ══════════════════════════════════════════════════════════════════
local PROP_MODEL = "AFX1500"
local vehicleProps = {} -- { [modelName] = { x, y, z, rx, ry, rz } }
local attachedProps = {} -- { [vehicleHandle] = propHandle }
--- Returns the radio prop handle attached to a vehicle, or nil.
--- Used by screen.lua to locate the prop for 3D screen rendering.
function getAttachedRadioProp(vehicle)
local prop = attachedProps[vehicle]
if prop and DoesEntityExist(prop) then return prop end
return nil
end
--- Returns the saved prop attachment data { x, y, z, rx, ry, rz } for a vehicle, or nil.
--- Used by screen.lua to compute the screen position directly on the vehicle (jitter-free).
function getRadioPropData(vehicle)
local modelName = GetDisplayNameFromVehicleModel(GetEntityModel(vehicle)):lower()
return vehicleProps[modelName]
end
-- ── Placement mode state ──
local placementActive = false
local placementProp = nil
local placementVehicle = nil
local placementModel = ""
local offset = { x = 0.0, y = 0.0, z = 0.0 }
local rotation = { x = 0.0, y = 0.0, z = 0.0 }
local posStep = 0.005
local rotStep = 0.5
-- Controls disabled during placement (movement, combat, weapon wheel, etc.)
local disabledControls = {
24, 25, -- attack / aim
30, 31, -- move LR / move UD
21, 36, -- sprint / duck
22, -- jump
44, 38, -- cover / pickup (Q / E)
37, 47, -- weapon select / detonate
71, 72, -- vehicle accel / brake
59, 60, -- vehicle LR / vehicle UD
85, 86, -- vehicle radio / horn
140, 141, -- melee
142, 143, -- melee alt
257, -- attack 2
263, 264, -- melee alt 2
}
-- ══════════════════════════════════════════════════════════════════
-- HELPERS
-- ══════════════════════════════════════════════════════════════════
--- Returns the lowercase spawn/model name for a vehicle entity.
local function getVehicleModelName(vehicle)
return GetDisplayNameFromVehicleModel(GetEntityModel(vehicle)):lower()
end
--- Draws a single line of text on screen (0-1 coordinates, centered).
local function drawText(text, x, y, scale, r, g, b, a)
SetTextFont(0)
SetTextProportional(true)
SetTextScale(0.0, scale or 0.35)
SetTextColour(r or 255, g or 255, b or 255, a or 255)
SetTextDropshadow(1, 0, 0, 0, 255)
SetTextEdge(1, 0, 0, 0, 255)
SetTextCentre(true)
SetTextEntry("STRING")
AddTextComponentString(text)
DrawText(x, y)
end
--- Requests the AFX1500 model and blocks until loaded.
--- @return boolean true if the model loaded successfully
local function loadPropModel()
local hash = GetHashKey(PROP_MODEL)
RequestModel(hash)
local attempts = 0
while not HasModelLoaded(hash) and attempts < 100 do
Citizen.Wait(10)
attempts = attempts + 1
end
return HasModelLoaded(hash)
end
-- ══════════════════════════════════════════════════════════════════
-- PROP LIFECYCLE (auto-attach / auto-cleanup)
-- ══════════════════════════════════════════════════════════════════
--- Attaches the radio prop to a vehicle using saved position data.
local function attachPropToVehicle(vehicle, propData)
if attachedProps[vehicle] then return end
if not loadPropModel() then return end
local hash = GetHashKey(PROP_MODEL)
local prop = CreateObject(hash, 0.0, 0.0, 0.0, false, false, false)
AttachEntityToEntity(
prop, vehicle, 0,
propData.x, propData.y, propData.z,
propData.rx, propData.ry, propData.rz,
false, false, false, false, 0, true
)
attachedProps[vehicle] = prop
SetModelAsNoLongerNeeded(hash)
end
--- Deletes the radio prop attached to a vehicle and clears the tracking entry.
local function detachProp(vehicle)
local prop = attachedProps[vehicle]
if prop and DoesEntityExist(prop) then
DeleteObject(prop)
end
attachedProps[vehicle] = nil
end
--- Re-attaches the placement prop at the current offset / rotation.
local function reattachPlacementProp()
if not placementProp or not DoesEntityExist(placementProp) then return end
DetachEntity(placementProp, false, false)
AttachEntityToEntity(
placementProp, placementVehicle, 0,
offset.x, offset.y, offset.z,
rotation.x, rotation.y, rotation.z,
false, false, false, false, 0, true
)
end
-- ══════════════════════════════════════════════════════════════════
-- EVENTS
-- ══════════════════════════════════════════════════════════════════
--- Receives the full prop table from the server (on join and after any save/remove).
RegisterNetEvent("radio:receiveVehicleProps", function(props)
vehicleProps = props or {}
end)
--- Notification from the server (save/remove results, access denied, etc.).
RegisterNetEvent("radio:vehiclePropNotify", function(message)
TriggerEvent("chat:addMessage", {
args = { "[Radio]", message }
})
end)
-- Request saved positions shortly after resource start
Citizen.CreateThread(function()
Citizen.Wait(2000)
TriggerServerEvent("radio:requestVehicleProps")
end)
-- ══════════════════════════════════════════════════════════════════
-- MAIN LIFECYCLE THREAD
-- Scans the vehicle pool once per second, attaching props to
-- configured models and cleaning up stale entries.
-- ══════════════════════════════════════════════════════════════════
Citizen.CreateThread(function()
while true do
Citizen.Wait(1000)
if not placementActive then
local allVehicles = GetGamePool("CVehicle")
local seen = {}
for _, vehicle in ipairs(allVehicles) do
local modelName = getVehicleModelName(vehicle)
if vehicleProps[modelName] then
seen[vehicle] = true
if not attachedProps[vehicle] then
attachPropToVehicle(vehicle, vehicleProps[modelName])
end
end
end
-- Remove props from vehicles that no longer exist or lost their config
for vehicle, _ in pairs(attachedProps) do
if not DoesEntityExist(vehicle) or not seen[vehicle] then
detachProp(vehicle)
end
end
end
end
end)
-- ══════════════════════════════════════════════════════════════════
-- PLACEMENT MODE THREAD
-- Runs every frame while placement is active. Handles controls,
-- updates the prop position, and draws the on-screen HUD.
-- ══════════════════════════════════════════════════════════════════
Citizen.CreateThread(function()
while true do
-- Sleep when idle
if not placementActive then
Citizen.Wait(500)
-- Cancel if the vehicle disappeared or player exited
elseif not placementVehicle
or not DoesEntityExist(placementVehicle)
or not IsPedInAnyVehicle(PlayerPedId(), false) then
if placementProp and DoesEntityExist(placementProp) then
DeleteObject(placementProp)
end
placementProp = nil
placementVehicle = nil
placementActive = false
else
Citizen.Wait(0) -- every frame
-- Disable normal controls
for _, ctrl in ipairs(disabledControls) do
DisableControlAction(0, ctrl, true)
end
-- ── Step multiplier (Shift = fine mode) ──
local fine = IsDisabledControlPressed(0, 21)
local pStep = fine and (posStep * 0.2) or posStep
local rStp = fine and (rotStep * 0.2) or rotStep
-- ── Position: WASD + Space / Ctrl ──
local moved = false
if IsDisabledControlPressed(0, 32) then offset.y = offset.y + pStep; moved = true end -- W (forward)
if IsDisabledControlPressed(0, 33) then offset.y = offset.y - pStep; moved = true end -- S (backward)
if IsDisabledControlPressed(0, 34) then offset.x = offset.x - pStep; moved = true end -- A (left)
if IsDisabledControlPressed(0, 35) then offset.x = offset.x + pStep; moved = true end -- D (right)
if IsDisabledControlPressed(0, 22) then offset.z = offset.z + pStep; moved = true end -- Space (up)
if IsDisabledControlPressed(0, 36) then offset.z = offset.z - pStep; moved = true end -- Ctrl (down)
-- ── Rotation: Arrow keys + Q / E (mouse stays free for camera) ──
if IsDisabledControlPressed(0, 172) then rotation.x = rotation.x + rStp; moved = true end -- Up (pitch)
if IsDisabledControlPressed(0, 173) then rotation.x = rotation.x - rStp; moved = true end -- Down (pitch)
if IsDisabledControlPressed(0, 174) then rotation.z = rotation.z + rStp; moved = true end -- Left (yaw)
if IsDisabledControlPressed(0, 175) then rotation.z = rotation.z - rStp; moved = true end -- Right (yaw)
if IsDisabledControlPressed(0, 44) then rotation.y = rotation.y - rStp; moved = true end -- Q (roll)
if IsDisabledControlPressed(0, 38) then rotation.y = rotation.y + rStp; moved = true end -- E (roll)
-- ── Step size: scroll wheel ──
if IsDisabledControlJustPressed(0, 241) then
posStep = math.min(posStep * 1.5, 0.05)
rotStep = math.min(rotStep * 1.5, 5.0)
end
if IsDisabledControlJustPressed(0, 242) then
posStep = math.max(posStep / 1.5, 0.0005)
rotStep = math.max(rotStep / 1.5, 0.05)
end
-- ── Apply new position ──
if moved then reattachPlacementProp() end
-- ── Save: Enter ──
if IsDisabledControlJustPressed(0, 191) then
TriggerServerEvent("radio:saveVehicleProp", placementModel, {
x = offset.x, y = offset.y, z = offset.z,
rx = rotation.x, ry = rotation.y, rz = rotation.z,
})
if placementProp and DoesEntityExist(placementProp) then
DeleteObject(placementProp)
end
placementProp = nil
placementVehicle = nil
placementActive = false
end
-- ── Cancel: Backspace ──
if IsDisabledControlJustPressed(0, 194) then
if placementProp and DoesEntityExist(placementProp) then
DeleteObject(placementProp)
end
placementProp = nil
placementVehicle = nil
placementActive = false
TriggerEvent("chat:addMessage", {
args = { "[Radio]", "Placement cancelled." }
})
end
-- ── On-screen HUD ──
drawText("~b~Radio Prop Placement~w~ — " .. placementModel:upper(), 0.5, 0.02, 0.45)
drawText("WASD ~b~Move~w~ | Arrows ~b~Pitch/Yaw~w~ | Q/E ~b~Roll~w~", 0.5, 0.065, 0.28)
drawText("Space/Ctrl ~b~Up/Down~w~ | Shift ~b~Fine~w~ | Scroll ~b~Speed~w~", 0.5, 0.09, 0.28)
drawText(
string.format("Pos: %.4f %.4f %.4f", offset.x, offset.y, offset.z),
0.5, 0.125, 0.30, 180, 255, 180
)
drawText(
string.format("Rot: %.2f %.2f %.2f", rotation.x, rotation.y, rotation.z),
0.5, 0.15, 0.30, 180, 255, 180
)
drawText(
string.format("Step: %.4f | Rot Step: %.2f", posStep, rotStep),
0.5, 0.175, 0.25, 180, 180, 255
)
drawText("~g~Enter~w~ Save | ~r~Backspace~w~ Cancel", 0.5, 0.21, 0.32)
end
end
end)
-- ══════════════════════════════════════════════════════════════════
-- COMMANDS
-- ══════════════════════════════════════════════════════════════════
--- /radioattach — Enter placement mode for the vehicle you're sitting in.
--- If the model already has a saved position, placement starts there.
RegisterCommand("radioattach", function()
local playerPed = PlayerPedId()
if not IsPedInAnyVehicle(playerPed, false) then
TriggerEvent("chat:addMessage", {
args = { "[Radio]", "You must be in a vehicle to use this command." }
})
return
end
if placementActive then
TriggerEvent("chat:addMessage", {
args = { "[Radio]", "Already in placement mode. Press Enter to save or Backspace to cancel." }
})
return
end
local vehicle = GetVehiclePedIsIn(playerPed, false)
local modelName = getVehicleModelName(vehicle)
-- Remove any existing auto-attached prop so it doesn't overlap
detachProp(vehicle)
-- Load the prop model
if not loadPropModel() then
TriggerEvent("chat:addMessage", {
args = { "[Radio]", "~r~Failed to load AFX1500 model." }
})
return
end
-- Start from the saved position if one exists, otherwise origin
local existing = vehicleProps[modelName]
if existing then
offset = { x = existing.x, y = existing.y, z = existing.z }
rotation = { x = existing.rx, y = existing.ry, z = existing.rz }
else
offset = { x = 0.0, y = 0.0, z = 0.0 }
rotation = { x = 0.0, y = 0.0, z = 0.0 }
end
posStep = 0.005
rotStep = 0.5
-- Create the placement prop and attach it
local hash = GetHashKey(PROP_MODEL)
placementProp = CreateObject(hash, 0.0, 0.0, 0.0, false, false, false)
placementVehicle = vehicle
placementModel = modelName
placementActive = true
reattachPlacementProp()
end, false)
--- /radioremove — Remove the saved radio prop position for the current vehicle model.
RegisterCommand("radioremove", function()
local playerPed = PlayerPedId()
if not IsPedInAnyVehicle(playerPed, false) then
TriggerEvent("chat:addMessage", {
args = { "[Radio]", "You must be in a vehicle to use this command." }
})
return
end
local vehicle = GetVehiclePedIsIn(playerPed, false)
local modelName = getVehicleModelName(vehicle)
-- Immediately remove the local prop so feedback is instant
detachProp(vehicle)
-- Ask the server to delete the saved position (permission checked server-side)
TriggerServerEvent("radio:removeVehicleProp", modelName)
end, false)