diff --git a/client/modules/raycast.lua b/client/modules/raycast.lua new file mode 100644 index 0000000..eaecd0b --- /dev/null +++ b/client/modules/raycast.lua @@ -0,0 +1,166 @@ +local vector3 = vector3 +local abs = math.abs +local cos = math.cos +local sin = math.sin +local rad = math.rad + +---@class RAYCAST_RESULT +---@field public hit boolean +---@field public state integer +---@field public handle integer +---@field public didHit integer +---@field public coords vector3 +---@field public normal vector3 +---@field public entity integer +---@field public material integer? + +---@class RAYCAST +local Raycast = {} + +Raycast.Flags = { + World = 1, + Vehicles = 2, + Peds = 4, + Ragdolls = 8, + Objects = 16, + Pickups = 32, + Glass = 64, + Rivers = 128, + Foliage = 256, + All = 511 +} + +local function toVector3(value, label) + if value == nil then + error(("raycast: %s is required"):format(label), 3) + end + + local valueType = type(value) + if valueType == "vector3" then + return value + end + + if valueType ~= "table" then + error(("raycast: %s must be a vector3 or table, received %s"):format(label, valueType), 3) + end + + local x = value.x or value[1] + local y = value.y or value[2] + local z = value.z or value[3] + + if x == nil or y == nil or z == nil then + error(("raycast: %s requires x, y and z values"):format(label), 3) + end + + return vector3(x + 0.0, y + 0.0, z + 0.0) +end + +local function rotationToDirection(rotation) + local pitch = rad(rotation.x) + local yaw = rad(rotation.z) + local cosPitch = abs(cos(pitch)) + + return vector3(-sin(yaw) * cosPitch, cos(yaw) * cosPitch, sin(pitch)) +end + +local function buildResult(state, handle, didHit, hitCoords, surfaceNormal, entityHit, materialHash) + return { + state = state, + handle = handle, + didHit = didHit, + hit = didHit == 1, + coords = hitCoords, + normal = surfaceNormal, + entity = entityHit, + material = materialHash + } +end + +local function getShapeTestResult(handle) + if GetShapeTestResultIncludingMaterial then + local state, didHit, hitCoords, surfaceNormal, materialHash, entityHit = GetShapeTestResultIncludingMaterial(handle) + return buildResult(state, handle, didHit, hitCoords, surfaceNormal, entityHit, materialHash) + end + + local state, didHit, hitCoords, surfaceNormal, entityHit = GetShapeTestResult(handle) + return buildResult(state, handle, didHit, hitCoords, surfaceNormal, entityHit) +end + +---@param startCoords vector3 | {x:number, y:number, z:number} +---@param endCoords vector3 | {x:number, y:number, z:number} +---@param flags integer? +---@param ignoreEntity integer? +---@param options {traceType?: integer, timeout?: integer, wait?: integer}? +---@return RAYCAST_RESULT +function Raycast.Cast(startCoords, endCoords, flags, ignoreEntity, options) + local start = toVector3(startCoords, "startCoords") + local finish = toVector3(endCoords, "endCoords") + local mask = flags or Raycast.Flags.All + local target = ignoreEntity or PlayerPedId() + local traceType = options?.traceType or 7 + local timeout = options?.timeout or 1000 + local delay = options?.wait or 0 + + local handle = StartShapeTestLosProbe( + start.x, start.y, start.z, + finish.x, finish.y, finish.z, + mask, + target, + traceType + ) + + local startTime = GetGameTimer() + local result = getShapeTestResult(handle) + + while result.state == 1 do + if (GetGameTimer() - startTime) > timeout then + break + end + + Wait(delay) + result = getShapeTestResult(handle) + end + + return result +end + +---@param distance number? +---@param flags integer? +---@param ignoreEntity integer? +---@param options {offset?: vector3|{x:number,y:number,z:number}, traceType?: integer, timeout?: integer, wait?: integer}? +---@return RAYCAST_RESULT +function Raycast.FromCamera(distance, flags, ignoreEntity, options) + local camCoords = GetGameplayCamCoord() + local camRotation = GetGameplayCamRot(2) + local direction = rotationToDirection(camRotation) + local origin = options?.offset and (camCoords + toVector3(options.offset, "options.offset")) or camCoords + local rayDistance = distance or 10.0 + local destination = origin + (direction * rayDistance) + + return Raycast.Cast(origin, destination, flags, ignoreEntity, options) +end + +---@param entity integer +---@param distance number? +---@param flags integer? +---@param ignoreEntity integer? +---@param options {offset?: vector3|{x:number,y:number,z:number}, traceType?: integer, timeout?: integer, wait?: integer}? +---@return RAYCAST_RESULT +function Raycast.FromEntity(entity, distance, flags, ignoreEntity, options) + if not entity or not DoesEntityExist(entity) then + error("raycast: entity does not exist", 2) + end + + local origin = GetEntityCoords(entity) + local offset = options?.offset and toVector3(options.offset, "options.offset") or vector3(0.0, 0.0, 0.0) + local direction = GetEntityForwardVector(entity) + local rayDistance = distance or 10.0 + local start = origin + offset + local destination = start + (direction * rayDistance) + + return Raycast.Cast(start, destination, flags, ignoreEntity or entity, options) +end + +return { + Raycast = Raycast +} diff --git a/import.lua b/import.lua index 59809de..6ac9fac 100644 --- a/import.lua +++ b/import.lua @@ -38,6 +38,7 @@ local content = { class = true, functions = true, notify = true, + logger = true, }, } diff --git a/shared/logger.lua b/shared/logger.lua new file mode 100644 index 0000000..503c472 --- /dev/null +++ b/shared/logger.lua @@ -0,0 +1,187 @@ +---@class LOGGER_CONTEXT: table + +---@class LOGGER_OPTIONS +---@field public resource string? +---@field public prefix string? +---@field public debug boolean? +---@field public colorize boolean? + +---@class LOGGER +local Logger = {} + +local DEBUG_ENABLED = false + +local LEVELS = { + INFO = { label = "INFO", color = "^2" }, + WARN = { label = "WARN", color = "^3" }, + ERROR = { label = "ERROR", color = "^1" }, + DEBUG = { label = "DEBUG", color = "^4" } +} + +local function getResourceName() + local invoking = GetInvokingResource and GetInvokingResource() or nil + if invoking and invoking ~= "" then + return invoking + end + + return GetCurrentResourceName() +end + +local function padTime(value) + return ("%02d"):format(value) +end + +local function getServerTime() + return os.date("%H:%M:%S") +end + +local function getClientTime() + local totalSeconds = math.floor(GetGameTimer() / 1000) + local hours = math.floor(totalSeconds / 3600) % 24 + local minutes = math.floor((totalSeconds % 3600) / 60) + local seconds = totalSeconds % 60 + + return ("%s:%s:%s"):format(padTime(hours), padTime(minutes), padTime(seconds)) +end + +local function getTime() + if IsDuplicityVersion() then + return getServerTime() + end + + return getClientTime() +end + +local function encodeValue(value) + local valueType = type(value) + if valueType == "string" then + return value + end + + if valueType == "number" or valueType == "boolean" then + return tostring(value) + end + + if value == nil then + return "nil" + end + + if valueType == "table" and json and json.encode then + local ok, encoded = pcall(json.encode, value) + if ok and encoded then + return encoded + end + end + + return tostring(value) +end + +local function buildContext(context) + if context == nil then + return nil + end + + if type(context) ~= "table" then + return tostring(context) + end + + local parts = {} + for key, value in pairs(context) do + parts[#parts + 1] = ("%s=%s"):format(tostring(key), encodeValue(value)) + end + + table.sort(parts) + + if #parts == 0 then + return nil + end + + return table.concat(parts, " ") +end + +local function applyColor(enabled, color, text) + if enabled == false then + return text + end + + return ("%s%s^7"):format(color, text) +end + +local function normalizeLevel(level) + local key = tostring(level or "INFO"):upper() + return LEVELS[key] and key or "INFO" +end + +---@param level string +---@param message any +---@param context LOGGER_CONTEXT? +---@param options LOGGER_OPTIONS? +function Logger.Log(level, message, context, options) + local normalizedLevel = normalizeLevel(level) + local metadata = LEVELS[normalizedLevel] + local shouldForceDebug = options?.debug == true + + if normalizedLevel == "DEBUG" and not DEBUG_ENABLED and not shouldForceDebug then + return + end + + local colorize = options?.colorize ~= false + local resourceName = options?.resource or getResourceName() + local timestamp = getTime() + local prefix = options?.prefix and ("[%s] "):format(options.prefix) or "" + local contextString = buildContext(context) + + local resourcePart = applyColor(colorize, "^6", ("[%s]"):format(resourceName)) + local timePart = applyColor(colorize, "^5", ("[%s]"):format(timestamp)) + local levelPart = applyColor(colorize, metadata.color, ("[%s]"):format(metadata.label)) + local body = ("%s%s"):format(prefix, tostring(message)) + + local line = ("%s %s %s %s"):format(resourcePart, timePart, levelPart, body) + if contextString then + line = ("%s | %s"):format(line, contextString) + end + + print(line) +end + +---@param message any +---@param context LOGGER_CONTEXT? +---@param options LOGGER_OPTIONS? +function Logger.Info(message, context, options) + Logger.Log("INFO", message, context, options) +end + +---@param message any +---@param context LOGGER_CONTEXT? +---@param options LOGGER_OPTIONS? +function Logger.Warn(message, context, options) + Logger.Log("WARN", message, context, options) +end + +---@param message any +---@param context LOGGER_CONTEXT? +---@param options LOGGER_OPTIONS? +function Logger.Error(message, context, options) + Logger.Log("ERROR", message, context, options) +end + +---@param message any +---@param context LOGGER_CONTEXT? +---@param options LOGGER_OPTIONS? +function Logger.Debug(message, context, options) + Logger.Log("DEBUG", message, context, options) +end + +---@param enabled boolean +function Logger.SetDebugEnabled(enabled) + DEBUG_ENABLED = enabled == true +end + +---@return boolean +function Logger.GetDebugEnabled() + return DEBUG_ENABLED +end + +return { + Logger = Logger +} diff --git a/types/types.lua b/types/types.lua index d778749..8bd0452 100644 --- a/types/types.lua +++ b/types/types.lua @@ -58,6 +58,36 @@ ---@field public LoadClipSet fun(clipSet: string, timeout: number?) ---@field public LoadScene fun(pos: vector3 | {x: number, y: number, z: number}, offset: vector3 | {x: number, y: number, z: number}, radius: number, p7: integer) +---@class RAYCAST_RESULT +---@field public hit boolean +---@field public state integer +---@field public handle integer +---@field public didHit integer +---@field public coords vector3 +---@field public normal vector3 +---@field public entity integer +---@field public material integer? + +---@class RAYCAST +---@field public Flags {World: integer, Vehicles: integer, Peds: integer, Ragdolls: integer, Objects: integer, Pickups: integer, Glass: integer, Rivers: integer, Foliage: integer, All: integer} +---@field public Cast fun(startCoords: vector3 | {x: number, y: number, z: number}, endCoords: vector3 | {x: number, y: number, z: number}, flags: integer?, ignoreEntity: integer?, options: {traceType?: integer, timeout?: integer, wait?: integer}?): RAYCAST_RESULT +---@field public FromCamera fun(distance: number?, flags: integer?, ignoreEntity: integer?, options: {offset?: vector3 | {x: number, y: number, z: number}, traceType?: integer, timeout?: integer, wait?: integer}?): RAYCAST_RESULT +---@field public FromEntity fun(entity: integer, distance: number?, flags: integer?, ignoreEntity: integer?, options: {offset?: vector3 | {x: number, y: number, z: number}, traceType?: integer, timeout?: integer, wait?: integer}?): RAYCAST_RESULT + +---@class LOGGER_OPTIONS +---@field public resource string? +---@field public prefix string? +---@field public debug boolean? +---@field public colorize boolean? + +---@class LOGGER +---@field public Log fun(level: string, message: any, context: table?, options: LOGGER_OPTIONS?) +---@field public Info fun(message: any, context: table?, options: LOGGER_OPTIONS?) +---@field public Warn fun(message: any, context: table?, options: LOGGER_OPTIONS?) +---@field public Error fun(message: any, context: table?, options: LOGGER_OPTIONS?) +---@field public Debug fun(message: any, context: table?, options: LOGGER_OPTIONS?) +---@field public SetDebugEnabled fun(enabled: boolean) +---@field public GetDebugEnabled fun(): boolean ---@class MAP ---@field public New fun(self:MAP, handle:number):Blip