From cc50e64e21de1c15114ca7549d1825dbab84da75 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 30 Jul 2026 17:11:01 +0800 Subject: [PATCH] feat(players): NPC disposition types and launch-with-players session tokens Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map. Co-authored-by: Cursor --- app/main/foundry/foundryImport.ts | 1 + app/main/index.ts | 40 +++- .../sceneNpcTokensSessionStore.test.ts | 28 +++ .../players/sceneNpcTokensSessionStore.ts | 82 ++++++++- .../scenePlayerTokensSessionStore.test.ts | 39 ++++ .../players/scenePlayerTokensSessionStore.ts | 143 ++++++++++++++ app/main/project/zipStore.ts | 37 +++- app/renderer/control/ControlApp.module.css | 40 ++++ app/renderer/control/ControlApp.tsx | 145 ++++++++++++++- app/renderer/editor/EditorApp.module.css | 62 +++++++ app/renderer/editor/EditorApp.tsx | 119 ++++++++++-- .../editor/LaunchPlayersModal.module.css | 63 +++++++ app/renderer/editor/LaunchPlayersModal.tsx | 165 +++++++++++++++++ app/renderer/editor/i18n/editorMessages.ts | 50 ++++- app/renderer/npcs/NpcsEditorApp.tsx | 21 ++- .../sceneEditor/SceneEditorApp.module.css | 46 +++++ app/renderer/sceneEditor/SceneEditorApp.tsx | 91 ++++++++- app/renderer/shared/PresentationView.tsx | 15 ++ .../shared/playerToken/PlayerTokenView.tsx | 6 +- .../playerToken/SceneNpcTokensOverlay.tsx | 44 ++++- .../playerToken/ScenePlayerTokensOverlay.tsx | 174 ++++++++++++++++++ .../playerToken/useLiveDragBroadcast.ts | 48 +++++ .../playerToken/useSceneNpcTokensSession.ts | 66 ++++++- .../useScenePlayerTokensSession.ts | 102 ++++++++++ .../ui/secondaryWindows.stability.test.ts | 13 +- .../graph/storylineExportImport.test.ts | 15 +- app/shared/graph/storylineExportImport.ts | 1 + app/shared/ipc/contracts.players.test.ts | 1 + app/shared/ipc/contracts.ts | 22 ++- .../players/launchPlayersSelection.test.ts | 43 +++++ app/shared/players/resolveLaunchPlayerIds.ts | 15 ++ app/shared/types/appPlayers.ts | 55 +++++- app/shared/types/domain.ts | 7 +- app/shared/types/index.ts | 1 + app/shared/types/npcDisposition.test.ts | 28 +++ app/shared/types/npcDisposition.ts | 29 +++ e2e/scene-npc.spec.ts | 3 +- package.json | 2 +- 38 files changed, 1803 insertions(+), 59 deletions(-) create mode 100644 app/main/players/scenePlayerTokensSessionStore.test.ts create mode 100644 app/main/players/scenePlayerTokensSessionStore.ts create mode 100644 app/renderer/editor/LaunchPlayersModal.module.css create mode 100644 app/renderer/editor/LaunchPlayersModal.tsx create mode 100644 app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx create mode 100644 app/renderer/shared/playerToken/useLiveDragBroadcast.ts create mode 100644 app/renderer/shared/playerToken/useScenePlayerTokensSession.ts create mode 100644 app/shared/players/launchPlayersSelection.test.ts create mode 100644 app/shared/players/resolveLaunchPlayerIds.ts create mode 100644 app/shared/types/npcDisposition.test.ts create mode 100644 app/shared/types/npcDisposition.ts diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index 3f68e3f..b452fe8 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -448,6 +448,7 @@ export async function buildProjectFromFoundryDocuments( y: 80 + Math.floor(npcIndex / 4) * 200, groupId, ringColor: '#c9a227', + disposition: 'neutral', imageOffset: { x: 0, y: 0 }, imageScale: 1, }); diff --git a/app/main/index.ts b/app/main/index.ts index 86f0541..3984aa0 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -31,6 +31,7 @@ import { SceneViewStore } from './sceneView/sceneViewStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { PlayersStore } from './players/playersStore'; import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore'; +import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore'; import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore'; import { TokensStore } from './tokens/tokensStore'; import { installAutoUpdater } from './update/installAutoUpdater'; @@ -174,6 +175,7 @@ const materialsOverlayStore = new MaterialsOverlayStore(); const npcsOverlayStore = new NpcsOverlayStore(); const sceneTokensSessionStore = new SceneTokensSessionStore(); const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore(); +const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore(); let tokensStore: TokensStore | null = null; let playersStore: PlayersStore | null = null; @@ -265,6 +267,13 @@ function emitSceneNpcTokensSessionState(): void { } } +function emitScenePlayerTokensSessionState(): void { + const state = scenePlayerTokensSessionStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.scenePlayerTokensSession.stateChanged, { state }); + } +} + function syncSceneDarknessForProject(project: Project): void { const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; @@ -435,11 +444,16 @@ async function main() { registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token)); registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); - registerHandler(ipcChannels.windows.openMultiWindow, () => { + registerHandler(ipcChannels.windows.openMultiWindow, (req) => { sceneDarknessStore.resetSession(); sceneTrapsStore.resetSession(); sceneTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset(); + scenePlayerTokensSessionStore.reset(); + const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : []; + if (playerIds.length > 0) { + scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds }); + } effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() }); openMultiWindow(); const project = projectStore.getOpenProject(); @@ -451,6 +465,7 @@ async function main() { emitSceneTrapsState(); emitSceneTokensSessionState(); emitSceneNpcTokensSessionState(); + emitScenePlayerTokensSessionState(); emitEffectsState(); return { ok: true }; }); @@ -548,9 +563,11 @@ async function main() { sceneViewStore.reset(); sceneTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset(); + scenePlayerTokensSessionStore.reset(); emitSceneViewState(); emitSceneTokensSessionState(); emitSceneNpcTokensSessionState(); + emitScenePlayerTokensSessionState(); emitSessionState(); warmNpcsEditorWindow(); return { project }; @@ -566,6 +583,7 @@ async function main() { sceneViewStore.reset(); sceneTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset(); + scenePlayerTokensSessionStore.reset(); emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); @@ -574,6 +592,7 @@ async function main() { emitSceneViewState(); emitSceneTokensSessionState(); emitSceneNpcTokensSessionState(); + emitScenePlayerTokensSessionState(); emitSessionState(); return { ok: true }; }); @@ -590,6 +609,7 @@ async function main() { materialsOverlayStore.clear(); npcsOverlayStore.clear(); sceneViewStore.reset(); + scenePlayerTokensSessionStore.clearPlacements(); // Token moves persist for the whole play session (reset only on project open/close). const project = projectStore.getOpenProject(); if (project) { @@ -603,6 +623,7 @@ async function main() { emitSceneTrapsState(); emitSceneViewState(); emitSceneTokensSessionState(); + emitScenePlayerTokensSessionState(); emitSessionState(); return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; }); @@ -619,6 +640,7 @@ async function main() { materialsOverlayStore.clear(); npcsOverlayStore.clear(); sceneViewStore.reset(); + scenePlayerTokensSessionStore.clearPlacements(); // Token moves persist for the whole play session (reset only on project open/close). const project = projectStore.getOpenProject(); if (project) { @@ -632,6 +654,7 @@ async function main() { emitSceneTrapsState(); emitSceneViewState(); emitSceneTokensSessionState(); + emitScenePlayerTokensSessionState(); emitSessionState(); const p = projectStore.getOpenProject(); return { @@ -784,7 +807,7 @@ async function main() { }); registerHandler( ipcChannels.project.upsertNpc, - async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, imageOffset }) => { + async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => { let filePath = pathFromDrop; if (!filePath && !npcId) { const { canceled, filePaths } = await dialog.showOpenDialog({ @@ -809,6 +832,7 @@ async function main() { ...(filePath ? { filePath } : {}), ...(groupId !== undefined ? { groupId } : {}), ...(ringColor !== undefined ? { ringColor } : {}), + ...(disposition !== undefined ? { disposition } : {}), ...(imageOffset !== undefined ? { imageOffset } : {}), }, (p) => emitNpcUpsertProgress(p), @@ -821,13 +845,15 @@ async function main() { ); registerHandler( ipcChannels.project.updateNpcFields, - async ({ npcId, name, description, groupId, ringColor, imageOffset }) => { + async ({ npcId, name, description, groupId, ringColor, disposition, imageOffset, imageScale }) => { const project = await projectStore.updateNpcFields(npcId, { ...(typeof name === 'string' ? { name } : {}), ...(typeof description === 'string' ? { description } : {}), ...(groupId !== undefined ? { groupId } : {}), ...(ringColor !== undefined ? { ringColor } : {}), + ...(disposition !== undefined ? { disposition } : {}), ...(imageOffset !== undefined ? { imageOffset } : {}), + ...(imageScale !== undefined ? { imageScale } : {}), }); emitSessionState(); return { project }; @@ -1428,6 +1454,14 @@ async function main() { emitSceneNpcTokensSessionState(); return { ok: true }; }); + registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => { + return { state: scenePlayerTokensSessionStore.getState() }; + }); + registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => { + scenePlayerTokensSessionStore.dispatch(event); + emitScenePlayerTokensSessionState(); + return { ok: true }; + }); registerHandler(ipcChannels.video.getState, () => { return { state: videoStore.getState() }; diff --git a/app/main/players/sceneNpcTokensSessionStore.test.ts b/app/main/players/sceneNpcTokensSessionStore.test.ts index 5965fe0..139f310 100644 --- a/app/main/players/sceneNpcTokensSessionStore.test.ts +++ b/app/main/players/sceneNpcTokensSessionStore.test.ts @@ -30,3 +30,31 @@ void test('SceneNpcTokensSessionStore setScale', () => { assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE); assert.deepEqual(s3.byPlacementId, {}); }); + +void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => { + const store = new SceneNpcTokensSessionStore(); + const s1 = store.dispatch({ + kind: 'setDisposition', + placementId: 'a', + disposition: 'hostile', + }); + assert.equal(s1.byPlacementId.a?.disposition, 'hostile'); + assert.equal(s1.byPlacementId.a?.nx, undefined); + assert.equal(s1.byPlacementId.a?.ny, undefined); + const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 }); + assert.equal(s2.byPlacementId.a?.nx, 0.4); + assert.equal(s2.byPlacementId.a?.disposition, 'hostile'); +}); + +void test('SceneNpcTokensSessionStore setInactive is session flag', () => { + const store = new SceneNpcTokensSessionStore(); + const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true }); + assert.equal(s1.byPlacementId.a?.inactive, true); + const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false }); + assert.deepEqual(s2.byPlacementId, {}); + store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 }); + store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true }); + const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false }); + assert.equal(s3.byPlacementId.a?.nx, 0.2); + assert.equal(s3.byPlacementId.a?.inactive, undefined); +}); diff --git a/app/main/players/sceneNpcTokensSessionStore.ts b/app/main/players/sceneNpcTokensSessionStore.ts index 35c3674..935896b 100644 --- a/app/main/players/sceneNpcTokensSessionStore.ts +++ b/app/main/players/sceneNpcTokensSessionStore.ts @@ -2,8 +2,10 @@ import { clampNpcTokenSessionScale, DEFAULT_NPC_TOKEN_SESSION_SCALE, type SceneNpcTokensSessionEvent, + type SceneNpcTokensSessionPlacement, type SceneNpcTokensSessionState, } from '../../shared/types/appPlayers'; +import { normalizeNpcDisposition } from '../../shared/types/npcDisposition'; function emptyState(revision = 1): SceneNpcTokensSessionState { return { @@ -13,6 +15,36 @@ function emptyState(revision = 1): SceneNpcTokensSessionState { }; } +function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean { + return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive; +} + +function withMove( + prev: SceneNpcTokensSessionPlacement | undefined, + nx: number, + ny: number, +): SceneNpcTokensSessionPlacement { + return { + nx, + ny, + ...(prev?.disposition ? { disposition: prev.disposition } : {}), + ...(prev?.inactive ? { inactive: true } : {}), + }; +} + +function withoutPlacement( + state: SceneNpcTokensSessionState, + placementId: string, +): SceneNpcTokensSessionState { + if (!(placementId in state.byPlacementId)) return state; + const { [placementId]: _removed, ...rest } = state.byPlacementId; + return { + revision: state.revision + 1, + byPlacementId: rest, + scale: state.scale, + }; +} + export class SceneNpcTokensSessionStore { private state: SceneNpcTokensSessionState = emptyState(); @@ -57,7 +89,55 @@ export class SceneNpcTokensSessionStore { revision: this.state.revision + 1, byPlacementId: { ...this.state.byPlacementId, - [placementId]: { nx, ny }, + [placementId]: withMove(prev, nx, ny), + }, + scale: this.state.scale, + }; + return this.state; + } + case 'setDisposition': { + const placementId = String(event.placementId ?? ''); + if (!placementId) return this.state; + const disposition = normalizeNpcDisposition(event.disposition); + const prev = this.state.byPlacementId[placementId]; + if (prev?.disposition === disposition) return this.state; + const next: SceneNpcTokensSessionPlacement = { + ...(prev?.nx !== undefined ? { nx: prev.nx } : {}), + ...(prev?.ny !== undefined ? { ny: prev.ny } : {}), + disposition, + ...(prev?.inactive ? { inactive: true } : {}), + }; + this.state = { + revision: this.state.revision + 1, + byPlacementId: { + ...this.state.byPlacementId, + [placementId]: next, + }, + scale: this.state.scale, + }; + return this.state; + } + case 'setInactive': { + const placementId = String(event.placementId ?? ''); + if (!placementId) return this.state; + const inactive = Boolean(event.inactive); + const prev = this.state.byPlacementId[placementId]; + if (Boolean(prev?.inactive) === inactive) return this.state; + const next: SceneNpcTokensSessionPlacement = { + ...(prev?.nx !== undefined ? { nx: prev.nx } : {}), + ...(prev?.ny !== undefined ? { ny: prev.ny } : {}), + ...(prev?.disposition ? { disposition: prev.disposition } : {}), + ...(inactive ? { inactive: true } : {}), + }; + if (isEmptyPlacement(next)) { + this.state = withoutPlacement(this.state, placementId); + return this.state; + } + this.state = { + revision: this.state.revision + 1, + byPlacementId: { + ...this.state.byPlacementId, + [placementId]: next, }, scale: this.state.scale, }; diff --git a/app/main/players/scenePlayerTokensSessionStore.test.ts b/app/main/players/scenePlayerTokensSessionStore.test.ts new file mode 100644 index 0000000..45924c3 --- /dev/null +++ b/app/main/players/scenePlayerTokensSessionStore.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ScenePlayerTokensSessionStore } from './scenePlayerTokensSessionStore'; + +void test('ScenePlayerTokensSessionStore selection and show/hide', () => { + const store = new ScenePlayerTokensSessionStore(); + store.dispatch({ kind: 'setSelection', playerIds: ['a', 'b', 'a'] }); + assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']); + assert.equal(store.getState().visible, false); + + store.dispatch({ kind: 'show', sizeN: 0.08 }); + assert.equal(store.getState().visible, true); + assert.equal(Object.keys(store.getState().byPlayerId).length, 2); + assert.ok(store.getState().byPlayerId.a); + assert.equal(store.getState().byPlayerId.a?.ny, 0.88); + + store.dispatch({ kind: 'setVisible', visible: false }); + assert.equal(store.getState().visible, false); + assert.ok(store.getState().byPlayerId.a); + + store.dispatch({ kind: 'clearPlacements' }); + assert.equal(store.getState().visible, false); + assert.deepEqual(store.getState().byPlayerId, {}); + assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']); +}); + +void test('ScenePlayerTokensSessionStore move and reset', () => { + const store = new ScenePlayerTokensSessionStore(); + store.dispatch({ kind: 'setSelection', playerIds: ['p1'] }); + store.dispatch({ kind: 'show', sizeN: 0.1 }); + const s1 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.3, ny: 0.4 }); + assert.equal(s1.byPlayerId.p1?.nx, 0.3); + assert.equal(s1.byPlayerId.p1?.ny, 0.4); + const s2 = store.reset(); + assert.deepEqual(s2.selectedPlayerIds, []); + assert.equal(s2.visible, false); + assert.deepEqual(s2.byPlayerId, {}); +}); diff --git a/app/main/players/scenePlayerTokensSessionStore.ts b/app/main/players/scenePlayerTokensSessionStore.ts new file mode 100644 index 0000000..a23b98a --- /dev/null +++ b/app/main/players/scenePlayerTokensSessionStore.ts @@ -0,0 +1,143 @@ +import { + clampSceneNpcTokenSizeN, + DEFAULT_SCENE_NPC_TOKEN_SIZE_N, + layoutPlayerTokensBottom, + type ScenePlayerTokensSessionEvent, + type ScenePlayerTokensSessionState, +} from '../../shared/types/appPlayers'; + +function emptyState(revision = 1): ScenePlayerTokensSessionState { + return { + revision, + selectedPlayerIds: [], + visible: false, + byPlayerId: {}, + }; +} + +function normalizePlayerIds(raw: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const id of raw) { + const s = String(id ?? '').trim(); + if (!s || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out; +} + +export class ScenePlayerTokensSessionStore { + private state: ScenePlayerTokensSessionState = emptyState(); + + getState(): ScenePlayerTokensSessionState { + return this.state; + } + + reset(): ScenePlayerTokensSessionState { + if ( + this.state.selectedPlayerIds.length === 0 && + !this.state.visible && + Object.keys(this.state.byPlayerId).length === 0 + ) { + return this.state; + } + this.state = emptyState(this.state.revision + 1); + return this.state; + } + + /** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */ + clearPlacements(): ScenePlayerTokensSessionState { + if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) { + return this.state; + } + this.state = { + ...this.state, + revision: this.state.revision + 1, + visible: false, + byPlayerId: {}, + }; + return this.state; + } + + dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState { + switch (event.kind) { + case 'clear': + return this.reset(); + case 'clearPlacements': + return this.clearPlacements(); + case 'setSelection': { + const selectedPlayerIds = normalizePlayerIds(event.playerIds); + const same = + selectedPlayerIds.length === this.state.selectedPlayerIds.length && + selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]); + if (same) return this.state; + this.state = { + revision: this.state.revision + 1, + selectedPlayerIds, + visible: false, + byPlayerId: {}, + }; + return this.state; + } + case 'setVisible': { + const visible = Boolean(event.visible); + if (this.state.visible === visible) return this.state; + if (visible && this.state.selectedPlayerIds.length === 0) return this.state; + this.state = { ...this.state, revision: this.state.revision + 1, visible }; + return this.state; + } + case 'show': { + if (this.state.selectedPlayerIds.length === 0) return this.state; + const sizeN = clampSceneNpcTokenSizeN(event.sizeN); + const byPlayerId = + Object.keys(this.state.byPlayerId).length > 0 + ? this.state.byPlayerId + : layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN); + if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state; + this.state = { + ...this.state, + revision: this.state.revision + 1, + visible: true, + byPlayerId, + }; + return this.state; + } + case 'seedBottom': { + if (this.state.selectedPlayerIds.length === 0) return this.state; + if (Object.keys(this.state.byPlayerId).length > 0) return this.state; + const sizeN = clampSceneNpcTokenSizeN(event.sizeN); + this.state = { + ...this.state, + revision: this.state.revision + 1, + byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN), + }; + return this.state; + } + case 'move': { + const playerId = String(event.playerId ?? ''); + if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state; + const nx = Math.max(0, Math.min(1, event.nx)); + const ny = Math.max(0, Math.min(1, event.ny)); + if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state; + const prev = this.state.byPlayerId[playerId]; + if (prev && prev.nx === nx && prev.ny === ny) return this.state; + const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N); + this.state = { + ...this.state, + revision: this.state.revision + 1, + byPlayerId: { + ...this.state.byPlayerId, + [playerId]: { nx, ny, sizeN }, + }, + }; + return this.state; + } + default: { + const _exhaustive: never = event; + void _exhaustive; + return this.state; + } + } + } +} diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 29df496..e69e2f4 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -60,6 +60,12 @@ import { normalizeSceneNpcToken, type SceneNpcToken, } from '../../shared/types/appPlayers'; +import { + DEFAULT_NPC_DISPOSITION, + normalizeNpcDisposition, + npcDispositionRingColor, + type NpcDisposition, +} from '../../shared/types/npcDisposition'; import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid'; import { normalizeSceneTrap } from '../../shared/types/sceneTraps'; import type { @@ -1263,6 +1269,7 @@ export class ZipProjectStore { filePath?: string; groupId?: NpcGroupId | null; ringColor?: string; + disposition?: NpcDisposition; imageOffset?: { x: number; y: number }; imageScale?: number; }, @@ -1344,6 +1351,12 @@ export class ZipProjectStore { ...(input.ringColor !== undefined ? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) } : {}), + ...(input.disposition !== undefined + ? { + disposition: normalizeNpcDisposition(input.disposition), + ringColor: npcDispositionRingColor(normalizeNpcDisposition(input.disposition)), + } + : {}), ...(input.imageOffset !== undefined ? { imageOffset: clampPlayerImageOffset(input.imageOffset) } : {}), @@ -1354,6 +1367,7 @@ export class ZipProjectStore { } else { if (!nextAssetId) throw new Error('NPC avatar is required'); const count = npcs.length; + const disposition = normalizeNpcDisposition(input.disposition ?? DEFAULT_NPC_DISPOSITION); npcs.push({ id: asNpcId(`npc_${this.randomId()}`), name, @@ -1362,7 +1376,8 @@ export class ZipProjectStore { x: 80 + (count % 4) * 220, y: 80 + Math.floor(count / 4) * 200, groupId: resolveGroup(input.groupId, null), - ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR), + disposition, + ringColor: npcDispositionRingColor(disposition), imageOffset: clampPlayerImageOffset(input.imageOffset), imageScale: clampPlayerImageScale(input.imageScale), }); @@ -1383,6 +1398,7 @@ export class ZipProjectStore { description?: string; groupId?: NpcGroupId | null; ringColor?: string; + disposition?: NpcDisposition; imageOffset?: { x: number; y: number }; imageScale?: number; }, @@ -1405,14 +1421,22 @@ export class ZipProjectStore { if (patch.groupId !== undefined) { groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null; } + const disposition = + patch.disposition !== undefined + ? normalizeNpcDisposition(patch.disposition) + : normalizeNpcDisposition(n.disposition); return { ...n, ...(name !== undefined ? { name } : {}), ...(typeof patch.description === 'string' ? { description: patch.description } : {}), groupId, - ...(patch.ringColor !== undefined - ? { ringColor: normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR) } - : {}), + disposition, + ringColor: + patch.disposition !== undefined + ? npcDispositionRingColor(disposition) + : patch.ringColor !== undefined + ? normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR) + : n.ringColor || npcDispositionRingColor(disposition), ...(patch.imageOffset !== undefined ? { imageOffset: clampPlayerImageOffset(patch.imageOffset) } : {}), @@ -2495,7 +2519,10 @@ function normalizeProject(p: Project): Project { x, y, groupId: resolveNpcGroupId(obj.groupId, groupIdSet), - ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR), + disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition), + ringColor: npcDispositionRingColor( + normalizeNpcDisposition((obj as { disposition?: unknown }).disposition), + ), imageOffset: clampPlayerImageOffset(obj.imageOffset), imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale), }; diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index 96bfebb..e4ad336 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -595,3 +595,43 @@ .audioScrubDefault { cursor: default; } + +.ctxMenuBackdrop { + position: fixed; + inset: 0; + z-index: 40; + border: none; + padding: 0; + margin: 0; + background: transparent; + cursor: default; +} + +.ctxMenu { + position: fixed; + z-index: 41; + min-width: 200px; + padding: 6px; + border-radius: 8px; + border: 1px solid var(--stroke, #2a2f3a); + background: var(--color-surface-menu, #1a1e28); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + display: grid; + gap: 2px; +} + +.ctxItem { + text-align: left; + padding: 8px 10px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text1, #e8eaef); + font-size: 13px; + cursor: pointer; + width: 100%; +} + +.ctxItem:hover { + background: rgba(255, 255, 255, 0.06); +} diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index e729710..8e46730 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { ipcChannels } from '../../shared/ipc/contracts'; @@ -8,19 +9,27 @@ import { isNodeInSideStoryline, listSideStoryStarts, } from '../../shared/graph/sceneGraphLineage'; -import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types'; +import type { GraphNodeId, NpcDisposition, Scene, SceneId, SceneViewCamera } from '../../shared/types'; import { DEFAULT_NPC_TOKEN_SESSION_SCALE, + DEFAULT_SCENE_NPC_TOKEN_SIZE_N, NPC_TOKEN_SESSION_SCALE_MAX, NPC_TOKEN_SESSION_SCALE_MIN, } from '../../shared/types/appPlayers'; +import { otherNpcDispositions } from '../../shared/types/npcDisposition'; import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml'; import { getDndApi } from '../shared/dndApi'; import { RotatedImage } from '../shared/RotatedImage'; -import { SceneNpcTokensOverlay } from '../shared/playerToken/SceneNpcTokensOverlay'; +import { + resolveNpcTokenDisposition, + SceneNpcTokensOverlay, +} from '../shared/playerToken/SceneNpcTokensOverlay'; +import { ScenePlayerTokensOverlay } from '../shared/playerToken/ScenePlayerTokensOverlay'; +import { useAppPlayers } from '../shared/playerToken/useAppPlayers'; import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession'; +import { useScenePlayerTokensSession } from '../shared/playerToken/useScenePlayerTokensSession'; import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay'; import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay'; import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects'; @@ -59,6 +68,12 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx'; /** Длительность молнии: быстрый удар + акцент в точке попадания. */ const LIGHTNING_EFFECT_MS = 840; +function dispositionMakeKey(d: NpcDisposition): string { + if (d === 'hostile') return 'npcs.makeHostile'; + if (d === 'friendly') return 'npcs.makeFriendly'; + return 'npcs.makeNeutral'; +} + function clampAudioGain(v: number): number { if (!Number.isFinite(v)) return 1; return Math.max(0, Math.min(1, v)); @@ -127,6 +142,13 @@ export function ControlApp() { const appTokens = useAppTokens(); const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession(); const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession(); + const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession(); + const { players: appPlayers } = useAppPlayers(); + const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{ + x: number; + y: number; + placementId: string; + } | null>(null); const [sceneView, sceneViewApi] = useSceneViewState(); const [sceneViewDraft, setSceneViewDraft] = useState(null); const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); @@ -1704,6 +1726,27 @@ export function ControlApp() { }} /> + {scenePlayerTokensSession.selectedPlayerIds.length > 0 ? ( + + ) : null} @@ -1914,11 +1957,33 @@ export function ControlApp() { library={session?.project?.npcs ?? []} session={sceneNpcTokensSession} viewport={previewContentRect} - grid={currentScene?.grid} + grid={currentScene?.grid ?? null} editable onMove={(placementId, nx, ny) => { sceneNpcTokensApi.dispatch({ kind: 'move', placementId, nx, ny }); }} + onContextMenu={(e, placement) => { + setNpcSessionCtxMenu({ + x: e.clientX, + y: e.clientY, + placementId: String(placement.id), + }); + }} + /> + ) : null} + {previewContentRect ? ( + { + scenePlayerTokensApi.dispatch({ kind: 'move', playerId, nx, ny }); + }} /> ) : null} {previewContentRect ? ( @@ -2315,6 +2380,80 @@ export function ControlApp() { ) : null} + + {npcSessionCtxMenu + ? createPortal( + <> + + ))} + + + ); + })()} + + , + document.body, + ) + : null} ); } diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index ac830cf..8424b84 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -59,6 +59,68 @@ gap: 10px; } +.splitRun { + display: inline-flex; + align-items: stretch; + height: 34px; + border-radius: var(--radius-sm); + border: 1px solid var(--accent-border); + background: var(--accent-fill-solid); + overflow: hidden; +} + +.splitRun:disabled, +.splitRun[aria-disabled='true'] { + opacity: 0.45; + pointer-events: none; +} + +.splitRunMain, +.splitRunChevron { + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.95); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.splitRunMain { + padding: 0 14px; +} + +.splitRunMain:hover:not(:disabled), +.splitRunChevron:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent-fill-solid) 72%, white); +} + +.splitRunMain:active:not(:disabled), +.splitRunChevron:active:not(:disabled), +.splitRunChevronOpen { + background: color-mix(in srgb, var(--accent-fill-solid) 78%, black); +} + +.splitRunDivider { + width: 1px; + align-self: stretch; + background: rgba(0, 0, 0, 0.28); + flex: 0 0 auto; +} + +.splitRunChevron { + width: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.splitRunChevronSvg { + width: 12px; + height: 12px; + display: block; +} + .editorSidebar { height: 100%; min-height: 0; diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 3ea5658..e046cec 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -56,6 +56,7 @@ import { useEditorI18n } from './i18n/EditorI18nContext'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals'; import { PlayersManagerModal } from './PlayersModals'; +import { LaunchPlayersModal } from './LaunchPlayersModal'; import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; import { SceneDescriptionModal } from './SceneDescriptionModal'; import type { ProjectNoticeCode } from './state/projectState'; @@ -156,6 +157,10 @@ export function EditorApp() { const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null); const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); const [playersManagerOpen, setPlayersManagerOpen] = useState(false); + const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false); + const [runMenuOpen, setRunMenuOpen] = useState(false); + const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null); + const runSplitRef = useRef(null); const [materialEdit, setMaterialEdit] = useState(null); const onProjectNotice = useCallback( (code: ProjectNoticeCode) => { @@ -353,13 +358,17 @@ export function EditorApp() { const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene'); const launchFromGraphNode = useCallback( - (graphNodeId: GraphNodeId) => { + (graphNodeId: GraphNodeId, playerIds?: string[]) => { if (!licenseActive || launching) return; setLaunching(true); + setRunMenuOpen(false); + setLaunchPlayersOpen(false); void (async () => { try { await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId }); - await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {}); + await getDndApi().invoke(ipcChannels.windows.openMultiWindow, { + ...(playerIds && playerIds.length > 0 ? { playerIds } : {}), + }); } catch { setLaunching(false); } @@ -463,6 +472,23 @@ export function EditorApp() { return () => window.removeEventListener('mousedown', onDown); }, [aboutMenuOpen]); + useEffect(() => { + if (!runMenuOpen) return; + const r = runSplitRef.current?.getBoundingClientRect() ?? null; + queueMicrotask(() => { + if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 }); + else setRunMenuPos(null); + }); + const onDown = (e: MouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + if (t.closest('[data-runmenu-root="1"]')) return; + setRunMenuOpen(false); + }; + window.addEventListener('mousedown', onDown); + return () => window.removeEventListener('mousedown', onDown); + }, [runMenuOpen]); + useEffect(() => { let off: (() => void) | null = null; void (async () => { @@ -842,16 +868,55 @@ export function EditorApp() {
{state.project ? ( <> - + + + +
{runDisabled ? ( + , + document.body, + ) + : null} void; + onConfirm: (playerIds: string[]) => void; +}) { + const { t } = useEditorI18n(); + const { players, teams } = useAppPlayers(); + const [selectedPlayers, setSelectedPlayers] = useState>(() => new Set()); + const [selectedTeams, setSelectedTeams] = useState>(() => new Set()); + + useEffect(() => { + if (!open) return; + setSelectedPlayers(new Set()); + setSelectedTeams(new Set()); + }, [open]); + + const resolvedIds = useMemo( + () => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams), + [players, selectedPlayers, selectedTeams], + ); + + const standalonePlayers = useMemo( + () => + players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))), + [players, selectedTeams], + ); + + if (!open) return null; + + const togglePlayer = (id: PlayerId) => { + setSelectedPlayers((prev) => { + const next = new Set(prev); + const key = String(id); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const toggleTeam = (id: PlayerTeamId) => { + const key = String(id); + setSelectedTeams((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + // Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор. + setSelectedPlayers((prev) => { + if (prev.size === 0) return prev; + const next = new Set(prev); + let changed = false; + for (const p of players) { + if (String(p.teamId) !== key) continue; + if (next.delete(String(p.id))) changed = true; + } + return changed ? next : prev; + }); + }; + + return createPortal( + <> +
+
+
+
{t('top.runWithPlayersTitle')}
+ +
+
+

{t('top.runWithPlayersHint')}

+ {teams.length > 0 ? ( +
+
{t('top.runWithPlayersTeams')}
+
+ {teams.map((team: AppPlayerTeam) => { + const count = players.filter((p) => p.teamId === team.id).length; + return ( + + ); + })} +
+
+ ) : null} + {standalonePlayers.length > 0 ? ( +
+
{t('top.runWithPlayersPlayers')}
+
+ {standalonePlayers.map((player: AppPlayer) => ( + + ))} +
+
+ ) : players.length === 0 ? ( +
+
{t('top.runWithPlayersEmpty')}
+
+ ) : null} +
+
+ + +
+
+ , + document.body, + ); +} diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 6481a30..1679997 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -114,6 +114,14 @@ export const EDITOR_MESSAGES: Record> = { 'top.backToProjects': 'К списку проектов', 'top.appVersion': 'Версия приложения', 'top.run': 'Запустить', + 'top.runMenuAria': 'Дополнительные варианты запуска', + 'top.runWithPlayers': 'Запустить с игроками', + 'top.runWithPlayersTitle': 'Запуск с игроками', + 'top.runWithPlayersHint': 'Выберите игроков и/или команды. Выбор сохранится на время сессии.', + 'top.runWithPlayersTeams': 'Команды', + 'top.runWithPlayersPlayers': 'Игроки', + 'top.runWithPlayersEmpty': 'Нет игроков в библиотеке.', + 'top.runWithPlayersConfirm': 'Запустить', 'top.launching': 'Запуск…', 'top.afterLicense': 'Доступно после активации лицензии', 'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)', @@ -207,7 +215,7 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.session.title': 'Запуск сессии', 'help.section.session.body': - 'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить демонстрацию» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.', + 'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.', 'help.section.controlPanel.title': 'Пульт управления', 'help.section.controlPanel.body': @@ -465,6 +473,15 @@ export const EDITOR_MESSAGES: Record> = { 'npcs.avatarRequired': 'Выберите аватар.', 'npcs.chooseAvatar': 'Выбрать аватар', 'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)', + 'npcs.disposition': 'ТИП', + 'npcs.disposition.hostile': 'Враждебный', + 'npcs.disposition.neutral': 'Нейтральный', + 'npcs.disposition.friendly': 'Дружественный', + 'npcs.makeHostile': 'Сделать враждебным', + 'npcs.makeNeutral': 'Сделать нейтральным', + 'npcs.makeFriendly': 'Сделать дружественным', + 'npcs.inactive': 'Неактивен', + 'npcs.makeActive': 'Сделать активным', 'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА', 'npcs.description': 'ОПИСАНИЕ', 'npcs.descriptionPlaceholder': 'Описание персонажа…', @@ -603,8 +620,10 @@ export const EDITOR_MESSAGES: Record> = { 'control.passed': 'Пройдено', 'control.noActiveScene': 'Нет активной сцены.', 'control.screenPreview': 'Предпросмотр экрана', - 'control.npcTokenScale': 'Размер НПС', - 'control.stopPresentation': 'Выключить демонстрацию', + 'control.npcTokenScale': 'Размеры игр. токенов', + 'control.stopPresentation': 'Выключить', + 'control.showPlayers': 'Показать игроков', + 'control.hidePlayers': 'Скрыть игроков', 'control.videoBrushHint': 'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).', 'control.branches': 'Варианты ветвления', @@ -710,6 +729,14 @@ export const EDITOR_MESSAGES: Record> = { 'top.backToProjects': 'Back to projects', 'top.appVersion': 'App version', 'top.run': 'Run', + 'top.runMenuAria': 'More launch options', + 'top.runWithPlayers': 'Run with players', + 'top.runWithPlayersTitle': 'Launch with players', + 'top.runWithPlayersHint': 'Select players and/or teams. The selection is kept for this session.', + 'top.runWithPlayersTeams': 'Teams', + 'top.runWithPlayersPlayers': 'Players', + 'top.runWithPlayersEmpty': 'No players in the library.', + 'top.runWithPlayersConfirm': 'Launch', 'top.launching': 'Starting…', 'top.afterLicense': 'Available after license activation', 'top.setStartScene': 'Set a start scene on the graph (right‑click a node)', @@ -803,7 +830,7 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.session.title': 'Starting a session', 'help.section.session.body': - 'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Stop presentation or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.', + 'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.', 'help.section.controlPanel.title': 'Control panel', 'help.section.controlPanel.body': @@ -1063,6 +1090,15 @@ export const EDITOR_MESSAGES: Record> = { 'npcs.avatarRequired': 'Choose an avatar.', 'npcs.chooseAvatar': 'Choose avatar', 'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)', + 'npcs.disposition': 'TYPE', + 'npcs.disposition.hostile': 'Hostile', + 'npcs.disposition.neutral': 'Neutral', + 'npcs.disposition.friendly': 'Friendly', + 'npcs.makeHostile': 'Make Hostile', + 'npcs.makeNeutral': 'Make Neutral', + 'npcs.makeFriendly': 'Make Friendly', + 'npcs.inactive': 'Inactive', + 'npcs.makeActive': 'Make Active', 'npcs.ringColor': 'TOKEN RING COLOR', 'npcs.description': 'DESCRIPTION', 'npcs.descriptionPlaceholder': 'Character description…', @@ -1201,8 +1237,10 @@ export const EDITOR_MESSAGES: Record> = { 'control.passed': 'Visited', 'control.noActiveScene': 'No active scene.', 'control.screenPreview': 'Screen preview', - 'control.npcTokenScale': 'NPC size', - 'control.stopPresentation': 'Stop presentation', + 'control.npcTokenScale': 'Play token size', + 'control.stopPresentation': 'Turn off', + 'control.showPlayers': 'Show players', + 'control.hidePlayers': 'Hide players', 'control.videoBrushHint': 'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).', 'control.branches': 'Branch options', diff --git a/app/renderer/npcs/NpcsEditorApp.tsx b/app/renderer/npcs/NpcsEditorApp.tsx index e9f15a8..56ec31f 100644 --- a/app/renderer/npcs/NpcsEditorApp.tsx +++ b/app/renderer/npcs/NpcsEditorApp.tsx @@ -17,6 +17,7 @@ import { getDndApi } from '../shared/dndApi'; import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView'; import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; +import { npcDispositionRingColor } from '../../shared/types/npcDisposition'; import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcEditModal } from './NpcEditModal'; @@ -739,7 +740,7 @@ export function NpcsEditorApp() {