fix(materials): синхронизация легенды и маркеров на превью и презентации

Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 10:57:50 +08:00
parent ea718a26df
commit 153be98780
17 changed files with 531 additions and 128 deletions
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
LEGEND_PANEL_DESIGN_WIDTH,
LEGEND_PANEL_REF_VIEW_W,
legendMarkerPositionInContainFrame,
legendMarkerPositionInOverlayHost,
legendMarkerPositionInViewport,
legendMarkerScreenScale,
legendPanelVisualLayout,
} from './materialLegend';
void test('legendMarkerPositionInContainFrame: letterboxed 16:9 in square frame', () => {
const p = legendMarkerPositionInContainFrame(0.5, 0.5, 400, 400, 1920, 1080);
assert.ok(Math.abs(p.left - 200) < 0.01);
assert.ok(Math.abs(p.top - 200) < 0.01);
});
void test('legendMarkerPositionInContainFrame: top-left UV at image corner', () => {
const p = legendMarkerPositionInContainFrame(0, 0, 400, 400, 1920, 1080);
const imageH = (400 * 1080) / 1920;
const imageY = (400 - imageH) / 2;
assert.ok(Math.abs(p.left - 0) < 0.01);
assert.ok(Math.abs(p.top - imageY) < 0.01);
});
void test('legendMarkerPositionInViewport: center marker at frame center', () => {
const p = legendMarkerPositionInViewport(0.5, 0.5, { cx: 0.5, cy: 0.5, rotationDeg: 0 }, 400, 300, 800, 600, 800, 600);
assert.ok(Math.abs(p.left - 400) < 0.01);
assert.ok(Math.abs(p.top - 300) < 0.01);
});
void test('legendMarkerPositionInOverlayHost: adds viewport offset', () => {
const p = legendMarkerPositionInOverlayHost(
0.5,
0.5,
{ cx: 0.5, cy: 0.5, rotationDeg: 0 },
400,
300,
800,
600,
{ x: 100, y: 50, w: 800, h: 600 },
);
assert.ok(Math.abs(p.left - 500) < 0.01);
assert.ok(Math.abs(p.top - 350) < 0.01);
});
void test('legendPanelVisualLayout: same cx/cy stays proportional across screen sizes', () => {
const layout = { cx: 0.82, cy: 0.5, scale: 0.85 };
const a = legendPanelVisualLayout(layout, { x: 10, y: 20, w: 800, h: 450 });
const b = legendPanelVisualLayout(layout, { x: 0, y: 0, w: 1920, h: 1080 });
assert.equal(a.width, LEGEND_PANEL_DESIGN_WIDTH);
assert.equal(b.width, LEGEND_PANEL_DESIGN_WIDTH);
assert.ok(Math.abs(a.left - (10 + 0.82 * 800)) < 0.01);
assert.ok(Math.abs(b.left - 0.82 * 1920) < 0.01);
assert.ok(Math.abs(a.scale / b.scale - 800 / LEGEND_PANEL_REF_VIEW_W) < 1e-9);
});
void test('legendPanelVisualLayout: size ignores viewport origin (no edge shrink)', () => {
const layout = { cx: 0.02, cy: 0.98, scale: 1 };
const near = legendPanelVisualLayout(layout, { x: 0, y: 0, w: 1920, h: 1080 });
const far = legendPanelVisualLayout({ cx: 0.5, cy: 0.5, scale: 1 }, { x: 0, y: 0, w: 1920, h: 1080 });
assert.equal(near.scale, far.scale);
assert.equal(near.width, far.width);
});
void test('legendMarkerScreenScale: proportional to player screen, ignores edge', () => {
assert.equal(legendMarkerScreenScale(1920), 1);
assert.ok(Math.abs(legendMarkerScreenScale(800) - 800 / 1920) < 1e-9);
assert.equal(legendMarkerScreenScale(800), legendMarkerScreenScale(800));
});
+112
View File
@@ -151,3 +151,115 @@ export function legendImageUvToHostPoint(
y: contentRect.y + contentRect.h / 2 + (lx * sin + ly * cos),
};
}
/** UV маркера → px внутри кадра MaterialOverlay (object-fit: contain). */
export function legendMarkerPositionInContainFrame(
nx: number,
ny: number,
frameW: number,
frameH: number,
imageNaturalW: number,
imageNaturalH: number,
): { left: number; top: number } {
const bw = Math.max(1, frameW);
const bh = Math.max(1, frameH);
const aw = Math.max(1, imageNaturalW);
const ah = Math.max(1, imageNaturalH);
const boxAspect = bw / bh;
const targetAspect = aw / ah;
let ir: ContentRect;
if (boxAspect > targetAspect) {
const h = bh;
const w = h * targetAspect;
ir = { x: (bw - w) / 2, y: 0, w, h };
} else {
const w = bw;
const h = w / targetAspect;
ir = { x: 0, y: (bh - h) / 2, w, h };
}
return {
left: ir.x + clampLegendCoord(nx) * ir.w,
top: ir.y + clampLegendCoord(ny) * ir.h,
};
}
/** UV маркера → px внутри viewport SceneOverlayHost (без смещения viewport.x/y). */
export function legendMarkerPositionInViewport(
nx: number,
ny: number,
layout: { cx: number; cy: number; rotationDeg: number },
frameW: number,
frameH: number,
imageNaturalW: number,
imageNaturalH: number,
viewportW: number,
viewportH: number,
): { left: number; top: number } {
const local = legendMarkerPositionInContainFrame(nx, ny, frameW, frameH, imageNaturalW, imageNaturalH);
const cx = layout.cx * viewportW;
const cy = layout.cy * viewportH;
const lx = local.left - frameW / 2;
const ly = local.top - frameH / 2;
const rad = (layout.rotationDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
left: cx + lx * cos - ly * sin,
top: cy + lx * sin + ly * cos,
};
}
/** UV маркера → px в координатах SceneOverlayHost (учитывает viewport offset и поворот кадра). */
export function legendMarkerPositionInOverlayHost(
nx: number,
ny: number,
layout: { cx: number; cy: number; rotationDeg: number },
frameW: number,
frameH: number,
imageNaturalW: number,
imageNaturalH: number,
viewport: ContentRect,
): { left: number; top: number } {
const p = legendMarkerPositionInViewport(
nx,
ny,
layout,
frameW,
frameH,
imageNaturalW,
imageNaturalH,
viewport.w,
viewport.h,
);
return { left: viewport.x + p.left, top: viewport.y + p.top };
}
/** Ширина карточки легенды в design-px при scale=1 на экране 1920px. */
export const LEGEND_PANEL_DESIGN_WIDTH = 320;
export const LEGEND_PANEL_REF_VIEW_W = 1920;
/** Диаметр маркера на материале в design-px (как кружок в списке легенды). */
export const LEGEND_MARKER_DESIGN_SIZE = 26;
/**
* Позиция и единый масштаб карточки легенды.
* Размер не зависит от близости к краю — только от ширины экрана игроков и layout.scale.
*/
export function legendPanelVisualLayout(
layout: { cx: number; cy: number; scale: number },
viewport: ContentRect,
): { left: number; top: number; width: number; scale: number } {
const vw = Math.max(1, viewport.w);
const vh = Math.max(1, viewport.h);
const layoutScale = Number.isFinite(layout.scale) && layout.scale > 0 ? layout.scale : 1;
return {
left: viewport.x + layout.cx * vw,
top: viewport.y + layout.cy * vh,
width: LEGEND_PANEL_DESIGN_WIDTH,
scale: (vw / LEGEND_PANEL_REF_VIEW_W) * layoutScale,
};
}
/** Единый масштаб цифр на материале — как у карточки легенды, без сжатия у края. */
export function legendMarkerScreenScale(viewportW: number): number {
return Math.max(1, viewportW) / LEGEND_PANEL_REF_VIEW_W;
}