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:
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user