From f6c301a12bac69f23cad2ad0b325ad6806d9de32 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Fri, 2 Oct 2026 10:15:32 +0800 Subject: [PATCH] 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 --- .../sceneNpcTokensSessionStore.test.ts | 16 ++++++ .../players/sceneNpcTokensSessionStore.ts | 31 ++++++++++- app/renderer/control/ControlApp.tsx | 52 +++++++++++++------ app/renderer/editor/i18n/editorMessages.ts | 8 ++- app/renderer/sceneEditor/SceneEditorApp.tsx | 6 +-- app/renderer/shared/PresentationView.tsx | 6 ++- .../SceneNpcTokensOverlay.module.css | 17 ++++++ .../playerToken/SceneNpcTokensOverlay.tsx | 12 +++++ .../playerToken/useSceneNpcTokensSession.ts | 37 ++++++++++++- app/shared/types/appPlayers.ts | 3 ++ 10 files changed, 165 insertions(+), 23 deletions(-) diff --git a/app/main/players/sceneNpcTokensSessionStore.test.ts b/app/main/players/sceneNpcTokensSessionStore.test.ts index 84122d4..101e4ae 100644 --- a/app/main/players/sceneNpcTokensSessionStore.test.ts +++ b/app/main/players/sceneNpcTokensSessionStore.test.ts @@ -82,6 +82,22 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => { assert.equal(s3.byPlacementId.a?.inactive, undefined); }); +void test('SceneNpcTokensSessionStore hide from players keeps the token for the GM', () => { + const store = new SceneNpcTokensSessionStore(); + const s1 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: true }); + assert.equal(s1.byPlacementId.a?.hiddenFromPlayers, true); + const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.4 }); + assert.equal(s2.byPlacementId.a?.hiddenFromPlayers, true); + assert.equal(s2.byPlacementId.a?.nx, 0.2); + const s3 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: false }); + assert.equal(s3.byPlacementId.a?.hiddenFromPlayers, undefined); + assert.equal(s3.byPlacementId.a?.nx, 0.2); + const only = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: true }); + store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: false }); + assert.equal(store.getState().byPlacementId.b, undefined); + assert.ok(only.byPlacementId.b?.hiddenFromPlayers); +}); + void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => { const store = new SceneNpcTokensSessionStore(); const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 }); diff --git a/app/main/players/sceneNpcTokensSessionStore.ts b/app/main/players/sceneNpcTokensSessionStore.ts index 7698868..8f44b8e 100644 --- a/app/main/players/sceneNpcTokensSessionStore.ts +++ b/app/main/players/sceneNpcTokensSessionStore.ts @@ -21,7 +21,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean { p.ny === undefined && p.sizeN === undefined && !p.disposition && - !p.inactive + !p.inactive && + !p.hiddenFromPlayers ); } @@ -32,6 +33,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 } : {}), }; } @@ -238,6 +240,7 @@ export class SceneNpcTokensSessionStore { ...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}), ...(prev?.disposition ? { disposition: prev.disposition } : {}), ...(inactive ? { inactive: true } : {}), + ...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}), }; if (isEmptyPlacement(next)) { this.state = withoutPlacement(this.state, placementId); @@ -254,6 +257,32 @@ export class SceneNpcTokensSessionStore { }; return this.state; } + case 'setHiddenFromPlayers': { + const placementId = String(event.placementId ?? ''); + if (!placementId) return this.state; + const hidden = Boolean(event.hidden); + const prev = this.state.byPlacementId[placementId]; + if (Boolean(prev?.hiddenFromPlayers) === hidden) return this.state; + const next: SceneNpcTokensSessionPlacement = { + ...restOf(prev), + ...(hidden ? { hiddenFromPlayers: true } : {}), + }; + if (!hidden) delete next.hiddenFromPlayers; + if (isEmptyPlacement(next)) { + this.state = withoutPlacement(this.state, placementId); + return this.state; + } + this.state = { + revision: this.state.revision + 1, + byPlacementId: { + ...this.state.byPlacementId, + [placementId]: next, + }, + scale: this.state.scale, + spawned: this.state.spawned, + }; + return this.state; + } case 'setSize': { const placementId = String(event.placementId ?? ''); if (!placementId) return this.state; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 3c691f5..7c951c3 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -3499,6 +3499,24 @@ export function ControlApp() { override?.disposition, ); const inactive = Boolean(override?.inactive); + const hiddenFromPlayers = Boolean(override?.hiddenFromPlayers); + const hideFromPlayersItem = ( + + ); const path = placement.path; const pathTarget = { kind: 'npcToken' as const, @@ -3509,21 +3527,24 @@ export function ControlApp() { const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]); if (inactive) { return ( - + <> + {hideFromPlayersItem} + + ); } const isSessionSpawn = Boolean( @@ -3531,6 +3552,7 @@ export function ControlApp() { ); return ( <> + {hideFromPlayersItem} {isSessionSpawn ? (