feat(light): gesyncte Tauchlampe
Lint / Lint Resource (push) Has been cancelled

Licht ist in GTA nie synchronisiert - DrawSpotLight zeichnet nur lokal fuer den
aktuellen Frame. "Gesynct" heisst deshalb: der Zustand wird als Statebag
repliziert und jeder Client zeichnet die Lampen aller Taucher in seiner Naehe
selbst. Muster uebernommen aus d4rk_phone (Statebag, adaptives Polling,
Distanzfilter).

client/light.lua ist ein reiner Renderer ohne gemeinsamen Zustand mit main.lua -
er kommt mit dem Statebag allein aus und kennt weder Ausruestung noch Flasche.

- Richtung ueber zwei bone-relative Punkte am Kopf-Bone statt ueber
  GetEntityForwardVector: beim Tauchen liegt der Ped waagerecht im Wasser, der
  Entity-Forward wuerde stur horizontal leuchten. Die Offsets von
  GetPedBoneCoords sind laut Native-Doku "relative to the bone's rotation",
  die Differenz zweier solcher Punkte enthaelt also den Pitch
- Toggle per lib.addKeybind, Standard L, vom Spieler umlegbar
- Distanzfilter und Limit auf die naechsten N Lampen, Schatten abschaltbar
- Prop-Model wird vorher mit IsModelValid geprueft: lib.requestModel wirft bei
  ungueltigen Models einen Error, der sonst die ganze Ausruestung lahmlegt
- Lampen-Status im HUD

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:56:40 +02:00
co-authored by Claude Opus 5
parent 28ab8e796c
commit d9643620ff
7 changed files with 288 additions and 44 deletions
+112
View File
@@ -0,0 +1,112 @@
local config = require 'config.client'
-- Reiner Renderer: zeichnet die Lampen aller Taucher in der Nähe, gesteuert
-- ausschließlich über den replizierten Statebag. Kein gemeinsamer Zustand mit
-- client/main.lua - das hier läuft auch für Spieler, deren Ausrüstung wir gar
-- nicht kennen.
--
-- Licht ist in GTA nie synchronisiert: DrawSpotLight zeichnet nur lokal, für den
-- aktuellen Frame. "Gesynct" heißt deshalb, dass jeder Client das Licht jedes
-- anderen Tauchers selbst zeichnet.
local STATE_KEY = 'divelight'
local HEAD_BONE = 31086 -- SKEL_Head
--- Kandidaten des aktuellen Frames. Wird wiederverwendet statt jedes Frame neu
--- angelegt, damit der GC bei Dauerbetrieb nichts zu tun bekommt.
local candidates = {}
--- Position und Richtung der Lampe eines Peds.
---
--- Die Offsets von GetPedBoneCoords sind laut Native-Doku "relative to the bone's
--- rotation" - zwei bone-relative Punkte ergeben also die echte Blickrichtung
--- inklusive Pitch. Genau das wird beim Tauchen gebraucht: der Ped liegt waagerecht
--- im Wasser, GetEntityForwardVector würde die Lampe stur horizontal leuchten lassen.
local function getLampVectors(ped)
local offset = config.lampOffset
local direction = config.lampDirection
local from = GetPedBoneCoords(ped, HEAD_BONE, offset.x, offset.y, offset.z)
local ahead = GetPedBoneCoords(ped, HEAD_BONE,
offset.x + direction.x,
offset.y + direction.y,
offset.z + direction.z)
local delta = ahead - from
local length = #delta
if length <= 0 then return end
return from, delta / length
end
local function drawLamp(ped)
local from, direction = getLampVectors(ped)
if not from then return end
local colour = config.lampColour
if config.lampShadows then
DrawSpotLightWithShadow(from.x, from.y, from.z, direction.x, direction.y, direction.z,
colour[1], colour[2], colour[3],
config.lampDistance, config.lampBrightness, config.lampRoundness,
config.lampRadius, config.lampFalloff, 0)
else
DrawSpotLight(from.x, from.y, from.z, direction.x, direction.y, direction.z,
colour[1], colour[2], colour[3],
config.lampDistance, config.lampBrightness, config.lampRoundness,
config.lampRadius, config.lampFalloff)
end
end
CreateThread(function()
while true do
local players = GetActivePlayers()
local myCoords = GetEntityCoords(cache.ped)
local count = 0
for i = 1, #players do
local player = players[i]
local state = Player(GetPlayerServerId(player)).state
if state and state[STATE_KEY] then
local ped = GetPlayerPed(player)
if ped ~= 0 and DoesEntityExist(ped) then
local distance = #(myCoords - GetEntityCoords(ped))
if distance < config.lampDrawDistance then
count += 1
local entry = candidates[count]
if entry then
entry.ped, entry.distance = ped, distance
else
candidates[count] = { ped = ped, distance = distance }
end
end
end
end
end
-- Reste des letzten Frames abschneiden, sonst sortiert table.sort veraltete
-- Einträge mit und ein alter, näherer Eintrag verdrängt einen echten.
for i = #candidates, count + 1, -1 do
candidates[i] = nil
end
-- Nur sortieren wenn tatsächlich mehr Lampen da sind als gezeichnet werden
-- dürfen. DrawSpotLightWithShadow ist teuer, in einer vollen Höhle sonst
-- der erstbeste Frame-Killer.
if count > config.lampMaxLights then
table.sort(candidates, function(a, b) return a.distance < b.distance end)
end
for i = 1, math.min(count, config.lampMaxLights) do
drawLamp(candidates[i].ped)
end
Wait(count > 0 and 0 or 300)
end
end)
+51 -2
View File
@@ -4,9 +4,14 @@ local shared = require 'config.shared'
local currentGear = {
mask = 0,
tank = 0,
lamp = 0,
enabled = false
}
--- 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
@@ -86,6 +91,12 @@ local function deleteGear()
DeleteEntity(currentGear.tank)
currentGear.tank = 0
end
if currentGear.lamp ~= 0 then
DetachEntity(currentGear.lamp, false, true)
DeleteEntity(currentGear.lamp)
currentGear.lamp = 0
end
end
--- Hängt ein Prop hart an einen Ped-Bone.
@@ -111,11 +122,34 @@ local function attachProp(ped, model, bone, offset, rotation)
return prop
end
--- Lampen-Model einmalig prüfen. lib.requestModel wirft bei ungültigen Models einen
--- Error - ohne diese Prüfung würde ein falsch geschriebener Prop-Name die komplette
--- Ausrüstung unbenutzbar machen, statt nur die Optik der Lampe zu kosten.
local lampModel = joaat(config.lampProp)
local hasLampProp = IsModelValid(lampModel) or IsModelInCdimage(lampModel)
if not hasLampProp then
lib.print.warn(('lampProp "%s" ist kein gültiges Model - die Lampe leuchtet, das Prop fehlt.'):format(config.lampProp))
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))
if hasLampProp then
currentGear.lamp = attachProp(ped, lampModel, MASK_BONE, vec3(0.1, 0.05, 0.0), vec3(180.0, 90.0, 0.0))
end
end
--- @param state boolean
local function setLamp(state)
if not currentGear.enabled then state = false end
lampOn = state
-- Repliziert: client/light.lua zeichnet daraus die Lampen aller Taucher.
LocalPlayer.state:set('divelight', state, true)
end
local function isPropIntact(prop)
@@ -123,6 +157,8 @@ local function isPropIntact(prop)
end
local function isGearIntact()
if hasLampProp and not isPropIntact(currentGear.lamp) then return false end
return isPropIntact(currentGear.mask) and isPropIntact(currentGear.tank)
end
@@ -151,6 +187,7 @@ local function removeGear(outOfAir, immediateSync)
syncTank(immediateSync)
currentGear.enabled = false
setLamp(false)
activeTank = nil
warned = {}
@@ -272,8 +309,8 @@ local function sendHudState(visible)
depth = currentDepth,
level = level,
deep = currentDepth >= config.deepWarningDepth,
hasLamp = false,
lamp = false
hasLamp = true,
lamp = lampOn
})
end
@@ -441,6 +478,17 @@ RegisterNetEvent('d4rk_divegear:client:useGear', function()
end
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)
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.
---
@@ -463,6 +511,7 @@ AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
syncTank(true)
setLamp(false)
deleteGear()
SendNUIMessage({ action = 'state', visible = false })
SetEnableScuba(cache.ped, false)