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
+86 -48
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Erzeugt die HUD-Renderings für die README aus denselben Werten wie nui/style.css.
// Keine In-Game-Screenshots - eine massstabsgetreue Nachbildung des Overlays.
// Keine In-Game-Screenshots - eine massstabsgetreue Nachbildung der Konsole.
//
// Nutzung: node docs/gen-hud-svg.mjs [ziel-ordner] (default: docs)
@@ -17,44 +17,78 @@ const C = {
text: '#e2e8f0',
muted: '#94a3b8',
panel: '#09121c',
border: '#94a3b8'
face: '#0b1622'
};
// Geometrie spiegelt das Grid aus style.css:
// padding 14/22, Spalten 84 | 92 | 84 mit column-gap 22, row-gap 8, Lampe 23 hoch.
const PAD_X = 22, PAD_Y = 14, COL = 84, GAUGE = 92, GAP = 22, ROW_GAP = 8, LAMP_H = 23;
const PW = PAD_X * 2 + COL * 2 + GAUGE + GAP * 2;
const PH = PAD_Y * 2 + GAUGE + ROW_GAP + LAMP_H;
const F = "'Segoe UI',Roboto,system-ui,sans-serif";
const W = 520, H = PH + 70;
// Masse spiegeln das Grid aus style.css: padding 14/20, column-gap 20,
// Uhr 96, Ring 92, Beschriftung darunter, Lampe in Zeile 2.
const PAD_X = 20, PAD_Y = 14, DIAL = 96, GAUGE = 92, GAP = 20;
const ROW1 = 112, ROW_GAP = 6, LAMP_H = 23;
const PW = PAD_X * 2 + DIAL * 2 + GAUGE + GAP * 2;
const PH = PAD_Y * 2 + ROW1 + ROW_GAP + LAMP_H;
const W = 560, H = PH + 70;
const PX = (W - PW) / 2, PY = (H - PH) / 2;
const GX = PX + PAD_X + COL + GAP;
const CX = GX + GAUGE / 2, CY = PY + PAD_Y + GAUGE / 2;
const LEFT_CX = PX + PAD_X + DIAL / 2;
const RIGHT_CX = PX + PW - PAD_X - DIAL / 2;
const MID_CX = PX + PW / 2;
const DIAL_CY = PY + PAD_Y + DIAL / 2;
const GAUGE_CY = PY + PAD_Y + ROW1 / 2;
const R = 52 * (GAUGE / 120); // r=52 im viewBox 120, auf 92px skaliert
const SW = 8 * (GAUGE / 120);
const CIRC = 2 * Math.PI * R;
const n = (v) => Number(v.toFixed(2));
const rad = (d) => ((d - 90) * Math.PI) / 180;
const pt = (cx, cy, r, d) => [cx + r * Math.cos(rad(d)), cy + r * Math.sin(rad(d))];
const DEPTH_X = PX + PAD_X + COL; // rechtsbündig, zeigt zum Ring
const PRESS_X = GX + GAUGE + GAP; // linksbündig, zeigt zum Ring
const CAP_Y = PY + PAD_Y + 35;
const VAL_Y = PY + PAD_Y + 61;
const FONT = "'Segoe UI',Roboto,system-ui,sans-serif";
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
function readout(x, anchor, label, value, unit, colour) {
const unitTspan = `<tspan font-size="13" font-weight="500" fill="${C.muted}" dx="3">${unit}</tspan>`;
return `<text x="${x}" y="${CAP_Y}" text-anchor="${anchor}" font-family="${FONT}" font-size="9" font-weight="600" letter-spacing="1.4" fill="${C.muted}">${label}</text>
<text x="${x}" y="${VAL_Y}" text-anchor="${anchor}" font-family="${FONT}" font-size="26" font-weight="600" fill="${colour}">${value}${unitTspan}</text>`;
/** Zeigerinstrument. 270-Grad-Skala, unten offen - wie ein echtes Finimeter. */
function dial(cx, cy, { value, max, unit, label, zoneFrom, zoneTo, needleColour, valueColour, decimals = 0 }) {
// 96px Uhr, Geometrie aus dem viewBox 0 0 100 100 skaliert
const k = DIAL / 100;
const R = 42 * k, A0 = -135, SWEEP = 270;
const ang = (v) => A0 + SWEEP * Math.min(1, Math.max(0, v / (max || 1)));
let s = `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(46 * k)}" fill="${C.face}" stroke="${C.muted}" stroke-opacity="0.25" stroke-width="1.5"/>`;
if (zoneFrom !== undefined && zoneFrom < max) {
const a = ang(zoneFrom), b = ang(Math.min(zoneTo, max));
const [x1, y1] = pt(cx, cy, R - 2 * k, a);
const [x2, y2] = pt(cx, cy, R - 2 * k, b);
s += `<path d="M ${n(x1)} ${n(y1)} A ${n(R - 2 * k)} ${n(R - 2 * k)} 0 ${b - a > 180 ? 1 : 0} 1 ${n(x2)} ${n(y2)}" fill="none" stroke="${C.danger}" stroke-width="3.5" stroke-opacity="0.9"/>`;
}
for (let i = 0; i <= 10; i++) {
const a = A0 + (SWEEP * i) / 10, major = i % 2 === 0;
const [x1, y1] = pt(cx, cy, R - 6 * k, a);
const [x2, y2] = pt(cx, cy, (major ? R - 13 * k : R - 10 * k), a);
s += `<line x1="${n(x1)}" y1="${n(y1)}" x2="${n(x2)}" y2="${n(y2)}" stroke="#cbd5e1" stroke-opacity="${major ? 0.85 : 0.4}" stroke-width="${major ? 1.6 : 1}"/>`;
}
const [tipX, tipY] = pt(cx, cy, 33 * k, ang(value));
const [tailX, tailY] = pt(cx, cy, -8 * k, ang(value));
s += `<line x1="${n(tailX)}" y1="${n(tailY)}" x2="${n(tipX)}" y2="${n(tipY)}" stroke="${needleColour}" stroke-width="2.6" stroke-linecap="round"/>`;
s += `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(3.4 * k)}" fill="${needleColour}"/>`;
s += `<text x="${n(cx)}" y="${n(cy + 22)}" text-anchor="middle" font-size="16" font-weight="700" fill="${valueColour}" font-family="${F}">${value.toFixed(decimals)}<tspan font-size="10" font-weight="500" fill="${C.muted}" dx="2">${unit}</tspan></text>`;
s += `<text x="${n(cx)}" y="${n(cy + DIAL / 2 + 13)}" text-anchor="middle" font-size="9" font-weight="600" letter-spacing="1.4" fill="${C.muted}" font-family="${F}">${label}</text>`;
return s;
}
function build({ file, title, oxygen, capacity, depth, pressure, level, deep, reserve, lamp }) {
function build({ file, title, oxygen, capacity, level, depth, depthLimit, pressure, reservePressure, lamp, deep }) {
const ratio = Math.max(0, Math.min(1, oxygen / capacity));
const ring = level === 'danger' ? C.danger : level === 'warn' ? C.warn : C.accent;
const ringColour = level === 'danger' ? C.danger : level === 'warn' ? C.warn : C.accent;
const R = 52 * (GAUGE / 120), SW = 8 * (GAUGE / 120), CIRC = 2 * Math.PI * R;
const overLimit = depth >= depthLimit;
const depthColour = overLimit ? C.danger : deep ? C.warn : C.text;
const inReserve = pressure <= reservePressure;
const pressureColour = inReserve ? C.danger : C.text;
const lampColour = lamp ? C.accent : C.muted;
const lampX = CX - 43;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${title}">
<defs>
@@ -65,27 +99,30 @@ function build({ file, title, oxygen, capacity, depth, pressure, level, deep, re
</defs>
<rect width="${W}" height="${H}" fill="url(#water)"/>
<rect x="${n(PX)}" y="${n(PY)}" width="${PW}" height="${PH}" rx="16" fill="${C.panel}" fill-opacity="0.72" stroke="${C.muted}" stroke-opacity="0.18"/>
<rect x="${PX}" y="${PY}" width="${PW}" height="${PH}" rx="16"
fill="${C.panel}" fill-opacity="0.72" stroke="${C.border}" stroke-opacity="0.18"/>
${dial(LEFT_CX, DIAL_CY, {
value: pressure, max: 300, unit: 'bar', label: 'DRUCK',
zoneFrom: 0, zoneTo: reservePressure,
needleColour: pressureColour, valueColour: pressureColour
})}
<g transform="rotate(-90 ${CX} ${CY})">
<circle cx="${CX}" cy="${CY}" r="${R.toFixed(2)}" fill="none" stroke="${C.muted}" stroke-opacity="0.16" stroke-width="${SW.toFixed(2)}" stroke-linecap="round"/>
<circle cx="${CX}" cy="${CY}" r="${R.toFixed(2)}" fill="none" stroke="${ring}" stroke-width="${SW.toFixed(2)}" stroke-linecap="round"
stroke-dasharray="${CIRC.toFixed(2)}" stroke-dashoffset="${(CIRC * (1 - ratio)).toFixed(2)}"/>
<g transform="rotate(-90 ${n(MID_CX)} ${n(GAUGE_CY)})">
<circle cx="${n(MID_CX)}" cy="${n(GAUGE_CY)}" r="${n(R)}" fill="none" stroke="${C.muted}" stroke-opacity="0.16" stroke-width="${n(SW)}" stroke-linecap="round"/>
<circle cx="${n(MID_CX)}" cy="${n(GAUGE_CY)}" r="${n(R)}" fill="none" stroke="${ringColour}" stroke-width="${n(SW)}" stroke-linecap="round" stroke-dasharray="${n(CIRC)}" stroke-dashoffset="${n(CIRC * (1 - ratio))}"/>
</g>
<text x="${n(MID_CX)}" y="${n(GAUGE_CY - 1)}" text-anchor="middle" font-size="21" font-weight="600" fill="${ringColour}" font-family="${F}">${fmt(oxygen)}</text>
<text x="${n(MID_CX)}" y="${n(GAUGE_CY + 15)}" text-anchor="middle" font-size="9" font-weight="600" letter-spacing="1.4" fill="${C.muted}" font-family="${F}">LUFT</text>
<text x="${CX}" y="${CY - 1}" text-anchor="middle" font-family="${FONT}" font-size="21" font-weight="600" fill="${ring}">${fmt(oxygen)}</text>
<text x="${CX}" y="${CY + 15}" text-anchor="middle" font-family="${FONT}" font-size="9" font-weight="600" letter-spacing="1.4" fill="${C.muted}">LUFT</text>
${dial(RIGHT_CX, DIAL_CY, {
value: depth, max: 100, unit: 'm', label: 'TIEFE', decimals: 1,
zoneFrom: depthLimit, zoneTo: 100,
needleColour: depthColour, valueColour: depthColour
})}
${readout(DEPTH_X, 'end', 'TIEFE', depth.toFixed(1), 'm', deep ? C.warn : C.text)}
${readout(PRESS_X, 'start', 'DRUCK', String(Math.round(pressure)), 'bar', reserve ? C.danger : C.text)}
<g>
<rect x="${lampX}" y="${PY + PAD_Y + GAUGE + ROW_GAP}" width="86" height="${LAMP_H}" rx="8" fill="${lampColour}" fill-opacity="${lamp ? 0.15 : 0.1}"/>
<circle cx="${lampX + 14}" cy="${PY + PAD_Y + GAUGE + ROW_GAP + LAMP_H / 2}" r="4" fill="${lampColour}"/>
<text x="${lampX + 24}" y="${PY + PAD_Y + GAUGE + ROW_GAP + LAMP_H / 2 + 3.5}" font-family="${FONT}" font-size="10" font-weight="600" letter-spacing="1.2" fill="${lampColour}">LAMPE</text>
</g>
<rect x="${n(MID_CX - 43)}" y="${n(PY + PAD_Y + ROW1 + ROW_GAP)}" width="86" height="${LAMP_H}" rx="8" fill="${lampColour}" fill-opacity="${lamp ? 0.15 : 0.1}"/>
<circle cx="${n(MID_CX - 29)}" cy="${n(PY + PAD_Y + ROW1 + ROW_GAP + LAMP_H / 2)}" r="4" fill="${lampColour}"/>
<text x="${n(MID_CX - 19)}" y="${n(PY + PAD_Y + ROW1 + ROW_GAP + LAMP_H / 2 + 3.5)}" font-size="10" font-weight="600" letter-spacing="1.2" fill="${lampColour}" font-family="${F}">LAMPE</text>
</svg>
`;
@@ -95,17 +132,18 @@ function build({ file, title, oxygen, capacity, depth, pressure, level, deep, re
build({
file: 'hud-normal.svg', title: 'Tauch-HUD: normaler Zustand',
oxygen: 545, capacity: 1200, depth: 12.4, pressure: 136, level: 'ok', lamp: true
oxygen: 545, capacity: 1200, level: 'ok',
depth: 12.4, depthLimit: 45, pressure: 136, reservePressure: 50, lamp: true
});
build({
file: 'hud-warnung.svg', title: 'Tauch-HUD: Warnstufe und Reservedruck',
oxygen: 92, capacity: 900, depth: 27.5, pressure: 31,
level: 'warn', deep: true, reserve: true, lamp: false
oxygen: 92, capacity: 900, level: 'warn',
depth: 27.5, depthLimit: 45, deep: true, pressure: 31, reservePressure: 50, lamp: false
});
build({
file: 'hud-kritisch.svg', title: 'Tauch-HUD: kritischer Luftstand',
oxygen: 8, capacity: 600, depth: 31.7, pressure: 4,
level: 'danger', deep: true, reserve: true, lamp: true
file: 'hud-kritisch.svg', title: 'Tauch-HUD: unter der Tiefengrenze des Atemgases',
oxygen: 38, capacity: 900, level: 'danger',
depth: 52.4, depthLimit: 45, deep: true, pressure: 13, reservePressure: 50, lamp: true
});