Thing/packages/eve-browser-lowering/dist/fields-browser-adapter.js

552 lines
24 KiB
JavaScript

import { drawGravityFieldWebGl } from "./fields-webgl-renderer.js";
const FIELD_SCHEMAS = new Set([
"gamecult.fields.splats.v1",
"gamecult.fields.gravity.v1",
"gamecult.fields.objects.v1",
]);
export const fieldsBrowserAdapter = {
pluginId: "fields.surface",
capabilities: ["field.surface2d", "gravity.surface", "field.scalar", "field.vector", "field.objects"],
componentKinds: ["field.surface2d", "gravity.surface"],
schemas: [...FIELD_SCHEMAS],
normalizeDocument: normalizeFieldsDocument,
renderComponent: renderFieldsComponent,
};
function renderFieldsComponent(component, props, layout, style, options, context) {
const canvas = document.createElement("canvas");
canvas.className = "cultui-gravity-surface";
if (component.id)
canvas.id = component.id;
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", stringValue(props.label, "gravity surface"));
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.pointerEvents = "none";
context.applyGeneratedLayout(canvas, layout, style);
let drawQueued = false;
const state = {};
const draw = () => drawGravitySurface(canvas, props, state, context.resolveAssetUrl);
const scheduleDraw = () => {
if (!canvas.isConnected || drawQueued)
return;
drawQueued = true;
requestAnimationFrame(() => {
drawQueued = false;
if (canvas.isConnected)
draw();
});
};
canvas.addEventListener("cultui:asset-loaded", scheduleDraw);
let refreshTimer = 0;
let observer;
const cleanup = () => {
if (refreshTimer)
window.clearInterval(refreshTimer);
observer?.disconnect();
canvas.removeEventListener("cultui:asset-loaded", scheduleDraw);
};
const refreshDocuments = () => {
if (!canvas.isConnected) {
cleanup();
return;
}
void resolveFieldsDocuments(component, props, options, context.providerId, state)
.then(changed => {
if (changed && canvas.isConnected)
scheduleDraw();
})
.catch(error => {
state.lastError = error instanceof Error ? error.message : String(error);
});
};
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(scheduleDraw);
observer.observe(canvas);
}
scheduleDraw();
const refreshMs = Math.max(33, Math.min(1000, positiveInteger(props.stateRefreshMs, 100)));
refreshTimer = window.setInterval(refreshDocuments, refreshMs);
queueMicrotask(refreshDocuments);
window.setTimeout(scheduleDraw, 300);
window.setTimeout(scheduleDraw, 1000);
return canvas;
}
async function resolveFieldsDocuments(component, props, options, providerId, state) {
if (state.loading)
return false;
const requests = [
fieldDocumentRequest(component, props, "renderSplats", "renderSplatsDocumentId", "renderSplatsSchemaId"),
fieldDocumentRequest(component, props, "gravity", "gravityDocumentId", "gravitySchemaId"),
fieldDocumentRequest(component, props, "objects", "objectsDocumentId", "objectsSchemaId"),
].filter((request) => Boolean(request?.documentId));
if (requests.length === 0)
return false;
state.loading = true;
try {
let changed = false;
for (const request of requests) {
const resolved = await options.documentResolver?.(request, component);
let document = record(normalizeFieldsDocument(resolved?.schemaId || request.schemaId, resolved?.document));
if (Object.keys(document).length === 0) {
document = record(normalizeFieldsDocument(request.schemaId, await fetchFieldsDocument(request, providerId || options.provider?.providerId)));
}
if (Object.keys(document).length === 0)
continue;
const key = request.slotId === "renderSplats" ? "renderSplats" : request.slotId === "gravity" ? "gravity" : "objects";
const previousFrame = numberValue(state[key]?.frameId, -1);
const nextFrame = numberValue(document.frameId, previousFrame);
state[key] = document;
changed ||= nextFrame !== previousFrame;
}
state.lastLoadedAt = Date.now();
return changed;
}
finally {
state.loading = false;
}
}
async function fetchFieldsDocument(request, providerId) {
if (typeof fetch !== "function" || typeof window === "undefined" || !providerId)
return undefined;
const params = new URLSearchParams({ documentId: request.documentId });
if (request.schemaId)
params.set("schemaId", request.schemaId);
if (request.slotId)
params.set("slotId", request.slotId);
const response = await fetch(`/eir/document/${encodeURIComponent(providerId)}?${params.toString()}`, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok)
return undefined;
return record(await response.json()).document;
}
function fieldDocumentRequest(component, props, slotId, documentIdProp, schemaIdProp) {
const slot = (component.embeddedDocuments || []).find(candidate => String(candidate?.slotId || "") === slotId);
const documentId = firstString(props[documentIdProp], slot?.documentId);
if (!documentId)
return undefined;
return {
documentId,
schemaId: firstString(props[schemaIdProp], slot?.schemaId),
presentationKind: firstString(slot?.presentationKind) || "data",
slotId,
};
}
export function normalizeFieldsDocument(schemaId, value) {
const tuple = Array.isArray(value) ? value : undefined;
const documentSchema = tuple ? tuple[0] : value?.schema;
const schema = String(schemaId || documentSchema || "");
if (!tuple || !FIELD_SCHEMAS.has(schema))
return value;
if (schema === "gamecult.fields.splats.v1")
return normalizeSplats(tuple);
if (schema === "gamecult.fields.gravity.v1")
return normalizeGravity(tuple);
return normalizeObjects(tuple);
}
function drawGravitySurface(canvas, props, state, resolveAssetUrl) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || canvas.clientWidth || 1));
const height = Math.max(1, Math.floor(rect.height || canvas.clientHeight || 1));
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const pixelWidth = Math.max(1, Math.floor(width * dpr));
const pixelHeight = Math.max(1, Math.floor(height * dpr));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext("2d");
if (!ctx)
return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const bounds = fitBoundsToAspect(gravityBounds(props), width, height);
const gravityDocument = objectProps(state?.gravity);
const bodies = gravityBodiesFromDocument(state?.gravity) || parseGravityBodies(firstString(props.bodies, ""));
const objects = gravityObjectsFromDocument(state?.objects) || parseGravityObjects(firstString(props.objects, ""));
if (!drawGravityFieldWebGl(ctx, props, gravityDocument, bodies, bounds, width, height, state?.renderSplats)) {
drawFieldLoweringFailure(ctx, props, width, height, "field.surface2d requires WebGL2 float render targets");
}
drawGravityBodies(ctx, bodies, props, bounds, width, height, resolveAssetUrl);
drawGravityObjects(ctx, objects, props, bounds, width, height, resolveAssetUrl);
}
function drawFieldLoweringFailure(ctx, props, width, height, message) {
ctx.save();
ctx.fillStyle = firstString(props.failureBackground, "rgba(0, 0, 0, 0.72)");
ctx.fillRect(0, 0, width, height);
ctx.font = firstString(props.failureFont, "700 13px Cascadia Mono, Consolas, monospace");
ctx.fillStyle = firstString(props.failureColor, "rgba(255, 184, 79, 0.95)");
ctx.fillText(message, 16, 28);
ctx.restore();
}
function drawGravityBodies(ctx, bodies, props, bounds, width, height, resolveAssetUrl) {
for (const body of bodies) {
const screen = worldToSurface(bounds, body.x, body.y, width, height);
const visualRadius = Math.max(18, Math.min(190, body.radius / Math.max(1, bounds.maxX - bounds.minX) * width * 0.32));
const icon = resolveAssetUrl(body.icon);
const fallbackIconSize = body.kind.toLowerCase().includes("sun")
? Math.max(numberProp(props.sunIconMinPx, 34), visualRadius * numberProp(props.sunIconScale, 0.72))
: Math.max(numberProp(props.bodyIconMinPx, 24), visualRadius * numberProp(props.bodyIconScale, 0.48));
const iconSize = body.iconSize > 0 ? body.iconSize : fallbackIconSize;
if (icon)
drawGravityAsset(ctx, icon, screen.x, screen.y, iconSize, 0.92);
if (body.key) {
drawGravityLabel(ctx, props, body.key.replace(/^local\./, ""), screen.x + visualRadius * 0.44, screen.y - visualRadius * 0.48, firstString(props.bodyLabelColor, "rgba(226, 244, 255, 0.82)"));
}
}
}
const gravityImageCache = new Map();
function drawGravityAsset(ctx, src, x, y, size, alpha) {
let image = gravityImageCache.get(src);
if (!image) {
image = new Image();
image.decoding = "async";
image.onload = () => requestAnimationFrame(() => {
const canvas = ctx.canvas;
canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
});
image.src = src;
gravityImageCache.set(src, image);
}
if (!image.complete || image.naturalWidth === 0)
return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.drawImage(image, x - size / 2, y - size / 2, size, size);
ctx.restore();
}
function drawGravityObjects(ctx, objects, props, bounds, width, height, resolveAssetUrl) {
for (const obj of objects) {
const screen = worldToSurface(bounds, obj.x, obj.y, width, height);
if (screen.x < -40 || screen.x > width + 40 || screen.y < -40 || screen.y > height + 40)
continue;
const size = obj.kind.toLowerCase().includes("station")
? numberProp(props.stationIconSizePx, 34)
: obj.controlled ? numberProp(props.shipIconSizePx, 22) : numberProp(props.remoteShipIconSizePx, 18);
const color = factionColor(props, obj.faction, obj.controlled, obj.visibility);
const icon = resolveAssetUrl(obj.icon);
ctx.save();
ctx.globalAlpha = Math.max(0.25, Math.min(1, obj.visibility));
ctx.translate(screen.x, screen.y);
const angle = Math.atan2(obj.directionY, obj.directionX) + Math.PI / 2;
if (obj.kind.toLowerCase().includes("ship"))
ctx.rotate(angle);
if (icon) {
const image = gravityImageCache.get(icon);
if (!image) {
const loading = new Image();
loading.decoding = "async";
loading.onload = () => ctx.canvas.dispatchEvent(new CustomEvent("cultui:asset-loaded"));
loading.src = icon;
gravityImageCache.set(icon, loading);
}
else if (image.complete && image.naturalWidth > 0) {
ctx.drawImage(image, -size / 2, -size / 2, size, size);
}
}
ctx.restore();
drawGravityLabel(ctx, props, obj.label, screen.x + size * 0.55, screen.y - size * 0.45, color);
}
}
function drawGravityLabel(ctx, props, label, x, y, color) {
if (!label)
return;
ctx.save();
ctx.font = firstString(props.objectLabelFont, "700 12px Ubuntu, system-ui, sans-serif");
ctx.textBaseline = "middle";
ctx.lineWidth = numberProp(props.objectLabelStrokeWidth, 3);
ctx.strokeStyle = firstString(props.objectLabelStroke, "rgba(0, 0, 0, 0.72)");
ctx.fillStyle = color;
ctx.strokeText(label, x, y);
ctx.fillText(label, x, y);
ctx.restore();
}
function factionColor(props, faction, controlled, visibility) {
const alpha = Math.max(0.25, Math.min(1, visibility));
if (controlled)
return colorTemplate(firstString(props.objectControlledColor, "rgba(122, 240, 255, {alpha})"), alpha);
const normalized = faction.toLowerCase();
if (normalized.includes("raider"))
return colorTemplate(firstString(props.objectRaiderColor, "rgba(255, 143, 74, {alpha})"), alpha);
if (normalized.includes("neutral"))
return colorTemplate(firstString(props.objectNeutralColor, "rgba(232, 232, 224, {alpha})"), alpha);
return colorTemplate(firstString(props.objectDefaultColor, "rgba(214, 244, 255, {alpha})"), alpha);
}
function colorTemplate(template, alpha) {
return template.replaceAll("{alpha}", String(alpha));
}
function parseGravityBodies(value) {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
x: Number(parts[2]) || 0,
y: Number(parts[3]) || 0,
radius: Math.max(1, Number(parts[4]) || 1),
depth: Number(parts[5]) || 0,
exponent: Number(parts[6]) || 1,
waveRadius: Number(parts[7]) || 0,
waveDepth: Number(parts[8]) || 0,
waveSpeed: Number(parts[9]) || 0,
icon: parts[10] ?? "",
iconSize: Number(parts[12]) || 0,
tint: parts[11] ?? "",
};
});
}
function parseGravityObjects(value) {
return value
.split(";")
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const parts = entry.split("|");
return {
key: parts[0] ?? "",
kind: parts[1] ?? "",
label: parts[2] ?? "",
x: Number(parts[3]) || 0,
y: Number(parts[4]) || 0,
directionX: Number(parts[5]) || 0,
directionY: Number(parts[6]) || 1,
faction: parts[7] ?? "",
controlled: parts[8] === "1" || parts[8] === "true",
visibility: Number(parts[9]) || 1,
icon: parts[10] ?? "",
};
});
}
function gravityBodiesFromDocument(document) {
const influences = Array.isArray(document?.gravityInfluences) ? document.gravityInfluences : [];
const bodies = Array.isArray(document?.bodies) ? document.bodies : [];
if (influences.length === 0 && bodies.length === 0)
return undefined;
const displayBodiesByKey = new Map();
for (const body of bodies) {
const key = firstString(body.bodyKey, body.name, "");
if (key)
displayBodiesByKey.set(key, body);
}
return (influences.length > 0 ? influences : bodies).map((body) => {
const key = firstString(body.bodyKey, body.name, "body");
const displayBody = displayBodiesByKey.get(key);
const kind = firstString(body.kind, "Body");
return {
key,
kind,
x: numberProp(body.x, 0),
y: numberProp(body.y, 0),
radius: Math.max(1, numberProp(body.radius, 80)),
depth: numberProp(body.gravityDepth, 0),
exponent: numberProp(body.gravityDepthExponent, 1),
waveRadius: numberProp(body.waveRadius, 0),
waveDepth: numberProp(body.waveDepth, 0),
waveSpeed: numberProp(body.waveSpeed, 0),
icon: assetUriFromRef(body.iconAsset) || assetUriFromRef(displayBody?.iconAsset),
iconSize: numberProp(displayBody?.iconSize, numberProp(body.iconSize, 0)),
tint: "",
};
});
}
function gravityObjectsFromDocument(document) {
const objects = Array.isArray(document?.objects) ? document.objects : [];
if (objects.length === 0)
return undefined;
return objects.map((obj) => {
const kind = firstString(obj.kind, "object");
return {
key: firstString(obj.entityKey, String(obj.entityIndex ?? ""), "object"),
kind,
label: firstString(obj.displayName, obj.entityKey, "object"),
x: numberProp(obj.x, 0),
y: numberProp(obj.y, 0),
directionX: numberProp(obj.directionX, 1),
directionY: numberProp(obj.directionY, 0),
faction: firstString(obj.factionKey, ""),
controlled: boolProp(obj.controlled),
visibility: numberProp(obj.visibility, 1),
icon: assetUriFromRef(obj.iconAsset),
};
});
}
function assetUriFromRef(value) {
const asset = objectProps(value);
const uri = firstString(asset.uri, "");
return uri;
}
function gravityBounds(props) {
const radius = Math.max(1, numberProp(props.viewRadius, Math.max(1200, numberProp(props.terrainRadius, 1200))));
return {
minX: numberProp(props.minX, -radius),
minY: numberProp(props.minY, -radius),
maxX: numberProp(props.maxX, radius),
maxY: numberProp(props.maxY, radius),
};
}
function fitBoundsToAspect(bounds, width, height) {
const worldWidth = Math.max(0.0001, bounds.maxX - bounds.minX);
const worldHeight = Math.max(0.0001, bounds.maxY - bounds.minY);
const canvasAspect = Math.max(0.0001, width / Math.max(1, height));
const worldAspect = worldWidth / worldHeight;
const centerX = (bounds.minX + bounds.maxX) * 0.5;
const centerY = (bounds.minY + bounds.maxY) * 0.5;
if (canvasAspect > worldAspect) {
const fittedWidth = worldHeight * canvasAspect;
return {
minX: centerX - fittedWidth * 0.5,
maxX: centerX + fittedWidth * 0.5,
minY: bounds.minY,
maxY: bounds.maxY,
};
}
const fittedHeight = worldWidth / canvasAspect;
return {
minX: bounds.minX,
maxX: bounds.maxX,
minY: centerY - fittedHeight * 0.5,
maxY: centerY + fittedHeight * 0.5,
};
}
function worldToSurface(bounds, x, y, width, height) {
return {
x: (x - bounds.minX) / Math.max(1, bounds.maxX - bounds.minX) * width,
y: (y - bounds.minY) / Math.max(1, bounds.maxY - bounds.minY) * height,
};
}
function normalizeSplats(value) {
const splats = array(value[9]);
return {
schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3],
runId: value[4], zoneIndex: value[5], zoneName: value[6], viewport: normalizeViewport(value[7]),
layers: array(value[8]).map(normalizeSplatLayer),
splats: {
count: splats[0] ?? 0, centerX: splats[1] || [], centerY: splats[2] || [],
halfExtentX: splats[3] || [], halfExtentY: splats[4] || [], rotationCos: splats[5] || [],
rotationSin: splats[6] || [], channel: splats[7] || [], falloff: splats[8] || [],
valueR: splats[9] || [], valueG: splats[10] || [], valueB: splats[11] || [], valueA: splats[12] || [],
sourceKey: splats[13] || [], layerIndex: splats[14] || [], sourceKind: splats[15] || [],
frequencyX: splats[16] || [], frequencyY: splats[17] || [], phaseX: splats[18] || [],
phaseY: splats[19] || [], animationSpeed: splats[20] || [], sourceFlags: splats[21] || [],
},
};
}
function normalizeGravity(value) {
return {
schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3],
runId: value[4], zoneIndex: value[5], zoneName: value[6], viewport: normalizeViewport(value[7]),
gravityInfluences: array(value[8]).map(normalizeGravityInfluence),
bodies: array(value[9]).map(normalizeBody), terrainRadius: value[10], terrainDepth: value[11],
terrainDepthExponent: value[12], terrainWaveFrequency: value[13],
};
}
function normalizeObjects(value) {
return {
schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3],
runId: value[4], zoneIndex: value[5], zoneName: value[6], currentEntityKey: value[7],
viewport: normalizeViewport(value[8]), controlledEntityIndices: array(value[9]),
objects: array(value[10]).map(normalizeObject),
};
}
function normalizeViewport(value) {
const row = array(value);
return row.length ? { minX: row[0], minY: row[1], maxX: row[2], maxY: row[3] } : value;
}
function normalizeSplatLayer(value) {
const row = array(value);
if (!row.length)
return value;
return {
layerKey: row[0], displayName: row[1], channel: row[2], blendMode: row[3], graphicsFormat: row[4],
clearBeforeDraw: row[5], clearR: row[6], clearG: row[7], clearB: row[8], clearA: row[9],
};
}
function normalizeGravityInfluence(value) {
const row = array(value);
if (!row.length)
return value;
return {
bodyKey: row[0], orbitKey: row[1], kind: row[2], x: row[3], y: row[4], radius: row[5],
gravityDepth: row[6], gravityDepthExponent: row[7], waveRadius: row[8], waveDepth: row[9], waveSpeed: row[10],
};
}
function normalizeBody(value) {
const row = array(value);
if (!row.length)
return value;
return {
bodyKey: row[0], orbitKey: row[1], name: row[2], kind: row[3], x: row[4], y: row[5], radius: row[6],
isAsteroidBelt: row[7], body: row[8], iconAsset: normalizeAsset(row[9]),
};
}
function normalizeObject(value) {
const row = array(value);
if (!row.length)
return value;
return {
entityIndex: row[0], entityKey: row[1], displayName: row[2], kind: row[3], factionKey: row[4],
x: row[5], y: row[6], z: row[7], directionX: row[8], directionY: row[9], velocityX: row[10],
velocityY: row[11], controlled: row[12], targetEntityIndex: row[13], isActive: row[14], visibility: row[15],
status: normalizeStatus(row[16]), inventory: array(row[17]).map(normalizeInventoryItem), iconAsset: normalizeAsset(row[18]),
};
}
function normalizeStatus(value) {
const row = array(value);
return row.length ? { hull: row[0], shield: row[1], heat: row[2] } : value || {};
}
function normalizeInventoryItem(value) {
const row = array(value);
if (!row.length)
return value;
return {
source: row[0], itemKey: row[1], quantity: row[2], quality: row[3], durability: row[4], enabled: row[5],
sourceIndex: row[6], x: row[7], y: row[8], iconAsset: normalizeAsset(row[9]),
};
}
function normalizeAsset(value) {
const row = array(value);
if (!row.length)
return value || {};
return {
assetKey: row[0], kind: row[1], uri: row[2], transport: row[3], contentHash: row[4],
mimeType: row[5], metadata: row[6] || {},
};
}
function array(value) {
return Array.isArray(value) ? value : [];
}
function record(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function firstString(...values) {
for (const value of values)
if (typeof value === "string" && value.trim())
return value.trim();
return "";
}
function stringValue(value, fallback) {
return typeof value === "string" && value ? value : fallback;
}
function positiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}
function numberValue(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
const objectProps = record;
const numberProp = numberValue;
function boolProp(value) {
if (typeof value === "boolean")
return value;
if (typeof value === "number")
return value !== 0;
return typeof value === "string" && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}