feat(light): auf die spieleigene Scuba-Lampe umgestellt
Lint / Lint Resource (push) Has been cancelled

GTA bringt seit v1493 eine eigene Lampe an der Tauchausruestung mit:

    SetEnableScubaGearLight(ped, toggle)   0xEE2476B9EE4A094F
    IsScubaGearLightEnabled(ped)           0x88274C11CF0D866D

Die sitzt an der richtigen Stelle, leuchtet in Blickrichtung und braucht weder
Prop noch Offsets noch einen Draw-Thread. Damit faellt der komplette
DrawSpotLight-Aufbau weg: Kegelwerte, Farbe, Schatten, Distanzfilter,
Lichter-Limit, die bone-relative Richtungsrechnung und das Sortieren der
Kandidaten pro Frame. client/light.lua schrumpft von ~110 auf ~55 Zeilen.

Gefunden ueber wobozkyng/esx_scuba.

- Der Flag ist ein lokaler Ped-Zustand, der Statebag bleibt also. Gesetzt wird
  er per AddStateBagChangeHandler und zusaetzlich alle 500ms nachgezogen, weil
  Peds beim Streaming, Respawn und Model-Wechsel neu erzeugt werden
- Die Lampe haengt an der Scuba-Kleidung (Component 8). Das Outfit wird bewusst
  nicht angefasst; wer sie nicht traegt, bekommt beim Einschalten eine Meldung
  statt stiller Wirkungslosigkeit
- /divelampcheck sagt, ob das aktuelle Outfit eine Lampe hat
- Prop ist jetzt optional und per Default aus

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 08:45:02 +02:00
co-authored by Claude Opus 5
parent 037acf1bfe
commit d2f6e3cd30
6 changed files with 119 additions and 189 deletions
+37 -37
View File
@@ -172,44 +172,31 @@ FiveM* umlegbar.
| Option | Default | Bedeutung |
|---|---|---|
| `lampKey` | `'l'` | Vorbelegung |
| `lampBone` | `12844` | Bone für Prop **und** Licht — derselbe, damit der Strahl aus der Lampe kommt. `28422` = Hand statt Kopf |
| `lampProp` | `'prop_cs_police_torch'` | Optik. Ungültiges Model → Warnung im Log, Licht funktioniert trotzdem. `''` = kein Prop |
| `lampPropOffset` / `lampPropRotation` | `vec3(0,0,0)` | Sitz des Props am Bone — mit `/divelamp` einstellen |
| `lampOffset` | `vec3(0,0,0)` | Ursprung des Lichtkegels, bone-relativ |
| `lampDirection` | `vec3(0, 1, 0)` | Richtung des Kegels, bone-relativ |
| `lampColour` | `{255, 250, 235}` | RGB |
| `lampDistance` / `lampBrightness` / `lampRoundness` / `lampRadius` / `lampFalloff` | `28/3/0/16/2` | Form des Lichtkegels, Werte aus `d4rk_phone` übernommen |
| `lampShadows` | `false` | `true` nutzt `DrawSpotLightWithShadow` — sieht besser aus, kostet aber deutlich Performance |
| `lampDrawDistance` | `45.0` | Ab hier werden fremde Lampen nicht mehr gezeichnet |
| `lampMaxLights` | `4` | Gleichzeitige Lampen, die nächstgelegenen gewinnen |
| `lampReconcileIntervalMs` | `500` | Wie oft der Lampen-Zustand auf die Peds in der Nähe nachgezogen wird |
| `lampProp` | `''` | Optionales Prop am Kopf, rein optisch. Die Lampe leuchtet unabhängig davon |
| `lampBone` / `lampPropOffset` / `lampPropRotation` | `12844` / `vec3(0,0,0)` | Sitz des optionalen Props — mit `/divelamp` einstellen |
> **Beim Balancing beachten:** `maxDrainPerSecond` (shared) muss über
> `maxDecayFactor` (client) liegen. Sonst hält der Server den ehrlichen Verbrauch
> in großer Tiefe für Cheat und schreibt Luft zurück.
## Lampe einstellen
## Debug-Befehle
Wo Prop und Lichtkegel am Kopf sitzen, hängt daran, wie das Prop-Model selbst
ausgerichtet ist — das lässt sich nicht am Schreibtisch erraten. Mit
`debug = true` in `config/client.lua` gibt es zwei Befehle, die die Werte im
laufenden Spiel ändern und die fertige Config-Zeile ins Log schreiben:
Mit `debug = true` in `config/client.lua`:
```
/divelamp <x> <y> <z> <rx> <ry> <rz> Sitz des Lampen-Props
/divelight <x> <y> <z> <dx> <dy> <dz> Ursprung und Richtung des Lichtkegels
/divelampcheck Hat das aktuelle Outfit eine Scuba-Lampe?
/divelamp <x> <y> <z> <rx> <ry> <rz> Sitz des optionalen Lampen-Props
```
Das Prop wird sofort neu angehängt, das Licht zieht ohne Verzögerung mit — kein
Resource-Restart pro Versuch. Sind die Werte gut, die ausgegebenen Zeilen in die
Config übernehmen und `debug` wieder ausschalten.
`/divelamp` hängt das Prop sofort neu an und schreibt die fertige Config-Zeile ins
Log — kein Resource-Restart pro Versuch. Alternativ mit einem Prop-Attach-Editor
wie [noted_propattacher](https://github.com/NotedDevelopment/noted_propattacher)
ausmessen.
Alternativ mit einem Prop-Attach-Editor wie
[noted_propattacher](https://github.com/NotedDevelopment/noted_propattacher)
ausmessen: Prop spawnen, mit der Maus an die Stelle ziehen, Werte übernehmen.
> Im Basegame gibt es **keine** Tauchlampe. `prop_cs_police_torch` ist eine
> Polizei-Taschenlampe; Varianten davon sind `prop_police_torch` und
> `prop_scn_police_torch`. Wer eine echte Tauchlampe will, braucht ein Custom-Model.
> Ein Tauchlampen-Prop gibt es im Basegame nicht. Was es gibt, sind Varianten
> derselben Polizei-Taschenlampe: `prop_cs_police_torch`, `prop_police_torch`,
> `prop_scn_police_torch`. Für die Beleuchtung braucht es das Prop ohnehin nicht.
## Exports
@@ -222,18 +209,31 @@ Gedacht für HUD- und Screenshot-Resources. Zusätzlich folgt das HUD automatisc
`IsHudHidden()`, greift also bei allem was `DisplayHud(false)` setzt, ohne dass
etwas angebunden werden muss.
## Wie die Lampe synchron wird
## Wie die Lampe funktioniert
Licht ist in GTA nie synchronisiert — `DrawSpotLight` zeichnet nur lokal für den
aktuellen Frame. Der Lampenzustand wird deshalb als Statebag repliziert
(`LocalPlayer.state:set('divelight', …, true)`), und jeder Client zeichnet die
Lampen aller Taucher in seiner Nähe selbst.
Es wird kein Licht selbst gezeichnet. GTA bringt seit v1493 (Cayo Perico) eine
eigene Lampe an der Tauchausrüstung mit:
Die Richtung kommt aus **zwei bone-relativen Punkten** am Kopf-Bone, nicht aus
`GetEntityForwardVector`: beim Tauchen liegt der Ped waagerecht im Wasser, der
Entity-Forward würde stur horizontal leuchten. Die Offsets von `GetPedBoneCoords`
sind laut Native-Doku *"relative to the bone's rotation"*, die Differenz zweier
solcher Punkte enthält also den Pitch.
```lua
SetEnableScubaGearLight(ped, toggle) -- 0xEE2476B9EE4A094F
IsScubaGearLightEnabled(ped) -- 0x88274C11CF0D866D
```
Die sitzt an der richtigen Stelle, leuchtet in Blickrichtung und braucht weder
Prop noch Offsets noch einen Draw-Thread.
> **Voraussetzung:** Die Lampe hängt an der echten **Scuba-Kleidung** des Peds
> (Component 8). Trägt der Spieler die nicht, tut die Native nichts. Diese
> Resource fasst das Outfit bewusst **nicht** an — das gehört ins Kleidungs-System.
> Beim Einschalten ohne passendes Outfit gibt es eine Meldung statt stiller Wirkungslosigkeit.
> Mit `debug = true` sagt `/divelampcheck`, ob das aktuelle Outfit eine Lampe hat.
Der Flag ist ein **lokaler** Ped-Zustand und wird nicht von selbst repliziert.
Deshalb läuft der Zustand über einen Statebag
(`LocalPlayer.state:set('divelight', …, true)`), und jeder Client setzt den Flag
für die Taucher in seiner Nähe selbst — sofort per `AddStateBagChangeHandler`,
plus ein Abgleich alle `lampReconcileIntervalMs`, weil Peds beim Streaming,
Respawn und Model-Wechsel neu erzeugt werden und den Flag dabei verlieren.
## Entwicklung
+35 -86
View File
@@ -1,111 +1,60 @@
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.
-- Tauchlampe über die spieleigene Scuba-Lampe.
--
-- 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.
-- GTA bringt seit v1493 (Cayo Perico) eine eigene Lampe an der Tauchausrüstung mit:
-- SetEnableScubaGearLight(ped, toggle) 0xEE2476B9EE4A094F
-- IsScubaGearLightEnabled(ped) 0x88274C11CF0D866D
--
-- Die sitzt an der richtigen Stelle, leuchtet in Blickrichtung und braucht weder
-- Prop noch Offsets noch einen eigenen Draw-Thread.
--
-- WICHTIG: Die Lampe hängt an der echten Scuba-Kleidung des Peds (Component 8).
-- Trägt der Spieler die nicht, tut die Native nichts - dieses Script fasst das
-- Outfit bewusst nicht an.
--
-- Der Flag ist ein lokaler Ped-Zustand und wird nicht von selbst repliziert.
-- Deshalb wie vorher der Umweg über den Statebag: jeder Client setzt den Flag für
-- jeden Taucher in seiner Nähe selbst.
local STATE_KEY = 'divelight'
--- Kandidaten des aktuellen Frames. Wird wiederverwendet statt jedes Frame neu
--- angelegt, damit der GC bei Dauerbetrieb nichts zu tun bekommt.
local candidates = {}
--- @param ped number
--- @param enabled boolean
local function applyLight(ped, enabled)
if ped == 0 or not DoesEntityExist(ped) then return end
--- 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
-- Nur setzen wenn es sich tatsächlich ändert. Die Native ist billig, aber der
-- Aufruf pro Ped und Durchlauf wäre trotzdem unnötig.
if IsScubaGearLightEnabled(ped) == enabled then return end
local from = GetPedBoneCoords(ped, config.lampBone, offset.x, offset.y, offset.z)
local ahead = GetPedBoneCoords(ped, config.lampBone,
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
SetEnableScubaGearLight(ped, enabled)
end
local function drawLamp(ped)
local from, direction = getLampVectors(ped)
--- Reagiert sofort auf Änderungen, auch von entfernten Spielern.
AddStateBagChangeHandler(STATE_KEY, nil, function(bagName, _, value)
local player = GetPlayerFromStateBagName(bagName)
if not from then return end
if player == 0 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
applyLight(GetPlayerPed(player), value == true)
end)
--- Der Statebag-Handler allein reicht nicht: Peds werden beim Streaming neu
--- erzeugt, beim Respawn ausgetauscht und beim Model-Wechsel ersetzt - der Flag
--- ist dann wieder weg, ohne dass sich der Statebag geändert hat. Deshalb
--- regelmäßig nachziehen.
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
applyLight(GetPlayerPed(player), state and state[STATE_KEY] == true)
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)
Wait(config.lampReconcileIntervalMs)
end
end)
+21 -13
View File
@@ -144,12 +144,22 @@ local function attachGear(ped)
end
--- @param state boolean
local function setLamp(state)
--- @param notifyIfUnavailable boolean? Rückmeldung, wenn das Outfit keine Lampe hat
local function setLamp(state, notifyIfUnavailable)
if not currentGear.enabled then state = false end
lampOn = state
-- Repliziert: client/light.lua zeichnet daraus die Lampen aller Taucher.
-- Repliziert: client/light.lua setzt daraus den Lampen-Flag bei allen Tauchern.
LocalPlayer.state:set('divelight', state, true)
-- Lokal sofort setzen statt auf den Abgleich-Thread zu warten.
SetEnableScubaGearLight(cache.ped, state)
-- Die Spiel-Lampe hängt an der Scuba-Kleidung (Component 8). Fehlt die, tut
-- die Native nichts - ohne Hinweis sieht das nach einem kaputten Keybind aus.
if state and notifyIfUnavailable and not IsScubaGearLightEnabled(cache.ped) then
exports.qbx_core:Notify(locale('error.no_lamp_on_outfit'), 'error')
end
end
local function isPropIntact(prop)
@@ -532,7 +542,7 @@ lib.addKeybind({
onPressed = function()
if not currentGear.enabled then return end
setLamp(not lampOn)
setLamp(not lampOn, true)
end
})
@@ -575,17 +585,15 @@ if config.debug then
lib.print.info(('lampPropOffset = %s,\nlampPropRotation = %s,'):format(fmtVec(offset), fmtVec(rotation)))
end, false)
RegisterCommand('divelight', function(_, args)
local offset, direction = readVec(args, 1), readVec(args, 4)
--- Die Spiel-Lampe braucht die Scuba-Kleidung am Ped. Dieser Befehl sagt, ob
--- das aktuelle Outfit sie überhaupt hat - sonst rätselt man, warum nichts
--- leuchtet.
RegisterCommand('divelampcheck', function()
SetEnableScubaGearLight(cache.ped, true)
local works = IsScubaGearLightEnabled(cache.ped)
SetEnableScubaGearLight(cache.ped, lampOn)
if not offset or not direction then
lib.print.info('/divelight <x> <y> <z> <dx> <dy> <dz> - Ursprung und Richtung des Lichtkegels')
return
end
config.lampOffset, config.lampDirection = offset, direction
lib.print.info(('lampOffset = %s,\nlampDirection = %s,'):format(fmtVec(offset), fmtVec(direction)))
lib.print.info(('Scuba-Lampe am aktuellen Outfit: %s'):format(works and 'ja' or 'nein'))
end, false)
end
+22 -51
View File
@@ -66,58 +66,29 @@ return {
--- Tastenbelegungen unter "FiveM" umlegen.
lampKey = 'l',
--- Bone, an dem Lampe und Licht hängen. Beides derselbe Bone, damit der
--- Strahl auch sichtbar aus der Lampe kommt.
--- 12844 = Kopf (wie die Maske) - Strahl folgt der Blickrichtung
--- 28422 = Hand (wie das Prop in d4rk_phone) - Handlampe, aber beim
--- Schwimmen sind die Arme seitlich ausgestreckt, der Kegel
--- wandert also mit den Schwimmzügen
--- Die Lampe ist die spieleigene Scuba-Lampe (SetEnableScubaGearLight, seit
--- v1493). Sie sitzt an der richtigen Stelle und leuchtet in Blickrichtung -
--- deshalb gibt es hier weder Offsets noch Farb- und Kegelwerte.
---
--- ACHTUNG: Sie hängt an der echten Scuba-Kleidung des Peds (Component 8).
--- Trägt der Spieler die nicht, passiert nichts. Diese Resource fasst das
--- Outfit bewusst nicht an - das macht bei Bedarf das Kleidungs-System.
--- Wie oft der Lampen-Zustand auf die Peds in der Nähe nachgezogen wird (ms).
--- Nötig, weil Peds beim Streaming, Respawn und Model-Wechsel neu erzeugt
--- werden und den Flag dabei verlieren.
lampReconcileIntervalMs = 500,
--- Optionales Prop am Kopf, rein optisch. Die Lampe leuchtet unabhängig davon.
--- Standardmäßig aus, weil die Spiel-Lampe schon an der Ausrüstung sitzt.
--- Ein Basegame-Tauchlampen-Prop gibt es nicht; die Polizei-Taschenlampe
--- ('prop_cs_police_torch', 'prop_police_torch', 'prop_scn_police_torch')
--- passt optisch nur bedingt.
lampProp = '',
--- Bone und Sitz des optionalen Props. 12844 = Kopf (wie die Maske).
--- Mit /divelamp im Spiel einstellen statt hier zu raten.
lampBone = 12844,
--- Prop am Kopf. Rein optisch - das Licht hängt nicht daran, es wird nur
--- vom selben Bone aus gezeichnet. Ist das Model ungültig, leuchtet es
--- trotzdem und es gibt eine Warnung im Log.
--- Im Basegame gibt es keine Tauchlampe. Alternativen sind Varianten
--- derselben Polizei-Lampe: 'prop_police_torch', 'prop_scn_police_torch'.
--- Auf '' setzen = gar kein Prop, nur Licht.
lampProp = 'prop_cs_police_torch',
--- Sitz des Props am Bone. Wie in d4rk_phone bewusst auf null: das Prop sitzt
--- dann genau im Bone statt auf geratenen Offsets. Beim Kopf-Bone darf ein
--- kleiner Versatz nach vorne/seitlich nötig sein, damit die Lampe nicht im
--- Schädel steckt - mit /divelamp im Spiel einstellen statt hier zu raten.
lampPropOffset = vec3(0.0, 0.0, 0.0),
lampPropRotation = vec3(0.0, 0.0, 0.0),
--- Ursprung des Lichtkegels relativ zum Bone (bone-relativ, nicht Welt).
--- Sollte ungefähr dort liegen, wo das Prop sitzt.
lampOffset = vec3(0.0, 0.0, 0.0),
--- Richtung des Lichtkegels, ebenfalls bone-relativ. Leuchtet der Kegel in die
--- falsche Richtung, hier die Achse tauschen oder das Vorzeichen drehen -
--- welche lokale Achse des Kopf-Bones nach vorne zeigt, hängt am Skelett.
lampDirection = vec3(0.0, 1.0, 0.0),
--- Lichtwerte aus d4rk_phone übernommen - dort im Betrieb erprobt, statt hier
--- neu geraten. Leicht warmweiß wie eine echte Taschenlampe.
lampColour = { 255, 250, 235 },
lampDistance = 28.0, -- Reichweite in Metern
lampBrightness = 3.0, -- Helligkeit
lampRoundness = 0.0, -- Weichzeichnung des Kegelrands
lampRadius = 16.0, -- Öffnungswinkel
lampFalloff = 2.0, -- Abfall zum Rand hin
--- Schatten kosten deutlich Performance. d4rk_phone nutzt bewusst das
--- schattenlose DrawSpotLight, deshalb hier auch der Default - unter Wasser
--- fällt der Unterschied kaum auf, in einer Höhle mit mehreren Tauchern
--- dagegen sehr wohl.
lampShadows = false,
--- Ab dieser Entfernung werden fremde Lampen nicht mehr gezeichnet (Meter).
lampDrawDistance = 45.0,
--- Wie viele Lampen gleichzeitig gezeichnet werden. Die nächstgelegenen
--- gewinnen. Schützt vor Frame-Einbrüchen bei Gruppentauchgängen.
lampMaxLights = 4,
}
+2 -1
View File
@@ -6,7 +6,8 @@
"no_tank": "Du hast keine Tauchflasche zum Auffüllen dabei!",
"tank_empty": "Diese Tauchflasche ist leer!",
"tank_already_full": "Diese Tauchflasche ist bereits voll!",
"out_of_air": "Deine Luft ist alle! Sofort auftauchen!"
"out_of_air": "Deine Luft ist alle! Sofort auftauchen!",
"no_lamp_on_outfit": "Deine Tauchausrüstung hat keine Lampe."
},
"success": {
"took_out": "Du hast deine Ausrüstung abgelegt!",
+2 -1
View File
@@ -6,7 +6,8 @@
"no_tank": "You don't have an air tank to refill!",
"tank_empty": "This air tank is empty!",
"tank_already_full": "This air tank is already full!",
"out_of_air": "You're out of air! Surface now!"
"out_of_air": "You're out of air! Surface now!",
"no_lamp_on_outfit": "Your diving suit doesn't have a lamp."
},
"success": {
"took_out": "You've taken your gear off!",