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
@@ -38,9 +38,11 @@
cursor: zoom-out;
}
/** Затемнение всего родителя (превью пульта / экран презентации); только при открытом материале/NPC. */
.dim {
position: absolute;
inset: 0;
z-index: 39;
background: rgba(0, 0, 0, 0.62);
pointer-events: none;
}
@@ -43,6 +43,7 @@ function NpcSprite({
inactive,
onMove,
onContextMenu,
snapNorm,
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
@@ -56,6 +57,7 @@ function NpcSprite({
inactive: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
@@ -135,8 +137,11 @@ function NpcSprite({
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const p = point(e);
drag.lastNx = 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 rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
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 });
schedule(drag.lastNx, drag.lastNy);
}
@@ -167,6 +172,7 @@ export function SceneNpcTokensOverlay({
editable = false,
onMove,
onContextMenu,
snapNorm,
}: {
placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[];
@@ -177,6 +183,7 @@ export function SceneNpcTokensOverlay({
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
if (!viewport) return null;
const byId = new Map(library.map((npc) => [npc.id, npc]));
@@ -205,6 +212,7 @@ export function SceneNpcTokensOverlay({
inactive={inactive}
{...(onMove ? { onMove } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
);
})}
@@ -25,6 +25,7 @@ function PlayerSprite({
displayScale,
gridFit,
onMove,
snapNorm,
}: {
player: AppPlayer;
nx: number;
@@ -35,6 +36,7 @@ function PlayerSprite({
displayScale: number;
gridFit: number;
onMove?: (playerId: string, nx: number, ny: number) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = usePlayerImageUrl(player.id);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
@@ -105,8 +107,11 @@ function PlayerSprite({
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const p = point(e);
drag.lastNx = 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 rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
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 });
schedule(drag.lastNx, drag.lastNy);
}
@@ -135,6 +140,7 @@ export function ScenePlayerTokensOverlay({
grid = null,
editable = false,
onMove,
snapNorm,
}: {
library: readonly AppPlayer[];
session: ScenePlayerTokensSessionState | null;
@@ -144,6 +150,7 @@ export function ScenePlayerTokensOverlay({
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (playerId: string, nx: number, ny: number) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
if (!viewport || !session?.visible) return null;
const byId = new Map(library.map((p) => [String(p.id), p]));
@@ -166,6 +173,7 @@ export function ScenePlayerTokensOverlay({
displayScale={displayScale}
gridFit={gridFit}
{...(onMove ? { onMove } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
);
})}
@@ -15,9 +15,12 @@ export type SceneOverlayCloseAction = {
type SceneOverlayHostProps = {
/** Есть ли что показывать (материал и/или NPC). */
active: boolean;
/** Область картинки сцены (contain); координаты относительно родителя. */
/**
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
* На пульте — прямоугольник соотношения сторон презентации; на презентации обычно не задаётся (весь экран).
*/
viewport?: SceneOverlayViewport | null;
/** Рамка видимой области (предпросмотр пульта). */
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
showViewportGuide?: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
@@ -26,8 +29,9 @@ type SceneOverlayHostProps = {
};
/**
* Общий слой подложки для Materials + NPCs: один root и один `.dim`.
* Кадры остаются в дочерних оверлеях (`embedded`).
* Общий слой подложки для Materials + NPCs.
* Dim — только при `active`, на весь родитель (экран презентации / рамка превью пульта).
* Кадры остаются в дочерних оверлеях (`embedded`) внутри `viewport`.
*/
export function SceneOverlayHost({
active,
@@ -61,7 +65,7 @@ export function SceneOverlayHost({
ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [active]);
}, [active, showViewportGuide, viewport]);
const ctx = useMemo(() => ({ rootRef, view }), [view]);
@@ -83,6 +87,7 @@ export function SceneOverlayHost({
return (
<SceneOverlayViewContext.Provider value={ctx}>
{active ? <div className={styles.dim} aria-hidden /> : null}
<div
ref={rootRef}
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
@@ -98,7 +103,6 @@ export function SceneOverlayHost({
}}
>
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
<div className={styles.dim} />
{children}
{closes.length > 0 ? (
<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('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('embedded'));
assert.ok(presentation.includes('SceneOverlayHost'));
@@ -29,6 +32,7 @@ void test('SceneOverlayHost: один dim для materials + npcs в Control и
assert.ok(control.includes('embedded'));
assert.ok(presentation.includes('embedded'));
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', () => {
@@ -14,6 +14,8 @@ type Props = {
viewport: Viewport | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
/** Snap во время drag (пульт, привязка к сетке). */
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
};
function TokenSprite({
@@ -23,6 +25,7 @@ function TokenSprite({
viewport,
editable,
onMove,
snapNorm,
}: {
placement: SceneToken;
nx: number;
@@ -30,6 +33,7 @@ function TokenSprite({
viewport: Viewport;
editable: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const url = useTokenImageUrl(placement.tokenId);
const dragRef = useRef<{
@@ -132,8 +136,9 @@ function TokenSprite({
const p = hostToNorm(e.clientX, e.clientY, host);
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)));
d.lastNx = nextNx;
d.lastNy = nextNy;
const snapped = snapNorm ? snapNorm(nextNx, nextNy) : { nx: nextNx, ny: nextNy };
d.lastNx = snapped.nx;
d.lastNy = snapped.ny;
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0;
@@ -163,6 +168,7 @@ export function SceneTokensOverlay({
viewport,
editable = false,
onMove,
snapNorm,
}: Props) {
if (!viewport || placements.length === 0) return null;
const known = new Set(library.map((t) => t.id));
@@ -185,6 +191,7 @@ export function SceneTokensOverlay({
viewport={viewport}
editable={editable}
{...(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;
}
.trapNonInteractive {
pointer-events: none;
}
.label {
position: absolute;
left: 50%;
@@ -21,6 +21,8 @@ type Props = {
viewport: { x: number; y: number; w: number; h: number } | null;
/** Пульт: показывать все ловушки + RMB меню. Презентация: только revealed. */
mode: 'control' | 'presentation';
/** На пульте: false — без контекстного меню (например, активна кисть эффектов). */
interactive?: boolean;
onReveal?: (trapId: string) => void;
onActivate?: (trapId: string) => void;
onDisarm?: (trapId: string) => void;
@@ -47,6 +49,7 @@ export function SceneTrapsOverlay({
session,
viewport,
mode,
interactive = true,
onReveal,
onActivate,
onDisarm,
@@ -54,6 +57,10 @@ export function SceneTrapsOverlay({
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
useEffect(() => {
if (!interactive) setMenu(null);
}, [interactive]);
useEffect(() => {
const act = session?.lastActivation;
if (!act) return;
@@ -112,6 +119,7 @@ export function SceneTrapsOverlay({
rt.status === 'active' ? styles.trapActive : '',
rt.status === 'disarmed' ? styles.trapDisarmed : '',
mode === 'control' && !rt.revealed ? styles.trapGmHidden : '',
mode === 'control' && !interactive ? styles.trapNonInteractive : '',
]
.filter(Boolean)
.join(' ');
@@ -121,7 +129,7 @@ export function SceneTrapsOverlay({
className={cls}
style={{ left, top, width: sizePx, height: sizePx }}
onContextMenu={
mode === 'control'
mode === 'control' && interactive
? (e) => {
e.preventDefault();
e.stopPropagation();
@@ -163,7 +171,7 @@ export function SceneTrapsOverlay({
}}
/>
) : null}
{menu && mode === 'control'
{menu && mode === 'control' && interactive
? createPortal(
<div
role="menu"