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,
|
||||
} from '../shared/project/projectZipExtension';
|
||||
import type { Project } from '../shared/types';
|
||||
import { asNpcId } from '../shared/types/ids';
|
||||
|
||||
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||
@@ -34,6 +35,7 @@ import { PlayersStore } from './players/playersStore';
|
||||
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
||||
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
||||
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
||||
import { TokenGridSnapSessionStore } from './tokens/tokenGridSnapSessionStore';
|
||||
import { TokensStore } from './tokens/tokensStore';
|
||||
import { installAutoUpdater } from './update/installAutoUpdater';
|
||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||
@@ -156,6 +158,7 @@ const npcsOverlayStore = new NpcsOverlayStore();
|
||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
||||
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
||||
let tokensStore: TokensStore | null = null;
|
||||
let playersStore: PlayersStore | null = null;
|
||||
|
||||
@@ -226,6 +229,13 @@ function emitSceneTokensSessionState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function emitTokenGridSnapState(): void {
|
||||
const { enabled } = tokenGridSnapSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.tokenGridSnap.stateChanged, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
function emitPlayersState(): void {
|
||||
const players = playersStore?.listPlayers() ?? [];
|
||||
const teams = playersStore?.listTeams() ?? [];
|
||||
@@ -432,6 +442,7 @@ async function main() {
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||
if (playerIds.length > 0) {
|
||||
@@ -450,11 +461,14 @@ async function main() {
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitTokenGridSnapState();
|
||||
emitEffectsState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||
closeMultiWindow();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
emitTokenGridSnapState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
|
||||
@@ -507,8 +521,13 @@ async function main() {
|
||||
closeSceneEditorWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openNpcs, () => {
|
||||
registerHandler(ipcChannels.windows.openNpcs, (req) => {
|
||||
openNpcsWindow();
|
||||
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
|
||||
if (npcId) {
|
||||
npcsOverlayStore.dispatch({ kind: 'show', npcId });
|
||||
emitNpcsOverlayState();
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeNpcs, () => {
|
||||
@@ -576,6 +595,7 @@ async function main() {
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
@@ -585,6 +605,7 @@ async function main() {
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitTokenGridSnapState();
|
||||
emitSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -1361,6 +1382,13 @@ async function main() {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.tokenGridSnap.getState, () => tokenGridSnapSessionStore.getState());
|
||||
registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => {
|
||||
const next = tokenGridSnapSessionStore.setEnabled(enabled);
|
||||
emitTokenGridSnapState();
|
||||
return next;
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.players.list, async () => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
return { players: [], teams: [] };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.snapToGrid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snapToGridLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -116,6 +116,18 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
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: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
|
||||
@@ -483,6 +483,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.makeFriendly': 'Сделать дружественным',
|
||||
'npcs.inactive': 'Неактивен',
|
||||
'npcs.makeActive': 'Сделать активным',
|
||||
'npcs.openInfo': 'Открыть информацию',
|
||||
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
||||
'npcs.description': 'ОПИСАНИЕ',
|
||||
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
||||
@@ -623,6 +624,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.noActiveScene': 'Нет активной сцены.',
|
||||
'control.screenPreview': 'Предпросмотр экрана',
|
||||
'control.npcTokenScale': 'Размеры игр. токенов',
|
||||
'control.snapTokensToGrid': 'Привязка токенов к сетке',
|
||||
'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется',
|
||||
'control.stopPresentation': 'Выключить',
|
||||
'control.showPlayers': 'Показать игроков',
|
||||
'control.hidePlayers': 'Скрыть игроков',
|
||||
@@ -1102,6 +1105,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.makeFriendly': 'Make Friendly',
|
||||
'npcs.inactive': 'Inactive',
|
||||
'npcs.makeActive': 'Make Active',
|
||||
'npcs.openInfo': 'Open information',
|
||||
'npcs.ringColor': 'TOKEN RING COLOR',
|
||||
'npcs.description': 'DESCRIPTION',
|
||||
'npcs.descriptionPlaceholder': 'Character description…',
|
||||
@@ -1242,6 +1246,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.noActiveScene': 'No active scene.',
|
||||
'control.screenPreview': 'Screen preview',
|
||||
'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.showPlayers': 'Show 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, 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 q = query.trim().toLowerCase();
|
||||
@@ -226,7 +235,7 @@ export function NpcsApp() {
|
||||
|
||||
const relationsByNpcId = useMemo(() => {
|
||||
const map = new Map<NpcId, { id: string; text: string }[]>();
|
||||
for (const npc of selectedNpcs) {
|
||||
for (const npc of detailNpcs) {
|
||||
const list = relations
|
||||
.filter((r) => r.sourceNpcId === npc.id)
|
||||
.map((r) => {
|
||||
@@ -236,7 +245,7 @@ export function NpcsApp() {
|
||||
map.set(npc.id, list);
|
||||
}
|
||||
return map;
|
||||
}, [npcs, relations, selectedNpcs]);
|
||||
}, [detailNpcs, npcs, relations]);
|
||||
|
||||
const onSelectTile = useCallback(
|
||||
(id: NpcId) => {
|
||||
@@ -304,8 +313,8 @@ export function NpcsApp() {
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detail}>
|
||||
{selectedNpcs.length > 0 ? (
|
||||
selectedNpcs.map((npc) => {
|
||||
{detailNpcs.length > 0 ? (
|
||||
detailNpcs.map((npc) => {
|
||||
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
|
||||
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
|
||||
return (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
* (Players library, run-with-players, circular NPC/player session tokens, disposition UI).
|
||||
* 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, /sceneNpcTokensSession:\s*\{/);
|
||||
assert.match(src, /scenePlayerTokensSession:\s*\{/);
|
||||
assert.match(src, /tokenGridSnap:\s*\{/);
|
||||
assert.match(src, /tokenGridSnap\.setEnabled/);
|
||||
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
||||
});
|
||||
|
||||
@@ -197,6 +197,11 @@ export const ipcChannels = {
|
||||
dispatch: 'sceneTokensSession.dispatch',
|
||||
stateChanged: 'sceneTokensSession.stateChanged',
|
||||
},
|
||||
tokenGridSnap: {
|
||||
getState: 'tokenGridSnap.getState',
|
||||
setEnabled: 'tokenGridSnap.setEnabled',
|
||||
stateChanged: 'tokenGridSnap.stateChanged',
|
||||
},
|
||||
players: {
|
||||
list: 'players.list',
|
||||
upsert: 'players.upsert',
|
||||
@@ -293,6 +298,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||
@@ -673,7 +679,7 @@ export type IpcInvokeMap = {
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openNpcs]: {
|
||||
req: Record<string, never>;
|
||||
req: { npcId?: NpcId | null };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.closeNpcs]: {
|
||||
@@ -768,6 +774,14 @@ export type IpcInvokeMap = {
|
||||
req: { event: SceneTokensSessionEvent };
|
||||
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]: {
|
||||
req: Record<string, never>;
|
||||
res: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
@@ -873,6 +887,7 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './npcDisposition';
|
||||
export * from './npcs';
|
||||
export * from './sceneDarkness';
|
||||
export * from './sceneGrid';
|
||||
export * from './sceneGridSnap';
|
||||
export * from './sceneTraps';
|
||||
export * from './sceneView';
|
||||
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",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"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:write": "prettier . --write",
|
||||
"postinstall": "patch-package",
|
||||
|
||||
Reference in New Issue
Block a user