Files
d4rk_divegear/client/main.lua
T
D4rkst3randClaude Opus 5 d661152bc9
Lint / Lint Resource (push) Has been cancelled
feat: Flaschengroessen mit echter Laufzeit und persistenter Restluft
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>
2026-08-10 23:45:11 +02:00

412 lines
12 KiB
Lua

local config = require 'config.client'
local shared = require 'config.shared'
local currentGear = {
mask = 0,
tank = 0,
enabled = 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 = {}
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
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
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
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)
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
activeTank = nil
warned = {}
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)),
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
local function startOxygenLevelDrawTextThread()
CreateThread(function()
while currentGear.enabled do
if IsPedSwimmingUnderWater(cache.ped) and activeTank then
qbx.drawText2d({
text = formatTime(activeTank.oxygen)..'⏱',
coords = vec2(1.0, 1.42),
scale = 0.45
})
end
Wait(0)
end
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
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()
startOxygenLevelDrawTextThread()
startGearWatchdogThread()
end
end
RegisterNetEvent('d4rk_divegear:client:useGear', function()
if currentGear.enabled then
takeOffSuit()
else
putOnSuit()
end
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)
deleteGear()
SetEnableScuba(cache.ped, false)
SetPedMaxTimeUnderwater(cache.ped, config.maxTimeUnderwater)
end)