feat(players): app Players library and circular NPC tokens on scenes

Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 13:38:35 +08:00
parent a3a03eb9e3
commit 101f595bac
57 changed files with 4176 additions and 317 deletions
@@ -302,9 +302,29 @@
.previewActions {
display: flex;
align-items: center;
gap: 10px;
}
.npcTokenScale {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.npcTokenScaleLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
}
.npcTokenScaleRange {
width: min(140px, 22vw);
accent-color: var(--color-accent, #c9a227);
}
.videoHint {
color: var(--text2);
font-size: var(--text-xs);
+57 -37
View File
@@ -10,19 +10,19 @@ import {
} from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
} from '../../shared/types/sceneView';
DEFAULT_NPC_TOKEN_SESSION_SCALE,
NPC_TOKEN_SESSION_SCALE_MAX,
NPC_TOKEN_SESSION_SCALE_MIN,
} from '../../shared/types/appPlayers';
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { SceneNpcTokensOverlay } from '../shared/playerToken/SceneNpcTokensOverlay';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
import {
PixiEffectsOverlay,
type PixiEffectsOverlayHandle,
} from '../shared/effects/PxiEffectsOverlay';
import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
@@ -69,12 +69,7 @@ function readAudioGain(gains: Map<string, number>, assetId: string): number {
}
/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
function applyAudioGain(
el: HTMLAudioElement,
gains: Map<string, number>,
assetId: string,
factor = 1,
): void {
function applyAudioGain(el: HTMLAudioElement, gains: Map<string, number>, assetId: string, factor = 1): void {
el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
}
@@ -131,6 +126,7 @@ export function ControlApp() {
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -683,10 +679,9 @@ export function ControlApp() {
return () => ro.disconnect();
}, []);
const activeSceneViewCamera: SceneViewCamera = sceneViewDraft ??
(sceneView
? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy }
: DEFAULT_SCENE_VIEW_CAMERA);
const activeSceneViewCamera: SceneViewCamera =
sceneViewDraft ??
(sceneView ? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy } : DEFAULT_SCENE_VIEW_CAMERA);
sceneViewRef.current = activeSceneViewCamera;
useEffect(() => {
@@ -1381,9 +1376,11 @@ export function ControlApp() {
title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
onClick={() => {
void api.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription }).catch((err) => {
console.error('[control] openSceneDescription failed', err);
});
void api
.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription })
.catch((err) => {
console.error('[control] openSceneDescription failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
@@ -1689,6 +1686,24 @@ export function ControlApp() {
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}>
<label className={styles.npcTokenScale}>
<span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
<input
type="range"
className={styles.npcTokenScaleRange}
min={NPC_TOKEN_SESSION_SCALE_MIN}
max={NPC_TOKEN_SESSION_SCALE_MAX}
step={0.05}
value={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
aria-label={t('control.npcTokenScale')}
onChange={(e) => {
sceneNpcTokensApi.dispatch({
kind: 'setScale',
scale: Number(e.currentTarget.value),
});
}}
/>
</label>
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
{t('control.stopPresentation')}
</Button>
@@ -1701,9 +1716,7 @@ export function ControlApp() {
ref={previewFrameRef}
className={styles.previewFrame}
title={
isVideoPreviewScene
? undefined
: 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
isVideoPreviewScene ? undefined : 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
}
>
<div ref={previewHostRef} className={styles.previewHost}>
@@ -1732,11 +1745,7 @@ export function ControlApp() {
: undefined
}
/>
<ExplosionVideoOverlay
state={fxState}
draft={explosionDraft}
viewport={previewContentRect}
/>
<ExplosionVideoOverlay state={fxState} draft={explosionDraft} viewport={previewContentRect} />
{previewContentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
) : null}
@@ -1821,9 +1830,7 @@ export function ControlApp() {
const dy = e.clientY - pan.lastY;
pan.lastX = e.clientX;
pan.lastY = e.clientY;
publishSceneViewCamera(
sceneViewPanBy(cam, { containW, containH, dx, dy }),
);
publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy }));
return;
}
const p = toNPoint(e);
@@ -1901,6 +1908,19 @@ export function ControlApp() {
}}
/>
) : null}
{previewContentRect ? (
<SceneNpcTokensOverlay
placements={currentScene?.npcTokens ?? []}
library={session?.project?.npcs ?? []}
session={sceneNpcTokensSession}
viewport={previewContentRect}
grid={currentScene?.grid}
editable
onMove={(placementId, nx, ny) => {
sceneNpcTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
}}
/>
) : null}
{previewContentRect ? (
<SceneTrapsOverlay
traps={currentScene?.traps ?? []}
@@ -2169,8 +2189,7 @@ export function ControlApp() {
}
void el.play().catch(() => {
const mm =
sceneAudioMetaRef.current.get(ref.assetId) ??
({ lastPlayError: null } as const);
sceneAudioMetaRef.current.get(ref.assetId) ?? ({ lastPlayError: null } as const);
sceneAudioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: t('control.playFailed'),
@@ -2216,7 +2235,9 @@ export function ControlApp() {
{...(!allowCampaignAudio
? {
extraBadge: (
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div>
<div title={t('control.pauseSceneMusicTitle')}>
{t('control.pauseSceneMusic')}
</div>
),
}
: {})}
@@ -2250,8 +2271,7 @@ export function ControlApp() {
}
void el.play().catch(() => {
const mm =
campaignAudioMetaRef.current.get(ref.assetId) ??
({ lastPlayError: null } as const);
campaignAudioMetaRef.current.get(ref.assetId) ?? ({ lastPlayError: null } as const);
campaignAudioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: t('control.playFailed'),