28 Commits

Author SHA1 Message Date
ANTOND.
a8bf225b3d ci(create-release): run publish in package folder 2026-02-20 01:44:50 +01:00
ANTOND.
1e02469d9a ci(create-release): use npm trusted publisher 2026-02-20 01:35:10 +01:00
github-actions
4b2446f7a8 chore: bump version to v3.32.3 2026-02-19 21:34:58 +00:00
Senlar
f33c613b6d Fix: Skillcheck animation speed inconsistencies by switching to time-… (#66)
* Fix: Skillcheck animation speed inconsistencies by switching to time-based RAF loop

This PR fixes long-standing issues with the skillcheck mini-game animation speed being inconsistent across machines and after long play sessions. Some players reported extremely slow indicator movement, others extremely fast, and many noticed that the speed changed unpredictably the longer they stayed logged in.

The root cause was that the skillcheck relied on a useInterval tick (setInterval-like behavior) and assumed it fired every 1ms. In reality, browser timer clamping and throttling make interval timing highly unpredictable — especially in embedded CEF browsers like FiveM’s NUI.

This PR replaces tick-based animation with a time-based requestAnimationFrame loop using performance.now(), ensuring perfectly consistent animation timing across all hardware and browser states. I have used this method in other NUI based skillcheck scripts to address this same behavior.

Signed-off-by: Senlar <brandonrhue@gmail.com>

* Refactor keyHandler and clean up code

Signed-off-by: Senlar <brandonrhue@gmail.com>

* Refactor keyHandler and cleanup useEffect logic again

Signed-off-by: Senlar <brandonrhue@gmail.com>

---------

Signed-off-by: Senlar <brandonrhue@gmail.com>
2026-02-11 18:22:54 +01:00
Maximus7474
e66b6805bc fix(package): added an array check on callback args (#85)
-> Issue is that if callaback returns a null value it errors and fails
2026-02-11 18:07:42 +01:00
BerkieBb
fdc1674f4f fix(interface/menu): incosistent menu function types (#80)
Signed-off-by: BerkieBb <82737367+BerkieBb@users.noreply.github.com>
2026-01-19 23:47:42 +01:00
Angelo Rodrigues De Morais Barroso
c8db003cbc Add lockState support to vehicle properties (#83)
Hello,

I’d like to propose a small improvement to vehicle properties handling.

This change adds support for the vehicle door lock state (lockState) by:
- Including lockState in the VehicleProperties type
- Reading the value using GetVehicleDoorLockStatus in getVehicleProperties
- Applying the value using SetVehicleDoorsLocked in setVehicleProperties

This allows vehicle lock status to be properly saved and restored alongside other vehicle properties.

Let me know if this fits the project direction or if any adjustments are needed.
This allows the vehicle lock status to be properly saved and restored alongside other vehicle properties.

Note: Two additional commits and pull requests were accidentally opened during this process and have already been closed. Sorry for the noise.

Thank you!

Signed-off-by: Angelo Rodrigues De Morais Barroso <115129101+Angelo90810@users.noreply.github.com>
2026-01-19 23:46:37 +01:00
github-actions
ae16b78bc4 chore: bump version to v3.32.2 2025-12-21 17:18:40 +00:00
Christopher
3ea020cc50 feat(imports/logger): support metadata table for Fivemanage logger (#75) 2025-12-21 16:41:21 +01:00
Austin Dunn
db0bd16f0f Feat: Selector (#59)
* selector init

* fixes

* recalculate set weights on add/update
2025-12-08 00:19:59 +01:00
github-actions
01935adf8e chore: bump version to v3.32.1 2025-11-03 20:15:20 +00:00
Maximus7474
220c0c91ec fix(progress): client statebag change handler double creating the props (#60) 2025-11-03 21:13:15 +01:00
github-actions
855e1e4a36 chore: bump version to v3.32.0 2025-11-03 15:39:46 +00:00
PotatoFarmer441
a2f6b9a9e9 fix(progress): prop replication overload (#58) 2025-11-03 16:35:39 +01:00
RuksH4n
13a24baee4 fix(table_matches): remove leftover strict shape check from df7a6b9 (#50)
- The previous fix (commit df7a6b9) mistakenly left in a table.type
  / #t1 == #t2 check that breaks equality for {} and {x=nil}.
- This removes that leftover check, restoring the intended deep
  equality semantics.

Signed-off-by: RuksH4n <77233680+rukshanchamindu@users.noreply.github.com>
2025-09-13 12:47:16 +02:00
RuksH4n
df7a6b9c21 fix(table): restore expected behavior in table_matches (#49)
- The refactor in [Commit 4c460e0](4c460e0bd9) introduced stricter type checks (using table.type and array length comparison), which caused empty tables and tables with nil values to be considered unequal. 
- This commit removes the strict shape checks and restores the original deep equality semantics, ensuring {} == {x=nil}.

Signed-off-by: RuksH4n <77233680+rukshanchamindu@users.noreply.github.com>
2025-09-10 14:17:44 +02:00
github-actions
faa9dda57f chore: bump version to v3.31.4 2025-09-09 16:48:13 +00:00
Swellington Soares
619ecc9e55 feat(imports/dui): added mising dui methods (#47) 2025-09-09 18:47:01 +02:00
Maximus7474
56c0a91022 feat(imports/logger): add definable dataset for fivemanage logging (#45) 2025-09-04 18:48:50 +02:00
PotatoFarmer441
d6417b9a84 fix(zones): set zone distance on creation (#44)
* fix(zones): set zone distance on creation

Signed-off-by: PotatoFarmer441 <helioau@live.com.au>

* Update shared.lua

Signed-off-by: PotatoFarmer441 <helioau@live.com.au>

---------

Signed-off-by: PotatoFarmer441 <helioau@live.com.au>
2025-08-30 13:39:20 +02:00
github-actions
ac9625d33a chore: bump version to v3.31.3 2025-08-18 12:00:25 +00:00
Zoo
2ab9114d2f chore(package/index): export addKeybind function (#40) 2025-08-18 13:58:42 +02:00
github-actions
88c68c7068 chore: bump version to v3.31.2 2025-08-18 11:45:57 +00:00
PaPi
1e178180bb chore(locales): update fr.json (#38) 2025-08-18 13:45:14 +02:00
Zoo
fef441c456 feat(package/addKeybind): add keybind to ts package (#39) 2025-08-18 13:30:43 +02:00
github-actions
85a3879a03 chore: bump version to v3.31.1 2025-08-12 14:45:04 +00:00
mikigoalie
65813e75e6 feat(imports/table): add table.map (#35) 2025-08-12 16:42:57 +02:00
David Malchin
a9648fb45f fix(points): grid inconsistencies (#37) 2025-08-10 02:20:46 +02:00
19 changed files with 545 additions and 82 deletions

View File

@@ -5,6 +5,10 @@ on:
tags:
- 'v*.*.*'
permissions:
id-token: write # Required for OIDC
contents: write
jobs:
create-release:
if: github.actor_id != 210085057
@@ -86,8 +90,12 @@ jobs:
with:
ref: ${{ github.ref_name }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Publish package to npm registry
run: bun publish --access public
run: npm publish --access public
working-directory: package
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -6,7 +6,7 @@ rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aw
name 'ox_lib'
author 'Overextended'
version '3.31.0'
version '3.32.3'
license 'LGPL-3.0-or-later'
repository 'https://github.com/communityox/ox_lib'
description 'A library of shared functions to utilise in other resources.'

View File

@@ -84,6 +84,29 @@ function lib.dui:sendMessage(message)
end
end
---@param x number
---@param y number
function lib.dui:sendMouseMove(x, y)
SendDuiMouseMove(self.duiObject, x, y)
end
---@param button 'left' | 'middle' | 'right'
function lib.dui:sendMouseDown(button)
SendDuiMouseDown(self.duiObject, button)
end
---@param button 'left' | 'middle' | 'right'
function lib.dui:sendMouseUp(button)
SendDuiMouseUp(self.duiObject, button)
end
---@param deltaX number
---@param deltaY number
function lib.dui:sendMouseWheel(deltaX, deltaY)
SendDuiMouseWheel(self.duiObject, deltaY, deltaX)
end
AddEventHandler('onResourceStop', function(resourceName)
if cache.resource ~= resourceName then return end

View File

@@ -94,6 +94,7 @@ end
if service == 'fivemanage' then
local key = GetConvar('fivemanage:key', '')
local dataset = GetConvar('fivemanage:dataset', '')
if key ~= '' then
local endpoint = 'https://api.fivemanage.com/api/logs/batch'
@@ -101,9 +102,13 @@ if service == 'fivemanage' then
local headers = {
['Content-Type'] = 'application/json',
['Authorization'] = key,
['User-Agent'] = 'ox_lib'
['User-Agent'] = 'ox_lib',
}
if dataset ~= "" then
headers['X-Fivemanage-Dataset'] = dataset
end
function lib.logger(source, event, message, ...)
if not buffer then
buffer = {}
@@ -123,17 +128,43 @@ if service == 'fivemanage' then
end)
end
local metadata = {
hostname = hostname,
service = event,
source = source,
}
local playerTags = formatTags(source, nil)
if playerTags and type(playerTags) == 'string' then
local tempTable = { string.strsplit(',', playerTags) }
for _, v in pairs(tempTable) do
local key, value = string.strsplit(':', v)
if key and value then
metadata[key] = value
end
end
end
local args = { ... }
for _, arg in pairs(args) do
if type(arg) == 'table' then
for k, v in pairs(arg) do
metadata[k] = v
end
elseif type(arg) == 'string' then
local key, value = string.strsplit(':', arg)
if key and value then
metadata[key] = value
end
end
end
bufferSize += 1
buffer[bufferSize] = {
level = "info",
message = message,
resource = cache.resource,
metadata = {
hostname = hostname,
service = event,
source = source,
tags = formatTags(source, ... and string.strjoin(',', string.tostringall(...)) or nil),
}
metadata = metadata,
}
end
end

View File

@@ -39,10 +39,14 @@ local function removePoint(self)
points[self.id] = nil
end
local function hasRemovePoint(entry)
return entry.remove == removePoint
end
CreateThread(function()
while true do
local coords = GetEntityCoords(cache.ped)
local newPoints = lib.grid.getNearbyEntries(coords, function(entry) return entry.remove == removePoint end) --[[@as CPoint[] ]]
local newPoints = lib.grid.getNearbyEntries(coords, hasRemovePoint) --[[@as CPoint[] ]]
local cellX, cellY = lib.grid.getCellPosition(coords)
cache.coords = coords
closestPoint = nil
@@ -89,10 +93,12 @@ CreateThread(function()
nearbyCount += 1
nearbyPoints[nearbyCount] = point
if point.onEnter and not point.inside then
if not point.inside then
point.inside = true
if point.onEnter then
point:onEnter()
end
end
elseif point.currentDistance then
if point.onExit then point:onExit() end

175
imports/selector/shared.lua Normal file
View File

@@ -0,0 +1,175 @@
---@alias OxSelectorItem {[1]: number, [2]: any}
---@alias OxSelectorSet OxSelectorItem[]
---@class OxSelector: OxClass
---@field private sets OxSelectorSet | table<string, OxSelectorItem[]>
---@field private totalWeights table<string, number>
local OxSelector = lib.class("OxSelector")
local DEFAULT_SET = 'default'
local deepClone = lib.table.deepclone
local function calculateTotalWeight(set)
local total = 0
for i = 1, #set do
local item = set[i]
assert(type(item) == "table", "Each OxSelectorItem must be a table")
local weight = item[1]
assert(type(weight) == "number" and weight >= 0, "weight must be 0 or more")
total += weight
end
return total
end
---@param sets OxSelectorSet | table<string, OxSelectorItem[]>
function OxSelector:constructor(sets)
if type(sets) ~= "table" then
lib.print.error("Invalid sets provided to OxSelector constructor")
end
if lib.table.type(sets) == "array" then
sets = { [DEFAULT_SET] = sets }
end
self.private.totalWeights = {}
self.private.sets = {}
for setName, set in pairs(sets) do
assert(type(set) == "table" and lib.table.type(set) == "array", "Each set must be an array of OxSelectorItem")
assert(#set > 0, "Each set must contain at least one OxSelectorItem")
self.private.totalWeights[setName] = calculateTotalWeight(set)
end
self.private.sets = sets
end
--- Get a random non-weighted item from a specific set
---@param setName? string
---@return OxSelectorItem?
function OxSelector:getRandom(setName)
local set = (setName and self.private.sets[setName]) or self.private.sets[DEFAULT_SET]
if not set then return nil end
local item = set[math.random(#set)][2]
return type(item) == "table" and deepClone(item) or item
end
--- Get a random weighted item from a specific set
---@param setName? string
---@return OxSelectorItem?
function OxSelector:getRandomWeighted(setName)
local set = (setName and self.private.sets[setName]) or self.private.sets[DEFAULT_SET]
if not set then return nil end
local totalWeight = self.private.totalWeights[setName or DEFAULT_SET]
if totalWeight == 0 then return nil end
local randomWeight = math.random() * totalWeight
local cumulativeWeight = 0
for i = 1, #set do
cumulativeWeight = cumulativeWeight + set[i][1]
if randomWeight <= cumulativeWeight then
local item = set[i][2]
return type(item) == "table" and deepClone(item) or item
end
end
return nil
end
--- get multiple non-weighted random items from a specific set
---@param setName? string
---@param count number
---@return OxSelectorItem[]
function OxSelector:getRandomAmount(setName, count)
assert(type(count) == "number" and count > 0, "Count must be a positive number")
local items = {}
for _ = 1, count do
local item = self:getRandom(setName)
if item then
table.insert(items, item)
end
end
return items
end
--- get multiple weighted random items from a specific set
---@param setName? string
---@param count number
---@return OxSelectorItem[]
function OxSelector:getRandomWeightedAmount(setName, count)
assert(type(count) == "number" and count > 0, "Count must be a positive number")
local items = {}
for _ = 1, count do
local item = self:getRandomWeighted(setName)
if item then
table.insert(items, item)
end
end
return items
end
--- get all items from a specific set
---@param setName? string
---@return OxSelectorItem[]
function OxSelector:getSet(setName)
return deepClone((setName and self.private.sets[setName]) or self.private.sets[DEFAULT_SET])
end
--- get all sets
---@return table<string, OxSelectorItem[]>
function OxSelector:getAllSets()
return deepClone(self.private.sets)
end
--- add a new set
---@param setName string
---@param items OxSelectorItem[]
function OxSelector:addSet(setName, items)
assert(type(setName) == "string", "setName must be a string")
if self.private.sets[setName] then
lib.print.error("Selector set '" .. setName .. "' already exists.")
return
end
assert(type(items) == "table" and lib.table.type(items) == "array", "items must be an array")
assert(#items > 0, "set must contain at least one OxSelectorItem")
self.private.totalWeights[setName] = calculateTotalWeight(items)
self.private.sets[setName] = items
end
--- update an existing set
---@param setName string
---@param newItems OxSelectorItem[]
function OxSelector:updateSet(setName, newItems)
assert(type(setName) == "string", "setName must be a string")
if not self.private.sets[setName] then
lib.print.error("Selector set '" .. setName .. "' does not exist.")
return
end
assert(type(newItems) == "table" and lib.table.type(newItems) == "array", "newItems must be an array")
assert(#newItems > 0, "set must contain at least one OxSelectorItem")
self.private.totalWeights[setName] = calculateTotalWeight(newItems)
self.private.sets[setName] = newItems
end
--- remove a set
---@param setName string
function OxSelector:removeSet(setName)
assert(type(setName) == "string", "setName must be a string")
self.private.totalWeights[setName] = nil
self.private.sets[setName] = nil
end
lib.selector = OxSelector
return lib.selector

View File

@@ -47,13 +47,10 @@ end
---@return boolean
---Compares if two values are equal, iterating over tables and matching both keys and values.
local function table_matches(t1, t2)
local tabletype1 = table.type(t1)
local type1, type2 = type(t1), type(t2)
if not tabletype1 then return t1 == t2 end
if tabletype1 ~= table.type(t2) or (tabletype1 == 'array' and #t1 ~= #t2) then
return false
end
if type1 ~= type2 then return false end
if type1 ~= 'table' and type2 ~= 'table' then return t1 == t2 end
for k, v1 in pairs(t1) do
local v2 = t2[k]
@@ -123,11 +120,24 @@ local function shuffle(tbl)
return tbl
end
---@param tbl table
---@param fn function(value: any, key: any): any
---@return table
local function map(tbl, fn)
local result = {}
for k, v in pairs(tbl) do
result[k] = fn(v, k)
end
return result
end
table.contains = contains
table.matches = table_matches
table.deepclone = table_deepclone
table.merge = table_merge
table.shuffle = shuffle
table.map = map
local frozenNewIndex = function(self) error(('cannot set values on a frozen table (%s)'):format(self), 2) end
local _rawset = rawset

View File

@@ -345,6 +345,8 @@ local function setZone(data)
data.contains = data.contains or contains
if lib.context == 'client' then
local coords = cache.coords or GetEntityCoords(cache.ped)
data.distance = #(data.coords - coords)
data.setDebug = setDebug
if data.debug then

View File

@@ -7,27 +7,27 @@
"confirm": "Confirmer",
"more": "Plus...",
"settings": {
"locale": "Change locale",
"locale_description": "Current language: ${language} (%s)",
"notification_audio": "Notification audio",
"notification_position": "Notification position"
"locale": "Changer la langue",
"locale_description": "Langue actuelle : ${language} (%s)",
"notification_audio": "Audio des notifications",
"notification_position": "Position des notifications"
},
"position": {
"bottom": "Bottom",
"bottom-left": "Bottom-left",
"bottom-right": "Bottom-right",
"center-left": "Center-left",
"center-right": "Center-right",
"top": "Top",
"top-left": "Top-left",
"top-right": "Top-right"
"bottom": "Bas",
"bottom-left": "Bas-gauche",
"bottom-right": "Bas-droite",
"center-left": "Centre-gauche",
"center-right": "Centre-droite",
"top": "Haut",
"top-left": "Haut-gauche",
"top-right": "Haut-droite"
}
},
"open_radial_menu": "Open radial menu",
"cancel_progress": "Cancel current progress bar",
"txadmin_announcement": "Server announcement by %s",
"txadmin_dm": "Direct Message from %s",
"txadmin_warn": "You have been warned by %s",
"txadmin_warn_content": "%s \nAction ID: %s",
"txadmin_scheduledrestart": "Scheduled Restart"
"open_radial_menu": "Ouvrir le menu radial",
"cancel_progress": "Annuler la barre de progression actuelle",
"txadmin_announcement": "Annonce serveur par %s",
"txadmin_dm": "Message privé de %s",
"txadmin_warn": "Vous avez été averti par %s",
"txadmin_warn_content": "%s\nID de l'action : %s",
"txadmin_scheduledrestart": "Redémarrage programmé"
}

View File

@@ -0,0 +1,120 @@
interface CKeybind extends KeybindProps {
currentKey: string;
disabled: boolean;
isPressed: boolean;
hash: number;
getCurrentKey(): string;
isControlPressed(): boolean;
}
interface KeybindProps {
name: string;
description: string;
defaultMapper?: string;
defaultKey?: string;
disabled?: boolean;
disable?(this: CKeybind, toggle: boolean): void;
onPressed?(this: CKeybind): void;
onReleased?(this: CKeybind): void;
[key: string]: any;
}
const keybinds: Record<string, CKeybind> = {};
class Keybind implements CKeybind {
name: string;
description: string;
defaultMapper?: string;
defaultKey?: string;
onPressed?: (this: CKeybind) => void;
onReleased?: (this: CKeybind) => void;
secondaryKey?: string;
secondaryMapper?: string;
[key: string]: any;
disabled: boolean = false;
isPressed: boolean = false;
hash: number;
constructor(data: KeybindProps) {
this.name = data.name;
this.description = data.description;
this.defaultMapper = data.defaultMapper ?? "keyboard";
this.defaultKey = data.defaultKey ?? "";
this.secondaryKey = data.secondaryKey;
this.secondaryMapper = data.secondaryMapper;
if (typeof data.disabled === "boolean") this.disabled = data.disabled;
this.onPressed = data.onPressed;
this.onReleased = data.onReleased;
this.hash = GetHashKey("+" + this.name) | 0x80000000;
}
get currentKey(): string {
return this.getCurrentKey();
}
getCurrentKey(): string {
const label = GetControlInstructionalButton(0, this.hash, true);
return label.substring(2);
}
isControlPressed(): boolean {
return this.isPressed;
}
disable(toggle: boolean): void {
this.disabled = toggle;
}
}
export function addKeybind(data: KeybindProps): CKeybind {
const kb = new Keybind(data);
keybinds[kb.name] = kb;
RegisterCommand("+" + kb.name, () => {
if (kb.disabled || IsPauseMenuActive()) return;
kb.isPressed = true;
kb.onPressed?.call(kb);
}, false);
RegisterCommand("-" + kb.name, () => {
if (kb.disabled || IsPauseMenuActive()) return;
kb.isPressed = false;
kb.onReleased?.call(kb);
}, false);
RegisterKeyMapping(
"+" + kb.name,
kb.description,
kb.defaultMapper ?? "keyboard",
kb.defaultKey ?? ""
);
if (kb.secondaryKey) {
RegisterKeyMapping(
"~!+" + kb.name,
kb.description,
kb.secondaryMapper ?? kb.defaultMapper ?? "keyboard",
kb.secondaryKey
);
}
setTimeout(() => {
emit("chat:removeSuggestion", `/+${kb.name}`);
emit("chat:removeSuggestion", `/-${kb.name}`);
}, 500);
return kb;
}
export function getKeybind(name: string): CKeybind | undefined {
return keybinds[name];
}
export function getAllKeybinds(): Readonly<Record<string, CKeybind>> {
return keybinds;
}

View File

@@ -47,7 +47,7 @@ export function triggerServerCallback<T = unknown>(
return new Promise<T>((resolve, reject) => {
pendingCallbacks[key] = (args) => {
if (args[0] === 'cb_invalid') reject(`callback '${eventName} does not exist`);
if (Array.isArray(args) && args[0] === 'cb_invalid') reject(`callback '${eventName} does not exist`);
resolve(args);
};

View File

@@ -14,3 +14,4 @@ export * from './vehicleProperties';
export * from './callback';
export * from './points';
export * from './dui';
export * from './addKeybind';

View File

@@ -1,7 +1,7 @@
{
"name": "@communityox/ox_lib",
"author": "Overextended",
"version": "3.31.0",
"version": "3.32.3",
"description": "JS/TS wrapper for ox_lib exports",
"main": "./shared/index.js",
"types": "./shared/index.d.ts",
@@ -21,10 +21,10 @@
],
"repository": {
"type": "git",
"url": "git+https://github.com/communityox/ox_lib.git"
"url": "git+https://github.com/CommunityOx/ox_lib.git"
},
"bugs": {
"url": "https://github.com/communityox/ox_lib/issues"
"url": "https://github.com/CommunityOx/ox_lib/issues"
},
"license": "LGPL-3.0",
"dependencies": {

View File

@@ -29,7 +29,7 @@ export function triggerClientCallback<T = unknown>(
return new Promise<T>((resolve, reject) => {
pendingCallbacks[key] = (args) => {
if (args[0] === 'cb_invalid') reject(`callback '${eventName} does not exist`);
if (Array.isArray(args) && args[0] === 'cb_invalid') reject(`callback '${eventName} does not exist`);
resolve(args);
};

View File

@@ -13,6 +13,7 @@ local openMenu
---@alias MenuPosition 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
---@alias MenuChangeFunction fun(selected: number, scrollIndex?: number, args?: any, checked?: boolean)
---@alias MenuScrollSelectChangeFunction fun(selected: number, scrollIndex?: number, args?: any)
---@class MenuOptions
---@field label string
@@ -35,9 +36,9 @@ local openMenu
---@field disableInput? boolean
---@field canClose? boolean
---@field onClose? fun(keyPressed?: 'Escape' | 'Backspace')
---@field onSelected? MenuChangeFunction
---@field onSideScroll? MenuChangeFunction
---@field onCheck? MenuChangeFunction
---@field onSelected? MenuScrollSelectChangeFunction
---@field onSideScroll? MenuScrollSelectChangeFunction
---@field onCheck? fun(selected: number, checked: boolean, args?: any)
---@field cb? MenuChangeFunction
---@param data MenuProps

View File

@@ -11,6 +11,7 @@ local DisableControlAction = DisableControlAction
local DisablePlayerFiring = DisablePlayerFiring
local playerState = LocalPlayer.state
local createdProps = {}
local maxProps = GetConvarInt('ox:progressPropLimit', 2)
---@class ProgressPropProps
---@field model string
@@ -34,13 +35,16 @@ local createdProps = {}
---@field disable? { move?: boolean, sprint?: boolean, car?: boolean, combat?: boolean, mouse?: boolean }
local function createProp(ped, prop)
lib.requestModel(prop.model)
local ok, result = pcall(lib.requestModel, prop.model)
if not ok then return lib.print.error(result) end
local coords = GetEntityCoords(ped)
local object = CreateObject(prop.model, coords.x, coords.y, coords.z, false, false, false)
local object = CreateObject(result, coords.x, coords.y, coords.z, false, false, false)
AttachEntityToEntity(object, ped, GetPedBoneIndex(ped, prop.bone or 60309), prop.pos.x, prop.pos.y, prop.pos.z, prop.rot.x, prop.rot.y, prop.rot.z, true,
true, false, true, prop.rotOrder or 0, true)
SetModelAsNoLongerNeeded(prop.model)
SetModelAsNoLongerNeeded(result)
return object
end
@@ -90,7 +94,7 @@ local function startProgress(data)
end
if data.prop then
playerState:set('lib:progressProps', data.prop, true)
TriggerServerEvent('ox_lib:progressProps', data.prop)
end
local disable = data.disable
@@ -137,7 +141,7 @@ local function startProgress(data)
end
if data.prop then
playerState:set('lib:progressProps', nil, true)
TriggerServerEvent('ox_lib:progressProps', nil)
end
if anim then
@@ -225,14 +229,18 @@ end
local function deleteProgressProps(serverId)
local playerProps = createdProps[serverId]
if not playerProps then return end
createdProps[serverId] = nil
for i = 1, #playerProps do
local prop = playerProps[i]
if DoesEntityExist(prop) then
DeleteEntity(prop)
end
end
createdProps[serverId] = nil
end
RegisterNetEvent('onPlayerDropped', function(serverId)
@@ -248,22 +256,29 @@ AddStateBagChangeHandler('lib:progressProps', nil, function(bagName, key, value,
local ped = GetPlayerPed(ply)
local serverId = GetPlayerServerId(ply)
if not value then
if not value or createdProps[serverId] then
return deleteProgressProps(serverId)
end
createdProps[serverId] = {}
local playerProps = createdProps[serverId]
local playerProps = {}
if value.model then
playerProps[#playerProps + 1] = createProp(ped, value)
else
for i = 1, #value do
local prop = value[i]
local prop = createProp(ped, value)
if prop then
playerProps[#playerProps + 1] = createProp(ped, prop)
playerProps[#playerProps + 1] = prop
end
else
local propCount = math.min(maxProps, #value)
for i = 1, propCount do
local prop = createProp(ped, value[i])
if prop then
playerProps[#playerProps + 1] = prop
end
end
end
createdProps[serverId] = playerProps
end)

View File

@@ -0,0 +1,12 @@
local maxProps = GetConvarInt('ox:progressPropLimit', 2)
---@param props ProgressPropProps | ProgressPropProps[] | nil
RegisterNetEvent('ox_lib:progressProps', function(props)
if type(props) == 'table' then
props = #props > maxProps and { table.unpack(props, 1, maxProps) } or props
else
props = nil
end
Player(source).state:set('lib:progressProps', props, true)
end)

View File

@@ -12,6 +12,7 @@ if cache.game == 'redm' then return end
---@field model? number
---@field plate? string
---@field plateIndex? number
---@field lockState? number
---@field bodyHealth? number
---@field engineHealth? number
---@field tankHealth? number
@@ -202,6 +203,7 @@ function lib.getVehicleProperties(vehicle)
model = GetEntityModel(vehicle),
plate = GetVehicleNumberPlateText(vehicle),
plateIndex = GetVehicleNumberPlateTextIndex(vehicle),
lockState = GetVehicleDoorLockStatus(vehicle),
bodyHealth = math.floor(GetVehicleBodyHealth(vehicle) + 0.5),
engineHealth = math.floor(GetVehicleEngineHealth(vehicle) + 0.5),
tankHealth = math.floor(GetVehiclePetrolTankHealth(vehicle) + 0.5),
@@ -322,6 +324,10 @@ function lib.setVehicleProperties(vehicle, props, fixVehicle)
SetVehicleNumberPlateTextIndex(vehicle, props.plateIndex)
end
if props.lockState ~= nil then
SetVehicleDoorsLocked(vehicle, props.lockState)
end
if props.bodyHealth then
SetVehicleBodyHealth(vehicle, props.bodyHealth + 0.0)
end

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { SkillCheckProps } from '../../typings';
import { useInterval } from '@mantine/hooks';
interface Props {
angle: number;
@@ -11,15 +10,58 @@ interface Props {
handleComplete: (success: boolean) => void;
}
const Indicator: React.FC<Props> = ({ angle, offset, multiplier, handleComplete, skillCheck, className }) => {
const BASE_DURATION_MS = 2000;
const Indicator: React.FC<Props> = ({
angle,
offset,
multiplier,
handleComplete,
skillCheck,
className,
}) => {
const [indicatorAngle, setIndicatorAngle] = useState(-90);
const [keyPressed, setKeyPressed] = useState<false | string>(false);
const interval = useInterval(
() =>
setIndicatorAngle((prevState) => {
return (prevState += multiplier);
}),
1
const rafIdRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const completedRef = useRef(false);
const stopAnimation = () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
const animate = useCallback(
(time: number) => {
if (completedRef.current) return;
if (startTimeRef.current === null) {
startTimeRef.current = time;
}
const elapsed = time - startTimeRef.current;
const speed = Math.max(multiplier || 0, 0.0001);
const duration = BASE_DURATION_MS / speed;
const progress = Math.min(elapsed / duration, 1);
const newAngle = -90 + progress * 360;
setIndicatorAngle(newAngle);
if (newAngle + 90 >= 360) {
completedRef.current = true;
stopAnimation();
handleComplete(false);
return;
}
rafIdRef.current = requestAnimationFrame(animate);
},
[multiplier, handleComplete]
);
const keyHandler = useCallback(
(e: KeyboardEvent) => {
@@ -42,32 +84,43 @@ const Indicator: React.FC<Props> = ({ angle, offset, multiplier, handleComplete,
useEffect(() => {
setIndicatorAngle(-90);
startTimeRef.current = null;
completedRef.current = false;
window.addEventListener('keydown', keyHandler);
interval.start();
}, [skillCheck]);
rafIdRef.current = requestAnimationFrame(animate);
return () => {
stopAnimation();
window.removeEventListener('keydown', keyHandler);
startTimeRef.current = null;
completedRef.current = true;
};
}, [skillCheck, keyHandler, animate]);
useEffect(() => {
if (indicatorAngle + 90 >= 360) {
interval.stop();
handleComplete(false);
}
}, [indicatorAngle]);
useEffect(() => {
if (!keyPressed) return;
if (!keyPressed || completedRef.current) return;
if (skillCheck.keys && !skillCheck.keys?.includes(keyPressed)) return;
interval.stop();
stopAnimation();
window.removeEventListener('keydown', keyHandler);
completedRef.current = true;
if (keyPressed !== skillCheck.key || indicatorAngle < angle || indicatorAngle > angle + offset)
handleComplete(false);
else handleComplete(true);
setKeyPressed(false);
}, [keyPressed]);
}, [
keyPressed,
angle,
offset,
indicatorAngle,
skillCheck,
keyHandler,
handleComplete,
]);
return <circle transform={`rotate(${indicatorAngle}, 250, 250)`} className={className} />;
};