local config = require 'config.client' local shared = require 'config.shared' local currentGear = { mask = 0, tank = 0, lamp = 0, enabled = false } --- Lampe an? Der eigentliche Lichtkegel wird in client/light.lua gezeichnet, --- gesteuert über den replizierten Statebag. local lampOn = 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 = {} --- Geglättete Tauchtiefe in Metern. Wird vom HUD-Thread gepflegt und sowohl für --- die Anzeige als auch für den tiefenabhängigen Verbrauch benutzt. local currentDepth = 0.0 --- Grenzen für die Farbstufen im HUD, aus config.warnAtSeconds abgeleitet. local warnThreshold, dangerThreshold = 0, 0 for i = 1, #config.warnAtSeconds do local threshold = config.warnAtSeconds[i] if threshold > warnThreshold then warnThreshold = threshold end if dangerThreshold == 0 or threshold < dangerThreshold then dangerThreshold = threshold end end 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 --- Aktuell aktive Tiefenrausch-Stufe (Index in config.narcosis.stages), 0 = keine. local narcosisStage = 0 local narcosisWarned = false local narcosisDamageBuffer = 0.0 local engineDepthWarned = false --- Wie weit die aktuelle Tiefe unter der Grenze der getragenen Flasche liegt. --- @return number Meter unterhalb der Grenze, negativ solange alles gut ist local function getDepthOverLimit() if not activeTank or not activeTank.maxDepth then return -math.huge end return currentDepth - activeTank.maxDepth end --- Setzt Bildschirmeffekt und Kamerawackeln auf die angegebene Stufe. --- @param stage number 0 = alles aus local function applyNarcosisStage(stage) if stage == narcosisStage then return end narcosisStage = stage ClearTimecycleModifier() StopGameplayCamShaking(true) local data = stage > 0 and config.narcosis.stages[stage] if not data then return end if data.timecycle then SetTimecycleModifier(data.timecycle) SetTimecycleModifierStrength(data.strength or 1.0) end if data.shake then ShakeGameplayCam(data.shake, data.shakeIntensity or 0.5) end end --- Verbrauchsfaktor. Tiefer tauchen kostet mehr Luft. --- Nutzt die geglättete Tiefe aus dem HUD-Thread, damit Wellengang den Verbrauch --- nicht im Sekundentakt springen lässt. local function getDecayFactor() local factor = 1.0 if config.depthDecayEnabled then factor = math.min(config.maxDecayFactor, 1.0 + currentDepth / config.depthDecayReference) end -- Tiefenrausch: unter Stress atmet man mehr. local stage = narcosisStage > 0 and config.narcosis.stages[narcosisStage] if stage then factor = factor * (stage.drainFactor or 1.0) end return factor 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 if currentGear.lamp ~= 0 then DetachEntity(currentGear.lamp, false, true) DeleteEntity(currentGear.lamp) currentGear.lamp = 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 --- Lampen-Model einmalig prüfen. lib.requestModel wirft bei ungültigen Models einen --- Error - ohne diese Prüfung würde ein falsch geschriebener Prop-Name die komplette --- Ausrüstung unbenutzbar machen, statt nur die Optik der Lampe zu kosten. local lampModel = config.lampProp ~= '' and joaat(config.lampProp) or nil local hasLampProp = lampModel ~= nil and (IsModelValid(lampModel) or IsModelInCdimage(lampModel)) if lampModel and not hasLampProp then lib.print.warn(('lampProp "%s" ist kein gültiges Model - die Lampe leuchtet, das Prop fehlt.'):format(config.lampProp)) 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)) if hasLampProp then currentGear.lamp = attachProp(ped, lampModel, config.lampBone, config.lampPropOffset, config.lampPropRotation) end end --- @param state boolean --- @param notifyIfUnavailable boolean? Rückmeldung, wenn das Outfit keine Lampe hat local function setLamp(state, notifyIfUnavailable) if not currentGear.enabled then state = false end lampOn = state -- Repliziert: client/light.lua setzt daraus den Lampen-Flag bei allen Tauchern. LocalPlayer.state:set('divelight', state, true) -- Lokal sofort setzen statt auf den Abgleich-Thread zu warten. SetEnableScubaGearLight(cache.ped, state) -- Die Spiel-Lampe hängt an der Scuba-Kleidung (Component 8). Fehlt die, tut -- die Native nichts - ohne Hinweis sieht das nach einem kaputten Keybind aus. if state and notifyIfUnavailable and not IsScubaGearLightEnabled(cache.ped) then exports.qbx_core:Notify(locale('error.no_lamp_on_outfit'), 'error') end end local function isPropIntact(prop) return prop ~= 0 and DoesEntityExist(prop) and GetEntityAttachedTo(prop) == cache.ped end local function isGearIntact() if hasLampProp and not isPropIntact(currentGear.lamp) then return false end 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 setLamp(false) applyNarcosisStage(0) activeTank = nil warned = {} narcosisWarned = false narcosisDamageBuffer = 0.0 engineDepthWarned = false 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), tank.gas or '?', math.floor(tank.maxDepth or 0)), 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 --- Manometer-Anzeige. Bei konstantem Volumen ist der Druck proportional zur --- verbliebenen Gasmenge, deshalb reicht der lineare Anteil an der Füllung. --- @return number bar local function getPressure() if not activeTank or activeTank.capacity <= 0 then return 0 end return (activeTank.pressure or 0) * (activeTank.oxygen / activeTank.capacity) end --- @param visible boolean local function sendHudState(visible) local oxygen = activeTank and activeTank.oxygen or 0.0 local capacity = activeTank and activeTank.capacity or 1 local pressure = getPressure() local level = 'ok' if oxygen <= dangerThreshold then level = 'danger' elseif oxygen <= warnThreshold then level = 'warn' end SendNUIMessage({ action = 'state', visible = visible, oxygen = oxygen, capacity = capacity, depth = currentDepth, pressure = pressure, reserve = pressure > 0 and pressure <= config.reservePressure, level = level, deep = currentDepth >= config.deepWarningDepth, -- Grenze der getragenen Flasche überschritten: Tiefenrausch läuft. overLimit = config.narcosis.enabled and getDepthOverLimit() >= 0, hasLamp = true, lamp = lampOn }) end --- Manuell ausgeblendet (eigene Taste oder Export)? Unabhängig davon, ob das --- Spiel-HUD gerade sichtbar ist. local hudHidden = false --- Blendet das Tauch-HUD aus oder wieder ein, z.B. für Unterwasser-Screenshots. --- Als Export nutzbar, damit HUD- und Screenshot-Resources es mitnehmen können: --- exports.d4rk_divegear:setHudVisible(false) --- @param visible boolean local function setHudVisible(visible) hudHidden = not visible if hudHidden then SendNUIMessage({ action = 'state', visible = false }) end end exports('setHudVisible', setHudVisible) exports('isHudVisible', function() return not hudHidden end) --- Pflegt die geglättete Tiefe und schiebt den Zustand ins NUI. --- Läuft auch über Wasser weiter, weil getDecayFactor die Tiefe braucht - nur die --- Anzeige wird ausgeblendet. local function startHudThread() CreateThread(function() local wasVisible = false SendNUIMessage({ action = 'labels', air = locale('hud.air'), depth = locale('hud.depth'), pressure = locale('hud.pressure'), lamp = locale('hud.lamp') }) while currentGear.enabled do currentDepth = currentDepth + (getDepth() - currentDepth) * 0.25 local visible = config.hudEnabled and not hudHidden and activeTank ~= nil -- Standardmäßig schon an Land sichtbar, damit sich der -- Flaschendruck ohne Inventar ablesen lässt. and (not config.hudOnlyUnderwater or IsPedSwimmingUnderWater(cache.ped)) -- Folgt dem Spiel-HUD: alles was DisplayHud(false) setzt, blendet -- damit auch das Tauch-HUD aus. and not (config.hudFollowGameHud and IsHudHidden()) if visible or wasVisible then sendHudState(visible) wasVisible = visible end Wait(config.hudUpdateIntervalMs) end currentDepth = 0.0 sendHudState(false) end) end --- Wertet die Tiefe gegen die Grenze der getragenen Flasche aus und setzt Effekte, --- Verbrauchsaufschlag und Schaden. Läuft auch über Wasser weiter, damit die --- Effekte beim Auftauchen wieder verschwinden. --- @param elapsed number Sekunden seit dem letzten Aufruf local function updateNarcosis(elapsed) if not config.narcosis.enabled then return end -- Die Engine tötet ab einer bestimmten Tiefe über den Wasserdruck. Dagegen hilft -- kein Atemgas, deshalb warnt das hier unabhängig von der Flasche. local engineWarnAt = config.narcosis.engineDepthLimit - config.narcosis.engineWarnBeforeMeters if currentDepth >= engineWarnAt then if not engineDepthWarned then engineDepthWarned = true exports.qbx_core:Notify(locale('error.crush_depth', math.floor(config.narcosis.engineDepthLimit)), 'error') end else engineDepthWarned = false end local over = getDepthOverLimit() local stages = config.narcosis.stages local stage = 0 for i = 1, #stages do if over >= stages[i].over then stage = i end end applyNarcosisStage(stage) -- Vorwarnung, solange noch Zeit zum Umkehren ist. if activeTank and over >= -config.narcosis.warnBeforeMeters then if not narcosisWarned then narcosisWarned = true exports.qbx_core:Notify(locale('info.depth_limit', math.floor(activeTank.maxDepth or 0)), 'warning') end else narcosisWarned = false end local data = stage > 0 and stages[stage] if not data or (data.damagePerTick or 0) <= 0 then narcosisDamageBuffer = 0.0 return end -- Schaden sammeln statt jeden Tick zu runden, sonst geht bei 250 ms Takt -- alles unter 4 Schaden pro Sekunde verloren. narcosisDamageBuffer = narcosisDamageBuffer + data.damagePerTick * elapsed if narcosisDamageBuffer >= 1.0 then local damage = math.floor(narcosisDamageBuffer) narcosisDamageBuffer -= damage ApplyDamageToPed(cache.ped, damage, false) 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 updateNarcosis(elapsed) 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() startHudThread() startGearWatchdogThread() end end RegisterNetEvent('d4rk_divegear:client:useGear', function() if currentGear.enabled then takeOffSuit() else putOnSuit() end end) lib.addKeybind({ name = 'divegear_hud', description = locale('keybind.toggle_hud'), defaultKey = config.hudToggleKey, onPressed = function() setHudVisible(hudHidden) end }) lib.addKeybind({ name = 'divegear_lamp', description = locale('keybind.toggle_lamp'), defaultKey = config.lampKey, onPressed = function() if not currentGear.enabled then return end setLamp(not lampOn, true) end }) --- Wo Prop und Lichtkegel am Bone sitzen, lässt sich nicht am Schreibtisch --- erraten - das hängt daran, wie das Model selbst ausgerichtet ist. Diese Befehle --- ändern die Werte im laufenden Spiel und geben die Zeile zum Übernehmen aus, --- damit nicht pro Versuch die Resource neu gestartet werden muss. --- --- lib.require cached Module, main.lua und light.lua teilen sich also dieselbe --- config-Tabelle - das Licht zieht sofort mit. if config.debug then --- @return vector3? local function readVec(args, from) local x, y, z = tonumber(args[from]), tonumber(args[from + 1]), tonumber(args[from + 2]) if not x or not y or not z then return end return vec3(x, y, z) end local function fmtVec(v) return ('vec3(%.2f, %.2f, %.2f)'):format(v.x, v.y, v.z) end RegisterCommand('divelamp', function(_, args) local offset, rotation = readVec(args, 1), readVec(args, 4) if not offset or not rotation then lib.print.info('/divelamp - Sitz des Lampen-Props') return end config.lampPropOffset, config.lampPropRotation = offset, rotation if currentGear.enabled then deleteGear() attachGear() end lib.print.info(('lampPropOffset = %s,\nlampPropRotation = %s,'):format(fmtVec(offset), fmtVec(rotation))) end, false) --- Die Spiel-Lampe braucht die Scuba-Kleidung am Ped. Dieser Befehl sagt, ob --- das aktuelle Outfit sie überhaupt hat - sonst rätselt man, warum nichts --- leuchtet. RegisterCommand('divelampcheck', function() SetEnableScubaGearLight(cache.ped, true) local works = IsScubaGearLightEnabled(cache.ped) SetEnableScubaGearLight(cache.ped, lampOn) lib.print.info(('Scuba-Lampe am aktuellen Outfit: %s'):format(works and 'ja' or 'nein')) end, false) 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) setLamp(false) applyNarcosisStage(0) deleteGear() SendNUIMessage({ action = 'state', visible = false }) SetEnableScuba(cache.ped, false) SetPedMaxTimeUnderwater(cache.ped, config.maxTimeUnderwater) end)