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 ? ( <>