feat: multi-material overlays, presentation guides, and release prebuilds

Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-03 11:28:18 +08:00
parent 61446dacfc
commit 2979d06f1c
41 changed files with 1060 additions and 252 deletions
+7 -5
View File
@@ -12,7 +12,7 @@ export function appDisplayNameForLocale(localeTag: string): string {
return APP_DISPLAY_NAME_EN;
}
/** Префикс заголовка окон: `TTRPG - Редактор`. */
/** Префикс заголовка окон (EN): `TTRPG - Editor`. RU — полное имя «НРИ Плеер». */
export const APP_WINDOW_BRAND = 'TTRPG';
export type AppWindowKind =
@@ -27,9 +27,9 @@ export type AppWindowKind =
| 'npcs';
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
editor: { ru: 'Редактор', en: 'Editor' },
editor: { ru: 'Редактор компании', en: 'Campaign editor' },
presentation: { ru: 'Презентация', en: 'Presentation' },
control: { ru: 'Пульт', en: 'Control' },
control: { ru: 'Пульт управления', en: 'Control deck' },
boot: { ru: 'Загрузка', en: 'Loading' },
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
materials: { ru: 'Материалы', en: 'Materials' },
@@ -40,8 +40,10 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
const tag = localeTag.trim().toLowerCase();
const suffix = tag.startsWith('ru') ? WINDOW_SUFFIX[kind].ru : WINDOW_SUFFIX[kind].en;
return `${APP_WINDOW_BRAND} - ${suffix}`;
const ru = tag.startsWith('ru');
const prefix = ru ? APP_DISPLAY_NAME_RU : APP_WINDOW_BRAND;
const suffix = ru ? WINDOW_SUFFIX[kind].ru : WINDOW_SUFFIX[kind].en;
return `${prefix} - ${suffix}`;
}
/** Подписи фильтров в системных диалогах выбора файла (main process). */
+22
View File
@@ -0,0 +1,22 @@
/** Вписать прямоугольник целевого аспекта в бокс (как object-fit: contain). */
export function fitAspectRect(
boxW: number,
boxH: number,
aspectW: number,
aspectH: number,
): { x: number; y: number; w: number; h: number } {
const bw = Math.max(1, boxW);
const bh = Math.max(1, boxH);
const aw = Math.max(1, aspectW);
const ah = Math.max(1, aspectH);
const boxAspect = bw / bh;
const targetAspect = aw / ah;
if (boxAspect > targetAspect) {
const h = bh;
const w = h * targetAspect;
return { x: (bw - w) / 2, y: 0, w, h };
}
const w = bw;
const h = w / targetAspect;
return { x: 0, y: (bh - h) / 2, w, h };
}
+12
View File
@@ -135,6 +135,9 @@ export const ipcChannels = {
closeNpcs: 'windows.closeNpcs',
openSceneEditor: 'windows.openSceneEditor',
closeSceneEditor: 'windows.closeSceneEditor',
syncChromeTitles: 'windows.syncChromeTitles',
getPresentationContentSize: 'windows.getPresentationContentSize',
presentationContentSizeChanged: 'windows.presentationContentSizeChanged',
},
session: {
stateChanged: 'session.stateChanged',
@@ -257,6 +260,7 @@ export type IpcEventMap = {
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
[ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number };
[ipcChannels.windows.sceneDescriptionContent]: { html: string };
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
@@ -588,6 +592,10 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { open: boolean };
};
[ipcChannels.windows.getPresentationContentSize]: {
req: Record<string, never>;
res: { width: number; height: number } | { width: null; height: null };
};
[ipcChannels.windows.openSceneDescription]: {
req: { html: string };
res: { ok: true };
@@ -632,6 +640,10 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.syncChromeTitles]: {
req: { localeTag: string };
res: { ok: true };
};
[ipcChannels.materialsOverlay.getState]: {
req: Record<string, never>;
res: { state: MaterialsOverlayState };
+10
View File
@@ -11,6 +11,7 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
build: {
appId: string;
asar: boolean;
npmRebuild: boolean;
asarUnpack: string[];
extraResources: { from: string; to: string }[];
mac: { target: unknown; artifactName?: string };
@@ -25,6 +26,11 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
assert.ok(pkg.build);
assert.equal(pkg.build.appId, 'com.ttrpgplayer.app');
assert.equal(pkg.build.asar, true, 'релизный артефакт: app.asar без «голого» дерева dist в .app/.exe');
assert.equal(
pkg.build.npmRebuild,
false,
'N-API prebuilds (classic-level, sharp @img) — без node-gyp / Visual Studio на pack',
);
assert.ok(Array.isArray(pkg.build.asarUnpack));
assert.ok(pkg.build.asarUnpack.some((p) => p.includes('preload')));
assert.ok(Array.isArray(pkg.build.extraResources));
@@ -56,6 +62,10 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
pkg.build.asarUnpack.some((p) => p.includes('@img')),
'sharp native binaries live under node_modules/@img',
);
assert.ok(
pkg.build.asarUnpack.some((p) => p.includes('classic-level')),
'Foundry DB: classic-level stays unpacked with its prebuilds',
);
});
void test('package.json: pack:mac runs release-mac-prep before electron-builder', () => {
+23 -8
View File
@@ -14,13 +14,14 @@ export type MaterialsOverlayLayout = {
export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null;
/** Session-only: какой материал сейчас показан поверх сцены. */
/** Session-only: какие материалы сейчас показаны поверх сцены. */
export type MaterialsOverlayState = {
revision: number;
activeMaterialId: MaterialId | null;
layout: MaterialsOverlayLayout;
/** Раскладка блока описаний легенды (если материал с легендой). */
legendLayout: MaterialsOverlayLayout;
activeMaterialIds: MaterialId[];
layouts: Record<string, MaterialsOverlayLayout>;
/** Раскладка блока описаний легенды по materialId. */
legendLayouts: Record<string, MaterialsOverlayLayout>;
focusMaterialId: MaterialId | null;
zoomTool: MaterialsZoomTool;
};
@@ -35,10 +36,10 @@ export type MaterialsOverlayEvent =
| { kind: 'show'; materialId: MaterialId; rotationDeg?: number }
| { kind: 'hide' }
| { kind: 'toggle'; materialId: MaterialId; rotationDeg?: number }
| { kind: 'layout.set'; layout: MaterialsOverlayLayout }
| { kind: 'legendLayout.set'; layout: MaterialsOverlayLayout }
| { kind: 'layout.set'; materialId: MaterialId; layout: MaterialsOverlayLayout }
| { kind: 'legendLayout.set'; materialId: MaterialId; layout: MaterialsOverlayLayout }
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
| { kind: 'zoomAt'; nx: number; ny: number };
| { kind: 'zoomAt'; nx: number; ny: number; materialId?: MaterialId };
/** Нормализация угла в диапазон [0, 360). */
export function normalizeMaterialsRotation(deg: number): number {
@@ -73,3 +74,17 @@ export function zoomMaterialsLayoutAt(
scale: clamped.scale,
});
}
function initialLegendLayout(): MaterialsOverlayLayout {
return clampMaterialsLayout({
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
cx: 0.82,
cy: 0.5,
scale: 0.85,
rotationDeg: 0,
});
}
export function defaultLegendLayoutForMaterial(): MaterialsOverlayLayout {
return initialLegendLayout();
}