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 <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 17:11:01 +08:00
parent 101f595bac
commit cc50e64e21
38 changed files with 1803 additions and 59 deletions
+1
View File
@@ -448,6 +448,7 @@ export async function buildProjectFromFoundryDocuments(
y: 80 + Math.floor(npcIndex / 4) * 200, y: 80 + Math.floor(npcIndex / 4) * 200,
groupId, groupId,
ringColor: '#c9a227', ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 }, imageOffset: { x: 0, y: 0 },
imageScale: 1, imageScale: 1,
}); });
+37 -3
View File
@@ -31,6 +31,7 @@ import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore'; import { PlayersStore } from './players/playersStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore'; import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore'; import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokensStore } from './tokens/tokensStore'; import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater'; import { installAutoUpdater } from './update/installAutoUpdater';
@@ -174,6 +175,7 @@ const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore(); const npcsOverlayStore = new NpcsOverlayStore();
const sceneTokensSessionStore = new SceneTokensSessionStore(); const sceneTokensSessionStore = new SceneTokensSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore(); const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
let tokensStore: TokensStore | null = null; let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | 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 { function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; 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.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, () => { registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.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() }); effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow(); openMultiWindow();
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
@@ -451,6 +465,7 @@ async function main() {
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSceneNpcTokensSessionState(); emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitEffectsState(); emitEffectsState();
return { ok: true }; return { ok: true };
}); });
@@ -548,9 +563,11 @@ async function main() {
sceneViewStore.reset(); sceneViewStore.reset();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset();
scenePlayerTokensSessionStore.reset();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSceneNpcTokensSessionState(); emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitSessionState(); emitSessionState();
warmNpcsEditorWindow(); warmNpcsEditorWindow();
return { project }; return { project };
@@ -566,6 +583,7 @@ async function main() {
sceneViewStore.reset(); sceneViewStore.reset();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset();
scenePlayerTokensSessionStore.reset();
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
@@ -574,6 +592,7 @@ async function main() {
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSceneNpcTokensSessionState(); emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitSessionState(); emitSessionState();
return { ok: true }; return { ok: true };
}); });
@@ -590,6 +609,7 @@ async function main() {
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
scenePlayerTokensSessionStore.clearPlacements();
// Token moves persist for the whole play session (reset only on project open/close). // Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
@@ -603,6 +623,7 @@ async function main() {
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitScenePlayerTokensSessionState();
emitSessionState(); emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
}); });
@@ -619,6 +640,7 @@ async function main() {
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
scenePlayerTokensSessionStore.clearPlacements();
// Token moves persist for the whole play session (reset only on project open/close). // Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
@@ -632,6 +654,7 @@ async function main() {
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitScenePlayerTokensSessionState();
emitSessionState(); emitSessionState();
const p = projectStore.getOpenProject(); const p = projectStore.getOpenProject();
return { return {
@@ -784,7 +807,7 @@ async function main() {
}); });
registerHandler( registerHandler(
ipcChannels.project.upsertNpc, 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; let filePath = pathFromDrop;
if (!filePath && !npcId) { if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({ const { canceled, filePaths } = await dialog.showOpenDialog({
@@ -809,6 +832,7 @@ async function main() {
...(filePath ? { filePath } : {}), ...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}), ...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}), ...(ringColor !== undefined ? { ringColor } : {}),
...(disposition !== undefined ? { disposition } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}), ...(imageOffset !== undefined ? { imageOffset } : {}),
}, },
(p) => emitNpcUpsertProgress(p), (p) => emitNpcUpsertProgress(p),
@@ -821,13 +845,15 @@ async function main() {
); );
registerHandler( registerHandler(
ipcChannels.project.updateNpcFields, 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, { const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}), ...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}), ...(typeof description === 'string' ? { description } : {}),
...(groupId !== undefined ? { groupId } : {}), ...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}), ...(ringColor !== undefined ? { ringColor } : {}),
...(disposition !== undefined ? { disposition } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}), ...(imageOffset !== undefined ? { imageOffset } : {}),
...(imageScale !== undefined ? { imageScale } : {}),
}); });
emitSessionState(); emitSessionState();
return { project }; return { project };
@@ -1428,6 +1454,14 @@ async function main() {
emitSceneNpcTokensSessionState(); emitSceneNpcTokensSessionState();
return { ok: true }; 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, () => { registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() }; return { state: videoStore.getState() };
@@ -30,3 +30,31 @@ void test('SceneNpcTokensSessionStore setScale', () => {
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE); assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.deepEqual(s3.byPlacementId, {}); 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);
});
+81 -1
View File
@@ -2,8 +2,10 @@ import {
clampNpcTokenSessionScale, clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE, DEFAULT_NPC_TOKEN_SESSION_SCALE,
type SceneNpcTokensSessionEvent, type SceneNpcTokensSessionEvent,
type SceneNpcTokensSessionPlacement,
type SceneNpcTokensSessionState, type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers'; } from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
function emptyState(revision = 1): SceneNpcTokensSessionState { function emptyState(revision = 1): SceneNpcTokensSessionState {
return { 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 { export class SceneNpcTokensSessionStore {
private state: SceneNpcTokensSessionState = emptyState(); private state: SceneNpcTokensSessionState = emptyState();
@@ -57,7 +89,55 @@ export class SceneNpcTokensSessionStore {
revision: this.state.revision + 1, revision: this.state.revision + 1,
byPlacementId: { byPlacementId: {
...this.state.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, scale: this.state.scale,
}; };
@@ -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, {});
});
@@ -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<string>();
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;
}
}
}
}
+32 -5
View File
@@ -60,6 +60,12 @@ import {
normalizeSceneNpcToken, normalizeSceneNpcToken,
type SceneNpcToken, type SceneNpcToken,
} from '../../shared/types/appPlayers'; } 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 { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps'; import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type { import type {
@@ -1263,6 +1269,7 @@ export class ZipProjectStore {
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
ringColor?: string; ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: { x: number; y: number }; imageOffset?: { x: number; y: number };
imageScale?: number; imageScale?: number;
}, },
@@ -1344,6 +1351,12 @@ export class ZipProjectStore {
...(input.ringColor !== undefined ...(input.ringColor !== undefined
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) } ? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
: {}), : {}),
...(input.disposition !== undefined
? {
disposition: normalizeNpcDisposition(input.disposition),
ringColor: npcDispositionRingColor(normalizeNpcDisposition(input.disposition)),
}
: {}),
...(input.imageOffset !== undefined ...(input.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(input.imageOffset) } ? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
: {}), : {}),
@@ -1354,6 +1367,7 @@ export class ZipProjectStore {
} else { } else {
if (!nextAssetId) throw new Error('NPC avatar is required'); if (!nextAssetId) throw new Error('NPC avatar is required');
const count = npcs.length; const count = npcs.length;
const disposition = normalizeNpcDisposition(input.disposition ?? DEFAULT_NPC_DISPOSITION);
npcs.push({ npcs.push({
id: asNpcId(`npc_${this.randomId()}`), id: asNpcId(`npc_${this.randomId()}`),
name, name,
@@ -1362,7 +1376,8 @@ export class ZipProjectStore {
x: 80 + (count % 4) * 220, x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200, y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null), groupId: resolveGroup(input.groupId, null),
ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR), disposition,
ringColor: npcDispositionRingColor(disposition),
imageOffset: clampPlayerImageOffset(input.imageOffset), imageOffset: clampPlayerImageOffset(input.imageOffset),
imageScale: clampPlayerImageScale(input.imageScale), imageScale: clampPlayerImageScale(input.imageScale),
}); });
@@ -1383,6 +1398,7 @@ export class ZipProjectStore {
description?: string; description?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
ringColor?: string; ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: { x: number; y: number }; imageOffset?: { x: number; y: number };
imageScale?: number; imageScale?: number;
}, },
@@ -1405,14 +1421,22 @@ export class ZipProjectStore {
if (patch.groupId !== undefined) { if (patch.groupId !== undefined) {
groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null; groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
} }
const disposition =
patch.disposition !== undefined
? normalizeNpcDisposition(patch.disposition)
: normalizeNpcDisposition(n.disposition);
return { return {
...n, ...n,
...(name !== undefined ? { name } : {}), ...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}), ...(typeof patch.description === 'string' ? { description: patch.description } : {}),
groupId, groupId,
...(patch.ringColor !== undefined disposition,
? { ringColor: normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR) } ringColor:
: {}), patch.disposition !== undefined
? npcDispositionRingColor(disposition)
: patch.ringColor !== undefined
? normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR)
: n.ringColor || npcDispositionRingColor(disposition),
...(patch.imageOffset !== undefined ...(patch.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) } ? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
: {}), : {}),
@@ -2495,7 +2519,10 @@ function normalizeProject(p: Project): Project {
x, x,
y, y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet), 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), imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale), imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
}; };
@@ -595,3 +595,43 @@
.audioScrubDefault { .audioScrubDefault {
cursor: default; 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);
}
+142 -3
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
@@ -8,19 +9,27 @@ import {
isNodeInSideStoryline, isNodeInSideStoryline,
listSideStoryStarts, listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage'; } from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types'; import type { GraphNodeId, NpcDisposition, Scene, SceneId, SceneViewCamera } from '../../shared/types';
import { import {
DEFAULT_NPC_TOKEN_SESSION_SCALE, DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
NPC_TOKEN_SESSION_SCALE_MAX, NPC_TOKEN_SESSION_SCALE_MAX,
NPC_TOKEN_SESSION_SCALE_MIN, NPC_TOKEN_SESSION_SCALE_MIN,
} from '../../shared/types/appPlayers'; } from '../../shared/types/appPlayers';
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml'; import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage'; 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 { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from '../shared/playerToken/useScenePlayerTokensSession';
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay'; import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay'; import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects'; import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
@@ -59,6 +68,12 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность молнии: быстрый удар + акцент в точке попадания. */ /** Длительность молнии: быстрый удар + акцент в точке попадания. */
const LIGHTNING_EFFECT_MS = 840; 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 { function clampAudioGain(v: number): number {
if (!Number.isFinite(v)) return 1; if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v)); return Math.max(0, Math.min(1, v));
@@ -127,6 +142,13 @@ export function ControlApp() {
const appTokens = useAppTokens(); const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession(); const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession(); 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 [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null); const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -1704,6 +1726,27 @@ export function ControlApp() {
}} }}
/> />
</label> </label>
{scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
<Button
data-testid="toggle-session-players"
onClick={() => {
if (scenePlayerTokensSession.visible) {
scenePlayerTokensApi.dispatch({ kind: 'setVisible', visible: false });
return;
}
const grid = currentScene?.grid;
const sizeN =
grid?.enabled && Number.isFinite(grid.sizeN)
? grid.sizeN
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
scenePlayerTokensApi.dispatch({ kind: 'show', sizeN });
}}
>
{scenePlayerTokensSession.visible
? t('control.hidePlayers')
: t('control.showPlayers')}
</Button>
) : null}
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}> <Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
{t('control.stopPresentation')} {t('control.stopPresentation')}
</Button> </Button>
@@ -1914,11 +1957,33 @@ export function ControlApp() {
library={session?.project?.npcs ?? []} library={session?.project?.npcs ?? []}
session={sceneNpcTokensSession} session={sceneNpcTokensSession}
viewport={previewContentRect} viewport={previewContentRect}
grid={currentScene?.grid} grid={currentScene?.grid ?? null}
editable editable
onMove={(placementId, nx, ny) => { onMove={(placementId, nx, ny) => {
sceneNpcTokensApi.dispatch({ kind: 'move', 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 ? (
<ScenePlayerTokensOverlay
library={appPlayers}
session={scenePlayerTokensSession}
displayScale={
sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE
}
viewport={previewContentRect}
grid={currentScene?.grid ?? null}
editable
onMove={(playerId, nx, ny) => {
scenePlayerTokensApi.dispatch({ kind: 'move', playerId, nx, ny });
}}
/> />
) : null} ) : null}
{previewContentRect ? ( {previewContentRect ? (
@@ -2315,6 +2380,80 @@ export function ControlApp() {
</Surface> </Surface>
) : null} ) : null}
</div> </div>
{npcSessionCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setNpcSessionCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: npcSessionCtxMenu.x, top: npcSessionCtxMenu.y }}
role="menu"
>
{(() => {
const placement = (currentScene?.npcTokens ?? []).find(
(item) => String(item.id) === npcSessionCtxMenu.placementId,
);
const npc = placement
? (session?.project?.npcs ?? []).find((item) => item.id === placement.npcId)
: undefined;
if (!placement || !npc) return null;
const override =
sceneNpcTokensSession.byPlacementId[npcSessionCtxMenu.placementId];
const current = resolveNpcTokenDisposition(
placement,
npc,
override?.disposition,
);
const inactive = Boolean(override?.inactive);
return (
<>
{otherNpcDispositions(current).map((d) => (
<button
key={d}
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
sceneNpcTokensApi.dispatch({
kind: 'setDisposition',
placementId: npcSessionCtxMenu.placementId,
disposition: d,
});
setNpcSessionCtxMenu(null);
}}
>
{t(dispositionMakeKey(d))}
</button>
))}
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
sceneNpcTokensApi.dispatch({
kind: 'setInactive',
placementId: npcSessionCtxMenu.placementId,
inactive: !inactive,
});
setNpcSessionCtxMenu(null);
}}
>
{inactive ? t('npcs.makeActive') : t('npcs.inactive')}
</button>
</>
);
})()}
</div>
</>,
document.body,
)
: null}
</div> </div>
); );
} }
+62
View File
@@ -59,6 +59,68 @@
gap: 10px; 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 { .editorSidebar {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
+108 -11
View File
@@ -56,6 +56,7 @@ import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals'; import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals'; import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal'; import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState'; import type { ProjectNoticeCode } from './state/projectState';
@@ -156,6 +157,10 @@ export function EditorApp() {
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null); const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = 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<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null); const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const onProjectNotice = useCallback( const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => { (code: ProjectNoticeCode) => {
@@ -353,13 +358,17 @@ export function EditorApp() {
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene'); const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
const launchFromGraphNode = useCallback( const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId) => { (graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return; if (!licenseActive || launching) return;
setLaunching(true); setLaunching(true);
setRunMenuOpen(false);
setLaunchPlayersOpen(false);
void (async () => { void (async () => {
try { try {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId }); 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 { } catch {
setLaunching(false); setLaunching(false);
} }
@@ -463,6 +472,23 @@ export function EditorApp() {
return () => window.removeEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown);
}, [aboutMenuOpen]); }, [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(() => { useEffect(() => {
let off: (() => void) | null = null; let off: (() => void) | null = null;
void (async () => { void (async () => {
@@ -842,16 +868,55 @@ export function EditorApp() {
<div className={styles.headerActions}> <div className={styles.headerActions}>
{state.project ? ( {state.project ? (
<> <>
<Button <div
variant="primary" ref={runSplitRef}
disabled={runDisabled || launching} className={styles.splitRun}
onClick={() => { data-runmenu-root="1"
if (!licenseActive || !graphStartGraphNodeId || launching) return; aria-disabled={runDisabled || launching ? true : undefined}
launchFromGraphNode(graphStartGraphNodeId);
}}
> >
{t('top.run')} <button
</Button> type="button"
className={styles.splitRunMain}
disabled={runDisabled || launching}
data-testid="run-main-btn"
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</button>
<span className={styles.splitRunDivider} aria-hidden />
<button
type="button"
className={[
styles.splitRunChevron,
runMenuOpen ? styles.splitRunChevronOpen : '',
]
.filter(Boolean)
.join(' ')}
disabled={runDisabled || launching}
aria-label={t('top.runMenuAria')}
aria-haspopup="menu"
aria-expanded={runMenuOpen}
data-testid="run-menu-btn"
onClick={() => {
if (runDisabled || launching) return;
setRunMenuOpen((v) => !v);
}}
>
<svg className={styles.splitRunChevronSvg} viewBox="0 0 12 12" aria-hidden>
<path
d="M2.5 4.25 L6 7.75 L9.5 4.25"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{runDisabled ? ( {runDisabled ? (
<Button <Button
variant="ghost" variant="ghost"
@@ -1549,6 +1614,38 @@ export function EditorApp() {
/> />
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} /> <CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} /> <PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
<LaunchPlayersModal
open={launchPlayersOpen}
onClose={() => setLaunchPlayersOpen(false)}
onConfirm={(playerIds) => {
if (!graphStartGraphNodeId) return;
launchFromGraphNode(graphStartGraphNodeId, playerIds);
}}
/>
{runMenuOpen && runMenuPos
? createPortal(
<div
data-runmenu-root="1"
className={styles.fileMenu}
style={{ left: runMenuPos.left, top: runMenuPos.top, transform: 'translateX(-100%)' }}
role="menu"
>
<button
type="button"
className={styles.fileMenuItem}
role="menuitem"
data-testid="run-with-players-item"
onClick={() => {
setRunMenuOpen(false);
setLaunchPlayersOpen(true);
}}
>
{t('top.runWithPlayers')}
</button>
</div>,
document.body,
)
: null}
<MaterialsManagerModal <MaterialsManagerModal
open={materialsManagerOpen} open={materialsManagerOpen}
materials={state.project?.materials ?? []} materials={state.project?.materials ?? []}
@@ -0,0 +1,63 @@
.dialog {
width: min(440px, calc(100vw - 48px));
}
.hint {
margin: 0 0 12px;
color: var(--text2);
font-size: var(--text-sm);
line-height: 1.4;
}
.section {
display: grid;
gap: 8px;
margin-bottom: 14px;
}
.sectionTitle {
font-size: 11px;
font-weight: 700;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.list {
display: grid;
gap: 4px;
max-height: 220px;
overflow: auto;
padding: 8px;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: var(--color-overlay-dark-3);
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
color: var(--text);
font-size: var(--text-sm);
}
.row:hover {
background: var(--accent-fill-soft);
}
.teamDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
}
.empty {
padding: 10px 8px;
color: var(--text2);
font-size: var(--text-sm);
}
+165
View File
@@ -0,0 +1,165 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import launchStyles from './LaunchPlayersModal.module.css';
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
export function LaunchPlayersModal({
open,
onClose,
onConfirm,
}: {
open: boolean;
onClose: () => void;
onConfirm: (playerIds: string[]) => void;
}) {
const { t } = useEditorI18n();
const { players, teams } = useAppPlayers();
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => 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(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="launch-players-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div>
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
{teams.length > 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
<div className={launchStyles.list}>
{teams.map((team: AppPlayerTeam) => {
const count = players.filter((p) => p.teamId === team.id).length;
return (
<label key={team.id} className={launchStyles.row}>
<input
type="checkbox"
checked={selectedTeams.has(String(team.id))}
onChange={() => toggleTeam(team.id)}
data-testid={`launch-team-${team.id}`}
/>
<span className={launchStyles.teamDot} style={{ background: team.color }} />
<span>
{team.name} ({count})
</span>
</label>
);
})}
</div>
</section>
) : null}
{standalonePlayers.length > 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
<div className={launchStyles.list}>
{standalonePlayers.map((player: AppPlayer) => (
<label key={player.id} className={launchStyles.row}>
<input
type="checkbox"
checked={selectedPlayers.has(String(player.id))}
onChange={() => togglePlayer(player.id)}
data-testid={`launch-player-${player.id}`}
/>
<span>{player.name}</span>
</label>
))}
</div>
</section>
) : players.length === 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
</section>
) : null}
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={resolvedIds.length === 0}
data-testid="launch-players-confirm"
onClick={() => onConfirm(resolvedIds)}
>
{t('top.runWithPlayersConfirm')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+44 -6
View File
@@ -114,6 +114,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.backToProjects': 'К списку проектов', 'top.backToProjects': 'К списку проектов',
'top.appVersion': 'Версия приложения', 'top.appVersion': 'Версия приложения',
'top.run': 'Запустить', 'top.run': 'Запустить',
'top.runMenuAria': 'Дополнительные варианты запуска',
'top.runWithPlayers': 'Запустить с игроками',
'top.runWithPlayersTitle': 'Запуск с игроками',
'top.runWithPlayersHint': 'Выберите игроков и/или команды. Выбор сохранится на время сессии.',
'top.runWithPlayersTeams': 'Команды',
'top.runWithPlayersPlayers': 'Игроки',
'top.runWithPlayersEmpty': 'Нет игроков в библиотеке.',
'top.runWithPlayersConfirm': 'Запустить',
'top.launching': 'Запуск…', 'top.launching': 'Запуск…',
'top.afterLicense': 'Доступно после активации лицензии', 'top.afterLicense': 'Доступно после активации лицензии',
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)', 'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
@@ -207,7 +215,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.session.title': 'Запуск сессии', 'help.section.session.title': 'Запуск сессии',
'help.section.session.body': '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.title': 'Пульт управления',
'help.section.controlPanel.body': 'help.section.controlPanel.body':
@@ -465,6 +473,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.avatarRequired': 'Выберите аватар.', 'npcs.avatarRequired': 'Выберите аватар.',
'npcs.chooseAvatar': 'Выбрать аватар', 'npcs.chooseAvatar': 'Выбрать аватар',
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)', '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.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
'npcs.description': 'ОПИСАНИЕ', 'npcs.description': 'ОПИСАНИЕ',
'npcs.descriptionPlaceholder': 'Описание персонажа…', 'npcs.descriptionPlaceholder': 'Описание персонажа…',
@@ -603,8 +620,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.passed': 'Пройдено', 'control.passed': 'Пройдено',
'control.noActiveScene': 'Нет активной сцены.', 'control.noActiveScene': 'Нет активной сцены.',
'control.screenPreview': 'Предпросмотр экрана', 'control.screenPreview': 'Предпросмотр экрана',
'control.npcTokenScale': 'Размер НПС', 'control.npcTokenScale': 'Размеры игр. токенов',
'control.stopPresentation': 'Выключить демонстрацию', 'control.stopPresentation': 'Выключить',
'control.showPlayers': 'Показать игроков',
'control.hidePlayers': 'Скрыть игроков',
'control.videoBrushHint': 'control.videoBrushHint':
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).', 'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
'control.branches': 'Варианты ветвления', 'control.branches': 'Варианты ветвления',
@@ -710,6 +729,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.backToProjects': 'Back to projects', 'top.backToProjects': 'Back to projects',
'top.appVersion': 'App version', 'top.appVersion': 'App version',
'top.run': 'Run', '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.launching': 'Starting…',
'top.afterLicense': 'Available after license activation', 'top.afterLicense': 'Available after license activation',
'top.setStartScene': 'Set a start scene on the graph (rightclick a node)', 'top.setStartScene': 'Set a start scene on the graph (rightclick a node)',
@@ -803,7 +830,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.session.title': 'Starting a session', 'help.section.session.title': 'Starting a session',
'help.section.session.body': '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.title': 'Control panel',
'help.section.controlPanel.body': 'help.section.controlPanel.body':
@@ -1063,6 +1090,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.avatarRequired': 'Choose an avatar.', 'npcs.avatarRequired': 'Choose an avatar.',
'npcs.chooseAvatar': 'Choose avatar', 'npcs.chooseAvatar': 'Choose avatar',
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)', '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.ringColor': 'TOKEN RING COLOR',
'npcs.description': 'DESCRIPTION', 'npcs.description': 'DESCRIPTION',
'npcs.descriptionPlaceholder': 'Character description…', 'npcs.descriptionPlaceholder': 'Character description…',
@@ -1201,8 +1237,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.passed': 'Visited', 'control.passed': 'Visited',
'control.noActiveScene': 'No active scene.', 'control.noActiveScene': 'No active scene.',
'control.screenPreview': 'Screen preview', 'control.screenPreview': 'Screen preview',
'control.npcTokenScale': 'NPC size', 'control.npcTokenScale': 'Play token size',
'control.stopPresentation': 'Stop presentation', 'control.stopPresentation': 'Turn off',
'control.showPlayers': 'Show players',
'control.hidePlayers': 'Hide players',
'control.videoBrushHint': 'control.videoBrushHint':
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).', 'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
'control.branches': 'Branch options', 'control.branches': 'Branch options',
+13 -8
View File
@@ -17,6 +17,7 @@ import { getDndApi } from '../shared/dndApi';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView'; import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { Button, Input, Select } from '../shared/ui/controls'; import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal'; import { NpcEditModal } from './NpcEditModal';
@@ -739,7 +740,7 @@ export function NpcsEditorApp() {
<PlayerTokenView <PlayerTokenView
name={selected.name} name={selected.name}
imageUrl={selectedUrl} imageUrl={selectedUrl}
ringColor={selected.ringColor} ringColor={npcDispositionRingColor(selected.disposition ?? 'neutral')}
imageOffset={selected.imageOffset} imageOffset={selected.imageOffset}
imageScale={selected.imageScale} imageScale={selected.imageScale}
sizePx={140} sizePx={140}
@@ -782,17 +783,21 @@ export function NpcsEditorApp() {
</div> </div>
<label> <label>
<div className={styles.fieldLabel}>{t('npcs.ringColor')}</div> <div className={styles.fieldLabel}>{t('npcs.disposition')}</div>
<input <Select
type="color" value={selected.disposition ?? 'neutral'}
className={styles.colorInput} ariaLabel={t('npcs.disposition')}
value={selected.ringColor} onChange={(next) => {
onChange={(e) => {
void api.invoke(ipcChannels.project.updateNpcFields, { void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id, npcId: selected.id,
ringColor: e.currentTarget.value, disposition: next as 'hostile' | 'neutral' | 'friendly',
}); });
}} }}
options={[
{ value: 'hostile', label: t('npcs.disposition.hostile') },
{ value: 'neutral', label: t('npcs.disposition.neutral') },
{ value: 'friendly', label: t('npcs.disposition.friendly') },
]}
/> />
</label> </label>
@@ -465,3 +465,49 @@
.toolbar button { .toolbar button {
font-size: 12px; font-size: 12px;
} }
.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,
.ctxItemDanger {
text-align: left;
padding: 8px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text1, #e8eaef);
font-size: 13px;
cursor: pointer;
width: 100%;
}
.ctxItemDanger {
color: var(--color-danger, #e57373);
}
.ctxItem:hover,
.ctxItemDanger:hover {
background: rgba(255, 255, 255, 0.06);
}
+83 -8
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { import type {
NpcDisposition,
NpcId, NpcId,
ProjectNpc, ProjectNpc,
SceneGrid, SceneGrid,
@@ -17,6 +18,11 @@ import {
clampSceneNpcTokenSizeN, clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N, DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
} from '../../shared/types/appPlayers'; } from '../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
otherNpcDispositions,
} from '../../shared/types/npcDisposition';
import { import {
asSceneTokenId, asSceneTokenId,
asTokenId, asTokenId,
@@ -39,6 +45,7 @@ import {
} from '../../shared/types/sceneTraps'; } from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView'; import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
@@ -53,6 +60,11 @@ import { SceneTokenMarker } from './SceneTokenMarker';
import { TokenEditModal } from './TokenEditModal'; import { TokenEditModal } from './TokenEditModal';
import { TOKEN_DND_MIME, TokenTile } from './TokenTile'; import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'hostile') return 'npcs.makeHostile';
if (d === 'friendly') return 'npcs.makeFriendly';
return 'npcs.makeNeutral';
}
function isTypingTarget(el: EventTarget | null): boolean { function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false; if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName; const tag = el.tagName;
@@ -127,7 +139,7 @@ function SceneNpcMarker({
sizePx, sizePx,
selected, selected,
onSelect, onSelect,
onDelete, onContextMenu,
onMovePointerDown, onMovePointerDown,
onResizePointerDown, onResizePointerDown,
}: { }: {
@@ -138,11 +150,12 @@ function SceneNpcMarker({
sizePx: number; sizePx: number;
selected: boolean; selected: boolean;
onSelect: () => void; onSelect: () => void;
onDelete: () => void; onContextMenu: (e: React.MouseEvent) => void;
onMovePointerDown: (e: React.PointerEvent) => void; onMovePointerDown: (e: React.PointerEvent) => void;
onResizePointerDown: (e: React.PointerEvent) => void; onResizePointerDown: (e: React.PointerEvent) => void;
}) { }) {
const imageUrl = useAssetUrl(npc.avatarAssetId); const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(placement.disposition ?? npc.disposition);
return ( return (
<div <div
data-testid={`scene-npc-token-${placement.id}`} data-testid={`scene-npc-token-${placement.id}`}
@@ -151,7 +164,7 @@ function SceneNpcMarker({
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onDelete(); onContextMenu(e);
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return; if (e.button !== 0) return;
@@ -162,7 +175,7 @@ function SceneNpcMarker({
<PlayerTokenView <PlayerTokenView
name={npc.name} name={npc.name}
imageUrl={imageUrl} imageUrl={imageUrl}
ringColor={npc.ringColor} ringColor={npcDispositionRingColor(disposition)}
imageOffset={npc.imageOffset} imageOffset={npc.imageOffset}
imageScale={npc.imageScale} imageScale={npc.imageScale}
sizePx={sizePx} sizePx={sizePx}
@@ -174,6 +187,7 @@ function SceneNpcMarker({
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) { function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
const imageUrl = useAssetUrl(npc.avatarAssetId); const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(npc.disposition);
return ( return (
<div <div
className={styles.npcTile} className={styles.npcTile}
@@ -188,7 +202,7 @@ function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
<PlayerTokenView <PlayerTokenView
name={npc.name} name={npc.name}
imageUrl={imageUrl} imageUrl={imageUrl}
ringColor={npc.ringColor} ringColor={npcDispositionRingColor(disposition)}
imageOffset={npc.imageOffset} imageOffset={npc.imageOffset}
imageScale={npc.imageScale} imageScale={npc.imageScale}
sizePx={70} sizePx={70}
@@ -199,6 +213,7 @@ function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
export function SceneEditorApp() { export function SceneEditorApp() {
const api = getDndApi(); const api = getDndApi();
const { t } = useEditorI18n();
const appTokens = useAppTokens(); const appTokens = useAppTokens();
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(false); const [trapsOpen, setTrapsOpen] = useState(false);
@@ -211,6 +226,11 @@ export function SceneEditorApp() {
{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null { mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null
>(null); >(null);
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null); const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
const [npcCtxMenu, setNpcCtxMenu] = useState<{
x: number;
y: number;
placementId: string;
} | null>(null);
const [selected, setSelected] = useState<Selection>(null); const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 }); const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
@@ -439,12 +459,14 @@ export function SceneEditorApp() {
localGrid.enabled && Number.isFinite(localGrid.sizeN) localGrid.enabled && Number.isFinite(localGrid.sizeN)
? clampSceneNpcTokenSizeN(localGrid.sizeN) ? clampSceneNpcTokenSizeN(localGrid.sizeN)
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N; : DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
const npc = project?.npcs.find((item) => item.id === npcId);
const placement: SceneNpcToken = { const placement: SceneNpcToken = {
id: asSceneNpcTokenId(randomId('snpc')), id: asSceneNpcTokenId(randomId('snpc')),
npcId, npcId,
nx, nx,
ny, ny,
sizeN: gridCellSize, sizeN: gridCellSize,
disposition: normalizeNpcDisposition(npc?.disposition),
}; };
setSelected({ kind: 'npcToken', id: placement.id }); setSelected({ kind: 'npcToken', id: placement.id });
persistNpcTokens([...npcTokensRef.current, placement]); persistNpcTokens([...npcTokensRef.current, placement]);
@@ -871,9 +893,9 @@ export function SceneEditorApp() {
sizePx={sizePx} sizePx={sizePx}
selected={selected?.kind === 'npcToken' && selected.id === tok.id} selected={selected?.kind === 'npcToken' && selected.id === tok.id}
onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })} onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })}
onDelete={() => { onContextMenu={(e) => {
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== tok.id)); setSelected({ kind: 'npcToken', id: tok.id });
setSelected((current) => (current?.id === tok.id ? null : current)); setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
}} }}
onMovePointerDown={(e) => { onMovePointerDown={(e) => {
if (spaceDownRef.current) return; if (spaceDownRef.current) return;
@@ -1018,6 +1040,59 @@ export function SceneEditorApp() {
document.body, document.body,
) )
: null} : null}
{npcCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setNpcCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
role="menu"
>
<button
type="button"
className={styles.ctxItemDanger}
role="menuitem"
onClick={() => {
const id = npcCtxMenu.placementId;
setNpcCtxMenu(null);
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== id));
setSelected((current) => (current?.id === id ? null : current));
}}
>
{t('common.delete')}
</button>
{(() => {
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
const npc = tok ? project?.npcs.find((item) => item.id === tok.npcId) : undefined;
if (!tok || !npc) return null;
const current = normalizeNpcDisposition(tok.disposition ?? npc.disposition);
return otherNpcDispositions(current).map((d) => (
<button
key={d}
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
updateNpcToken(tok.id, { disposition: d });
setNpcCtxMenu(null);
}}
>
{t(dispositionMakeKey(d))}
</button>
));
})()}
</div>
</>,
document.body,
)
: null}
</div> </div>
); );
} }
+15
View File
@@ -16,7 +16,10 @@ import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay'; import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay'; import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay';
import { ScenePlayerTokensOverlay } from './playerToken/ScenePlayerTokensOverlay';
import { useAppPlayers } from './playerToken/useAppPlayers';
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession'; import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost'; import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from './sceneView/useSceneViewState'; import { useSceneViewState } from './sceneView/useSceneViewState';
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay'; import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
@@ -28,6 +31,7 @@ import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage'; import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl'; import { useAssetUrl } from './useAssetImageUrl';
import { useVideoPlaybackState } from './video/useVideoPlaybackState'; import { useVideoPlaybackState } from './video/useVideoPlaybackState';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
export type PresentationViewProps = { export type PresentationViewProps = {
session: SessionState | null; session: SessionState | null;
@@ -50,8 +54,10 @@ export function PresentationView({
const [materialsOverlay] = useMaterialsOverlayState(); const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState(); const [npcsOverlay] = useNpcsOverlayState();
const appTokens = useAppTokens(); const appTokens = useAppTokens();
const { players: appPlayers } = useAppPlayers();
const [sceneTokensSession] = useSceneTokensSession(); const [sceneTokensSession] = useSceneTokensSession();
const [sceneNpcTokensSession] = useSceneNpcTokensSession(); const [sceneNpcTokensSession] = useSceneNpcTokensSession();
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
const [vp] = useVideoPlaybackState(); const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null); const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>( const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -189,6 +195,15 @@ export function PresentationView({
grid={scene.grid} grid={scene.grid}
/> />
) : null} ) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
<ScenePlayerTokensOverlay
library={appPlayers}
session={scenePlayerTokensSession}
displayScale={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
viewport={contentRect}
grid={scene.grid}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? ( {scene?.previewAssetType === 'image' && contentRect ? (
<SceneTrapsOverlay <SceneTrapsOverlay
traps={scene.traps ?? []} traps={scene.traps ?? []}
@@ -26,6 +26,8 @@ export type PlayerTokenViewProps = {
className?: string; className?: string;
/** Без подписи имени (компактный маркер). */ /** Без подписи имени (компактный маркер). */
hideName?: boolean; hideName?: boolean;
/** Session «Неактивен»: серый фильтр аватара. */
inactive?: boolean;
'data-testid'?: string; 'data-testid'?: string;
}; };
@@ -41,6 +43,7 @@ export function PlayerTokenView({
onImageScaleChange, onImageScaleChange,
className, className,
hideName = false, hideName = false,
inactive = false,
'data-testid': testId, 'data-testid': testId,
}: PlayerTokenViewProps) { }: PlayerTokenViewProps) {
const offset = clampPlayerImageOffset(imageOffset); const offset = clampPlayerImageOffset(imageOffset);
@@ -128,7 +131,7 @@ export function PlayerTokenView({
style={{ style={{
width: sizePx, width: sizePx,
height: sizePx, height: sizePx,
borderColor: ringColor, borderColor: inactive ? '#9ca3af' : ringColor,
}} }}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
@@ -143,6 +146,7 @@ export function PlayerTokenView({
draggable={false} draggable={false}
style={{ style={{
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`, transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
}} }}
/> />
) : ( ) : (
@@ -1,20 +1,35 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import type { import type {
NpcDisposition,
ProjectNpc, ProjectNpc,
SceneGrid, SceneGrid,
SceneNpcToken, SceneNpcToken,
SceneNpcTokensSessionState, SceneNpcTokensSessionState,
} from '../../../shared/types'; } from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers'; import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
} from '../../../shared/types/npcDisposition';
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid'; import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { useAssetUrl } from '../useAssetImageUrl'; import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView'; import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css'; import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number }; type Viewport = { x: number; y: number; w: number; h: number };
export function resolveNpcTokenDisposition(
placement: SceneNpcToken,
npc: ProjectNpc,
sessionDisposition?: NpcDisposition,
): NpcDisposition {
if (sessionDisposition) return normalizeNpcDisposition(sessionDisposition);
return normalizeNpcDisposition(placement.disposition ?? npc.disposition);
}
function NpcSprite({ function NpcSprite({
placement, placement,
npc, npc,
@@ -24,7 +39,10 @@ function NpcSprite({
editable, editable,
displayScale, displayScale,
gridFit, gridFit,
disposition,
inactive,
onMove, onMove,
onContextMenu,
}: { }: {
placement: SceneNpcToken; placement: SceneNpcToken;
npc: ProjectNpc; npc: ProjectNpc;
@@ -34,7 +52,10 @@ function NpcSprite({
editable: boolean; editable: boolean;
displayScale: number; displayScale: number;
gridFit: number; gridFit: number;
disposition: NpcDisposition;
inactive: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void; onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
}) { }) {
const imageUrl = useAssetUrl(npc.avatarAssetId); const imageUrl = useAssetUrl(npc.avatarAssetId);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null); const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
@@ -47,6 +68,7 @@ function NpcSprite({
lastNx: number; lastNx: number;
lastNy: number; lastNy: number;
} | null>(null); } | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
const pos = localPos ?? { nx, ny }; const pos = localPos ?? { nx, ny };
const minDim = Math.min(viewport.w, viewport.h); const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim); const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
@@ -64,7 +86,7 @@ function NpcSprite({
const drag = dragRef.current; const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return; if (drag?.pointerId !== e.pointerId) return;
dragRef.current = null; dragRef.current = null;
onMove?.(String(placement.id), drag.lastNx, drag.lastNy); flush(drag.lastNx, drag.lastNy);
setLocalPos(null); setLocalPos(null);
}; };
@@ -78,6 +100,15 @@ function NpcSprite({
width: sizePx, width: sizePx,
height: sizePx, height: sizePx,
}} }}
onContextMenu={
onContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, placement);
}
: undefined
}
onPointerDown={ onPointerDown={
editable && onMove !== undefined editable && onMove !== undefined
? (e) => { ? (e) => {
@@ -107,6 +138,7 @@ function NpcSprite({
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx)); drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy)); drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy }); setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
schedule(drag.lastNx, drag.lastNy);
} }
: undefined : undefined
} }
@@ -116,10 +148,11 @@ function NpcSprite({
<PlayerTokenView <PlayerTokenView
name={npc.name} name={npc.name}
imageUrl={imageUrl} imageUrl={imageUrl}
ringColor={npc.ringColor} ringColor={npcDispositionRingColor(disposition, inactive)}
imageOffset={npc.imageOffset} imageOffset={npc.imageOffset}
imageScale={npc.imageScale} imageScale={npc.imageScale}
sizePx={sizePx} sizePx={sizePx}
inactive={inactive}
/> />
</div> </div>
); );
@@ -133,6 +166,7 @@ export function SceneNpcTokensOverlay({
grid = null, grid = null,
editable = false, editable = false,
onMove, onMove,
onContextMenu,
}: { }: {
placements: readonly SceneNpcToken[]; placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[]; library: readonly ProjectNpc[];
@@ -142,6 +176,7 @@ export function SceneNpcTokensOverlay({
grid?: SceneGrid | null; grid?: SceneGrid | null;
editable?: boolean; editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void; onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
}) { }) {
if (!viewport) return null; if (!viewport) return null;
const byId = new Map(library.map((npc) => [npc.id, npc])); const byId = new Map(library.map((npc) => [npc.id, npc]));
@@ -153,6 +188,8 @@ export function SceneNpcTokensOverlay({
const npc = byId.get(placement.npcId); const npc = byId.get(placement.npcId);
if (!npc) return null; if (!npc) return null;
const override = session?.byPlacementId[String(placement.id)]; const override = session?.byPlacementId[String(placement.id)];
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
const inactive = Boolean(override?.inactive);
return ( return (
<NpcSprite <NpcSprite
key={placement.id} key={placement.id}
@@ -164,7 +201,10 @@ export function SceneNpcTokensOverlay({
editable={editable} editable={editable}
displayScale={displayScale} displayScale={displayScale}
gridFit={gridFit} gridFit={gridFit}
disposition={disposition}
inactive={inactive}
{...(onMove ? { onMove } : {})} {...(onMove ? { onMove } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/> />
); );
})} })}
@@ -0,0 +1,174 @@
import React, { useRef, useState } from 'react';
import type {
AppPlayer,
SceneGrid,
ScenePlayerTokensSessionState,
} from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { usePlayerImageUrl } from './usePlayerImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
function PlayerSprite({
player,
nx,
ny,
sizeN,
viewport,
editable,
displayScale,
gridFit,
onMove,
}: {
player: AppPlayer;
nx: number;
ny: number;
sizeN: number;
viewport: Viewport;
editable: boolean;
displayScale: number;
gridFit: number;
onMove?: (playerId: string, nx: number, ny: number) => void;
}) {
const imageUrl = usePlayerImageUrl(player.id);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const dragRef = useRef<{
pointerId: number;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
lastNx: number;
lastNy: number;
} | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
const pos = localPos ?? { nx, ny };
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim);
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
const rect = host?.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
};
};
const end = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
dragRef.current = null;
flush(drag.lastNx, drag.lastNy);
setLocalPos(null);
};
return (
<div
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
data-testid={`session-player-token-${player.id}`}
style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
}}
onPointerDown={
editable && onMove !== undefined
? (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const p = point(e);
dragRef.current = {
pointerId: e.pointerId,
startNx: pos.nx,
startNy: pos.ny,
pointerNx: p.x,
pointerNy: p.y,
lastNx: pos.nx,
lastNy: pos.ny,
};
e.currentTarget.setPointerCapture(e.pointerId);
}
: undefined
}
onPointerMove={
editable && onMove
? (e) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const p = point(e);
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
schedule(drag.lastNx, drag.lastNy);
}
: undefined
}
onPointerUp={end}
onPointerCancel={end}
>
<PlayerTokenView
name={player.name}
imageUrl={imageUrl}
ringColor={player.ringColor}
imageOffset={player.imageOffset}
imageScale={player.imageScale}
sizePx={sizePx}
/>
</div>
);
}
export function ScenePlayerTokensOverlay({
library,
session,
displayScale = DEFAULT_NPC_TOKEN_SESSION_SCALE,
viewport,
grid = null,
editable = false,
onMove,
}: {
library: readonly AppPlayer[];
session: ScenePlayerTokensSessionState | null;
/** Общий масштаб с НПС-токенами. */
displayScale?: number;
viewport: Viewport | null;
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (playerId: string, nx: number, ny: number) => void;
}) {
if (!viewport || !session?.visible) return null;
const byId = new Map(library.map((p) => [String(p.id), p]));
const gridFit = sceneGridTokenFitFactor(grid);
return (
<div className={styles.layer}>
{session.selectedPlayerIds.map((playerId) => {
const player = byId.get(playerId);
const placement = session.byPlayerId[playerId];
if (!player || !placement) return null;
return (
<PlayerSprite
key={playerId}
player={player}
nx={placement.nx}
ny={placement.ny}
sizeN={placement.sizeN}
viewport={viewport}
editable={editable}
displayScale={displayScale}
gridFit={gridFit}
{...(onMove ? { onMove } : {})}
/>
);
})}
</div>
);
}
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef } from 'react';
/** Во время drag шлёт onMove не чаще одного раза за кадр (для live-синхронизации презентации). */
export function useLiveDragBroadcast(
onMove: ((id: string, nx: number, ny: number) => void) | undefined,
id: string,
): {
schedule: (nx: number, ny: number) => void;
flush: (nx: number, ny: number) => void;
} {
const rafRef = useRef(0);
const pendingRef = useRef<{ nx: number; ny: number } | null>(null);
const onMoveRef = useRef(onMove);
onMoveRef.current = onMove;
const idRef = useRef(id);
idRef.current = id;
useEffect(
() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
},
[],
);
const schedule = useCallback((nx: number, ny: number) => {
if (!onMoveRef.current) return;
pendingRef.current = { nx, ny };
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
const pending = pendingRef.current;
const cb = onMoveRef.current;
if (!pending || !cb) return;
cb(idRef.current, pending.nx, pending.ny);
});
}, []);
const flush = useCallback((nx: number, ny: number) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}
pendingRef.current = null;
onMoveRef.current?.(idRef.current, nx, ny);
}, []);
return { schedule, flush };
}
@@ -3,12 +3,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts'; import { ipcChannels } from '../../../shared/ipc/contracts';
import type { import type {
SceneNpcTokensSessionEvent, SceneNpcTokensSessionEvent,
SceneNpcTokensSessionPlacement,
SceneNpcTokensSessionState, SceneNpcTokensSessionState,
} from '../../../shared/types'; } from '../../../shared/types';
import { import {
clampNpcTokenSessionScale, clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE, DEFAULT_NPC_TOKEN_SESSION_SCALE,
} from '../../../shared/types/appPlayers'; } from '../../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
import { getDndApi } from '../dndApi'; import { getDndApi } from '../dndApi';
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState { function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
@@ -18,6 +20,10 @@ function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionStat
}; };
} }
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
}
function applyEvent( function applyEvent(
prev: SceneNpcTokensSessionState, prev: SceneNpcTokensSessionState,
event: SceneNpcTokensSessionEvent, event: SceneNpcTokensSessionEvent,
@@ -34,11 +40,69 @@ function applyEvent(
if (prev.scale === scale) return prev; if (prev.scale === scale) return prev;
return { ...prev, revision: prev.revision + 1, scale }; return { ...prev, revision: prev.revision + 1, scale };
} }
if (event.kind === 'setDisposition') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const disposition = normalizeNpcDisposition(event.disposition);
const cur = prev.byPlacementId[placementId];
if (cur?.disposition === disposition) return prev;
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
disposition,
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
if (event.kind === 'setInactive') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const inactive = Boolean(event.inactive);
const cur = prev.byPlacementId[placementId];
if (Boolean(cur?.inactive) === inactive) return prev;
const next: SceneNpcTokensSessionPlacement = {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
if (isEmptyPlacement(next)) {
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
return {
revision: prev.revision + 1,
byPlacementId: rest,
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: next,
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
// move
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const cur = prev.byPlacementId[placementId];
return { return {
revision: prev.revision + 1, revision: prev.revision + 1,
byPlacementId: { byPlacementId: {
...prev.byPlacementId, ...prev.byPlacementId,
[event.placementId]: { nx: event.nx, ny: event.ny }, [placementId]: {
nx: event.nx,
ny: event.ny,
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(cur?.inactive ? { inactive: true } : {}),
},
}, },
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
}; };
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
ScenePlayerTokensSessionEvent,
ScenePlayerTokensSessionState,
} from '../../../shared/types';
import { getDndApi } from '../dndApi';
function applyEvent(
prev: ScenePlayerTokensSessionState,
event: ScenePlayerTokensSessionEvent,
): ScenePlayerTokensSessionState {
if (event.kind === 'clear') {
return { revision: prev.revision + 1, selectedPlayerIds: [], visible: false, byPlayerId: {} };
}
if (event.kind === 'clearPlacements') {
return { ...prev, revision: prev.revision + 1, visible: false, byPlayerId: {} };
}
if (event.kind === 'setSelection') {
const seen = new Set<string>();
const selectedPlayerIds: string[] = [];
for (const id of event.playerIds) {
const s = String(id ?? '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
selectedPlayerIds.push(s);
}
return { revision: prev.revision + 1, selectedPlayerIds, visible: false, byPlayerId: {} };
}
if (event.kind === 'setVisible') {
if (event.visible && prev.selectedPlayerIds.length === 0) return prev;
return { ...prev, revision: prev.revision + 1, visible: Boolean(event.visible) };
}
if (event.kind === 'show') {
if (prev.selectedPlayerIds.length === 0) return prev;
// placements приходят с main; локально только включаем visible после seed через broadcast
return { ...prev, revision: prev.revision + 1, visible: true };
}
if (event.kind === 'seedBottom') {
return prev;
}
if (event.kind === 'move') {
const prevPlacement = prev.byPlayerId[event.playerId];
return {
...prev,
revision: prev.revision + 1,
byPlayerId: {
...prev.byPlayerId,
[event.playerId]: {
nx: event.nx,
ny: event.ny,
sizeN: prevPlacement?.sizeN ?? 0.1,
},
},
};
}
return prev;
}
export function useScenePlayerTokensSession(): [
ScenePlayerTokensSessionState,
{ dispatch: (event: ScenePlayerTokensSessionEvent) => void },
] {
const api = getDndApi();
const [state, setState] = useState<ScenePlayerTokensSessionState>({
revision: 0,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
});
const localRevRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
if (res.state.revision < localRevRef.current) return;
localRevRef.current = res.state.revision;
setState(res.state);
});
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
});
}, [api]);
const dispatch = useCallback(
(event: ScenePlayerTokensSessionEvent) => {
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
setState((prev) => {
const next = applyEvent(prev, event);
localRevRef.current = Math.max(localRevRef.current, next.revision);
return next;
});
}
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
},
[api],
);
return [state, { dispatch }];
}
@@ -112,10 +112,21 @@ void test('Control and Presentation use SceneNpcTokensOverlay', () => {
assert.ok(control.includes('SceneNpcTokensOverlay')); assert.ok(control.includes('SceneNpcTokensOverlay'));
assert.ok(presentation.includes('SceneNpcTokensOverlay')); assert.ok(presentation.includes('SceneNpcTokensOverlay'));
assert.ok(control.includes('sceneNpcTokensSession')); assert.ok(control.includes('sceneNpcTokensSession'));
assert.ok(control.includes('ScenePlayerTokensOverlay'));
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
assert.ok(control.includes('toggle-session-players'));
});
void test('EditorApp: split Run button and launch-with-players modal', () => {
const editor = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
assert.ok(editor.includes('splitRun'));
assert.ok(editor.includes('run-menu-btn'));
assert.ok(editor.includes('LaunchPlayersModal'));
assert.ok(editor.includes('top.runWithPlayers'));
}); });
void test('NpcsEditorApp: token appearance via PlayerTokenView', () => { void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8'); const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView')); assert.ok(src.includes('PlayerTokenView'));
assert.ok(src.includes('ringColor') || src.includes('updateNpcFields')); assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
}); });
+11 -4
View File
@@ -245,6 +245,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
y: 0, y: 0,
groupId: gChild, groupId: gChild,
ringColor: '#c9a227', ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 }, imageOffset: { x: 0, y: 0 },
imageScale: 1, imageScale: 1,
}, },
@@ -257,6 +258,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
y: 0, y: 0,
groupId: gChild, groupId: gChild,
ringColor: '#c9a227', ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 }, imageOffset: { x: 0, y: 0 },
imageScale: 1, imageScale: 1,
}, },
@@ -269,6 +271,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
y: 0, y: 0,
groupId: asNpcGroupId('g_other'), groupId: asNpcGroupId('g_other'),
ringColor: '#c9a227', ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 }, imageOffset: { x: 0, y: 0 },
imageScale: 1, imageScale: 1,
}, },
@@ -329,9 +332,9 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
[sceneId]: { [sceneId]: {
...scene('s1', 'Start'), ...scene('s1', 'Start'),
npcTokens: [ npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: n1, nx: 0.2, ny: 0.3, sizeN: 0.1 }, { id: asSceneNpcTokenId('nt1'), npcId: n1, nx: 0.2, ny: 0.3, sizeN: 0.1, disposition: 'neutral' },
{ id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1 }, { id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1, disposition: 'hostile' },
{ id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, sizeN: 0.1 }, { id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, sizeN: 0.1, disposition: 'neutral' },
], ],
}, },
}, },
@@ -358,6 +361,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
y: 0, y: 0,
groupId: null, groupId: null,
ringColor: '#c9a227', ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 }, imageOffset: { x: 0, y: 0 },
imageScale: 1, imageScale: 1,
}, },
@@ -370,6 +374,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
y: 0, y: 0,
groupId: null, groupId: null,
ringColor: '#c9a227', ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 }, imageOffset: { x: 0, y: 0 },
imageScale: 1, imageScale: 1,
}, },
@@ -395,7 +400,7 @@ void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', ()
[asSceneId('s1')]: { [asSceneId('s1')]: {
...scene('s1', 'Side'), ...scene('s1', 'Side'),
npcTokens: [ npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: sourceNpcId, nx: 0.4, ny: 0.6, sizeN: 0.12 }, { id: asSceneNpcTokenId('nt1'), npcId: sourceNpcId, nx: 0.4, ny: 0.6, sizeN: 0.12, disposition: 'friendly' },
], ],
}, },
}, },
@@ -422,6 +427,7 @@ void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', ()
y: 0, y: 0,
groupId: null, groupId: null,
ringColor: '#aabbcc', ringColor: '#aabbcc',
disposition: 'neutral',
imageOffset: { x: 0.1, y: -0.1 }, imageOffset: { x: 0.1, y: -0.1 },
imageScale: 1, imageScale: 1,
}, },
@@ -480,6 +486,7 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
y: 0, y: 0,
groupId: null, groupId: null,
ringColor: '#c9a227', ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 }, imageOffset: { x: 0, y: 0 },
imageScale: 1, imageScale: 1,
}, },
@@ -697,6 +697,7 @@ export function mergeStorylinesIntoProject(
y: n.y, y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null, groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
ringColor: n.ringColor ?? '#c9a227', ringColor: n.ringColor ?? '#c9a227',
disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
imageOffset: n.imageOffset ?? { x: 0, y: 0 }, imageOffset: n.imageOffset ?? { x: 0, y: 0 },
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1, imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
}); });
+1
View File
@@ -13,5 +13,6 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
assert.match(src, /list:\s*'players\.list'/); assert.match(src, /list:\s*'players\.list'/);
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/); assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
assert.match(src, /sceneNpcTokensSession:\s*\{/); assert.match(src, /sceneNpcTokensSession:\s*\{/);
assert.match(src, /scenePlayerTokensSession:\s*\{/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/); assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
}); });
+21 -1
View File
@@ -12,6 +12,7 @@ import type {
NpcId, NpcId,
NpcRelationId, NpcRelationId,
NpcGroupId, NpcGroupId,
NpcDisposition,
NpcsOverlayEvent, NpcsOverlayEvent,
NpcsOverlayState, NpcsOverlayState,
Project, Project,
@@ -31,6 +32,8 @@ import type {
SceneNpcToken, SceneNpcToken,
SceneNpcTokensSessionEvent, SceneNpcTokensSessionEvent,
SceneNpcTokensSessionState, SceneNpcTokensSessionState,
ScenePlayerTokensSessionEvent,
ScenePlayerTokensSessionState,
SceneToken, SceneToken,
SceneTokensSessionEvent, SceneTokensSessionEvent,
SceneTokensSessionState, SceneTokensSessionState,
@@ -210,6 +213,11 @@ export const ipcChannels = {
dispatch: 'sceneNpcTokensSession.dispatch', dispatch: 'sceneNpcTokensSession.dispatch',
stateChanged: 'sceneNpcTokensSession.stateChanged', stateChanged: 'sceneNpcTokensSession.stateChanged',
}, },
scenePlayerTokensSession: {
getState: 'scenePlayerTokensSession.getState',
dispatch: 'scenePlayerTokensSession.dispatch',
stateChanged: 'scenePlayerTokensSession.stateChanged',
},
video: { video: {
getState: 'video.getState', getState: 'video.getState',
dispatch: 'video.dispatch', dispatch: 'video.dispatch',
@@ -285,6 +293,7 @@ export type IpcEventMap = {
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] }; [ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent; [ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState }; [ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
@@ -400,6 +409,7 @@ export type IpcInvokeMap = {
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
ringColor?: string; ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: PlayerImageOffset; imageOffset?: PlayerImageOffset;
imageScale?: number; imageScale?: number;
}; };
@@ -412,6 +422,7 @@ export type IpcInvokeMap = {
description?: string; description?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
ringColor?: string; ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: PlayerImageOffset; imageOffset?: PlayerImageOffset;
imageScale?: number; imageScale?: number;
}; };
@@ -610,7 +621,7 @@ export type IpcInvokeMap = {
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.openMultiWindow]: { [ipcChannels.windows.openMultiWindow]: {
req: Record<string, never>; req: { playerIds?: string[] };
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.closeMultiWindow]: { [ipcChannels.windows.closeMultiWindow]: {
@@ -801,6 +812,14 @@ export type IpcInvokeMap = {
req: { event: SceneNpcTokensSessionEvent }; req: { event: SceneNpcTokensSessionEvent };
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.scenePlayerTokensSession.getState]: {
req: Record<string, never>;
res: { state: ScenePlayerTokensSessionState };
};
[ipcChannels.scenePlayerTokensSession.dispatch]: {
req: { event: ScenePlayerTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: { [ipcChannels.video.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: { state: VideoPlaybackState }; res: { state: VideoPlaybackState };
@@ -845,6 +864,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] }; [ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent; [ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState }; [ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>; [ipcChannels.license.statusChanged]: Record<string, never>;
}; };
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { asPlayerId, asPlayerTeamId } from '../types/ids';
import { layoutPlayerTokensBottom } from '../types/appPlayers';
import { resolveLaunchPlayerIds } from './resolveLaunchPlayerIds';
void test('layoutPlayerTokensBottom spaces tokens along bottom', () => {
const layout = layoutPlayerTokensBottom(['a', 'b', 'c'], 0.1);
assert.equal(layout.a?.ny, 0.88);
assert.equal(layout.a?.nx, 0.25);
assert.equal(layout.b?.nx, 0.5);
assert.equal(layout.c?.nx, 0.75);
});
void test('resolveLaunchPlayerIds merges players and teams with dedupe', () => {
const teamId = asPlayerTeamId('t1');
const players = [
{
id: asPlayerId('p1'),
name: 'A',
imageRelPath: 'x',
sha256: '1',
teamId,
ringColor: '#fff',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: asPlayerId('p2'),
name: 'B',
imageRelPath: 'y',
sha256: '2',
teamId: null,
ringColor: '#fff',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
];
const ids = resolveLaunchPlayerIds(players, new Set(['p1', 'p2']), new Set(['t1']));
assert.deepEqual([...ids].sort(), ['p1', 'p2']);
});
@@ -0,0 +1,15 @@
import type { AppPlayer } from '../types';
/** Игроки + члены выбранных команд, без дублей. */
export function resolveLaunchPlayerIds(
players: readonly AppPlayer[],
selectedPlayerIds: ReadonlySet<string>,
selectedTeamIds: ReadonlySet<string>,
): string[] {
const out = new Set<string>();
for (const id of selectedPlayerIds) out.add(id);
for (const p of players) {
if (p.teamId && selectedTeamIds.has(String(p.teamId))) out.add(String(p.id));
}
return [...out];
}
+54 -1
View File
@@ -1,5 +1,7 @@
/** App-local библиотека живых игроков (userData, не в project zip). */ /** App-local библиотека живых игроков (userData, не в project zip). */
import type { NpcDisposition } from './npcDisposition';
import { normalizeNpcDisposition } from './npcDisposition';
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids'; import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids'; import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
import { normalizeHexColor } from '../npcs/npcGroups'; import { normalizeHexColor } from '../npcs/npcGroups';
@@ -37,6 +39,8 @@ export type SceneNpcToken = {
nx: number; nx: number;
ny: number; ny: number;
sizeN: number; sizeN: number;
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
disposition: NpcDisposition;
}; };
export const DEFAULT_PLAYER_RING_COLOR = '#c9a227'; export const DEFAULT_PLAYER_RING_COLOR = '#c9a227';
@@ -57,23 +61,71 @@ export const DEFAULT_NPC_TOKEN_SESSION_SCALE = 1;
export const NPC_TOKEN_SESSION_SCALE_MIN = 0.4; export const NPC_TOKEN_SESSION_SCALE_MIN = 0.4;
export const NPC_TOKEN_SESSION_SCALE_MAX = 2.5; export const NPC_TOKEN_SESSION_SCALE_MAX = 2.5;
export type SceneNpcTokensSessionPlacement = {
/** Есть после move; без move позиция берётся из сцены. */
nx?: number;
ny?: number;
/** Session-only override типа экземпляра. */
disposition?: NpcDisposition;
/** Session-only: серая рамка и grayscale. */
inactive?: boolean;
};
export type SceneNpcTokensSessionState = { export type SceneNpcTokensSessionState = {
revision: number; revision: number;
byPlacementId: Record<string, { nx: number; ny: number }>; byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
/** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */ /** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
scale: number; scale: number;
}; };
export type SceneNpcTokensSessionEvent = export type SceneNpcTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number } | { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
| { kind: 'setInactive'; placementId: string; inactive: boolean }
| { kind: 'setScale'; scale: number } | { kind: 'setScale'; scale: number }
| { kind: 'clear' }; | { kind: 'clear' };
/** Session-only токены живых игроков на карте во время показа. */
export type ScenePlayerTokenPlacement = { nx: number; ny: number; sizeN: number };
export type ScenePlayerTokensSessionState = {
revision: number;
selectedPlayerIds: string[];
visible: boolean;
byPlayerId: Record<string, ScenePlayerTokenPlacement>;
};
export type ScenePlayerTokensSessionEvent =
| { kind: 'setSelection'; playerIds: readonly string[] }
| { kind: 'setVisible'; visible: boolean }
| { kind: 'show'; sizeN: number }
| { kind: 'seedBottom'; sizeN: number }
| { kind: 'move'; playerId: string; nx: number; ny: number }
| { kind: 'clearPlacements' }
| { kind: 'clear' };
export function clampNpcTokenSessionScale(raw: unknown): number { export function clampNpcTokenSessionScale(raw: unknown): number {
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_NPC_TOKEN_SESSION_SCALE; const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_NPC_TOKEN_SESSION_SCALE;
return Math.max(NPC_TOKEN_SESSION_SCALE_MIN, Math.min(NPC_TOKEN_SESSION_SCALE_MAX, n)); return Math.max(NPC_TOKEN_SESSION_SCALE_MIN, Math.min(NPC_TOKEN_SESSION_SCALE_MAX, n));
} }
/** Ряд токенов внизу карты (первый показ «Показать игроков»). */
export function layoutPlayerTokensBottom(
playerIds: readonly string[],
sizeN: number,
): Record<string, ScenePlayerTokenPlacement> {
const clamped = clampSceneNpcTokenSizeN(sizeN);
const n = playerIds.length;
const out: Record<string, ScenePlayerTokenPlacement> = {};
const ny = 0.88;
for (let i = 0; i < n; i += 1) {
const id = playerIds[i];
if (!id) continue;
out[id] = { nx: (i + 1) / (n + 1), ny, sizeN: clamped };
}
return out;
}
export type PlayersUpsertProgressEvent = { export type PlayersUpsertProgressEvent = {
percent: number; percent: number;
stage: string; stage: string;
@@ -154,5 +206,6 @@ export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
nx: Math.max(0, Math.min(1, nx)), nx: Math.max(0, Math.min(1, nx)),
ny: Math.max(0, Math.min(1, ny)), ny: Math.max(0, Math.min(1, ny)),
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N), sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
}; };
} }
+6 -1
View File
@@ -11,6 +11,7 @@ import type {
import type { MaterialLegend } from './materialLegend'; import type { MaterialLegend } from './materialLegend';
import type { SceneToken } from './appTokens'; import type { SceneToken } from './appTokens';
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers'; import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
import type { NpcDisposition } from './npcDisposition';
import type { SceneGrid } from './sceneGrid'; import type { SceneGrid } from './sceneGrid';
import type { SceneTrap } from './sceneTraps'; import type { SceneTrap } from './sceneTraps';
@@ -47,8 +48,12 @@ export type ProjectNpc = {
y: number; y: number;
/** `null` — системная секция «Без группы». */ /** `null` — системная секция «Без группы». */
groupId: NpcGroupId | null; groupId: NpcGroupId | null;
/** Цвет кольца игрового токена на сцене. */ /**
* @deprecated Цвет кольца выводится из `disposition`. Поле оставлено для совместимости старых проектов.
*/
ringColor: string; ringColor: string;
/** Дефолтный тип токена при постановке на карту. */
disposition: NpcDisposition;
/** Сдвиг аватара внутри круга токена. */ /** Сдвиг аватара внутри круга токена. */
imageOffset: PlayerImageOffset; imageOffset: PlayerImageOffset;
/** Масштаб аватара внутри круга токена. */ /** Масштаб аватара внутри круга токена. */
+1
View File
@@ -5,6 +5,7 @@ export * from './effects';
export * from './ids'; export * from './ids';
export * from './materialLegend'; export * from './materialLegend';
export * from './materials'; export * from './materials';
export * from './npcDisposition';
export * from './npcs'; export * from './npcs';
export * from './sceneDarkness'; export * from './sceneDarkness';
export * from './sceneGrid'; export * from './sceneGrid';
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_NPC_DISPOSITION,
normalizeNpcDisposition,
npcDispositionRingColor,
otherNpcDispositions,
} from './npcDisposition';
void test('normalizeNpcDisposition defaults to neutral', () => {
assert.equal(normalizeNpcDisposition(undefined), DEFAULT_NPC_DISPOSITION);
assert.equal(normalizeNpcDisposition('hostile'), 'hostile');
assert.equal(normalizeNpcDisposition('friendly'), 'friendly');
assert.equal(normalizeNpcDisposition('nope'), 'neutral');
});
void test('npcDispositionRingColor and inactive', () => {
assert.equal(npcDispositionRingColor('hostile'), '#e53935');
assert.equal(npcDispositionRingColor('neutral'), '#c9a227');
assert.equal(npcDispositionRingColor('friendly'), '#43a047');
assert.equal(npcDispositionRingColor('hostile', true), '#9e9e9e');
});
void test('otherNpcDispositions hides current', () => {
assert.deepEqual(otherNpcDispositions('neutral'), ['hostile', 'friendly']);
assert.deepEqual(otherNpcDispositions('hostile'), ['neutral', 'friendly']);
});
+29
View File
@@ -0,0 +1,29 @@
/** Отношение НПС: задаёт цвет кольца токена. */
export const NPC_DISPOSITIONS = ['hostile', 'neutral', 'friendly'] as const;
export type NpcDisposition = (typeof NPC_DISPOSITIONS)[number];
export const DEFAULT_NPC_DISPOSITION: NpcDisposition = 'neutral';
export const NPC_DISPOSITION_RING_COLOR: Record<NpcDisposition, string> = {
hostile: '#e53935',
neutral: '#c9a227',
friendly: '#43a047',
};
/** Серый для session-only «Неактивен». */
export const NPC_INACTIVE_RING_COLOR = '#9e9e9e';
export function normalizeNpcDisposition(raw: unknown): NpcDisposition {
if (raw === 'hostile' || raw === 'friendly' || raw === 'neutral') return raw;
return DEFAULT_NPC_DISPOSITION;
}
export function npcDispositionRingColor(disposition: NpcDisposition, inactive = false): string {
if (inactive) return NPC_INACTIVE_RING_COLOR;
return NPC_DISPOSITION_RING_COLOR[disposition];
}
export function otherNpcDispositions(current: NpcDisposition): NpcDisposition[] {
return NPC_DISPOSITIONS.filter((d) => d !== current);
}
+2 -1
View File
@@ -47,7 +47,7 @@ async function seedProjectWithNpcAndPlacement(
const npcUpsert = await invokeInRenderer<{ project: Project }>(page, 'project.upsertNpc', { const npcUpsert = await invokeInRenderer<{ project: Project }>(page, 'project.upsertNpc', {
name: `Guard ${Date.now()}`, name: `Guard ${Date.now()}`,
filePath: sampleImagePath, filePath: sampleImagePath,
ringColor: '#c9a227', disposition: 'neutral',
imageOffset: { x: 0, y: 0 }, imageOffset: { x: 0, y: 0 },
}); });
const npc = npcUpsert.project.npcs[npcUpsert.project.npcs.length - 1]!; const npc = npcUpsert.project.npcs[npcUpsert.project.npcs.length - 1]!;
@@ -63,6 +63,7 @@ async function seedProjectWithNpcAndPlacement(
nx: 0.35, nx: 0.35,
ny: 0.4, ny: 0.4,
sizeN: 0.12, sizeN: 0.12,
disposition: 'neutral',
}, },
], ],
}, },
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/types/appPlayers.test.ts app/shared/types/sceneGrid.test.ts app/shared/players/playerTeams.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",