diff --git a/app/main/index.ts b/app/main/index.ts
index ace002d..1da613e 100644
--- a/app/main/index.ts
+++ b/app/main/index.ts
@@ -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: [] };
diff --git a/app/main/tokens/tokenGridSnapSessionStore.test.ts b/app/main/tokens/tokenGridSnapSessionStore.test.ts
new file mode 100644
index 0000000..2386673
--- /dev/null
+++ b/app/main/tokens/tokenGridSnapSessionStore.test.ts
@@ -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);
+});
diff --git a/app/main/tokens/tokenGridSnapSessionStore.ts b/app/main/tokens/tokenGridSnapSessionStore.ts
new file mode 100644
index 0000000..e87e11a
--- /dev/null
+++ b/app/main/tokens/tokenGridSnapSessionStore.ts
@@ -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();
+ }
+}
diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css
index 945cea2..173f2d0 100644
--- a/app/renderer/control/ControlApp.module.css
+++ b/app/renderer/control/ControlApp.module.css
@@ -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);
diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx
index 53d7f37..fef8866 100644
--- a/app/renderer/control/ControlApp.tsx
+++ b/app/renderer/control/ControlApp.tsx
@@ -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() {
{t('control.screenPreview')}
+
{USERS_BRANCH_FEATURES_ENABLED ? (
<>