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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user