-- ============================================================================ -- M-Addon :: client core -- Rendering only. The client never decides what a prop is worth — it draws -- what the server registry says exists. -- ============================================================================ ESX = exports['es_extended']:getSharedObject() -- Loads locales/.json and creates the global `locale()` function. -- This must run before any other client file calls locale(), which is why it -- sits at the top of the first file in the manifest's client_scripts list. -- Passing nil makes ox_lib fall back to the `ox_locale` convar. lib.locale(Config.Locale or nil) Registry = {} -- [resourceIndex][propIndex] = { coords, heading, model, rare, state } ResourceState = {} -- [resourceIndex] = { depletedUntil, available } local points = {} -- [key] = lib.points object (only for the active job) local objects = {} -- [key] = entity handle local rareBlips = {} -- [key] = blip attached to a rare node local activeBlips = {} -- every blip this resource created for the active job local booted = false -- The one resource the player is currently working, or nil. Props exist only -- while this is set, and only for this resource. ActiveJob = nil local leashRunning = false -- -------------------------------------------------------------------------- -- NOTIFICATIONS -- Single funnel, so every user-facing string goes through the locale system. -- -------------------------------------------------------------------------- function Notify(key, args, level) lib.notify({ title = 'M-Addon', description = (args and #args > 0) and locale(key, table.unpack(args)) or locale(key), type = level or 'inform', position = 'top-right' }) end RegisterNetEvent('M-Addon:notify', function(key, args, level) Notify(key, args, level) end) -- -------------------------------------------------------------------------- -- MODEL VALIDATION -- Only the client can check a model, so bad props are reported here once. -- -------------------------------------------------------------------------- local warned = {} local function requestModel(model) local hash = joaat(model) if not IsModelValid(hash) then if not warned[model] then warned[model] = true print(('[M-Addon] CONFIG ERROR: prop model "%s" does not exist.'):format(model)) end return nil end if not HasModelLoaded(hash) then RequestModel(hash) local timeout = GetGameTimer() + 5000 while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(10) end end return HasModelLoaded(hash) and hash or nil end -- -------------------------------------------------------------------------- -- OBJECT LIFECYCLE -- -------------------------------------------------------------------------- local function attachRareEffect(entity, key) local rare = Config.Defaults.rare lib.requestNamedPtfxAsset(rare.particle.dict) UseParticleFxAsset(rare.particle.dict) StartParticleFxLoopedOnEntity(rare.particle.name, entity, 0.0, 0.0, 0.3, 0.0, 0.0, 0.0, 0.6, false, false, false) local blip = AddBlipForEntity(entity) SetBlipSprite(blip, rare.blip.sprite) SetBlipColour(blip, rare.blip.color) SetBlipScale(blip, rare.blip.scale) SetBlipAsShortRange(blip, true) rareBlips[key] = blip end local function deleteObject(key, withEffect) local entity = objects[key] if entity and DoesEntityExist(entity) then if withEffect then local coords = GetEntityCoords(entity) -- The rock burst used to fire for every resource, which looked -- wrong on bin bags and tyres. It is now opt-in per resource via -- `destroyEffect`, and off by default — only the sound remains. local resourceIndex = tonumber(key:match('^(%d+):')) local effect = resourceIndex and Resources[resourceIndex] and Resources[resourceIndex].destroyEffect if effect then lib.requestNamedPtfxAsset(effect.dict) UseParticleFxAsset(effect.dict) StartParticleFxNonLoopedAtCoord(effect.name, coords.x + effect.offset.x, coords.y + effect.offset.y, coords.z + effect.offset.z, 0.0, 0.0, 0.0, effect.scale, false, false, false) end PlaySoundFromCoord(-1, 'Pickup_Ammo', coords.x, coords.y, coords.z, 'HUD_FRONTEND_CUSTOM_SOUNDSET', false, 0, false) end exports.ox_target:removeLocalEntity(entity) DeleteObject(entity) end objects[key] = nil if rareBlips[key] then RemoveBlip(rareBlips[key]) rareBlips[key] = nil end end ---Drops a prop onto the terrain once the ground under it actually exists. --- ---This is the fix for floating props: points fire at RenderDistance (100m), ---where the map collision is usually not streamed yet. GetGroundZFor_3dCoord ---then fails and PlaceObjectOnGroundProperly silently does nothing, leaving ---the prop hanging at whatever Z the config guessed. So we request collision, ---wait for it, and only then snap — and only report the height to the server ---once we know it came from real terrain. local function snapToGround(entity, resourceIndex, propIndex, prop) CreateThread(function() local coords = prop.coords RequestCollisionAtCoord(coords.x, coords.y, coords.z) local deadline = GetGameTimer() + 10000 while GetGameTimer() < deadline do if not DoesEntityExist(entity) then return end if HasCollisionLoadedAroundEntity(entity) then local found, groundZ = GetGroundZFor_3dCoord(coords.x, coords.y, coords.z + 50.0, false) if found then FreezeEntityPosition(entity, false) SetEntityCoordsNoOffset(entity, coords.x, coords.y, groundZ, false, false, false) PlaceObjectOnGroundProperly(entity) FreezeEntityPosition(entity, true) if not prop.zResolved then TriggerServerEvent('M-Addon:reportGroundZ', resourceIndex, propIndex, groundZ) end return end end RequestCollisionAtCoord(coords.x, coords.y, coords.z) Wait(100) end -- Terrain never streamed in (player kept their distance). Leave the -- prop frozen where it is rather than dropping it through the world. if DoesEntityExist(entity) then FreezeEntityPosition(entity, true) end end) end local function createObject(resourceIndex, propIndex) local prop = Registry[resourceIndex] and Registry[resourceIndex][propIndex] if not prop or prop.state ~= 'available' then return end local key = Utils.PropKey(resourceIndex, propIndex) if objects[key] and DoesEntityExist(objects[key]) then return end local hash = requestModel(prop.model) if not hash then return end local coords = prop.coords -- Not networked on purpose: every client builds its own copy from the same -- registry, which costs the server nothing in entity ownership traffic. local entity = CreateObject(hash, coords.x, coords.y, coords.z, false, false, false) SetEntityHeading(entity, prop.heading) FreezeEntityPosition(entity, true) SetEntityCollision(entity, true, true) SetModelAsNoLongerNeeded(hash) objects[key] = entity snapToGround(entity, resourceIndex, propIndex, prop) if prop.rare then attachRareEffect(entity, key) end exports.ox_target:addLocalEntity(entity, { { name = 'maddon_harvest_' .. key, icon = prop.rare and 'fa-solid fa-gem' or Resources[resourceIndex].icon, label = locale('target_harvest', Resources[resourceIndex].label), distance = 2.0, onSelect = function() StartHarvest(resourceIndex, propIndex) end } }) end ---Used by client/harvest.lua to hang the working effect on the right prop. function GetPropEntity(resourceIndex, propIndex) return objects[Utils.PropKey(resourceIndex, propIndex)] end -- -------------------------------------------------------------------------- -- POINTS -- lib.points replaces the classic `while true do ... Wait(0)` distance loop: -- onEnter/onExit only fire when the player crosses the radius. -- -------------------------------------------------------------------------- local function createPoint(resourceIndex, propIndex) local prop = Registry[resourceIndex][propIndex] local key = Utils.PropKey(resourceIndex, propIndex) if points[key] then points[key]:remove() points[key] = nil end points[key] = lib.points.new({ coords = prop.coords, distance = Resources[resourceIndex].renderDistance, resourceIndex = resourceIndex, propIndex = propIndex, onEnter = function(self) createObject(self.resourceIndex, self.propIndex) end, onExit = function(self) deleteObject(Utils.PropKey(self.resourceIndex, self.propIndex), false) end }) end local function clearAll() for key, point in pairs(points) do point:remove() points[key] = nil end for key in pairs(objects) do deleteObject(key, false) end end -- -------------------------------------------------------------------------- -- JOB BLIPS -- Created when a job starts, destroyed when it ends. Everything lives in one -- table so a single clear() can never miss one and leave a ghost on the map. -- -------------------------------------------------------------------------- local function clearBlips() for i = #activeBlips, 1, -1 do if DoesBlipExist(activeBlips[i]) then RemoveBlip(activeBlips[i]) end activeBlips[i] = nil end end local function createJobBlip(resource) if not resource.blip then return end -- Always clear first: starting a second job must never stack a new blip on -- top of the old one. clearBlips() local blip = AddBlipForCoord(resource.coords.x, resource.coords.y, resource.coords.z) SetBlipSprite(blip, resource.blip.sprite or 618) SetBlipColour(blip, resource.blip.color or resource.blip.colour or 5) SetBlipScale(blip, resource.blip.scale or 0.8) SetBlipAsShortRange(blip, false) BeginTextCommandSetBlipName('STRING') AddTextComponentSubstringPlayerName(resource.label) -- Slovenian, from config EndTextCommandSetBlipName(blip) activeBlips[#activeBlips + 1] = blip end -- -------------------------------------------------------------------------- -- SYNC -- -------------------------------------------------------------------------- local function syncRegistry() local snapshot = lib.callback.await('M-Addon:getRegistry', false) if not snapshot then return print('[M-Addon] ERROR: could not fetch the prop registry from the server.') end clearAll() Registry = {} for resourceIndex, props in pairs(snapshot) do if Resources[resourceIndex] then Registry[resourceIndex] = {} for propIndex, prop in pairs(props) do Registry[resourceIndex][propIndex] = { coords = vec3(prop.coords.x, prop.coords.y, prop.coords.z), heading = prop.heading, model = prop.model, rare = prop.rare, state = prop.state, zResolved = prop.zResolved } end end end booted = true -- Re-arm the props for whatever job is running (e.g. after /maddon reload). if ActiveJob then for propIndex in pairs(Registry[ActiveJob] or {}) do createPoint(ActiveJob, propIndex) end end end RegisterNetEvent('M-Addon:fullRefresh', function() syncRegistry() end) RegisterNetEvent('M-Addon:propState', function(resourceIndex, propIndex, state, data) if not booted or not Resources[resourceIndex] then return end Registry[resourceIndex] = Registry[resourceIndex] or {} local prop = Registry[resourceIndex][propIndex] or {} prop.coords = vec3(data.coords.x, data.coords.y, data.coords.z) prop.heading, prop.model, prop.rare = data.heading, data.model, data.rare prop.state, prop.zResolved = state, true Registry[resourceIndex][propIndex] = prop -- Registry stays in sync for every resource, but only the job the player -- clocked into is ever rendered. if resourceIndex ~= ActiveJob then return end if state == 'available' then createPoint(resourceIndex, propIndex) if #(GetEntityCoords(cache.ped) - prop.coords) <= Resources[resourceIndex].renderDistance then createObject(resourceIndex, propIndex) end elseif state == 'harvested' then deleteObject(Utils.PropKey(resourceIndex, propIndex), true) end -- 'locked' keeps the object visible; the server rejects anyone but the owner. end) RegisterNetEvent('M-Addon:resourceState', function(resourceIndex, state) ResourceState[resourceIndex] = state end) -- -------------------------------------------------------------------------- -- JOB MANAGER -- Props are created on StartJob and torn down on StopJob. Every exit path — -- manual stop, switching jobs, death, disconnect, resource stop, wandering -- off — funnels through StopJob, so there is exactly one cleanup routine. -- -------------------------------------------------------------------------- ---Ends the current job. `key` is an optional locale string to notify with. function StopJob(key, silent) if not ActiveJob then if not silent and key then Notify(key, nil, 'inform') end return false end ActiveJob = nil clearAll() -- points + objects + rare blips clearBlips() -- job blip HideHud() TriggerServerEvent('M-Addon:stopJob') if not silent then Notify(key or 'notify_job_stopped', nil, 'inform') end return true end ---Watches the player while a job runs. Only alive while working, so the ---resource still idles at 0.00ms when nobody is clocked in. --- ---The leash only arms once the player has actually reached the work area. Jobs ---are started at the NPC, which can be kilometres from the site, so measuring ---from the moment of clock-in would cancel the job instantly. local function startLeash() if leashRunning then return end leashRunning = true CreateThread(function() local arrived = false while ActiveJob do local resource = Resources[ActiveJob] if not resource then StopJob('notify_job_stopped') break end if IsEntityDead(cache.ped) then StopJob('notify_job_stopped') break end local distance = #(GetEntityCoords(cache.ped) - resource.coords) if not arrived then -- "Arrived" means close enough that props are streaming in. if distance <= resource.radius + resource.renderDistance then arrived = true end elseif distance > Config.AutoStopDistance then StopJob('notify_auto_stopped') break end Wait(2000) end leashRunning = false end) end ---Starts (or switches to) a job. The server validates level, job and depletion ---before anything is rendered. function StartJob(resourceIndex) local resource = Resources[resourceIndex] if not resource then return end if ActiveJob == resourceIndex then return end local ok, reason, args, data = lib.callback.await('M-Addon:startJob', false, resourceIndex) if not ok then return Notify(reason or 'notify_gone', args, 'error') end -- Tear the previous job down first; the server already switched us over. if ActiveJob then ActiveJob = nil clearAll() clearBlips() end ActiveJob = resourceIndex for propIndex in pairs(Registry[resourceIndex] or {}) do createPoint(resourceIndex, propIndex) end createJobBlip(resource) startLeash() -- The HUD's numbers all come from the server; the client only displays. ShowHud(lib.callback.await('M-Addon:getHud', false)) if data and data.switched then Notify('notify_job_switched', nil, 'inform') end Notify('notify_job_started', { resource.label }, 'success') end -- Fallback for players who lose the tablet, get stuck, or just prefer commands. RegisterCommand('konecdela', function() if not StopJob() then Notify('notify_no_active_job', nil, 'error') end end, false) -- Dying ends the job: the props would otherwise stay rendered through respawn. AddEventHandler('esx:onPlayerDeath', function() StopJob(nil, true) end) -- -------------------------------------------------------------------------- -- POLICE BLIP -- -------------------------------------------------------------------------- RegisterNetEvent('M-Addon:policeBlip', function(coords, label) local blip = AddBlipForCoord(coords.x, coords.y, coords.z) SetBlipSprite(blip, Config.Police.blip.sprite) SetBlipColour(blip, Config.Police.blip.color) SetBlipScale(blip, Config.Police.blip.scale) BeginTextCommandSetBlipName('STRING') AddTextComponentSubstringPlayerName(label) EndTextCommandSetBlipName(blip) SetTimeout(Config.Police.blip.duration * 1000, function() if DoesBlipExist(blip) then RemoveBlip(blip) end end) end) -- -------------------------------------------------------------------------- -- ADMIN: print a paste-ready config block from the player's position -- -------------------------------------------------------------------------- RegisterNetEvent('M-Addon:printConfig', function() local coords = GetEntityCoords(cache.ped) print(([[ { id = 'nov_vir', label = 'Nov vir', category = 'Rude', icon = 'fa-solid fa-cube', coords = vec3(%.2f, %.2f, %.2f), radius = 50.0, amount = 15, props = { 'prop_rock_4_a' }, tool = 'pickaxe', reward = 'zelezo', duration = 6000, respawnTime = 120 }, ]]):format(coords.x, coords.y, coords.z)) Notify('admin_config_printed', nil, 'success') end) -- -------------------------------------------------------------------------- -- DEBUG MARKERS (only ever runs with Config.Debug = true) -- -------------------------------------------------------------------------- if Config.Debug then CreateThread(function() while true do local sleep = 1000 local coords = GetEntityCoords(cache.ped) for _, resource in ipairs(Resources) do if #(coords - resource.coords) < 150.0 then sleep = 0 DrawMarker(1, resource.coords.x, resource.coords.y, resource.coords.z - 1.0, 0, 0, 0, 0, 0, 0, resource.radius * 2, resource.radius * 2, 2.0, 0, 150, 255, 60, false, false, 0, false) end end Wait(sleep) end end) end -- -------------------------------------------------------------------------- -- BOOT / SHUTDOWN -- -------------------------------------------------------------------------- -- Shared with M-Minigames so both resources use the same accent colour. exports('GetAccent', function() return Config.Accent end) AddEventHandler('onClientResourceStart', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end -- Optional dependency: everything still works without it, minus minigames. if GetResourceState('M-Minigames') == 'started' then SetMinigamesReady(true) else print('[M-Addon] NOTICE: M-Minigames is not started — harvesting will run without minigames.') end for _, dep in ipairs({ 'ox_lib', 'ox_target', 'ox_inventory' }) do if GetResourceState(dep) ~= 'started' then print(('[M-Addon] FATAL: dependency "%s" is not started.'):format(dep)) Notify('notify_dependency_missing', nil, 'error') end end -- ESX may still be loading player data on a fresh connect. while not ESX.IsPlayerLoaded() do Wait(500) end syncRegistry() end) AddEventHandler('onResourceStop', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end ActiveJob = nil clearAll() clearBlips() SetNuiFocus(false, false) end)