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:
+29
-1
@@ -19,6 +19,7 @@ import {
|
|||||||
stripProjectZipExtension,
|
stripProjectZipExtension,
|
||||||
} from '../shared/project/projectZipExtension';
|
} from '../shared/project/projectZipExtension';
|
||||||
import type { Project } from '../shared/types';
|
import type { Project } from '../shared/types';
|
||||||
|
import { asNpcId } from '../shared/types/ids';
|
||||||
|
|
||||||
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
||||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||||
@@ -34,6 +35,7 @@ import { PlayersStore } from './players/playersStore';
|
|||||||
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
||||||
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
||||||
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
||||||
|
import { TokenGridSnapSessionStore } from './tokens/tokenGridSnapSessionStore';
|
||||||
import { TokensStore } from './tokens/tokensStore';
|
import { TokensStore } from './tokens/tokensStore';
|
||||||
import { installAutoUpdater } from './update/installAutoUpdater';
|
import { installAutoUpdater } from './update/installAutoUpdater';
|
||||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||||
@@ -156,6 +158,7 @@ const npcsOverlayStore = new NpcsOverlayStore();
|
|||||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||||
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
||||||
|
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
||||||
let tokensStore: TokensStore | null = null;
|
let tokensStore: TokensStore | null = null;
|
||||||
let playersStore: PlayersStore | 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 {
|
function emitPlayersState(): void {
|
||||||
const players = playersStore?.listPlayers() ?? [];
|
const players = playersStore?.listPlayers() ?? [];
|
||||||
const teams = playersStore?.listTeams() ?? [];
|
const teams = playersStore?.listTeams() ?? [];
|
||||||
@@ -432,6 +442,7 @@ async function main() {
|
|||||||
sceneTokensSessionStore.reset();
|
sceneTokensSessionStore.reset();
|
||||||
sceneNpcTokensSessionStore.reset();
|
sceneNpcTokensSessionStore.reset();
|
||||||
scenePlayerTokensSessionStore.reset();
|
scenePlayerTokensSessionStore.reset();
|
||||||
|
tokenGridSnapSessionStore.reset();
|
||||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||||
if (playerIds.length > 0) {
|
if (playerIds.length > 0) {
|
||||||
@@ -450,11 +461,14 @@ async function main() {
|
|||||||
emitSceneTokensSessionState();
|
emitSceneTokensSessionState();
|
||||||
emitSceneNpcTokensSessionState();
|
emitSceneNpcTokensSessionState();
|
||||||
emitScenePlayerTokensSessionState();
|
emitScenePlayerTokensSessionState();
|
||||||
|
emitTokenGridSnapState();
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||||
closeMultiWindow();
|
closeMultiWindow();
|
||||||
|
tokenGridSnapSessionStore.reset();
|
||||||
|
emitTokenGridSnapState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
|
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
|
||||||
@@ -507,8 +521,13 @@ async function main() {
|
|||||||
closeSceneEditorWindow();
|
closeSceneEditorWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.windows.openNpcs, () => {
|
registerHandler(ipcChannels.windows.openNpcs, (req) => {
|
||||||
openNpcsWindow();
|
openNpcsWindow();
|
||||||
|
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
|
||||||
|
if (npcId) {
|
||||||
|
npcsOverlayStore.dispatch({ kind: 'show', npcId });
|
||||||
|
emitNpcsOverlayState();
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.windows.closeNpcs, () => {
|
registerHandler(ipcChannels.windows.closeNpcs, () => {
|
||||||
@@ -576,6 +595,7 @@ async function main() {
|
|||||||
sceneTokensSessionStore.reset();
|
sceneTokensSessionStore.reset();
|
||||||
sceneNpcTokensSessionStore.reset();
|
sceneNpcTokensSessionStore.reset();
|
||||||
scenePlayerTokensSessionStore.reset();
|
scenePlayerTokensSessionStore.reset();
|
||||||
|
tokenGridSnapSessionStore.reset();
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
emitMaterialsOverlayState();
|
emitMaterialsOverlayState();
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
@@ -585,6 +605,7 @@ async function main() {
|
|||||||
emitSceneTokensSessionState();
|
emitSceneTokensSessionState();
|
||||||
emitSceneNpcTokensSessionState();
|
emitSceneNpcTokensSessionState();
|
||||||
emitScenePlayerTokensSessionState();
|
emitScenePlayerTokensSessionState();
|
||||||
|
emitTokenGridSnapState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
@@ -1361,6 +1382,13 @@ async function main() {
|
|||||||
return { ok: true };
|
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 () => {
|
registerHandler(ipcChannels.players.list, async () => {
|
||||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||||
return { players: [], teams: [] };
|
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;
|
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 {
|
.npcTokenScaleLabel {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
NPC_TOKEN_SESSION_SCALE_MIN,
|
NPC_TOKEN_SESSION_SCALE_MIN,
|
||||||
} from '../../shared/types/appPlayers';
|
} from '../../shared/types/appPlayers';
|
||||||
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
|
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
|
||||||
|
import { snapNormToGridCell } from '../../shared/types/sceneGridSnap';
|
||||||
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
||||||
@@ -57,6 +58,7 @@ import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
|||||||
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
|
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
|
||||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||||
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
|
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
|
||||||
|
import { useTokenGridSnapSession } from '../shared/tokens/useTokenGridSnapSession';
|
||||||
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
|
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
|
||||||
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
||||||
import { Button } from '../shared/ui/controls';
|
import { Button } from '../shared/ui/controls';
|
||||||
@@ -154,6 +156,7 @@ export function ControlApp() {
|
|||||||
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
||||||
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
|
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
|
||||||
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
|
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
|
||||||
|
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
|
||||||
const { players: appPlayers } = useAppPlayers();
|
const { players: appPlayers } = useAppPlayers();
|
||||||
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
|
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
@@ -372,6 +375,81 @@ export function ControlApp() {
|
|||||||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||||||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||||||
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
|
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 sceneDescription = currentScene?.description ?? '';
|
||||||
const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription);
|
const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription);
|
||||||
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
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 tool = fxState?.tool ?? { tool: 'none' as const, radiusN: 0.08, intensity: 0.6 };
|
||||||
const toolRef = useRef(tool);
|
const toolRef = useRef(tool);
|
||||||
|
/** Действия с токенами/ловушками только без активной кисти эффектов. */
|
||||||
|
const markersInteractive = tool.tool === 'none';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markersInteractive) setNpcSessionCtxMenu(null);
|
||||||
|
}, [markersInteractive]);
|
||||||
toolRef.current = tool;
|
toolRef.current = tool;
|
||||||
|
|
||||||
/** Повторный клик по активному инструменту снимает выбор. */
|
/** Повторный клик по активному инструменту снимает выбор. */
|
||||||
@@ -1779,6 +1863,28 @@ export function ControlApp() {
|
|||||||
<div className={styles.previewHeader}>
|
<div className={styles.previewHeader}>
|
||||||
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
|
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
|
||||||
<div className={styles.previewActions}>
|
<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 ? (
|
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||||
<>
|
<>
|
||||||
<label className={styles.npcTokenScale}>
|
<label className={styles.npcTokenScale}>
|
||||||
@@ -1813,6 +1919,32 @@ export function ControlApp() {
|
|||||||
? grid.sizeN
|
? grid.sizeN
|
||||||
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
|
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
|
||||||
scenePlayerTokensApi.dispatch({ kind: 'show', sizeN });
|
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
|
{scenePlayerTokensSession.visible
|
||||||
@@ -2025,9 +2157,16 @@ export function ControlApp() {
|
|||||||
library={appTokens}
|
library={appTokens}
|
||||||
session={sceneTokensSession}
|
session={sceneTokensSession}
|
||||||
viewport={previewContentRect}
|
viewport={previewContentRect}
|
||||||
editable
|
editable={markersInteractive}
|
||||||
|
snapNorm={snapNormActive}
|
||||||
onMove={(placementId, nx, ny) => {
|
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}
|
) : null}
|
||||||
@@ -2038,17 +2177,28 @@ export function ControlApp() {
|
|||||||
session={sceneNpcTokensSession}
|
session={sceneNpcTokensSession}
|
||||||
viewport={previewContentRect}
|
viewport={previewContentRect}
|
||||||
grid={currentScene?.grid ?? null}
|
grid={currentScene?.grid ?? null}
|
||||||
editable
|
editable={markersInteractive}
|
||||||
|
snapNorm={snapNormActive}
|
||||||
onMove={(placementId, nx, ny) => {
|
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({
|
setNpcSessionCtxMenu({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
placementId: String(placement.id),
|
placementId: String(placement.id),
|
||||||
});
|
});
|
||||||
}}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
|
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
|
||||||
@@ -2060,9 +2210,16 @@ export function ControlApp() {
|
|||||||
}
|
}
|
||||||
viewport={previewContentRect}
|
viewport={previewContentRect}
|
||||||
grid={currentScene?.grid ?? null}
|
grid={currentScene?.grid ?? null}
|
||||||
editable
|
editable={markersInteractive}
|
||||||
|
snapNorm={snapNormActive}
|
||||||
onMove={(playerId, nx, ny) => {
|
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}
|
) : null}
|
||||||
@@ -2072,6 +2229,7 @@ export function ControlApp() {
|
|||||||
session={sceneTraps}
|
session={sceneTraps}
|
||||||
viewport={previewContentRect}
|
viewport={previewContentRect}
|
||||||
mode="control"
|
mode="control"
|
||||||
|
interactive={markersInteractive}
|
||||||
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
|
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
|
||||||
onActivate={(trapId) => {
|
onActivate={(trapId) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -2096,10 +2254,8 @@ export function ControlApp() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
void playPoisonCloudEffectSound(poisonLifeMs);
|
void playPoisonCloudEffectSound(poisonLifeMs);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (trap?.type === 'explosion') {
|
if (trap?.type === 'explosion') {
|
||||||
// Тот же VFX/SFX, что у инструмента «Взрыв» на пульте эффектов.
|
|
||||||
const createdAtMs = Date.now();
|
const createdAtMs = Date.now();
|
||||||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||||||
const explosionLifeMs = await getExplosionEffectLifeMs();
|
const explosionLifeMs = await getExplosionEffectLifeMs();
|
||||||
@@ -2111,8 +2267,8 @@ export function ControlApp() {
|
|||||||
seed,
|
seed,
|
||||||
createdAtMs,
|
createdAtMs,
|
||||||
at: { x: trap.nx, y: trap.ny },
|
at: { x: trap.nx, y: trap.ny },
|
||||||
radiusN: Math.max(0.04, trap.sizeN * 1.15),
|
radiusN: Math.max(0.05, trap.sizeN * 1.35),
|
||||||
intensity: 1.05,
|
intensity: 1.15,
|
||||||
lifetimeMs: explosionLifeMs,
|
lifetimeMs: explosionLifeMs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2544,8 +2700,42 @@ export function ControlApp() {
|
|||||||
override?.disposition,
|
override?.disposition,
|
||||||
);
|
);
|
||||||
const inactive = Boolean(override?.inactive);
|
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 (
|
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) => (
|
{otherNpcDispositions(current).map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
@@ -2572,12 +2762,12 @@ export function ControlApp() {
|
|||||||
sceneNpcTokensApi.dispatch({
|
sceneNpcTokensApi.dispatch({
|
||||||
kind: 'setInactive',
|
kind: 'setInactive',
|
||||||
placementId: npcSessionCtxMenu.placementId,
|
placementId: npcSessionCtxMenu.placementId,
|
||||||
inactive: !inactive,
|
inactive: true,
|
||||||
});
|
});
|
||||||
setNpcSessionCtxMenu(null);
|
setNpcSessionCtxMenu(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{inactive ? t('npcs.makeActive') : t('npcs.inactive')}
|
{t('npcs.inactive')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
|||||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
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: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
const css = readControlAppCss();
|
const css = readControlAppCss();
|
||||||
|
|||||||
@@ -483,6 +483,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.makeFriendly': 'Сделать дружественным',
|
'npcs.makeFriendly': 'Сделать дружественным',
|
||||||
'npcs.inactive': 'Неактивен',
|
'npcs.inactive': 'Неактивен',
|
||||||
'npcs.makeActive': 'Сделать активным',
|
'npcs.makeActive': 'Сделать активным',
|
||||||
|
'npcs.openInfo': 'Открыть информацию',
|
||||||
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
||||||
'npcs.description': 'ОПИСАНИЕ',
|
'npcs.description': 'ОПИСАНИЕ',
|
||||||
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
||||||
@@ -623,6 +624,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.noActiveScene': 'Нет активной сцены.',
|
'control.noActiveScene': 'Нет активной сцены.',
|
||||||
'control.screenPreview': 'Предпросмотр экрана',
|
'control.screenPreview': 'Предпросмотр экрана',
|
||||||
'control.npcTokenScale': 'Размеры игр. токенов',
|
'control.npcTokenScale': 'Размеры игр. токенов',
|
||||||
|
'control.snapTokensToGrid': 'Привязка токенов к сетке',
|
||||||
|
'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется',
|
||||||
'control.stopPresentation': 'Выключить',
|
'control.stopPresentation': 'Выключить',
|
||||||
'control.showPlayers': 'Показать игроков',
|
'control.showPlayers': 'Показать игроков',
|
||||||
'control.hidePlayers': 'Скрыть игроков',
|
'control.hidePlayers': 'Скрыть игроков',
|
||||||
@@ -1102,6 +1105,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.makeFriendly': 'Make Friendly',
|
'npcs.makeFriendly': 'Make Friendly',
|
||||||
'npcs.inactive': 'Inactive',
|
'npcs.inactive': 'Inactive',
|
||||||
'npcs.makeActive': 'Make Active',
|
'npcs.makeActive': 'Make Active',
|
||||||
|
'npcs.openInfo': 'Open information',
|
||||||
'npcs.ringColor': 'TOKEN RING COLOR',
|
'npcs.ringColor': 'TOKEN RING COLOR',
|
||||||
'npcs.description': 'DESCRIPTION',
|
'npcs.description': 'DESCRIPTION',
|
||||||
'npcs.descriptionPlaceholder': 'Character description…',
|
'npcs.descriptionPlaceholder': 'Character description…',
|
||||||
@@ -1242,6 +1246,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.noActiveScene': 'No active scene.',
|
'control.noActiveScene': 'No active scene.',
|
||||||
'control.screenPreview': 'Screen preview',
|
'control.screenPreview': 'Screen preview',
|
||||||
'control.npcTokenScale': 'Play token size',
|
'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.stopPresentation': 'Turn off',
|
||||||
'control.showPlayers': 'Show players',
|
'control.showPlayers': 'Show players',
|
||||||
'control.hidePlayers': 'Hide players',
|
'control.hidePlayers': 'Hide players',
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ export function NpcsApp() {
|
|||||||
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
|
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
|
||||||
[activeIds, npcs],
|
[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 filteredNpcs = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -226,7 +235,7 @@ export function NpcsApp() {
|
|||||||
|
|
||||||
const relationsByNpcId = useMemo(() => {
|
const relationsByNpcId = useMemo(() => {
|
||||||
const map = new Map<NpcId, { id: string; text: string }[]>();
|
const map = new Map<NpcId, { id: string; text: string }[]>();
|
||||||
for (const npc of selectedNpcs) {
|
for (const npc of detailNpcs) {
|
||||||
const list = relations
|
const list = relations
|
||||||
.filter((r) => r.sourceNpcId === npc.id)
|
.filter((r) => r.sourceNpcId === npc.id)
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
@@ -236,7 +245,7 @@ export function NpcsApp() {
|
|||||||
map.set(npc.id, list);
|
map.set(npc.id, list);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [npcs, relations, selectedNpcs]);
|
}, [detailNpcs, npcs, relations]);
|
||||||
|
|
||||||
const onSelectTile = useCallback(
|
const onSelectTile = useCallback(
|
||||||
(id: NpcId) => {
|
(id: NpcId) => {
|
||||||
@@ -304,8 +313,8 @@ export function NpcsApp() {
|
|||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.detail}>
|
<div className={styles.detail}>
|
||||||
{selectedNpcs.length > 0 ? (
|
{detailNpcs.length > 0 ? (
|
||||||
selectedNpcs.map((npc) => {
|
detailNpcs.map((npc) => {
|
||||||
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
|
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
|
||||||
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
|
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,9 +38,11 @@
|
|||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Затемнение всего родителя (превью пульта / экран презентации); только при открытом материале/NPC. */
|
||||||
.dim {
|
.dim {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 39;
|
||||||
background: rgba(0, 0, 0, 0.62);
|
background: rgba(0, 0, 0, 0.62);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function NpcSprite({
|
|||||||
inactive,
|
inactive,
|
||||||
onMove,
|
onMove,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
snapNorm,
|
||||||
}: {
|
}: {
|
||||||
placement: SceneNpcToken;
|
placement: SceneNpcToken;
|
||||||
npc: ProjectNpc;
|
npc: ProjectNpc;
|
||||||
@@ -56,6 +57,7 @@ function NpcSprite({
|
|||||||
inactive: boolean;
|
inactive: boolean;
|
||||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||||
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
}) {
|
}) {
|
||||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||||
@@ -135,8 +137,11 @@ function NpcSprite({
|
|||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (drag?.pointerId !== e.pointerId) return;
|
if (drag?.pointerId !== e.pointerId) return;
|
||||||
const p = point(e);
|
const p = point(e);
|
||||||
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
const rawNx = 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 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 });
|
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
|
||||||
schedule(drag.lastNx, drag.lastNy);
|
schedule(drag.lastNx, drag.lastNy);
|
||||||
}
|
}
|
||||||
@@ -167,6 +172,7 @@ export function SceneNpcTokensOverlay({
|
|||||||
editable = false,
|
editable = false,
|
||||||
onMove,
|
onMove,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
snapNorm,
|
||||||
}: {
|
}: {
|
||||||
placements: readonly SceneNpcToken[];
|
placements: readonly SceneNpcToken[];
|
||||||
library: readonly ProjectNpc[];
|
library: readonly ProjectNpc[];
|
||||||
@@ -177,6 +183,7 @@ export function SceneNpcTokensOverlay({
|
|||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||||
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
}) {
|
}) {
|
||||||
if (!viewport) return null;
|
if (!viewport) return null;
|
||||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||||
@@ -205,6 +212,7 @@ export function SceneNpcTokensOverlay({
|
|||||||
inactive={inactive}
|
inactive={inactive}
|
||||||
{...(onMove ? { onMove } : {})}
|
{...(onMove ? { onMove } : {})}
|
||||||
{...(onContextMenu ? { onContextMenu } : {})}
|
{...(onContextMenu ? { onContextMenu } : {})}
|
||||||
|
{...(snapNorm ? { snapNorm } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function PlayerSprite({
|
|||||||
displayScale,
|
displayScale,
|
||||||
gridFit,
|
gridFit,
|
||||||
onMove,
|
onMove,
|
||||||
|
snapNorm,
|
||||||
}: {
|
}: {
|
||||||
player: AppPlayer;
|
player: AppPlayer;
|
||||||
nx: number;
|
nx: number;
|
||||||
@@ -35,6 +36,7 @@ function PlayerSprite({
|
|||||||
displayScale: number;
|
displayScale: number;
|
||||||
gridFit: number;
|
gridFit: number;
|
||||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||||
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
}) {
|
}) {
|
||||||
const imageUrl = usePlayerImageUrl(player.id);
|
const imageUrl = usePlayerImageUrl(player.id);
|
||||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||||
@@ -105,8 +107,11 @@ function PlayerSprite({
|
|||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (drag?.pointerId !== e.pointerId) return;
|
if (drag?.pointerId !== e.pointerId) return;
|
||||||
const p = point(e);
|
const p = point(e);
|
||||||
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
const rawNx = 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 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 });
|
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
|
||||||
schedule(drag.lastNx, drag.lastNy);
|
schedule(drag.lastNx, drag.lastNy);
|
||||||
}
|
}
|
||||||
@@ -135,6 +140,7 @@ export function ScenePlayerTokensOverlay({
|
|||||||
grid = null,
|
grid = null,
|
||||||
editable = false,
|
editable = false,
|
||||||
onMove,
|
onMove,
|
||||||
|
snapNorm,
|
||||||
}: {
|
}: {
|
||||||
library: readonly AppPlayer[];
|
library: readonly AppPlayer[];
|
||||||
session: ScenePlayerTokensSessionState | null;
|
session: ScenePlayerTokensSessionState | null;
|
||||||
@@ -144,6 +150,7 @@ export function ScenePlayerTokensOverlay({
|
|||||||
grid?: SceneGrid | null;
|
grid?: SceneGrid | null;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||||
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
}) {
|
}) {
|
||||||
if (!viewport || !session?.visible) return null;
|
if (!viewport || !session?.visible) return null;
|
||||||
const byId = new Map(library.map((p) => [String(p.id), p]));
|
const byId = new Map(library.map((p) => [String(p.id), p]));
|
||||||
@@ -166,6 +173,7 @@ export function ScenePlayerTokensOverlay({
|
|||||||
displayScale={displayScale}
|
displayScale={displayScale}
|
||||||
gridFit={gridFit}
|
gridFit={gridFit}
|
||||||
{...(onMove ? { onMove } : {})}
|
{...(onMove ? { onMove } : {})}
|
||||||
|
{...(snapNorm ? { snapNorm } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ export type SceneOverlayCloseAction = {
|
|||||||
type SceneOverlayHostProps = {
|
type SceneOverlayHostProps = {
|
||||||
/** Есть ли что показывать (материал и/или NPC). */
|
/** Есть ли что показывать (материал и/или NPC). */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
/** Область картинки сцены (contain); координаты относительно родителя. */
|
/**
|
||||||
|
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
|
||||||
|
* На пульте — прямоугольник соотношения сторон презентации; на презентации обычно не задаётся (весь экран).
|
||||||
|
*/
|
||||||
viewport?: SceneOverlayViewport | null;
|
viewport?: SceneOverlayViewport | null;
|
||||||
/** Рамка видимой области (предпросмотр пульта). */
|
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
|
||||||
showViewportGuide?: boolean;
|
showViewportGuide?: boolean;
|
||||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||||
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
|
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
|
||||||
@@ -26,8 +29,9 @@ type SceneOverlayHostProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Общий слой подложки для Materials + NPCs: один root и один `.dim`.
|
* Общий слой подложки для Materials + NPCs.
|
||||||
* Кадры остаются в дочерних оверлеях (`embedded`).
|
* Dim — только при `active`, на весь родитель (экран презентации / рамка превью пульта).
|
||||||
|
* Кадры остаются в дочерних оверлеях (`embedded`) внутри `viewport`.
|
||||||
*/
|
*/
|
||||||
export function SceneOverlayHost({
|
export function SceneOverlayHost({
|
||||||
active,
|
active,
|
||||||
@@ -61,7 +65,7 @@ export function SceneOverlayHost({
|
|||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
if (raf !== 0) window.cancelAnimationFrame(raf);
|
if (raf !== 0) window.cancelAnimationFrame(raf);
|
||||||
};
|
};
|
||||||
}, [active]);
|
}, [active, showViewportGuide, viewport]);
|
||||||
|
|
||||||
const ctx = useMemo(() => ({ rootRef, view }), [view]);
|
const ctx = useMemo(() => ({ rootRef, view }), [view]);
|
||||||
|
|
||||||
@@ -83,6 +87,7 @@ export function SceneOverlayHost({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SceneOverlayViewContext.Provider value={ctx}>
|
<SceneOverlayViewContext.Provider value={ctx}>
|
||||||
|
{active ? <div className={styles.dim} aria-hidden /> : null}
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
|
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
|
||||||
@@ -98,7 +103,6 @@ export function SceneOverlayHost({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
|
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
|
||||||
<div className={styles.dim} />
|
|
||||||
{children}
|
{children}
|
||||||
{closes.length > 0 ? (
|
{closes.length > 0 ? (
|
||||||
<div className={styles.closeStack}>
|
<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('styles.dim'));
|
||||||
assert.ok(host.includes('hostHitThrough'));
|
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('SceneOverlayHost'));
|
||||||
assert.ok(control.includes('embedded'));
|
assert.ok(control.includes('embedded'));
|
||||||
assert.ok(presentation.includes('SceneOverlayHost'));
|
assert.ok(presentation.includes('SceneOverlayHost'));
|
||||||
@@ -29,6 +32,7 @@ void test('SceneOverlayHost: один dim для materials + npcs в Control и
|
|||||||
assert.ok(control.includes('embedded'));
|
assert.ok(control.includes('embedded'));
|
||||||
assert.ok(presentation.includes('embedded'));
|
assert.ok(presentation.includes('embedded'));
|
||||||
assert.match(css, /\.hostHitThrough\s*\{[^}]*pointer-events:\s*none/s);
|
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', () => {
|
void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded без собственного dim', () => {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type Props = {
|
|||||||
viewport: Viewport | null;
|
viewport: Viewport | null;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||||
|
/** Snap во время drag (пульт, привязка к сетке). */
|
||||||
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
function TokenSprite({
|
function TokenSprite({
|
||||||
@@ -23,6 +25,7 @@ function TokenSprite({
|
|||||||
viewport,
|
viewport,
|
||||||
editable,
|
editable,
|
||||||
onMove,
|
onMove,
|
||||||
|
snapNorm,
|
||||||
}: {
|
}: {
|
||||||
placement: SceneToken;
|
placement: SceneToken;
|
||||||
nx: number;
|
nx: number;
|
||||||
@@ -30,6 +33,7 @@ function TokenSprite({
|
|||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||||
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
}) {
|
}) {
|
||||||
const url = useTokenImageUrl(placement.tokenId);
|
const url = useTokenImageUrl(placement.tokenId);
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
@@ -132,8 +136,9 @@ function TokenSprite({
|
|||||||
const p = hostToNorm(e.clientX, e.clientY, host);
|
const p = hostToNorm(e.clientX, e.clientY, host);
|
||||||
const nextNx = Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx)));
|
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)));
|
const nextNy = Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy)));
|
||||||
d.lastNx = nextNx;
|
const snapped = snapNorm ? snapNorm(nextNx, nextNy) : { nx: nextNx, ny: nextNy };
|
||||||
d.lastNy = nextNy;
|
d.lastNx = snapped.nx;
|
||||||
|
d.lastNy = snapped.ny;
|
||||||
if (frameRef.current) return;
|
if (frameRef.current) return;
|
||||||
frameRef.current = requestAnimationFrame(() => {
|
frameRef.current = requestAnimationFrame(() => {
|
||||||
frameRef.current = 0;
|
frameRef.current = 0;
|
||||||
@@ -163,6 +168,7 @@ export function SceneTokensOverlay({
|
|||||||
viewport,
|
viewport,
|
||||||
editable = false,
|
editable = false,
|
||||||
onMove,
|
onMove,
|
||||||
|
snapNorm,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (!viewport || placements.length === 0) return null;
|
if (!viewport || placements.length === 0) return null;
|
||||||
const known = new Set(library.map((t) => t.id));
|
const known = new Set(library.map((t) => t.id));
|
||||||
@@ -185,6 +191,7 @@ export function SceneTokensOverlay({
|
|||||||
viewport={viewport}
|
viewport={viewport}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
{...(onMove ? { onMove } : {})}
|
{...(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;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trapNonInteractive {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type Props = {
|
|||||||
viewport: { x: number; y: number; w: number; h: number } | null;
|
viewport: { x: number; y: number; w: number; h: number } | null;
|
||||||
/** Пульт: показывать все ловушки + RMB меню. Презентация: только revealed. */
|
/** Пульт: показывать все ловушки + RMB меню. Презентация: только revealed. */
|
||||||
mode: 'control' | 'presentation';
|
mode: 'control' | 'presentation';
|
||||||
|
/** На пульте: false — без контекстного меню (например, активна кисть эффектов). */
|
||||||
|
interactive?: boolean;
|
||||||
onReveal?: (trapId: string) => void;
|
onReveal?: (trapId: string) => void;
|
||||||
onActivate?: (trapId: string) => void;
|
onActivate?: (trapId: string) => void;
|
||||||
onDisarm?: (trapId: string) => void;
|
onDisarm?: (trapId: string) => void;
|
||||||
@@ -47,6 +49,7 @@ export function SceneTrapsOverlay({
|
|||||||
session,
|
session,
|
||||||
viewport,
|
viewport,
|
||||||
mode,
|
mode,
|
||||||
|
interactive = true,
|
||||||
onReveal,
|
onReveal,
|
||||||
onActivate,
|
onActivate,
|
||||||
onDisarm,
|
onDisarm,
|
||||||
@@ -54,6 +57,10 @@ export function SceneTrapsOverlay({
|
|||||||
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
|
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
|
||||||
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
|
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!interactive) setMenu(null);
|
||||||
|
}, [interactive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const act = session?.lastActivation;
|
const act = session?.lastActivation;
|
||||||
if (!act) return;
|
if (!act) return;
|
||||||
@@ -112,6 +119,7 @@ export function SceneTrapsOverlay({
|
|||||||
rt.status === 'active' ? styles.trapActive : '',
|
rt.status === 'active' ? styles.trapActive : '',
|
||||||
rt.status === 'disarmed' ? styles.trapDisarmed : '',
|
rt.status === 'disarmed' ? styles.trapDisarmed : '',
|
||||||
mode === 'control' && !rt.revealed ? styles.trapGmHidden : '',
|
mode === 'control' && !rt.revealed ? styles.trapGmHidden : '',
|
||||||
|
mode === 'control' && !interactive ? styles.trapNonInteractive : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
@@ -121,7 +129,7 @@ export function SceneTrapsOverlay({
|
|||||||
className={cls}
|
className={cls}
|
||||||
style={{ left, top, width: sizePx, height: sizePx }}
|
style={{ left, top, width: sizePx, height: sizePx }}
|
||||||
onContextMenu={
|
onContextMenu={
|
||||||
mode === 'control'
|
mode === 'control' && interactive
|
||||||
? (e) => {
|
? (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -163,7 +171,7 @@ export function SceneTrapsOverlay({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{menu && mode === 'control'
|
{menu && mode === 'control' && interactive
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
* (Players library, run-with-players, circular NPC/player session tokens, disposition UI).
|
* (Players library, run-with-players, circular NPC/player session tokens, disposition UI).
|
||||||
* Re-enable after QA.
|
* Re-enable after QA.
|
||||||
*/
|
*/
|
||||||
export const USERS_BRANCH_FEATURES_ENABLED = false;
|
export const USERS_BRANCH_FEATURES_ENABLED = true;
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
|
|||||||
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
|
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
|
||||||
assert.match(src, /sceneNpcTokensSession:\s*\{/);
|
assert.match(src, /sceneNpcTokensSession:\s*\{/);
|
||||||
assert.match(src, /scenePlayerTokensSession:\s*\{/);
|
assert.match(src, /scenePlayerTokensSession:\s*\{/);
|
||||||
|
assert.match(src, /tokenGridSnap:\s*\{/);
|
||||||
|
assert.match(src, /tokenGridSnap\.setEnabled/);
|
||||||
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ export const ipcChannels = {
|
|||||||
dispatch: 'sceneTokensSession.dispatch',
|
dispatch: 'sceneTokensSession.dispatch',
|
||||||
stateChanged: 'sceneTokensSession.stateChanged',
|
stateChanged: 'sceneTokensSession.stateChanged',
|
||||||
},
|
},
|
||||||
|
tokenGridSnap: {
|
||||||
|
getState: 'tokenGridSnap.getState',
|
||||||
|
setEnabled: 'tokenGridSnap.setEnabled',
|
||||||
|
stateChanged: 'tokenGridSnap.stateChanged',
|
||||||
|
},
|
||||||
players: {
|
players: {
|
||||||
list: 'players.list',
|
list: 'players.list',
|
||||||
upsert: 'players.upsert',
|
upsert: 'players.upsert',
|
||||||
@@ -293,6 +298,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||||
|
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||||
@@ -673,7 +679,7 @@ export type IpcInvokeMap = {
|
|||||||
res: { ok: true };
|
res: { ok: true };
|
||||||
};
|
};
|
||||||
[ipcChannels.windows.openNpcs]: {
|
[ipcChannels.windows.openNpcs]: {
|
||||||
req: Record<string, never>;
|
req: { npcId?: NpcId | null };
|
||||||
res: { ok: true };
|
res: { ok: true };
|
||||||
};
|
};
|
||||||
[ipcChannels.windows.closeNpcs]: {
|
[ipcChannels.windows.closeNpcs]: {
|
||||||
@@ -768,6 +774,14 @@ export type IpcInvokeMap = {
|
|||||||
req: { event: SceneTokensSessionEvent };
|
req: { event: SceneTokensSessionEvent };
|
||||||
res: { ok: true };
|
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]: {
|
[ipcChannels.players.list]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
res: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||||
@@ -873,6 +887,7 @@ export type LegacyIpcEventMap = {
|
|||||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||||
|
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export * from './npcDisposition';
|
|||||||
export * from './npcs';
|
export * from './npcs';
|
||||||
export * from './sceneDarkness';
|
export * from './sceneDarkness';
|
||||||
export * from './sceneGrid';
|
export * from './sceneGrid';
|
||||||
|
export * from './sceneGridSnap';
|
||||||
export * from './sceneTraps';
|
export * from './sceneTraps';
|
||||||
export * from './sceneView';
|
export * from './sceneView';
|
||||||
export * from './videoPlayback';
|
export * from './videoPlayback';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"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": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user