update radio script
This commit is contained in:
Binary file not shown.
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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 (80–800) Low cut. Removes rumble/low-end.
|
||||
-- 250 Hz is a clean radio sound without
|
||||
-- eating the low harmonics of deep voices.
|
||||
lowpassFrequency = 3400, -- Hz (1200–8000) High cut. 3400 Hz = telephone band.
|
||||
distortion = 20, -- (0–100) Tube saturation / analog warmth.
|
||||
compression = 60, -- (0–100) Dynamic range crushing.
|
||||
midBoost = 2, -- dB (-12 to 12) Presence boost at ~1200 Hz.
|
||||
inputGain = 1.2, -- (0.5–3.0) Pre-amp before the chain.
|
||||
highpassFrequency = 250, -- Hz (80–800) Low cut. Removes rumble/low-end.
|
||||
-- 250 Hz is a clean radio sound without
|
||||
-- eating the low harmonics of deep voices.
|
||||
lowpassFrequency = 3400, -- Hz (1200–8000) High cut. 3400 Hz = telephone band.
|
||||
distortion = 20, -- (0–100) Tube saturation / analog warmth.
|
||||
compression = 60, -- (0–100) Dynamic range crushing.
|
||||
midBoost = 2, -- dB (-12 to 12) Presence boost at ~1200 Hz.
|
||||
inputGain = 1.2, -- (0.5–3.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] = {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user