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:
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ProjectNpc } from '../../shared/types';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
@@ -13,24 +13,51 @@ import {
|
||||
} from '../editor/fileDrop';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function flattenGroupOptions(
|
||||
nodes: ReturnType<typeof buildNpcGroupForest>['roots'],
|
||||
depth = 0,
|
||||
): { id: NpcGroupId; label: string }[] {
|
||||
const out: { id: NpcGroupId; label: string }[] = [];
|
||||
for (const node of nodes) {
|
||||
const prefix = depth > 0 ? ' '.repeat(depth) : '';
|
||||
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
|
||||
out.push(...flattenGroupOptions(node.children, depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type NpcEditModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectNpc | null;
|
||||
existingNames: string[];
|
||||
project: Project | null;
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
onClose: () => void;
|
||||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
|
||||
onSave: (input: {
|
||||
name: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
export function NpcEditModal({
|
||||
open,
|
||||
initial,
|
||||
existingNames,
|
||||
project,
|
||||
npcGroups,
|
||||
onClose,
|
||||
onPickImage,
|
||||
onSave,
|
||||
@@ -39,15 +66,24 @@ export function NpcEditModal({
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
|
||||
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
|
||||
|
||||
const groupOptions = useMemo(() => {
|
||||
const { roots } = buildNpcGroupForest(npcGroups, []);
|
||||
return flattenGroupOptions(roots);
|
||||
}, [npcGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setGroupId(initial?.groupId ?? '');
|
||||
setBinding(initial?.binding ?? noneBinding());
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [initial, open]);
|
||||
@@ -95,7 +131,7 @@ export function NpcEditModal({
|
||||
);
|
||||
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl || existingUrl;
|
||||
const previewSrc = localPreviewUrl ?? existingUrl;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -109,9 +145,7 @@ export function NpcEditModal({
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>
|
||||
{initial ? t('npcs.editTitle') : t('npcs.addTitle')}
|
||||
</div>
|
||||
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editTitle') : t('npcs.addTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
@@ -129,6 +163,24 @@ export function NpcEditModal({
|
||||
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
|
||||
</div>
|
||||
|
||||
{!initial && groupOptions.length > 0 ? (
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value as NpcGroupId | '')}
|
||||
>
|
||||
<option value="">{t('npcs.ungrouped')}</option>
|
||||
{groupOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
<div
|
||||
@@ -139,11 +191,11 @@ export function NpcEditModal({
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
const entry = entries[i];
|
||||
if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files[i];
|
||||
if (file) {
|
||||
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
|
||||
return;
|
||||
@@ -153,9 +205,7 @@ export function NpcEditModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? (
|
||||
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
|
||||
) : null}
|
||||
{drop.dragOver ? <div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div> : null}
|
||||
{previewSrc ? (
|
||||
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
|
||||
) : (
|
||||
@@ -176,6 +226,10 @@ export function NpcEditModal({
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{project && !initial ? (
|
||||
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
|
||||
) : null}
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
@@ -191,7 +245,11 @@ export function NpcEditModal({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
||||
await onSave({
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(!initial ? { groupId: groupId || null, binding } : {}),
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
Reference in New Issue
Block a user