// Das HUD ist reine Anzeige - es sendet nichts zurueck an den Client. // 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'); // Das HUD ist auf 1080p entworfen. Ohne Nachskalieren waere es auf 1440p und 4K // deutlich zu klein, weil FiveM das NUI in der echten Bildschirmaufloesung // rendert. Der Faktor aus der Config kommt obendrauf. const DESIGN_HEIGHT = 1080; let configScale = 1; function applyScale() { const scale = Math.min(2.5, Math.max(0.6, (window.innerHeight / DESIGN_HEIGHT) * configScale)); document.documentElement.style.setProperty('--hud-scale', scale.toFixed(3)); } applyScale(); window.addEventListener('resize', applyScale); const gaugeFill = document.getElementById('gauge-fill'); const airValue = document.getElementById('air-value'); const lamp = document.getElementById('lamp'); const dials = { pressure: { needle: document.getElementById('pressure-needle'), zone: document.getElementById('pressure-zone'), ticks: document.getElementById('pressure-ticks'), value: document.getElementById('pressure-value') }, 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'), pressure: document.getElementById('pressure-label'), 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'); 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); // 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('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); hud.classList.toggle('pressure-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); hud.classList.toggle('depth-alert', !!state.overLimit); hud.classList.toggle('depth-deep', !state.overLimit && !!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') { if (typeof data.scale === 'number' && data.scale !== configScale) { configScale = data.scale; applyScale(); } labels.air.textContent = data.air; labels.depth.textContent = data.depth; labels.pressure.textContent = data.pressure; 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: 27.5, depthScale: 100, depthLimit: 45, deep: true, pressure: 186, maxPressure: 300, reservePressure: 50, level: 'ok', hasLamp: true, lamp: true }); }