Files
d4rk_divegear/client/main.lua
T
D4rkst3randClaude Opus 5.5 accee02a05 feat: Patronen per Client-Export, /tauchartikel legt die Items an
CreateUseableItem feuert fuer Items aus d4rk_items nie: consume ist dort
immer gesetzt (daten.lua:224), das Inventar erreicht den qbx-Rueckruf nur
bei consume == nil (server.lua:623). Die Patronen tragen deshalb
client_export = d4rk_divegear.patroneBenutzen und consume 0, wie
d4rk_medical. /tauchartikel legt die sieben Items ueber adminItemCreate an,
nur auf Zuruf; "nachziehen" stellt bei vorhandenen nur consume und Export
richtig. Flasche und Maske consume 0 wie das Kleidungsitem clothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:52:27 +02:00

685 lines
23 KiB
Lua

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 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)
--- 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)
--- 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 lib.progressBar({
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)
AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
syncTank(true)
setLamp(false)
applyNarcosisStage(0)
SendNUIMessage({ action = 'state', visible = false })
SetEnableScuba(cache.ped, false)
SetPedMaxTimeUnderwater(cache.ped, config.maxTimeUnderwater)
end)