10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/fxmanifest.lua:
--------------------------------------------------------------------------------
1 | -- FX Information
2 | fx_version 'cerulean'
3 | use_experimental_fxv2_oal 'yes'
4 | lua54 'yes'
5 | game 'gta5'
6 |
7 | -- Resource Information
8 | name 'ox_target'
9 | author 'Overextended'
10 | version '1.17.2'
11 | repository 'https://github.com/overextended/ox_target'
12 | description ''
13 |
14 | -- Manifest
15 | ui_page 'web/index.html'
16 |
17 | shared_scripts {
18 | '@ox_lib/init.lua',
19 | }
20 |
21 | client_scripts {
22 | 'client/main.lua',
23 | }
24 |
25 | server_scripts {
26 | 'server/main.lua'
27 | }
28 |
29 | files {
30 | 'web/**',
31 | 'locales/*.json',
32 | 'client/api.lua',
33 | 'client/utils.lua',
34 | 'client/state.lua',
35 | 'client/debug.lua',
36 | 'client/defaults.lua',
37 | 'client/framework/nd.lua',
38 | 'client/framework/ox.lua',
39 | 'client/framework/esx.lua',
40 | 'client/framework/qbx.lua',
41 | 'client/compat/qtarget.lua',
42 | }
43 |
44 | provide 'qtarget'
45 |
46 | dependency 'ox_lib'
47 |
--------------------------------------------------------------------------------
/client/state.lua:
--------------------------------------------------------------------------------
1 | local state = {}
2 |
3 | local isActive = false
4 |
5 | ---@return boolean
6 | function state.isActive()
7 | return isActive
8 | end
9 |
10 | ---@param value boolean
11 | function state.setActive(value)
12 | isActive = value
13 |
14 | if value then
15 | SendNuiMessage('{"event": "visible", "state": true}')
16 | end
17 | end
18 |
19 | local nuiFocus = false
20 |
21 | ---@return boolean
22 | function state.isNuiFocused()
23 | return nuiFocus
24 | end
25 |
26 | ---@param value boolean
27 | function state.setNuiFocus(value, cursor)
28 | if value then SetCursorLocation(0.5, 0.5) end
29 |
30 | nuiFocus = value
31 | SetNuiFocus(value, cursor or false)
32 | SetNuiFocusKeepInput(value)
33 | end
34 |
35 | local isDisabled = false
36 |
37 | ---@return boolean
38 | function state.isDisabled()
39 | return isDisabled
40 | end
41 |
42 | ---@param value boolean
43 | function state.setDisabled(value)
44 | isDisabled = value
45 | end
46 |
47 | return state
48 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Overextended
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/web/js/createOptions.js:
--------------------------------------------------------------------------------
1 | import { fetchNui } from "./fetchNui.js";
2 |
3 | const optionsWrapper = document.getElementById("options-wrapper");
4 |
5 | function onClick() {
6 | // when nuifocus is disabled after a click, the hover event is never released
7 | this.style.pointerEvents = "none";
8 |
9 | fetchNui("select", [this.targetType, this.targetId, this.zoneId]);
10 | // is there a better way to handle this? probably
11 | setTimeout(() => (this.style.pointerEvents = "auto"), 100);
12 | }
13 |
14 | export function createOptions(type, data, id, zoneId) {
15 | if (data.hide) return;
16 |
17 | const option = document.createElement("div");
18 | const iconElement = ``;
21 |
22 | option.innerHTML = `${iconElement}
${data.label}
`;
23 | option.className = "option-container";
24 | option.targetType = type;
25 | option.targetId = id;
26 | option.zoneId = zoneId;
27 |
28 | option.addEventListener("click", onClick);
29 | optionsWrapper.appendChild(option);
30 | }
31 |
--------------------------------------------------------------------------------
/web/js/main.js:
--------------------------------------------------------------------------------
1 | import { createOptions } from "./createOptions.js";
2 |
3 | const optionsWrapper = document.getElementById("options-wrapper");
4 | const body = document.body;
5 | const eye = document.getElementById("eyeSvg");
6 |
7 | window.addEventListener("message", (event) => {
8 | optionsWrapper.innerHTML = "";
9 |
10 | switch (event.data.event) {
11 | case "visible": {
12 | body.style.visibility = event.data.state ? "visible" : "hidden";
13 | return eye.classList.remove("eye-hover");
14 | }
15 |
16 | case "leftTarget": {
17 | return eye.classList.remove("eye-hover");
18 | }
19 |
20 | case "setTarget": {
21 | eye.classList.add("eye-hover");
22 |
23 | if (event.data.options) {
24 | for (const type in event.data.options) {
25 | event.data.options[type].forEach((data, id) => {
26 | createOptions(type, data, id + 1);
27 | });
28 | }
29 | }
30 |
31 | if (event.data.zones) {
32 | for (let i = 0; i < event.data.zones.length; i++) {
33 | event.data.zones[i].forEach((data, id) => {
34 | createOptions("zones", data, id + 1, i + 1);
35 | });
36 | }
37 | }
38 | }
39 | }
40 | });
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ox_target
2 |
3 | 
4 | 
5 | 
6 | 
7 |
8 |
9 | A performant and flexible standalone "third-eye" targeting resource, with additional functionality for supported frameworks.
10 |
11 | ox_target is the successor to qtarget, which was a mostly-compatible fork of bt-target.
12 | To improve many design flaws, ox_target has been written from scratch and drops support for bt-target/qtarget standards, though partial compatibility is being implemented where possible.
13 |
14 |
15 | ## 📚 Documentation
16 |
17 | https://overextended.dev/ox_target
18 |
19 | ## 💾 Download
20 |
21 | https://github.com/overextended/ox_target/releases/latest/download/ox_target.zip
22 |
23 | ## ✨ Features
24 |
25 | - Improved entity and world collision than its predecessor.
26 | - Improved error handling when running external code.
27 | - Menus for nested target options.
28 | - Partial compatibility for qtarget (the thing qb-target is based on, I made the original idiots).
29 | - Registering options no longer overrides existing options.
30 | - Groups and items checking for supported frameworks.
31 |
--------------------------------------------------------------------------------
/client/framework/nd.lua:
--------------------------------------------------------------------------------
1 | local NDCore = exports["ND_Core"]
2 |
3 | local playerGroups = NDCore:getPlayer()?.groups or {}
4 |
5 | RegisterNetEvent("ND:characterLoaded", function(data)
6 | playerGroups = data.groups
7 | end)
8 |
9 | RegisterNetEvent("ND:updateCharacter", function(data)
10 | if source == '' then return end
11 | playerGroups = data.groups or {}
12 | end)
13 |
14 | local utils = require 'client.utils'
15 |
16 | ---@diagnostic disable-next-line: duplicate-set-field
17 | function utils.hasPlayerGotGroup(filter)
18 | local _type = type(filter)
19 |
20 | if _type == 'string' then
21 | local group = playerGroups[filter]
22 |
23 | if group then
24 | return true
25 | end
26 | elseif _type == 'table' then
27 | local tabletype = table.type(filter)
28 |
29 | if tabletype == 'hash' then
30 | for name, grade in pairs(filter) do
31 | local playerGrade = playerGroups[name]?.rank
32 |
33 | if playerGrade and grade <= playerGrade then
34 | return true
35 | end
36 | end
37 | elseif tabletype == 'array' then
38 | for i = 1, #filter do
39 | local name = filter[i]
40 | local group = playerGroups[name]
41 |
42 | if group then
43 | return true
44 | end
45 | end
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/server/main.lua:
--------------------------------------------------------------------------------
1 | lib.versionCheck('overextended/ox_target')
2 |
3 | if not lib.checkDependency('ox_lib', '3.30.0', true) then return end
4 |
5 | ---@type table
6 | local entityStates = {}
7 |
8 | ---@param netId number
9 | RegisterNetEvent('ox_target:setEntityHasOptions', function(netId)
10 | local entity = Entity(NetworkGetEntityFromNetworkId(netId))
11 | entity.state.hasTargetOptions = true
12 | entityStates[netId] = entity
13 | end)
14 |
15 | ---@param netId number
16 | ---@param door number
17 | RegisterNetEvent('ox_target:toggleEntityDoor', function(netId, door)
18 | local entity = NetworkGetEntityFromNetworkId(netId)
19 | if not DoesEntityExist(entity) then return end
20 |
21 | local owner = NetworkGetEntityOwner(entity)
22 | TriggerClientEvent('ox_target:toggleEntityDoor', owner, netId, door)
23 | end)
24 |
25 | CreateThread(function()
26 | local arr = {}
27 | local num = 0
28 |
29 | while true do
30 | Wait(10000)
31 |
32 | for netId, entity in pairs(entityStates) do
33 | if not DoesEntityExist(entity.__data) or not entity.state.hasTargetOptions then
34 | entityStates[netId] = nil
35 | num += 1
36 |
37 | arr[num] = netId
38 | end
39 | end
40 |
41 | if num > 0 then
42 | TriggerClientEvent('ox_target:removeEntity', -1, arr)
43 | table.wipe(arr)
44 |
45 | num = 0
46 | end
47 | end
48 | end)
49 |
--------------------------------------------------------------------------------
/.github/workflows/create-release.yml:
--------------------------------------------------------------------------------
1 | name: Create release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*.*.*'
7 |
8 | jobs:
9 | create-release:
10 | name: Package and Create Tagged Release
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Install archive tools
15 | run: sudo apt install zip
16 |
17 | - name: Checkout source code
18 | uses: actions/checkout@v2
19 | with:
20 | fetch-depth: 0
21 | ref: ${{ github.event.repository.default_branch }}
22 |
23 | - name: Bump manifest version
24 | run: node .github/actions/bump-manifest-version.js
25 | env:
26 | TGT_RELEASE_VERSION: ${{ github.ref_name }}
27 |
28 | - name: Push manifest change
29 | uses: EndBug/add-and-commit@v8
30 | with:
31 | add: fxmanifest.lua
32 | push: true
33 | author_name: Manifest Bumper
34 | author_email: 41898282+github-actions[bot]@users.noreply.github.com
35 | message: 'chore: bump manifest version to ${{ github.ref_name }}'
36 |
37 | - name: Update tag ref
38 | uses: EndBug/latest-tag@latest
39 | with:
40 | tag-name: ${{ github.ref_name }}
41 |
42 | - name: Bundle files
43 | run: |
44 | mkdir -p ./temp/ox_target
45 | cp ./{LICENSE,README.md,fxmanifest.lua} ./temp/ox_target
46 | cp -r ./{client,server,web,locales} ./temp/ox_target
47 | cd ./temp && zip -r ../ox_target.zip ./ox_target
48 |
49 | - name: Create Release
50 | uses: 'marvinpinto/action-automatic-releases@v1.2.1'
51 | id: auto_release
52 | with:
53 | repo_token: '${{ secrets.GITHUB_TOKEN }}'
54 | title: '${{ env.RELEASE_VERSION }}'
55 | prerelease: false
56 | files: ox_target.zip
57 |
58 | env:
59 | CI: false
60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61 |
--------------------------------------------------------------------------------
/web/style.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700&display=swap");
2 |
3 | :root {
4 | --color-default: #cfd2da;
5 | --color-hover: white;
6 | }
7 |
8 | body {
9 | visibility: hidden;
10 | user-select: none;
11 | white-space: nowrap;
12 | margin: 0;
13 | user-select: none;
14 | overflow: hidden;
15 | }
16 |
17 | p {
18 | margin: 0;
19 | }
20 |
21 | .material-symbols-outlined {
22 | font-variation-settings: "FILL" 0, "wght" 300, "GRAD" 0, "opsz" 40;
23 | }
24 |
25 | #eye {
26 | position: absolute;
27 | top: 50%;
28 | left: 50%;
29 | transform: translate(-50%, -50%);
30 | font-size: 22pt;
31 | fill: black;
32 | }
33 |
34 | .eye-hover {
35 | fill: var(--color-default);
36 | }
37 |
38 | #options-wrapper {
39 | position: absolute;
40 | top: calc(48.4%);
41 | left: calc(50% + 18pt);
42 | }
43 |
44 | .option-container {
45 | color: var(--color-default);
46 | display: flex;
47 | flex-direction: row;
48 | justify-content: flex-start;
49 | align-items: center;
50 | font-family: "Nunito";
51 | background: linear-gradient(
52 | 90deg,
53 | rgba(20, 20, 20, 0.7) 0%,
54 | rgba(20, 20, 20, 0.6) 66%,
55 | rgba(47, 48, 53, 0) 100%
56 | );
57 | font-size: 11pt;
58 | line-height: 22pt;
59 | vertical-align: middle;
60 | margin: 2pt;
61 | transition: 300ms;
62 | transform-origin: left top;
63 | scale: 1;
64 | height: 22pt;
65 | width: 150pt;
66 | top: 0;
67 | }
68 |
69 | .option-container:hover {
70 | background: linear-gradient(
71 | 90deg,
72 | rgba(30, 30, 30, 0.7) 0%,
73 | rgba(30, 30, 30, 0.6) 66%,
74 | rgba(57, 58, 63, 0) 100%
75 | );
76 | transform-origin: left top;
77 | color: var(--color-hover);
78 | margin-left: 4pt;
79 | }
80 |
81 | .option-icon {
82 | font-size: 12pt;
83 | line-height: 22pt;
84 | width: 14pt;
85 | margin: 5pt;
86 | color: var(--color-default);
87 | }
88 |
89 | .option-label {
90 | font-weight: 500;
91 | }
92 |
--------------------------------------------------------------------------------
/client/debug.lua:
--------------------------------------------------------------------------------
1 | AddEventHandler('ox_target:debug', function(data)
2 | if data.entity and GetEntityType(data.entity) > 0 then
3 | data.archetype = GetEntityArchetypeName(data.entity)
4 | data.model = GetEntityModel(data.entity)
5 | end
6 |
7 | print(json.encode(data, {indent=true}))
8 | end)
9 |
10 | if GetConvarInt('ox_target:debug', 0) ~= 1 then return end
11 |
12 | local ox_target = exports.ox_target
13 | local drawZones = true
14 |
15 | ox_target:addBoxZone({
16 | coords = vec3(442.5363, -1017.666, 28.85637),
17 | size = vec3(3, 3, 3),
18 | rotation = 45,
19 | debug = drawZones,
20 | drawSprite = true,
21 | options = {
22 | {
23 | name = 'debug_box',
24 | event = 'ox_target:debug',
25 | icon = 'fa-solid fa-cube',
26 | label = locale('debug_box'),
27 | }
28 | }
29 | })
30 |
31 | ox_target:addSphereZone({
32 | coords = vec3(440.5363, -1015.666, 28.85637),
33 | radius = 3,
34 | debug = drawZones,
35 | drawSprite = true,
36 | options = {
37 | {
38 | name = 'debug_sphere',
39 | event = 'ox_target:debug',
40 | icon = 'fa-solid fa-circle',
41 | label = locale('debug_sphere'),
42 | }
43 | }
44 | })
45 |
46 | ox_target:addModel(`police`, {
47 | {
48 | name = 'debug_model',
49 | event = 'ox_target:debug',
50 | icon = 'fa-solid fa-handcuffs',
51 | label = locale('debug_police_car'),
52 | }
53 | })
54 |
55 | ox_target:addGlobalPed({
56 | {
57 | name = 'debug_ped',
58 | event = 'ox_target:debug',
59 | icon = 'fa-solid fa-male',
60 | label = locale('debug_ped'),
61 | }
62 | })
63 |
64 | ox_target:addGlobalVehicle({
65 | {
66 | name = 'debug_vehicle',
67 | event = 'ox_target:debug',
68 | icon = 'fa-solid fa-car',
69 | label = locale('debug_vehicle'),
70 | }
71 | })
72 |
73 | ox_target:addGlobalObject({
74 | {
75 | name = 'debug_object',
76 | event = 'ox_target:debug',
77 | icon = 'fa-solid fa-bong',
78 | label = locale('debug_object'),
79 | }
80 | })
81 |
82 | ox_target:addGlobalOption({
83 | {
84 | name = 'debug_global',
85 | icon = 'fa-solid fa-globe',
86 | label = locale('debug_global'),
87 | openMenu = 'debug_global'
88 | }
89 | })
90 |
91 | ox_target:addGlobalOption({
92 | {
93 | name = 'debug_global2',
94 | event = 'ox_target:debug',
95 | icon = 'fa-solid fa-globe',
96 | label = locale('debug_global') .. ' 2',
97 | menuName = 'debug_global'
98 | }
99 | })
--------------------------------------------------------------------------------
/client/framework/esx.lua:
--------------------------------------------------------------------------------
1 | local ESX = exports.es_extended:getSharedObject()
2 | local utils = require 'client.utils'
3 | local groups = { 'job', 'job2' }
4 | local playerGroups = {}
5 | local playerItems = utils.getItems()
6 | local usingOxInventory = utils.hasExport('ox_inventory.Items')
7 |
8 | local function setPlayerData(playerData)
9 | table.wipe(playerGroups)
10 | table.wipe(playerItems)
11 |
12 | for i = 1, #groups do
13 | local group = groups[i]
14 | local data = playerData[group]
15 |
16 | if data then
17 | playerGroups[group] = data
18 | end
19 | end
20 |
21 | if usingOxInventory or not playerData.inventory then return end
22 |
23 | for _, v in pairs(playerData.inventory) do
24 | if v.count > 0 then
25 | playerItems[v.name] = v.count
26 | end
27 | end
28 | end
29 |
30 | if ESX.PlayerLoaded then
31 | setPlayerData(ESX.PlayerData)
32 | end
33 |
34 | RegisterNetEvent('esx:playerLoaded', function(data)
35 | if source == '' then return end
36 | setPlayerData(data)
37 | end)
38 |
39 | RegisterNetEvent('esx:setJob', function(job)
40 | if source == '' then return end
41 | playerGroups.job = job
42 | end)
43 |
44 | RegisterNetEvent('esx:setJob2', function(job)
45 | if source == '' then return end
46 | playerGroups.job2 = job
47 | end)
48 |
49 | RegisterNetEvent('esx:addInventoryItem', function(name, count)
50 | playerItems[name] = count
51 | end)
52 |
53 | RegisterNetEvent('esx:removeInventoryItem', function(name, count)
54 | playerItems[name] = count
55 | end)
56 |
57 | ---@diagnostic disable-next-line: duplicate-set-field
58 | function utils.hasPlayerGotGroup(filter)
59 | local _type = type(filter)
60 | for i = 1, #groups do
61 | local group = groups[i]
62 |
63 | if _type == 'string' then
64 | local data = playerGroups[group]
65 |
66 | if filter == data?.name then
67 | return true
68 | end
69 | elseif _type == 'table' then
70 | local tabletype = table.type(filter)
71 |
72 | if tabletype == 'hash' then
73 | for name, grade in pairs(filter) do
74 | local data = playerGroups[group]
75 |
76 | if data?.name == name and grade <= data.grade then
77 | return true
78 | end
79 | end
80 | elseif tabletype == 'array' then
81 | for j = 1, #filter do
82 | local name = filter[j]
83 | local data = playerGroups[group]
84 |
85 | if data?.name == name then
86 | return true
87 | end
88 | end
89 | end
90 | end
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/client/defaults.lua:
--------------------------------------------------------------------------------
1 | if GetConvarInt('ox_target:defaults', 1) ~= 1 then return end
2 |
3 | local api = require 'client.api'
4 | local GetEntityBoneIndexByName = GetEntityBoneIndexByName
5 | local GetEntityBonePosition_2 = GetEntityBonePosition_2
6 | local GetVehicleDoorLockStatus = GetVehicleDoorLockStatus
7 |
8 | local bones = {
9 | [0] = 'dside_f',
10 | [1] = 'pside_f',
11 | [2] = 'dside_r',
12 | [3] = 'pside_r'
13 | }
14 |
15 | ---@param vehicle number
16 | ---@param door number
17 | local function toggleDoor(vehicle, door)
18 | if GetVehicleDoorLockStatus(vehicle) ~= 2 then
19 | if GetVehicleDoorAngleRatio(vehicle, door) > 0.0 then
20 | SetVehicleDoorShut(vehicle, door, false)
21 | else
22 | SetVehicleDoorOpen(vehicle, door, false, false)
23 | end
24 | end
25 | end
26 |
27 | ---@param entity number
28 | ---@param coords vector3
29 | ---@param door number
30 | ---@param useOffset boolean?
31 | ---@return boolean?
32 | local function canInteractWithDoor(entity, coords, door, useOffset)
33 | if not GetIsDoorValid(entity, door) or GetVehicleDoorLockStatus(entity) > 1 or IsVehicleDoorDamaged(entity, door) or cache.vehicle then return end
34 |
35 | if useOffset then return true end
36 |
37 | local boneName = bones[door]
38 |
39 | if not boneName then return false end
40 |
41 | local boneId = GetEntityBoneIndexByName(entity, 'door_' .. boneName)
42 |
43 | if boneId ~= -1 then
44 | return #(coords - GetEntityBonePosition_2(entity, boneId)) < 0.5 or
45 | #(coords - GetEntityBonePosition_2(entity, GetEntityBoneIndexByName(entity, 'seat_' .. boneName))) < 0.72
46 | end
47 | end
48 |
49 | local function onSelectDoor(data, door)
50 | local entity = data.entity
51 |
52 | if NetworkGetEntityOwner(entity) == cache.playerId then
53 | return toggleDoor(entity, door)
54 | end
55 |
56 | TriggerServerEvent('ox_target:toggleEntityDoor', VehToNet(entity), door)
57 | end
58 |
59 | RegisterNetEvent('ox_target:toggleEntityDoor', function(netId, door)
60 | local entity = NetToVeh(netId)
61 | toggleDoor(entity, door)
62 | end)
63 |
64 | api.addGlobalVehicle({
65 | {
66 | name = 'ox_target:driverF',
67 | icon = 'fa-solid fa-car-side',
68 | label = locale('toggle_front_driver_door'),
69 | bones = { 'door_dside_f', 'seat_dside_f' },
70 | distance = 2,
71 | canInteract = function(entity, distance, coords, name)
72 | return canInteractWithDoor(entity, coords, 0)
73 | end,
74 | onSelect = function(data)
75 | onSelectDoor(data, 0)
76 | end
77 | },
78 | {
79 | name = 'ox_target:passengerF',
80 | icon = 'fa-solid fa-car-side',
81 | label = locale('toggle_front_passenger_door'),
82 | bones = { 'door_pside_f', 'seat_pside_f' },
83 | distance = 2,
84 | canInteract = function(entity, distance, coords, name)
85 | return canInteractWithDoor(entity, coords, 1)
86 | end,
87 | onSelect = function(data)
88 | onSelectDoor(data, 1)
89 | end
90 | },
91 | {
92 | name = 'ox_target:driverR',
93 | icon = 'fa-solid fa-car-side',
94 | label = locale('toggle_rear_driver_door'),
95 | bones = { 'door_dside_r', 'seat_dside_r' },
96 | distance = 2,
97 | canInteract = function(entity, distance, coords)
98 | return canInteractWithDoor(entity, coords, 2)
99 | end,
100 | onSelect = function(data)
101 | onSelectDoor(data, 2)
102 | end
103 | },
104 | {
105 | name = 'ox_target:passengerR',
106 | icon = 'fa-solid fa-car-side',
107 | label = locale('toggle_rear_passenger_door'),
108 | bones = { 'door_pside_r', 'seat_pside_r' },
109 | distance = 2,
110 | canInteract = function(entity, distance, coords)
111 | return canInteractWithDoor(entity, coords, 3)
112 | end,
113 | onSelect = function(data)
114 | onSelectDoor(data, 3)
115 | end
116 | },
117 | {
118 | name = 'ox_target:bonnet',
119 | icon = 'fa-solid fa-car',
120 | label = locale('toggle_hood'),
121 | offset = vec3(0.5, 1, 0.5),
122 | distance = 2,
123 | canInteract = function(entity, distance, coords)
124 | return canInteractWithDoor(entity, coords, 4, true)
125 | end,
126 | onSelect = function(data)
127 | onSelectDoor(data, 4)
128 | end
129 | },
130 | {
131 | name = 'ox_target:trunk',
132 | icon = 'fa-solid fa-car-rear',
133 | label = locale('toggle_trunk'),
134 | offset = vec3(0.5, 0, 0.5),
135 | distance = 2,
136 | canInteract = function(entity, distance, coords, name)
137 | return canInteractWithDoor(entity, coords, 5, true)
138 | end,
139 | onSelect = function(data)
140 | onSelectDoor(data, 5)
141 | end
142 | }
143 | })
144 |
--------------------------------------------------------------------------------
/client/compat/qtarget.lua:
--------------------------------------------------------------------------------
1 | local function exportHandler(exportName, func)
2 | AddEventHandler(('__cfx_export_qtarget_%s'):format(exportName), function(setCB)
3 | setCB(func)
4 | end)
5 | end
6 |
7 | ---@param options table
8 | ---@return table
9 | local function convert(options)
10 | local distance = options.distance
11 | options = options.options
12 |
13 | -- People may pass options as a hashmap (or mixed, even)
14 | for k, v in pairs(options) do
15 | if type(k) ~= 'number' then
16 | table.insert(options, v)
17 | end
18 | end
19 |
20 | for id, v in pairs(options) do
21 | if type(id) ~= 'number' then
22 | options[id] = nil
23 | goto continue
24 | end
25 |
26 | v.onSelect = v.action
27 | v.distance = v.distance or distance
28 | v.name = v.name or v.label
29 | v.groups = v.job
30 | v.items = v.item or v.required_item
31 |
32 | if v.event and v.type and v.type ~= 'client' then
33 | if v.type == 'server' then
34 | v.serverEvent = v.event
35 | elseif v.type == 'command' then
36 | v.command = v.event
37 | end
38 |
39 | v.event = nil
40 | v.type = nil
41 | end
42 |
43 | v.action = nil
44 | v.job = nil
45 | v.item = nil
46 | v.required_item = nil
47 | v.qtarget = true
48 |
49 | ::continue::
50 | end
51 |
52 | return options
53 | end
54 |
55 | local api = require 'client.api'
56 |
57 | exportHandler('AddBoxZone', function(name, center, length, width, options, targetoptions)
58 | local z = center.z
59 |
60 | if not options.minZ then
61 | options.minZ = -100
62 | end
63 |
64 | if not options.maxZ then
65 | options.maxZ = 800
66 | end
67 |
68 | if not options.useZ then
69 | z = z + math.abs(options.maxZ - options.minZ) / 2
70 | center = vec3(center.x, center.y, z)
71 | end
72 |
73 | return api.addBoxZone({
74 | name = name,
75 | coords = center,
76 | size = vec3(width, length, (options.useZ or not options.maxZ) and center.z or math.abs(options.maxZ - options.minZ)),
77 | debug = options.debugPoly,
78 | rotation = options.heading,
79 | options = convert(targetoptions),
80 | })
81 | end)
82 |
83 | exportHandler('AddPolyZone', function(name, points, options, targetoptions)
84 | local newPoints = table.create(#points, 0)
85 | local thickness = math.abs(options.maxZ - options.minZ)
86 |
87 | for i = 1, #points do
88 | local point = points[i]
89 | newPoints[i] = vec3(point.x, point.y, options.maxZ - (thickness / 2))
90 | end
91 |
92 | return api.addPolyZone({
93 | name = name,
94 | points = newPoints,
95 | thickness = thickness,
96 | debug = options.debugPoly,
97 | options = convert(targetoptions),
98 | })
99 | end)
100 |
101 | exportHandler('AddCircleZone', function(name, center, radius, options, targetoptions)
102 | return api.addSphereZone({
103 | name = name,
104 | coords = center,
105 | radius = radius,
106 | debug = options.debugPoly,
107 | options = convert(targetoptions),
108 | })
109 | end)
110 |
111 | exportHandler('RemoveZone', function(id)
112 | api.removeZone(id, true)
113 | end)
114 |
115 | exportHandler('AddTargetBone', function(bones, options)
116 | if type(bones) ~= 'table' then bones = { bones } end
117 | options = convert(options)
118 |
119 | for _, v in pairs(options) do
120 | v.bones = bones
121 | end
122 |
123 | exports.ox_target:addGlobalVehicle(options)
124 | end)
125 |
126 | exportHandler('AddTargetEntity', function(entities, options)
127 | if type(entities) ~= 'table' then entities = { entities } end
128 | options = convert(options)
129 |
130 | for i = 1, #entities do
131 | local entity = entities[i]
132 |
133 | if NetworkGetEntityIsNetworked(entity) then
134 | api.addEntity(NetworkGetNetworkIdFromEntity(entity), options)
135 | else
136 | api.addLocalEntity(entity, options)
137 | end
138 | end
139 | end)
140 |
141 | exportHandler('RemoveTargetEntity', function(entities, labels)
142 | if type(entities) ~= 'table' then entities = { entities } end
143 |
144 | for i = 1, #entities do
145 | local entity = entities[i]
146 |
147 | if NetworkGetEntityIsNetworked(entity) then
148 | api.removeEntity(NetworkGetNetworkIdFromEntity(entity), labels)
149 | else
150 | api.removeLocalEntity(entity, labels)
151 | end
152 | end
153 | end)
154 |
155 | exportHandler('AddTargetModel', function(models, options)
156 | api.addModel(models, convert(options))
157 | end)
158 |
159 | exportHandler('RemoveTargetModel', function(models, labels)
160 | api.removeModel(models, labels)
161 | end)
162 |
163 | exportHandler('Ped', function(options)
164 | api.addGlobalPed(convert(options))
165 | end)
166 |
167 | exportHandler('RemovePed', function(labels)
168 | api.removeGlobalPed(labels)
169 | end)
170 |
171 | exportHandler('Vehicle', function(options)
172 | api.addGlobalVehicle(convert(options))
173 | end)
174 |
175 | exportHandler('RemoveVehicle', function(labels)
176 | api.removeGlobalVehicle(labels)
177 | end)
178 |
179 | exportHandler('Object', function(options)
180 | api.addGlobalObject(convert(options))
181 | end)
182 |
183 | exportHandler('RemoveObject', function(labels)
184 | api.removeGlobalObject(labels)
185 | end)
186 |
187 | exportHandler('Player', function(options)
188 | api.addGlobalPlayer(convert(options))
189 | end)
190 |
191 | exportHandler('RemovePlayer', function(labels)
192 | api.removeGlobalPlayer(labels)
193 | end)
--------------------------------------------------------------------------------
/client/utils.lua:
--------------------------------------------------------------------------------
1 | local utils = {}
2 |
3 | local GetWorldCoordFromScreenCoord = GetWorldCoordFromScreenCoord
4 | local StartShapeTestLosProbe = StartShapeTestLosProbe
5 | local GetShapeTestResultIncludingMaterial = GetShapeTestResultIncludingMaterial
6 |
7 | ---@param flag number
8 | ---@return boolean hit
9 | ---@return number entityHit
10 | ---@return vector3 endCoords
11 | ---@return vector3 surfaceNormal
12 | ---@return number materialHash
13 | function utils.raycastFromCamera(flag)
14 | local coords, normal = GetWorldCoordFromScreenCoord(0.5, 0.5)
15 | local destination = coords + normal * 10
16 | local handle = StartShapeTestLosProbe(coords.x, coords.y, coords.z, destination.x, destination.y, destination.z,
17 | flag, cache.ped, 4)
18 |
19 | while true do
20 | Wait(0)
21 | local retval, hit, endCoords, surfaceNormal, materialHash, entityHit = GetShapeTestResultIncludingMaterial(
22 | handle)
23 |
24 | if retval ~= 1 then
25 | ---@diagnostic disable-next-line: return-type-mismatch
26 | return hit, entityHit, endCoords, surfaceNormal, materialHash
27 | end
28 | end
29 | end
30 |
31 | function utils.getTexture()
32 | return lib.requestStreamedTextureDict('shared'), 'emptydot_32'
33 | end
34 |
35 | -- SetDrawOrigin is limited to 32 calls per frame. Set as 0 to disable.
36 | local drawZoneSprites = GetConvarInt('ox_target:drawSprite', 24)
37 | local SetDrawOrigin = SetDrawOrigin
38 | local DrawSprite = DrawSprite
39 | local ClearDrawOrigin = ClearDrawOrigin
40 | local colour = vector(155, 155, 155, 175)
41 | local hover = vector(98, 135, 236, 255)
42 | local currentZones = {}
43 | local previousZones = {}
44 | local drawZones = {}
45 | local drawN = 0
46 | local width = 0.02
47 | local height = width * GetAspectRatio(false)
48 |
49 | if drawZoneSprites == 0 then drawZoneSprites = -1 end
50 |
51 | ---@param coords vector3
52 | ---@return CZone[], boolean
53 | function utils.getNearbyZones(coords)
54 | if not Zones then return currentZones, false end
55 |
56 | local n = 0
57 | local nearbyZones = lib.zones.getNearbyZones()
58 | drawN = 0
59 | previousZones, currentZones = currentZones, table.wipe(previousZones)
60 |
61 | for i = 1, #nearbyZones do
62 | local zone = nearbyZones[i]
63 | local contains = zone:contains(coords)
64 |
65 | if contains then
66 | n += 1
67 | currentZones[n] = zone
68 | end
69 |
70 | if drawN <= drawZoneSprites and zone.drawSprite ~= false and (contains or (zone.distance or 7) < 7) then
71 | drawN += 1
72 | drawZones[drawN] = zone
73 | zone.colour = contains and hover or nil
74 | end
75 | end
76 |
77 | local previousN = #previousZones
78 |
79 | if n ~= previousN then
80 | return currentZones, true
81 | end
82 |
83 | if n > 0 then
84 | for i = 1, n do
85 | local zoneA = currentZones[i]
86 | local found = false
87 |
88 | for j = 1, previousN do
89 | local zoneB = previousZones[j]
90 |
91 | if zoneA == zoneB then
92 | found = true
93 | break
94 | end
95 | end
96 |
97 | if not found then
98 | return currentZones, true
99 | end
100 | end
101 | end
102 |
103 | return currentZones, false
104 | end
105 |
106 | function utils.drawZoneSprites(dict, texture)
107 | if drawN == 0 then return end
108 |
109 | for i = 1, drawN do
110 | local zone = drawZones[i]
111 | local spriteColour = zone.colour or colour
112 |
113 | if zone.drawSprite ~= false then
114 | SetDrawOrigin(zone.coords.x, zone.coords.y, zone.coords.z)
115 | DrawSprite(dict, texture, 0, 0, width, height, 0, spriteColour.r, spriteColour.g, spriteColour.b,
116 | spriteColour.a)
117 | end
118 | end
119 |
120 | ClearDrawOrigin()
121 | end
122 |
123 | function utils.hasExport(export)
124 | local resource, exportName = string.strsplit('.', export)
125 |
126 | return pcall(function()
127 | return exports[resource][exportName]
128 | end)
129 | end
130 |
131 | local playerItems = {}
132 |
133 | function utils.getItems()
134 | return playerItems
135 | end
136 |
137 | ---@param filter string | string[] | table
138 | ---@param hasAny boolean?
139 | ---@return boolean
140 | function utils.hasPlayerGotItems(filter, hasAny)
141 | if not playerItems then return true end
142 |
143 | local _type = type(filter)
144 |
145 | if _type == 'string' then
146 | return (playerItems[filter] or 0) > 0
147 | elseif _type == 'table' then
148 | local tabletype = table.type(filter)
149 |
150 | if tabletype == 'hash' then
151 | for name, amount in pairs(filter) do
152 | local hasItem = (playerItems[name] or 0) >= amount
153 |
154 | if hasAny then
155 | if hasItem then return true end
156 | elseif not hasItem then
157 | return false
158 | end
159 | end
160 | elseif tabletype == 'array' then
161 | for i = 1, #filter do
162 | local hasItem = (playerItems[filter[i]] or 0) > 0
163 |
164 | if hasAny then
165 | if hasItem then return true end
166 | elseif not hasItem then
167 | return false
168 | end
169 | end
170 | end
171 | end
172 |
173 | return not hasAny
174 | end
175 |
176 | ---stub
177 | ---@param filter string | string[] | table
178 | ---@return boolean
179 | function utils.hasPlayerGotGroup(filter)
180 | return true
181 | end
182 |
183 | SetTimeout(0, function()
184 | if utils.hasExport('ox_inventory.Items') then
185 | setmetatable(playerItems, {
186 | __index = function(self, index)
187 | self[index] = exports.ox_inventory:Search('count', index) or 0
188 | return self[index]
189 | end
190 | })
191 |
192 | AddEventHandler('ox_inventory:itemCount', function(name, count)
193 | playerItems[name] = count
194 | end)
195 | end
196 |
197 | if utils.hasExport('ox_core.GetPlayer') then
198 | require 'client.framework.ox'
199 | elseif utils.hasExport('es_extended.getSharedObject') then
200 | require 'client.framework.esx'
201 | elseif utils.hasExport('qbx_core.HasGroup') then
202 | require 'client.framework.qbx'
203 | elseif utils.hasExport('ND_Core.getPlayer') then
204 | require 'client.framework.nd'
205 | end
206 | end)
207 |
208 | function utils.warn(msg)
209 | local trace = Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString())
210 | local _, _, src = string.strsplit('\n', trace, 4)
211 |
212 | warn(('%s ^0%s\n'):format(msg, src:gsub(".-%(", '(')))
213 | end
214 |
215 | return utils
216 |
--------------------------------------------------------------------------------
/client/main.lua:
--------------------------------------------------------------------------------
1 | if not lib.checkDependency('ox_lib', '3.30.0', true) then return end
2 |
3 | lib.locale()
4 |
5 | local utils = require 'client.utils'
6 | local state = require 'client.state'
7 | local options = require 'client.api'.getTargetOptions()
8 |
9 | require 'client.debug'
10 | require 'client.defaults'
11 | require 'client.compat.qtarget'
12 |
13 | local SendNuiMessage = SendNuiMessage
14 | local GetEntityCoords = GetEntityCoords
15 | local GetEntityType = GetEntityType
16 | local HasEntityClearLosToEntity = HasEntityClearLosToEntity
17 | local GetEntityBoneIndexByName = GetEntityBoneIndexByName
18 | local GetEntityBonePosition_2 = GetEntityBonePosition_2
19 | local GetEntityModel = GetEntityModel
20 | local IsDisabledControlJustPressed = IsDisabledControlJustPressed
21 | local DisableControlAction = DisableControlAction
22 | local DisablePlayerFiring = DisablePlayerFiring
23 | local GetModelDimensions = GetModelDimensions
24 | local GetOffsetFromEntityInWorldCoords = GetOffsetFromEntityInWorldCoords
25 | local currentTarget = {}
26 | local currentMenu
27 | local menuChanged
28 | local menuHistory = {}
29 | local nearbyZones
30 |
31 | -- Toggle ox_target, instead of holding the hotkey
32 | local toggleHotkey = GetConvarInt('ox_target:toggleHotkey', 0) == 1
33 | local mouseButton = GetConvarInt('ox_target:leftClick', 1) == 1 and 24 or 25
34 | local debug = GetConvarInt('ox_target:debug', 0) == 1
35 | local vec0 = vec3(0, 0, 0)
36 |
37 | ---@param option OxTargetOption
38 | ---@param distance number
39 | ---@param endCoords vector3
40 | ---@param entityHit? number
41 | ---@param entityType? number
42 | ---@param entityModel? number | false
43 | local function shouldHide(option, distance, endCoords, entityHit, entityType, entityModel)
44 | if option.menuName ~= currentMenu then
45 | return true
46 | end
47 |
48 | if distance > (option.distance or 7) then
49 | return true
50 | end
51 |
52 | if option.groups and not utils.hasPlayerGotGroup(option.groups) then
53 | return true
54 | end
55 |
56 | if option.items and not utils.hasPlayerGotItems(option.items, option.anyItem) then
57 | return true
58 | end
59 |
60 | local bone = entityModel and option.bones or nil
61 |
62 | if bone then
63 | ---@cast entityHit number
64 | ---@cast entityType number
65 | ---@cast entityModel number
66 |
67 | local _type = type(bone)
68 |
69 | if _type == 'string' then
70 | local boneId = GetEntityBoneIndexByName(entityHit, bone)
71 |
72 | if boneId ~= -1 and #(endCoords - GetEntityBonePosition_2(entityHit, boneId)) <= 2 then
73 | bone = boneId
74 | else
75 | return true
76 | end
77 | elseif _type == 'table' then
78 | local closestBone, boneDistance
79 |
80 | for j = 1, #bone do
81 | local boneId = GetEntityBoneIndexByName(entityHit, bone[j])
82 |
83 | if boneId ~= -1 then
84 | local dist = #(endCoords - GetEntityBonePosition_2(entityHit, boneId))
85 |
86 | if dist <= (boneDistance or 1) then
87 | closestBone = boneId
88 | boneDistance = dist
89 | end
90 | end
91 | end
92 |
93 | if closestBone then
94 | bone = closestBone
95 | else
96 | return true
97 | end
98 | end
99 | end
100 |
101 | local offset = entityModel and option.offset or nil
102 |
103 | if offset then
104 | ---@cast entityHit number
105 | ---@cast entityType number
106 | ---@cast entityModel number
107 |
108 | if not option.absoluteOffset then
109 | local min, max = GetModelDimensions(entityModel)
110 | offset = (max - min) * offset + min
111 | end
112 |
113 | offset = GetOffsetFromEntityInWorldCoords(entityHit, offset.x, offset.y, offset.z)
114 |
115 | if #(endCoords - offset) > (option.offsetSize or 1) then
116 | return true
117 | end
118 | end
119 |
120 | if option.canInteract then
121 | local success, resp = pcall(option.canInteract, entityHit, distance, endCoords, option.name, bone)
122 | return not success or not resp
123 | end
124 | end
125 |
126 | local function startTargeting()
127 | if state.isDisabled() or state.isActive() or IsNuiFocused() or IsPauseMenuActive() then return end
128 |
129 | state.setActive(true)
130 |
131 | local flag = 511
132 | local hit, entityHit, endCoords, distance, lastEntity, entityType, entityModel, hasTarget, zonesChanged
133 | local zones = {}
134 |
135 | CreateThread(function()
136 | local dict, texture = utils.getTexture()
137 | local lastCoords
138 |
139 | while state.isActive() do
140 | lastCoords = endCoords == vec0 and lastCoords or endCoords or vec0
141 |
142 | if debug then
143 | DrawMarker(28, lastCoords.x, lastCoords.y, lastCoords.z, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2, 0.2,
144 | 0.2,
145 | ---@diagnostic disable-next-line: param-type-mismatch
146 | 255, 42, 24, 100, false, false, 0, true, false, false, false)
147 | end
148 |
149 | utils.drawZoneSprites(dict, texture)
150 | DisablePlayerFiring(cache.playerId, true)
151 | DisableControlAction(0, 25, true)
152 | DisableControlAction(0, 140, true)
153 | DisableControlAction(0, 141, true)
154 | DisableControlAction(0, 142, true)
155 |
156 | if state.isNuiFocused() then
157 | DisableControlAction(0, 1, true)
158 | DisableControlAction(0, 2, true)
159 |
160 | if not hasTarget or options and IsDisabledControlJustPressed(0, 25) then
161 | state.setNuiFocus(false, false)
162 | end
163 | elseif hasTarget and IsDisabledControlJustPressed(0, mouseButton) then
164 | state.setNuiFocus(true, true)
165 | end
166 |
167 | Wait(0)
168 | end
169 |
170 | SetStreamedTextureDictAsNoLongerNeeded(dict)
171 | end)
172 |
173 | while state.isActive() do
174 | if not state.isNuiFocused() and lib.progressActive() then
175 | state.setActive(false)
176 | break
177 | end
178 |
179 | local playerCoords = GetEntityCoords(cache.ped)
180 | hit, entityHit, endCoords = lib.raycast.fromCamera(flag, 4, 20)
181 | distance = #(playerCoords - endCoords)
182 |
183 | if entityHit ~= 0 and entityHit ~= lastEntity then
184 | local success, result = pcall(GetEntityType, entityHit)
185 | entityType = success and result or 0
186 | end
187 |
188 | if entityType == 0 then
189 | local _flag = flag == 511 and 26 or 511
190 | local _hit, _entityHit, _endCoords = lib.raycast.fromCamera(_flag, 4, 20)
191 | local _distance = #(playerCoords - _endCoords)
192 |
193 | if _distance < distance then
194 | flag, hit, entityHit, endCoords, distance = _flag, _hit, _entityHit, _endCoords, _distance
195 |
196 | if entityHit ~= 0 then
197 | local success, result = pcall(GetEntityType, entityHit)
198 | entityType = success and result or 0
199 | end
200 | end
201 | end
202 |
203 | nearbyZones, zonesChanged = utils.getNearbyZones(endCoords)
204 |
205 | local entityChanged = entityHit ~= lastEntity
206 | local newOptions = (zonesChanged or entityChanged or menuChanged) and true
207 |
208 | if entityHit > 0 and entityChanged then
209 | currentMenu = nil
210 |
211 | if flag ~= 511 then
212 | entityHit = HasEntityClearLosToEntity(entityHit, cache.ped, 7) and entityHit or 0
213 | end
214 |
215 | if lastEntity ~= entityHit and debug then
216 | if lastEntity then
217 | SetEntityDrawOutline(lastEntity, false)
218 | end
219 |
220 | if entityType ~= 1 then
221 | SetEntityDrawOutline(entityHit, true)
222 | end
223 | end
224 |
225 | if entityHit > 0 then
226 | local success, result = pcall(GetEntityModel, entityHit)
227 | entityModel = success and result
228 | end
229 | end
230 |
231 | if hasTarget and (zonesChanged or entityChanged and hasTarget > 1) then
232 | SendNuiMessage('{"event": "leftTarget"}')
233 |
234 | if entityChanged then options:wipe() end
235 |
236 | if debug and lastEntity > 0 then SetEntityDrawOutline(lastEntity, false) end
237 |
238 | hasTarget = false
239 | end
240 |
241 | if newOptions and entityModel and entityHit > 0 then
242 | options:set(entityHit, entityType, entityModel)
243 | end
244 |
245 | lastEntity = entityHit
246 | currentTarget.entity = entityHit
247 | currentTarget.coords = endCoords
248 | currentTarget.distance = distance
249 | local hidden = 0
250 | local totalOptions = 0
251 |
252 | for k, v in pairs(options) do
253 | local optionCount = #v
254 | local dist = k == '__global' and 0 or distance
255 | totalOptions += optionCount
256 |
257 | for i = 1, optionCount do
258 | local option = v[i]
259 | local hide = shouldHide(option, dist, endCoords, entityHit, entityType, entityModel)
260 |
261 | if option.hide ~= hide then
262 | option.hide = hide
263 | newOptions = true
264 | end
265 |
266 | if hide then hidden += 1 end
267 | end
268 | end
269 |
270 | if zonesChanged then table.wipe(zones) end
271 |
272 | for i = 1, #nearbyZones do
273 | local zoneOptions = nearbyZones[i].options
274 | local optionCount = #zoneOptions
275 | totalOptions += optionCount
276 | zones[i] = zoneOptions
277 |
278 | for j = 1, optionCount do
279 | local option = zoneOptions[j]
280 | local hide = shouldHide(option, distance, endCoords, entityHit)
281 |
282 | if option.hide ~= hide then
283 | option.hide = hide
284 | newOptions = true
285 | end
286 |
287 | if hide then hidden += 1 end
288 | end
289 | end
290 |
291 | if newOptions then
292 | if hasTarget == 1 and (totalOptions - hidden) > 1 then
293 | hasTarget = true
294 | end
295 |
296 | if hasTarget and hidden == totalOptions then
297 | if hasTarget and hasTarget ~= 1 then
298 | hasTarget = false
299 | SendNuiMessage('{"event": "leftTarget"}')
300 | end
301 | elseif menuChanged or hasTarget ~= 1 and hidden ~= totalOptions then
302 | hasTarget = options.size
303 |
304 | if currentMenu and options.__global[1]?.name ~= 'builtin:goback' then
305 | table.insert(options.__global, 1,
306 | {
307 | icon = 'fa-solid fa-circle-chevron-left',
308 | label = locale('go_back'),
309 | name = 'builtin:goback',
310 | menuName = currentMenu,
311 | openMenu = 'home'
312 | })
313 | end
314 |
315 | SendNuiMessage(json.encode({
316 | event = 'setTarget',
317 | options = options,
318 | zones = zones,
319 | }, { sort_keys = true }))
320 | end
321 |
322 | menuChanged = false
323 | end
324 |
325 | if toggleHotkey and IsPauseMenuActive() then
326 | state.setActive(false)
327 | end
328 |
329 | if not hasTarget or hasTarget == 1 then
330 | flag = flag == 511 and 26 or 511
331 | end
332 |
333 | Wait(hit and 50 or 100)
334 | end
335 |
336 | if lastEntity and debug then
337 | SetEntityDrawOutline(lastEntity, false)
338 | end
339 |
340 | state.setNuiFocus(false)
341 | SendNuiMessage('{"event": "visible", "state": false}')
342 | table.wipe(currentTarget)
343 | options:wipe()
344 |
345 | if nearbyZones then table.wipe(nearbyZones) end
346 | end
347 |
348 | do
349 | ---@type KeybindProps
350 | local keybind = {
351 | name = 'ox_target',
352 | defaultKey = GetConvar('ox_target:defaultHotkey', 'LMENU'),
353 | defaultMapper = 'keyboard',
354 | description = locale('toggle_targeting'),
355 | }
356 |
357 | if toggleHotkey then
358 | function keybind:onPressed()
359 | if state.isActive() then
360 | return state.setActive(false)
361 | end
362 |
363 | return startTargeting()
364 | end
365 | else
366 | keybind.onPressed = startTargeting
367 |
368 | function keybind:onReleased()
369 | state.setActive(false)
370 | end
371 | end
372 |
373 | lib.addKeybind(keybind)
374 | end
375 |
376 | ---@generic T
377 | ---@param option T
378 | ---@param server? boolean
379 | ---@return T
380 | local function getResponse(option, server)
381 | local response = table.clone(option)
382 | response.entity = currentTarget.entity
383 | response.zone = currentTarget.zone
384 | response.coords = currentTarget.coords
385 | response.distance = currentTarget.distance
386 |
387 | if server then
388 | response.entity = response.entity ~= 0 and NetworkGetEntityIsNetworked(response.entity) and
389 | NetworkGetNetworkIdFromEntity(response.entity) or 0
390 | end
391 |
392 | response.icon = nil
393 | response.groups = nil
394 | response.items = nil
395 | response.canInteract = nil
396 | response.onSelect = nil
397 | response.export = nil
398 | response.event = nil
399 | response.serverEvent = nil
400 | response.command = nil
401 |
402 | return response
403 | end
404 |
405 | RegisterNUICallback('select', function(data, cb)
406 | cb(1)
407 |
408 | local zone = data[3] and nearbyZones[data[3]]
409 |
410 | ---@type OxTargetOption?
411 | local option = zone and zone.options[data[2]] or options[data[1]][data[2]]
412 |
413 | if option then
414 | if option.openMenu then
415 | local menuDepth = #menuHistory
416 |
417 | if option.name == 'builtin:goback' then
418 | option.menuName = option.openMenu
419 | option.openMenu = menuHistory[menuDepth]
420 |
421 | if menuDepth > 0 then
422 | menuHistory[menuDepth] = nil
423 | end
424 | else
425 | menuHistory[menuDepth + 1] = currentMenu
426 | end
427 |
428 | menuChanged = true
429 | currentMenu = option.openMenu ~= 'home' and option.openMenu or nil
430 |
431 | options:wipe()
432 | else
433 | state.setNuiFocus(false)
434 | end
435 |
436 | currentTarget.zone = zone?.id
437 |
438 | if option.onSelect then
439 | option.onSelect(option.qtarget and currentTarget.entity or getResponse(option))
440 | elseif option.export then
441 | exports[option.resource or zone.resource][option.export](nil, getResponse(option))
442 | elseif option.event then
443 | TriggerEvent(option.event, getResponse(option))
444 | elseif option.serverEvent then
445 | TriggerServerEvent(option.serverEvent, getResponse(option, true))
446 | elseif option.command then
447 | ExecuteCommand(option.command)
448 | end
449 |
450 | if option.menuName == 'home' then return end
451 | end
452 |
453 | if not option?.openMenu and IsNuiFocused() then
454 | state.setActive(false)
455 | end
456 | end)
457 |
--------------------------------------------------------------------------------
/client/api.lua:
--------------------------------------------------------------------------------
1 | ---@class OxTargetOption
2 | ---@field resource? string
3 |
4 | local utils = require 'client.utils'
5 |
6 | local api = setmetatable({}, {
7 | __newindex = function(self, index, value)
8 | rawset(self, index, value)
9 | exports(index, value)
10 | end
11 | })
12 |
13 | ---Throws a formatted type error
14 | ---@param variable string
15 | ---@param expected string
16 | ---@param received string
17 | local function typeError(variable, expected, received)
18 | error(("expected %s to have type '%s' (received %s)"):format(variable, expected, received))
19 | end
20 |
21 | ---Checks options and throws an error on type mismatch
22 | ---@param options OxTargetOption | OxTargetOption[]
23 | ---@return OxTargetOption[]
24 | local function checkOptions(options)
25 | local optionsType = type(options)
26 |
27 | if optionsType ~= 'table' then
28 | typeError('options', 'table', optionsType)
29 | end
30 |
31 | local tableType = table.type(options)
32 |
33 | if tableType == 'hash' and options.label then
34 | options = { options }
35 | elseif tableType ~= 'array' then
36 | typeError('options', 'array', ('%s table'):format(tableType))
37 | end
38 |
39 | return options
40 | end
41 |
42 | ---@param data OxTargetPolyZone | table
43 | ---@return number
44 | function api.addPolyZone(data)
45 | if data.debug then utils.warn('Creating new PolyZone with debug enabled.') end
46 |
47 | data.resource = GetInvokingResource()
48 | data.options = checkOptions(data.options)
49 | return lib.zones.poly(data).id
50 | end
51 |
52 | ---@param data OxTargetBoxZone | table
53 | ---@return number
54 | function api.addBoxZone(data)
55 | if data.debug then utils.warn('Creating new BoxZone with debug enabled.') end
56 |
57 | data.resource = GetInvokingResource()
58 | data.options = checkOptions(data.options)
59 | return lib.zones.box(data).id
60 | end
61 |
62 | ---@param data OxTargetSphereZone | table
63 | ---@return number
64 | function api.addSphereZone(data)
65 | if data.debug then utils.warn('Creating new SphereZone with debug enabled.') end
66 |
67 | data.resource = GetInvokingResource()
68 | data.options = checkOptions(data.options)
69 | return lib.zones.sphere(data).id
70 | end
71 |
72 | ---@param id number | string The ID of the zone to check. It can be either a number or a string representing the zone's index or name, respectively.
73 | ---@return boolean returns true if the zone with the specified ID exists, otherwise false.
74 | function api.zoneExists(id)
75 | if not Zones or (type(id) ~= 'number' and type(id) ~= 'string') then return false end
76 |
77 | if type(id) == 'number' and Zones[id] then return true end
78 |
79 | for _, zone in pairs(lib.zones.getAllZones()) do
80 | if type(id) == 'string' and zone.name == id then return true end
81 | end
82 |
83 | return false
84 | end
85 |
86 | ---@param id number | string
87 | ---@param suppressWarning boolean?
88 | function api.removeZone(id, suppressWarning)
89 | if Zones then
90 | if type(id) == 'string' then
91 | local foundZone
92 |
93 | for _, v in pairs(lib.zones.getAllZones()) do
94 | if v.name == id then
95 | foundZone = true
96 | v:remove()
97 | end
98 | end
99 |
100 | if foundZone then return end
101 | elseif Zones[id] then
102 | return Zones[id]:remove()
103 | end
104 | end
105 |
106 | if suppressWarning then return end
107 |
108 | warn(('attempted to remove a zone that does not exist (id: %s)'):format(id))
109 | end
110 |
111 | ---@param target table
112 | ---@param remove string | string[]
113 | ---@param resource string
114 | ---@param showWarning? boolean
115 | local function removeTarget(target, remove, resource, showWarning)
116 | if type(remove) ~= 'table' then remove = { remove } end
117 |
118 | for i = #target, 1, -1 do
119 | local option = target[i]
120 |
121 | if option.resource == resource then
122 | for j = #remove, 1, -1 do
123 | if option.name == remove[j] then
124 | table.remove(target, i)
125 |
126 | if showWarning then
127 | utils.warn(("Replacing existing target option '%s'."):format(option.name))
128 | end
129 | end
130 | end
131 | end
132 | end
133 | end
134 |
135 | ---@param target table
136 | ---@param options OxTargetOption | OxTargetOption[]
137 | ---@param resource string
138 | local function addTarget(target, options, resource)
139 | options = checkOptions(options)
140 |
141 | local checkNames = {}
142 |
143 | resource = resource or 'ox_target'
144 |
145 | for i = 1, #options do
146 | local option = options[i]
147 | option.resource = resource
148 |
149 | if option.name then
150 | checkNames[#checkNames + 1] = option.name
151 | end
152 | end
153 |
154 | if checkNames[1] then
155 | removeTarget(target, checkNames, resource, true)
156 | end
157 |
158 | local num = #target
159 |
160 | for i = 1, #options do
161 | local option = options[i]
162 |
163 | if resource == 'ox_target' then
164 | if option.canInteract then
165 | option.canInteract = msgpack.unpack(msgpack.pack(option.canInteract))
166 | end
167 |
168 | if option.onSelect then
169 | option.onSelect = msgpack.unpack(msgpack.pack(option.onSelect))
170 | end
171 | end
172 |
173 | num += 1
174 | target[num] = options[i]
175 | end
176 | end
177 |
178 | ---@type table
179 | local peds = {}
180 |
181 | ---@param options OxTargetOption | OxTargetOption[]
182 | function api.addGlobalPed(options)
183 | addTarget(peds, options, GetInvokingResource())
184 | end
185 |
186 | ---@param options string | string[]
187 | function api.removeGlobalPed(options)
188 | removeTarget(peds, options, GetInvokingResource())
189 | end
190 |
191 | ---@type table
192 | local vehicles = {}
193 |
194 | ---@param options OxTargetOption | OxTargetOption[]
195 | function api.addGlobalVehicle(options)
196 | addTarget(vehicles, options, GetInvokingResource())
197 | end
198 |
199 | ---@param options string | string[]
200 | function api.removeGlobalVehicle(options)
201 | removeTarget(vehicles, options, GetInvokingResource())
202 | end
203 |
204 | ---@type table
205 | local objects = {}
206 |
207 | ---@param options OxTargetOption | OxTargetOption[]
208 | function api.addGlobalObject(options)
209 | addTarget(objects, options, GetInvokingResource())
210 | end
211 |
212 | ---@param options string | string[]
213 | function api.removeGlobalObject(options)
214 | removeTarget(objects, options, GetInvokingResource())
215 | end
216 |
217 | ---@type table
218 | local players = {}
219 |
220 | ---@param options OxTargetOption | OxTargetOption[]
221 | function api.addGlobalPlayer(options)
222 | addTarget(players, options, GetInvokingResource())
223 | end
224 |
225 | ---@param options string | string[]
226 | function api.removeGlobalPlayer(options)
227 | removeTarget(players, options, GetInvokingResource())
228 | end
229 |
230 | ---@type table
231 | local models = {}
232 |
233 | ---@param arr (number | string) | (number | string)[]
234 | ---@param options OxTargetOption | OxTargetOption[]
235 | function api.addModel(arr, options)
236 | if type(arr) ~= 'table' then arr = { arr } end
237 | local resource = GetInvokingResource()
238 |
239 | for i = 1, #arr do
240 | local model = arr[i]
241 | model = tonumber(model) or joaat(model)
242 |
243 | if not models[model] then
244 | models[model] = {}
245 | end
246 |
247 | addTarget(models[model], options, resource)
248 | end
249 | end
250 |
251 | ---@param arr (number | string) | (number | string)[]
252 | ---@param options? string | string[]
253 | function api.removeModel(arr, options)
254 | if type(arr) ~= 'table' then arr = { arr } end
255 | local resource = GetInvokingResource()
256 |
257 | for i = 1, #arr do
258 | local model = arr[i]
259 | model = tonumber(model) or joaat(model)
260 |
261 | if models[model] then
262 | if options then
263 | removeTarget(models[model], options, resource)
264 | end
265 |
266 | if not options or #models[model] == 0 then
267 | models[model] = nil
268 | end
269 | end
270 | end
271 | end
272 |
273 | ---@type table
274 | local entities = {}
275 |
276 | ---@param arr number | number[]
277 | ---@param options OxTargetOption | OxTargetOption[]
278 | function api.addEntity(arr, options)
279 | if type(arr) ~= 'table' then arr = { arr } end
280 | local resource = GetInvokingResource()
281 |
282 | for i = 1, #arr do
283 | local netId = arr[i]
284 |
285 | if NetworkDoesNetworkIdExist(netId) then
286 | if not entities[netId] then
287 | entities[netId] = {}
288 |
289 | if not Entity(NetworkGetEntityFromNetworkId(netId)).state.hasTargetOptions then
290 | TriggerServerEvent('ox_target:setEntityHasOptions', netId)
291 | end
292 | end
293 |
294 | addTarget(entities[netId], options, resource)
295 | end
296 | end
297 | end
298 |
299 | ---@param arr number | number[]
300 | ---@param options? string | string[]
301 | function api.removeEntity(arr, options)
302 | if type(arr) ~= 'table' then arr = { arr } end
303 | local resource = GetInvokingResource()
304 |
305 | for i = 1, #arr do
306 | local netId = arr[i]
307 |
308 | if entities[netId] then
309 | if options then
310 | removeTarget(entities[netId], options, resource)
311 | end
312 |
313 | if not options or #entities[netId] == 0 then
314 | entities[netId] = nil
315 | end
316 | end
317 | end
318 | end
319 |
320 | RegisterNetEvent('ox_target:removeEntity', api.removeEntity)
321 |
322 | ---@type table
323 | local localEntities = {}
324 |
325 | ---@param arr number | number[]
326 | ---@param options OxTargetOption | OxTargetOption[]
327 | function api.addLocalEntity(arr, options)
328 | if type(arr) ~= 'table' then arr = { arr } end
329 | local resource = GetInvokingResource()
330 |
331 | for i = 1, #arr do
332 | local entityId = arr[i]
333 |
334 | if DoesEntityExist(entityId) then
335 | if not localEntities[entityId] then
336 | localEntities[entityId] = {}
337 | end
338 |
339 | addTarget(localEntities[entityId], options, resource)
340 | else
341 | lib.print.warn(("No entity with id '%s' exists in %s."):format(entityId, resource))
342 | end
343 | end
344 | end
345 |
346 | ---@param arr number | number[]
347 | ---@param options? table
348 | function api.removeLocalEntity(arr, options)
349 | if type(arr) ~= 'table' then arr = { arr } end
350 | local resource = GetInvokingResource()
351 |
352 | for i = 1, #arr do
353 | local entity = arr[i]
354 |
355 | if localEntities[entity] then
356 | if options then
357 | removeTarget(localEntities[entity], options, resource)
358 | end
359 |
360 | if not options or #localEntities[entity] == 0 then
361 | localEntities[entity] = nil
362 | end
363 | end
364 | end
365 | end
366 |
367 | CreateThread(function()
368 | while true do
369 | Wait(60000)
370 |
371 | for entityId in pairs(localEntities) do
372 | if not DoesEntityExist(entityId) then
373 | localEntities[entityId] = nil
374 | end
375 | end
376 | end
377 | end)
378 |
379 | ---@param resource string
380 | ---@param target table
381 | local function removeResourceGlobals(resource, target)
382 | for i = 1, #target do
383 | local options = target[i]
384 |
385 | for j = #options, 1, -1 do
386 | if options[j].resource == resource then
387 | table.remove(options, j)
388 | end
389 | end
390 | end
391 | end
392 |
393 | ---@param resource string
394 | ---@param target table
395 | local function removeResourceTargets(resource, target)
396 | for i = 1, #target do
397 | local tbl = target[i]
398 |
399 | for key, options in pairs(tbl) do
400 | for j = #options, 1, -1 do
401 | if options[j].resource == resource then
402 | table.remove(options, j)
403 | end
404 | end
405 |
406 | if #options == 0 then
407 | tbl[key] = nil
408 | end
409 | end
410 | end
411 | end
412 |
413 | ---@param resource string
414 | AddEventHandler('onClientResourceStop', function(resource)
415 | removeResourceGlobals(resource, { peds, vehicles, objects, players })
416 | removeResourceTargets(resource, { models, entities, localEntities })
417 |
418 | if Zones then
419 | for _, v in pairs(Zones) do
420 | if v.resource == resource then
421 | v:remove()
422 | end
423 | end
424 | end
425 | end)
426 |
427 | local NetworkGetEntityIsNetworked = NetworkGetEntityIsNetworked
428 | local NetworkGetNetworkIdFromEntity = NetworkGetNetworkIdFromEntity
429 |
430 | ---@class OxTargetOptions
431 | local options_mt = {}
432 | options_mt.__index = options_mt
433 | options_mt.size = 1
434 |
435 | function options_mt:wipe()
436 | options_mt.size = 1
437 | self.globalTarget = nil
438 | self.model = nil
439 | self.entity = nil
440 | self.localEntity = nil
441 |
442 | if self.__global[1]?.name == 'builtin:goback' then
443 | table.remove(self.__global, 1)
444 | end
445 | end
446 |
447 | ---@param entity? number
448 | ---@param _type? number
449 | ---@param model? number
450 | function options_mt:set(entity, _type, model)
451 | if not entity then return end
452 |
453 | if _type == 1 and IsPedAPlayer(entity) then
454 | self:wipe()
455 | self.globalTarget = players
456 | options_mt.size += 1
457 |
458 | return
459 | end
460 |
461 | local netId = NetworkGetEntityIsNetworked(entity) and NetworkGetNetworkIdFromEntity(entity)
462 |
463 | self.globalTarget = _type == 1 and peds or _type == 2 and vehicles or objects
464 | self.model = models[model]
465 | self.entity = netId and entities[netId] or nil
466 | self.localEntity = localEntities[entity]
467 | options_mt.size += 1
468 |
469 | if self.model then options_mt.size += 1 end
470 | if self.entity then options_mt.size += 1 end
471 | if self.localEntity then options_mt.size += 1 end
472 | end
473 |
474 | ---@type OxTargetOption[]
475 | local global = {}
476 |
477 | ---@param options OxTargetOption | OxTargetOption[]
478 | function api.addGlobalOption(options)
479 | addTarget(global, options, GetInvokingResource())
480 | end
481 |
482 | ---@param options string | string[]
483 | function api.removeGlobalOption(options)
484 | removeTarget(global, options, GetInvokingResource())
485 | end
486 |
487 | ---@class OxTargetOptions
488 | local options = setmetatable({
489 | __global = global
490 | }, options_mt)
491 |
492 | ---@param entity? number
493 | ---@param _type? number
494 | ---@param model? number
495 | function api.getTargetOptions(entity, _type, model)
496 | if not entity then return options end
497 |
498 | if IsPedAPlayer(entity) then
499 | return {
500 | global = players,
501 | }
502 | end
503 |
504 | local netId = NetworkGetEntityIsNetworked(entity) and NetworkGetNetworkIdFromEntity(entity)
505 |
506 | return {
507 | global = _type == 1 and peds or _type == 2 and vehicles or objects,
508 | model = models[model],
509 | entity = netId and entities[netId] or nil,
510 | localEntity = localEntities[entity],
511 | }
512 | end
513 |
514 | local state = require 'client.state'
515 |
516 | function api.disableTargeting(value)
517 | if value then
518 | state.setActive(false)
519 | end
520 |
521 | state.setDisabled(value)
522 | end
523 |
524 | function api.isActive()
525 | return state.isActive()
526 | end
527 |
528 | return api
529 |
--------------------------------------------------------------------------------