feat(npcs): show multiple character overlays in session playback

Toggle NPCs independently from the characters window, keep one shared dim/close-all, and scroll descriptions for all selected characters.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-21 13:23:19 +08:00
parent f4c0ac1438
commit 1829191410
8 changed files with 538 additions and 121 deletions
+90 -22
View File
@@ -12,12 +12,17 @@ import {
function emptyState(): NpcsOverlayState { function emptyState(): NpcsOverlayState {
return { return {
revision: 1, revision: 1,
activeNpcId: null, activeNpcIds: [],
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, layouts: {},
focusNpcId: null,
zoomTool: null, zoomTool: null,
}; };
} }
function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
}
export class NpcsOverlayStore { export class NpcsOverlayStore {
private state: NpcsOverlayState = emptyState(); private state: NpcsOverlayState = emptyState();
@@ -26,13 +31,14 @@ export class NpcsOverlayStore {
} }
clear(): NpcsOverlayState { clear(): NpcsOverlayState {
if (this.state.activeNpcId === null && this.state.zoomTool === null) { if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
return this.state; return this.state;
} }
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeNpcId: null, activeNpcIds: [],
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, layouts: {},
focusNpcId: null,
zoomTool: null, zoomTool: null,
}; };
return this.state; return this.state;
@@ -42,32 +48,67 @@ export class NpcsOverlayStore {
switch (event.kind) { switch (event.kind) {
case 'hide': case 'hide':
return this.clear(); return this.clear();
case 'show': case 'show': {
if (this.state.activeNpcIds.includes(event.npcId)) {
this.state = { this.state = {
...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeNpcId: event.npcId, focusNpcId: event.npcId,
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
zoomTool: this.state.zoomTool,
}; };
return this.state; return this.state;
case 'toggle': {
if (this.state.activeNpcId === event.npcId) {
return this.clear();
} }
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeNpcId: event.npcId, activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, layouts: {
...this.state.layouts,
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'toggle': {
if (this.state.activeNpcIds.includes(event.npcId)) {
const activeNpcIds = this.state.activeNpcIds.filter((id) => id !== event.npcId);
const layouts = { ...this.state.layouts };
delete layouts[event.npcId];
const focusNpcId =
this.state.focusNpcId === event.npcId
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
: this.state.focusNpcId;
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
...this.state.layouts,
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool, zoomTool: this.state.zoomTool,
}; };
return this.state; return this.state;
} }
case 'layout.set': { case 'layout.set': {
if (this.state.activeNpcId === null) return this.state; if (!this.state.activeNpcIds.includes(event.npcId)) return this.state;
this.state = { this.state = {
...this.state, ...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
layout: clampNpcsLayout(event.layout), focusNpcId: event.npcId,
layouts: {
...this.state.layouts,
[event.npcId]: clampNpcsLayout(event.layout),
},
}; };
return this.state; return this.state;
} }
@@ -81,10 +122,16 @@ export class NpcsOverlayStore {
return this.state; return this.state;
} }
case 'zoomAt': { case 'zoomAt': {
if (this.state.activeNpcId === null || !this.state.zoomTool) return this.state; if (this.state.activeNpcIds.length === 0 || !this.state.zoomTool) return this.state;
const targetId =
(event.npcId && this.state.activeNpcIds.includes(event.npcId) ? event.npcId : null) ??
this.state.focusNpcId ??
this.state.activeNpcIds[this.state.activeNpcIds.length - 1] ??
null;
if (!targetId) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25; const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: NpcsOverlayLayout = zoomNpcsLayoutAt( const layout: NpcsOverlayLayout = zoomNpcsLayoutAt(
this.state.layout, layoutFor(this.state, targetId),
event.nx, event.nx,
event.ny, event.ny,
factor, factor,
@@ -92,7 +139,11 @@ export class NpcsOverlayStore {
this.state = { this.state = {
...this.state, ...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
layout, focusNpcId: targetId,
layouts: {
...this.state.layouts,
[targetId]: layout,
},
}; };
return this.state; return this.state;
} }
@@ -102,8 +153,25 @@ export class NpcsOverlayStore {
} }
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState { ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
const active = this.state.activeNpcId; const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
if (active === null || npcIds.has(active)) return this.state; if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state;
return this.clear(); if (activeNpcIds.length === 0) return this.clear();
const layouts: Record<string, NpcsOverlayLayout> = {};
for (const id of activeNpcIds) {
const layout = this.state.layouts[id];
if (layout) layouts[id] = layout;
}
const focusNpcId =
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
? this.state.focusNpcId
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: this.state.zoomTool,
};
return this.state;
} }
} }
+24 -12
View File
@@ -20,6 +20,7 @@ import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay'; import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState'; import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { Button } from '../shared/ui/controls'; import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface'; import { Surface } from '../shared/ui/Surface';
@@ -1611,15 +1612,24 @@ export function ControlApp() {
); );
})()} })()}
{(() => { {(() => {
const activeNpc = const project = session?.project;
session?.project && npcsOverlay?.activeNpcId const activeIds = npcsOverlay?.activeNpcIds ?? [];
? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId) if (!project || activeIds.length === 0) return null;
: undefined; const items = activeIds
if (!activeNpc) return null; .map((id) => {
const npc = (project.npcs ?? []).find((n) => n.id === id);
if (!npc) return null;
return {
npcId: npc.id,
assetId: npc.avatarAssetId,
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
};
})
.filter((x): x is NonNullable<typeof x> => x !== null);
if (items.length === 0) return null;
return ( return (
<MaterialOverlay <NpcsSceneOverlay
assetId={activeNpc.avatarAssetId} items={items}
layout={npcsOverlay?.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT}
editable editable
zoomTool={npcsOverlay?.zoomTool ?? null} zoomTool={npcsOverlay?.zoomTool ?? null}
showClose showClose
@@ -1627,11 +1637,13 @@ export function ControlApp() {
onClose={() => { onClose={() => {
void npcsApi.dispatch({ kind: 'hide' }); void npcsApi.dispatch({ kind: 'hide' });
}} }}
onLayoutChange={(layout) => { onLayoutChange={(npcId, layout) => {
void npcsApi.dispatch({ kind: 'layout.set', layout }); void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
}} }}
onZoomAt={(nx, ny) => { onZoomAt={(npcId, nx, ny) => {
void npcsApi.dispatch({ kind: 'zoomAt', nx, ny }); void npcsApi.dispatch(
npcId ? { kind: 'zoomAt', nx, ny, npcId } : { kind: 'zoomAt', nx, ny },
);
}} }}
/> />
); );
+4 -2
View File
@@ -425,7 +425,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.graphZoomOut': 'Уменьшить', 'npcs.graphZoomOut': 'Уменьшить',
'npcs.graphFitAll': 'Показать всё', 'npcs.graphFitAll': 'Показать всё',
'npcs.windowEmpty': 'Добавьте НПС в редакторе.', 'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
'npcs.closeOverlay': 'Закрыть НПС', 'npcs.selectToShow': 'Выберите персонажей в списке — они появятся на экране.',
'npcs.closeOverlay': 'Закрыть всех',
'npcs.zoomIn': 'Увеличить', 'npcs.zoomIn': 'Увеличить',
'npcs.zoomOut': 'Уменьшить', 'npcs.zoomOut': 'Уменьшить',
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.', 'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
@@ -960,7 +961,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.graphZoomOut': 'Zoom out', 'npcs.graphZoomOut': 'Zoom out',
'npcs.graphFitAll': 'Fit view', 'npcs.graphFitAll': 'Fit view',
'npcs.windowEmpty': 'Add NPCs in the editor.', 'npcs.windowEmpty': 'Add NPCs in the editor.',
'npcs.closeOverlay': 'Close NPC', 'npcs.selectToShow': 'Select characters in the list — they will appear on screen.',
'npcs.closeOverlay': 'Close all',
'npcs.zoomIn': 'Zoom in', 'npcs.zoomIn': 'Zoom in',
'npcs.zoomOut': 'Zoom out', 'npcs.zoomOut': 'Zoom out',
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.', 'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
+12
View File
@@ -50,6 +50,18 @@
line-height: 1.45; line-height: 1.45;
} }
.detailCard {
display: grid;
gap: 10px;
padding-bottom: 14px;
border-bottom: 1px solid var(--stroke);
}
.detailCard:last-child {
border-bottom: none;
padding-bottom: 0;
}
.detailName { .detailName {
font-size: 18px; font-size: 18px;
font-weight: 900; font-weight: 900;
+45 -46
View File
@@ -52,13 +52,11 @@ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
function RuntimeNpcTile({ function RuntimeNpcTile({
npc, npc,
selected, selected,
active,
accentColor, accentColor,
onActivate, onActivate,
}: { }: {
npc: ProjectNpc; npc: ProjectNpc;
selected: boolean; selected: boolean;
active: boolean;
accentColor?: string | null; accentColor?: string | null;
onActivate: () => void; onActivate: () => void;
}) { }) {
@@ -66,9 +64,7 @@ function RuntimeNpcTile({
return ( return (
<button <button
type="button" type="button"
className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : ''] className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')}
.filter(Boolean)
.join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined} style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
onClick={onActivate} onClick={onActivate}
> >
@@ -85,16 +81,14 @@ function RuntimeGroupSection({
depth, depth,
isExpanded, isExpanded,
onToggleExpanded, onToggleExpanded,
selectedId, selectedIds,
activeId,
onActivate, onActivate,
}: { }: {
node: NpcGroupTreeNode; node: NpcGroupTreeNode;
depth: number; depth: number;
isExpanded: (id: NpcGroupId) => boolean; isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void; onToggleExpanded: (id: NpcGroupId) => void;
selectedId: NpcId | null; selectedIds: ReadonlySet<NpcId>;
activeId: NpcId | null;
onActivate: (id: NpcId) => void; onActivate: (id: NpcId) => void;
}) { }) {
const g = node.group; const g = node.group;
@@ -120,8 +114,7 @@ function RuntimeGroupSection({
<RuntimeNpcTile <RuntimeNpcTile
key={n.id} key={n.id}
npc={n} npc={n}
selected={n.id === selectedId} selected={selectedIds.has(n.id)}
active={n.id === activeId}
accentColor={g.color} accentColor={g.color}
onActivate={() => onActivate(n.id)} onActivate={() => onActivate(n.id)}
/> />
@@ -133,8 +126,7 @@ function RuntimeGroupSection({
depth={depth + 1} depth={depth + 1}
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded} onToggleExpanded={onToggleExpanded}
selectedId={selectedId} selectedIds={selectedIds}
activeId={activeId}
onActivate={onActivate} onActivate={onActivate}
/> />
))} ))}
@@ -149,29 +141,30 @@ export function NpcsApp() {
const api = getDndApi(); const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const [overlay, overlayApi] = useNpcsOverlayState(); const [overlay, overlayApi] = useNpcsOverlayState();
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set()); const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
useEffect(() => { useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => { void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
setSession({ project, currentSceneId: project?.currentSceneId ?? null }); setSession({ project, currentSceneId: project?.currentSceneId ?? null });
const list = project?.npcs ?? [];
setSelectedId(list[0]?.id ?? null);
}); });
return api.on(ipcChannels.session.stateChanged, ({ state }) => { return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state); setSession(state);
}); });
}, [api]); }, [api]);
const activeIds = overlay?.activeNpcIds ?? [];
const hasActive = activeIds.length > 0;
const zoomTool = overlay?.zoomTool ?? null;
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (overlay?.activeNpcId) { if (hasActive) {
void overlayApi.dispatch({ kind: 'hide' }); void overlayApi.dispatch({ kind: 'hide' });
return; return;
} }
if (overlay?.zoomTool) { if (zoomTool) {
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null }); void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
return; return;
} }
@@ -180,16 +173,16 @@ export function NpcsApp() {
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]); }, [hasActive, zoomTool, overlayApi]);
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]); const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]); const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]); const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
const activeId = overlay?.activeNpcId ?? null; const selectedIds = useMemo(() => new Set(activeIds), [activeIds]);
const zoomTool = overlay?.zoomTool ?? null; const selectedNpcs = useMemo(
const effectiveSelectedId = () => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
selectedId && npcs.some((n) => n.id === selectedId) ? selectedId : (npcs[0]?.id ?? null); [activeIds, npcs],
const selected = npcs.find((n) => n.id === effectiveSelectedId) ?? null; );
const filteredNpcs = useMemo(() => { const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -229,26 +222,27 @@ export function NpcsApp() {
[searching], [searching],
); );
const relationsForSelected = useMemo(() => { const relationsByNpcId = useMemo(() => {
if (!selected) return []; const map = new Map<NpcId, { id: string; text: string }[]>();
return relations for (const npc of selectedNpcs) {
.filter((r) => r.sourceNpcId === selected.id) const list = relations
.filter((r) => r.sourceNpcId === npc.id)
.map((r) => { .map((r) => {
const other = npcs.find((n) => n.id === r.targetNpcId); const other = npcs.find((n) => n.id === r.targetNpcId);
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` }; return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
}); });
}, [npcs, relations, selected]); map.set(npc.id, list);
}
return map;
}, [npcs, relations, selectedNpcs]);
const onSelectTile = useCallback( const onSelectTile = useCallback(
(id: NpcId) => { (id: NpcId) => {
setSelectedId(id);
void overlayApi.dispatch({ kind: 'toggle', npcId: id }); void overlayApi.dispatch({ kind: 'toggle', npcId: id });
}, },
[overlayApi], [overlayApi],
); );
const safeHtml = selected ? sanitizeSceneDescriptionHtml(selected.description) : '';
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.toolbar}> <div className={styles.toolbar}>
@@ -283,7 +277,7 @@ export function NpcsApp() {
> >
<ZoomOutIcon /> <ZoomOutIcon />
</Button> </Button>
{activeId ? ( {hasActive ? (
<Button <Button
title={t('npcs.closeOverlay')} title={t('npcs.closeOverlay')}
ariaLabel={t('npcs.closeOverlay')} ariaLabel={t('npcs.closeOverlay')}
@@ -307,9 +301,13 @@ export function NpcsApp() {
<div className={styles.body}> <div className={styles.body}>
<div className={styles.detail}> <div className={styles.detail}>
{selected ? ( {selectedNpcs.length > 0 ? (
<> selectedNpcs.map((npc) => {
<div className={styles.detailName}>{selected.name}</div> const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
return (
<div key={npc.id} className={styles.detailCard}>
<div className={styles.detailName}>{npc.name}</div>
{safeHtml ? ( {safeHtml ? (
<div> <div>
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div> <div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
@@ -318,11 +316,11 @@ export function NpcsApp() {
) : ( ) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div> <div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
)} )}
{relationsForSelected.length > 0 ? ( {npcRelations.length > 0 ? (
<div> <div>
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div> <div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}> <div className={styles.relationsList}>
{relationsForSelected.map((r) => ( {npcRelations.map((r) => (
<div key={r.id} className={styles.relationItem}> <div key={r.id} className={styles.relationItem}>
{r.text} {r.text}
</div> </div>
@@ -330,9 +328,13 @@ export function NpcsApp() {
</div> </div>
</div> </div>
) : null} ) : null}
</> </div>
);
})
) : ( ) : (
<div className={styles.detailEmpty}>{t('npcs.windowEmpty')}</div> <div className={styles.detailEmpty}>
{npcs.length === 0 ? t('npcs.windowEmpty') : t('npcs.selectToShow')}
</div>
)} )}
</div> </div>
@@ -344,8 +346,7 @@ export function NpcsApp() {
<RuntimeNpcTile <RuntimeNpcTile
key={n.id} key={n.id}
npc={n} npc={n}
selected={n.id === effectiveSelectedId} selected={selectedIds.has(n.id)}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)} onActivate={() => onSelectTile(n.id)}
/> />
)) ))
@@ -358,8 +359,7 @@ export function NpcsApp() {
depth={0} depth={0}
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
selectedId={effectiveSelectedId} selectedIds={selectedIds}
activeId={activeId}
onActivate={onSelectTile} onActivate={onSelectTile}
/> />
))} ))}
@@ -371,8 +371,7 @@ export function NpcsApp() {
<RuntimeNpcTile <RuntimeNpcTile
key={n.id} key={n.id}
npc={n} npc={n}
selected={n.id === effectiveSelectedId} selected={selectedIds.has(n.id)}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)} onActivate={() => onSelectTile(n.id)}
/> />
))} ))}
+17 -10
View File
@@ -10,6 +10,7 @@ import { useSceneDarknessState } from './effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from './materials/MaterialOverlay'; import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState'; import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import styles from './PresentationView.module.css'; import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage'; import { RotatedImage } from './RotatedImage';
@@ -45,10 +46,21 @@ export function PresentationView({
session?.project && materialsOverlay?.activeMaterialId session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId) ? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined; : undefined;
const activeNpc = const project = session?.project;
session?.project && npcsOverlay?.activeNpcId const activeNpcItems =
? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId) project && (npcsOverlay?.activeNpcIds?.length ?? 0) > 0
: undefined; ? (npcsOverlay?.activeNpcIds ?? [])
.map((id) => {
const npc = (project.npcs ?? []).find((n) => n.id === id);
if (!npc) return null;
return {
npcId: npc.id,
assetId: npc.avatarAssetId,
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
};
})
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null); const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null); const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null); const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
@@ -159,12 +171,7 @@ export function PresentationView({
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT} layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/> />
) : null} ) : null}
{activeNpc ? ( {activeNpcItems.length > 0 ? <NpcsSceneOverlay items={activeNpcItems} /> : null}
<MaterialOverlay
assetId={activeNpc.avatarAssetId}
layout={npcsOverlay?.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT}
/>
) : null}
{showTitle ? ( {showTitle ? (
<div className={styles.titleWrap}> <div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}> <div className={compact ? styles.titleCompact : styles.titleFull}>
@@ -0,0 +1,314 @@
import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types';
import styles from '../materials/MaterialOverlay.module.css';
import { useAssetUrl } from '../useAssetImageUrl';
type Corner = 'nw' | 'ne' | 'sw' | 'se';
export type NpcsSceneOverlayItem = {
npcId: NpcId;
assetId: AssetId | null;
layout: NpcsOverlayLayout;
};
type NpcsSceneOverlayProps = {
items: readonly NpcsSceneOverlayItem[];
editable?: boolean;
zoomTool?: NpcsZoomTool;
showClose?: boolean;
onClose?: () => void;
closeLabel?: string;
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
};
function nextBaseSize(
viewW: number,
viewH: number,
naturalW: number,
naturalH: number,
): { w: number; h: number } {
if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
const maxW = viewW * 0.92;
const maxH = viewH * 0.88;
const fit = Math.min(maxW / naturalW, maxH / naturalH);
return { w: naturalW * fit, h: naturalH * fit };
}
function NpcAvatarFrame({
item,
view,
rootRef,
editable,
zoomTool,
onLayoutChange,
}: {
item: NpcsSceneOverlayItem;
view: { w: number; h: number };
rootRef: React.RefObject<HTMLDivElement | null>;
editable: boolean;
zoomTool: NpcsZoomTool;
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
}) {
const url = useAssetUrl(item.assetId);
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
const dragRef = useRef<
| { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout }
| {
mode: 'resize';
corner: Corner;
startX: number;
startY: number;
origin: NpcsOverlayLayout;
baseW: number;
baseH: number;
viewW: number;
viewH: number;
}
| null
>(null);
if (!item.assetId || !url) return null;
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
const w = base.w * layout.scale;
const h = base.h * layout.scale;
const left = layout.cx * view.w - w / 2;
const top = layout.cy * view.h - h / 2;
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (!drag || !onLayoutChange) return;
const root = rootRef.current;
if (!root) return;
const r = root.getBoundingClientRect();
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
if (drag.mode === 'move') {
onLayoutChange(item.npcId, {
...drag.origin,
cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy,
});
return;
}
const { baseW, baseH, viewW, viewH, origin, corner } = drag;
const originW = baseW * origin.scale;
const originH = baseH * origin.scale;
const originLeft = origin.cx * viewW - originW / 2;
const originTop = origin.cy * viewH - originH / 2;
const originRight = originLeft + originW;
const originBottom = originTop + originH;
let anchorX = originLeft;
let anchorY = originTop;
if (corner === 'nw') {
anchorX = originRight;
anchorY = originBottom;
} else if (corner === 'ne') {
anchorX = originLeft;
anchorY = originBottom;
} else if (corner === 'sw') {
anchorX = originRight;
anchorY = originTop;
}
const pointerX = e.clientX - r.left;
const pointerY = e.clientY - r.top;
const newW = Math.max(8, Math.abs(pointerX - anchorX));
const newH = Math.max(8, Math.abs(pointerY - anchorY));
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
const ww = baseW * nextScale;
const hh = baseH * nextScale;
let nextLeft = anchorX;
let nextTop = anchorY;
if (corner === 'nw') {
nextLeft = anchorX - ww;
nextTop = anchorY - hh;
} else if (corner === 'ne') {
nextLeft = anchorX;
nextTop = anchorY - hh;
} else if (corner === 'sw') {
nextLeft = anchorX - ww;
nextTop = anchorY;
}
onLayoutChange(item.npcId, {
cx: (nextLeft + ww / 2) / Math.max(1, viewW),
cy: (nextTop + hh / 2) / Math.max(1, viewH),
scale: nextScale,
});
};
const endDrag = () => {
dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', endDrag);
};
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault();
e.stopPropagation();
dragRef.current =
mode === 'move'
? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } }
: {
mode: 'resize',
corner: corner ?? 'se',
startX: e.clientX,
startY: e.clientY,
origin: { ...layout },
baseW: base.w,
baseH: base.h,
viewW: view.w,
viewH: view.h,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
return (
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-npc-id={item.npcId}
style={{ left, top, width: w, height: h }}
onPointerDown={(e) => {
if (zoomTool) return;
startDrag(e, 'move');
}}
>
<img
className={styles.image}
src={url}
alt=""
draggable={false}
style={{
width: w,
height: h,
transform: 'translate(-50%, -50%)',
}}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
key={corner}
type="button"
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
aria-label={corner}
onPointerDown={(e) => startDrag(e, 'resize', corner)}
/>
))
: null}
</div>
);
}
export function NpcsSceneOverlay({
items,
editable = false,
zoomTool = null,
showClose = false,
onClose,
closeLabel = 'Close',
onLayoutChange,
onZoomAt,
}: NpcsSceneOverlayProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const [view, setView] = useState({ w: 1, h: 1 });
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const sync = () => setView({ w: el.clientWidth, h: el.clientHeight });
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, [items.length]);
if (items.length === 0) return null;
const interactive = editable || showClose || Boolean(zoomTool);
const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const toNorm = (clientX: number, clientY: number) => {
const root = rootRef.current;
if (!root) return { nx: 0.5, ny: 0.5 };
const r = root.getBoundingClientRect();
return {
nx: (clientX - r.left) / Math.max(1, r.width),
ny: (clientY - r.top) / Math.max(1, r.height),
};
};
const npcIdFromTarget = (target: EventTarget | null): NpcId | undefined => {
if (!(target instanceof Element)) return undefined;
const frame = target.closest('[data-npc-id]');
const raw = frame?.getAttribute('data-npc-id');
return raw ? (raw as NpcId) : undefined;
};
return (
<div
ref={rootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean)
.join(' ')}
role="dialog"
aria-modal="true"
onClick={(e) => {
if (!zoomTool || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
onZoomAt(npcIdFromTarget(e.target), nx, ny);
}}
>
<div className={styles.dim} />
{items.map((item) =>
onLayoutChange ? (
<NpcAvatarFrame
key={item.npcId}
item={item}
view={view}
rootRef={rootRef}
editable={editable}
zoomTool={zoomTool}
onLayoutChange={onLayoutChange}
/>
) : (
<NpcAvatarFrame
key={item.npcId}
item={item}
view={view}
rootRef={rootRef}
editable={editable}
zoomTool={zoomTool}
/>
),
)}
{showClose ? (
<button
type="button"
className={styles.close}
onClick={onClose}
aria-label={closeLabel}
title={closeLabel}
>
×
</button>
) : null}
</div>
);
}
+8 -5
View File
@@ -9,11 +9,14 @@ export type NpcsOverlayLayout = {
export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null; export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null;
/** Session-only: какой НПС сейчас показан поверх сцены (только аватар). */ /** Session-only: какие НПС сейчас показаны поверх сцены (только аватары). */
export type NpcsOverlayState = { export type NpcsOverlayState = {
revision: number; revision: number;
activeNpcId: NpcId | null; /** Порядок выделения / показа описаний. */
layout: NpcsOverlayLayout; activeNpcIds: NpcId[];
layouts: Record<string, NpcsOverlayLayout>;
/** На кого действует zoom, если клик не попал в конкретный аватар. */
focusNpcId: NpcId | null;
zoomTool: NpcsZoomTool; zoomTool: NpcsZoomTool;
}; };
@@ -27,9 +30,9 @@ export type NpcsOverlayEvent =
| { kind: 'show'; npcId: NpcId } | { kind: 'show'; npcId: NpcId }
| { kind: 'hide' } | { kind: 'hide' }
| { kind: 'toggle'; npcId: NpcId } | { kind: 'toggle'; npcId: NpcId }
| { kind: 'layout.set'; layout: NpcsOverlayLayout } | { kind: 'layout.set'; npcId: NpcId; layout: NpcsOverlayLayout }
| { kind: 'zoomTool.set'; tool: NpcsZoomTool } | { kind: 'zoomTool.set'; tool: NpcsZoomTool }
| { kind: 'zoomAt'; nx: number; ny: number }; | { kind: 'zoomAt'; nx: number; ny: number; npcId?: NpcId };
export function clampNpcsLayout(layout: NpcsOverlayLayout): NpcsOverlayLayout { export function clampNpcsLayout(layout: NpcsOverlayLayout): NpcsOverlayLayout {
return { return {