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:
@@ -82,6 +82,22 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
|||||||
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
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', () => {
|
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
|
||||||
const store = new SceneNpcTokensSessionStore();
|
const store = new SceneNpcTokensSessionStore();
|
||||||
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
|
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
|||||||
p.ny === undefined &&
|
p.ny === undefined &&
|
||||||
p.sizeN === undefined &&
|
p.sizeN === undefined &&
|
||||||
!p.disposition &&
|
!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?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||||
...(prev?.inactive ? { inactive: true } : {}),
|
...(prev?.inactive ? { inactive: true } : {}),
|
||||||
|
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +240,7 @@ export class SceneNpcTokensSessionStore {
|
|||||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||||
...(inactive ? { inactive: true } : {}),
|
...(inactive ? { inactive: true } : {}),
|
||||||
|
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||||
};
|
};
|
||||||
if (isEmptyPlacement(next)) {
|
if (isEmptyPlacement(next)) {
|
||||||
this.state = withoutPlacement(this.state, placementId);
|
this.state = withoutPlacement(this.state, placementId);
|
||||||
@@ -254,6 +257,32 @@ export class SceneNpcTokensSessionStore {
|
|||||||
};
|
};
|
||||||
return this.state;
|
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': {
|
case 'setSize': {
|
||||||
const placementId = String(event.placementId ?? '');
|
const placementId = String(event.placementId ?? '');
|
||||||
if (!placementId) return this.state;
|
if (!placementId) return this.state;
|
||||||
|
|||||||
@@ -3499,6 +3499,24 @@ export function ControlApp() {
|
|||||||
override?.disposition,
|
override?.disposition,
|
||||||
);
|
);
|
||||||
const inactive = Boolean(override?.inactive);
|
const inactive = Boolean(override?.inactive);
|
||||||
|
const hiddenFromPlayers = Boolean(override?.hiddenFromPlayers);
|
||||||
|
const hideFromPlayersItem = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.ctxItem}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
sceneNpcTokensApi.dispatch({
|
||||||
|
kind: 'setHiddenFromPlayers',
|
||||||
|
placementId: npcSessionCtxMenu.placementId,
|
||||||
|
hidden: !hiddenFromPlayers,
|
||||||
|
});
|
||||||
|
setNpcSessionCtxMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hiddenFromPlayers ? t('npcs.showToPlayers') : t('npcs.hideFromPlayers')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
const path = placement.path;
|
const path = placement.path;
|
||||||
const pathTarget = {
|
const pathTarget = {
|
||||||
kind: 'npcToken' as const,
|
kind: 'npcToken' as const,
|
||||||
@@ -3509,21 +3527,24 @@ export function ControlApp() {
|
|||||||
const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]);
|
const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]);
|
||||||
if (inactive) {
|
if (inactive) {
|
||||||
return (
|
return (
|
||||||
<button
|
<>
|
||||||
type="button"
|
{hideFromPlayersItem}
|
||||||
className={styles.ctxItem}
|
<button
|
||||||
role="menuitem"
|
type="button"
|
||||||
onClick={() => {
|
className={styles.ctxItem}
|
||||||
sceneNpcTokensApi.dispatch({
|
role="menuitem"
|
||||||
kind: 'setInactive',
|
onClick={() => {
|
||||||
placementId: npcSessionCtxMenu.placementId,
|
sceneNpcTokensApi.dispatch({
|
||||||
inactive: false,
|
kind: 'setInactive',
|
||||||
});
|
placementId: npcSessionCtxMenu.placementId,
|
||||||
setNpcSessionCtxMenu(null);
|
inactive: false,
|
||||||
}}
|
});
|
||||||
>
|
setNpcSessionCtxMenu(null);
|
||||||
{t('npcs.makeActive')}
|
}}
|
||||||
</button>
|
>
|
||||||
|
{t('npcs.makeActive')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const isSessionSpawn = Boolean(
|
const isSessionSpawn = Boolean(
|
||||||
@@ -3531,6 +3552,7 @@ export function ControlApp() {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{hideFromPlayersItem}
|
||||||
{isSessionSpawn ? (
|
{isSessionSpawn ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
|
|
||||||
'help.section.controlPanel.title': 'Пульт управления',
|
'help.section.controlPanel.title': 'Пульт управления',
|
||||||
'help.section.controlPanel.body':
|
'help.section.controlPanel.body':
|
||||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты, «История» и «Сцены»; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты»: маркер (рисунок на карте), книга (описание текущей сцены; если описания нет, кнопка неактивна и подсказка «Описание отсутствует»), материалы (см. «Материалы»), плейлисты, таймер (обратный отсчёт: «Показать игрокам» выводит виджет на предпросмотр и презентацию), инициатива (очередь хода), кубики, НПС (карточки, чекбокс «Показать» для аватара и постановка токена на карту — см. «НПС») и «?» («Справка»: открывает этот раздел, не закрывая показ).\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размер токенов игроков» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«История» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю. «Сцены» — сцены текущей линии, чтобы перейти к любой из них.\n\n«Завершить» у предпросмотра и в конце ветки (когда переходов нет) спрашивает подтверждение и после него закрывает показ. Escape на пульте и на презентации, а также закрытие этих окон, задают тот же вопрос.',
|
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты, «История» и «Сцены»; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты»: маркер (рисунок на карте), книга (описание текущей сцены; если описания нет, кнопка неактивна и подсказка «Описание отсутствует»), материалы (см. «Материалы»), плейлисты, таймер (обратный отсчёт: «Показать игрокам» выводит виджет на предпросмотр и презентацию), инициатива (очередь хода), кубики, НПС (карточки, чекбокс «Показать» для аватара и постановка токена на карту — см. «НПС») и «?» («Справка»: открывает этот раздел, не закрывая показ).\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — скрыть от игроков или показать игрокам (на пульте токен остаётся), активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размер токенов игроков» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«История» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю. «Сцены» — сцены текущей линии, чтобы перейти к любой из них.\n\n«Завершить» у предпросмотра и в конце ветки (когда переходов нет) спрашивает подтверждение и после него закрывает показ. Escape на пульте и на презентации, а также закрытие этих окон, задают тот же вопрос.',
|
||||||
|
|
||||||
'help.section.transitions.title': 'Переходы между сценами',
|
'help.section.transitions.title': 'Переходы между сценами',
|
||||||
'help.section.transitions.body':
|
'help.section.transitions.body':
|
||||||
@@ -594,6 +594,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.makeFriendly': 'Сделать дружественным',
|
'npcs.makeFriendly': 'Сделать дружественным',
|
||||||
'npcs.inactive': 'Неактивен',
|
'npcs.inactive': 'Неактивен',
|
||||||
'npcs.makeActive': 'Сделать активным',
|
'npcs.makeActive': 'Сделать активным',
|
||||||
|
'npcs.hideFromPlayers': 'Скрыть от игроков',
|
||||||
|
'npcs.showToPlayers': 'Показать игрокам',
|
||||||
'npcs.openInfo': 'Открыть информацию',
|
'npcs.openInfo': 'Открыть информацию',
|
||||||
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
||||||
'npcs.description': 'ОПИСАНИЕ',
|
'npcs.description': 'ОПИСАНИЕ',
|
||||||
@@ -1100,7 +1102,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
|
|
||||||
'help.section.controlPanel.title': 'Control panel',
|
'help.section.controlPanel.title': 'Control panel',
|
||||||
'help.section.controlPanel.body':
|
'help.section.controlPanel.body':
|
||||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects, History, and Scenes. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools: marker (draw on the map), book (current scene description; if there is none the button is disabled and the tooltip reads “No description”), materials (see Materials), playlists, timer (countdown: Show to players places the widget on the preview and presentation), initiative (turn order), dice, NPCs (cards, the Show checkbox for an avatar, and placing a token on the map — see NPCs), and “?” (Help: opens this section without ending the show).\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Player token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nHistory lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry. Scenes lists scenes on the current line so you can jump to any of them.\n\nEnd by the preview and at the end of a branch (when there is nowhere left to go) asks for confirmation and then closes the show. Escape on the control panel and on presentation, and closing those windows, ask the same question.',
|
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects, History, and Scenes. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools: marker (draw on the map), book (current scene description; if there is none the button is disabled and the tooltip reads “No description”), materials (see Materials), playlists, timer (countdown: Show to players places the widget on the preview and presentation), initiative (turn order), dice, NPCs (cards, the Show checkbox for an avatar, and placing a token on the map — see NPCs), and “?” (Help: opens this section without ending the show).\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to hide it from players or show it to players (it stays on the control panel), activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Player token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nHistory lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry. Scenes lists scenes on the current line so you can jump to any of them.\n\nEnd by the preview and at the end of a branch (when there is nowhere left to go) asks for confirmation and then closes the show. Escape on the control panel and on presentation, and closing those windows, ask the same question.',
|
||||||
|
|
||||||
'help.section.transitions.title': 'Scene transitions',
|
'help.section.transitions.title': 'Scene transitions',
|
||||||
'help.section.transitions.body':
|
'help.section.transitions.body':
|
||||||
@@ -1406,6 +1408,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.makeFriendly': 'Make Friendly',
|
'npcs.makeFriendly': 'Make Friendly',
|
||||||
'npcs.inactive': 'Inactive',
|
'npcs.inactive': 'Inactive',
|
||||||
'npcs.makeActive': 'Make Active',
|
'npcs.makeActive': 'Make Active',
|
||||||
|
'npcs.hideFromPlayers': 'Hide from players',
|
||||||
|
'npcs.showToPlayers': 'Show to players',
|
||||||
'npcs.openInfo': 'Open information',
|
'npcs.openInfo': 'Open information',
|
||||||
'npcs.ringColor': 'TOKEN RING COLOR',
|
'npcs.ringColor': 'TOKEN RING COLOR',
|
||||||
'npcs.description': 'DESCRIPTION',
|
'npcs.description': 'DESCRIPTION',
|
||||||
|
|||||||
@@ -420,8 +420,8 @@ export function SceneEditorApp() {
|
|||||||
|
|
||||||
const defaultTemplateName = useMemo(() => {
|
const defaultTemplateName = useMemo(() => {
|
||||||
const sizeLabel = Math.round(localGrid.sizeN * 100);
|
const sizeLabel = Math.round(localGrid.sizeN * 100);
|
||||||
return `${sceneGridTypeLabelRu(localGrid.type)} · ${String(sizeLabel)}`;
|
return `${t(localGrid.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · ${String(sizeLabel)}`;
|
||||||
}, [localGrid.sizeN, localGrid.type]);
|
}, [localGrid.sizeN, localGrid.type, t]);
|
||||||
|
|
||||||
const saveGridTemplate = async () => {
|
const saveGridTemplate = async () => {
|
||||||
if (!sceneId) return;
|
if (!sceneId) return;
|
||||||
@@ -782,7 +782,7 @@ export function SceneEditorApp() {
|
|||||||
<div className={styles.templateName}>{tpl.name}</div>
|
<div className={styles.templateName}>{tpl.name}</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.templateHint}>
|
<div className={styles.templateHint}>
|
||||||
{sceneGridTypeLabelRu(tpl.type)} · {Math.round(tpl.sizeN * 100)}
|
{t(tpl.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · {Math.round(tpl.sizeN * 100)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.templateActions}>
|
<div className={styles.templateActions}>
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ export function PresentationView({
|
|||||||
sceneNpcTokensSession.spawned,
|
sceneNpcTokensSession.spawned,
|
||||||
session?.currentSceneId,
|
session?.currentSceneId,
|
||||||
);
|
);
|
||||||
|
const presentationNpcTokens = visibleNpcTokens.filter(
|
||||||
|
(token) => !sceneNpcTokensSession.byPlacementId[String(token.id)]?.hiddenFromPlayers,
|
||||||
|
);
|
||||||
useTokenPathLivePoses({
|
useTokenPathLivePoses({
|
||||||
tokens: scene?.tokens ?? [],
|
tokens: scene?.tokens ?? [],
|
||||||
npcTokens: visibleNpcTokens,
|
npcTokens: visibleNpcTokens,
|
||||||
@@ -245,7 +248,7 @@ export function PresentationView({
|
|||||||
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||||
<TokenPathsOverlay
|
<TokenPathsOverlay
|
||||||
tokens={scene.tokens ?? []}
|
tokens={scene.tokens ?? []}
|
||||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []}
|
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? presentationNpcTokens : []}
|
||||||
viewport={contentRect}
|
viewport={contentRect}
|
||||||
mode="presentation"
|
mode="presentation"
|
||||||
pathSession={tokenPathSession}
|
pathSession={tokenPathSession}
|
||||||
@@ -268,6 +271,7 @@ export function PresentationView({
|
|||||||
session={sceneNpcTokensSession}
|
session={sceneNpcTokensSession}
|
||||||
viewport={contentRect}
|
viewport={contentRect}
|
||||||
grid={scene.grid}
|
grid={scene.grid}
|
||||||
|
concealFromPlayers
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||||
|
|||||||
@@ -35,6 +35,23 @@
|
|||||||
z-index: 10;
|
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). */
|
/* Кольцо строго по центру круга токена (обёртка = sizePx × sizePx). */
|
||||||
.initiativeActive::before {
|
.initiativeActive::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ function NpcSprite({
|
|||||||
gridFit,
|
gridFit,
|
||||||
disposition,
|
disposition,
|
||||||
inactive,
|
inactive,
|
||||||
|
hiddenFromPlayers,
|
||||||
|
concealed,
|
||||||
onMove,
|
onMove,
|
||||||
onResize,
|
onResize,
|
||||||
resizeTitle,
|
resizeTitle,
|
||||||
@@ -71,6 +73,8 @@ function NpcSprite({
|
|||||||
gridFit: number;
|
gridFit: number;
|
||||||
disposition: NpcDisposition;
|
disposition: NpcDisposition;
|
||||||
inactive: boolean;
|
inactive: boolean;
|
||||||
|
hiddenFromPlayers: boolean;
|
||||||
|
concealed: boolean;
|
||||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||||
onResize?: (placementId: string, sizeN: number) => void;
|
onResize?: (placementId: string, sizeN: number) => void;
|
||||||
resizeTitle?: string;
|
resizeTitle?: string;
|
||||||
@@ -153,6 +157,8 @@ function NpcSprite({
|
|||||||
styles.token,
|
styles.token,
|
||||||
canDrag ? styles.editable : '',
|
canDrag ? styles.editable : '',
|
||||||
initiativeActive ? styles.initiativeActive : '',
|
initiativeActive ? styles.initiativeActive : '',
|
||||||
|
hiddenFromPlayers && !concealed ? styles.hiddenFromPlayers : '',
|
||||||
|
concealed ? styles.concealedFromPlayers : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
@@ -274,6 +280,7 @@ export function SceneNpcTokensOverlay({
|
|||||||
snapNorm,
|
snapNorm,
|
||||||
poseOverrides = null,
|
poseOverrides = null,
|
||||||
dragEnabledById = null,
|
dragEnabledById = null,
|
||||||
|
concealFromPlayers = false,
|
||||||
}: {
|
}: {
|
||||||
placements: readonly SceneNpcToken[];
|
placements: readonly SceneNpcToken[];
|
||||||
library: readonly ProjectNpc[];
|
library: readonly ProjectNpc[];
|
||||||
@@ -289,6 +296,8 @@ export function SceneNpcTokensOverlay({
|
|||||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
||||||
dragEnabledById?: Record<string, boolean> | null;
|
dragEnabledById?: Record<string, boolean> | null;
|
||||||
|
/** На презентации скрытый от игроков токен остаётся в DOM, чтобы путь продолжал идти. */
|
||||||
|
concealFromPlayers?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [initiative] = useInitiativeState();
|
const [initiative] = useInitiativeState();
|
||||||
if (!viewport) return null;
|
if (!viewport) return null;
|
||||||
@@ -305,6 +314,7 @@ export function SceneNpcTokensOverlay({
|
|||||||
const override = session?.byPlacementId[key];
|
const override = session?.byPlacementId[key];
|
||||||
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
|
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
|
||||||
const inactive = Boolean(override?.inactive);
|
const inactive = Boolean(override?.inactive);
|
||||||
|
const hiddenFromPlayers = Boolean(override?.hiddenFromPlayers);
|
||||||
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||||
return (
|
return (
|
||||||
<NpcSprite
|
<NpcSprite
|
||||||
@@ -321,6 +331,8 @@ export function SceneNpcTokensOverlay({
|
|||||||
gridFit={gridFit}
|
gridFit={gridFit}
|
||||||
disposition={disposition}
|
disposition={disposition}
|
||||||
inactive={inactive}
|
inactive={inactive}
|
||||||
|
hiddenFromPlayers={hiddenFromPlayers}
|
||||||
|
concealed={concealFromPlayers && hiddenFromPlayers}
|
||||||
initiativeActive={initiativeHighlightForNpc(initiative, key)}
|
initiativeActive={initiativeHighlightForNpc(initiative, key)}
|
||||||
initiativeConditions={initiativeConditionsForNpc(initiative, key)}
|
initiativeConditions={initiativeConditionsForNpc(initiative, key)}
|
||||||
{...(onMove ? { onMove } : {})}
|
{...(onMove ? { onMove } : {})}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
|||||||
p.ny === undefined &&
|
p.ny === undefined &&
|
||||||
p.sizeN === undefined &&
|
p.sizeN === undefined &&
|
||||||
!p.disposition &&
|
!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?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||||
...(prev?.inactive ? { inactive: true } : {}),
|
...(prev?.inactive ? { inactive: true } : {}),
|
||||||
|
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@ function applyEvent(
|
|||||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||||
disposition,
|
disposition,
|
||||||
...(cur?.inactive ? { inactive: true } : {}),
|
...(cur?.inactive ? { inactive: true } : {}),
|
||||||
|
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||||
@@ -135,6 +138,7 @@ function applyEvent(
|
|||||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||||
...(inactive ? { inactive: true } : {}),
|
...(inactive ? { inactive: true } : {}),
|
||||||
|
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||||
};
|
};
|
||||||
if (isEmptyPlacement(next)) {
|
if (isEmptyPlacement(next)) {
|
||||||
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
|
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
|
||||||
@@ -155,6 +159,36 @@ function applyEvent(
|
|||||||
spawned,
|
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') {
|
if (event.kind === 'setSize') {
|
||||||
const placementId = String(event.placementId ?? '');
|
const placementId = String(event.placementId ?? '');
|
||||||
if (!placementId) return prev;
|
if (!placementId) return prev;
|
||||||
@@ -187,6 +221,7 @@ function applyEvent(
|
|||||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||||
...(cur?.inactive ? { inactive: true } : {}),
|
...(cur?.inactive ? { inactive: true } : {}),
|
||||||
|
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export type SceneNpcTokensSessionPlacement = {
|
|||||||
disposition?: NpcDisposition;
|
disposition?: NpcDisposition;
|
||||||
/** Session-only: серая рамка и grayscale. */
|
/** Session-only: серая рамка и grayscale. */
|
||||||
inactive?: boolean;
|
inactive?: boolean;
|
||||||
|
/** Session-only: токен виден мастеру, но не на презентации. */
|
||||||
|
hiddenFromPlayers?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Подкрепление: токен только на сессию, не пишется в scene.npcTokens. */
|
/** Подкрепление: токен только на сессию, не пишется в scene.npcTokens. */
|
||||||
@@ -110,6 +112,7 @@ export type SceneNpcTokensSessionEvent =
|
|||||||
| { kind: 'setSize'; placementId: string; sizeN: number }
|
| { kind: 'setSize'; placementId: string; sizeN: number }
|
||||||
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
|
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
|
||||||
| { kind: 'setInactive'; placementId: string; inactive: boolean }
|
| { kind: 'setInactive'; placementId: string; inactive: boolean }
|
||||||
|
| { kind: 'setHiddenFromPlayers'; placementId: string; hidden: boolean }
|
||||||
| { kind: 'setScale'; scale: number }
|
| { kind: 'setScale'; scale: number }
|
||||||
| {
|
| {
|
||||||
kind: 'spawn';
|
kind: 'spawn';
|
||||||
|
|||||||
Reference in New Issue
Block a user