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>
150 lines
7.2 KiB
JavaScript
150 lines
7.2 KiB
JavaScript
#!/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 der Konsole.
|
|
//
|
|
// Nutzung: node docs/gen-hud-svg.mjs [ziel-ordner] (default: docs)
|
|
|
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const OUT = process.argv[2] ?? 'docs';
|
|
mkdirSync(OUT, { recursive: true });
|
|
|
|
const C = {
|
|
accent: '#38bdf8',
|
|
warn: '#fbbf24',
|
|
danger: '#f87171',
|
|
text: '#e2e8f0',
|
|
muted: '#94a3b8',
|
|
panel: '#09121c',
|
|
face: '#0b1622'
|
|
};
|
|
|
|
const F = "'Segoe UI',Roboto,system-ui,sans-serif";
|
|
|
|
// 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 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 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 fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
|
|
|
/** 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, level, depth, depthLimit, pressure, reservePressure, lamp, deep }) {
|
|
const ratio = Math.max(0, Math.min(1, oxygen / capacity));
|
|
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 svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${title}">
|
|
<defs>
|
|
<linearGradient id="water" x1="0" y1="0" x2="0.6" y2="1">
|
|
<stop offset="0%" stop-color="#0d3550"/>
|
|
<stop offset="100%" stop-color="#071a26"/>
|
|
</linearGradient>
|
|
</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"/>
|
|
|
|
${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 ${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>
|
|
|
|
${dial(RIGHT_CX, DIAL_CY, {
|
|
value: depth, max: 100, unit: 'm', label: 'TIEFE', decimals: 1,
|
|
zoneFrom: depthLimit, zoneTo: 100,
|
|
needleColour: depthColour, valueColour: depthColour
|
|
})}
|
|
|
|
<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>
|
|
`;
|
|
|
|
writeFileSync(join(OUT, file), svg);
|
|
console.log(file);
|
|
}
|
|
|
|
build({
|
|
file: 'hud-normal.svg', title: 'Tauch-HUD: normaler Zustand',
|
|
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, level: 'warn',
|
|
depth: 27.5, depthLimit: 45, deep: true, pressure: 31, reservePressure: 50, lamp: false
|
|
});
|
|
|
|
build({
|
|
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
|
|
});
|