feat(play): hide NPC tokens from the player screen
A token can be hidden per session from the control menu. The GM still sees it, and its path keeps moving. Scene editor grid labels no longer call a removed helper. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 ? (
|
||||
<TokenPathsOverlay
|
||||
tokens={scene.tokens ?? []}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? presentationNpcTokens : []}
|
||||
viewport={contentRect}
|
||||
mode="presentation"
|
||||
pathSession={tokenPathSession}
|
||||
@@ -268,6 +271,7 @@ export function PresentationView({
|
||||
session={sceneNpcTokensSession}
|
||||
viewport={contentRect}
|
||||
grid={scene.grid}
|
||||
concealFromPlayers
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||
|
||||
@@ -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: '';
|
||||
|
||||
@@ -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<string, { nx: number; ny: number }> | null;
|
||||
dragEnabledById?: Record<string, boolean> | 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 (
|
||||
<NpcSprite
|
||||
@@ -321,6 +331,8 @@ export function SceneNpcTokensOverlay({
|
||||
gridFit={gridFit}
|
||||
disposition={disposition}
|
||||
inactive={inactive}
|
||||
hiddenFromPlayers={hiddenFromPlayers}
|
||||
concealed={concealFromPlayers && hiddenFromPlayers}
|
||||
initiativeActive={initiativeHighlightForNpc(initiative, key)}
|
||||
initiativeConditions={initiativeConditionsForNpc(initiative, key)}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
|
||||
@@ -34,7 +34,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
p.ny === undefined &&
|
||||
p.sizeN === undefined &&
|
||||
!p.disposition &&
|
||||
!p.inactive
|
||||
!p.inactive &&
|
||||
!p.hiddenFromPlayers
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcToken
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,6 +119,7 @@ function applyEvent(
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
disposition,
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
@@ -135,6 +138,7 @@ function applyEvent(
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
|
||||
@@ -155,6 +159,36 @@ function applyEvent(
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setHiddenFromPlayers') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const hidden = Boolean(event.hidden);
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
if (Boolean(cur?.hiddenFromPlayers) === hidden) return prev;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...restOf(cur),
|
||||
...(hidden ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (!hidden) delete next.hiddenFromPlayers;
|
||||
if (isEmptyPlacement(next)) {
|
||||
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setSize') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
@@ -187,6 +221,7 @@ function applyEvent(
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
|
||||
Reference in New Issue
Block a user