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:
@@ -451,6 +451,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
externalUrl: '',
|
||||
});
|
||||
npcIndex += 1;
|
||||
}
|
||||
|
||||
+9
-5
@@ -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 } : {}),
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user