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
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { DEFAULT_NPC_GROUP_COLOR } from '../../shared/npcs/npcGroups';
import type { ProjectNpcGroup } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { Button, Input } from '../shared/ui/controls';
import styles from './NpcGroupModal.module.css';
type NpcGroupModalProps = {
open: boolean;
initial: ProjectNpcGroup | null;
siblingNames: string[];
onClose: () => void;
onSave: (input: { name: string; color: string }) => Promise<void>;
};
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
export function NpcGroupModal({ open, initial, siblingNames, onClose, onSave }: NpcGroupModalProps) {
const { t } = useEditorI18n();
const [name, setName] = useState('');
const [color, setColor] = useState(DEFAULT_NPC_GROUP_COLOR);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setColor(initial?.color ?? DEFAULT_NPC_GROUP_COLOR);
setSaving(false);
setError(null);
}, [initial, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
const trimmed = name.trim();
const nameOk = trimmed.length >= 1;
const nameDup = useMemo(() => {
if (!nameOk) return false;
const key = normalizeName(trimmed);
const except = normalizeName(initial?.name ?? '');
return siblingNames.some((n) => {
const nk = normalizeName(n);
return nk === key && nk !== except;
});
}, [initial?.name, nameOk, siblingNames, trimmed]);
const canSave = nameOk && !nameDup && !saving;
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editGroup') : t('npcs.addGroup')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalClose}
>
×
</button>
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.groupName')}</div>
<div className={styles.nameColorRow}>
<input
type="color"
className={styles.colorInput}
value={color}
onChange={(e) => setColor(e.target.value)}
aria-label={t('npcs.groupColor')}
/>
<Input value={name} onChange={setName} placeholder={t('npcs.groupName')} />
</div>
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.groupNameRequired')}</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.groupNameDup')}</div> : null}
</div>
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onSave({ name: trimmed, color });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}