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
+13 -1
View File
@@ -7,9 +7,10 @@ import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './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 './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
@@ -32,6 +33,7 @@ export function PresentationView({
const [fxState] = useEffectsState();
const [sdState] = useSceneDarknessState();
const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -43,6 +45,10 @@ export function PresentationView({
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
const activeNpc =
session?.project && npcsOverlay?.activeNpcId
? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId)
: undefined;
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
@@ -153,6 +159,12 @@ export function PresentationView({
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/>
) : null}
{activeNpc ? (
<MaterialOverlay
assetId={activeNpc.avatarAssetId}
layout={npcsOverlay?.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT}
/>
) : null}
{showTitle ? (
<div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}>
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool } from '../../../shared/types';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { useAssetUrl } from '../useAssetImageUrl';
import styles from './MaterialOverlay.module.css';
@@ -12,7 +12,7 @@ type MaterialOverlayProps = {
rotationDeg?: 0 | 90 | 180 | 270;
layout: MaterialsOverlayLayout;
editable?: boolean;
zoomTool?: MaterialsZoomTool;
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
showClose?: boolean;
onClose?: () => void;
closeLabel?: string;
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { NpcsOverlayEvent, NpcsOverlayState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useNpcsOverlayState(): readonly [
NpcsOverlayState | null,
{ dispatch: (event: NpcsOverlayEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<NpcsOverlayState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.npcsOverlay.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.npcsOverlay.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.npcsOverlay.dispatch, { event });
},
},
] as const;
}