From 28ab8e796ceb6df92ab36badaa4e92d79635e2c3 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Mon, 10 Aug 2026 23:49:03 +0200 Subject: [PATCH] feat(hud): NUI-Overlay mit Luft und Tauchtiefe Ersetzt die qbx.drawText2d-Anzeige (rohe Zahl plus Uhr-Emoji in der Ecke, jeden Frame neu gezeichnet) durch ein NUI-Overlay. - Luft als Ring mit mm:ss, Farbstufen aus config.warnAtSeconds abgeleitet, Pulsieren im kritischen Bereich - Tiefe in Metern ueber GetWaterHeight, geglaettet damit Wellengang die Zahl nicht zappeln laesst; Einfaerbung ab config.deepWarningDepth - Platz fuer den Lampen-Status, wird in Phase 5 befuellt - Sichtbar nur mit angelegter Ausruestung unter Wasser, mit Fade - Updates gedrosselt auf 4/s statt jeden Frame - Kein backdrop-filter (bekannter FiveM-Bug) Die geglaettete Tiefe speist auch den tiefenabhaengigen Verbrauch, der bisher bei jedem Tick neu und ungeglaettet gemessen wurde. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 + client/main.lua | 79 +++++++++++++++++--- config/client.lua | 11 +++ fxmanifest.lua | 5 ++ locales/de.json | 5 ++ locales/en.json | 5 ++ nui/index.html | 37 ++++++++++ nui/script.js | 66 +++++++++++++++++ nui/style.css | 184 ++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 .gitignore create mode 100644 nui/index.html create mode 100644 nui/script.js create mode 100644 nui/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99e8b76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Self-contained NUI-Vorschau, gebaut von .tools/nui-preview.mjs +nui/_preview.html diff --git a/client/main.lua b/client/main.lua index 90e73c1..1e67487 100644 --- a/client/main.lua +++ b/client/main.lua @@ -14,6 +14,20 @@ local activeTank = nil --- Bereits ausgelöste Warnschwellen des laufenden Tauchgangs. local warned = {} +--- Geglättete Tauchtiefe in Metern. Wird vom HUD-Thread gepflegt und sowohl für +--- die Anzeige als auch für den tiefenabhängigen Verbrauch benutzt. +local currentDepth = 0.0 + +--- Grenzen für die Farbstufen im HUD, aus config.warnAtSeconds abgeleitet. +local warnThreshold, dangerThreshold = 0, 0 + +for i = 1, #config.warnAtSeconds do + local threshold = config.warnAtSeconds[i] + + if threshold > warnThreshold then warnThreshold = threshold end + if dangerThreshold == 0 or threshold < dangerThreshold then dangerThreshold = threshold end +end + local MASK_MODEL = `p_d_scuba_mask_s` local TANK_MODEL = `p_s_scuba_tank_s` local MASK_BONE = 12844 @@ -37,10 +51,12 @@ local function getDepth() end --- Verbrauchsfaktor. Tiefer tauchen kostet mehr Luft. +--- Nutzt die geglättete Tiefe aus dem HUD-Thread, damit Wellengang den Verbrauch +--- nicht im Sekundentakt springen lässt. local function getDecayFactor() if not config.depthDecayEnabled then return 1.0 end - return math.min(config.maxDecayFactor, 1.0 + getDepth() / config.depthDecayReference) + return math.min(config.maxDecayFactor, 1.0 + currentDepth / config.depthDecayReference) end --- @param ped number? default cache.ped - beim Model-Wechsel muss der neue Ped explizit rein @@ -236,18 +252,60 @@ local function takeOffSuit() end end -local function startOxygenLevelDrawTextThread() +--- @param visible boolean +local function sendHudState(visible) + local oxygen = activeTank and activeTank.oxygen or 0.0 + local capacity = activeTank and activeTank.capacity or 1 + local level = 'ok' + + if oxygen <= dangerThreshold then + level = 'danger' + elseif oxygen <= warnThreshold then + level = 'warn' + end + + SendNUIMessage({ + action = 'state', + visible = visible, + oxygen = oxygen, + capacity = capacity, + depth = currentDepth, + level = level, + deep = currentDepth >= config.deepWarningDepth, + hasLamp = false, + lamp = false + }) +end + +--- Pflegt die geglättete Tiefe und schiebt den Zustand ins NUI. +--- Läuft auch über Wasser weiter, weil getDecayFactor die Tiefe braucht - nur die +--- Anzeige wird ausgeblendet. +local function startHudThread() CreateThread(function() + local wasVisible = false + + SendNUIMessage({ + action = 'labels', + air = locale('hud.air'), + depth = locale('hud.depth'), + lamp = locale('hud.lamp') + }) + 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 - }) + currentDepth = currentDepth + (getDepth() - currentDepth) * 0.25 + + local visible = config.hudEnabled and activeTank ~= nil and IsPedSwimmingUnderWater(cache.ped) + + if visible or wasVisible then + sendHudState(visible) + wasVisible = visible end - Wait(0) + + Wait(config.hudUpdateIntervalMs) end + + currentDepth = 0.0 + sendHudState(false) end) end @@ -370,7 +428,7 @@ local function putOnSuit() currentGear.enabled = true -- Initiate breathing suit audio startOxygenLevelDecrementerThread() - startOxygenLevelDrawTextThread() + startHudThread() startGearWatchdogThread() end end @@ -406,6 +464,7 @@ AddEventHandler('onResourceStop', function(resource) syncTank(true) deleteGear() + SendNUIMessage({ action = 'state', visible = false }) SetEnableScuba(cache.ped, false) SetPedMaxTimeUnderwater(cache.ped, config.maxTimeUnderwater) end) diff --git a/config/client.lua b/config/client.lua index 5f1e7ef..40c414b 100644 --- a/config/client.lua +++ b/config/client.lua @@ -27,4 +27,15 @@ return { depthDecayEnabled = true, depthDecayReference = 30.0, maxDecayFactor = 4.0, + + --- NUI-HUD mit Luft und Tiefe. Auf false bleibt die Ausrüstung voll nutzbar, + --- nur ohne Anzeige. + hudEnabled = true, + + --- Update-Intervall des HUD (ms). 250 = 4 Updates pro Sekunde, reicht für eine + --- ruhige Anzeige ohne unnötigen NUI-Traffic. + hudUpdateIntervalMs = 250, + + --- Ab dieser Tiefe wird die Tiefenanzeige eingefärbt (Meter). + deepWarningDepth = 25.0, } diff --git a/fxmanifest.lua b/fxmanifest.lua index 186e005..5737f94 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -17,10 +17,15 @@ server_script 'server/main.lua' client_script 'client/main.lua' +ui_page 'nui/index.html' + files { 'config/client.lua', 'config/shared.lua', 'locales/*.json', + 'nui/index.html', + 'nui/style.css', + 'nui/script.js', } lua54 'yes' diff --git a/locales/de.json b/locales/de.json index e11a0c7..2b284d9 100644 --- a/locales/de.json +++ b/locales/de.json @@ -21,5 +21,10 @@ }, "menu": { "choose_tank": "Tauchflasche wählen" + }, + "hud": { + "air": "Luft", + "depth": "Tiefe", + "lamp": "Lampe" } } diff --git a/locales/en.json b/locales/en.json index a8d1c43..c8d5ce8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -21,5 +21,10 @@ }, "menu": { "choose_tank": "Choose an air tank" + }, + "hud": { + "air": "Air", + "depth": "Depth", + "lamp": "Lamp" } } diff --git a/nui/index.html b/nui/index.html new file mode 100644 index 0000000..cdb012f --- /dev/null +++ b/nui/index.html @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/nui/script.js b/nui/script.js new file mode 100644 index 0000000..cb7ff52 --- /dev/null +++ b/nui/script.js @@ -0,0 +1,66 @@ +// Das HUD ist reine Anzeige - es sendet nichts zurueck an den Client. +// Der Lua-Client schickt den kompletten Zustand, damit die Locale-Texte an einer +// Stelle bleiben und hier nichts uebersetzt werden muss. + +const RING = 327; // 2 * PI * 52, muss zu --ring in style.css passen + +const hud = document.getElementById('hud'); +const gaugeFill = document.getElementById('gauge-fill'); +const airValue = document.getElementById('air-value'); +const depthValue = document.getElementById('depth-value'); +const lamp = document.getElementById('lamp'); + +const labels = { + air: document.getElementById('air-label'), + depth: document.getElementById('depth-label'), + lamp: document.getElementById('lamp-label') +}; + +function formatTime(seconds) { + const total = Math.max(0, Math.floor(seconds)); + const mm = String(Math.floor(total / 60)).padStart(2, '0'); + const ss = String(total % 60).padStart(2, '0'); + return `${mm}:${ss}`; +} + +function render(state) { + hud.classList.toggle('hidden', !state.visible); + + if (!state.visible) return; + + const capacity = state.capacity > 0 ? state.capacity : 1; + const ratio = Math.min(1, Math.max(0, state.oxygen / capacity)); + + gaugeFill.style.strokeDashoffset = String(RING * (1 - ratio)); + airValue.textContent = formatTime(state.oxygen); + depthValue.textContent = state.depth.toFixed(1); + + // Warnstufen kommen als Restsekunden vom Client, damit sie zu + // config.warnAtSeconds passen statt hier nochmal definiert zu werden. + hud.classList.toggle('warn', state.level === 'warn'); + hud.classList.toggle('danger', state.level === 'danger'); + hud.classList.toggle('deep', !!state.deep); + + lamp.classList.toggle('hidden', !state.hasLamp); + lamp.classList.toggle('on', !!state.lamp); +} + +window.addEventListener('message', (event) => { + const data = event.data; + + if (!data) return; + + if (data.action === 'labels') { + labels.air.textContent = data.air; + labels.depth.textContent = data.depth; + labels.lamp.textContent = data.lamp; + return; + } + + if (data.action === 'state') render(data); +}); + +// Damit sich das HUD im Browser ohne FiveM anschauen laesst. +if (typeof GetParentResourceName !== 'function') { + render({ visible: true, oxygen: 372, capacity: 600, depth: 12.4, level: 'ok', hasLamp: true, lamp: true }); +} diff --git a/nui/style.css b/nui/style.css new file mode 100644 index 0000000..969e01f --- /dev/null +++ b/nui/style.css @@ -0,0 +1,184 @@ +/* Kein backdrop-filter: bricht nach FiveM-Updates zu einem schwarzen Kasten auf. */ + +:root { + --accent: #38bdf8; + --accent-dim: rgba(56, 189, 248, 0.15); + --warn: #fbbf24; + --danger: #f87171; + --text: #e2e8f0; + --muted: #94a3b8; + --panel: rgba(9, 18, 28, 0.72); + --ring: 327; /* 2 * PI * 52, Umfang des Gauge-Kreises */ +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + overflow: hidden; + background: transparent; + font-family: 'Segoe UI', 'Roboto', system-ui, sans-serif; + user-select: none; +} + +#hud { + position: absolute; + left: 50%; + bottom: 7vh; + transform: translateX(-50%) translateY(0); + display: flex; + align-items: center; + gap: 18px; + padding: 14px 22px 14px 14px; + border-radius: 16px; + background: var(--panel); + border: 1px solid rgba(148, 163, 184, 0.18); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + color: var(--text); + transition: opacity 220ms ease, transform 220ms ease; +} + +#hud.hidden { + opacity: 0; + transform: translateX(-50%) translateY(14px); + pointer-events: none; +} + +/* --- Luft-Gauge --- */ + +.gauge { + position: relative; + width: 92px; + height: 92px; + flex-shrink: 0; +} + +.gauge svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.gauge circle { + fill: none; + stroke-width: 8; + stroke-linecap: round; +} + +.gauge-track { + stroke: rgba(148, 163, 184, 0.16); +} + +.gauge-fill { + stroke: var(--accent); + stroke-dasharray: var(--ring); + stroke-dashoffset: 0; + transition: stroke-dashoffset 260ms linear, stroke 260ms ease; +} + +.gauge-inner { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; +} + +#air-value { + font-size: 21px; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.5px; + line-height: 1; +} + +.caption { + font-size: 9px; + font-weight: 600; + letter-spacing: 1.4px; + color: var(--muted); + text-transform: uppercase; +} + +/* --- Tiefe + Lampe --- */ + +.readouts { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 92px; +} + +.readout { + display: flex; + flex-direction: column; + gap: 3px; +} + +.value { + display: flex; + align-items: baseline; + gap: 3px; + font-size: 26px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1; + transition: color 260ms ease; +} + +.unit { + font-size: 13px; + font-weight: 500; + color: var(--muted); +} + +.lamp { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 9px; + border-radius: 8px; + background: rgba(148, 163, 184, 0.1); + font-size: 10px; + font-weight: 600; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--muted); + transition: background 200ms ease, color 200ms ease; +} + +.lamp svg { + width: 13px; + height: 13px; + fill: currentColor; +} + +.lamp.hidden { + display: none; +} + +.lamp.on { + background: var(--accent-dim); + color: var(--accent); +} + +/* --- Warnzustaende --- */ + +#hud.warn .gauge-fill { stroke: var(--warn); } +#hud.warn #air-value { color: var(--warn); } + +#hud.danger .gauge-fill { stroke: var(--danger); } +#hud.danger #air-value { color: var(--danger); } +#hud.danger .gauge { animation: pulse 1s ease-in-out infinite; } + +#hud.deep .value { color: var(--warn); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +}