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,252 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { NpcId, ProjectNpc } from '../../shared/types';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './NpcsApp.module.css';
|
||||
|
||||
function ZoomInIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path
|
||||
d="M10.5 7.8v5.4M7.8 10.5h5.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomOutIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeNpcTile({
|
||||
npc,
|
||||
selected,
|
||||
active,
|
||||
onActivate,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onActivate: () => void;
|
||||
}) {
|
||||
const url = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.tile,
|
||||
selected ? styles.tileSelected : '',
|
||||
active ? styles.tileActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={onActivate}
|
||||
>
|
||||
<div className={styles.tileAvatar}>
|
||||
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
|
||||
</div>
|
||||
<div className={styles.tileName}>{npc.name}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [overlay, overlayApi] = useNpcsOverlayState();
|
||||
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (overlay?.activeNpcId) {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
return;
|
||||
}
|
||||
if (overlay?.zoomTool) {
|
||||
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const activeId = overlay?.activeNpcId ?? null;
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
const selected = npcs.find((n) => n.id === selectedId) ?? null;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [npcs, query]);
|
||||
|
||||
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 { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
|
||||
});
|
||||
}, [npcs, relations, selected]);
|
||||
|
||||
const onSelectTile = useCallback(
|
||||
(id: NpcId) => {
|
||||
setSelectedId(id);
|
||||
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
|
||||
},
|
||||
[overlayApi],
|
||||
);
|
||||
|
||||
const safeHtml = selected ? sanitizeSceneDescriptionHtml(selected.description) : '';
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarRow}>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('npcs.zoomIn')}
|
||||
ariaLabel={t('npcs.zoomIn')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('npcs.zoomOut')}
|
||||
ariaLabel={t('npcs.zoomOut')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
{activeId ? (
|
||||
<Button
|
||||
title={t('npcs.closeOverlay')}
|
||||
ariaLabel={t('npcs.closeOverlay')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
}}
|
||||
>
|
||||
{t('npcs.closeOverlay')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.toolbarHint}>
|
||||
{zoomTool === 'zoomIn'
|
||||
? t('npcs.zoomInHint')
|
||||
: zoomTool === 'zoomOut'
|
||||
? t('npcs.zoomOutHint')
|
||||
: t('npcs.zoomIdleHint')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detail}>
|
||||
{selected ? (
|
||||
<>
|
||||
<div className={styles.detailName}>{selected.name}</div>
|
||||
{safeHtml ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
||||
<div
|
||||
className={styles.detailDesc}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
||||
)}
|
||||
{relationsForSelected.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
||||
<div className={styles.relationsList}>
|
||||
{relationsForSelected.map((r) => (
|
||||
<div key={r.id} className={styles.relationItem}>
|
||||
{r.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.detailEmpty}>{t('npcs.windowEmpty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.listCol}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<div className={styles.list}>
|
||||
{filtered.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
active={n.id === activeId}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
|
||||
{npcs.length > 0 && filtered.length === 0 ? (
|
||||
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user