feat(hud): Dreier-Konsole mit Finimeter und Tiefenmesser

Statt zweier Zahlenfelder jetzt zwei Zeigerinstrumente links und rechts vom
Luft-Ring - wie eine echte Tauchkonsole. Der Ring bleibt in der Mitte, weil die
Restzeit die Zahl ist, auf die es ankommt.

- Finimeter 0-300 bar, roter Bereich = Reserve unter reservePressure
- Tiefenmesser 0-depthDialMax, roter Bereich = Tiefengrenze des Atemgases. Der
  faengt bei Pressluft bei 45 m an und bei Trimix erst bei 100 m - der
  Gefahrenbereich haengt also an der getragenen Flasche. Gibt es real nicht,
  hilft im Spiel aber sofort beim Einschaetzen
- Skalenzahlen bewusst weggelassen: bei 96 px kollidieren sie mit allem und
  liest ohnehin niemand. Zeiger zeigt die Tendenz, die Zahl in der Uhr den Wert
- Zeiger und Wert faerben sich rot, sobald der rote Bereich erreicht ist

Beim Bauen gefunden: CSS transform-origin auf dem Zeiger wird zusaetzlich zum
rotate(winkel 50 50) im Attribut angewendet, der Drehpunkt wandert dadurch
doppelt und der Zeiger landet ausserhalb der Uhr. transform-origin ist raus.

README-Renderings auf die Konsole umgebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 09:55:45 +02:00
co-authored by Claude Opus 5
parent 64b2549cf9
commit 30c40c9f69
10 changed files with 404 additions and 201 deletions
+101 -10
View File
@@ -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
});
}