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 @@
+
+
+
+
+
+
+
+
+
+
+
+ --:--
+ LUFT
+
+
+
+
+
+
+
+
+
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; }
+}