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:
Ivan Fontosh
2026-07-17 13:13:58 +08:00
parent 61875be857
commit 37ba855faf
40 changed files with 3655 additions and 17 deletions
+109
View File
@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Input } from '../shared/ui/controls';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
type NpcRelationModalProps = {
open: boolean;
initialLabel?: string;
onClose: () => void;
onSave: (label: string) => Promise<void>;
};
export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: NpcRelationModalProps) {
const { t } = useEditorI18n();
const [label, setLabel] = useState(initialLabel);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setLabel(initialLabel);
setSaving(false);
setError(null);
}, [initialLabel, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
const trimmed = label.trim();
const canSave = trimmed.length >= 1 && !saving;
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>
{initialLabel ? t('npcs.relationEditTitle') : t('npcs.relationCreateTitle')}
</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalClose}
>
×
</button>
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div>
<Input
value={label}
onChange={setLabel}
placeholder={t('npcs.relationLabelPlaceholder')}
/>
{trimmed.length < 1 ? (
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div>
) : null}
</div>
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onSave(trimmed);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}