feat: Flaschengroessen mit echter Laufzeit und persistenter Restluft
Lint / Lint Resource (push) Has been cancelled

Der Sauerstoff war eine lokale Client-Variable: nicht persistent, an kein Item
gebunden, weg bei Relog. Dazu ergab startingOxygenLevel=100 bei decayRate=1/s
genau 100 Sekunden Tauchzeit statt Minuten.

Sauerstoff-Logik:
- Restsekunden statt Punkte, Verbrauch ueber GetGameTimer-Delta statt gezaehlter
  Wait(1000) - ein Tick laeuft unter Last laenger als eine Sekunde, was sich
  ueber einen langen Tauchgang aufsummiert
- Float-Vergleiche (oxygenLevel % 10 == 0, == 0) raus, Warnschwellen als
  config.warnAtSeconds, jede Schwelle einmal pro Tauchgang
- Verbrauch steigt mit der Tiefe (1 + tiefe/30, gedeckelt), abschaltbar
- Anzeige als mm:ss

Flaschen:
- Vier Items mit capacity in Sekunden (300/600/900/1200) in config/shared.lua
- Restluft in metadata.oxygen, Fuellstand zusaetzlich in metadata.durability,
  damit der Balken direkt im Inventar-Slot sichtbar ist
- Kein decay auf den Items: ox_inventory loescht sonst die leere Flasche
- Auswahlmenue beim Anlegen und Auffuellen (entfaellt bei nur einer Flasche)
- diving_fill fuellt bis capacity statt auf einen Fixwert, verweigert volle
  Flaschen und verbraucht sich nur bei Erfolg
- items_for_ox_inventory.lua zum Reinkopieren

Server:
- getTanks/syncTank als Callbacks, Restluft kann nur sinken und nicht schneller
  als maxDrainPerSecond pro echter Sekunde
- SetMetadata ersetzt die Metadata-Tabelle komplett, daher wird der bestehende
  Inhalt kopiert statt nur oxygen gesetzt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:45:11 +02:00
co-authored by Claude Opus 5
parent 58f31adfa9
commit d661152bc9
8 changed files with 469 additions and 47 deletions
+180 -28
View File
@@ -1,4 +1,5 @@
local config = require 'config.client'
local shared = require 'config.shared'
local currentGear = {
mask = 0,
@@ -6,13 +7,42 @@ local currentGear = {
enabled = false
}
local oxygenLevel = 0
--- 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
@@ -80,22 +110,87 @@ 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?
local function removeGear(outOfAir)
--- @param immediateSync boolean?
local function removeGear(outOfAir, immediateSync)
syncTank(immediateSync)
currentGear.enabled = false
activeTank = nil
warned = {}
deleteGear()
disableScuba(outOfAir)
-- Stop breathing suit audio
end
lib.callback.register('qbx_divegear:client:fillTank', function()
if IsPedSwimmingUnderWater(cache.ped) then
exports.qbx_core:Notify(locale('error.underwater', {oxygenlevel = oxygenLevel}), 'error')
return false
--- 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
if lib.progressBar({
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,
@@ -106,13 +201,22 @@ lib.callback.register('qbx_divegear:client:fillTank', function()
blendIn = 8.0
}
}) then
oxygenLevel = config.startingOxygenLevel
exports.qbx_core:Notify(locale('success.tube_filled'), 'success')
if currentGear.enabled then
enableScuba()
end
return true
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()
@@ -135,9 +239,9 @@ end
local function startOxygenLevelDrawTextThread()
CreateThread(function()
while currentGear.enabled do
if IsPedSwimmingUnderWater(cache.ped) then
if IsPedSwimmingUnderWater(cache.ped) and activeTank then
qbx.drawText2d({
text = oxygenLevel..'',
text = formatTime(activeTank.oxygen)..'',
coords = vec2(1.0, 1.42),
scale = 0.45
})
@@ -147,20 +251,53 @@ local function startOxygenLevelDrawTextThread()
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
if IsPedSwimmingUnderWater(cache.ped) and oxygenLevel > 0 then
oxygenLevel -= config.decayRate
if oxygenLevel % 10 == 0 and oxygenLevel ~= config.startingOxygenLevel then
-- Initiate breathing suit audio
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 oxygenLevel == 0 then
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
Wait(1000)
::continue::
end
end)
end
@@ -173,7 +310,7 @@ local function startGearWatchdogThread()
CreateThread(function()
while currentGear.enabled do
if config.removeGearOnDeath and IsPedDeadOrDying(cache.ped, true) then
removeGear()
removeGear(false, true)
break
end
@@ -182,7 +319,7 @@ local function startGearWatchdogThread()
attachGear()
end
if oxygenLevel > 0 then
if activeTank and activeTank.oxygen > 0 then
enableScuba()
end
@@ -192,13 +329,24 @@ local function startGearWatchdogThread()
end
local function putOnSuit()
if oxygenLevel <= 0 then
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
if IsPedSwimming(cache.ped) or cache.vehicle then
exports.qbx_core:Notify(locale('error.not_standing_up'), 'error')
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
@@ -213,6 +361,9 @@ local function putOnSuit()
blendIn = 8.0
}
}) then
activeTank = tank
warned = {}
deleteGear()
attachGear()
enableScuba()
@@ -224,7 +375,7 @@ local function putOnSuit()
end
end
RegisterNetEvent('qbx_divegear:client:useGear', function()
RegisterNetEvent('d4rk_divegear:client:useGear', function()
if currentGear.enabled then
takeOffSuit()
else
@@ -245,7 +396,7 @@ lib.onCache('ped', function(ped)
deleteGear()
attachGear(ped)
if oxygenLevel > 0 then
if activeTank and activeTank.oxygen > 0 then
enableScuba(ped)
end
end)
@@ -253,6 +404,7 @@ end)
AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
syncTank(true)
deleteGear()
SetEnableScuba(cache.ped, false)
SetPedMaxTimeUnderwater(cache.ped, config.maxTimeUnderwater)