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
+51 -1
View File
@@ -17,9 +17,10 @@ import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -91,6 +92,7 @@ export function ControlApp() {
const [fxState, fx] = useEffectsState();
const [sdState, sd] = useSceneDarknessState();
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
@@ -1203,6 +1205,28 @@ export function ControlApp() {
</svg>
</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.npcsTool')}
ariaLabel={t('control.npcsTool')}
onClick={() => {
void api.invoke(ipcChannels.windows.openNpcs, {}).catch((err) => {
console.error('[control] openNpcs failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="12" cy="8" r="3.4" fill="#3b82f6" />
<path
fill="#22c55e"
d="M5.2 19.2c.6-3.4 3.2-5.2 6.8-5.2s6.2 1.8 6.8 5.2c.1.5-.3 1-.8 1H6c-.5 0-.9-.5-.8-1z"
/>
<circle cx="12" cy="8" r="1.4" fill="#93c5fd" />
</svg>
</span>
</Button>
</div>
<div className={styles.spacer12} />
{!isVideoPreviewScene ? (
@@ -1586,6 +1610,32 @@ export function ControlApp() {
/>
);
})()}
{(() => {
const activeNpc =
session?.project && npcsOverlay?.activeNpcId
? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId)
: undefined;
if (!activeNpc) return null;
return (
<MaterialOverlay
assetId={activeNpc.avatarAssetId}
layout={npcsOverlay?.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT}
editable
zoomTool={npcsOverlay?.zoomTool ?? null}
showClose
closeLabel={t('npcs.closeOverlay')}
onClose={() => {
void npcsApi.dispatch({ kind: 'hide' });
}}
onLayoutChange={(layout) => {
void npcsApi.dispatch({ kind: 'layout.set', layout });
}}
onZoomAt={(nx, ny) => {
void npcsApi.dispatch({ kind: 'zoomAt', nx, ny });
}}
/>
);
})()}
</div>
</Surface>