feat: Flasche und Maske als Kleidung, Patronen tauschen, HUD-Anmeldung

- Tauchgeraet bereit = Taucherflasche (rigItem, Slot 31) mit Patrone UND
  Maske (maskItem, Slot 29) angezogen; Server getWorn statt verifyGear,
  geprueft bei jeder Inventaraenderung und nach jedem Umziehen.
  Props, diving_gear und der Anzieh-Balken entfallen.
- Die fuenf Flaschen sind Patronen: benutzen = in die getragene Flasche
  einsetzen, die alte kommt mit Restluft zurueck (patroneTauschen).
- Faeden mit Laufnummer, damit ein Tausch sie nicht verdoppelt.
- exports.ox_inventory -> exports.d4rk_inventory, Meldungen ueber
  lib.notify (meldung_shared) und d4rk_bridge:Notify.
- Konsole beim HUD angemeldet (client/hud.lua), 366 x 165 bei 1080p
  gemessen; ohne HUD bleibt die CSS-Position.
- Nachfuellen (diving_fill) stillgelegt: kommt spaeter ueber Shop/Crafting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
D4rkst3randClaude Opus 5.5 committed 2026-09-28 23:48:26 +02:00
1 parent 930ff6381f
commit e64a1bd3d0
9 files changed
+401 -214

No files matched your search

+125 -173
View File
@@ -1,12 +1,17 @@
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 = {
mask = 0,
tank = 0,
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
@@ -32,10 +37,6 @@ for i = 1, #config.warnAtSeconds do
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
@@ -127,50 +128,6 @@ local function disableScuba(outOfAir, ped)
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
--- @param state boolean
--- @param notifyIfUnavailable boolean? Rückmeldung, wenn das Outfit keine Lampe hat
local function setLamp(state, notifyIfUnavailable)
@@ -187,18 +144,10 @@ local function setLamp(state, notifyIfUnavailable)
-- 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
exports.qbx_core:Notify(locale('error.no_lamp_on_outfit'), 'error')
lib.notify({ description = locale('error.no_lamp_on_outfit'), type = 'error' })
end
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)
@@ -220,8 +169,10 @@ end
--- Gemeinsamer Teardown für Ablegen, leere Flasche und Tod.
--- @param outOfAir boolean?
--- @param immediateSync boolean?
local function removeGear(outOfAir, immediateSync)
syncTank(immediateSync)
--- @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)
@@ -232,7 +183,6 @@ local function removeGear(outOfAir, immediateSync)
narcosisDamageBuffer = 0.0
engineDepthWarned = false
deleteGear()
disableScuba(outOfAir)
-- Stop breathing suit audio
end
@@ -272,7 +222,7 @@ end
lib.callback.register('d4rk_divegear:client:fillTank', function(tanks)
if IsPedSwimmingUnderWater(cache.ped) then
exports.qbx_core:Notify(locale('error.underwater'), 'error')
lib.notify({ description = locale('error.underwater'), type = 'error' })
return
end
@@ -281,7 +231,7 @@ lib.callback.register('d4rk_divegear:client:fillTank', function(tanks)
if not tank then return end
if tank.oxygen >= tank.capacity then
exports.qbx_core:Notify(locale('error.tank_already_full'), 'error')
lib.notify({ description = locale('error.tank_already_full'), type = 'error' })
return
end
@@ -314,23 +264,6 @@ RegisterNetEvent('d4rk_divegear:client:tankRefilled', function(slot, capacity)
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
@@ -411,7 +344,9 @@ local function startHudThread()
lamp = locale('hud.lamp')
})
while currentGear.enabled do
local meinLauf = gearLauf
while currentGear.enabled and meinLauf == gearLauf do
currentDepth = currentDepth + (getDepth() - currentDepth) * 0.25
local visible = config.hudEnabled
@@ -432,6 +367,9 @@ local function startHudThread()
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)
@@ -451,7 +389,7 @@ local function updateNarcosis(elapsed)
if currentDepth >= engineWarnAt then
if not engineDepthWarned then
engineDepthWarned = true
exports.qbx_core:Notify(locale('error.crush_depth', math.floor(config.narcosis.engineDepthLimit)), 'error')
lib.notify({ description = locale('error.crush_depth', math.floor(config.narcosis.engineDepthLimit)), type = 'error' })
end
else
engineDepthWarned = false
@@ -471,7 +409,7 @@ local function updateNarcosis(elapsed)
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')
lib.notify({ description = locale('info.depth_limit', math.floor(activeTank.maxDepth or 0)), type = 'warning' })
end
else
narcosisWarned = false
@@ -502,8 +440,9 @@ local function startOxygenLevelDecrementerThread()
CreateThread(function()
local lastTick = GetGameTimer()
local sinceSync = 0.0
local meinLauf = gearLauf
while currentGear.enabled do
while currentGear.enabled and meinLauf == gearLauf do
Wait(250)
local now = GetGameTimer()
@@ -522,13 +461,13 @@ local function startOxygenLevelDecrementerThread()
if activeTank.oxygen <= threshold and not warned[threshold] then
warned[threshold] = true
exports.qbx_core:Notify(locale('info.oxygen_low', formatTime(activeTank.oxygen)), 'warning')
lib.notify({ description = locale('info.oxygen_low', formatTime(activeTank.oxygen)), type = 'warning' })
end
end
if activeTank.oxygen <= 0 then
disableScuba(true)
exports.qbx_core:Notify(locale('error.out_of_air'), 'error')
lib.notify({ description = locale('error.out_of_air'), type = 'error' })
syncTank()
sinceSync = 0.0
-- Stop breathing suit audio
@@ -554,17 +493,14 @@ end
--- und Fahrzeugausstieg vom Spiel zurückgesetzt werden.
local function startGearWatchdogThread()
CreateThread(function()
while currentGear.enabled do
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 not isGearIntact() then
deleteGear()
attachGear()
end
if activeTank and activeTank.oxygen > 0 then
enableScuba()
end
@@ -574,31 +510,94 @@ local function startGearWatchdogThread()
end)
end
local function putOnSuit()
--[[
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 (Server: CreateUseableItem je
Patrone). Nur an Land, mit Balken - wie vorher das Anlegen.
]]
RegisterNetEvent('d4rk_divegear:client:patroneEinsetzen', function(slot)
if IsPedSwimming(cache.ped) or cache.vehicle then
exports.qbx_core:Notify(locale('error.not_standing_up'), 'error')
lib.notify({ description = locale('error.not_standing_up'), type = '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({
if not lib.progressBar({
duration = config.putOnSuitTimeMs,
label = locale('info.put_suit'),
label = locale('info.patrone_einsetzen'),
useWhileDead = false,
canCancel = true,
anim = {
@@ -607,26 +606,22 @@ local function putOnSuit()
blendIn = 8.0
}
}) then
activeTank = tank
warned = {}
deleteGear()
attachGear()
enableScuba()
currentGear.enabled = true
-- Initiate breathing suit audio
startOxygenLevelDecrementerThread()
startHudThread()
startGearWatchdogThread()
return
end
end
RegisterNetEvent('d4rk_divegear:client:useGear', function()
if currentGear.enabled then
takeOffSuit()
else
putOnSuit()
-- 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)
lib.addKeybind({
@@ -659,60 +654,17 @@ lib.addKeybind({
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)
--- Ausrüstung und Flasche können während des Tauchgangs das Inventar verlassen -
--- weggegeben, abgelegt, vom Admin entfernt. Ohne diese Prüfung hielt der Client
--- nur eine Slot-Nummer, die niemand mehr gegenprüfte, und man konnte unbegrenzt
--- weitertauchen. ox_inventory feuert bei jeder Änderung, also einmal nachfragen.
AddEventHandler('ox_inventory:updateInventory', function()
if not currentGear.enabled or not activeTank then return end
CreateThread(function()
local expected = activeTank
-- Der Thread startet erst im nächsten Tick, die Prüfung oben ist also
-- schon veraltet. Beim Ablegen passiert genau das: removeGear sichert die
-- Restluft, das ist selbst eine Inventar-Änderung, und bis hierher ist
-- activeTank längst nil.
if not expected or not currentGear.enabled then return end
local result = lib.callback.await('d4rk_divegear:server:verifyGear', false, expected.slot, expected.name)
-- Während des Callbacks kann die Ausrüstung schon abgelegt worden sein.
if not currentGear.enabled or activeTank ~= expected then return end
if not result then return end
if not result.gear then
exports.qbx_core:Notify(locale('error.gear_gone'), 'error')
removeGear(true)
return
end
if not result.tank then
exports.qbx_core:Notify(locale('error.tank_gone'), 'error')
removeGear(true)
return
end
activeTank.slot = result.tank
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)