diff --git a/app/main/npcs/npcsOverlayStore.ts b/app/main/npcs/npcsOverlayStore.ts index 7cf498d..e3981f3 100644 --- a/app/main/npcs/npcsOverlayStore.ts +++ b/app/main/npcs/npcsOverlayStore.ts @@ -12,12 +12,17 @@ import { function emptyState(): NpcsOverlayState { return { revision: 1, - activeNpcId: null, - layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, + activeNpcIds: [], + layouts: {}, + focusNpcId: null, zoomTool: null, }; } +function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout { + return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT }; +} + export class NpcsOverlayStore { private state: NpcsOverlayState = emptyState(); @@ -26,13 +31,14 @@ export class NpcsOverlayStore { } clear(): NpcsOverlayState { - if (this.state.activeNpcId === null && this.state.zoomTool === null) { + if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) { return this.state; } this.state = { revision: this.state.revision + 1, - activeNpcId: null, - layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, + activeNpcIds: [], + layouts: {}, + focusNpcId: null, zoomTool: null, }; return this.state; @@ -42,32 +48,67 @@ export class NpcsOverlayStore { switch (event.kind) { case 'hide': return this.clear(); - case 'show': - this.state = { - revision: this.state.revision + 1, - activeNpcId: event.npcId, - layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, - zoomTool: this.state.zoomTool, - }; - return this.state; - case 'toggle': { - if (this.state.activeNpcId === event.npcId) { - return this.clear(); + case 'show': { + if (this.state.activeNpcIds.includes(event.npcId)) { + this.state = { + ...this.state, + revision: this.state.revision + 1, + focusNpcId: event.npcId, + }; + return this.state; } this.state = { revision: this.state.revision + 1, - activeNpcId: event.npcId, - layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, + activeNpcIds: [...this.state.activeNpcIds, event.npcId], + layouts: { + ...this.state.layouts, + [event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, + }, + focusNpcId: event.npcId, + zoomTool: this.state.zoomTool, + }; + return this.state; + } + case 'toggle': { + if (this.state.activeNpcIds.includes(event.npcId)) { + const activeNpcIds = this.state.activeNpcIds.filter((id) => id !== event.npcId); + const layouts = { ...this.state.layouts }; + delete layouts[event.npcId]; + const focusNpcId = + this.state.focusNpcId === event.npcId + ? (activeNpcIds[activeNpcIds.length - 1] ?? null) + : this.state.focusNpcId; + this.state = { + revision: this.state.revision + 1, + activeNpcIds, + layouts, + focusNpcId, + zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool, + }; + return this.state; + } + this.state = { + revision: this.state.revision + 1, + activeNpcIds: [...this.state.activeNpcIds, event.npcId], + layouts: { + ...this.state.layouts, + [event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, + }, + focusNpcId: event.npcId, zoomTool: this.state.zoomTool, }; return this.state; } case 'layout.set': { - if (this.state.activeNpcId === null) return this.state; + if (!this.state.activeNpcIds.includes(event.npcId)) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, - layout: clampNpcsLayout(event.layout), + focusNpcId: event.npcId, + layouts: { + ...this.state.layouts, + [event.npcId]: clampNpcsLayout(event.layout), + }, }; return this.state; } @@ -81,10 +122,16 @@ export class NpcsOverlayStore { return this.state; } case 'zoomAt': { - if (this.state.activeNpcId === null || !this.state.zoomTool) return this.state; + if (this.state.activeNpcIds.length === 0 || !this.state.zoomTool) return this.state; + const targetId = + (event.npcId && this.state.activeNpcIds.includes(event.npcId) ? event.npcId : null) ?? + this.state.focusNpcId ?? + this.state.activeNpcIds[this.state.activeNpcIds.length - 1] ?? + null; + if (!targetId) return this.state; const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25; const layout: NpcsOverlayLayout = zoomNpcsLayoutAt( - this.state.layout, + layoutFor(this.state, targetId), event.nx, event.ny, factor, @@ -92,7 +139,11 @@ export class NpcsOverlayStore { this.state = { ...this.state, revision: this.state.revision + 1, - layout, + focusNpcId: targetId, + layouts: { + ...this.state.layouts, + [targetId]: layout, + }, }; return this.state; } @@ -102,8 +153,25 @@ export class NpcsOverlayStore { } ensureNpcStillExists(npcIds: ReadonlySet): NpcsOverlayState { - const active = this.state.activeNpcId; - if (active === null || npcIds.has(active)) return this.state; - return this.clear(); + const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id)); + if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state; + if (activeNpcIds.length === 0) return this.clear(); + const layouts: Record = {}; + for (const id of activeNpcIds) { + const layout = this.state.layouts[id]; + if (layout) layouts[id] = layout; + } + const focusNpcId = + this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId) + ? this.state.focusNpcId + : (activeNpcIds[activeNpcIds.length - 1] ?? null); + this.state = { + revision: this.state.revision + 1, + activeNpcIds, + layouts, + focusNpcId, + zoomTool: this.state.zoomTool, + }; + return this.state; } } diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index ac91ac2..aca16da 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -20,6 +20,7 @@ import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; 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 { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { Button } from '../shared/ui/controls'; import { Surface } from '../shared/ui/Surface'; @@ -1611,15 +1612,24 @@ export function ControlApp() { ); })()} {(() => { - const activeNpc = - session?.project && npcsOverlay?.activeNpcId - ? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId) - : undefined; - if (!activeNpc) return null; + const project = session?.project; + const activeIds = npcsOverlay?.activeNpcIds ?? []; + if (!project || activeIds.length === 0) return null; + const items = activeIds + .map((id) => { + const npc = (project.npcs ?? []).find((n) => n.id === id); + if (!npc) return null; + return { + npcId: npc.id, + assetId: npc.avatarAssetId, + layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT, + }; + }) + .filter((x): x is NonNullable => x !== null); + if (items.length === 0) return null; return ( - { void npcsApi.dispatch({ kind: 'hide' }); }} - onLayoutChange={(layout) => { - void npcsApi.dispatch({ kind: 'layout.set', layout }); + onLayoutChange={(npcId, layout) => { + void npcsApi.dispatch({ kind: 'layout.set', npcId, layout }); }} - onZoomAt={(nx, ny) => { - void npcsApi.dispatch({ kind: 'zoomAt', nx, ny }); + onZoomAt={(npcId, nx, ny) => { + void npcsApi.dispatch( + npcId ? { kind: 'zoomAt', nx, ny, npcId } : { kind: 'zoomAt', nx, ny }, + ); }} /> ); diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index f693ced..cca390b 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -425,7 +425,8 @@ export const EDITOR_MESSAGES: Record> = { 'npcs.graphZoomOut': 'Уменьшить', 'npcs.graphFitAll': 'Показать всё', 'npcs.windowEmpty': 'Добавьте НПС в редакторе.', - 'npcs.closeOverlay': 'Закрыть НПС', + 'npcs.selectToShow': 'Выберите персонажей в списке — они появятся на экране.', + 'npcs.closeOverlay': 'Закрыть всех', 'npcs.zoomIn': 'Увеличить', 'npcs.zoomOut': 'Уменьшить', 'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.', @@ -960,7 +961,8 @@ export const EDITOR_MESSAGES: Record> = { 'npcs.graphZoomOut': 'Zoom out', 'npcs.graphFitAll': 'Fit view', 'npcs.windowEmpty': 'Add NPCs in the editor.', - 'npcs.closeOverlay': 'Close NPC', + 'npcs.selectToShow': 'Select characters in the list — they will appear on screen.', + 'npcs.closeOverlay': 'Close all', 'npcs.zoomIn': 'Zoom in', 'npcs.zoomOut': 'Zoom out', 'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.', diff --git a/app/renderer/npcs/NpcsApp.module.css b/app/renderer/npcs/NpcsApp.module.css index f72d6ca..de69f0f 100644 --- a/app/renderer/npcs/NpcsApp.module.css +++ b/app/renderer/npcs/NpcsApp.module.css @@ -50,6 +50,18 @@ line-height: 1.45; } +.detailCard { + display: grid; + gap: 10px; + padding-bottom: 14px; + border-bottom: 1px solid var(--stroke); +} + +.detailCard:last-child { + border-bottom: none; + padding-bottom: 0; +} + .detailName { font-size: 18px; font-weight: 900; diff --git a/app/renderer/npcs/NpcsApp.tsx b/app/renderer/npcs/NpcsApp.tsx index f9c2332..4ef2886 100644 --- a/app/renderer/npcs/NpcsApp.tsx +++ b/app/renderer/npcs/NpcsApp.tsx @@ -52,13 +52,11 @@ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] { function RuntimeNpcTile({ npc, selected, - active, accentColor, onActivate, }: { npc: ProjectNpc; selected: boolean; - active: boolean; accentColor?: string | null; onActivate: () => void; }) { @@ -66,9 +64,7 @@ function RuntimeNpcTile({ return ( - {activeId ? ( + {hasActive ? ( + ) : null} + + ); +} diff --git a/app/shared/types/npcs.ts b/app/shared/types/npcs.ts index d7cb48c..6e30441 100644 --- a/app/shared/types/npcs.ts +++ b/app/shared/types/npcs.ts @@ -9,11 +9,14 @@ export type NpcsOverlayLayout = { export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null; -/** Session-only: какой НПС сейчас показан поверх сцены (только аватар). */ +/** Session-only: какие НПС сейчас показаны поверх сцены (только аватары). */ export type NpcsOverlayState = { revision: number; - activeNpcId: NpcId | null; - layout: NpcsOverlayLayout; + /** Порядок выделения / показа описаний. */ + activeNpcIds: NpcId[]; + layouts: Record; + /** На кого действует zoom, если клик не попал в конкретный аватар. */ + focusNpcId: NpcId | null; zoomTool: NpcsZoomTool; }; @@ -27,9 +30,9 @@ export type NpcsOverlayEvent = | { kind: 'show'; npcId: NpcId } | { kind: 'hide' } | { kind: 'toggle'; npcId: NpcId } - | { kind: 'layout.set'; layout: NpcsOverlayLayout } + | { kind: 'layout.set'; npcId: NpcId; layout: NpcsOverlayLayout } | { kind: 'zoomTool.set'; tool: NpcsZoomTool } - | { kind: 'zoomAt'; nx: number; ny: number }; + | { kind: 'zoomAt'; nx: number; ny: number; npcId?: NpcId }; export function clampNpcsLayout(layout: NpcsOverlayLayout): NpcsOverlayLayout { return {