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:
@@ -448,6 +448,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
y: 80 + Math.floor(npcIndex / 4) * 200,
|
||||
groupId,
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
});
|
||||
|
||||
+37
-3
@@ -31,6 +31,7 @@ import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { PlayersStore } from './players/playersStore';
|
||||
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
||||
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
||||
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
||||
import { TokensStore } from './tokens/tokensStore';
|
||||
import { installAutoUpdater } from './update/installAutoUpdater';
|
||||
@@ -174,6 +175,7 @@ const materialsOverlayStore = new MaterialsOverlayStore();
|
||||
const npcsOverlayStore = new NpcsOverlayStore();
|
||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
||||
let tokensStore: TokensStore | null = null;
|
||||
let playersStore: PlayersStore | null = null;
|
||||
|
||||
@@ -265,6 +267,13 @@ function emitSceneNpcTokensSessionState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function emitScenePlayerTokensSessionState(): void {
|
||||
const state = scenePlayerTokensSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.scenePlayerTokensSession.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function syncSceneDarknessForProject(project: Project): void {
|
||||
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
||||
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
|
||||
@@ -435,11 +444,16 @@ async function main() {
|
||||
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
|
||||
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
||||
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||
if (playerIds.length > 0) {
|
||||
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
|
||||
}
|
||||
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
|
||||
openMultiWindow();
|
||||
const project = projectStore.getOpenProject();
|
||||
@@ -451,6 +465,7 @@ async function main() {
|
||||
emitSceneTrapsState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitEffectsState();
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -548,9 +563,11 @@ async function main() {
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitSessionState();
|
||||
warmNpcsEditorWindow();
|
||||
return { project };
|
||||
@@ -566,6 +583,7 @@ async function main() {
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
@@ -574,6 +592,7 @@ async function main() {
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -590,6 +609,7 @@ async function main() {
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
scenePlayerTokensSessionStore.clearPlacements();
|
||||
// Token moves persist for the whole play session (reset only on project open/close).
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
@@ -603,6 +623,7 @@ async function main() {
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitSessionState();
|
||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||
});
|
||||
@@ -619,6 +640,7 @@ async function main() {
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
scenePlayerTokensSessionStore.clearPlacements();
|
||||
// Token moves persist for the whole play session (reset only on project open/close).
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
@@ -632,6 +654,7 @@ async function main() {
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitSessionState();
|
||||
const p = projectStore.getOpenProject();
|
||||
return {
|
||||
@@ -784,7 +807,7 @@ async function main() {
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.upsertNpc,
|
||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, imageOffset }) => {
|
||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath && !npcId) {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
@@ -809,6 +832,7 @@ async function main() {
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(ringColor !== undefined ? { ringColor } : {}),
|
||||
...(disposition !== undefined ? { disposition } : {}),
|
||||
...(imageOffset !== undefined ? { imageOffset } : {}),
|
||||
},
|
||||
(p) => emitNpcUpsertProgress(p),
|
||||
@@ -821,13 +845,15 @@ async function main() {
|
||||
);
|
||||
registerHandler(
|
||||
ipcChannels.project.updateNpcFields,
|
||||
async ({ npcId, name, description, groupId, ringColor, imageOffset }) => {
|
||||
async ({ npcId, name, description, groupId, ringColor, disposition, imageOffset, imageScale }) => {
|
||||
const project = await projectStore.updateNpcFields(npcId, {
|
||||
...(typeof name === 'string' ? { name } : {}),
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(ringColor !== undefined ? { ringColor } : {}),
|
||||
...(disposition !== undefined ? { disposition } : {}),
|
||||
...(imageOffset !== undefined ? { imageOffset } : {}),
|
||||
...(imageScale !== undefined ? { imageScale } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
@@ -1428,6 +1454,14 @@ async function main() {
|
||||
emitSceneNpcTokensSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
|
||||
return { state: scenePlayerTokensSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
|
||||
scenePlayerTokensSessionStore.dispatch(event);
|
||||
emitScenePlayerTokensSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.video.getState, () => {
|
||||
return { state: videoStore.getState() };
|
||||
|
||||
@@ -30,3 +30,31 @@ void test('SceneNpcTokensSessionStore setScale', () => {
|
||||
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
|
||||
assert.deepEqual(s3.byPlacementId, {});
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({
|
||||
kind: 'setDisposition',
|
||||
placementId: 'a',
|
||||
disposition: 'hostile',
|
||||
});
|
||||
assert.equal(s1.byPlacementId.a?.disposition, 'hostile');
|
||||
assert.equal(s1.byPlacementId.a?.nx, undefined);
|
||||
assert.equal(s1.byPlacementId.a?.ny, undefined);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 });
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.4);
|
||||
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
assert.equal(s1.byPlacementId.a?.inactive, true);
|
||||
const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
|
||||
assert.deepEqual(s2.byPlacementId, {});
|
||||
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
|
||||
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
type SceneNpcTokensSessionEvent,
|
||||
type SceneNpcTokensSessionPlacement,
|
||||
type SceneNpcTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||
|
||||
function emptyState(revision = 1): SceneNpcTokensSessionState {
|
||||
return {
|
||||
@@ -13,6 +15,36 @@ function emptyState(revision = 1): SceneNpcTokensSessionState {
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
||||
}
|
||||
|
||||
function withMove(
|
||||
prev: SceneNpcTokensSessionPlacement | undefined,
|
||||
nx: number,
|
||||
ny: number,
|
||||
): SceneNpcTokensSessionPlacement {
|
||||
return {
|
||||
nx,
|
||||
ny,
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withoutPlacement(
|
||||
state: SceneNpcTokensSessionState,
|
||||
placementId: string,
|
||||
): SceneNpcTokensSessionState {
|
||||
if (!(placementId in state.byPlacementId)) return state;
|
||||
const { [placementId]: _removed, ...rest } = state.byPlacementId;
|
||||
return {
|
||||
revision: state.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: state.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneNpcTokensSessionStore {
|
||||
private state: SceneNpcTokensSessionState = emptyState();
|
||||
|
||||
@@ -57,7 +89,55 @@ export class SceneNpcTokensSessionStore {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: { nx, ny },
|
||||
[placementId]: withMove(prev, nx, ny),
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setDisposition': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const disposition = normalizeNpcDisposition(event.disposition);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev?.disposition === disposition) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
disposition,
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setInactive': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const inactive = Boolean(event.inactive);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (Boolean(prev?.inactive) === inactive) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
this.state = withoutPlacement(this.state, placementId);
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,12 @@ import {
|
||||
normalizeSceneNpcToken,
|
||||
type SceneNpcToken,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
DEFAULT_NPC_DISPOSITION,
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
type NpcDisposition,
|
||||
} from '../../shared/types/npcDisposition';
|
||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
||||
import type {
|
||||
@@ -1263,6 +1269,7 @@ export class ZipProjectStore {
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
disposition?: NpcDisposition;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
},
|
||||
@@ -1344,6 +1351,12 @@ export class ZipProjectStore {
|
||||
...(input.ringColor !== undefined
|
||||
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
||||
: {}),
|
||||
...(input.disposition !== undefined
|
||||
? {
|
||||
disposition: normalizeNpcDisposition(input.disposition),
|
||||
ringColor: npcDispositionRingColor(normalizeNpcDisposition(input.disposition)),
|
||||
}
|
||||
: {}),
|
||||
...(input.imageOffset !== undefined
|
||||
? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
|
||||
: {}),
|
||||
@@ -1354,6 +1367,7 @@ export class ZipProjectStore {
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||
const count = npcs.length;
|
||||
const disposition = normalizeNpcDisposition(input.disposition ?? DEFAULT_NPC_DISPOSITION);
|
||||
npcs.push({
|
||||
id: asNpcId(`npc_${this.randomId()}`),
|
||||
name,
|
||||
@@ -1362,7 +1376,8 @@ export class ZipProjectStore {
|
||||
x: 80 + (count % 4) * 220,
|
||||
y: 80 + Math.floor(count / 4) * 200,
|
||||
groupId: resolveGroup(input.groupId, null),
|
||||
ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR),
|
||||
disposition,
|
||||
ringColor: npcDispositionRingColor(disposition),
|
||||
imageOffset: clampPlayerImageOffset(input.imageOffset),
|
||||
imageScale: clampPlayerImageScale(input.imageScale),
|
||||
});
|
||||
@@ -1383,6 +1398,7 @@ export class ZipProjectStore {
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
disposition?: NpcDisposition;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
},
|
||||
@@ -1405,14 +1421,22 @@ export class ZipProjectStore {
|
||||
if (patch.groupId !== undefined) {
|
||||
groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
}
|
||||
const disposition =
|
||||
patch.disposition !== undefined
|
||||
? normalizeNpcDisposition(patch.disposition)
|
||||
: normalizeNpcDisposition(n.disposition);
|
||||
return {
|
||||
...n,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
groupId,
|
||||
...(patch.ringColor !== undefined
|
||||
? { ringColor: normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
||||
: {}),
|
||||
disposition,
|
||||
ringColor:
|
||||
patch.disposition !== undefined
|
||||
? npcDispositionRingColor(disposition)
|
||||
: patch.ringColor !== undefined
|
||||
? normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR)
|
||||
: n.ringColor || npcDispositionRingColor(disposition),
|
||||
...(patch.imageOffset !== undefined
|
||||
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
|
||||
: {}),
|
||||
@@ -2495,7 +2519,10 @@ function normalizeProject(p: Project): Project {
|
||||
x,
|
||||
y,
|
||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||
ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR),
|
||||
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
|
||||
ringColor: npcDispositionRingColor(
|
||||
normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
|
||||
),
|
||||
imageOffset: clampPlayerImageOffset(obj.imageOffset),
|
||||
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
|
||||
};
|
||||
|
||||
@@ -595,3 +595,43 @@
|
||||
.audioScrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: var(--color-surface-menu, #1a1e28);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1, #e8eaef);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItem:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
@@ -8,19 +9,27 @@ import {
|
||||
isNodeInSideStoryline,
|
||||
listSideStoryStarts,
|
||||
} from '../../shared/graph/sceneGraphLineage';
|
||||
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
|
||||
import type { GraphNodeId, NpcDisposition, Scene, SceneId, SceneViewCamera } from '../../shared/types';
|
||||
import {
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
NPC_TOKEN_SESSION_SCALE_MAX,
|
||||
NPC_TOKEN_SESSION_SCALE_MIN,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
|
||||
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { SceneNpcTokensOverlay } from '../shared/playerToken/SceneNpcTokensOverlay';
|
||||
import {
|
||||
resolveNpcTokenDisposition,
|
||||
SceneNpcTokensOverlay,
|
||||
} from '../shared/playerToken/SceneNpcTokensOverlay';
|
||||
import { ScenePlayerTokensOverlay } from '../shared/playerToken/ScenePlayerTokensOverlay';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
|
||||
import { useScenePlayerTokensSession } from '../shared/playerToken/useScenePlayerTokensSession';
|
||||
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
|
||||
import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
|
||||
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
|
||||
@@ -59,6 +68,12 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
|
||||
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
|
||||
const LIGHTNING_EFFECT_MS = 840;
|
||||
|
||||
function dispositionMakeKey(d: NpcDisposition): string {
|
||||
if (d === 'hostile') return 'npcs.makeHostile';
|
||||
if (d === 'friendly') return 'npcs.makeFriendly';
|
||||
return 'npcs.makeNeutral';
|
||||
}
|
||||
|
||||
function clampAudioGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 1;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
@@ -127,6 +142,13 @@ export function ControlApp() {
|
||||
const appTokens = useAppTokens();
|
||||
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
||||
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
|
||||
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
|
||||
const { players: appPlayers } = useAppPlayers();
|
||||
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||
@@ -1704,6 +1726,27 @@ export function ControlApp() {
|
||||
}}
|
||||
/>
|
||||
</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, {})}>
|
||||
{t('control.stopPresentation')}
|
||||
</Button>
|
||||
@@ -1914,11 +1957,33 @@ export function ControlApp() {
|
||||
library={session?.project?.npcs ?? []}
|
||||
session={sceneNpcTokensSession}
|
||||
viewport={previewContentRect}
|
||||
grid={currentScene?.grid}
|
||||
grid={currentScene?.grid ?? null}
|
||||
editable
|
||||
onMove={(placementId, nx, ny) => {
|
||||
sceneNpcTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
|
||||
}}
|
||||
onContextMenu={(e, placement) => {
|
||||
setNpcSessionCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
placementId: String(placement.id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{previewContentRect ? (
|
||||
<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}
|
||||
{previewContentRect ? (
|
||||
@@ -2315,6 +2380,80 @@ export function ControlApp() {
|
||||
</Surface>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,68 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.splitRun {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splitRun:disabled,
|
||||
.splitRun[aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.splitRunMain,
|
||||
.splitRunChevron {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.splitRunMain {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.splitRunMain:hover:not(:disabled),
|
||||
.splitRunChevron:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
|
||||
}
|
||||
|
||||
.splitRunMain:active:not(:disabled),
|
||||
.splitRunChevron:active:not(:disabled),
|
||||
.splitRunChevronOpen {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
|
||||
}
|
||||
|
||||
.splitRunDivider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.splitRunChevron {
|
||||
width: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.splitRunChevronSvg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editorSidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
|
||||
import { PlayersManagerModal } from './PlayersModals';
|
||||
import { LaunchPlayersModal } from './LaunchPlayersModal';
|
||||
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||
import type { ProjectNoticeCode } from './state/projectState';
|
||||
@@ -156,6 +157,10 @@ export function EditorApp() {
|
||||
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
|
||||
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
||||
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
|
||||
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
|
||||
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
||||
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const runSplitRef = useRef<HTMLDivElement | null>(null);
|
||||
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
|
||||
const onProjectNotice = useCallback(
|
||||
(code: ProjectNoticeCode) => {
|
||||
@@ -353,13 +358,17 @@ export function EditorApp() {
|
||||
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
||||
|
||||
const launchFromGraphNode = useCallback(
|
||||
(graphNodeId: GraphNodeId) => {
|
||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||
if (!licenseActive || launching) return;
|
||||
setLaunching(true);
|
||||
setRunMenuOpen(false);
|
||||
setLaunchPlayersOpen(false);
|
||||
void (async () => {
|
||||
try {
|
||||
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {
|
||||
...(playerIds && playerIds.length > 0 ? { playerIds } : {}),
|
||||
});
|
||||
} catch {
|
||||
setLaunching(false);
|
||||
}
|
||||
@@ -463,6 +472,23 @@ export function EditorApp() {
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [aboutMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runMenuOpen) return;
|
||||
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
|
||||
queueMicrotask(() => {
|
||||
if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 });
|
||||
else setRunMenuPos(null);
|
||||
});
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (!t) return;
|
||||
if (t.closest('[data-runmenu-root="1"]')) return;
|
||||
setRunMenuOpen(false);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [runMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let off: (() => void) | null = null;
|
||||
void (async () => {
|
||||
@@ -842,16 +868,55 @@ export function EditorApp() {
|
||||
<div className={styles.headerActions}>
|
||||
{state.project ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={runDisabled || launching}
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
<div
|
||||
ref={runSplitRef}
|
||||
className={styles.splitRun}
|
||||
data-runmenu-root="1"
|
||||
aria-disabled={runDisabled || launching ? true : undefined}
|
||||
>
|
||||
{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 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1549,6 +1614,38 @@ export function EditorApp() {
|
||||
/>
|
||||
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(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
|
||||
open={materialsManagerOpen}
|
||||
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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.backToProjects': 'К списку проектов',
|
||||
'top.appVersion': 'Версия приложения',
|
||||
'top.run': 'Запустить',
|
||||
'top.runMenuAria': 'Дополнительные варианты запуска',
|
||||
'top.runWithPlayers': 'Запустить с игроками',
|
||||
'top.runWithPlayersTitle': 'Запуск с игроками',
|
||||
'top.runWithPlayersHint': 'Выберите игроков и/или команды. Выбор сохранится на время сессии.',
|
||||
'top.runWithPlayersTeams': 'Команды',
|
||||
'top.runWithPlayersPlayers': 'Игроки',
|
||||
'top.runWithPlayersEmpty': 'Нет игроков в библиотеке.',
|
||||
'top.runWithPlayersConfirm': 'Запустить',
|
||||
'top.launching': 'Запуск…',
|
||||
'top.afterLicense': 'Доступно после активации лицензии',
|
||||
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
|
||||
@@ -207,7 +215,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.session.title': 'Запуск сессии',
|
||||
'help.section.session.body':
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить демонстрацию» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
|
||||
|
||||
'help.section.controlPanel.title': 'Пульт управления',
|
||||
'help.section.controlPanel.body':
|
||||
@@ -465,6 +473,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.avatarRequired': 'Выберите аватар.',
|
||||
'npcs.chooseAvatar': 'Выбрать аватар',
|
||||
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'npcs.disposition': 'ТИП',
|
||||
'npcs.disposition.hostile': 'Враждебный',
|
||||
'npcs.disposition.neutral': 'Нейтральный',
|
||||
'npcs.disposition.friendly': 'Дружественный',
|
||||
'npcs.makeHostile': 'Сделать враждебным',
|
||||
'npcs.makeNeutral': 'Сделать нейтральным',
|
||||
'npcs.makeFriendly': 'Сделать дружественным',
|
||||
'npcs.inactive': 'Неактивен',
|
||||
'npcs.makeActive': 'Сделать активным',
|
||||
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
||||
'npcs.description': 'ОПИСАНИЕ',
|
||||
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
||||
@@ -603,8 +620,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.passed': 'Пройдено',
|
||||
'control.noActiveScene': 'Нет активной сцены.',
|
||||
'control.screenPreview': 'Предпросмотр экрана',
|
||||
'control.npcTokenScale': 'Размер НПС',
|
||||
'control.stopPresentation': 'Выключить демонстрацию',
|
||||
'control.npcTokenScale': 'Размеры игр. токенов',
|
||||
'control.stopPresentation': 'Выключить',
|
||||
'control.showPlayers': 'Показать игроков',
|
||||
'control.hidePlayers': 'Скрыть игроков',
|
||||
'control.videoBrushHint':
|
||||
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
|
||||
'control.branches': 'Варианты ветвления',
|
||||
@@ -710,6 +729,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.backToProjects': 'Back to projects',
|
||||
'top.appVersion': 'App version',
|
||||
'top.run': 'Run',
|
||||
'top.runMenuAria': 'More launch options',
|
||||
'top.runWithPlayers': 'Run with players',
|
||||
'top.runWithPlayersTitle': 'Launch with players',
|
||||
'top.runWithPlayersHint': 'Select players and/or teams. The selection is kept for this session.',
|
||||
'top.runWithPlayersTeams': 'Teams',
|
||||
'top.runWithPlayersPlayers': 'Players',
|
||||
'top.runWithPlayersEmpty': 'No players in the library.',
|
||||
'top.runWithPlayersConfirm': 'Launch',
|
||||
'top.launching': 'Starting…',
|
||||
'top.afterLicense': 'Available after license activation',
|
||||
'top.setStartScene': 'Set a start scene on the graph (right‑click a node)',
|
||||
@@ -803,7 +830,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.session.title': 'Starting a session',
|
||||
'help.section.session.body':
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Stop presentation or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'help.section.controlPanel.body':
|
||||
@@ -1063,6 +1090,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.avatarRequired': 'Choose an avatar.',
|
||||
'npcs.chooseAvatar': 'Choose avatar',
|
||||
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'npcs.disposition': 'TYPE',
|
||||
'npcs.disposition.hostile': 'Hostile',
|
||||
'npcs.disposition.neutral': 'Neutral',
|
||||
'npcs.disposition.friendly': 'Friendly',
|
||||
'npcs.makeHostile': 'Make Hostile',
|
||||
'npcs.makeNeutral': 'Make Neutral',
|
||||
'npcs.makeFriendly': 'Make Friendly',
|
||||
'npcs.inactive': 'Inactive',
|
||||
'npcs.makeActive': 'Make Active',
|
||||
'npcs.ringColor': 'TOKEN RING COLOR',
|
||||
'npcs.description': 'DESCRIPTION',
|
||||
'npcs.descriptionPlaceholder': 'Character description…',
|
||||
@@ -1201,8 +1237,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.passed': 'Visited',
|
||||
'control.noActiveScene': 'No active scene.',
|
||||
'control.screenPreview': 'Screen preview',
|
||||
'control.npcTokenScale': 'NPC size',
|
||||
'control.stopPresentation': 'Stop presentation',
|
||||
'control.npcTokenScale': 'Play token size',
|
||||
'control.stopPresentation': 'Turn off',
|
||||
'control.showPlayers': 'Show players',
|
||||
'control.hidePlayers': 'Hide players',
|
||||
'control.videoBrushHint':
|
||||
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
|
||||
'control.branches': 'Branch options',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getDndApi } from '../shared/dndApi';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
|
||||
|
||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||
import { NpcEditModal } from './NpcEditModal';
|
||||
@@ -739,7 +740,7 @@ export function NpcsEditorApp() {
|
||||
<PlayerTokenView
|
||||
name={selected.name}
|
||||
imageUrl={selectedUrl}
|
||||
ringColor={selected.ringColor}
|
||||
ringColor={npcDispositionRingColor(selected.disposition ?? 'neutral')}
|
||||
imageOffset={selected.imageOffset}
|
||||
imageScale={selected.imageScale}
|
||||
sizePx={140}
|
||||
@@ -782,17 +783,21 @@ export function NpcsEditorApp() {
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<div className={styles.fieldLabel}>{t('npcs.ringColor')}</div>
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorInput}
|
||||
value={selected.ringColor}
|
||||
onChange={(e) => {
|
||||
<div className={styles.fieldLabel}>{t('npcs.disposition')}</div>
|
||||
<Select
|
||||
value={selected.disposition ?? 'neutral'}
|
||||
ariaLabel={t('npcs.disposition')}
|
||||
onChange={(next) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
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>
|
||||
|
||||
|
||||
@@ -465,3 +465,49 @@
|
||||
.toolbar button {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type {
|
||||
NpcDisposition,
|
||||
NpcId,
|
||||
ProjectNpc,
|
||||
SceneGrid,
|
||||
@@ -17,6 +18,11 @@ import {
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
otherNpcDispositions,
|
||||
} from '../../shared/types/npcDisposition';
|
||||
import {
|
||||
asSceneTokenId,
|
||||
asTokenId,
|
||||
@@ -39,6 +45,7 @@ import {
|
||||
} from '../../shared/types/sceneTraps';
|
||||
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
@@ -53,6 +60,11 @@ import { SceneTokenMarker } from './SceneTokenMarker';
|
||||
import { TokenEditModal } from './TokenEditModal';
|
||||
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 {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
@@ -127,7 +139,7 @@ function SceneNpcMarker({
|
||||
sizePx,
|
||||
selected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onContextMenu,
|
||||
onMovePointerDown,
|
||||
onResizePointerDown,
|
||||
}: {
|
||||
@@ -138,11 +150,12 @@ function SceneNpcMarker({
|
||||
sizePx: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
onMovePointerDown: (e: React.PointerEvent) => void;
|
||||
onResizePointerDown: (e: React.PointerEvent) => void;
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const disposition = normalizeNpcDisposition(placement.disposition ?? npc.disposition);
|
||||
return (
|
||||
<div
|
||||
data-testid={`scene-npc-token-${placement.id}`}
|
||||
@@ -151,7 +164,7 @@ function SceneNpcMarker({
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
onContextMenu(e);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -162,7 +175,7 @@ function SceneNpcMarker({
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
ringColor={npcDispositionRingColor(disposition)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={sizePx}
|
||||
@@ -174,6 +187,7 @@ function SceneNpcMarker({
|
||||
|
||||
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const disposition = normalizeNpcDisposition(npc.disposition);
|
||||
return (
|
||||
<div
|
||||
className={styles.npcTile}
|
||||
@@ -188,7 +202,7 @@ function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
ringColor={npcDispositionRingColor(disposition)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={70}
|
||||
@@ -199,6 +213,7 @@ function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
|
||||
export function SceneEditorApp() {
|
||||
const api = getDndApi();
|
||||
const { t } = useEditorI18n();
|
||||
const appTokens = useAppTokens();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [trapsOpen, setTrapsOpen] = useState(false);
|
||||
@@ -211,6 +226,11 @@ export function SceneEditorApp() {
|
||||
{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | 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 [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);
|
||||
@@ -439,12 +459,14 @@ export function SceneEditorApp() {
|
||||
localGrid.enabled && Number.isFinite(localGrid.sizeN)
|
||||
? clampSceneNpcTokenSizeN(localGrid.sizeN)
|
||||
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
|
||||
const npc = project?.npcs.find((item) => item.id === npcId);
|
||||
const placement: SceneNpcToken = {
|
||||
id: asSceneNpcTokenId(randomId('snpc')),
|
||||
npcId,
|
||||
nx,
|
||||
ny,
|
||||
sizeN: gridCellSize,
|
||||
disposition: normalizeNpcDisposition(npc?.disposition),
|
||||
};
|
||||
setSelected({ kind: 'npcToken', id: placement.id });
|
||||
persistNpcTokens([...npcTokensRef.current, placement]);
|
||||
@@ -871,9 +893,9 @@ export function SceneEditorApp() {
|
||||
sizePx={sizePx}
|
||||
selected={selected?.kind === 'npcToken' && selected.id === tok.id}
|
||||
onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })}
|
||||
onDelete={() => {
|
||||
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== tok.id));
|
||||
setSelected((current) => (current?.id === tok.id ? null : current));
|
||||
onContextMenu={(e) => {
|
||||
setSelected({ kind: 'npcToken', id: tok.id });
|
||||
setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
|
||||
}}
|
||||
onMovePointerDown={(e) => {
|
||||
if (spaceDownRef.current) return;
|
||||
@@ -1018,6 +1040,59 @@ export function SceneEditorApp() {
|
||||
document.body,
|
||||
)
|
||||
: 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay';
|
||||
import { ScenePlayerTokensOverlay } from './playerToken/ScenePlayerTokensOverlay';
|
||||
import { useAppPlayers } from './playerToken/useAppPlayers';
|
||||
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
|
||||
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
||||
@@ -28,6 +31,7 @@ import styles from './PresentationView.module.css';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
|
||||
|
||||
export type PresentationViewProps = {
|
||||
session: SessionState | null;
|
||||
@@ -50,8 +54,10 @@ export function PresentationView({
|
||||
const [materialsOverlay] = useMaterialsOverlayState();
|
||||
const [npcsOverlay] = useNpcsOverlayState();
|
||||
const appTokens = useAppTokens();
|
||||
const { players: appPlayers } = useAppPlayers();
|
||||
const [sceneTokensSession] = useSceneTokensSession();
|
||||
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
|
||||
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
const videoElRef = useRef<HTMLVideoElement | null>(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}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={scene.traps ?? []}
|
||||
|
||||
@@ -26,6 +26,8 @@ export type PlayerTokenViewProps = {
|
||||
className?: string;
|
||||
/** Без подписи имени (компактный маркер). */
|
||||
hideName?: boolean;
|
||||
/** Session «Неактивен»: серый фильтр аватара. */
|
||||
inactive?: boolean;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
@@ -41,6 +43,7 @@ export function PlayerTokenView({
|
||||
onImageScaleChange,
|
||||
className,
|
||||
hideName = false,
|
||||
inactive = false,
|
||||
'data-testid': testId,
|
||||
}: PlayerTokenViewProps) {
|
||||
const offset = clampPlayerImageOffset(imageOffset);
|
||||
@@ -128,7 +131,7 @@ export function PlayerTokenView({
|
||||
style={{
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
borderColor: ringColor,
|
||||
borderColor: inactive ? '#9ca3af' : ringColor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -143,6 +146,7 @@ export function PlayerTokenView({
|
||||
draggable={false}
|
||||
style={{
|
||||
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 type {
|
||||
NpcDisposition,
|
||||
ProjectNpc,
|
||||
SceneGrid,
|
||||
SceneNpcToken,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
} from '../../../shared/types/npcDisposition';
|
||||
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
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({
|
||||
placement,
|
||||
npc,
|
||||
@@ -24,7 +39,10 @@ function NpcSprite({
|
||||
editable,
|
||||
displayScale,
|
||||
gridFit,
|
||||
disposition,
|
||||
inactive,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
}: {
|
||||
placement: SceneNpcToken;
|
||||
npc: ProjectNpc;
|
||||
@@ -34,7 +52,10 @@ function NpcSprite({
|
||||
editable: boolean;
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
disposition: NpcDisposition;
|
||||
inactive: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
@@ -47,6 +68,7 @@ function NpcSprite({
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
|
||||
@@ -64,7 +86,7 @@ function NpcSprite({
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
onMove?.(String(placement.id), drag.lastNx, drag.lastNy);
|
||||
flush(drag.lastNx, drag.lastNy);
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
@@ -78,6 +100,15 @@ function NpcSprite({
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, placement);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={
|
||||
editable && onMove !== undefined
|
||||
? (e) => {
|
||||
@@ -107,6 +138,7 @@ function NpcSprite({
|
||||
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
|
||||
}
|
||||
@@ -116,10 +148,11 @@ function NpcSprite({
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
ringColor={npcDispositionRingColor(disposition, inactive)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={sizePx}
|
||||
inactive={inactive}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -133,6 +166,7 @@ export function SceneNpcTokensOverlay({
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
}: {
|
||||
placements: readonly SceneNpcToken[];
|
||||
library: readonly ProjectNpc[];
|
||||
@@ -142,6 +176,7 @@ export function SceneNpcTokensOverlay({
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
}) {
|
||||
if (!viewport) return null;
|
||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||
@@ -153,6 +188,8 @@ export function SceneNpcTokensOverlay({
|
||||
const npc = byId.get(placement.npcId);
|
||||
if (!npc) return null;
|
||||
const override = session?.byPlacementId[String(placement.id)];
|
||||
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
|
||||
const inactive = Boolean(override?.inactive);
|
||||
return (
|
||||
<NpcSprite
|
||||
key={placement.id}
|
||||
@@ -164,7 +201,10 @@ export function SceneNpcTokensOverlay({
|
||||
editable={editable}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
disposition={disposition}
|
||||
inactive={inactive}
|
||||
{...(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 type {
|
||||
SceneNpcTokensSessionEvent,
|
||||
SceneNpcTokensSessionPlacement,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
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(
|
||||
prev: SceneNpcTokensSessionState,
|
||||
event: SceneNpcTokensSessionEvent,
|
||||
@@ -34,11 +40,69 @@ function applyEvent(
|
||||
if (prev.scale === scale) return prev;
|
||||
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 {
|
||||
revision: prev.revision + 1,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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(presentation.includes('SceneNpcTokensOverlay'));
|
||||
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', () => {
|
||||
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
||||
assert.ok(src.includes('PlayerTokenView'));
|
||||
assert.ok(src.includes('ringColor') || src.includes('updateNpcFields'));
|
||||
assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
|
||||
});
|
||||
|
||||
@@ -245,6 +245,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
|
||||
y: 0,
|
||||
groupId: gChild,
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
},
|
||||
@@ -257,6 +258,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
|
||||
y: 0,
|
||||
groupId: gChild,
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
},
|
||||
@@ -269,6 +271,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
|
||||
y: 0,
|
||||
groupId: asNpcGroupId('g_other'),
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
},
|
||||
@@ -329,9 +332,9 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
|
||||
[sceneId]: {
|
||||
...scene('s1', 'Start'),
|
||||
npcTokens: [
|
||||
{ id: asSceneNpcTokenId('nt1'), npcId: n1, nx: 0.2, ny: 0.3, sizeN: 0.1 },
|
||||
{ id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1 },
|
||||
{ id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, 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, disposition: 'hostile' },
|
||||
{ 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,
|
||||
groupId: null,
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
},
|
||||
@@ -370,6 +374,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
|
||||
y: 0,
|
||||
groupId: null,
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
},
|
||||
@@ -395,7 +400,7 @@ void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', ()
|
||||
[asSceneId('s1')]: {
|
||||
...scene('s1', 'Side'),
|
||||
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,
|
||||
groupId: null,
|
||||
ringColor: '#aabbcc',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0.1, y: -0.1 },
|
||||
imageScale: 1,
|
||||
},
|
||||
@@ -480,6 +486,7 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
|
||||
y: 0,
|
||||
groupId: null,
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
},
|
||||
|
||||
@@ -697,6 +697,7 @@ export function mergeStorylinesIntoProject(
|
||||
y: n.y,
|
||||
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
|
||||
ringColor: n.ringColor ?? '#c9a227',
|
||||
disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
|
||||
imageOffset: n.imageOffset ?? { x: 0, y: 0 },
|
||||
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
|
||||
});
|
||||
|
||||
@@ -13,5 +13,6 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
|
||||
assert.match(src, /list:\s*'players\.list'/);
|
||||
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
|
||||
assert.match(src, /sceneNpcTokensSession:\s*\{/);
|
||||
assert.match(src, /scenePlayerTokensSession:\s*\{/);
|
||||
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
NpcGroupId,
|
||||
NpcDisposition,
|
||||
NpcsOverlayEvent,
|
||||
NpcsOverlayState,
|
||||
Project,
|
||||
@@ -31,6 +32,8 @@ import type {
|
||||
SceneNpcToken,
|
||||
SceneNpcTokensSessionEvent,
|
||||
SceneNpcTokensSessionState,
|
||||
ScenePlayerTokensSessionEvent,
|
||||
ScenePlayerTokensSessionState,
|
||||
SceneToken,
|
||||
SceneTokensSessionEvent,
|
||||
SceneTokensSessionState,
|
||||
@@ -210,6 +213,11 @@ export const ipcChannels = {
|
||||
dispatch: 'sceneNpcTokensSession.dispatch',
|
||||
stateChanged: 'sceneNpcTokensSession.stateChanged',
|
||||
},
|
||||
scenePlayerTokensSession: {
|
||||
getState: 'scenePlayerTokensSession.getState',
|
||||
dispatch: 'scenePlayerTokensSession.dispatch',
|
||||
stateChanged: 'scenePlayerTokensSession.stateChanged',
|
||||
},
|
||||
video: {
|
||||
getState: 'video.getState',
|
||||
dispatch: 'video.dispatch',
|
||||
@@ -285,6 +293,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||
@@ -400,6 +409,7 @@ export type IpcInvokeMap = {
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
disposition?: NpcDisposition;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
};
|
||||
@@ -412,6 +422,7 @@ export type IpcInvokeMap = {
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
disposition?: NpcDisposition;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
};
|
||||
@@ -610,7 +621,7 @@ export type IpcInvokeMap = {
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openMultiWindow]: {
|
||||
req: Record<string, never>;
|
||||
req: { playerIds?: string[] };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.closeMultiWindow]: {
|
||||
@@ -801,6 +812,14 @@ export type IpcInvokeMap = {
|
||||
req: { event: SceneNpcTokensSessionEvent };
|
||||
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]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: VideoPlaybackState };
|
||||
@@ -845,6 +864,7 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[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];
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/** App-local библиотека живых игроков (userData, не в project zip). */
|
||||
|
||||
import type { NpcDisposition } from './npcDisposition';
|
||||
import { normalizeNpcDisposition } from './npcDisposition';
|
||||
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
|
||||
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
|
||||
import { normalizeHexColor } from '../npcs/npcGroups';
|
||||
@@ -37,6 +39,8 @@ export type SceneNpcToken = {
|
||||
nx: number;
|
||||
ny: number;
|
||||
sizeN: number;
|
||||
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
|
||||
disposition: NpcDisposition;
|
||||
};
|
||||
|
||||
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_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 = {
|
||||
revision: number;
|
||||
byPlacementId: Record<string, { nx: number; ny: number }>;
|
||||
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
|
||||
/** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
|
||||
scale: number;
|
||||
};
|
||||
|
||||
export type SceneNpcTokensSessionEvent =
|
||||
| { 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: '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 {
|
||||
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));
|
||||
}
|
||||
|
||||
/** Ряд токенов внизу карты (первый показ «Показать игроков»). */
|
||||
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 = {
|
||||
percent: number;
|
||||
stage: string;
|
||||
@@ -154,5 +206,6 @@ export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
|
||||
nx: Math.max(0, Math.min(1, nx)),
|
||||
ny: Math.max(0, Math.min(1, ny)),
|
||||
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
|
||||
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import type { MaterialLegend } from './materialLegend';
|
||||
import type { SceneToken } from './appTokens';
|
||||
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
|
||||
import type { NpcDisposition } from './npcDisposition';
|
||||
import type { SceneGrid } from './sceneGrid';
|
||||
import type { SceneTrap } from './sceneTraps';
|
||||
|
||||
@@ -47,8 +48,12 @@ export type ProjectNpc = {
|
||||
y: number;
|
||||
/** `null` — системная секция «Без группы». */
|
||||
groupId: NpcGroupId | null;
|
||||
/** Цвет кольца игрового токена на сцене. */
|
||||
/**
|
||||
* @deprecated Цвет кольца выводится из `disposition`. Поле оставлено для совместимости старых проектов.
|
||||
*/
|
||||
ringColor: string;
|
||||
/** Дефолтный тип токена при постановке на карту. */
|
||||
disposition: NpcDisposition;
|
||||
/** Сдвиг аватара внутри круга токена. */
|
||||
imageOffset: PlayerImageOffset;
|
||||
/** Масштаб аватара внутри круга токена. */
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './effects';
|
||||
export * from './ids';
|
||||
export * from './materialLegend';
|
||||
export * from './materials';
|
||||
export * from './npcDisposition';
|
||||
export * from './npcs';
|
||||
export * from './sceneDarkness';
|
||||
export * from './sceneGrid';
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function seedProjectWithNpcAndPlacement(
|
||||
const npcUpsert = await invokeInRenderer<{ project: Project }>(page, 'project.upsertNpc', {
|
||||
name: `Guard ${Date.now()}`,
|
||||
filePath: sampleImagePath,
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
});
|
||||
const npc = npcUpsert.project.npcs[npcUpsert.project.npcs.length - 1]!;
|
||||
@@ -63,6 +63,7 @@ async function seedProjectWithNpcAndPlacement(
|
||||
nx: 0.35,
|
||||
ny: 0.4,
|
||||
sizeN: 0.12,
|
||||
disposition: 'neutral',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"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:write": "prettier . --write",
|
||||
"postinstall": "patch-package",
|
||||
|
||||
Reference in New Issue
Block a user