2025-06-06 00:45:08 +01:00
|
|
|
-- Global Key Table, defined once.
|
|
|
|
|
local KEY_TABLE = { 38, 29, 47, 23, 45, 159, 162, 163 }
|
|
|
|
|
|
|
|
|
|
-- Mapping of key codes to human-readable key names.
|
|
|
|
|
local Keys = {
|
|
|
|
|
[322] = "ESC", [288] = "F1", [289] = "F2", [170] = "F3", [166] = "F5",
|
|
|
|
|
[167] = "F6", [168] = "F7", [169] = "F8", [56] = "F9", [57] = "F10",
|
|
|
|
|
[243] = "~", [157] = "1", [158] = "2", [160] = "3", [164] = "4", [165] = "5",
|
|
|
|
|
[159] = "6", [161] = "7", [162] = "8", [163] = "9", [84] = "-", [83] = "=",
|
|
|
|
|
[177] = "BACKSPACE", [37] = "TAB",
|
|
|
|
|
[44] = "Q", [32] = "W", [38] = "E", [45] = "R", [245] = "T", [246] = "Y",
|
|
|
|
|
[303] = "U", [199] = "P",
|
|
|
|
|
[39] = "[", [40] = "]", [18] = "ENTER", [137] = "CAPS",
|
|
|
|
|
[34] = "A", [8] = "S", [9] = "D", [23] = "F", [47] = "G",
|
|
|
|
|
[74] = "H", [311] = "K", [182] = "L", [21] = "LEFTSHIFT",
|
|
|
|
|
[20] = "Z", [73] = "X", [26] = "C", [0] = "V", [29] = "B", [249] = "N",
|
|
|
|
|
[244] = "M", [82] = ",", [81] = "."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
-- ===== Ownership + indexes =====
|
|
|
|
|
-- TextTargets: key -> target data (coords/entity/models/options/etc.)
|
|
|
|
|
local TextTargets = {}
|
|
|
|
|
-- targetEntities kept for parity (not strictly required)
|
|
|
|
|
local targetEntities = {}
|
2025-06-06 00:45:08 +01:00
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
-- registry: key -> owner, owner -> set(keys)
|
|
|
|
|
local TargetRegistry = { byKey = {}, byResource = {} }
|
|
|
|
|
|
|
|
|
|
local function getOwnerResource()
|
|
|
|
|
return GetInvokingResource() or GetCurrentResourceName() or "unknown"
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
local function registerTarget(owner, key)
|
|
|
|
|
-- move key across owners if replacing
|
|
|
|
|
local prev = TargetRegistry.byKey[key]
|
|
|
|
|
if prev and TargetRegistry.byResource[prev] then
|
|
|
|
|
TargetRegistry.byResource[prev][key] = nil
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
2025-08-30 13:19:01 +01:00
|
|
|
TargetRegistry.byKey[key] = owner
|
|
|
|
|
TargetRegistry.byResource[owner] = TargetRegistry.byResource[owner] or {}
|
|
|
|
|
TargetRegistry.byResource[owner][key] = true
|
|
|
|
|
end
|
2025-06-06 00:45:08 +01:00
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
local function removeTargetKey(key, reason)
|
|
|
|
|
local owner = TargetRegistry.byKey[key]
|
|
|
|
|
if TextTargets[key] then
|
|
|
|
|
TextTargets[key] = nil
|
|
|
|
|
-- print("^6Bridge^7:^5 Target^7: ^2Removed target '%s'%s", key, reason and (" ("..reason..")") or "")
|
|
|
|
|
end
|
|
|
|
|
if owner then
|
|
|
|
|
if TargetRegistry.byResource[owner] then
|
|
|
|
|
TargetRegistry.byResource[owner][key] = nil
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
2025-08-30 13:19:01 +01:00
|
|
|
TargetRegistry.byKey[key] = nil
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
AddEventHandler("onResourceStop", function(res)
|
|
|
|
|
local owned = TargetRegistry.byResource[res]
|
|
|
|
|
if not owned then return end
|
|
|
|
|
local cnt = 0
|
|
|
|
|
for key in pairs(owned) do
|
|
|
|
|
removeTargetKey(key, "resource stopped: "..res)
|
|
|
|
|
cnt = cnt + 1
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
2025-08-30 13:19:01 +01:00
|
|
|
TargetRegistry.byResource[res] = nil
|
|
|
|
|
-- print("^6Bridge^7:^5 Target^7: ^2Cleared "..cnt.." target(s) from '"..res.."'")
|
|
|
|
|
end)
|
|
|
|
|
|
|
|
|
|
-- ===== Helpers =====
|
|
|
|
|
local function vecKey(v)
|
|
|
|
|
-- stable rounded coord string for entity dedupe when name isn't provided
|
|
|
|
|
return ("%.3f,%.3f,%.3f"):format(v.x, v.y, v.z)
|
|
|
|
|
end
|
2025-06-06 00:45:08 +01:00
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
local function bakeButtons(opts)
|
2025-06-06 00:45:08 +01:00
|
|
|
local tempText = {}
|
|
|
|
|
for i = 1, #opts do
|
|
|
|
|
opts[i].key = KEY_TABLE[i]
|
2025-08-30 13:19:01 +01:00
|
|
|
tempText[#tempText + 1] =
|
|
|
|
|
(" ~b~[~w~%s~b~] ~w~%s"):format(Keys[opts[i].key] or ("K"..opts[i].key), opts[i].label or ("Option "..i))
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
2025-08-30 13:19:01 +01:00
|
|
|
return tempText
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Update cached text blob
|
|
|
|
|
local function updateCachedText(target)
|
|
|
|
|
target.text = table.concat(target.buttontext, "\n")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- ===== Public API: Create targets =====
|
|
|
|
|
-- ENTITY: createEntityTarget(entity, opts, dist, nameOpt?)
|
|
|
|
|
function createEntityTarget(entity, opts, dist, name)
|
|
|
|
|
startTargetLoop()
|
|
|
|
|
|
|
|
|
|
if not entity or entity == 0 then return end
|
|
|
|
|
targetEntities[#targetEntities + 1] = entity
|
2025-06-06 00:45:08 +01:00
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
local owner = getOwnerResource()
|
|
|
|
|
local coords = GetEntityCoords(entity)
|
|
|
|
|
local key = tostring(name) or ("entity@" .. vecKey(coords))
|
|
|
|
|
|
|
|
|
|
-- Always overwrite on same key
|
|
|
|
|
local buttontext = bakeButtons(opts)
|
|
|
|
|
TextTargets[key] = {
|
|
|
|
|
_key = key,
|
|
|
|
|
_type = "entity",
|
|
|
|
|
_owner = owner,
|
|
|
|
|
entity = entity,
|
|
|
|
|
coords = vec3(coords.x, coords.y, coords.z),
|
|
|
|
|
buttontext = buttontext,
|
|
|
|
|
options = opts,
|
|
|
|
|
dist = dist,
|
|
|
|
|
}
|
|
|
|
|
updateCachedText(TextTargets[key])
|
|
|
|
|
registerTarget(owner, key)
|
|
|
|
|
-- print("^6Bridge^7:^5 Target^7: ^2Added/Updated ENTITY target '"..key.."' by '"..owner.."' @ "..formatCoord(coords))
|
|
|
|
|
return key
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- ZONE: createZoneTarget(data, opts, dist)
|
|
|
|
|
-- Expect data[1] = id/name, data[2] = vec3 coords (as in your original)
|
|
|
|
|
function createZoneTarget(data, opts, dist)
|
|
|
|
|
startTargetLoop()
|
|
|
|
|
|
|
|
|
|
local owner = getOwnerResource()
|
|
|
|
|
local zname = tostring(data[1] or ("zone@"..vecKey(data[2] or vec3(0,0,0))))
|
|
|
|
|
local coords = data[2]
|
|
|
|
|
|
|
|
|
|
local buttontext = bakeButtons(opts)
|
|
|
|
|
TextTargets[zname] = {
|
|
|
|
|
_key = zname,
|
|
|
|
|
_type = "zone",
|
|
|
|
|
_owner = owner,
|
|
|
|
|
coords = coords,
|
|
|
|
|
buttontext = buttontext,
|
|
|
|
|
options = opts,
|
|
|
|
|
dist = dist,
|
|
|
|
|
}
|
|
|
|
|
updateCachedText(TextTargets[zname])
|
|
|
|
|
registerTarget(owner, zname)
|
|
|
|
|
-- print("^6Bridge^7:^5 Target^7: ^2Added/Updated ZONE target '"..zname.."' by '"..owner.."' @ "..formatCoord(coords))
|
|
|
|
|
return zname
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- MODEL: createModelTarget(models, opts, dist, nameOpt?)
|
|
|
|
|
function createModelTarget(models, opts, dist, name)
|
|
|
|
|
startTargetLoop()
|
|
|
|
|
|
|
|
|
|
local owner = getOwnerResource()
|
|
|
|
|
if type(models) ~= "table" then models = { models } end
|
|
|
|
|
|
|
|
|
|
local key
|
|
|
|
|
if name then
|
|
|
|
|
key = tostring(name)
|
|
|
|
|
else
|
|
|
|
|
local parts = {}
|
|
|
|
|
for i, m in ipairs(models) do parts[i] = tostring(m) end
|
|
|
|
|
key = "model_" .. table.concat(parts, "_")
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
local buttontext = bakeButtons(opts)
|
|
|
|
|
TextTargets[key] = {
|
|
|
|
|
_key = key,
|
|
|
|
|
_type = "model",
|
|
|
|
|
_owner = owner,
|
2025-06-06 00:45:08 +01:00
|
|
|
models = models,
|
2025-08-30 13:19:01 +01:00
|
|
|
buttontext = buttontext,
|
|
|
|
|
options = opts,
|
|
|
|
|
dist = dist,
|
|
|
|
|
coords = vec3(0, 0, 0), -- will be updated by the refresher
|
2025-06-06 00:45:08 +01:00
|
|
|
}
|
2025-08-30 13:19:01 +01:00
|
|
|
updateCachedText(TextTargets[key])
|
|
|
|
|
registerTarget(owner, key)
|
|
|
|
|
-- print("^6Bridge^7:^5 Target^7: ^2Added/Updated MODEL target '"..key.."' by '"..owner.."' (models: "..table.concat(models, ",")..")")
|
|
|
|
|
return key
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
-- ===== Public API: Remove targets =====
|
|
|
|
|
-- Entity removal accepts entity handle or key string.
|
|
|
|
|
function removeEntityTarget(entityOrKey)
|
|
|
|
|
local key = nil
|
|
|
|
|
if type(entityOrKey) == "string" then
|
|
|
|
|
key = entityOrKey
|
|
|
|
|
elseif type(entityOrKey) == "number" then
|
|
|
|
|
for k, t in pairs(TextTargets) do
|
|
|
|
|
if t._type == "entity" and t.entity == entityOrKey then key = k break end
|
|
|
|
|
end
|
|
|
|
|
if not key then
|
|
|
|
|
-- Fallback: try coord-key match
|
|
|
|
|
local c = GetEntityCoords(entityOrKey)
|
|
|
|
|
local guess = "entity@"..vecKey(c)
|
|
|
|
|
if TextTargets[guess] then key = guess end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
if key then removeTargetKey(key, "removeEntityTarget") end
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
function removeZoneTarget(key)
|
|
|
|
|
if not key then return end
|
|
|
|
|
removeTargetKey(key, "removeZoneTarget")
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
-- For models, pass the returned key from createModelTarget (recommended).
|
|
|
|
|
function removeModelTarget(key)
|
|
|
|
|
if not key then return end
|
|
|
|
|
removeTargetKey(key, "removeModelTarget")
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
exports("createEntityTarget", createEntityTarget)
|
|
|
|
|
exports("createZoneTarget", createZoneTarget)
|
|
|
|
|
exports("createModelTarget", createModelTarget)
|
|
|
|
|
|
|
|
|
|
exports("removeEntityTarget", removeEntityTarget)
|
|
|
|
|
exports("removeZoneTarget", removeZoneTarget)
|
|
|
|
|
exports("removeModelTarget", removeModelTarget)
|
|
|
|
|
|
|
|
|
|
-------------------------------------------------------------
|
|
|
|
|
-- Fallback: DrawText3D Targets (Experimental)
|
|
|
|
|
-------------------------------------------------------------
|
|
|
|
|
local started = false
|
|
|
|
|
function startTargetLoop()
|
|
|
|
|
if started then return end
|
|
|
|
|
started = true
|
2025-08-30 13:19:01 +01:00
|
|
|
|
|
|
|
|
-- lazy include (unchanged from your original)
|
|
|
|
|
Config = { System = {} }
|
2025-06-06 00:45:08 +01:00
|
|
|
local fileLoader = assert(load(LoadResourceFile("jim_bridge", ('starter.lua')), ('@@jim_bridge/starter.lua')))
|
|
|
|
|
fileLoader()
|
2025-08-30 13:19:01 +01:00
|
|
|
|
|
|
|
|
-- Model Entity Refresher (kept)
|
2025-06-06 00:45:08 +01:00
|
|
|
CreateThread(function()
|
|
|
|
|
while true do
|
|
|
|
|
local pedCoords = GetEntityCoords(PlayerPedId())
|
|
|
|
|
for _, target in pairs(TextTargets) do
|
|
|
|
|
if target.models then
|
|
|
|
|
for _, model in ipairs(target.models) do
|
|
|
|
|
local entity = GetClosestObjectOfType(pedCoords.x, pedCoords.y, pedCoords.z, target.dist, model, false, false, false)
|
|
|
|
|
if entity and entity ~= 0 then
|
|
|
|
|
target.entity = entity
|
|
|
|
|
target.coords = GetEntityCoords(entity)
|
|
|
|
|
break
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
Wait(3000) -- Refresh every 3s
|
|
|
|
|
end
|
|
|
|
|
end)
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
-- Main Target Loop (unchanged logic, just uses new TextTargets entries)
|
2025-06-06 00:45:08 +01:00
|
|
|
CreateThread(function()
|
|
|
|
|
while true do
|
|
|
|
|
local ped = PlayerPedId()
|
|
|
|
|
local pedCoords = GetEntityCoords(ped)
|
|
|
|
|
local camCoords = GetGameplayCamCoord()
|
|
|
|
|
local camRot = GetGameplayCamRot(2)
|
|
|
|
|
local camForward = RotationToDirection(camRot)
|
|
|
|
|
|
|
|
|
|
local closestTarget, closestDist, targetEntity = nil, math.huge, nil
|
|
|
|
|
|
|
|
|
|
for _, target in pairs(TextTargets) do
|
|
|
|
|
local coords = target.coords
|
|
|
|
|
if coords then
|
|
|
|
|
local dist = #(pedCoords - coords)
|
|
|
|
|
if dist <= target.dist then
|
|
|
|
|
local normVec = normalizeVector(coords - camCoords)
|
|
|
|
|
local dot = camForward.x * normVec.x + camForward.y * normVec.y + camForward.z * normVec.z
|
|
|
|
|
|
|
|
|
|
if dot > 0.5 and dist < closestDist then
|
|
|
|
|
closestTarget = target
|
|
|
|
|
closestDist = dist
|
|
|
|
|
targetEntity = target.entity
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
for _, target in pairs(TextTargets) do
|
|
|
|
|
if not target.coords then goto continue end
|
|
|
|
|
local dist = #(pedCoords - target.coords)
|
|
|
|
|
if dist > target.dist then goto continue end
|
|
|
|
|
local isClosest = (target == closestTarget)
|
|
|
|
|
|
|
|
|
|
for i, opt in ipairs(target.options) do
|
|
|
|
|
if IsControlJustPressed(0, opt.key) and isClosest then
|
|
|
|
|
if (not target.canInteract or target.canInteract()) and
|
2025-08-30 13:19:01 +01:00
|
|
|
(not opt.item or hasItem(opt.item)) and
|
|
|
|
|
(not opt.job or hasJob(opt.job, nil)) then
|
2025-06-06 00:45:08 +01:00
|
|
|
if opt.onSelect then opt.onSelect(targetEntity) end
|
2025-08-30 13:19:01 +01:00
|
|
|
if opt.action then opt.action(targetEntity) end
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local baseZ, lineHeight = target.coords.z + 1.0, -0.16
|
|
|
|
|
local lineOffset = 0
|
|
|
|
|
|
|
|
|
|
for i, opt in ipairs(target.options) do
|
|
|
|
|
if (not target.canInteract or target.canInteract()) and
|
2025-08-30 13:19:01 +01:00
|
|
|
(not opt.item or hasItem(opt.item)) and
|
|
|
|
|
(not opt.job or hasJob(opt.job, nil)) then
|
2025-06-06 00:45:08 +01:00
|
|
|
DrawText3D(vec3(target.coords.x, target.coords.y, baseZ + lineHeight * lineOffset), target.buttontext[i], isClosest)
|
|
|
|
|
lineOffset = lineOffset + 1
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
::continue::
|
|
|
|
|
end
|
|
|
|
|
|
2025-08-30 13:19:01 +01:00
|
|
|
Wait(1)
|
2025-06-06 00:45:08 +01:00
|
|
|
end
|
|
|
|
|
end)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function DrawText3D(coord, text, highlight)
|
|
|
|
|
SetTextScale(0.30, 0.30)
|
|
|
|
|
SetTextFont(0)
|
|
|
|
|
SetTextProportional(1)
|
|
|
|
|
SetTextColour(255, 255, 255, 215)
|
|
|
|
|
SetTextEntry("STRING")
|
|
|
|
|
SetTextCentre(true)
|
|
|
|
|
|
|
|
|
|
local totalLength = string.len(text)
|
2025-08-30 13:19:01 +01:00
|
|
|
local textMaxLength = 99
|
|
|
|
|
local txt = totalLength > textMaxLength and text:sub(1, textMaxLength) or text
|
|
|
|
|
AddTextComponentString(highlight and txt:gsub("%~w~", "~y~") or txt)
|
2025-06-06 00:45:08 +01:00
|
|
|
SetDrawOrigin(coord.x, coord.y, coord.z, 0)
|
|
|
|
|
DrawText(0.0, 0.0)
|
2025-08-30 13:19:01 +01:00
|
|
|
local count, length = GetLineCountAndMaxLength(txt)
|
2025-06-06 00:45:08 +01:00
|
|
|
|
|
|
|
|
local padding = 0.005
|
|
|
|
|
local heightFactor = (count / 43) + padding
|
|
|
|
|
local weightFactor = (length / 150) + padding
|
|
|
|
|
|
|
|
|
|
local height = (heightFactor / 2) - padding / 1
|
2025-08-30 13:19:01 +01:00
|
|
|
local width = (weightFactor / 2) - padding / 1
|
2025-06-06 00:45:08 +01:00
|
|
|
|
|
|
|
|
DrawRect(0.0, height, width, heightFactor, 0, 0, 0, 150)
|
|
|
|
|
ClearDrawOrigin()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function GetLineCountAndMaxLength(text)
|
|
|
|
|
local lineCount, maxLength = 0, 0
|
|
|
|
|
for line in text:gmatch("[^\n]+") do
|
2025-08-30 13:19:01 +01:00
|
|
|
lineCount = lineCount + 1
|
2025-06-06 00:45:08 +01:00
|
|
|
local lineLength = string.len(line)
|
|
|
|
|
if lineLength > maxLength then
|
|
|
|
|
maxLength = lineLength
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
if lineCount == 0 then lineCount = 1 end
|
|
|
|
|
return lineCount, maxLength
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function RotationToDirection(rot)
|
|
|
|
|
local adjust = math.pi / 180
|
|
|
|
|
return vec3(
|
|
|
|
|
-math.sin(adjust * rot.z) * math.abs(math.cos(adjust * rot.x)),
|
|
|
|
|
math.cos(adjust * rot.z) * math.abs(math.cos(adjust * rot.x)),
|
|
|
|
|
math.sin(adjust * rot.x)
|
|
|
|
|
)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function normalizeVector(vec)
|
|
|
|
|
local len = math.sqrt(vec.x^2 + vec.y^2 + vec.z^2)
|
|
|
|
|
if len ~= 0 then
|
|
|
|
|
return vec3(vec.x / len, vec.y / len, vec.z / len)
|
|
|
|
|
else
|
|
|
|
|
return vec3(0, 0, 0)
|
|
|
|
|
end
|
|
|
|
|
end
|