f4c0ac1438
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>
141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
|
|
import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding';
|
|
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
|
|
import editorStyles from '../editor/EditorApp.module.css';
|
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
|
import controlStyles from '../shared/ui/Controls.module.css';
|
|
|
|
type NpcBindingFieldsProps = {
|
|
project: Project;
|
|
binding: NpcBinding;
|
|
onChange: (binding: NpcBinding) => void;
|
|
};
|
|
|
|
function defaultBinding(project: Project): NpcBinding {
|
|
const opts = listStorylineOptionsForBinding(project);
|
|
if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } };
|
|
if (opts.sides[0]) {
|
|
return {
|
|
kind: 'storyline',
|
|
storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId },
|
|
};
|
|
}
|
|
const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined;
|
|
if (firstScene) return { kind: 'scene', sceneId: firstScene };
|
|
return noneBinding();
|
|
}
|
|
|
|
export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) {
|
|
const { t } = useEditorI18n();
|
|
const enabled = !isNpcBindingNone(binding);
|
|
const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]);
|
|
const sceneOptions = useMemo(
|
|
() =>
|
|
Object.entries(project.scenes)
|
|
.map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id }))
|
|
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })),
|
|
[project.scenes],
|
|
);
|
|
|
|
const kind = binding.kind === 'none' ? 'storyline' : binding.kind;
|
|
|
|
const bindingTargetValue = useMemo(() => {
|
|
if (binding.kind === 'scene') return binding.sceneId;
|
|
if (binding.kind === 'storyline') {
|
|
if (binding.storyline.kind === 'main') return 'main';
|
|
return `side:${binding.storyline.startGraphNodeId}`;
|
|
}
|
|
return '';
|
|
}, [binding]);
|
|
|
|
return (
|
|
<div className={editorStyles.fieldGrid}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
onChange={(e) => {
|
|
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
|
|
}}
|
|
/>
|
|
<span>{t('npcs.bindingEnable')}</span>
|
|
</label>
|
|
|
|
{enabled ? (
|
|
<>
|
|
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
|
|
<select
|
|
className={controlStyles.input}
|
|
value={kind}
|
|
onChange={(e) => {
|
|
const nextKind = e.target.value;
|
|
if (nextKind === 'scene') {
|
|
const first = sceneOptions[0];
|
|
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
|
|
return;
|
|
}
|
|
if (storylineOpts.main) {
|
|
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
|
|
} else if (storylineOpts.sides[0]) {
|
|
onChange({
|
|
kind: 'storyline',
|
|
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
|
|
});
|
|
} else {
|
|
onChange(noneBinding());
|
|
}
|
|
}}
|
|
>
|
|
<option value="storyline">{t('npcs.bindingStoryline')}</option>
|
|
<option value="scene">{t('npcs.bindingScene')}</option>
|
|
</select>
|
|
|
|
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
|
|
<select
|
|
className={controlStyles.input}
|
|
value={bindingTargetValue}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
if (kind === 'scene') {
|
|
onChange({ kind: 'scene', sceneId: v as SceneId });
|
|
return;
|
|
}
|
|
if (v === 'main') {
|
|
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
|
|
return;
|
|
}
|
|
if (v.startsWith('side:')) {
|
|
onChange({
|
|
kind: 'storyline',
|
|
storyline: {
|
|
kind: 'side',
|
|
startGraphNodeId: v.slice(5) as GraphNodeId,
|
|
},
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
{kind === 'storyline' ? (
|
|
<>
|
|
{storylineOpts.main ? <option value="main">{t('npcs.bindingMain')}</option> : null}
|
|
{storylineOpts.sides.map((s) => (
|
|
<option key={s.startGraphNodeId} value={`side:${s.startGraphNodeId}`}>
|
|
{s.label}
|
|
</option>
|
|
))}
|
|
</>
|
|
) : (
|
|
sceneOptions.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.title}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|