549 lines
24 KiB
Lua
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)
|