mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(hud): Cyber HUD theme with GPU contact sonar (#706)
Add the opt-in Cyber HUD layout with coordinated map and cockpit panel styling, GPU-only contact sonar (rings, range, power, opacity, sector) with native-rendering fallback, and set_cyber_sonar voice controls. Sonar visual style inspired by kk376's tactical naval sonar (#214). Co-authored-by: Sameh Khamis <[email protected]> Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
74 files changed
+7320
-90
No files matched your search
+63
-4
@@ -48,6 +48,12 @@ import {
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
cyberSonarFrameDelay,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
isCyberContactThemeActive as isCyberMapThemeActive,
|
||||
} from '../cyberSonar.js';
|
||||
|
||||
/**
|
||||
* @module detection
|
||||
@@ -206,6 +212,8 @@ let _lastRenderMs = 0;
|
||||
let _lastPaintMs = 0;
|
||||
let _lastSolveMs = 0;
|
||||
let _throttleSkipCount = 0;
|
||||
let _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
let _cyberSonarRenderTimer = null;
|
||||
/** @type {Function|null} External callback invoked when the detection mode changes */
|
||||
let _onModeChange = null;
|
||||
/** @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null} */
|
||||
@@ -325,6 +333,7 @@ export function initDetection(viewer, layers, onModeChange) {
|
||||
|
||||
/** Release the host lane and all retained detection runtime state. */
|
||||
export function destroyDetection() {
|
||||
_clearCyberSonarRenderTimer();
|
||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
@@ -362,6 +371,25 @@ export function destroyDetection() {
|
||||
_lastPaintMs = 0;
|
||||
_lastSolveMs = 0;
|
||||
_throttleSkipCount = 0;
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
function _clearCyberSonarRenderTimer() {
|
||||
if (_cyberSonarRenderTimer != null) {
|
||||
globalThis.clearTimeout?.(_cyberSonarRenderTimer);
|
||||
_cyberSonarRenderTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _scheduleCyberSonarRender(nowMs) {
|
||||
if (_cyberSonarRenderTimer != null || !globalThis.setTimeout) return;
|
||||
const delay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
|
||||
_cyberSonarRenderTimer = globalThis.setTimeout(() => {
|
||||
_cyberSonarRenderTimer = null;
|
||||
if (isCyberSonarActive()) {
|
||||
governorRequestRender('cyber-sonar-cadence');
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -682,7 +710,18 @@ function _applyModeState() {
|
||||
function _shouldPaintDetectionLane(frame) {
|
||||
if (_mode === MODE_OFF || _suspended) return false;
|
||||
const layoutChanged = frame.layoutRevision !== _hostLayoutRevision;
|
||||
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
_frameCount++;
|
||||
if (isCyberSonarActive()) {
|
||||
const sonarDelay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
|
||||
if (!layoutChanged && sonarDelay > 0) {
|
||||
_scheduleCyberSonarRender(nowMs);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
_clearCyberSonarRenderTimer();
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
// The skip and the follow-up request come from ONE decision, so the valve
|
||||
// cannot drop a frame without handing its request forward — see
|
||||
// `detectionPaintSkipDecision`. (Skipping is a deferral, never a cancellation:
|
||||
@@ -729,6 +768,14 @@ function _paintDetectionLane(frame) {
|
||||
_lastRenderMs = performance.now() - start;
|
||||
_lastSolveMs = result.solveMs || 0;
|
||||
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
|
||||
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
if (isCyberSonarActive() && _lastDiagnostics?.visibleCount > 0) {
|
||||
_lastCyberSonarPaintAt = nowMs;
|
||||
_scheduleCyberSonarRender(nowMs);
|
||||
} else {
|
||||
_clearCyberSonarRenderTimer();
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
if (_lastDiagnostics) {
|
||||
_lastDiagnostics.frameTotalMs = _lastRenderMs;
|
||||
_lastDiagnostics.paintMs = _lastPaintMs;
|
||||
@@ -748,7 +795,7 @@ function _paintDetectionLane(frame) {
|
||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||
// 220 ms, and the settled alpha never painted).
|
||||
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
|
||||
nowMs,
|
||||
enabledAtMs: _enableTime,
|
||||
fadeMs: FADE_MS,
|
||||
animatingLabelCount: result.animatingCount || 0,
|
||||
@@ -996,7 +1043,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
const alpha = acquireFade * temporalAlpha * radialAlpha;
|
||||
const alpha =
|
||||
acquireFade * temporalAlpha * radialAlpha * (candidate.sonarFactor ?? 1);
|
||||
if (alpha <= 0.001) return;
|
||||
|
||||
let row = _calloutPool[_calloutCount];
|
||||
@@ -1146,6 +1194,7 @@ function _materializeCandidate(
|
||||
keyholeAlpha,
|
||||
placements,
|
||||
color: obj._candidateColor,
|
||||
sonarFactor: obj._candidateSonarFactor ?? 1,
|
||||
// Costed here rather than in the object sweep: the sweep walks every
|
||||
// observation in view, while this runs only for callouts that actually
|
||||
// placed — a budgeted handful per frame.
|
||||
@@ -1180,6 +1229,9 @@ function _drawOverlay(frame) {
|
||||
// with every arbiter call in this function — so the frame that paints an
|
||||
// animation's final state is the same frame that ends its demand.
|
||||
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
const sonarActive = isCyberSonarActive();
|
||||
const cyberMapActive = isCyberMapThemeActive();
|
||||
const sonarSampler = createCyberSonarSampler(width, height, now);
|
||||
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
|
||||
const shouldSolve =
|
||||
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||
@@ -1362,11 +1414,16 @@ function _drawOverlay(frame) {
|
||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||
const color = colorFor(resolveTier(obj));
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
const bracketAlpha = detectionBracketAlpha(
|
||||
const sonarFactor =
|
||||
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
|
||||
const sonarLabelFactor = sonarSampler.label(sonarFactor);
|
||||
const admissionAlpha = detectionBracketAlpha(
|
||||
obj.type,
|
||||
keyholeAlpha,
|
||||
keyholeOutsideOpacity,
|
||||
cyberMapActive,
|
||||
);
|
||||
const bracketAlpha = admissionAlpha * sonarFactor;
|
||||
if (bracketAlpha > 0) {
|
||||
const transit = obj.tier?.startsWith('transit_');
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(
|
||||
@@ -1404,13 +1461,15 @@ function _drawOverlay(frame) {
|
||||
|
||||
obj._cohortSourceId = sourceId;
|
||||
obj._cohortPriority = _semanticPriority(obj);
|
||||
obj._cohortBand = bracketAlpha >= 0.999 ? 8 : Math.floor(bracketAlpha * 8);
|
||||
obj._cohortBand =
|
||||
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
||||
obj._cohortHash = stableIdentityHash(layerId, sourceId);
|
||||
obj._candidateScreenX = sx;
|
||||
obj._candidateScreenY = sy;
|
||||
obj._candidateHalfW = halfW;
|
||||
obj._candidateHalfH = halfH;
|
||||
obj._candidateColor = color;
|
||||
obj._candidateSonarFactor = sonarLabelFactor;
|
||||
obj._candidatePrimary = primary;
|
||||
obj._candidateMicro = micro;
|
||||
|
||||
|
||||
@@ -92,14 +92,18 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
* @param {number} keyholeAlpha - Radial keyhole alpha for this contact.
|
||||
* @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means
|
||||
* the default, so a caller that does not know it reproduces the shipped look.
|
||||
* @param {boolean} [strictKeyholeFade] - When true, do not apply the AIR
|
||||
* readability floor; the bracket follows the radial keyhole alpha exactly.
|
||||
* @returns {number} Paint alpha, 0-1.
|
||||
*/
|
||||
export function detectionBracketAlpha(
|
||||
type,
|
||||
keyholeAlpha,
|
||||
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
|
||||
strictKeyholeFade = false,
|
||||
) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
|
||||
if (strictKeyholeFade) return alpha;
|
||||
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
|
||||
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,20 @@ test('side aircraft brackets stay readable without changing zero-opacity intent'
|
||||
assert.equal(detectionBracketAlpha('SAT', 0.05), 0.05);
|
||||
});
|
||||
|
||||
test('strict keyhole fading bypasses the aircraft floor for themed brackets', () => {
|
||||
assert.equal(
|
||||
detectionBracketAlpha(
|
||||
'AIR',
|
||||
0.01,
|
||||
KEYHOLE_OUTSIDE_OPACITY_DEFAULT,
|
||||
true,
|
||||
),
|
||||
0.01,
|
||||
);
|
||||
assert.equal(detectionBracketAlpha('AIR', 0.42, 1, true), 0.42);
|
||||
assert.equal(detectionBracketAlpha('AIR', 1, 0, true), 1);
|
||||
});
|
||||
|
||||
test('the bracket floor anchor mirrors the real keyhole default it is calibrated to', () => {
|
||||
// detectionPolicy stays Cesium-free, so the anchor is a mirror. If the keyhole
|
||||
// default ever moves, this fails rather than silently shifting the shipped
|
||||
|
||||
@@ -285,7 +285,15 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
|
||||
assert.ok(paintLane, 'detection.js still has a paint lane');
|
||||
assert.doesNotMatch(paintLane, /nowMs: Date\.now\(\)/,
|
||||
'demand must not re-sample the clock — that is the dropped terminal frame');
|
||||
assert.match(paintLane, /nowMs: Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/);
|
||||
assert.match(
|
||||
paintLane,
|
||||
/const nowMs = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
paintLane,
|
||||
/detectionNeedsFollowUpFrame\(\{[\s\S]*?\n\s*nowMs,/,
|
||||
'the sonar cadence and fade demand share the same frame-clock sample',
|
||||
);
|
||||
assert.match(paintLane, /animatingLabelCount: result\.animatingCount/,
|
||||
'demand counts fades in both directions');
|
||||
// …and `animatingCount` must really be the BOTH-directions count. Feeding the
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { detectionBracketAlpha } from './detectionPolicy.js';
|
||||
|
||||
// Exercise the actual renderer's admission and paint expressions. A sweep is
|
||||
// appearance only: it must not reshuffle candidates at the cohort cutoff.
|
||||
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
const alphaBlock = source.match(
|
||||
/const admissionAlpha = detectionBracketAlpha\([\s\S]*?const bracketAlpha = [^;]+;/,
|
||||
)?.[0];
|
||||
const bandAssignment = source.match(/obj\._cohortBand\s*=\s*[^;]+;/)?.[0];
|
||||
|
||||
test('Sonar changes paint alpha without changing detection cohort admission', () => {
|
||||
assert.ok(alphaBlock, 'renderer separates admission from paint');
|
||||
assert.ok(bandAssignment);
|
||||
for (const cyberMapActive of [false, true]) {
|
||||
for (const type of ['AIR', 'SAT', 'SEA']) {
|
||||
for (const keyholeAlpha of [0, 0.01, 0.125, 0.5, 0.999, 1]) {
|
||||
const alpha = detectionBracketAlpha(
|
||||
type,
|
||||
keyholeAlpha,
|
||||
0.35,
|
||||
cyberMapActive,
|
||||
);
|
||||
const expectedBand = alpha >= 0.999 ? 8 : Math.floor(alpha * 8);
|
||||
for (const sonarFactor of [0.01, 0.12, 0.5, 1]) {
|
||||
const state = {
|
||||
obj: { type },
|
||||
keyholeAlpha,
|
||||
keyholeOutsideOpacity: 0.35,
|
||||
cyberMapActive,
|
||||
sonarFactor,
|
||||
detectionBracketAlpha,
|
||||
};
|
||||
vm.runInNewContext(
|
||||
alphaBlock + bandAssignment + '; result = bracketAlpha;',
|
||||
state,
|
||||
);
|
||||
assert.equal(state.obj._cohortBand, expectedBand);
|
||||
assert.equal(state.result, alpha * sonarFactor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1059,6 +1059,10 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
this.parentElement = null;
|
||||
}
|
||||
setAttribute() {}
|
||||
// This fixture has no parsed DOM children; it exercises native label
|
||||
// ownership, not the replay overlay's separately rendered icon children.
|
||||
querySelectorAll() { return []; }
|
||||
querySelector() { return null; }
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 1600, height: 900 }; }
|
||||
getContext() { return this.tagName === 'CANVAS' ? context : null; }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user