fix(materials): синхронизация легенды и маркеров на превью и презентации
Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user