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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-06 13:48:59 +08:00
parent e53d1ea934
commit 4456eb0277
24 changed files with 563 additions and 41 deletions
+209 -19
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
@@ -26,6 +26,7 @@ import {
NPC_TOKEN_SESSION_SCALE_MIN,
} from '../../shared/types/appPlayers';
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
import { snapNormToGridCell } from '../../shared/types/sceneGridSnap';
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
@@ -57,6 +58,7 @@ import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
import { useTokenGridSnapSession } from '../shared/tokens/useTokenGridSnapSession';
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls';
@@ -154,6 +156,7 @@ export function ControlApp() {
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
const { players: appPlayers } = useAppPlayers();
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
x: number;
@@ -372,6 +375,81 @@ export function ControlApp() {
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
const snapNormActive = useCallback(
(nx: number, ny: number) => {
const grid = currentScene?.grid;
if (!tokenGridSnap || !grid?.enabled || !previewContentRect) return { nx, ny };
return snapNormToGridCell(nx, ny, grid, previewContentRect.w, previewContentRect.h);
},
[currentScene?.grid, previewContentRect, tokenGridSnap],
);
const snapAllTokensToGrid = useCallback(async () => {
const grid = currentScene?.grid;
const rect = previewContentRect;
if (!grid?.enabled || !rect) return;
for (const placement of currentScene?.tokens ?? []) {
const key = String(placement.id);
const override = sceneTokensSession?.byPlacementId[key];
const raw = { nx: override?.nx ?? placement.nx, ny: override?.ny ?? placement.ny };
const snapped = snapNormToGridCell(raw.nx, raw.ny, grid, rect.w, rect.h);
if (snapped.nx === raw.nx && snapped.ny === raw.ny) continue;
await sceneTokensApi.dispatch({
kind: 'move',
placementId: key,
nx: snapped.nx,
ny: snapped.ny,
});
}
for (const placement of currentScene?.npcTokens ?? []) {
const key = String(placement.id);
const override = sceneNpcTokensSession?.byPlacementId[key];
const raw = {
nx: override?.nx ?? placement.nx,
ny: override?.ny ?? placement.ny,
};
const snapped = snapNormToGridCell(raw.nx, raw.ny, grid, rect.w, rect.h);
if (snapped.nx === raw.nx && snapped.ny === raw.ny) continue;
await sceneNpcTokensApi.dispatch({
kind: 'move',
placementId: key,
nx: snapped.nx,
ny: snapped.ny,
});
}
if (scenePlayerTokensSession?.visible) {
for (const playerId of scenePlayerTokensSession.selectedPlayerIds) {
const placement = scenePlayerTokensSession.byPlayerId[playerId];
if (!placement) continue;
const snapped = snapNormToGridCell(placement.nx, placement.ny, grid, rect.w, rect.h);
if (snapped.nx === placement.nx && snapped.ny === placement.ny) continue;
await scenePlayerTokensApi.dispatch({
kind: 'move',
playerId,
nx: snapped.nx,
ny: snapped.ny,
});
}
}
}, [
currentScene?.grid,
currentScene?.npcTokens,
currentScene?.tokens,
previewContentRect,
sceneNpcTokensApi,
sceneNpcTokensSession?.byPlacementId,
scenePlayerTokensApi,
scenePlayerTokensSession?.byPlayerId,
scenePlayerTokensSession?.selectedPlayerIds,
scenePlayerTokensSession?.visible,
sceneTokensApi,
sceneTokensSession?.byPlacementId,
]);
const sceneDescription = currentScene?.description ?? '';
const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription);
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
@@ -911,6 +989,12 @@ export function ControlApp() {
const tool = fxState?.tool ?? { tool: 'none' as const, radiusN: 0.08, intensity: 0.6 };
const toolRef = useRef(tool);
/** Действия с токенами/ловушками только без активной кисти эффектов. */
const markersInteractive = tool.tool === 'none';
useEffect(() => {
if (!markersInteractive) setNpcSessionCtxMenu(null);
}, [markersInteractive]);
toolRef.current = tool;
/** Повторный клик по активному инструменту снимает выбор. */
@@ -1779,6 +1863,28 @@ export function ControlApp() {
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}>
<label
className={styles.snapToGrid}
title={
currentScene?.grid?.enabled
? t('control.snapTokensToGrid')
: t('control.snapTokensToGridNoGrid')
}
>
<input
type="checkbox"
data-testid="token-grid-snap"
checked={tokenGridSnap}
onChange={(e) => {
const next = e.currentTarget.checked;
void (async () => {
const enabled = await tokenGridSnapApi.setEnabled(next);
if (enabled) await snapAllTokensToGrid();
})();
}}
/>
<span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span>
</label>
{USERS_BRANCH_FEATURES_ENABLED ? (
<>
<label className={styles.npcTokenScale}>
@@ -1813,6 +1919,32 @@ export function ControlApp() {
? grid.sizeN
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
scenePlayerTokensApi.dispatch({ kind: 'show', sizeN });
if (tokenGridSnap && grid?.enabled && previewContentRect) {
void (async () => {
const { state } = await api.invoke(
ipcChannels.scenePlayerTokensSession.getState,
{},
);
for (const playerId of state.selectedPlayerIds) {
const placement = state.byPlayerId[playerId];
if (!placement) continue;
const snapped = snapNormToGridCell(
placement.nx,
placement.ny,
grid,
previewContentRect.w,
previewContentRect.h,
);
if (snapped.nx === placement.nx && snapped.ny === placement.ny) continue;
scenePlayerTokensApi.dispatch({
kind: 'move',
playerId,
nx: snapped.nx,
ny: snapped.ny,
});
}
})();
}
}}
>
{scenePlayerTokensSession.visible
@@ -2025,9 +2157,16 @@ export function ControlApp() {
library={appTokens}
session={sceneTokensSession}
viewport={previewContentRect}
editable
editable={markersInteractive}
snapNorm={snapNormActive}
onMove={(placementId, nx, ny) => {
void sceneTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
const snapped = snapNormActive(nx, ny);
void sceneTokensApi.dispatch({
kind: 'move',
placementId,
nx: snapped.nx,
ny: snapped.ny,
});
}}
/>
) : null}
@@ -2038,17 +2177,28 @@ export function ControlApp() {
session={sceneNpcTokensSession}
viewport={previewContentRect}
grid={currentScene?.grid ?? null}
editable
editable={markersInteractive}
snapNorm={snapNormActive}
onMove={(placementId, nx, ny) => {
sceneNpcTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
}}
onContextMenu={(e, placement) => {
setNpcSessionCtxMenu({
x: e.clientX,
y: e.clientY,
placementId: String(placement.id),
const snapped = snapNormActive(nx, ny);
sceneNpcTokensApi.dispatch({
kind: 'move',
placementId,
nx: snapped.nx,
ny: snapped.ny,
});
}}
onContextMenu={
markersInteractive
? (e, placement) => {
setNpcSessionCtxMenu({
x: e.clientX,
y: e.clientY,
placementId: String(placement.id),
});
}
: undefined
}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
@@ -2060,9 +2210,16 @@ export function ControlApp() {
}
viewport={previewContentRect}
grid={currentScene?.grid ?? null}
editable
editable={markersInteractive}
snapNorm={snapNormActive}
onMove={(playerId, nx, ny) => {
scenePlayerTokensApi.dispatch({ kind: 'move', playerId, nx, ny });
const snapped = snapNormActive(nx, ny);
scenePlayerTokensApi.dispatch({
kind: 'move',
playerId,
nx: snapped.nx,
ny: snapped.ny,
});
}}
/>
) : null}
@@ -2072,6 +2229,7 @@ export function ControlApp() {
session={sceneTraps}
viewport={previewContentRect}
mode="control"
interactive={markersInteractive}
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
onActivate={(trapId) => {
void (async () => {
@@ -2096,10 +2254,8 @@ export function ControlApp() {
},
});
void playPoisonCloudEffectSound(poisonLifeMs);
return;
}
if (trap?.type === 'explosion') {
// Тот же VFX/SFX, что у инструмента «Взрыв» на пульте эффектов.
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const explosionLifeMs = await getExplosionEffectLifeMs();
@@ -2111,8 +2267,8 @@ export function ControlApp() {
seed,
createdAtMs,
at: { x: trap.nx, y: trap.ny },
radiusN: Math.max(0.04, trap.sizeN * 1.15),
intensity: 1.05,
radiusN: Math.max(0.05, trap.sizeN * 1.35),
intensity: 1.15,
lifetimeMs: explosionLifeMs,
},
});
@@ -2544,8 +2700,42 @@ export function ControlApp() {
override?.disposition,
);
const inactive = Boolean(override?.inactive);
if (inactive) {
return (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
sceneNpcTokensApi.dispatch({
kind: 'setInactive',
placementId: npcSessionCtxMenu.placementId,
inactive: false,
});
setNpcSessionCtxMenu(null);
}}
>
{t('npcs.makeActive')}
</button>
);
}
return (
<>
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
setNpcSessionCtxMenu(null);
void api
.invoke(ipcChannels.windows.openNpcs, { npcId: npc.id })
.catch((err) => {
console.error('[control] openNpcs failed', err);
});
}}
>
{t('npcs.openInfo')}
</button>
{otherNpcDispositions(current).map((d) => (
<button
key={d}
@@ -2572,12 +2762,12 @@ export function ControlApp() {
sceneNpcTokensApi.dispatch({
kind: 'setInactive',
placementId: npcSessionCtxMenu.placementId,
inactive: !inactive,
inactive: true,
});
setNpcSessionCtxMenu(null);
}}
>
{inactive ? t('npcs.makeActive') : t('npcs.inactive')}
{t('npcs.inactive')}
</button>
</>
);