-
DRUCK
-
0bar
+
+
+
+
+
0.0m
+
+
TIEFE
diff --git a/nui/script.js b/nui/script.js
index 38cf095..8ce979c 100644
--- a/nui/script.js
+++ b/nui/script.js
@@ -1,17 +1,36 @@
// 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.
+// Der Lua-Client schickt den kompletten Zustand samt Skalenendwerten, damit die
+// Locale-Texte und die Config an einer Stelle bleiben.
const RING = 327; // 2 * PI * 52, muss zu --ring in style.css passen
+// Zeigerinstrument: 270-Grad-Skala, unten offen - wie ein echtes Finimeter.
+const DIAL_START = -135;
+const DIAL_SWEEP = 270;
+const DIAL_R = 42;
+
const hud = document.getElementById('hud');
const gaugeFill = document.getElementById('gauge-fill');
const airValue = document.getElementById('air-value');
-const depthValue = document.getElementById('depth-value');
-const pressure = document.getElementById('pressure');
-const pressureValue = document.getElementById('pressure-value');
const lamp = document.getElementById('lamp');
+const dials = {
+ pressure: {
+ root: document.getElementById('pressure'),
+ needle: document.getElementById('pressure-needle'),
+ zone: document.getElementById('pressure-zone'),
+ ticks: document.getElementById('pressure-ticks'),
+ value: document.getElementById('pressure-value')
+ },
+ depth: {
+ root: document.getElementById('depth'),
+ needle: document.getElementById('depth-needle'),
+ zone: document.getElementById('depth-zone'),
+ ticks: document.getElementById('depth-ticks'),
+ value: document.getElementById('depth-value')
+ }
+};
+
const labels = {
air: document.getElementById('air-label'),
depth: document.getElementById('depth-label'),
@@ -19,6 +38,67 @@ const labels = {
lamp: document.getElementById('lamp-label')
};
+const NS = 'http://www.w3.org/2000/svg';
+
+const toRad = (deg) => ((deg - 90) * Math.PI) / 180;
+
+function pointOn(radius, deg) {
+ const r = toRad(deg);
+ return [50 + radius * Math.cos(r), 50 + radius * Math.sin(r)];
+}
+
+/** Winkel für einen Messwert auf der Skala. */
+function angleFor(value, max) {
+ const ratio = Math.min(1, Math.max(0, value / (max || 1)));
+ return DIAL_START + DIAL_SWEEP * ratio;
+}
+
+/** Striche einmalig zeichnen - die ändern sich nie. */
+function buildTicks(group) {
+ for (let i = 0; i <= 10; i++) {
+ const deg = DIAL_START + (DIAL_SWEEP * i) / 10;
+ const major = i % 2 === 0;
+ const [x1, y1] = pointOn(DIAL_R - 6, deg);
+ const [x2, y2] = pointOn(major ? DIAL_R - 13 : DIAL_R - 10, deg);
+ const line = document.createElementNS(NS, 'line');
+
+ line.setAttribute('x1', x1.toFixed(2));
+ line.setAttribute('y1', y1.toFixed(2));
+ line.setAttribute('x2', x2.toFixed(2));
+ line.setAttribute('y2', y2.toFixed(2));
+
+ if (major) line.setAttribute('class', 'major');
+
+ group.appendChild(line);
+ }
+}
+
+buildTicks(dials.pressure.ticks);
+buildTicks(dials.depth.ticks);
+
+/** Roter Bereich. Beim Finimeter die Reserve, beim Tiefenmesser die Grenze des Atemgases. */
+function setZone(dial, from, to, max) {
+ if (!(max > 0) || from === undefined || from >= max) {
+ dial.zone.removeAttribute('d');
+ return;
+ }
+
+ const a = angleFor(from, max);
+ const b = angleFor(Math.min(to, max), max);
+ const [x1, y1] = pointOn(DIAL_R - 2, a);
+ const [x2, y2] = pointOn(DIAL_R - 2, b);
+ const large = b - a > 180 ? 1 : 0;
+
+ dial.zone.setAttribute(
+ 'd',
+ `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${DIAL_R - 2} ${DIAL_R - 2} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`
+ );
+}
+
+function setNeedle(dial, value, max) {
+ dial.needle.setAttribute('transform', `rotate(${angleFor(value, max).toFixed(2)} 50 50)`);
+}
+
function formatTime(seconds) {
const total = Math.max(0, Math.floor(seconds));
const mm = String(Math.floor(total / 60)).padStart(2, '0');
@@ -36,17 +116,26 @@ function render(state) {
gaugeFill.style.strokeDashoffset = String(RING * (1 - ratio));
airValue.textContent = formatTime(state.oxygen);
- depthValue.textContent = state.depth.toFixed(1);
- pressureValue.textContent = String(Math.round(state.pressure ?? 0));
- pressure.classList.toggle('reserve', !!state.reserve);
// 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);
hud.classList.toggle('overlimit', !!state.overLimit);
+ const maxPressure = state.maxPressure || 300;
+ dials.pressure.value.textContent = String(Math.round(state.pressure ?? 0));
+ setNeedle(dials.pressure, state.pressure ?? 0, maxPressure);
+ setZone(dials.pressure, 0, state.reservePressure ?? 0, maxPressure);
+ dials.pressure.root.classList.toggle('alert', !!state.reserve);
+
+ const depthScale = state.depthScale || 100;
+ dials.depth.value.textContent = state.depth.toFixed(1);
+ setNeedle(dials.depth, state.depth, depthScale);
+ setZone(dials.depth, state.depthLimit, depthScale, depthScale);
+ dials.depth.root.classList.toggle('alert', !!state.overLimit);
+ dials.depth.root.classList.toggle('deep', !state.overLimit && !!state.deep);
+
lamp.classList.toggle('hidden', !state.hasLamp);
lamp.classList.toggle('on', !!state.lamp);
}
@@ -70,7 +159,9 @@ window.addEventListener('message', (event) => {
// Damit sich das HUD im Browser ohne FiveM anschauen laesst.
if (typeof GetParentResourceName !== 'function') {
render({
- visible: true, oxygen: 372, capacity: 600, depth: 12.4, pressure: 186,
+ visible: true, oxygen: 372, capacity: 600,
+ depth: 27.5, depthScale: 100, depthLimit: 45, deep: true,
+ pressure: 186, maxPressure: 300, reservePressure: 50,
level: 'ok', hasLamp: true, lamp: true
});
}
diff --git a/nui/style.css b/nui/style.css
index d73f09b..3bd94b2 100644
--- a/nui/style.css
+++ b/nui/style.css
@@ -8,7 +8,8 @@
--text: #e2e8f0;
--muted: #94a3b8;
--panel: rgba(9, 18, 28, 0.72);
- --ring: 327; /* 2 * PI * 52, Umfang des Gauge-Kreises */
+ --dial-face: #0b1622;
+ --ring: 327; /* 2 * PI * 52, Umfang des Luft-Rings */
}
* {
@@ -24,9 +25,9 @@ body {
user-select: none;
}
-/* Grid statt Flex: so sitzen Tiefe, Ring und Druck garantiert auf einer
- gemeinsamen Mittelachse, und die Lampen-Leiste hängt in einer zweiten Zeile
- mittig unter dem Ring - ohne Ausgleichs-Margins. */
+/* Dreier-Konsole: Finimeter, Luft, Tiefenmesser nebeneinander, Lampe darunter.
+ Grid statt Flex, damit die drei Instrumente auf einer Mittelachse sitzen und
+ die Lampen-Leiste ohne Ausgleichs-Margins in Zeile 2 haengt. */
#hud {
position: absolute;
left: 50%;
@@ -37,9 +38,9 @@ body {
grid-template-rows: auto auto;
align-items: center;
justify-items: center;
- column-gap: 22px;
- row-gap: 8px;
- padding: 14px 22px;
+ column-gap: 20px;
+ row-gap: 6px;
+ padding: 14px 20px;
border-radius: 16px;
background: var(--panel);
border: 1px solid rgba(148, 163, 184, 0.18);
@@ -54,7 +55,119 @@ body {
pointer-events: none;
}
-/* --- Luft-Gauge --- */
+.caption {
+ font-size: 9px;
+ font-weight: 600;
+ letter-spacing: 1.4px;
+ color: var(--muted);
+ text-transform: uppercase;
+}
+
+/* --- Zeigerinstrumente --- */
+
+.dial {
+ grid-row: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 5px;
+}
+
+#pressure { grid-column: 1; }
+#depth { grid-column: 3; }
+
+.dial-face {
+ position: relative;
+ width: 96px;
+ height: 96px;
+}
+
+.dial-face svg {
+ width: 100%;
+ height: 100%;
+}
+
+.dial .face {
+ fill: var(--dial-face);
+ stroke: rgba(148, 163, 184, 0.25);
+ stroke-width: 1.5;
+}
+
+/* Skalenzahlen gibt es bewusst nicht: bei dieser Groesse liest die niemand.
+ Der Zeiger zeigt die Tendenz, die Zahl in der Mitte den genauen Wert. */
+.dial .ticks line {
+ stroke: #cbd5e1;
+ stroke-opacity: 0.4;
+ stroke-width: 1;
+}
+
+.dial .ticks line.major {
+ stroke-opacity: 0.85;
+ stroke-width: 1.6;
+}
+
+.dial .zone {
+ fill: none;
+ stroke: var(--danger);
+ stroke-width: 3.5;
+ stroke-opacity: 0.9;
+}
+
+.dial .needle line {
+ stroke: var(--text);
+ stroke-width: 2.6;
+ stroke-linecap: round;
+ transition: stroke 260ms ease;
+}
+
+.dial .needle circle {
+ fill: var(--text);
+ transition: fill 260ms ease;
+}
+
+/* Kein transform-origin hier: das rotate(winkel 50 50) im transform-Attribut
+ bringt den Drehpunkt schon mit. Beides zusammen verschiebt den Zeiger aus der
+ Uhr heraus - CSS rechnet die Origin nochmal obendrauf. */
+.dial .needle {
+ transition: transform 260ms ease-out;
+}
+
+.dial-value {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ /* In den offenen unteren Teil der Skala, damit die Zahl nicht am
+ Zeiger-Drehpunkt klebt - wie das Sichtfenster eines echten Instruments. */
+ padding-top: 36px;
+ font-size: 16px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ line-height: 1;
+ transition: color 260ms ease;
+}
+
+.unit {
+ font-size: 10px;
+ font-weight: 500;
+ color: var(--muted);
+}
+
+/* Zeiger und Wert faerben sich, sobald der rote Bereich erreicht ist. */
+#pressure.alert .needle line,
+#depth.alert .needle line { stroke: var(--danger); }
+#pressure.alert .needle circle,
+#depth.alert .needle circle { fill: var(--danger); }
+#pressure.alert .dial-value,
+#depth.alert .dial-value { color: var(--danger); }
+
+#depth.deep .dial-value { color: var(--warn); }
+#depth.deep .needle line { stroke: var(--warn); }
+#depth.deep .needle circle { fill: var(--warn); }
+
+/* --- Luft-Ring --- */
.gauge {
position: relative;
@@ -105,56 +218,7 @@ body {
line-height: 1;
}
-.caption {
- font-size: 9px;
- font-weight: 600;
- letter-spacing: 1.4px;
- color: var(--muted);
- text-transform: uppercase;
-}
-
-/* --- Tiefe + Lampe --- */
-
-.readout {
- display: flex;
- flex-direction: column;
- gap: 3px;
- min-width: 84px;
- grid-row: 1;
-}
-
-/* Tiefe spiegelt den Druck: Werte zeigen jeweils zum Ring hin. */
-.readout--right {
- align-items: flex-end;
- grid-column: 1;
- justify-self: end;
-}
-
-.readout--right .value {
- justify-content: flex-end;
-}
-
-#pressure {
- grid-column: 3;
- justify-self: start;
-}
-
-.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);
-}
+/* --- Lampe --- */
.lamp {
grid-column: 2;
@@ -179,16 +243,14 @@ body {
fill: currentColor;
}
-.lamp.hidden {
- display: none;
-}
+.lamp.hidden { display: none; }
.lamp.on {
background: var(--accent-dim);
color: var(--accent);
}
-/* --- Warnzustaende --- */
+/* --- Warnzustaende der Restluft --- */
#hud.warn .gauge-fill { stroke: var(--warn); }
#hud.warn #air-value { color: var(--warn); }
@@ -197,16 +259,8 @@ body {
#hud.danger #air-value { color: var(--danger); }
#hud.danger .gauge { animation: pulse 1s ease-in-out infinite; }
-#hud.deep #depth-value { color: var(--warn); }
-
-/* Tiefengrenze des Atemgases überschritten - Tiefenrausch läuft. */
-#hud.overlimit #depth-value {
- color: var(--danger);
- animation: pulse 1s ease-in-out infinite;
-}
-
-/* Reservedruck: in der Tauchpraxis die Schwelle zum Aufstieg. */
-#pressure.reserve .value { color: var(--danger); }
+/* Tiefengrenze des Atemgases ueberschritten - Tiefenrausch laeuft. */
+#hud.overlimit #depth .dial-face { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }