Files
DndGamePlayer/app/renderer/npcs/NpcsEditorApp.tsx
T
Ivan Fontosh 37ba855faf 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>
2026-07-17 13:13:58 +08:00

539 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}