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:
@@ -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': {
|
||||||
this.state = {
|
if (this.state.activeNpcIds.includes(event.npcId)) {
|
||||||
revision: this.state.revision + 1,
|
this.state = {
|
||||||
activeNpcId: event.npcId,
|
...this.state,
|
||||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
revision: this.state.revision + 1,
|
||||||
zoomTool: this.state.zoomTool,
|
focusNpcId: event.npcId,
|
||||||
};
|
};
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
.map((r) => {
|
.filter((r) => r.sourceNpcId === npc.id)
|
||||||
const other = npcs.find((n) => n.id === r.targetNpcId);
|
.map((r) => {
|
||||||
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
|
const other = npcs.find((n) => n.id === r.targetNpcId);
|
||||||
});
|
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,32 +301,40 @@ 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);
|
||||||
{safeHtml ? (
|
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
|
||||||
<div>
|
return (
|
||||||
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
<div key={npc.id} className={styles.detailCard}>
|
||||||
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
<div className={styles.detailName}>{npc.name}</div>
|
||||||
</div>
|
{safeHtml ? (
|
||||||
) : (
|
<div>
|
||||||
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
||||||
)}
|
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
||||||
{relationsForSelected.length > 0 ? (
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
||||||
<div className={styles.relationsList}>
|
)}
|
||||||
{relationsForSelected.map((r) => (
|
{npcRelations.length > 0 ? (
|
||||||
<div key={r.id} className={styles.relationItem}>
|
<div>
|
||||||
{r.text}
|
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
||||||
|
<div className={styles.relationsList}>
|
||||||
|
{npcRelations.map((r) => (
|
||||||
|
<div key={r.id} className={styles.relationItem}>
|
||||||
|
{r.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
);
|
||||||
</>
|
})
|
||||||
) : (
|
) : (
|
||||||
<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)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user