feat(players): NPC disposition types and launch-with-players session tokens

Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 17:11:01 +08:00
parent 101f595bac
commit cc50e64e21
38 changed files with 1803 additions and 59 deletions
+1
View File
@@ -448,6 +448,7 @@ export async function buildProjectFromFoundryDocuments(
y: 80 + Math.floor(npcIndex / 4) * 200,
groupId,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
});
+37 -3
View File
@@ -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);
});
+81 -1
View File
@@ -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;
}
}
}
}
+32 -5
View File
@@ -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),
};