local config = require 'config.client' local shared = require 'config.shared' local currentGear = { mask = 0, tank = 0, enabled = false } --- Aktuell getragene Flasche. { slot, name, label, oxygen, capacity } --- oxygen sind Restsekunden, kein Prozentwert. local activeTank = nil --- Bereits ausgelöste Warnschwellen des laufenden Tauchgangs. local warned = {} local MASK_MODEL = `p_d_scuba_mask_s` local TANK_MODEL = `p_s_scuba_tank_s` local MASK_BONE = 12844 local TANK_BONE = 24818 --- @param seconds number --- @return string mm:ss local function formatTime(seconds) seconds = math.max(0, math.floor(seconds)) return ('%02d:%02d'):format(seconds // 60, seconds % 60) end --- Aktuelle Tauchtiefe in Metern. 0 wenn kein Wasser über dem Ped ist. local function getDepth() local coords = GetEntityCoords(cache.ped) local hasWater, waterZ = GetWaterHeight(coords.x, coords.y, coords.z) if not hasWater then return 0.0 end return math.max(0.0, waterZ - coords.z) end --- Verbrauchsfaktor. Tiefer tauchen kostet mehr Luft. local function getDecayFactor() if not config.depthDecayEnabled then return 1.0 end return math.min(config.maxDecayFactor, 1.0 + getDepth() / config.depthDecayReference) end --- @param ped number? default cache.ped - beim Model-Wechsel muss der neue Ped explizit rein local function enableScuba(ped) ped = ped or cache.ped SetEnableScuba(ped, true) SetPedMaxTimeUnderwater(ped, 2000.00) end --- @param outOfAir boolean? true wenn die Flasche leer ist statt die Ausrüstung abgelegt wurde --- @param ped number? default cache.ped local function disableScuba(outOfAir, ped) ped = ped or cache.ped SetEnableScuba(ped, false) SetPedMaxTimeUnderwater(ped, outOfAir and config.maxTimeUnderwaterOutOfAir or config.maxTimeUnderwater) end local function deleteGear() if currentGear.mask ~= 0 then DetachEntity(currentGear.mask, false, true) DeleteEntity(currentGear.mask) currentGear.mask = 0 end if currentGear.tank ~= 0 then DetachEntity(currentGear.tank, false, true) DeleteEntity(currentGear.tank) currentGear.tank = 0 end end --- Hängt ein Prop hart an einen Ped-Bone. --- useSoftPinning bleibt bewusst false: mit true darf die Physik-Engine die --- Attachment bei Ragdoll und harten Anim-Übergängen lösen, dann fliegt das Prop weg. local function attachProp(ped, model, bone, offset, rotation) lib.requestModel(model) local prop = CreateObject(model, 1.0, 1.0, 1.0, true, true, false) SetEntityCollision(prop, false, false) --- Disable collision so it doesn't block bullets AttachEntityToEntity(prop, ped, GetPedBoneIndex(ped, bone), offset.x, offset.y, offset.z, rotation.x, rotation.y, rotation.z, false, -- p9 false, -- useSoftPinning ("if set to false attached entity will not detach when fixed") false, -- collision false, -- isPed 2, -- rotationOrder true) -- syncRot SetModelAsNoLongerNeeded(model) return prop end --- @param ped number? default cache.ped local function attachGear(ped) ped = ped or cache.ped currentGear.tank = attachProp(ped, TANK_MODEL, TANK_BONE, vec3(-0.25, -0.25, 0.0), vec3(180.0, 90.0, 0.0)) currentGear.mask = attachProp(ped, MASK_MODEL, MASK_BONE, vec3(0.0, 0.0, 0.0), vec3(180.0, 90.0, 0.0)) end local function isPropIntact(prop) return prop ~= 0 and DoesEntityExist(prop) and GetEntityAttachedTo(prop) == cache.ped end local function isGearIntact() return isPropIntact(currentGear.mask) and isPropIntact(currentGear.tank) end --- Schreibt die Restluft in die Metadata der Flasche. Der Server clampt den Wert --- und gibt den akzeptierten Stand zurück, damit Client und Inventar nicht auseinanderlaufen. --- @param immediate boolean? true = Fire-and-Forget (Tod, Resource-Stop) local function syncTank(immediate) if not activeTank then return end if immediate then TriggerServerEvent('d4rk_divegear:server:syncTankNow', activeTank.slot, activeTank.oxygen) return end local accepted = lib.callback.await('d4rk_divegear:server:syncTank', false, activeTank.slot, activeTank.oxygen) if accepted and activeTank then activeTank.oxygen = accepted end end --- Gemeinsamer Teardown für Ablegen, leere Flasche und Tod. --- @param outOfAir boolean? --- @param immediateSync boolean? local function removeGear(outOfAir, immediateSync) syncTank(immediateSync) currentGear.enabled = false activeTank = nil warned = {} deleteGear() disableScuba(outOfAir) -- Stop breathing suit audio end --- Lässt den Spieler eine Flasche wählen. Bei nur einer Flasche entfällt das Menü. --- @param tanks table[] --- @return table? tank local function chooseTank(tanks) if #tanks == 1 then return tanks[1] end local selection = promise.new() local options = {} for i = 1, #tanks do local tank = tanks[i] options[i] = { title = tank.label, description = locale('info.tank_remaining', formatTime(tank.oxygen), math.floor(tank.oxygen / tank.capacity * 100)), icon = 'fa-solid fa-bottle-water', onSelect = function() selection:resolve(tank) end } end lib.registerContext({ id = 'd4rk_divegear_tanks', title = locale('menu.choose_tank'), options = options, onExit = function() selection:resolve(nil) end }) lib.showContext('d4rk_divegear_tanks') return Citizen.Await(selection) end lib.callback.register('d4rk_divegear:client:fillTank', function(tanks) if IsPedSwimmingUnderWater(cache.ped) then exports.qbx_core:Notify(locale('error.underwater'), 'error') return end local tank = chooseTank(tanks) if not tank then return end if tank.oxygen >= tank.capacity then exports.qbx_core:Notify(locale('error.tank_already_full'), 'error') return end if not lib.progressBar({ duration = config.refillTankTimeMs, label = locale('info.filling_air'), useWhileDead = false, canCancel = true, anim = { dict = 'clothingshirt', clip = 'try_shirt_positive_d', blendIn = 8.0 } }) then return end -- Erfolgsmeldung und das Nachziehen der getragenen Flasche macht der Server, -- sobald das Füll-Item tatsächlich entfernt wurde. return tank.slot end) --- Getragene Flasche nach dem Auffüllen mitziehen, sonst überschreibt der --- nächste Sync die frische Füllung wieder mit dem alten Client-Stand. RegisterNetEvent('d4rk_divegear:client:tankRefilled', function(slot, capacity) if not activeTank or activeTank.slot ~= slot then return end activeTank.oxygen = capacity warned = {} enableScuba() end) local function takeOffSuit() if lib.progressBar({ duration = config.takeOffSuitTimeMs, label = locale('info.pullout_suit'), useWhileDead = false, canCancel = true, anim = { dict = 'clothingshirt', clip = 'try_shirt_positive_d', blendIn = 8.0 } }) then removeGear() exports.qbx_core:Notify(locale('success.took_out')) end end local function startOxygenLevelDrawTextThread() CreateThread(function() while currentGear.enabled do if IsPedSwimmingUnderWater(cache.ped) and activeTank then qbx.drawText2d({ text = formatTime(activeTank.oxygen)..'⏱', coords = vec2(1.0, 1.42), scale = 0.45 }) end Wait(0) end end) end --- Zieht Luft über die tatsächlich vergangene Zeit ab statt über gezählte Ticks. --- Ein Wait(1000) läuft bei Last spürbar länger als eine Sekunde - über einen --- 20-Minuten-Tauchgang summiert sich das sichtbar auf. local function startOxygenLevelDecrementerThread() CreateThread(function() local lastTick = GetGameTimer() local sinceSync = 0.0 while currentGear.enabled do Wait(250) local now = GetGameTimer() local elapsed = (now - lastTick) / 1000 lastTick = now if not activeTank then goto continue end if IsPedSwimmingUnderWater(cache.ped) and activeTank.oxygen > 0 then activeTank.oxygen = math.max(0.0, activeTank.oxygen - elapsed * config.decayRate * getDecayFactor()) for i = 1, #config.warnAtSeconds do local threshold = config.warnAtSeconds[i] if activeTank.oxygen <= threshold and not warned[threshold] then warned[threshold] = true exports.qbx_core:Notify(locale('info.oxygen_low', formatTime(activeTank.oxygen)), 'warning') end end if activeTank.oxygen <= 0 then disableScuba(true) exports.qbx_core:Notify(locale('error.out_of_air'), 'error') syncTank() sinceSync = 0.0 -- Stop breathing suit audio goto continue end sinceSync = sinceSync + elapsed if sinceSync >= shared.syncIntervalSeconds then sinceSync = 0.0 syncTank() end end ::continue:: end end) end --- Hängt die Ausrüstung nach, wenn sie verloren gegangen ist, und stellt die --- Scuba-Flags wieder her. Nötig weil Fremd-Scripts (Emotes, Handcuffs, Death-Handler) --- attached Props aufräumen und SetEnableScuba/SetPedMaxTimeUnderwater bei Respawn --- und Fahrzeugausstieg vom Spiel zurückgesetzt werden. local function startGearWatchdogThread() CreateThread(function() while currentGear.enabled do if config.removeGearOnDeath and IsPedDeadOrDying(cache.ped, true) then removeGear(false, true) break end if not isGearIntact() then deleteGear() attachGear() end if activeTank and activeTank.oxygen > 0 then enableScuba() end Wait(config.gearWatchdogIntervalMs) end end) end local function putOnSuit() if IsPedSwimming(cache.ped) or cache.vehicle then exports.qbx_core:Notify(locale('error.not_standing_up'), 'error') return end local tanks = lib.callback.await('d4rk_divegear:server:getTanks', false) if not tanks or #tanks == 0 then exports.qbx_core:Notify(locale('error.need_otube'), 'error') return end local tank = chooseTank(tanks) if not tank then return end if tank.oxygen <= 0 then exports.qbx_core:Notify(locale('error.tank_empty'), 'error') return end if lib.progressBar({ duration = config.putOnSuitTimeMs, label = locale('info.put_suit'), useWhileDead = false, canCancel = true, anim = { dict = 'clothingshirt', clip = 'try_shirt_positive_d', blendIn = 8.0 } }) then activeTank = tank warned = {} deleteGear() attachGear() enableScuba() currentGear.enabled = true -- Initiate breathing suit audio startOxygenLevelDecrementerThread() startOxygenLevelDrawTextThread() startGearWatchdogThread() end end RegisterNetEvent('d4rk_divegear:client:useGear', function() if currentGear.enabled then takeOffSuit() else putOnSuit() end end) --- Beim Model-Wechsel (Kleiderladen, Skin-Change) ist der Ped ein anderer - --- alle attached Props sind weg und die Scuba-Flags gelten für den alten Ped. --- --- Der neue Ped kommt zwingend aus dem Callback-Parameter: ox_lib feuert die --- onCache-Callbacks über Citizen.CreateThreadNow, *bevor* der neue Wert in den --- Cache geschrieben wird (ox_lib/init.lua). cache.ped zeigt hier also noch auf --- den alten, bereits verschwundenen Ped. lib.onCache('ped', function(ped) if not currentGear.enabled then return end deleteGear() attachGear(ped) if activeTank and activeTank.oxygen > 0 then enableScuba(ped) end end) AddEventHandler('onResourceStop', function(resource) if resource ~= GetCurrentResourceName() then return end syncTank(true) deleteGear() SetEnableScuba(cache.ped, false) SetPedMaxTimeUnderwater(cache.ped, config.maxTimeUnderwater) end)