update radio script

This commit is contained in:
KingMcDonalds
2026-03-28 17:31:40 -07:00
parent fd93a7b32d
commit db37d37b52
20 changed files with 1165 additions and 3628 deletions
Binary file not shown.
+16 -3
View File
@@ -35,6 +35,17 @@ local function LoadAnimDictAsync(dict, shouldContinue, onLoaded)
end)
end
--- Returns true if radio animations should be suppressed (in vehicle or holding a weapon).
--- @param playerPed number Ped handle
--- @return boolean
local function ShouldSuppressAnim(playerPed)
local vehicle = GetVehiclePedIsIn(playerPed, false)
if vehicle ~= 0 and GetPedInVehicleSeat(vehicle, -1) == playerPed then return true end
local _, weaponHash = GetCurrentPedWeapon(playerPed, true)
if weaponHash ~= GetHashKey("WEAPON_UNARMED") then return true end
return false
end
--- Starts a ped animation and optionally attaches a prop.
--- @param playerPed number Ped handle
--- @param dict string Animation dictionary
@@ -86,6 +97,7 @@ local function MakePTTHandler(dict, anim, prop)
if not playerPed or playerPed == 0 then return end
if isKeyDown then
if ShouldSuppressAnim(playerPed) then return end
_radioAnimState.pendingStart = true
LoadAnimDictAsync(dict,
function() return _radioAnimState.pendingStart end,
@@ -112,6 +124,7 @@ local function MakeFocusHandler(dict, anim, prop)
if not playerPed or playerPed == 0 then return end
if focused then
if ShouldSuppressAnim(playerPed) then return end
if not _radioAnimState.isPlaying then
LoadAnimDictAsync(dict,
function() return true end,
@@ -148,14 +161,14 @@ Config.animations = {
-- [2] Shoulder mic — PTT plays a shoulder-radio gesture, focus shows handheld + prop
[2] = {
name = "Shoulder",
onKeyState = MakePTTHandler("random@arrests", "generic_radio_enter", nil),
name = "Shoulder",
onKeyState = MakePTTHandler("random@arrests", "generic_radio_enter", nil),
onRadioFocus = MakeFocusHandler("cellphone@", "cellphone_call_to_text", "prop_cs_hand_radio"),
},
-- [3] Handheld radio — Both PTT and focus use the same handheld + prop animation
[3] = {
name = "Handheld",
name = "Handheld",
onKeyState = MakePTTHandler("cellphone@", "cellphone_call_to_text", "prop_cs_hand_radio"),
onRadioFocus = MakeFocusHandler("cellphone@", "cellphone_call_to_text", "prop_cs_hand_radio"),
},
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55 -13
View File
File diff suppressed because one or more lines are too long
+548
View File
@@ -0,0 +1,548 @@
-- ┌──────────────────────────────────────────────────────────────┐
-- │ 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)
@@ -0,0 +1 @@
{"fbi":{"ry":0.0,"rx":-270.9811374742786,"z":0.37792830504115,"y":0.72812757201645,"x":-0.013,"rz":-18.8968838462681}}
@@ -7,6 +7,7 @@
"radioWidth": 300,
"radioHeight": 406,
"defaultBottomPadding": 0,
"useAlertLineTS": false,
"alert": {
"x": 102,
"y": 306,
File diff suppressed because one or more lines are too long
+407
View File
@@ -0,0 +1,407 @@
-- ┌──────────────────────────────────────────────────────────────┐
-- │ Tommy's Radio - 3D Vehicle Radio Screen │
-- │ Renders the AFX-1500 screen on the vehicle radio prop │
-- │ using AddReplaceTexture to swap the prop's screen material │
-- │ with a DUI browser. Zero jitter — the DUI content renders │
-- │ as part of the 3D model geometry itself. │
-- │ Active only when the player owns a vehicle that has an │
-- │ attached radio prop (see positions.lua). │
-- │ Documentation: https://docs.timmygstudios.com/docs/tommys-radio │
-- └──────────────────────────────────────────────────────────────┘
-- Server has nothing to do here
if IsDuplicityVersion() then return end
-- ══════════════════════════════════════════════════════════════════
-- CONFIGURATION
-- ══════════════════════════════════════════════════════════════════
-- The original texture dictionary and texture name on the AFX1500 prop model.
-- AddReplaceTexture swaps this material with the DUI content at runtime.
local ORIG_TXD = "afx1500"
local ORIG_TEX = "C_Screen"
local DUI_RES_W = 480 -- DUI browser resolution (width)
local DUI_RES_H = 240 -- DUI browser resolution (height)
-- LED texture names on the AFX1500 prop and their lit colors (RGBA 0-255).
local LED_TEXTURES = {
power = { tex = "C_Red", onR = 255, onG = 0, onB = 0 },
connected = { tex = "C_Green", onR = 0, onG = 255, onB = 0 },
transmit = { tex = "C_Orange", onR = 255, onG = 153, onB = 0 },
}
-- Dim color when LED is off
local LED_OFF_R, LED_OFF_G, LED_OFF_B = 20, 20, 20
-- ══════════════════════════════════════════════════════════════════
-- DUI STATE
-- ══════════════════════════════════════════════════════════════════
local screenDui = nil
local screenTxdName = "radio_scr_txd"
local screenTexName = "radio_scr_tex"
local textureActive = false
local screenActive = false
local screenVehicle = nil
local screenProp = nil
-- LED runtime texture state: { [key] = { txdName, texName, currentlyOn } }
local ledState = {}
-- Cache of the last message sent for each action type, so we can
-- replay the full display state when the DUI is first created.
local lastScreenState = {}
-- ══════════════════════════════════════════════════════════════════
-- LED TEXTURE HELPERS
-- Creates a 4x4 solid-color runtime texture for each LED and
-- uses AddReplaceTexture to swap the prop's LED materials.
-- ══════════════════════════════════════════════════════════════════
--- Fills a runtime texture with a solid color by setting every pixel.
local function fillTexture(tex, w, h, r, g, b, a)
for y = 0, h - 1 do
for x = 0, w - 1 do
SetRuntimeTexturePixel(tex, x, y, r, g, b, a)
end
end
CommitRuntimeTexture(tex)
end
--- Creates the runtime textures for all LEDs and sets them to the off color.
local function createLedTextures()
for key, cfg in pairs(LED_TEXTURES) do
local txdName = "radio_led_" .. key .. "_txd"
local texName = "radio_led_" .. key .. "_tex"
local txd = CreateRuntimeTxd(txdName)
local tex = CreateRuntimeTexture(txd, texName, 4, 4)
fillTexture(tex, 4, 4, LED_OFF_R, LED_OFF_G, LED_OFF_B, 255)
AddReplaceTexture(ORIG_TXD, cfg.tex, txdName, texName)
ledState[key] = {
txdName = txdName,
texName = texName,
texHandle = tex,
currentlyOn = false,
}
end
end
--- Removes all LED texture replacements and clears state.
local function destroyLedTextures()
for key, st in pairs(ledState) do
local cfg = LED_TEXTURES[key]
if cfg then
RemoveReplaceTexture(ORIG_TXD, cfg.tex)
end
end
ledState = {}
end
--- Sets a single LED to its on or off color.
local function setLedColor(key, on)
local st = ledState[key]
local cfg = LED_TEXTURES[key]
if not st or not cfg then return end
if st.currentlyOn == on then return end -- no change
if on then
fillTexture(st.texHandle, 4, 4, cfg.onR, cfg.onG, cfg.onB, 255)
else
fillTexture(st.texHandle, 4, 4, LED_OFF_R, LED_OFF_G, LED_OFF_B, 255)
end
st.currentlyOn = on
end
-- ══════════════════════════════════════════════════════════════════
-- DUI LIFECYCLE
-- ══════════════════════════════════════════════════════════════════
local function createScreenDui()
if screenDui then return end
local resName = GetCurrentResourceName()
local url = "https://cfx-nui-" .. resName .. "/client/screen.html"
screenDui = CreateDui(url, DUI_RES_W, DUI_RES_H)
local handle = GetDuiHandle(screenDui)
local txd = CreateRuntimeTxd(screenTxdName)
CreateRuntimeTextureFromDuiHandle(txd, screenTexName, handle)
-- Swap the prop's screen texture with the DUI content
AddReplaceTexture(ORIG_TXD, ORIG_TEX, screenTxdName, screenTexName)
textureActive = true
-- Create LED textures and sync to cached state
createLedTextures()
for key, _ in pairs(LED_TEXTURES) do
local cached = lastScreenState['setLED_' .. key]
if cached and cached.mode then
setLedColor(key, true)
end
end
-- Give the browser a moment to load, then replay cached state.
-- Skip 'alert' — alerts are transient (3s auto-clear) and should
-- not be replayed on screen activation or power-on.
Citizen.SetTimeout(300, function()
if screenDui then
for key, msg in pairs(lastScreenState) do
if key ~= 'alert' then
SendDuiMessage(screenDui, json.encode(msg))
end
end
end
end)
end
local function destroyScreenDui()
destroyLedTextures()
if textureActive then
RemoveReplaceTexture(ORIG_TXD, ORIG_TEX)
textureActive = false
end
if screenDui then
DestroyDui(screenDui)
screenDui = nil
end
end
--- Sends a JSON message to the DUI browser (no-op if DUI is inactive).
local function sendToScreen(msg)
if screenDui then
SendDuiMessage(screenDui, json.encode(msg))
end
end
-- ══════════════════════════════════════════════════════════════════
-- DISPLAY FUNCTION WRAPPING
-- Intercepts the global display functions defined in shared.lua
-- to mirror every NUI update into the 3D DUI screen.
-- Cached so the full state can be replayed on DUI creation.
-- ══════════════════════════════════════════════════════════════════
-- dispUpdate — main display text, zone, channel
local _origDispUpdate = dispUpdate
function dispUpdate(btn01, btn02, btn03, btn04, btn05, ln01, ln02, zone, channel)
_origDispUpdate(btn01, btn02, btn03, btn04, btn05, ln01, ln02, zone, channel)
local msg = {
action = 'dispUpdate',
display = {
btn01 = btn01, btn02 = btn02, btn03 = btn03,
btn04 = btn04, btn05 = btn05,
ln01 = ln01, ln02 = ln02,
zone = zone, channel = channel,
}
}
lastScreenState['dispUpdate'] = msg
sendToScreen(msg)
end
-- setBatt
local _origSetBatt = setBatt
function setBatt(status)
_origSetBatt(status)
local msg = { action = 'setBatt', status = status }
lastScreenState['setBatt'] = msg
sendToScreen(msg)
end
-- setSignal
local _origSetSignal = setSignal
function setSignal(status)
_origSetSignal(status)
local msg = { action = 'setSignal', status = status }
lastScreenState['setSignal'] = msg
sendToScreen(msg)
end
-- setGPS
local _origSetGPS = setGPS
function setGPS(status)
_origSetGPS(status)
local msg = { action = 'setGPS', status = status }
lastScreenState['setGPS'] = msg
sendToScreen(msg)
end
-- setTrunk
local _origSetTrunk = setTrunk
function setTrunk(status)
_origSetTrunk(status)
local msg = { action = 'setTrunk', status = status }
lastScreenState['setTrunk'] = msg
sendToScreen(msg)
end
-- setScan
local _origSetScan = setScan
function setScan(status)
_origSetScan(status)
local msg = { action = 'setScan', status = status }
lastScreenState['setScan'] = msg
sendToScreen(msg)
end
-- setWarn
local _origSetWarn = setWarn
function setWarn(status)
_origSetWarn(status)
local msg = { action = 'setWarn', status = status }
lastScreenState['setWarn'] = msg
sendToScreen(msg)
end
-- setLED — also drives the 3D prop LED textures
local _origSetLED = setLED
function setLED(key, state)
_origSetLED(key, state)
local msg = { action = 'setLED', key = key, mode = state }
lastScreenState['setLED_' .. tostring(key)] = msg
sendToScreen(msg)
-- Update the prop's LED material color
setLedColor(key, state and true or false)
end
-- updateTime — recompute the time string (mirrors shared.lua logic)
local _origUpdateTime = updateTime
function updateTime()
_origUpdateTime()
local msg
if RadioState and RadioState.power then
local hours = GetClockHours()
local minutes = GetClockMinutes()
local period = "AM"
if hours >= 12 then
period = "PM"
if hours > 12 then hours = hours - 12 end
elseif hours == 0 then
hours = 12
end
msg = { action = 'setTime', time = string.format("%02d:%02d %s", hours, minutes, period) }
else
msg = { action = 'setTime', time = "" }
end
lastScreenState['setTime'] = msg
sendToScreen(msg)
end
-- setTheme — sync dark/light mode to the 3D screen
-- resolveTheme is local in shared.lua, so we inline the Auto logic here.
local _origSetTheme = setTheme
function setTheme(theme)
_origSetTheme(theme)
local resolved = theme
if theme == "Auto" then
local h = GetClockHours()
resolved = (h >= 20 or h < 6) and "Dark" or "Light"
end
local msg = { action = 'setTheme', theme = resolved }
lastScreenState['setTheme'] = msg
sendToScreen(msg)
end
-- setRadioConfig — forward model name so the DUI can load useAlertLineTS
local _origSetRadioConfig = setRadioConfig
function setRadioConfig(model)
_origSetRadioConfig(model)
local msg = { action = 'setRadioConfig', model = model }
lastScreenState['setRadioConfig'] = msg
sendToScreen(msg)
end
-- showAlert
local _origShowAlert = showAlert
function showAlert(message, mode, color)
_origShowAlert(message, mode, color)
if not mode then mode = 'none' end
if not color and mode == 'none' then color = 'black' end
if not color then color = 'white' end
local msg = { action = 'alert', message = message, mode = mode, textColor = color }
lastScreenState['alert'] = msg
sendToScreen(msg)
end
-- ══════════════════════════════════════════════════════════════════
-- VEHICLE OWNERSHIP LIFECYCLE
-- Watches playerOwnedVehicle (defined in shared.lua) and
-- activates the screen when the owned vehicle has a radio prop.
-- ══════════════════════════════════════════════════════════════════
Citizen.CreateThread(function()
while true do
Citizen.Wait(1000)
local vehicle = playerOwnedVehicle
if vehicle and DoesEntityExist(vehicle) then
-- Check if this vehicle has an attached radio prop
local prop = getAttachedRadioProp and getAttachedRadioProp(vehicle) or nil
if prop and DoesEntityExist(prop) then
if not screenActive or screenVehicle ~= vehicle then
screenVehicle = vehicle
screenProp = prop
screenActive = true
createScreenDui()
end
elseif screenActive then
screenActive = false
screenVehicle = nil
screenProp = nil
destroyScreenDui()
end
elseif screenActive then
screenActive = false
screenVehicle = nil
screenProp = nil
destroyScreenDui()
end
end
end)
-- ══════════════════════════════════════════════════════════════════
-- EVENT HOOKS
-- Catch LED changes that bypass the global setLED() function.
-- shared.lua line 9914: radioClient:setLED sends directly to NUI.
-- ══════════════════════════════════════════════════════════════════
AddEventHandler("radioClient:setLED", function(mode)
-- This event doesn't include a key — it's a general LED mode toggle.
-- Forward to the DUI and sync all prop LEDs based on the mode value.
if type(mode) == "table" then
-- mode might be a table of { key = state } pairs
for key, state in pairs(mode) do
setLedColor(key, state and true or false)
local msg = { action = 'setLED', key = key, mode = state }
lastScreenState['setLED_' .. tostring(key)] = msg
sendToScreen(msg)
end
elseif type(mode) == "boolean" then
-- Boolean: toggle all LEDs on/off
for key, _ in pairs(LED_TEXTURES) do
setLedColor(key, mode)
end
end
end)
-- ══════════════════════════════════════════════════════════════════
-- CLEANUP
-- ══════════════════════════════════════════════════════════════════
AddEventHandler('onResourceStop', function(resourceName)
if GetCurrentResourceName() == resourceName then
destroyScreenDui()
end
end)
+83 -65
View File
@@ -1,9 +1,11 @@
-- Tommy's Radio System Configuration
-- Documentation: https://docs.timmygstudios.com/docs/tommys-radio
-- Config Version 4.0 - Use a website like https://www.diffchecker.com/ to compare configuration file changes
-- Config Version 4.3 - Use a website like https://www.diffchecker.com/ to compare configuration file changes
Config = {
communityID = "egrpleo", -- This is so player's settings are unique to your server
-- ┌──────────────────────────────────────────────────────────────┐
-- │ RADIO LAYOUTS │
-- └──────────────────────────────────────────────────────────────┘
@@ -35,8 +37,18 @@ Config = {
["Air"] = "TXDF-9100",
-- Per-spawn-code overrides (add as many as you need)
["fbi2"] = "XPR-6500",
["police"] = "XPR-6500",
["fbi2"] = "XPR-6500",
["police"] = "XPR-6500",
},
-- External vehicle resources that ship pre-configured radio prop positions.
-- Each resource listed here should define tRadioProp 'filename.json'
-- in its fxmanifest.lua. The JSON format matches client/prop_locations.json:
-- { "modelname": { "x":0, "y":0.7, "z":0.38, "rx":-270, "ry":0, "rz":-19 } }
-- Positions saved in-game via /radioattach take priority over external ones.
ExternalVehResources = {
-- "my-police-pack",
-- "my-fire-vehicles",
},
-- ┌──────────────────────────────────────────────────────────────┐
@@ -48,33 +60,33 @@ Config = {
controls = {
-- Primary controls
talkRadioKey = "B", -- Push-to-talk
toggleRadioKey = "F6", -- Open / close the radio UI
closeRadioKey = "", -- Alternative close key
powerBtnKey = "", -- Toggle radio power on/off
talkRadioKey = "B", -- Push-to-talk
toggleRadioKey = "F6", -- Open / close the radio UI
closeRadioKey = "", -- Alternative close key
powerBtnKey = "", -- Toggle radio power on/off
-- Channel & zone navigation
channelUpKey = "",
channelDownKey = "",
zoneUpKey = "",
zoneDownKey = "",
channelUpKey = "",
channelDownKey = "",
zoneUpKey = "",
zoneDownKey = "",
-- Menu navigation
menuUpKey = "",
menuDownKey = "",
menuRightKey = "",
menuLeftKey = "",
menuHomeKey = "",
menuBtn1Key = "",
menuBtn2Key = "",
menuBtn3Key = "",
menuUpKey = "",
menuDownKey = "",
menuRightKey = "",
menuLeftKey = "",
menuHomeKey = "",
menuBtn1Key = "",
menuBtn2Key = "",
menuBtn3Key = "",
-- Misc
emergencyBtnKey = "", -- Trigger emergency / panic button
emergencyBtnKey = "", -- Trigger emergency / panic button
-- Radio style cycling
styleUpKey = "",
styleDownKey = "",
styleUpKey = "",
styleDownKey = "",
-- Volume hotkeys
voiceVolumeUpKey = "",
@@ -92,20 +104,20 @@ Config = {
-- Final connection string sent to clients. Include protocol and/or port
-- if using a proxy (e.g., "https://proxy.example.com"). When empty, the
-- server's auto-detected IP address and serverPort are used instead.
serverAddress = " ",
serverAddress = "",
-- Port for the radio voice server and dispatch panel. Choose a port not
-- used by other resources on your server.
serverPort = 012019,
serverPort = 7777,
-- Secure token for radio authentication. Change this to a long, random
-- string — it protects the voice server and dispatch panel API.
authToken = " ",
authToken = "changeme",
-- NAC ID / password for the dispatch channel. In-game players whose NAC
-- ID matches this value can access the trunked control frequency and
-- trigger SGN alerts from their radio.
dispatchNacId = " ",
dispatchNacId = "141",
-- Enable Discord-based authentication for the dispatch panel. Requires
-- a Discord application configured in server/.env (see server/.env.example).
@@ -126,16 +138,17 @@ Config = {
-- players who haven't set a custom callsign yet.
useCallsignSystem = true,
callsignCommand = "callsign", -- In-game command (e.g., /callsign 2L-319). Set to "" to disable.
callsignCommand = "callsign", -- In-game command (e.g., /callsign 2L-319). Set to "" to disable.
-- ┌──────────────────────────────────────────────────────────────┐
-- │ GENERAL │
-- └──────────────────────────────────────────────────────────────┘
checkForUpdates = true, -- Check for script updates on resource start
logLevel = 3, -- 0 = Error, 1 = Warnings, 2 = Minimal, 3 = Normal, 4 = Debug, 5 = Verbose
pttReleaseDelay = 350, -- Milliseconds before releasing PTT to prevent audio cut-off (250-500 recommended)
panicTimeout = 60000, -- Milliseconds before a panic alert auto-clears
checkForUpdates = true, -- Check for script updates on resource start
healthCheck = true, -- Perform HTTP health check after server starts (https://docs.timmygstudios.com/docs/tommys-radio/troubleshooting#health-checks-failing-despite-working-panel-toc)
logLevel = 3, -- 0 = Error, 1 = Warnings, 2 = Minimal, 3 = Normal, 4 = Debug, 5 = Verbose
pttReleaseDelay = 350, -- Milliseconds before releasing PTT to prevent audio cut-off (250-500 recommended)
panicTimeout = 60000, -- Milliseconds before a panic alert auto-clears
-- When true, pressing PTT on the radio will also trigger proximity
-- voice chat so nearby players can hear you speaking in-game.
@@ -178,32 +191,32 @@ Config = {
-- applied to all received voice. Works well on its own as a
-- lightweight "radio sound" without any vocoder overhead.
fxEnabled = false, -- Master switch for the FX chain below.
fxEnabled = false, -- Master switch for the FX chain below.
highpassFrequency = 250, -- Hz (80800) Low cut. Removes rumble/low-end.
-- 250 Hz is a clean radio sound without
-- eating the low harmonics of deep voices.
lowpassFrequency = 3400, -- Hz (12008000) High cut. 3400 Hz = telephone band.
distortion = 20, -- (0100) Tube saturation / analog warmth.
compression = 60, -- (0100) Dynamic range crushing.
midBoost = 2, -- dB (-12 to 12) Presence boost at ~1200 Hz.
inputGain = 1.2, -- (0.53.0) Pre-amp before the chain.
highpassFrequency = 250, -- Hz (80800) Low cut. Removes rumble/low-end.
-- 250 Hz is a clean radio sound without
-- eating the low harmonics of deep voices.
lowpassFrequency = 3400, -- Hz (12008000) High cut. 3400 Hz = telephone band.
distortion = 20, -- (0100) Tube saturation / analog warmth.
compression = 60, -- (0100) Dynamic range crushing.
midBoost = 2, -- dB (-12 to 12) Presence boost at ~1200 Hz.
inputGain = 1.2, -- (0.53.0) Pre-amp before the chain.
-- ── P25 IMBE Vocoder ────────────────────────────────────────
-- ── P25 IMBE Vocoder (EXPERIMENTAL) ────────────────────────────────────────
-- True IMBE encode→decode loop. Produces the
-- characteristic P25 digital radio sound. More CPU than fxEnabled
-- alone but bit-accurate to real P25 Phase 1 hardware.
p25Enabled = true, -- Master switch for the IMBE vocoder.
p25Enabled = true, -- Master switch for the IMBE vocoder.
},
-- 3D Audio (EXPERIMENTAL) — when enabled, radio audio is spatially
-- positioned in the game world instead of playing in 2D.
enable3DAudio = false, -- Master switch for the 3D audio system
default3DAudio = false, -- true = earbuds OFF by default (3D enabled), false = earbuds ON (3D disabled)
default3DVolume = 50, -- Default 3D volume (0-100), saved per user
vehicle3DActivationDistance = 3.0, -- Min distance (meters) from vehicle before its 3D audio activates
enable3DAudio = false, -- Master switch for the 3D audio system
default3DAudio = false, -- true = earbuds OFF by default (3D enabled), false = earbuds ON (3D disabled)
default3DVolume = 50, -- Default 3D volume (0-100), saved per user
vehicle3DActivationDistance = 3.0, -- Min distance (meters) from vehicle before its 3D audio activates
-- ┌──────────────────────────────────────────────────────────────┐
-- │ GPS & SIGNAL │
@@ -214,8 +227,13 @@ Config = {
-- Recommended: 50 (smooth), 100 (balanced), 250 (performance), 500 (low-end)
gpsBlipUpdateRate = 50,
-- Signal tower positions used for the signal-strength icon on the radio.
-- NOTE: These do NOT affect voice quality — they are cosmetic only.
-- When enabled, audio quality degrades based on distance to the nearest
-- signal tower. Closer = clearer voice, further = crushed/garbled audio
-- with dropouts. Affects both your outgoing mic and incoming playback.
signalDegradationEnabled = false,
-- Signal tower positions used for the signal-strength icon on the radio
-- and, when signalDegradationEnabled is true, for audio degradation.
signalTowerCoordinates = {
{ x = 1860.0, y = 3677.0, z = 33.0 },
{ x = 449.0, y = -992.0, z = 30.0 },
@@ -282,10 +300,10 @@ Config = {
-- doubleTapOverride = true.
bonking = {
blockTransmission = true,
playBonkTone = true,
doubleTapOverride = true,
doubleTapWindow = 1500,
blockTransmission = true,
playBonkTone = true,
doubleTapOverride = true,
doubleTapWindow = 1500,
},
-- ┌──────────────────────────────────────────────────────────────┐
@@ -342,22 +360,22 @@ Config = {
alerts = {
[1] = {
name = "SIGNAL 100",
color = "#a38718",
isPersistent = true, -- Stays active until manually cleared
name = "SIGNAL 100",
color = "#a38718",
isPersistent = true, -- Stays active until manually cleared
-- Per-phase tones: loud triple-beep to activate, quiet single
-- beep on each repeat so it stays noticeable without being
-- overwhelming, and a falling tone to confirm it's cleared.
tones = {
tones = {
["activate"] = "PRIORITY",
["repeat"] = "PRIORITY_REPEAT",
["deactivate"] = "ALERT_C",
},
repeatInterval = 15000, -- repeat fires every 15 seconds exactly
-- omit this line for the default random 5-10s cadence
repeatInterval = 15000, -- repeat fires every 15 seconds exactly
-- omit this line for the default random 5-10s cadence
repeatShowBanner = false, -- true → flash alert name on radio each repeat
-- false → play the repeat tone only, no visual flash
deactivateLabel = "RESUME", -- text shown on radio when cleared
-- false → play the repeat tone only, no visual flash
deactivateLabel = "RESUME", -- text shown on radio when cleared
deactivateColor = "#1a8a38", -- colour of that label
},
[2] = {
@@ -370,12 +388,12 @@ Config = {
[3] = {
name = "Ping",
color = "#1852a3",
tone = "ALERT_B", -- non-persistent: only activate phase matters
tone = "ALERT_B", -- non-persistent: only activate phase matters
},
[4] = {
name = "Boop",
color = "#1c4ba3",
toneOnly = true, -- Plays tone without showing an alert on the radio
toneOnly = true, -- Plays tone without showing an alert on the radio
tone = "BONK",
},
},
@@ -400,7 +418,7 @@ Config = {
zones = {
[1] = {
name = "California Highway Patrol",
name = "Statewide",
nacIds = { "141", "110" },
Channels = {
[1] = {
@@ -440,7 +458,7 @@ Config = {
},
},
[2] = {
name = "Los Angeles POLICE DEPARTMENT",
name = "Los Santos",
nacIds = { "141" },
Channels = {
[1] = {
@@ -480,7 +498,7 @@ Config = {
},
},
[3] = {
name = "Los Angeles County Sheriff",
name = "Blaine County",
nacIds = { "141" },
Channels = {
[1] = {
+13 -5
View File
@@ -4,9 +4,9 @@ fx_version 'bodacious'
game 'gta5'
name 'Tommy\'s Radio'
description 'FiveM In-Game Radio Script'
author 'Tommy Johnston'
version 'v4.0'
description 'Realistic Radio for FiveM'
author 'Tommy Johnston (TIMMYG Studios)'
version 'v4.3'
-- Lua Version
lua54 'yes'
@@ -21,15 +21,19 @@ files {
'client/dist/imbe_vocoder.wasm',
'client/radios/**/*.*',
'client/index.html',
'client/screen.html',
'stream/**'
}
-- Scripts
shared_scripts {
'config.lua',
'animations.lua',
'audio.lua',
'client/audio.lua',
'shared.lua',
'blips.lua',
'client/blips.lua',
'client/positions.lua',
'client/screen.lua',
}
server_scripts {
@@ -41,7 +45,11 @@ server_scripts {
escrow_ignore {
'config.lua',
'animations.lua',
'client/positions.lua',
'client/screen.lua',
'client/radios/**/*.*'
}
data_file 'DLC_ITYP_REQUEST' 'stream/afx1500.ytyp'
dependency '/assetpacks'
File diff suppressed because one or more lines are too long
Binary file not shown.
+36 -3541
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.