408 lines
16 KiB
Lua
408 lines
16 KiB
Lua
-- ┌──────────────────────────────────────────────────────────────┐
|
|
-- │ 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)
|