-- ============================================================================ -- M-Addon :: harvesting flow -- Order: pre-check -> reserve -> minigame -> anim -> progress -> server roll. -- The client only presents; the server decides everything real. -- ============================================================================ local busy = false local heldProp = nil local workFx = nil -- M-Minigames is optional. Resolved once at startup so the harvest path never -- pays for a resource-state lookup, and the server owner gets exactly one -- warning rather than a message per harvest. local minigamesReady = false function MinigamesReady() return minigamesReady end function SetMinigamesReady(state) minigamesReady = state == true end -- -------------------------------------------------------------------------- -- HAND PROP (pickaxe, jackhammer, drill) -- -------------------------------------------------------------------------- local function attachProp(config) if not config then return end local hash = joaat(config.model) if not IsModelValid(hash) then return print(('[M-Addon] ERROR: hand prop "%s" does not exist.'):format(config.model)) end lib.requestModel(hash, 5000) local coords = GetEntityCoords(cache.ped) local entity = CreateObject(hash, coords.x, coords.y, coords.z, true, true, false) AttachEntityToEntity(entity, cache.ped, GetPedBoneIndex(cache.ped, config.bone), config.offset.x, config.offset.y, config.offset.z, config.rotation.x, config.rotation.y, config.rotation.z, true, true, false, true, 1, true) SetModelAsNoLongerNeeded(hash) heldProp = entity end local function removeProp() if heldProp and DoesEntityExist(heldProp) then DetachEntity(heldProp, true, true) DeleteObject(heldProp) end heldProp = nil end -- -------------------------------------------------------------------------- -- WORKING EFFECT -- Looped particles on the node itself (welding sparks on the metals), so it -- reads as work being done rather than a player standing still. -- -------------------------------------------------------------------------- local function startEffect(resource, entity) if not resource.effect or not entity or not DoesEntityExist(entity) then return end lib.requestNamedPtfxAsset(resource.effect.dict) UseParticleFxAsset(resource.effect.dict) workFx = StartParticleFxLoopedOnEntity(resource.effect.name, entity, resource.effect.offset.x, resource.effect.offset.y, resource.effect.offset.z, 0.0, 0.0, 0.0, resource.effect.scale, false, false, false) end local function stopEffect() if workFx then StopParticleFxLooped(workFx, false) workFx = nil end end ---Everything that must be undone whether the harvest succeeded, failed or was ---cancelled. Called from exactly one place, so nothing can leak. local function cleanup() stopEffect() removeProp() ClearPedTasks(cache.ped) busy = false end -- -------------------------------------------------------------------------- -- CLIENT PRE-CHECKS -- Cheap and local, purely to save the player a pointless animation. The -- server repeats all of them, and owns the tool check outright. -- -------------------------------------------------------------------------- local function preCheck(resource) if resource.tool then local count = exports.ox_inventory:Search('count', resource.tool) if not count or count <= 0 then local item = exports.ox_inventory:Items(resource.tool) Notify('notify_no_tool', { item and item.label or resource.tool }, 'error') return false end end -- Weight awareness: the tools are heavy, so a full inventory is a real -- possibility. Refuse before the animation instead of after it. local weight = exports.ox_inventory:GetPlayerWeight() local maxWeight = exports.ox_inventory:GetPlayerMaxWeight() if weight and maxWeight and weight >= maxWeight then Notify('notify_inventory_full', nil, 'error') return false end return true end -- -------------------------------------------------------------------------- -- MAIN ENTRY (called from the ox_target option in client/main.lua) -- -------------------------------------------------------------------------- function StartHarvest(resourceIndex, propIndex) if busy then return end local resource = Resources[resourceIndex] local prop = Registry[resourceIndex] and Registry[resourceIndex][propIndex] if not resource or not prop then return end if not preCheck(resource) then return end busy = true -- ---- 1. reserve the prop server-side -------------------------------- local ok, reason, args, data = lib.callback.await('M-Addon:beginHarvest', false, resourceIndex, propIndex) if not ok then busy = false return Notify(reason or 'notify_gone', args, 'error') end -- ---- 2. minigame ----------------------------------------------------- -- Run BEFORE the animation: failing here wastes nothing but a moment, and -- the node is released straight away for whoever is next. local perfect, token = false, nil if resource.minigames and MinigamesReady() then local pick = resource.minigames[math.random(#resource.minigames)] local result = exports['M-Minigames']:StartVerified(pick, { difficulty = resource.minigameDifficulty, label = resource.label, icon = resource.rewards[1] and resource.rewards[1].item or nil, canCancel = true }) if not result or not result.success then TriggerServerEvent('M-Addon:cancelHarvest', resourceIndex, propIndex) cleanup() return Notify('notify_minigame_failed', nil, 'error') end perfect = result.perfect == true token = result.token end -- ---- 3. face the prop, take out the tool ----------------------------- TaskTurnPedToFaceCoord(cache.ped, prop.coords.x, prop.coords.y, prop.coords.z, 1000) Wait(400) attachProp(resource.propAttach) startEffect(resource, GetPropEntity(resourceIndex, propIndex)) -- Preloaded here so lib.progressCircle can start the clip instantly; -- progressCircle owns the animation and clears it on cancel. lib.requestAnimDict(resource.anim.dict, 5000) -- ---- 4. progress bar ------------------------------------------------- local success = lib.progressCircle({ label = locale('progress_harvesting', resource.label), duration = data.duration, position = 'bottom', useWhileDead = false, canCancel = true, disable = { move = true, car = true, combat = true, mouse = false }, anim = { dict = resource.anim.dict, clip = resource.anim.clip, flag = resource.anim.flag } }) if not success then TriggerServerEvent('M-Addon:cancelHarvest', resourceIndex, propIndex) cleanup() return Notify('notify_cancelled', nil, 'inform') end -- The player may have been dragged away mid-animation. if #(GetEntityCoords(cache.ped) - prop.coords) > Config.MaxHarvestDistance then TriggerServerEvent('M-Addon:cancelHarvest', resourceIndex, propIndex) cleanup() return Notify('notify_too_far', nil, 'error') end -- ---- 5. server rolls and grants -------------------------------------- local done, failReason, failArgs, result = lib.callback.await('M-Addon:finishHarvest', false, resourceIndex, propIndex, perfect, token) cleanup() if not done then return Notify(failReason or 'notify_gone', failArgs, 'error') end -- ---- 6. feedback ----------------------------------------------------- if result.rare then Notify('notify_rare_node', nil, 'success') end if result.perfectBonus then Notify('notify_perfect', { result.perfectBonus }, 'success') end if #result.rewards == 0 then Notify('notify_success_empty', nil, 'inform') else for _, reward in ipairs(result.rewards) do local item = exports.ox_inventory:Items(reward.item) Notify('notify_success', { reward.count, item and item.label or reward.item }, 'success') end end if result.toolLost then local item = exports.ox_inventory:Items(resource.tool) Notify('notify_tool_lost', { item and item.label or resource.tool }, 'warning') end UpdateHud(result.hud) -- Completing a goal only marks it claimable; the reward is collected from -- the tablet, so this toast announces progress rather than a payout. if result.quests then for _, quest in ipairs(result.quests) do ShowToast({ kind = 'quest', title = locale('quest_completed_title'), name = quest.label }) end end if result.levelUp then ShowToast({ kind = 'level', title = locale('levelup_title'), name = tostring(result.levelUp) }) end end AddEventHandler('onResourceStop', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end stopEffect() removeProp() end)