feat(npcs): inspect cards, Show overlay, and optional external link

Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-19 10:42:57 +08:00
parent 1a9f0f2557
commit 0f4bb76b41
35 changed files with 1189 additions and 259 deletions
+1
View File
@@ -451,6 +451,7 @@ export async function buildProjectFromFoundryDocuments(
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
});
npcIndex += 1;
}
+9 -5
View File
@@ -124,6 +124,11 @@ if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GP
app.disableHardwareAcceleration();
}
if (process.platform === 'darwin') {
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy');
}
if (process.platform === 'win32') {
app.setAppUserModelId('com.ttrpgplayer.app');
}
@@ -368,9 +373,7 @@ function syncSceneTrapsForProject(project: Project): void {
function emitVideoState(): void {
const state = videoStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.video.stateChanged, { state });
}
sendToAppWindows(ipcChannels.video.stateChanged, { state }, ['control', 'presentation']);
}
// Периодически чистим истёкшие эффекты (в основном молнии).
@@ -620,7 +623,7 @@ async function main() {
openNpcsWindow();
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
if (npcId) {
npcsOverlayStore.dispatch({ kind: 'show', npcId });
npcsOverlayStore.dispatch({ kind: 'inspect.show', npcId });
emitNpcsOverlayState();
}
return { ok: true };
@@ -981,10 +984,11 @@ async function main() {
);
registerHandler(
ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, groupId, ringColor, disposition, imageOffset, imageScale }) => {
async ({ npcId, name, description, externalUrl, groupId, ringColor, disposition, imageOffset, imageScale }) => {
const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
...(typeof externalUrl === 'string' ? { externalUrl } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(disposition !== undefined ? { disposition } : {}),
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { asNpcId } from '../../shared/types/ids';
import { NpcsOverlayStore } from './npcsOverlayStore';
const a = asNpcId('npc_a');
const b = asNpcId('npc_b');
void test('NpcsOverlayStore: inspect is independent from overlay show', () => {
const store = new NpcsOverlayStore();
store.dispatch({ kind: 'inspect.toggle', npcId: a });
store.dispatch({ kind: 'inspect.show', npcId: b });
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
assert.equal(store.getState().inspectFocusNpcId, b);
assert.deepEqual(store.getState().activeNpcIds, []);
store.dispatch({ kind: 'show', npcId: a });
assert.deepEqual(store.getState().activeNpcIds, [a]);
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
store.dispatch({ kind: 'hide' });
assert.deepEqual(store.getState().activeNpcIds, []);
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
store.dispatch({ kind: 'inspect.toggle', npcId: a });
assert.deepEqual(store.getState().inspectNpcIds, [b]);
});
void test('NpcsOverlayStore: clear wipes inspect; ensureNpcStillExists prunes', () => {
const store = new NpcsOverlayStore();
store.dispatch({ kind: 'inspect.show', npcId: a });
store.dispatch({ kind: 'show', npcId: b });
store.ensureNpcStillExists(new Set([a]));
assert.deepEqual(store.getState().inspectNpcIds, [a]);
assert.deepEqual(store.getState().activeNpcIds, []);
store.clear();
assert.deepEqual(store.getState().inspectNpcIds, []);
});
+85 -6
View File
@@ -16,6 +16,8 @@ function emptyState(): NpcsOverlayState {
layouts: {},
focusNpcId: null,
zoomTool: null,
inspectNpcIds: [],
inspectFocusNpcId: null,
};
}
@@ -23,6 +25,15 @@ function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
}
function addInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
if (ids.includes(npcId)) return ids;
return [...ids, npcId];
}
function removeInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
return ids.filter((id) => id !== npcId);
}
export class NpcsOverlayStore {
private state: NpcsOverlayState = emptyState();
@@ -30,11 +41,29 @@ export class NpcsOverlayStore {
return this.state;
}
/** Полный сброс (закрытие проекта / конец сессии). */
clear(): NpcsOverlayState {
const s = this.state;
if (
s.activeNpcIds.length === 0 &&
s.zoomTool === null &&
s.inspectNpcIds.length === 0 &&
s.inspectFocusNpcId === null
) {
return s;
}
this.state = emptyState();
this.state.revision = s.revision + 1;
return this.state;
}
/** Снять аватары с экрана, карточки описания оставить. */
hideAvatars(): NpcsOverlayState {
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [],
layouts: {},
@@ -47,7 +76,7 @@ export class NpcsOverlayStore {
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
return this.hideAvatars();
case 'show': {
if (this.state.activeNpcIds.includes(event.npcId)) {
this.state = {
@@ -58,6 +87,7 @@ export class NpcsOverlayStore {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
@@ -65,7 +95,6 @@ export class NpcsOverlayStore {
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
@@ -79,6 +108,7 @@ export class NpcsOverlayStore {
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
: this.state.focusNpcId;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds,
layouts,
@@ -88,6 +118,7 @@ export class NpcsOverlayStore {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
@@ -95,7 +126,38 @@ export class NpcsOverlayStore {
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'inspect.toggle': {
if (this.state.inspectNpcIds.includes(event.npcId)) {
const inspectNpcIds = removeInspect(this.state.inspectNpcIds, event.npcId);
const inspectFocusNpcId =
this.state.inspectFocusNpcId === event.npcId
? (inspectNpcIds[inspectNpcIds.length - 1] ?? null)
: this.state.inspectFocusNpcId;
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds,
inspectFocusNpcId,
};
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
inspectFocusNpcId: event.npcId,
};
return this.state;
}
case 'inspect.show': {
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
inspectFocusNpcId: event.npcId,
};
return this.state;
}
@@ -157,8 +219,19 @@ export class NpcsOverlayStore {
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
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 inspectNpcIds = this.state.inspectNpcIds.filter((id) => npcIds.has(id));
const sameActive = activeNpcIds.length === this.state.activeNpcIds.length;
const sameInspect = inspectNpcIds.length === this.state.inspectNpcIds.length;
if (sameActive && sameInspect) return this.state;
if (activeNpcIds.length === 0 && inspectNpcIds.length === 0) {
const zoomTool = this.state.zoomTool;
this.state = {
...emptyState(),
revision: this.state.revision + 1,
zoomTool,
};
return this.state;
}
const layouts: Record<string, NpcsOverlayLayout> = {};
for (const id of activeNpcIds) {
const layout = this.state.layouts[id];
@@ -168,12 +241,18 @@ export class NpcsOverlayStore {
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
? this.state.focusNpcId
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
const inspectFocusNpcId =
this.state.inspectFocusNpcId && inspectNpcIds.includes(this.state.inspectFocusNpcId)
? this.state.inspectFocusNpcId
: (inspectNpcIds[inspectNpcIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: this.state.zoomTool,
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
inspectNpcIds,
inspectFocusNpcId,
};
return this.state;
}
@@ -80,6 +80,11 @@ void test('zipStore: normalizeScene defaults previewThumbAssetId for older proje
assert.match(src, /function normalizeScene\(/);
});
void test('zipStore: NPC parse defaults externalUrl', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
});
void test('zipStore: listProjects skips unreadable archives', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(
+11
View File
@@ -66,6 +66,7 @@ import {
npcDispositionRingColor,
type NpcDisposition,
} from '../../shared/types/npcDisposition';
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import {
asPreviewRotationDeg,
@@ -1396,6 +1397,7 @@ export class ZipProjectStore {
npcId?: NpcId;
name: string;
description?: string;
externalUrl?: string;
filePath?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
@@ -1477,6 +1479,9 @@ export class ZipProjectStore {
name,
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
description: typeof input.description === 'string' ? input.description : prev.description,
...(input.externalUrl !== undefined
? { externalUrl: normalizeNpcExternalUrl(input.externalUrl) }
: {}),
groupId: resolveGroup(input.groupId, prev.groupId),
...(input.ringColor !== undefined
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
@@ -1503,6 +1508,7 @@ export class ZipProjectStore {
name,
avatarAssetId: nextAssetId,
description: typeof input.description === 'string' ? input.description : '',
externalUrl: normalizeNpcExternalUrl(input.externalUrl),
x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null),
@@ -1526,6 +1532,7 @@ export class ZipProjectStore {
patch: {
name?: string;
description?: string;
externalUrl?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
@@ -1559,6 +1566,9 @@ export class ZipProjectStore {
...n,
...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
...(patch.externalUrl !== undefined
? { externalUrl: normalizeNpcExternalUrl(patch.externalUrl) }
: {}),
groupId,
disposition,
ringColor:
@@ -2650,6 +2660,7 @@ function normalizeProject(p: Project): Project {
name,
avatarAssetId: obj.avatarAssetId,
description: typeof obj.description === 'string' ? obj.description : '',
externalUrl: normalizeNpcExternalUrl((obj as { externalUrl?: unknown }).externalUrl),
x,
y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
@@ -52,3 +52,14 @@ void test('session IPC: нет emitSessionState на graph/NPC position hot-path
);
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
});
void test('video IPC heartbeat does not fan out to every BrowserWindow', () => {
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
const emit =
/function emitVideoState\(\): void \{([\s\S]*?)\n\}/.exec(index);
assert.ok(emit, 'emitVideoState');
assert.doesNotMatch(emit[1] ?? '', /getAllWindows/);
assert.match(emit[1] ?? '', /sendToAppWindows\(ipcChannels\.video\.stateChanged/);
assert.match(emit[1] ?? '', /'control'/);
assert.match(emit[1] ?? '', /'presentation'/);
});
@@ -61,6 +61,8 @@ void test('createWindows: окно НПС закрывается с multi-window
assert.ok(src.includes("createWindow('npcs'"));
assert.ok(src.includes("createWindow('npcsEditor'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
assert.ok(src.includes('webviewTag: true'));
assert.ok(src.includes('will-attach-webview'));
});
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
+27 -7
View File
@@ -54,9 +54,9 @@ const MATERIALS_WINDOW_HEIGHT = 720;
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
/** Пульт НПС: деталь слева + список справа. */
const NPCS_WINDOW_WIDTH = 720;
const NPCS_WINDOW_HEIGHT = 720;
/** Пульт НПС: деталь слева (webview) + список справа. */
const NPCS_WINDOW_WIDTH = 960;
const NPCS_WINDOW_HEIGHT = 800;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void {
@@ -340,9 +340,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
? {
width: NPCS_WINDOW_WIDTH,
height: NPCS_WINDOW_HEIGHT,
minWidth: 560,
maxWidth: 900,
minHeight: 480,
minWidth: 720,
maxWidth: 1280,
minHeight: 560,
}
: {}),
show: false,
@@ -358,6 +358,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoplayPolicy: 'no-user-gesture-required',
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
...(kind === 'npcs'
? {
webviewTag: true,
// webviewTag в Electron не сочетается с sandbox хоста.
sandbox: false,
}
: {}),
},
});
if (icon) {
@@ -369,6 +376,19 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
bindWindowChromeTitle(win, kind);
if (kind === 'npcs') {
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
delete guestPrefs.preload;
guestPrefs.nodeIntegration = false;
guestPrefs.contextIsolation = true;
guestPrefs.sandbox = true;
guestPrefs.webSecurity = true;
const src = String(params.src ?? '');
if (!src.startsWith('https:') && !src.startsWith('http:')) {
event.preventDefault();
}
});
}
if (
kind === 'sceneDescription' ||
kind === 'materials' ||
@@ -799,7 +819,7 @@ export function openNpcsWindow(): void {
existing.setBounds({
x: b.x,
y: b.y,
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
width: Math.min(1280, Math.max(720, b.width)),
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
});
existing.show();