feat(control): grid snap, NPC context actions, and overlay dim fix

Re-enable users-branch UI, snap session tokens to square/hex grid from the control preview, refine inactive/open-info NPC menus, block marker actions while an effect brush is active, and dim materials/NPC overlays only when they are open.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-06 13:48:59 +08:00
parent e53d1ea934
commit 4456eb0277
24 changed files with 563 additions and 41 deletions
+29 -1
View File
@@ -19,6 +19,7 @@ import {
stripProjectZipExtension,
} from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types';
import { asNpcId } from '../shared/types/ids';
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
@@ -34,6 +35,7 @@ import { PlayersStore } from './players/playersStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokenGridSnapSessionStore } from './tokens/tokenGridSnapSessionStore';
import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
@@ -156,6 +158,7 @@ const npcsOverlayStore = new NpcsOverlayStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;
@@ -226,6 +229,13 @@ function emitSceneTokensSessionState(): void {
}
}
function emitTokenGridSnapState(): void {
const { enabled } = tokenGridSnapSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.tokenGridSnap.stateChanged, { enabled });
}
}
function emitPlayersState(): void {
const players = playersStore?.listPlayers() ?? [];
const teams = playersStore?.listTeams() ?? [];
@@ -432,6 +442,7 @@ async function main() {
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
scenePlayerTokensSessionStore.reset();
tokenGridSnapSessionStore.reset();
if (USERS_BRANCH_FEATURES_ENABLED) {
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) {
@@ -450,11 +461,14 @@ async function main() {
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitTokenGridSnapState();
emitEffectsState();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow();
tokenGridSnapSessionStore.reset();
emitTokenGridSnapState();
return { ok: true };
});
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
@@ -507,8 +521,13 @@ async function main() {
closeSceneEditorWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcs, () => {
registerHandler(ipcChannels.windows.openNpcs, (req) => {
openNpcsWindow();
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
if (npcId) {
npcsOverlayStore.dispatch({ kind: 'show', npcId });
emitNpcsOverlayState();
}
return { ok: true };
});
registerHandler(ipcChannels.windows.closeNpcs, () => {
@@ -576,6 +595,7 @@ async function main() {
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
scenePlayerTokensSessionStore.reset();
tokenGridSnapSessionStore.reset();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
@@ -585,6 +605,7 @@ async function main() {
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitTokenGridSnapState();
emitSessionState();
return { ok: true };
});
@@ -1361,6 +1382,13 @@ async function main() {
return { ok: true };
});
registerHandler(ipcChannels.tokenGridSnap.getState, () => tokenGridSnapSessionStore.getState());
registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => {
const next = tokenGridSnapSessionStore.setEnabled(enabled);
emitTokenGridSnapState();
return next;
});
registerHandler(ipcChannels.players.list, async () => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { players: [], teams: [] };
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TokenGridSnapSessionStore } from './tokenGridSnapSessionStore';
void test('TokenGridSnapSessionStore: set and reset', () => {
const store = new TokenGridSnapSessionStore();
assert.equal(store.getState().enabled, false);
assert.equal(store.setEnabled(true).enabled, true);
assert.equal(store.getState().enabled, true);
assert.equal(store.reset().enabled, false);
});
@@ -0,0 +1,21 @@
export type TokenGridSnapSessionState = {
enabled: boolean;
};
export class TokenGridSnapSessionStore {
private enabled = false;
getState(): TokenGridSnapSessionState {
return { enabled: this.enabled };
}
setEnabled(enabled: boolean): TokenGridSnapSessionState {
this.enabled = Boolean(enabled);
return this.getState();
}
reset(): TokenGridSnapSessionState {
this.enabled = false;
return this.getState();
}
}
@@ -314,6 +314,22 @@
min-width: 0;
}
.snapToGrid {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
cursor: pointer;
user-select: none;
}
.snapToGridLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
}
.npcTokenScaleLabel {
color: var(--text2);
font-size: var(--text-xs);
+205 -15
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
@@ -26,6 +26,7 @@ import {
NPC_TOKEN_SESSION_SCALE_MIN,
} from '../../shared/types/appPlayers';
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
import { snapNormToGridCell } from '../../shared/types/sceneGridSnap';
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
@@ -57,6 +58,7 @@ import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
import { useTokenGridSnapSession } from '../shared/tokens/useTokenGridSnapSession';
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls';
@@ -154,6 +156,7 @@ export function ControlApp() {
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
const { players: appPlayers } = useAppPlayers();
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
x: number;
@@ -372,6 +375,81 @@ export function ControlApp() {
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
const snapNormActive = useCallback(
(nx: number, ny: number) => {
const grid = currentScene?.grid;
if (!tokenGridSnap || !grid?.enabled || !previewContentRect) return { nx, ny };
return snapNormToGridCell(nx, ny, grid, previewContentRect.w, previewContentRect.h);
},
[currentScene?.grid, previewContentRect, tokenGridSnap],
);
const snapAllTokensToGrid = useCallback(async () => {
const grid = currentScene?.grid;
const rect = previewContentRect;
if (!grid?.enabled || !rect) return;
for (const placement of currentScene?.tokens ?? []) {
const key = String(placement.id);
const override = sceneTokensSession?.byPlacementId[key];
const raw = { nx: override?.nx ?? placement.nx, ny: override?.ny ?? placement.ny };
const snapped = snapNormToGridCell(raw.nx, raw.ny, grid, rect.w, rect.h);
if (snapped.nx === raw.nx && snapped.ny === raw.ny) continue;
await sceneTokensApi.dispatch({
kind: 'move',
placementId: key,
nx: snapped.nx,
ny: snapped.ny,
});
}
for (const placement of currentScene?.npcTokens ?? []) {
const key = String(placement.id);
const override = sceneNpcTokensSession?.byPlacementId[key];
const raw = {
nx: override?.nx ?? placement.nx,
ny: override?.ny ?? placement.ny,
};
const snapped = snapNormToGridCell(raw.nx, raw.ny, grid, rect.w, rect.h);
if (snapped.nx === raw.nx && snapped.ny === raw.ny) continue;
await sceneNpcTokensApi.dispatch({
kind: 'move',
placementId: key,
nx: snapped.nx,
ny: snapped.ny,
});
}
if (scenePlayerTokensSession?.visible) {
for (const playerId of scenePlayerTokensSession.selectedPlayerIds) {
const placement = scenePlayerTokensSession.byPlayerId[playerId];
if (!placement) continue;
const snapped = snapNormToGridCell(placement.nx, placement.ny, grid, rect.w, rect.h);
if (snapped.nx === placement.nx && snapped.ny === placement.ny) continue;
await scenePlayerTokensApi.dispatch({
kind: 'move',
playerId,
nx: snapped.nx,
ny: snapped.ny,
});
}
}
}, [
currentScene?.grid,
currentScene?.npcTokens,
currentScene?.tokens,
previewContentRect,
sceneNpcTokensApi,
sceneNpcTokensSession?.byPlacementId,
scenePlayerTokensApi,
scenePlayerTokensSession?.byPlayerId,
scenePlayerTokensSession?.selectedPlayerIds,
scenePlayerTokensSession?.visible,
sceneTokensApi,
sceneTokensSession?.byPlacementId,
]);
const sceneDescription = currentScene?.description ?? '';
const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription);
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
@@ -911,6 +989,12 @@ export function ControlApp() {
const tool = fxState?.tool ?? { tool: 'none' as const, radiusN: 0.08, intensity: 0.6 };
const toolRef = useRef(tool);
/** Действия с токенами/ловушками только без активной кисти эффектов. */
const markersInteractive = tool.tool === 'none';
useEffect(() => {
if (!markersInteractive) setNpcSessionCtxMenu(null);
}, [markersInteractive]);
toolRef.current = tool;
/** Повторный клик по активному инструменту снимает выбор. */
@@ -1779,6 +1863,28 @@ export function ControlApp() {
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}>
<label
className={styles.snapToGrid}
title={
currentScene?.grid?.enabled
? t('control.snapTokensToGrid')
: t('control.snapTokensToGridNoGrid')
}
>
<input
type="checkbox"
data-testid="token-grid-snap"
checked={tokenGridSnap}
onChange={(e) => {
const next = e.currentTarget.checked;
void (async () => {
const enabled = await tokenGridSnapApi.setEnabled(next);
if (enabled) await snapAllTokensToGrid();
})();
}}
/>
<span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span>
</label>
{USERS_BRANCH_FEATURES_ENABLED ? (
<>
<label className={styles.npcTokenScale}>
@@ -1813,6 +1919,32 @@ export function ControlApp() {
? grid.sizeN
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
scenePlayerTokensApi.dispatch({ kind: 'show', sizeN });
if (tokenGridSnap && grid?.enabled && previewContentRect) {
void (async () => {
const { state } = await api.invoke(
ipcChannels.scenePlayerTokensSession.getState,
{},
);
for (const playerId of state.selectedPlayerIds) {
const placement = state.byPlayerId[playerId];
if (!placement) continue;
const snapped = snapNormToGridCell(
placement.nx,
placement.ny,
grid,
previewContentRect.w,
previewContentRect.h,
);
if (snapped.nx === placement.nx && snapped.ny === placement.ny) continue;
scenePlayerTokensApi.dispatch({
kind: 'move',
playerId,
nx: snapped.nx,
ny: snapped.ny,
});
}
})();
}
}}
>
{scenePlayerTokensSession.visible
@@ -2025,9 +2157,16 @@ export function ControlApp() {
library={appTokens}
session={sceneTokensSession}
viewport={previewContentRect}
editable
editable={markersInteractive}
snapNorm={snapNormActive}
onMove={(placementId, nx, ny) => {
void sceneTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
const snapped = snapNormActive(nx, ny);
void sceneTokensApi.dispatch({
kind: 'move',
placementId,
nx: snapped.nx,
ny: snapped.ny,
});
}}
/>
) : null}
@@ -2038,17 +2177,28 @@ export function ControlApp() {
session={sceneNpcTokensSession}
viewport={previewContentRect}
grid={currentScene?.grid ?? null}
editable
editable={markersInteractive}
snapNorm={snapNormActive}
onMove={(placementId, nx, ny) => {
sceneNpcTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
const snapped = snapNormActive(nx, ny);
sceneNpcTokensApi.dispatch({
kind: 'move',
placementId,
nx: snapped.nx,
ny: snapped.ny,
});
}}
onContextMenu={(e, placement) => {
onContextMenu={
markersInteractive
? (e, placement) => {
setNpcSessionCtxMenu({
x: e.clientX,
y: e.clientY,
placementId: String(placement.id),
});
}}
}
: undefined
}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
@@ -2060,9 +2210,16 @@ export function ControlApp() {
}
viewport={previewContentRect}
grid={currentScene?.grid ?? null}
editable
editable={markersInteractive}
snapNorm={snapNormActive}
onMove={(playerId, nx, ny) => {
scenePlayerTokensApi.dispatch({ kind: 'move', playerId, nx, ny });
const snapped = snapNormActive(nx, ny);
scenePlayerTokensApi.dispatch({
kind: 'move',
playerId,
nx: snapped.nx,
ny: snapped.ny,
});
}}
/>
) : null}
@@ -2072,6 +2229,7 @@ export function ControlApp() {
session={sceneTraps}
viewport={previewContentRect}
mode="control"
interactive={markersInteractive}
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
onActivate={(trapId) => {
void (async () => {
@@ -2096,10 +2254,8 @@ export function ControlApp() {
},
});
void playPoisonCloudEffectSound(poisonLifeMs);
return;
}
if (trap?.type === 'explosion') {
// Тот же VFX/SFX, что у инструмента «Взрыв» на пульте эффектов.
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const explosionLifeMs = await getExplosionEffectLifeMs();
@@ -2111,8 +2267,8 @@ export function ControlApp() {
seed,
createdAtMs,
at: { x: trap.nx, y: trap.ny },
radiusN: Math.max(0.04, trap.sizeN * 1.15),
intensity: 1.05,
radiusN: Math.max(0.05, trap.sizeN * 1.35),
intensity: 1.15,
lifetimeMs: explosionLifeMs,
},
});
@@ -2544,8 +2700,42 @@ export function ControlApp() {
override?.disposition,
);
const inactive = Boolean(override?.inactive);
if (inactive) {
return (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
sceneNpcTokensApi.dispatch({
kind: 'setInactive',
placementId: npcSessionCtxMenu.placementId,
inactive: false,
});
setNpcSessionCtxMenu(null);
}}
>
{t('npcs.makeActive')}
</button>
);
}
return (
<>
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
setNpcSessionCtxMenu(null);
void api
.invoke(ipcChannels.windows.openNpcs, { npcId: npc.id })
.catch((err) => {
console.error('[control] openNpcs failed', err);
});
}}
>
{t('npcs.openInfo')}
</button>
{otherNpcDispositions(current).map((d) => (
<button
key={d}
@@ -2572,12 +2762,12 @@ export function ControlApp() {
sceneNpcTokensApi.dispatch({
kind: 'setInactive',
placementId: npcSessionCtxMenu.placementId,
inactive: !inactive,
inactive: true,
});
setNpcSessionCtxMenu(null);
}}
>
{inactive ? t('npcs.makeActive') : t('npcs.inactive')}
{t('npcs.inactive')}
</button>
</>
);
@@ -116,6 +116,18 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
});
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
const src = readControlApp();
const css = readControlAppCss();
assert.ok(src.includes('useTokenGridSnapSession'));
assert.ok(src.includes('snapNormToGridCell'));
assert.ok(src.includes('data-testid="token-grid-snap"'));
assert.ok(src.includes("t('control.snapTokensToGrid')"));
assert.ok(src.includes('snapAllTokensToGrid'));
assert.ok(src.includes('snapNorm={snapNormActive}'));
assert.ok(css.includes('.snapToGrid'));
});
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
const src = readControlApp();
const css = readControlAppCss();
@@ -483,6 +483,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.makeFriendly': 'Сделать дружественным',
'npcs.inactive': 'Неактивен',
'npcs.makeActive': 'Сделать активным',
'npcs.openInfo': 'Открыть информацию',
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
'npcs.description': 'ОПИСАНИЕ',
'npcs.descriptionPlaceholder': 'Описание персонажа…',
@@ -623,6 +624,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.noActiveScene': 'Нет активной сцены.',
'control.screenPreview': 'Предпросмотр экрана',
'control.npcTokenScale': 'Размеры игр. токенов',
'control.snapTokensToGrid': 'Привязка токенов к сетке',
'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется',
'control.stopPresentation': 'Выключить',
'control.showPlayers': 'Показать игроков',
'control.hidePlayers': 'Скрыть игроков',
@@ -1102,6 +1105,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.makeFriendly': 'Make Friendly',
'npcs.inactive': 'Inactive',
'npcs.makeActive': 'Make Active',
'npcs.openInfo': 'Open information',
'npcs.ringColor': 'TOKEN RING COLOR',
'npcs.description': 'DESCRIPTION',
'npcs.descriptionPlaceholder': 'Character description…',
@@ -1242,6 +1246,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.noActiveScene': 'No active scene.',
'control.screenPreview': 'Screen preview',
'control.npcTokenScale': 'Play token size',
'control.snapTokensToGrid': 'Snap tokens to grid',
'control.snapTokensToGridNoGrid': 'Grid is off on this scene — snap has no effect',
'control.stopPresentation': 'Turn off',
'control.showPlayers': 'Show players',
'control.hidePlayers': 'Hide players',
+13 -4
View File
@@ -185,6 +185,15 @@ export function NpcsApp() {
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
[activeIds, npcs],
);
/** Описание: сфокусированный НПС (открытие информации / последний выбор), иначе все активные. */
const detailNpcs = useMemo(() => {
const focusId = overlay?.focusNpcId ?? null;
if (focusId) {
const focused = npcs.find((n) => n.id === focusId);
if (focused) return [focused];
}
return selectedNpcs;
}, [npcs, overlay?.focusNpcId, selectedNpcs]);
const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -226,7 +235,7 @@ export function NpcsApp() {
const relationsByNpcId = useMemo(() => {
const map = new Map<NpcId, { id: string; text: string }[]>();
for (const npc of selectedNpcs) {
for (const npc of detailNpcs) {
const list = relations
.filter((r) => r.sourceNpcId === npc.id)
.map((r) => {
@@ -236,7 +245,7 @@ export function NpcsApp() {
map.set(npc.id, list);
}
return map;
}, [npcs, relations, selectedNpcs]);
}, [detailNpcs, npcs, relations]);
const onSelectTile = useCallback(
(id: NpcId) => {
@@ -304,8 +313,8 @@ export function NpcsApp() {
<div className={styles.body}>
<div className={styles.detail}>
{selectedNpcs.length > 0 ? (
selectedNpcs.map((npc) => {
{detailNpcs.length > 0 ? (
detailNpcs.map((npc) => {
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
return (
@@ -38,9 +38,11 @@
cursor: zoom-out;
}
/** Затемнение всего родителя (превью пульта / экран презентации); только при открытом материале/NPC. */
.dim {
position: absolute;
inset: 0;
z-index: 39;
background: rgba(0, 0, 0, 0.62);
pointer-events: none;
}
@@ -43,6 +43,7 @@ function NpcSprite({
inactive,
onMove,
onContextMenu,
snapNorm,
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
@@ -56,6 +57,7 @@ function NpcSprite({
inactive: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
@@ -135,8 +137,11 @@ function NpcSprite({
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const p = point(e);
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
const rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
const rawNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
const snapped = snapNorm ? snapNorm(rawNx, rawNy) : { nx: rawNx, ny: rawNy };
drag.lastNx = snapped.nx;
drag.lastNy = snapped.ny;
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
schedule(drag.lastNx, drag.lastNy);
}
@@ -167,6 +172,7 @@ export function SceneNpcTokensOverlay({
editable = false,
onMove,
onContextMenu,
snapNorm,
}: {
placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[];
@@ -177,6 +183,7 @@ export function SceneNpcTokensOverlay({
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
if (!viewport) return null;
const byId = new Map(library.map((npc) => [npc.id, npc]));
@@ -205,6 +212,7 @@ export function SceneNpcTokensOverlay({
inactive={inactive}
{...(onMove ? { onMove } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
);
})}
@@ -25,6 +25,7 @@ function PlayerSprite({
displayScale,
gridFit,
onMove,
snapNorm,
}: {
player: AppPlayer;
nx: number;
@@ -35,6 +36,7 @@ function PlayerSprite({
displayScale: number;
gridFit: number;
onMove?: (playerId: string, nx: number, ny: number) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = usePlayerImageUrl(player.id);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
@@ -105,8 +107,11 @@ function PlayerSprite({
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const p = point(e);
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
const rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
const rawNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
const snapped = snapNorm ? snapNorm(rawNx, rawNy) : { nx: rawNx, ny: rawNy };
drag.lastNx = snapped.nx;
drag.lastNy = snapped.ny;
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
schedule(drag.lastNx, drag.lastNy);
}
@@ -135,6 +140,7 @@ export function ScenePlayerTokensOverlay({
grid = null,
editable = false,
onMove,
snapNorm,
}: {
library: readonly AppPlayer[];
session: ScenePlayerTokensSessionState | null;
@@ -144,6 +150,7 @@ export function ScenePlayerTokensOverlay({
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (playerId: string, nx: number, ny: number) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
if (!viewport || !session?.visible) return null;
const byId = new Map(library.map((p) => [String(p.id), p]));
@@ -166,6 +173,7 @@ export function ScenePlayerTokensOverlay({
displayScale={displayScale}
gridFit={gridFit}
{...(onMove ? { onMove } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
);
})}
@@ -15,9 +15,12 @@ export type SceneOverlayCloseAction = {
type SceneOverlayHostProps = {
/** Есть ли что показывать (материал и/или NPC). */
active: boolean;
/** Область картинки сцены (contain); координаты относительно родителя. */
/**
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
* На пульте прямоугольник соотношения сторон презентации; на презентации обычно не задаётся (весь экран).
*/
viewport?: SceneOverlayViewport | null;
/** Рамка видимой области (предпросмотр пульта). */
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
showViewportGuide?: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
@@ -26,8 +29,9 @@ type SceneOverlayHostProps = {
};
/**
* Общий слой подложки для Materials + NPCs: один root и один `.dim`.
* Кадры остаются в дочерних оверлеях (`embedded`).
* Общий слой подложки для Materials + NPCs.
* Dim только при `active`, на весь родитель (экран презентации / рамка превью пульта).
* Кадры остаются в дочерних оверлеях (`embedded`) внутри `viewport`.
*/
export function SceneOverlayHost({
active,
@@ -61,7 +65,7 @@ export function SceneOverlayHost({
ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [active]);
}, [active, showViewportGuide, viewport]);
const ctx = useMemo(() => ({ rootRef, view }), [view]);
@@ -83,6 +87,7 @@ export function SceneOverlayHost({
return (
<SceneOverlayViewContext.Provider value={ctx}>
{active ? <div className={styles.dim} aria-hidden /> : null}
<div
ref={rootRef}
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
@@ -98,7 +103,6 @@ export function SceneOverlayHost({
}}
>
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
<div className={styles.dim} />
{children}
{closes.length > 0 ? (
<div className={styles.closeStack}>
@@ -18,6 +18,9 @@ void test('SceneOverlayHost: один dim для materials + npcs в Control и
assert.ok(host.includes('styles.dim'));
assert.ok(host.includes('hostHitThrough'));
// Dim только при active; жёлтая рамка без затемнения.
assert.match(host, /\{active \? <div className=\{styles\.dim\}/);
assert.ok(host.includes('showViewportGuide'));
assert.ok(control.includes('SceneOverlayHost'));
assert.ok(control.includes('embedded'));
assert.ok(presentation.includes('SceneOverlayHost'));
@@ -29,6 +32,7 @@ void test('SceneOverlayHost: один dim для materials + npcs в Control и
assert.ok(control.includes('embedded'));
assert.ok(presentation.includes('embedded'));
assert.match(css, /\.hostHitThrough\s*\{[^}]*pointer-events:\s*none/s);
assert.match(css, /\.dim\s*\{[^}]*inset:\s*0/s);
});
void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded без собственного dim', () => {
@@ -14,6 +14,8 @@ type Props = {
viewport: Viewport | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
/** Snap во время drag (пульт, привязка к сетке). */
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
};
function TokenSprite({
@@ -23,6 +25,7 @@ function TokenSprite({
viewport,
editable,
onMove,
snapNorm,
}: {
placement: SceneToken;
nx: number;
@@ -30,6 +33,7 @@ function TokenSprite({
viewport: Viewport;
editable: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const url = useTokenImageUrl(placement.tokenId);
const dragRef = useRef<{
@@ -132,8 +136,9 @@ function TokenSprite({
const p = hostToNorm(e.clientX, e.clientY, host);
const nextNx = Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx)));
const nextNy = Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy)));
d.lastNx = nextNx;
d.lastNy = nextNy;
const snapped = snapNorm ? snapNorm(nextNx, nextNy) : { nx: nextNx, ny: nextNy };
d.lastNx = snapped.nx;
d.lastNy = snapped.ny;
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0;
@@ -163,6 +168,7 @@ export function SceneTokensOverlay({
viewport,
editable = false,
onMove,
snapNorm,
}: Props) {
if (!viewport || placements.length === 0) return null;
const known = new Set(library.map((t) => t.id));
@@ -185,6 +191,7 @@ export function SceneTokensOverlay({
viewport={viewport}
editable={editable}
{...(onMove ? { onMove } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
);
})}
@@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import { getDndApi } from '../dndApi';
export function useTokenGridSnapSession(): [
boolean,
{ setEnabled: (enabled: boolean) => Promise<boolean> },
] {
const api = getDndApi();
const [enabled, setEnabledState] = useState(false);
useEffect(() => {
void api.invoke(ipcChannels.tokenGridSnap.getState, {}).then((res) => {
setEnabledState(Boolean(res.enabled));
});
return api.on(ipcChannels.tokenGridSnap.stateChanged, ({ enabled: next }) => {
setEnabledState(Boolean(next));
});
}, [api]);
const setEnabled = useCallback(
async (next: boolean) => {
setEnabledState(next);
const res = await api.invoke(ipcChannels.tokenGridSnap.setEnabled, { enabled: next });
setEnabledState(Boolean(res.enabled));
return Boolean(res.enabled);
},
[api],
);
return [enabled, { setEnabled }];
}
@@ -38,6 +38,10 @@
border-style: dashed;
}
.trapNonInteractive {
pointer-events: none;
}
.label {
position: absolute;
left: 50%;
@@ -21,6 +21,8 @@ type Props = {
viewport: { x: number; y: number; w: number; h: number } | null;
/** Пульт: показывать все ловушки + RMB меню. Презентация: только revealed. */
mode: 'control' | 'presentation';
/** На пульте: false — без контекстного меню (например, активна кисть эффектов). */
interactive?: boolean;
onReveal?: (trapId: string) => void;
onActivate?: (trapId: string) => void;
onDisarm?: (trapId: string) => void;
@@ -47,6 +49,7 @@ export function SceneTrapsOverlay({
session,
viewport,
mode,
interactive = true,
onReveal,
onActivate,
onDisarm,
@@ -54,6 +57,10 @@ export function SceneTrapsOverlay({
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
useEffect(() => {
if (!interactive) setMenu(null);
}, [interactive]);
useEffect(() => {
const act = session?.lastActivation;
if (!act) return;
@@ -112,6 +119,7 @@ export function SceneTrapsOverlay({
rt.status === 'active' ? styles.trapActive : '',
rt.status === 'disarmed' ? styles.trapDisarmed : '',
mode === 'control' && !rt.revealed ? styles.trapGmHidden : '',
mode === 'control' && !interactive ? styles.trapNonInteractive : '',
]
.filter(Boolean)
.join(' ');
@@ -121,7 +129,7 @@ export function SceneTrapsOverlay({
className={cls}
style={{ left, top, width: sizePx, height: sizePx }}
onContextMenu={
mode === 'control'
mode === 'control' && interactive
? (e) => {
e.preventDefault();
e.stopPropagation();
@@ -163,7 +171,7 @@ export function SceneTrapsOverlay({
}}
/>
) : null}
{menu && mode === 'control'
{menu && mode === 'control' && interactive
? createPortal(
<div
role="menu"
+1 -1
View File
@@ -3,4 +3,4 @@
* (Players library, run-with-players, circular NPC/player session tokens, disposition UI).
* Re-enable after QA.
*/
export const USERS_BRANCH_FEATURES_ENABLED = false;
export const USERS_BRANCH_FEATURES_ENABLED = true;
+2
View File
@@ -14,5 +14,7 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
assert.match(src, /sceneNpcTokensSession:\s*\{/);
assert.match(src, /scenePlayerTokensSession:\s*\{/);
assert.match(src, /tokenGridSnap:\s*\{/);
assert.match(src, /tokenGridSnap\.setEnabled/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
});
+16 -1
View File
@@ -197,6 +197,11 @@ export const ipcChannels = {
dispatch: 'sceneTokensSession.dispatch',
stateChanged: 'sceneTokensSession.stateChanged',
},
tokenGridSnap: {
getState: 'tokenGridSnap.getState',
setEnabled: 'tokenGridSnap.setEnabled',
stateChanged: 'tokenGridSnap.stateChanged',
},
players: {
list: 'players.list',
upsert: 'players.upsert',
@@ -293,6 +298,7 @@ export type IpcEventMap = {
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
@@ -673,7 +679,7 @@ export type IpcInvokeMap = {
res: { ok: true };
};
[ipcChannels.windows.openNpcs]: {
req: Record<string, never>;
req: { npcId?: NpcId | null };
res: { ok: true };
};
[ipcChannels.windows.closeNpcs]: {
@@ -768,6 +774,14 @@ export type IpcInvokeMap = {
req: { event: SceneTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.tokenGridSnap.getState]: {
req: Record<string, never>;
res: { enabled: boolean };
};
[ipcChannels.tokenGridSnap.setEnabled]: {
req: { enabled: boolean };
res: { enabled: boolean };
};
[ipcChannels.players.list]: {
req: Record<string, never>;
res: { players: AppPlayer[]; teams: AppPlayerTeam[] };
@@ -873,6 +887,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
+1
View File
@@ -9,6 +9,7 @@ export * from './npcDisposition';
export * from './npcs';
export * from './sceneDarkness';
export * from './sceneGrid';
export * from './sceneGridSnap';
export * from './sceneTraps';
export * from './sceneView';
export * from './videoPlayback';
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { snapNormToGridCell } from './sceneGridSnap';
void test('snapNormToGridCell: disabled / invalid size = no-op', () => {
assert.deepEqual(
snapNormToGridCell(0.33, 0.77, { enabled: false, type: 'square', sizeN: 0.1 }, 1000, 800),
{ nx: 0.33, ny: 0.77 },
);
assert.deepEqual(
snapNormToGridCell(0.33, 0.77, { enabled: true, type: 'square', sizeN: 0.1 }, 0, 800),
{ nx: 0.33, ny: 0.77 },
);
});
void test('snapNormToGridCell: square snaps to cell center', () => {
// minDim=1000, sizeN=0.1 → cell=100. Center of (0,0) at (50,50) → (0.05, 0.05) in 1000×1000.
const at = snapNormToGridCell(0.04, 0.06, { enabled: true, type: 'square', sizeN: 0.1 }, 1000, 1000);
assert.ok(Math.abs(at.nx - 0.05) < 1e-9);
assert.ok(Math.abs(at.ny - 0.05) < 1e-9);
const next = snapNormToGridCell(0.14, 0.16, { enabled: true, type: 'square', sizeN: 0.1 }, 1000, 1000);
assert.ok(Math.abs(next.nx - 0.15) < 1e-9);
assert.ok(Math.abs(next.ny - 0.15) < 1e-9);
});
void test('snapNormToGridCell: hex snaps to flat-top center', () => {
const cell = 100;
const horiz = cell * 0.75;
const vert = (Math.sqrt(3) / 2) * cell;
// Center of col=1,row=0: (75, vert/2) because odd col offset.
const cx = 1 * horiz;
const cy = 0 * vert + vert / 2;
const near = snapNormToGridCell(
(cx + 3) / 1000,
(cy - 4) / 1000,
{ enabled: true, type: 'hex', sizeN: 0.1 },
1000,
1000,
);
assert.ok(Math.abs(near.nx - cx / 1000) < 1e-9);
assert.ok(Math.abs(near.ny - cy / 1000) < 1e-9);
});
+88
View File
@@ -0,0 +1,88 @@
/** Привязка нормализованных координат токена к центру ячейки сетки сцены. */
import type { SceneGrid } from './sceneGrid';
function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}
function cellPx(sizeN: number, contentW: number, contentH: number): number {
const minDim = Math.min(contentW, contentH);
return Math.max(4, sizeN * minDim);
}
function snapSquare(
nx: number,
ny: number,
contentW: number,
contentH: number,
sizeN: number,
): { nx: number; ny: number } {
const cell = cellPx(sizeN, contentW, contentH);
const px = nx * contentW;
const py = ny * contentH;
const col = Math.round(px / cell - 0.5);
const row = Math.round(py / cell - 0.5);
return {
nx: clamp01(((col + 0.5) * cell) / contentW),
ny: clamp01(((row + 0.5) * cell) / contentH),
};
}
/**
* Flat-top hex, как в `SceneGridOverlay.drawHexGrid`:
* центры `(col * horiz, row * vert + (col % 2 === 0 ? 0 : vert/2))`.
*/
function snapHex(
nx: number,
ny: number,
contentW: number,
contentH: number,
sizeN: number,
): { nx: number; ny: number } {
const cell = cellPx(sizeN, contentW, contentH);
const hexW = cell;
const vert = (Math.sqrt(3) / 2) * hexW;
const horiz = hexW * 0.75;
const px = nx * contentW;
const py = ny * contentH;
const col0 = Math.round(px / horiz);
const row0 = Math.round(py / vert);
let bestNx = nx;
let bestNy = ny;
let bestD = Number.POSITIVE_INFINITY;
for (let col = col0 - 2; col <= col0 + 2; col++) {
for (let row = row0 - 2; row <= row0 + 2; row++) {
const cx = col * horiz;
const cy = row * vert + (col % 2 === 0 ? 0 : vert / 2);
const d = (cx - px) * (cx - px) + (cy - py) * (cy - py);
if (d < bestD) {
bestD = d;
bestNx = cx / contentW;
bestNy = cy / contentH;
}
}
}
return { nx: clamp01(bestNx), ny: clamp01(bestNy) };
}
/**
* Snap центра токена к ближайшему центру ячейки.
* Если сетка выключена / нет размеров координаты без изменений.
*/
export function snapNormToGridCell(
nx: number,
ny: number,
grid: Pick<SceneGrid, 'enabled' | 'type' | 'sizeN'> | null | undefined,
contentW: number,
contentH: number,
): { nx: number; ny: number } {
if (!grid?.enabled) return { nx, ny };
if (!(contentW > 0) || !(contentH > 0)) return { nx, ny };
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return { nx, ny };
if (grid.type === 'hex') {
return snapHex(nx, ny, contentW, contentH, grid.sizeN);
}
return snapSquare(nx, ny, contentW, contentH, grid.sizeN);
}
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs",
"format": "prettier . --check",
"format:write": "prettier . --write",
"postinstall": "patch-package",