mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Merge branch 'main' into feat/hebrew-i18n
This commit is contained in:
85 files changed
+3208
-126
No files matched your search
@@ -212,6 +212,10 @@ const _paintQueue = [];
|
||||
const _paintItemPool = [];
|
||||
const _paintRectPool = [];
|
||||
const _paintRectByKey = new Map();
|
||||
/** @type {Map<string, Map<string, object>>} This frame's paint items by source and entry id. */
|
||||
const _queuedItemBySource = new Map();
|
||||
/** Index rows that stop painting are dropped this many frames apart. */
|
||||
const QUEUED_ITEM_PRUNE_FRAMES = 600;
|
||||
const _hitRects = [];
|
||||
const _paintedBySource = Object.create(null);
|
||||
const _paintedSourceKeys = [];
|
||||
@@ -1042,6 +1046,32 @@ export function getOverlayPaintRect(sourceId, entryId) {
|
||||
return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opacity an entry paints with this frame, or 0 when it is not queued or the
|
||||
* yield-collision pass hid it. Custom lanes may call it before later lanes
|
||||
* publish paint rects. Leaves out the Cyber sonar fade.
|
||||
* @param {string} sourceId
|
||||
* @param {string} entryId
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getQueuedOverlayEntryAlpha(sourceId, entryId) {
|
||||
if (_destroyed) return 0;
|
||||
const item = _queuedItemBySource.get(sourceId)?.get(entryId);
|
||||
// Pooled items are reassigned every frame: check the frame and the entry.
|
||||
if (!item || item.stamp !== _frameStamp) return 0;
|
||||
// Declutter has already run when custom lanes call this.
|
||||
if (item.collisionHidden) return 0;
|
||||
const record = item.record;
|
||||
if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0;
|
||||
return (
|
||||
record.sourceAlpha *
|
||||
item.temporalAlpha *
|
||||
record.distanceAlpha *
|
||||
record.altitudeAlpha *
|
||||
(_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
|
||||
* @param {number} x
|
||||
@@ -2223,7 +2253,34 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
|
||||
item.zIndex = record.entry.zIndex;
|
||||
item.key = record.key;
|
||||
item.collisionHidden = false;
|
||||
item.stamp = _frameStamp;
|
||||
_paintQueue[_paintCount++] = item;
|
||||
// Keyed by source and id, not `record.key`, so a lookup builds no string.
|
||||
// Also holds cards fading out after they left `_records`.
|
||||
let bySource = _queuedItemBySource.get(record.entry.source);
|
||||
if (!bySource) {
|
||||
bySource = new Map();
|
||||
_queuedItemBySource.set(record.entry.source, bySource);
|
||||
}
|
||||
bySource.set(record.entry.id, item);
|
||||
}
|
||||
|
||||
/** Drop index rows for entries that stopped painting (bounded, not per frame). */
|
||||
function pruneQueuedItemIndex() {
|
||||
for (const [sourceId, bySource] of _queuedItemBySource) {
|
||||
for (const [entryId, item] of bySource) {
|
||||
// A pooled item that now paints another entry keeps a fresh stamp.
|
||||
const entry = item.record.entry;
|
||||
if (
|
||||
item.stamp !== _frameStamp ||
|
||||
entry.id !== entryId ||
|
||||
entry.source !== sourceId
|
||||
) {
|
||||
bySource.delete(entryId);
|
||||
}
|
||||
}
|
||||
if (bySource.size === 0) _queuedItemBySource.delete(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceCanPaint(sourceId) {
|
||||
@@ -2295,6 +2352,7 @@ function solveDomains(timestamp) {
|
||||
solveRevision += domain.arbiter.solveRevision;
|
||||
}
|
||||
_paintQueue.length = _paintCount;
|
||||
if (_frameStamp % QUEUED_ITEM_PRUNE_FRAMES === 0) pruneQueuedItemIndex();
|
||||
_solveDirty = false;
|
||||
_diagnostics.solveMs = solveMs;
|
||||
_diagnostics.selectedCount = selectedCount;
|
||||
@@ -2423,24 +2481,26 @@ function paintCustomLane(lane) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keyhole edge fade of one placed entry (1 for entries that do not fade). */
|
||||
function entryKeyholeAlpha(entry, placement, keyhole) {
|
||||
if (entry.edgeFade !== 'keyhole') return 1;
|
||||
const keyholeX = placement.centerX - keyhole.centerX;
|
||||
const keyholeY = placement.centerY - keyhole.centerY;
|
||||
return keyholeX * keyholeX + keyholeY * keyholeY <=
|
||||
keyhole.radius * keyhole.radius
|
||||
? 1
|
||||
: keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
}
|
||||
|
||||
function paintEntryItem(item, keyhole, sonar) {
|
||||
if (item.collisionHidden) return;
|
||||
const { record, placement } = item;
|
||||
const entry = record.entry;
|
||||
let keyholeAlpha = 1;
|
||||
if (entry.edgeFade === 'keyhole') {
|
||||
const keyholeX = placement.centerX - keyhole.centerX;
|
||||
const keyholeY = placement.centerY - keyhole.centerY;
|
||||
keyholeAlpha =
|
||||
keyholeX * keyholeX + keyholeY * keyholeY <=
|
||||
keyhole.radius * keyhole.radius
|
||||
? 1
|
||||
: keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
}
|
||||
const keyholeAlpha = entryKeyholeAlpha(entry, placement, keyhole);
|
||||
// All five channels are normalized at their source. Keep the multiply on
|
||||
// the hot paint path so its intermediate doubles remain unboxed; the pure
|
||||
// `combinedOverlayAlpha` export still specifies/tests the same binding.
|
||||
@@ -2829,6 +2889,7 @@ export function destroyWorldOverlay() {
|
||||
_accessibleActivatorByKey.clear();
|
||||
_accessibilitySignature = '';
|
||||
_paintRectByKey.clear();
|
||||
_queuedItemBySource.clear();
|
||||
_paintCount = 0;
|
||||
_paintRectCount = 0;
|
||||
_hitRectCount = 0;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
activateWorldOverlayAction,
|
||||
initWorldOverlay,
|
||||
subscribeWorldOverlayActions,
|
||||
getQueuedOverlayEntryAlpha,
|
||||
isOverlayPointVisible,
|
||||
normalizeOverlayEntry,
|
||||
paintLaneForOverlayEntry,
|
||||
@@ -1222,6 +1223,79 @@ test('custom detection lane receives the shared host frame and paints below ordi
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('a detection lane sees which cards this frame will paint before they publish paint rects', () => {
|
||||
// The detection lane paints before the cards publish this frame's paint
|
||||
// rects, so it reads the queued alpha instead.
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
initWorldOverlay(env.viewer);
|
||||
const seen = [];
|
||||
registerWorldOverlayPaintLane('detection', () => {
|
||||
seen.push({
|
||||
shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'),
|
||||
hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'),
|
||||
unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'),
|
||||
rect: getOverlayPaintRect('cards', 'SHOWN'),
|
||||
});
|
||||
}, { id: 'detection', active: true, target: 'detection' });
|
||||
setOverlayEntries('cards', [selectedEntry('SHOWN', {
|
||||
position: positionAtScreen(200, 150),
|
||||
})]);
|
||||
setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', {
|
||||
position: positionAtScreen(100, 100),
|
||||
})]);
|
||||
setOverlaySourceVisible('hidden-cards', false);
|
||||
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(seen[0], { shown: 1, hidden: 0, unknown: 0, rect: null },
|
||||
'the card is queued while its paint rect is still unpublished');
|
||||
assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane');
|
||||
assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null);
|
||||
|
||||
clearOverlaySource('cards');
|
||||
env.postRender.raise();
|
||||
assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued');
|
||||
destroyWorldOverlay();
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0);
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('a detection lane reads zero for a yielding entry the declutter pass hid this frame', () => {
|
||||
// Declutter runs before custom lanes, so a yielding entry it hid must not
|
||||
// report its full queued alpha to them.
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
initWorldOverlay(env.viewer);
|
||||
const seen = [];
|
||||
registerWorldOverlayPaintLane('detection', () => {
|
||||
seen.push({
|
||||
winner: getQueuedOverlayEntryAlpha('cards', 'ABOVE'),
|
||||
yielder: getQueuedOverlayEntryAlpha('labels', 'YIELDER'),
|
||||
});
|
||||
}, { id: 'detection', active: true, target: 'detection' });
|
||||
// Two cards take both vertical slots, so a vertical-only yielder has none.
|
||||
const at = positionAtScreen(200, 150);
|
||||
const card = (id, placement) => selectedEntry(id, {
|
||||
position: at,
|
||||
placement,
|
||||
verticalOnly: true,
|
||||
collisionGroup: 'shared-card',
|
||||
});
|
||||
setOverlayEntries('cards', [card('ABOVE', 'above'), card('BELOW', 'below')]);
|
||||
setOverlayEntries('labels', [selectedEntry('YIELDER', {
|
||||
position: at,
|
||||
verticalOnly: true,
|
||||
collisionGroup: 'yielding-label',
|
||||
yieldToCollisionGroup: 'shared-card',
|
||||
})]);
|
||||
|
||||
env.postRender.raise();
|
||||
assert.ok(getOverlayPaintRect('cards', 'ABOVE'), 'the winner painted');
|
||||
assert.equal(getOverlayPaintRect('labels', 'YIELDER'), null, 'the yielder was hidden');
|
||||
assert.ok(seen[0].winner > 0, 'the winner reads its queued alpha');
|
||||
assert.equal(seen[0].yielder, 0, 'the yielder hidden this frame reads zero');
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('custom-lane painter state is bracketed by host save/restore', () => {
|
||||
// A painter that leaks a clip or alpha must not corrupt later frames: the
|
||||
// host wraps every custom-lane painter in save/try/finally-restore. Pin the
|
||||
@@ -2489,3 +2563,49 @@ test('promoting an ambient entry to selected paints it once with no fading dupli
|
||||
assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown');
|
||||
} finally { env.cleanup(); }
|
||||
});
|
||||
|
||||
test('the queued-card index stays bounded while card identities churn', () => {
|
||||
// The index is internal: capture it through Map.prototype.set, like the
|
||||
// projection-record test.
|
||||
const originalSet = Map.prototype.set;
|
||||
let queuedIndex = null;
|
||||
Map.prototype.set = function captureQueuedIndex(key, value) {
|
||||
if (typeof key === 'string' && key.startsWith('churn-card-')
|
||||
&& value && typeof value === 'object' && 'temporalAlpha' in value
|
||||
&& 'placement' in value && 'stamp' in value) {
|
||||
queuedIndex = this;
|
||||
}
|
||||
return originalSet.call(this, key, value);
|
||||
};
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
const FRAMES = 1300;
|
||||
for (let frame = 0; frame < FRAMES; frame++) {
|
||||
// One card whose id changes every frame reuses one pooled paint item,
|
||||
// like a vessel cohort republished as the camera moves.
|
||||
setOverlayEntries('cards', [selectedEntry(`churn-card-${frame}`, {
|
||||
position: positionAtScreen(200, 150),
|
||||
})]);
|
||||
env.advanceTime(16);
|
||||
env.postRender.raise();
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame}`), 1,
|
||||
'the live card is queued in the frame just painted');
|
||||
if (frame > 0) {
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame - 1}`), 0,
|
||||
'a stale row never reports the previous card as queued');
|
||||
}
|
||||
}
|
||||
assert.ok(queuedIndex, 'the queued-card index was never populated');
|
||||
// Rows are pruned every 600 painted frames, so with one new id per frame
|
||||
// the index holds at most one prune window of ids plus the live card.
|
||||
const bound = 600 + 1;
|
||||
assert.ok(
|
||||
queuedIndex.size <= bound,
|
||||
`queued-card index holds ${queuedIndex.size} rows after ${FRAMES} identities (bound ${bound})`,
|
||||
);
|
||||
} finally {
|
||||
Map.prototype.set = originalSet;
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1217,6 +1217,9 @@ function buildPhase6DetectionWorkload(count) {
|
||||
: `FL${String(180 + (index % 220)).padStart(3, '0')}`,
|
||||
type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4],
|
||||
tier: index % 17 === 0 ? 'military' : undefined,
|
||||
// Vessels name their ambient card, so each callout asks the host for it.
|
||||
cardSourceId: index % 4 === 2 ? 'ais-live-vessels' : undefined,
|
||||
cardEntryId: index % 4 === 2 ? `vessel:${index}` : undefined,
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user