fix(client): Ausruestung verschwindet nicht mehr bei Animationen
Lint / Lint Resource (push) Has been cancelled

Die Props waren soft-pinned angehaengt (useSoftPinning = true), wodurch die
Physik-Engine die Attachment bei Ragdoll und harten Anim-Uebergaengen loesen
darf. Dazu kam, dass ein Ped-Model-Wechsel und aufraeumende Fremd-Scripts
(Emotes, Handcuffs) gar nicht abgefangen wurden.

- attachProp() Helper mit harter Attachment (useSoftPinning = false)
- Watchdog-Thread prueft jede Sekunde, ob beide Props noch am aktuellen Ped
  haengen, und haengt sie sonst neu an
- Scuba-Flags werden im Watchdog neu gesetzt, weil das Spiel sie bei Respawn
  und Fahrzeugausstieg zuruecksetzt
- lib.onCache('ped') haengt die Props nach einem Model-Wechsel neu an
- Ausruestung wird beim Tod abgenommen (removeGearOnDeath, default true)
- removeGear() als gemeinsamer Teardown fuer Ablegen, leere Flasche und Tod
- onResourceStop raeumt Props und Scuba-Flags auf
- maxTimeUnderwater / maxTimeUnderwaterOutOfAir statt Magic Numbers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:24:39 +02:00
co-authored by Claude Opus 5
parent c6b35ebe28
commit 921d9c89c8
3 changed files with 147 additions and 54 deletions
+18 -13
View File
@@ -34,21 +34,26 @@ Reihenfolge ist bewusst gewählt: Phase 1 und 2 sind Fundament, Phase 35 baue
Zuerst, weil kleinster Aufwand, echter Bug, und die Taschenlampe dieselbe Zuerst, weil kleinster Aufwand, echter Bug, und die Taschenlampe dieselbe
Attach-Logik nutzt. Attach-Logik nutzt.
- [ ] `attachGear()` auf harte Attachment umstellen: `useSoftPinning = false`, - [x] `attachGear()` auf harte Attachment umstellen: `useSoftPinning = false`,
`p9 = false`, `fixedRot = true` `p9 = false`, `fixedRot = true` — ausgelagert in einen `attachProp()`-Helper,
- [ ] `isGearIntact()` Helper: `DoesEntityExist` + `GetEntityAttachedTo(...) == cache.ped` den Phase 5 für die Lampe mitbenutzt
- [x] `isGearIntact()` Helper: `DoesEntityExist` + `GetEntityAttachedTo(...) == cache.ped`
für beide Props für beide Props
- [ ] Watchdog-Thread (1000 ms) solange `currentGear.enabled`: bei Defekt - [x] Watchdog-Thread (`gearWatchdogIntervalMs`, default 1000 ms) solange
`deleteGear()` + `attachGear()`, und `enableScuba()` unbedingt neu setzen `currentGear.enabled`: bei Defekt `deleteGear()` + `attachGear()`, und
(`SetEnableScuba`/`SetPedMaxTimeUnderwater` werden bei Respawn/Fahrzeugausstieg `enableScuba()` neu setzen (`SetEnableScuba`/`SetPedMaxTimeUnderwater` werden bei
vom Spiel zurückgesetzt) Respawn/Fahrzeugausstieg vom Spiel zurückgesetzt)
- [ ] `lib.onCache('ped', ...)` → bei Model-Wechsel (Kleiderladen, Skin-Change) - [x] `lib.onCache('ped', ...)` → bei Model-Wechsel (Kleiderladen, Skin-Change)
Props neu anhängen, Scuba-State neu setzen Props neu anhängen, Scuba-State neu setzen
- [ ] Death-Handler: beim Tod Gear sofort ablegen (ohne Progressbar), Restluft - [x] Death-Handler: `IsPedDeadOrDying`-Check im Watchdog statt eigenem Event —
vorher sichern — Flasche bleibt im Inventar keine Abhängigkeit zu qbx_medical o.ä. Sichern der Restluft kommt in Phase 2 dazu
- [ ] Inkonsistenz aufräumen: `takeOffSuit()` setzt `SetPedMaxTimeUnderwater(ped, 50.0)`, - [x] Gemeinsamer `removeGear(outOfAir)`-Teardown für Ablegen, leere Flasche und Tod
`disableScuba()` setzt `1.0` — einen Wert festlegen - [x] `onResourceStop` Cleanup-Handler für Props
- [ ] `onResourceStop` Cleanup-Handler für Props
**Zu `SetPedMaxTimeUnderwater`:** war doch kein Bug. Die `50.0` beim Ablegen und die
`1.0` bei leerer Flasche sind zwei verschiedene Absichten (normal weiterschwimmen vs.
ohne Luft absaufen). Beide Werte sind jetzt als `maxTimeUnderwater` /
`maxTimeUnderwaterOutOfAir` in der Config statt als Magic Number im Code.
### Phase 2 — Sauerstoff-Logik neu (~1 h) ### Phase 2 — Sauerstoff-Logik neu (~1 h)
+117 -41
View File
@@ -8,14 +8,79 @@ local currentGear = {
local oxygenLevel = 0 local oxygenLevel = 0
local MASK_MODEL = `p_d_scuba_mask_s`
local TANK_MODEL = `p_s_scuba_tank_s`
local MASK_BONE = 12844
local TANK_BONE = 24818
local function enableScuba() local function enableScuba()
SetEnableScuba(cache.ped, true) SetEnableScuba(cache.ped, true)
SetPedMaxTimeUnderwater(cache.ped, 2000.00) SetPedMaxTimeUnderwater(cache.ped, 2000.00)
end end
local function disableScuba() --- @param outOfAir boolean? true wenn die Flasche leer ist statt die Ausrüstung abgelegt wurde
local function disableScuba(outOfAir)
SetEnableScuba(cache.ped, false) SetEnableScuba(cache.ped, false)
SetPedMaxTimeUnderwater(cache.ped, 1.00) SetPedMaxTimeUnderwater(cache.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(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, cache.ped, GetPedBoneIndex(cache.ped, bone),
offset.x, offset.y, offset.z,
rotation.x, rotation.y, rotation.z,
false, -- p9
false, -- useSoftPinning
false, -- collision
false, -- isPed
2, -- rotationOrder
true) -- fixedRot
SetModelAsNoLongerNeeded(model)
return prop
end
local function attachGear()
currentGear.tank = attachProp(TANK_MODEL, TANK_BONE, vec3(-0.25, -0.25, 0.0), vec3(180.0, 90.0, 0.0))
currentGear.mask = attachProp(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
--- Gemeinsamer Teardown für Ablegen, leere Flasche und Tod.
--- @param outOfAir boolean?
local function removeGear(outOfAir)
currentGear.enabled = false
deleteGear()
disableScuba(outOfAir)
-- Stop breathing suit audio
end end
lib.callback.register('qbx_divegear:client:fillTank', function() lib.callback.register('qbx_divegear:client:fillTank', function()
@@ -44,39 +109,6 @@ lib.callback.register('qbx_divegear:client:fillTank', function()
end end
end) 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
local function attachGear()
local maskModel = `p_d_scuba_mask_s`
local tankModel = `p_s_scuba_tank_s`
lib.requestModel(maskModel)
lib.requestModel(tankModel)
currentGear.tank = CreateObject(tankModel, 1.0, 1.0, 1.0, true, true, false)
SetEntityCollision(currentGear.tank, false, false) --- Disable collision for the tank so it doesn't block bullets
local bone1 = GetPedBoneIndex(cache.ped, 24818)
AttachEntityToEntity(currentGear.tank, cache.ped, bone1, -0.25, -0.25, 0.0, 180.0, 90.0, 0.0, true, true, false, false, 2, true)
currentGear.mask = CreateObject(maskModel, 1.0, 1.0, 1.0, true, true, false)
SetEntityCollision(currentGear.mask, false, false) --- Disable collision for the mask so it doesn't block bullets
local bone2 = GetPedBoneIndex(cache.ped, 12844)
AttachEntityToEntity(currentGear.mask, cache.ped, bone2, 0.0, 0.0, 0.0, 180.0, 90.0, 0.0, true, true, false, false, 2, true)
SetModelAsNoLongerNeeded(maskModel)
SetModelAsNoLongerNeeded(tankModel)
end
local function takeOffSuit() local function takeOffSuit()
if lib.progressBar({ if lib.progressBar({
duration = config.takeOffSuitTimeMs, duration = config.takeOffSuitTimeMs,
@@ -89,12 +121,8 @@ local function takeOffSuit()
blendIn = 8.0 blendIn = 8.0
} }
}) then }) then
SetEnableScuba(cache.ped, false) removeGear()
SetPedMaxTimeUnderwater(cache.ped, 50.00)
currentGear.enabled = false
deleteGear()
exports.qbx_core:Notify(locale('success.took_out')) exports.qbx_core:Notify(locale('success.took_out'))
-- Stop breathing suit audio
end end
end end
@@ -122,7 +150,7 @@ local function startOxygenLevelDecrementerThread()
-- Initiate breathing suit audio -- Initiate breathing suit audio
end end
if oxygenLevel == 0 then if oxygenLevel == 0 then
disableScuba() disableScuba(true)
-- Stop breathing suit audio -- Stop breathing suit audio
end end
end end
@@ -131,6 +159,32 @@ local function startOxygenLevelDecrementerThread()
end) 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()
break
end
if not isGearIntact() then
deleteGear()
attachGear()
end
if oxygenLevel > 0 then
enableScuba()
end
Wait(config.gearWatchdogIntervalMs)
end
end)
end
local function putOnSuit() local function putOnSuit()
if oxygenLevel <= 0 then if oxygenLevel <= 0 then
exports.qbx_core:Notify(locale('error.need_otube'), 'error') exports.qbx_core:Notify(locale('error.need_otube'), 'error')
@@ -160,6 +214,7 @@ local function putOnSuit()
-- Initiate breathing suit audio -- Initiate breathing suit audio
startOxygenLevelDecrementerThread() startOxygenLevelDecrementerThread()
startOxygenLevelDrawTextThread() startOxygenLevelDrawTextThread()
startGearWatchdogThread()
end end
end end
@@ -170,3 +225,24 @@ RegisterNetEvent('qbx_divegear:client:useGear', function()
putOnSuit() putOnSuit()
end end
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.
lib.onCache('ped', function()
if not currentGear.enabled then return end
deleteGear()
attachGear()
if oxygenLevel > 0 then
enableScuba()
end
end)
AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
deleteGear()
SetEnableScuba(cache.ped, false)
SetPedMaxTimeUnderwater(cache.ped, config.maxTimeUnderwater)
end)
+12
View File
@@ -4,4 +4,16 @@ return {
takeOffSuitTimeMs = 5000, takeOffSuitTimeMs = 5000,
refillTankTimeMs = 5000, refillTankTimeMs = 5000,
decayRate = 1, -- The rate at which the oxygen level decays. Defaults to 1. decayRate = 1, -- The rate at which the oxygen level decays. Defaults to 1.
-- Intervall des Watchdogs, der prüft ob die Ausrüstung noch am Ped hängt (ms).
gearWatchdogIntervalMs = 1000,
-- Sekunden, die der Spieler ohne Ausrüstung unter Wasser überlebt (Vanilla-Verhalten).
maxTimeUnderwater = 50.0,
-- Sekunden, sobald die Flasche leer ist. Bewusst kurz: leer tauchen soll wehtun.
maxTimeUnderwaterOutOfAir = 1.0,
-- Ausrüstung beim Tod abnehmen. Die Flasche bleibt mit ihrer Restluft im Inventar.
removeGearOnDeath = true,
} }