> = {
'npcs.makeFriendly': 'Make Friendly',
'npcs.inactive': 'Inactive',
'npcs.makeActive': 'Make Active',
+ 'npcs.hideFromPlayers': 'Hide from players',
+ 'npcs.showToPlayers': 'Show to players',
'npcs.openInfo': 'Open information',
'npcs.ringColor': 'TOKEN RING COLOR',
'npcs.description': 'DESCRIPTION',
diff --git a/app/renderer/sceneEditor/SceneEditorApp.tsx b/app/renderer/sceneEditor/SceneEditorApp.tsx
index 4fb0dfa..fbf9324 100644
--- a/app/renderer/sceneEditor/SceneEditorApp.tsx
+++ b/app/renderer/sceneEditor/SceneEditorApp.tsx
@@ -420,8 +420,8 @@ export function SceneEditorApp() {
const defaultTemplateName = useMemo(() => {
const sizeLabel = Math.round(localGrid.sizeN * 100);
- return `${sceneGridTypeLabelRu(localGrid.type)} · ${String(sizeLabel)}`;
- }, [localGrid.sizeN, localGrid.type]);
+ return `${t(localGrid.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · ${String(sizeLabel)}`;
+ }, [localGrid.sizeN, localGrid.type, t]);
const saveGridTemplate = async () => {
if (!sceneId) return;
@@ -782,7 +782,7 @@ export function SceneEditorApp() {
{tpl.name}
)}
- {sceneGridTypeLabelRu(tpl.type)} · {Math.round(tpl.sizeN * 100)}
+ {t(tpl.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · {Math.round(tpl.sizeN * 100)}
diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx
index 1b8fc57..70917fc 100644
--- a/app/renderer/shared/PresentationView.tsx
+++ b/app/renderer/shared/PresentationView.tsx
@@ -107,6 +107,9 @@ export function PresentationView({
sceneNpcTokensSession.spawned,
session?.currentSceneId,
);
+ const presentationNpcTokens = visibleNpcTokens.filter(
+ (token) => !sceneNpcTokensSession.byPlacementId[String(token.id)]?.hiddenFromPlayers,
+ );
useTokenPathLivePoses({
tokens: scene?.tokens ?? [],
npcTokens: visibleNpcTokens,
@@ -245,7 +248,7 @@ export function PresentationView({
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
) : null}
{USERS_BRANCH_FEATURES_ENABLED &&
diff --git a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css
index 40ca8bb..759fc6c 100644
--- a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css
+++ b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css
@@ -35,6 +35,23 @@
z-index: 10;
}
+.hiddenFromPlayers {
+ opacity: 0.55;
+}
+
+.hiddenFromPlayers::after {
+ content: '';
+ position: absolute;
+ inset: -3px;
+ border-radius: 50%;
+ border: 2px dashed var(--text2);
+ pointer-events: none;
+}
+
+.concealedFromPlayers {
+ visibility: hidden;
+}
+
/* Кольцо строго по центру круга токена (обёртка = sizePx × sizePx). */
.initiativeActive::before {
content: '';
diff --git a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx
index a937637..e663ccc 100644
--- a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx
+++ b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx
@@ -51,6 +51,8 @@ function NpcSprite({
gridFit,
disposition,
inactive,
+ hiddenFromPlayers,
+ concealed,
onMove,
onResize,
resizeTitle,
@@ -71,6 +73,8 @@ function NpcSprite({
gridFit: number;
disposition: NpcDisposition;
inactive: boolean;
+ hiddenFromPlayers: boolean;
+ concealed: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
@@ -153,6 +157,8 @@ function NpcSprite({
styles.token,
canDrag ? styles.editable : '',
initiativeActive ? styles.initiativeActive : '',
+ hiddenFromPlayers && !concealed ? styles.hiddenFromPlayers : '',
+ concealed ? styles.concealedFromPlayers : '',
]
.filter(Boolean)
.join(' ')}
@@ -274,6 +280,7 @@ export function SceneNpcTokensOverlay({
snapNorm,
poseOverrides = null,
dragEnabledById = null,
+ concealFromPlayers = false,
}: {
placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[];
@@ -289,6 +296,8 @@ export function SceneNpcTokensOverlay({
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
poseOverrides?: Record | null;
dragEnabledById?: Record | null;
+ /** На презентации скрытый от игроков токен остаётся в DOM, чтобы путь продолжал идти. */
+ concealFromPlayers?: boolean;
}) {
const [initiative] = useInitiativeState();
if (!viewport) return null;
@@ -305,6 +314,7 @@ export function SceneNpcTokensOverlay({
const override = session?.byPlacementId[key];
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
const inactive = Boolean(override?.inactive);
+ const hiddenFromPlayers = Boolean(override?.hiddenFromPlayers);
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (