feat(npcs): add campaign NPCs with relation graph and session overlay
Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||
import { NpcEditModal } from './NpcEditModal';
|
||||
import { NpcGraph } from './NpcGraph';
|
||||
import { NpcRelationModal } from './NpcRelationModal';
|
||||
import styles from './NpcsEditorApp.module.css';
|
||||
|
||||
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
|
||||
|
||||
function NpcTile({
|
||||
npc,
|
||||
selected,
|
||||
dragging,
|
||||
dropPlace,
|
||||
onSelect,
|
||||
onMenu,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDropReorder,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
dragging: boolean;
|
||||
dropPlace: 'before' | 'after' | null;
|
||||
onSelect: () => void;
|
||||
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (place: 'before' | 'after') => void;
|
||||
onDropReorder: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const url = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.tile,
|
||||
selected ? styles.tileSelected : '',
|
||||
dragging ? styles.tileDragging : '',
|
||||
dropPlace === 'before' ? styles.tileDropBefore : '',
|
||||
dropPlace === 'after' ? styles.tileDropAfter : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
onDragStart();
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
onDragOver(e.clientY < mid ? 'before' : 'after');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
onDropReorder();
|
||||
}}
|
||||
>
|
||||
<button type="button" className={styles.tileBody} onClick={onSelect}>
|
||||
<div className={styles.tileAvatar}>
|
||||
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
|
||||
</div>
|
||||
<div className={styles.tileName}>{npc.name}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tileMenuBtn}
|
||||
data-npc-menu-root="1"
|
||||
aria-label={t('npcs.tileMenu')}
|
||||
onClick={onMenu}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsEditorApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<ProjectNpc | null>(null);
|
||||
const [menuFor, setMenuFor] = useState<NpcId | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [dragId, setDragId] = useState<NpcId | null>(null);
|
||||
const [dropPlace, setDropPlace] = useState<{ id: NpcId; place: 'before' | 'after' } | null>(null);
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [relationModal, setRelationModal] = useState<
|
||||
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
|
||||
| { mode: 'edit'; relationId: NpcRelationId; label: string }
|
||||
| null
|
||||
>(null);
|
||||
const [pendingDeleteRelation, setPendingDeleteRelation] = useState<ProjectNpcRelation | null>(null);
|
||||
const [avatarBusy, setAvatarBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
|
||||
const list = project?.npcs ?? [];
|
||||
setSelectedId(list[0]?.id ?? null);
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const selected = npcs.find((n) => n.id === selectedId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setNameDraft(selected?.name ?? '');
|
||||
}, [selected?.id, selected?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId && npcs.some((n) => n.id === selectedId)) return;
|
||||
setSelectedId(npcs[0]?.id ?? null);
|
||||
}, [npcs, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuFor) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
if (tgt?.closest('[data-npc-menu-root="1"]')) return;
|
||||
setMenuFor(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [npcs, query]);
|
||||
|
||||
const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null);
|
||||
|
||||
const relationsForSelected = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return relations
|
||||
.filter((r) => r.sourceNpcId === selected.id)
|
||||
.map((r) => {
|
||||
const other = npcs.find((n) => n.id === r.targetNpcId);
|
||||
return { relation: r, otherName: other?.name ?? '—' };
|
||||
});
|
||||
}, [npcs, relations, selected]);
|
||||
|
||||
const pickAvatar = useCallback(async () => {
|
||||
const res = await api.invoke(ipcChannels.project.pickNpcAvatar, {});
|
||||
if (res.canceled) return null;
|
||||
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
|
||||
}, [api]);
|
||||
|
||||
const graphUi = useMemo(
|
||||
() => ({
|
||||
zoomBar: t('npcs.graphZoomBar'),
|
||||
zoomIn: t('npcs.graphZoomIn'),
|
||||
zoomOut: t('npcs.graphZoomOut'),
|
||||
fitAll: t('npcs.graphFitAll'),
|
||||
editRelation: t('npcs.relationEdit'),
|
||||
deleteRelation: t('npcs.relationDelete'),
|
||||
untitled: t('npcs.untitled'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.topBar}>
|
||||
<div className={styles.topTitle}>{t('npcs.editorTitle')}</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void api.invoke(ipcChannels.windows.closeNpcsEditor, {});
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={[styles.col, styles.side].join(' ')}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setEditInitial(null);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('npcs.add')}
|
||||
</Button>
|
||||
<div className={styles.list}>
|
||||
{filtered.map((n) => (
|
||||
<NpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
dragging={dragId === n.id}
|
||||
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
|
||||
onSelect={() => setSelectedId(n.id)}
|
||||
onMenu={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const menuW = 180;
|
||||
const menuH = 88;
|
||||
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
|
||||
const top =
|
||||
r.bottom + 8 + menuH > window.innerHeight - 8
|
||||
? Math.max(8, r.top - menuH - 8)
|
||||
: r.bottom + 8;
|
||||
setMenuPos({ left, top });
|
||||
setMenuFor((cur) => (cur === n.id ? null : n.id));
|
||||
}}
|
||||
onDragStart={() => setDragId(n.id)}
|
||||
onDragEnd={() => {
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
}}
|
||||
onDragOver={(place) => {
|
||||
if (!dragId || dragId === n.id) {
|
||||
setDropPlace(null);
|
||||
return;
|
||||
}
|
||||
setDropPlace({ id: n.id, place });
|
||||
}}
|
||||
onDropReorder={() => {
|
||||
if (!dragId || !dropPlace || dragId === dropPlace.id) return;
|
||||
const ids = npcs.map((x) => x.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
if (from < 0) return;
|
||||
ids.splice(from, 1);
|
||||
let to = ids.indexOf(dropPlace.id);
|
||||
if (to < 0) return;
|
||||
if (dropPlace.place === 'after') to += 1;
|
||||
ids.splice(to, 0, dragId);
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
void api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: ids });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.empty')}</div> : null}
|
||||
{npcs.length > 0 && filtered.length === 0 ? (
|
||||
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.col}>
|
||||
<NpcGraph
|
||||
npcs={npcs}
|
||||
relations={relations}
|
||||
selectedNpcId={selectedId}
|
||||
graphUi={graphUi}
|
||||
onSelect={setSelectedId}
|
||||
onConnectRequest={(sourceNpcId, targetNpcId) => {
|
||||
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
|
||||
}}
|
||||
onNodePositionCommit={(npcId, x, y) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcPosition, { npcId, x, y });
|
||||
}}
|
||||
onEditRelation={(relationId) => {
|
||||
const rel = relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
setRelationModal({ mode: 'edit', relationId, label: rel.label });
|
||||
}}
|
||||
onDeleteRelation={(relationId) => {
|
||||
const rel = relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
setPendingDeleteRelation(rel);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={[styles.col, styles.inspector].join(' ')}>
|
||||
<div className={styles.inspectorScroll}>
|
||||
{selected ? (
|
||||
<>
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
<div className={styles.avatarPick}>
|
||||
<div className={styles.avatarPreview}>
|
||||
{selectedUrl ? (
|
||||
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
disabled={avatarBusy}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setAvatarBusy(true);
|
||||
try {
|
||||
const picked = await pickAvatar();
|
||||
if (!picked) return;
|
||||
await api.invoke(ipcChannels.project.upsertNpc, {
|
||||
npcId: selected.id,
|
||||
name: selected.name,
|
||||
filePath: picked.filePath,
|
||||
});
|
||||
} finally {
|
||||
setAvatarBusy(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('npcs.chooseAvatar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
|
||||
<input
|
||||
className={controlStyles.input}
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = nameDraft.trim();
|
||||
if (!next || next === selected.name) {
|
||||
setNameDraft(selected.name);
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
name: next,
|
||||
})
|
||||
.catch(() => setNameDraft(selected.name));
|
||||
}}
|
||||
placeholder={t('npcs.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
||||
<NpcDescriptionField
|
||||
html={selected.description}
|
||||
onCommit={(html) => {
|
||||
if (html === selected.description) return;
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
description: html,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relationsForSelected.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
||||
<div className={styles.relationsList}>
|
||||
{relationsForSelected.map(({ relation, otherName }) => (
|
||||
<div key={relation.id} className={styles.relationItem}>
|
||||
{relation.label} {otherName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyInspector}>{t('npcs.selectPrompt')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NpcEditModal
|
||||
open={editOpen}
|
||||
initial={editInitial}
|
||||
existingNames={npcs.map((n) => n.name)}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onPickImage={pickAvatar}
|
||||
onSave={async (input) => {
|
||||
const res = await api.invoke(ipcChannels.project.upsertNpc, {
|
||||
...(editInitial ? { npcId: editInitial.id } : {}),
|
||||
name: input.name,
|
||||
...(input.filePath ? { filePath: input.filePath } : {}),
|
||||
});
|
||||
const created = res.project.npcs.find((n) => n.name === input.name.trim());
|
||||
if (created) setSelectedId(created.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NpcRelationModal
|
||||
open={Boolean(relationModal)}
|
||||
initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''}
|
||||
onClose={() => setRelationModal(null)}
|
||||
onSave={async (label) => {
|
||||
if (!relationModal) return;
|
||||
if (relationModal.mode === 'create') {
|
||||
await api.invoke(ipcChannels.project.upsertNpcRelation, {
|
||||
sourceNpcId: relationModal.sourceNpcId,
|
||||
targetNpcId: relationModal.targetNpcId,
|
||||
label,
|
||||
});
|
||||
} else {
|
||||
const rel = relations.find((r) => r.id === relationModal.relationId);
|
||||
if (!rel) return;
|
||||
await api.invoke(ipcChannels.project.upsertNpcRelation, {
|
||||
relationId: rel.id,
|
||||
sourceNpcId: rel.sourceNpcId,
|
||||
targetNpcId: rel.targetNpcId,
|
||||
label,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{menuFor && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
className={styles.menu}
|
||||
style={{ left: menuPos.left, top: menuPos.top }}
|
||||
data-npc-menu-root="1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemDanger}
|
||||
onClick={() => {
|
||||
const npc = npcs.find((n) => n.id === menuFor);
|
||||
if (npc) setPendingDelete(npc);
|
||||
setMenuFor(null);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{pendingDelete
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={editorStyles.modalBackdrop}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>{t('npcs.deleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={editorStyles.modalClose}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>{t('npcs.deleteConfirm', { name: pendingDelete.name })}</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDelete.id;
|
||||
setPendingDelete(null);
|
||||
void api.invoke(ipcChannels.project.deleteNpc, { npcId: id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{pendingDeleteRelation
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={editorStyles.modalBackdrop}
|
||||
onClick={() => setPendingDeleteRelation(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>{t('npcs.relationDeleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={editorStyles.modalClose}
|
||||
onClick={() => setPendingDeleteRelation(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
|
||||
</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeleteRelation.id;
|
||||
setPendingDeleteRelation(null);
|
||||
void api.invoke(ipcChannels.project.deleteNpcRelation, { relationId: id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user