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:
Ivan Fontosh
2026-10-02 10:15:32 +08:00
parent 060bbd471a
commit f6c301a12b
10 changed files with 165 additions and 23 deletions
@@ -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,