feat(npcs): add groups, storyline bindings, and Foundry import
Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+167
-25
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { NpcId, ProjectNpc } from '../../shared/types';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
@@ -37,28 +38,38 @@ function ZoomOutIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Убрать пустые ветки групп (удобно при поиске). */
|
||||
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
||||
const out: NpcGroupTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const children = pruneEmptyGroupNodes(node.children);
|
||||
if (node.npcs.length === 0 && children.length === 0) continue;
|
||||
out.push({ ...node, children });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function RuntimeNpcTile({
|
||||
npc,
|
||||
selected,
|
||||
active,
|
||||
accentColor,
|
||||
onActivate,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
accentColor?: string | null;
|
||||
onActivate: () => void;
|
||||
}) {
|
||||
const url = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.tile,
|
||||
selected ? styles.tileSelected : '',
|
||||
active ? styles.tileActive : '',
|
||||
]
|
||||
className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
|
||||
onClick={onActivate}
|
||||
>
|
||||
<div className={styles.tileAvatar}>
|
||||
@@ -69,6 +80,70 @@ function RuntimeNpcTile({
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeGroupSection({
|
||||
node,
|
||||
depth,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
selectedId,
|
||||
activeId,
|
||||
onActivate,
|
||||
}: {
|
||||
node: NpcGroupTreeNode;
|
||||
depth: number;
|
||||
isExpanded: (id: NpcGroupId) => boolean;
|
||||
onToggleExpanded: (id: NpcGroupId) => void;
|
||||
selectedId: NpcId | null;
|
||||
activeId: NpcId | null;
|
||||
onActivate: (id: NpcId) => void;
|
||||
}) {
|
||||
const g = node.group;
|
||||
const expanded = isExpanded(g.id);
|
||||
|
||||
return (
|
||||
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
|
||||
<div className={styles.groupHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupToggle}
|
||||
onClick={() => onToggleExpanded(g.id)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
|
||||
<span className={styles.groupTitle}>{g.name}</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className={styles.groupBody}>
|
||||
{node.npcs.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
active={n.id === activeId}
|
||||
accentColor={g.color}
|
||||
onActivate={() => onActivate(n.id)}
|
||||
/>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<RuntimeGroupSection
|
||||
key={child.group.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
selectedId={selectedId}
|
||||
activeId={activeId}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
@@ -76,6 +151,7 @@ export function NpcsApp() {
|
||||
const [overlay, overlayApi] = useNpcsOverlayState();
|
||||
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
@@ -106,18 +182,53 @@ export function NpcsApp() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
|
||||
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
|
||||
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
|
||||
const activeId = overlay?.activeNpcId ?? null;
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
const selected = npcs.find((n) => n.id === selectedId) ?? null;
|
||||
const effectiveSelectedId =
|
||||
selectedId && npcs.some((n) => n.id === selectedId) ? selectedId : (npcs[0]?.id ?? null);
|
||||
const selected = npcs.find((n) => n.id === effectiveSelectedId) ?? null;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredNpcs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [npcs, query]);
|
||||
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
const { roots, ungrouped } = useMemo(() => {
|
||||
const forest = buildNpcGroupForest(npcGroups, filteredNpcs);
|
||||
if (!searching) return forest;
|
||||
return {
|
||||
roots: pruneEmptyGroupNodes(forest.roots),
|
||||
ungrouped: forest.ungrouped,
|
||||
};
|
||||
}, [filteredNpcs, npcGroups, searching]);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(id: NpcGroupId) => {
|
||||
if (searching) return true;
|
||||
return !collapsedGroups.has(id);
|
||||
},
|
||||
[collapsedGroups, searching],
|
||||
);
|
||||
|
||||
const toggleExpanded = useCallback(
|
||||
(id: NpcGroupId) => {
|
||||
if (searching) return;
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[searching],
|
||||
);
|
||||
|
||||
const relationsForSelected = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return relations
|
||||
@@ -202,10 +313,7 @@ export function NpcsApp() {
|
||||
{safeHtml ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
||||
<div
|
||||
className={styles.detailDesc}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
||||
@@ -231,17 +339,51 @@ export function NpcsApp() {
|
||||
<div className={styles.listCol}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<div className={styles.list}>
|
||||
{filtered.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
active={n.id === activeId}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
{npcGroups.length === 0 ? (
|
||||
filteredNpcs.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === effectiveSelectedId}
|
||||
active={n.id === activeId}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{roots.map((node) => (
|
||||
<RuntimeGroupSection
|
||||
key={node.group.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
selectedId={effectiveSelectedId}
|
||||
activeId={activeId}
|
||||
onActivate={onSelectTile}
|
||||
/>
|
||||
))}
|
||||
{ungrouped.length > 0 || !searching ? (
|
||||
<div className={styles.ungroupedSection}>
|
||||
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
|
||||
<div className={styles.groupBody}>
|
||||
{ungrouped.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === effectiveSelectedId}
|
||||
active={n.id === activeId}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
|
||||
{npcs.length > 0 && filtered.length === 0 ? (
|
||||
{npcs.length > 0 && filteredNpcs.length === 0 ? (
|
||||
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user