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:
Ivan Fontosh
2026-07-20 13:19:22 +08:00
parent 37ba855faf
commit f4c0ac1438
41 changed files with 5210 additions and 418 deletions
+167 -25
View File
@@ -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>