local config = require 'config.client' local shared = require 'config.shared' --- Tauchgeraet bereit: Flasche mit Patrone UND Maske angezogen (28.09.2026 - --- vorher hingen hier zwei Props, mask und tank). local currentGear = { enabled = false } --- Laufnummer der Faeden (Verbrauch, HUD, Waechter). Wird beim Tauschen einer --- Patrone sofort neu angelegt - ohne Nummer liefen die alten Faeden nach --- ihrem Wait einfach weiter, und es gaebe sie doppelt. local gearLauf = 0 --- 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 --- @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 --- @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) -- Sicherheitsnetz: die Native greift normalerweise auch ohne Scuba-Kleidung, -- aber falls sie bei einem Ped-Model doch nicht anschlägt, ist eine Meldung -- besser als ein scheinbar kaputter Keybind. if state and notifyIfUnavailable and not IsScubaGearLightEnabled(cache.ped) then lib.notify({ description = locale('error.no_lamp_on_outfit'), type = 'error' }) end 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? --- @param ohneSync boolean? nach einem Patronentausch: der Client-Stand gehoert --- zur ALTEN Patrone und darf die neue nicht ueberschreiben local function removeGear(outOfAir, immediateSync, ohneSync) if not ohneSync then syncTank(immediateSync) end currentGear.enabled = false setLamp(false) applyNarcosisStage(0) activeTank = nil warned = {} narcosisWarned = false narcosisDamageBuffer = 0.0 engineDepthWarned = false 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 lib.notify({ description = locale('error.underwater'), type = 'error' }) return end local tank = chooseTank(tanks) if not tank then return end if tank.oxygen >= tank.capacity then lib.notify({ description = locale('error.tank_already_full'), type = 'error' }) return end if not exports.d4rk_lib:progress({ 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) --- 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, -- Skalenwerte für die beiden Zeigerinstrumente. Kommen mit, damit die -- Config an einer Stelle bleibt und das NUI nichts nachrechnen muss. depthScale = config.depthDialMax, depthLimit = activeTank and activeTank.maxDepth or nil, pressure = pressure, maxPressure = activeTank and activeTank.pressure or nil, reservePressure = config.reservePressure, 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) -- Fuer /tauchprobe beobachten (client/probe.lua): ist das Tauchgeraet aktiv? exports('geraetAktiv', function() return currentGear.enabled == true 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', scale = config.hudScale, air = locale('hud.air'), depth = locale('hud.depth'), pressure = locale('hud.pressure'), lamp = locale('hud.lamp') }) local meinLauf = gearLauf while currentGear.enabled and meinLauf == gearLauf 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 -- Abgeloest durch einen neuen Faden (Patronentausch): der zeigt weiter. if meinLauf ~= gearLauf and currentGear.enabled then return 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 lib.notify({ description = locale('error.crush_depth', math.floor(config.narcosis.engineDepthLimit)), type = '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 lib.notify({ description = locale('info.depth_limit', math.floor(activeTank.maxDepth or 0)), type = '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 local meinLauf = gearLauf while currentGear.enabled and meinLauf == gearLauf 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 lib.notify({ description = locale('info.oxygen_low', formatTime(activeTank.oxygen)), type = 'warning' }) end end if activeTank.oxygen <= 0 then disableScuba(true) lib.notify({ description = locale('error.out_of_air'), type = '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() local meinLauf = gearLauf while currentGear.enabled and meinLauf == gearLauf do if config.removeGearOnDeath and IsPedDeadOrDying(cache.ped, true) then removeGear(false, true) break end if activeTank and activeTank.oxygen > 0 then enableScuba() end Wait(config.gearWatchdogIntervalMs) end end) end --[[ ANLEGEN - 28.09.2026. Kein Balken und keine Props mehr: angezogen wird im Inventar (Flasche und Maske sind Kleidung). Hier wird nur das Tauchgeraet scharf geschaltet, wenn der Server sagt, dass beides getragen wird und Luft in der Patrone ist. ]] --- @param tank table aus d4rk_divegear:server:getWorn --- @return boolean local function anlegen(tank) if not tank or (tank.oxygen or 0) <= 0 then return false end activeTank = tank warned = {} gearLauf = gearLauf + 1 enableScuba() currentGear.enabled = true startOxygenLevelDecrementerThread() startHudThread() startGearWatchdogThread() return true end --[[ NACHSEHEN, WAS ER TRAEGT - bei jeder Inventaraenderung und nach jedem Umziehen. Mehrere Anstoesse in kurzer Folge fuehren zu EINER Frage mehr, nicht zu vielen parallelen. ]] local pruefenLaeuft, pruefenNochmal = false, false local function pruefen() if pruefenLaeuft then pruefenNochmal = true return end pruefenLaeuft = true CreateThread(function() repeat pruefenNochmal = false local erg = lib.callback.await('d4rk_divegear:server:getWorn', false) if type(erg) == 'table' then if erg.ok then if not currentGear.enabled then if anlegen(erg.tank) then lib.notify({ description = locale('info.bereit', formatTime(erg.tank.oxygen)), type = 'info' }) end elseif activeTank and activeTank.name ~= erg.tank.name then -- Patrone getauscht: der Client-Stand gehoert zur alten. removeGear(false, false, true) anlegen(erg.tank) end elseif currentGear.enabled then lib.notify({ description = locale('error.' .. (erg.grund or 'gear_gone')), type = 'error' }) removeGear(false) end end until not pruefenNochmal pruefenLaeuft = false end) end AddEventHandler('d4rk:kleidung:angezogen', pruefen) AddEventHandler('ox_inventory:updateInventory', pruefen) CreateThread(function() Wait(2000) pruefen() end) --[[ EINE PATRONE BENUTZEN = SIE EINSETZEN. Nur an Land, mit Balken - wie vorher das Anlegen. UEBER EINEN CLIENT-EXPORT AM ITEM, NICHT CreateUseableItem (28.09.2026). d4rk_items setzt consume immer (daten.lua:224, `or 1`), und d4rk_inventory erreicht den qbx-Rueckruf nur bei consume == nil (server.lua:623) - fuer Items aus unserer Tabelle feuerte er also nie. Die Patronen tragen client_export = 'd4rk_divegear.patroneBenutzen' und consume = 0 (angelegt mit /tauchartikel, server/artikel.lua); das Inventar ruft exports.d4rk_divegear:patroneBenutzen(daten, slot) mit slot = { name, slot, metadata } - dasselbe wie d4rk_medical `benutzen`. ]] local function einsetzen(slot) if IsPedSwimming(cache.ped) or cache.vehicle then lib.notify({ description = locale('error.not_standing_up'), type = 'error' }) return end if not exports.d4rk_lib:progress({ duration = config.putOnSuitTimeMs, label = locale('info.patrone_einsetzen'), useWhileDead = false, canCancel = true, anim = { dict = 'clothingshirt', clip = 'try_shirt_positive_d', blendIn = 8.0 } }) then return end -- Die laufende Luft zuerst sichern - danach gehoert der Client-Stand zur -- alten Patrone und wird nicht mehr geschrieben (pruefen -> removeGear ohneSync). if currentGear.enabled then syncTank() end local ok, grund = lib.callback.await('d4rk_divegear:server:patroneTauschen', false, slot) if not ok then lib.notify({ description = locale('error.' .. tostring(grund or 'fehler')), type = 'error' }) return end lib.notify({ description = locale('success.patrone_getauscht'), type = 'success' }) pruefen() end exports('patroneBenutzen', function(_, slot) einsetzen(type(slot) == 'table' and slot.slot or nil) 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 }) --- 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 if activeTank and activeTank.oxygen > 0 then enableScuba(ped) end end) --[[ GTA NIMMT FLASCHE UND MASKE SELBST AB - gemessen am 29.09.2026 mit /tauchprobe beobachten (Betreiber): im Moment, in dem "schwimmt" auf nein springt (flaches Wasser, Boden unter den Fuessen), verschwanden Komponente 8 (global 123 = GTAs Taucherflasche) und Prop 1 gleichzeitig - bei ausgeschaltetem Tauchgeraet, also nicht durch diesen Code. Die Items lagen weiter in ihren Slots. CPED_CONFIG_FLAG_DisableTakeOffScubaGear = 409 (NACHGESCHLAGEN in der SetPedConfigFlag-Doku der Nativ-Datenbank) schaltet das ab. Bei uns zieht nur das Inventar aus. Gesetzt am Spieler-Ped, nach jedem Ped-Wechsel neu (onCache-Parameter, nicht cache.ped), beim Stoppen wieder ab - sonst bliebe sie ohne diese Resource stehen. ]] local ABNEHMEN_AUS = 409 CreateThread(function() SetPedConfigFlag(PlayerPedId(), ABNEHMEN_AUS, true) end) lib.onCache('ped', function(ped) SetPedConfigFlag(ped, ABNEHMEN_AUS, true) end) AddEventHandler('onResourceStop', function(resource) if resource ~= GetCurrentResourceName() then return end SetPedConfigFlag(cache.ped, ABNEHMEN_AUS, false) syncTank(true) setLamp(false) applyNarcosisStage(0) SendNUIMessage({ action = 'state', visible = false }) SetEnableScuba(cache.ped, false) SetPedMaxTimeUnderwater(cache.ped, config.maxTimeUnderwater) end)