feat(control): spawn session NPCs and resize tokens in preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-18 14:42:06 +08:00
parent 1ab6ffd593
commit 1a9f0f2557
28 changed files with 1446 additions and 89 deletions
+81
View File
@@ -314,6 +314,28 @@ function emitSceneNpcTokensSessionState(): void {
}
}
let npcMapSpawnDragNpcId: string | null = null;
let npcMapSpawnHover: { nx: number; ny: number } | null = null;
function emitNpcMapSpawnDragState(): void {
const state = {
dragging: Boolean(npcMapSpawnDragNpcId),
npcId: npcMapSpawnDragNpcId,
hoverNx: npcMapSpawnHover?.nx ?? null,
hoverNy: npcMapSpawnHover?.ny ?? null,
};
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.npcMapSpawn.stateChanged, { state });
}
}
function clearNpcMapSpawnDrag(): void {
if (!npcMapSpawnDragNpcId && !npcMapSpawnHover) return;
npcMapSpawnDragNpcId = null;
npcMapSpawnHover = null;
emitNpcMapSpawnDragState();
}
function emitScenePlayerTokensSessionState(): void {
const state = scenePlayerTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -501,6 +523,7 @@ async function main() {
sceneTokensSessionStore.reset();
tokenPathSessionStore.reset();
sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset();
tokenGridSnapSessionStore.reset();
if (USERS_BRANCH_FEATURES_ENABLED) {
@@ -646,6 +669,7 @@ async function main() {
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset();
emitSceneViewState();
emitSceneTokensSessionState();
@@ -666,6 +690,7 @@ async function main() {
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset();
tokenGridSnapSessionStore.reset();
emitEffectsState();
@@ -1611,6 +1636,62 @@ async function main() {
emitSceneNpcTokensSessionState();
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.getState, () => {
return {
state: {
dragging: Boolean(npcMapSpawnDragNpcId),
npcId: npcMapSpawnDragNpcId,
hoverNx: npcMapSpawnHover?.nx ?? null,
hoverNy: npcMapSpawnHover?.ny ?? null,
},
};
});
registerHandler(ipcChannels.npcMapSpawn.beginDrag, ({ npcId }) => {
npcMapSpawnDragNpcId = String(npcId ?? '') || null;
npcMapSpawnHover = null;
emitNpcMapSpawnDragState();
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.setHover, ({ nx, ny }) => {
if (!npcMapSpawnDragNpcId) return { ok: true };
if (nx == null || ny == null || !Number.isFinite(nx) || !Number.isFinite(ny)) {
if (npcMapSpawnHover === null) return { ok: true };
npcMapSpawnHover = null;
emitNpcMapSpawnDragState();
return { ok: true };
}
const next = { nx: Math.max(0, Math.min(1, nx)), ny: Math.max(0, Math.min(1, ny)) };
if (npcMapSpawnHover && npcMapSpawnHover.nx === next.nx && npcMapSpawnHover.ny === next.ny) {
return { ok: true };
}
npcMapSpawnHover = next;
emitNpcMapSpawnDragState();
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.cancel, () => {
clearNpcMapSpawnDrag();
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.commit, () => {
const npcId = npcMapSpawnDragNpcId;
const hover = npcMapSpawnHover;
clearNpcMapSpawnDrag();
if (!npcId || !hover) return { spawned: false };
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId;
const npc = project?.npcs.find((item) => item.id === npcId);
if (!sceneId || !npc) return { spawned: false };
sceneNpcTokensSessionStore.dispatch({
kind: 'spawn',
npcId: npc.id,
sceneId,
nx: hover.nx,
ny: hover.ny,
disposition: npc.disposition,
});
emitSceneNpcTokensSessionState();
return { spawned: true };
});
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
return { state: scenePlayerTokensSessionStore.getState() };
});
@@ -46,6 +46,29 @@ void test('SceneNpcTokensSessionStore setDisposition without inventing position'
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
});
void test('SceneNpcTokensSessionStore spawn is session-only and despawn removes it', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({
kind: 'spawn',
npcId: 'npc1',
sceneId: 'sceneA',
nx: 0.4,
ny: 0.6,
disposition: 'hostile',
});
const ids = Object.keys(s1.spawned);
assert.equal(ids.length, 1);
const spawn = s1.spawned[ids[0]!];
assert.equal(spawn?.npcId, 'npc1');
assert.equal(spawn?.sceneId, 'sceneA');
assert.equal(spawn?.nx, 0.4);
const s2 = store.dispatch({ kind: 'move', placementId: ids[0]!, nx: 0.7, ny: 0.2 });
assert.equal(s2.spawned[ids[0]!]?.nx, 0.7);
const s3 = store.dispatch({ kind: 'despawn', placementId: ids[0]! });
assert.deepEqual(s3.spawned, {});
assert.equal(s3.byPlacementId[ids[0]!], undefined);
});
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
@@ -58,3 +81,24 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
assert.equal(s3.byPlacementId.a?.nx, 0.2);
assert.equal(s3.byPlacementId.a?.inactive, undefined);
});
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
assert.equal(s1.byPlacementId.a?.sizeN, 0.2);
assert.equal(s1.byPlacementId.a?.nx, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.3, ny: 0.4 });
assert.equal(s2.byPlacementId.a?.sizeN, 0.2);
assert.equal(s2.byPlacementId.a?.nx, 0.3);
const spawned = store.dispatch({
kind: 'spawn',
npcId: 'npc1',
sceneId: 'sceneA',
nx: 0.5,
ny: 0.5,
});
const id = Object.keys(spawned.spawned)[0]!;
const s3 = store.dispatch({ kind: 'setSize', placementId: id, sizeN: 0.18 });
assert.equal(s3.spawned[id]?.sizeN, 0.18);
assert.equal(s3.byPlacementId[id]?.sizeN, 0.18);
});
+119 -17
View File
@@ -1,23 +1,38 @@
import {
clampNpcTokenSessionScale,
clampSceneNpcTokenSizeN,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
asSceneNpcTokenId,
emptySceneNpcTokensSessionState,
newSceneNpcSessionSpawnId,
type SceneNpcSessionSpawn,
type SceneNpcTokensSessionEvent,
type SceneNpcTokensSessionPlacement,
type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers';
import { asNpcId } from '../../shared/types/ids';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(revision = 1): SceneNpcTokensSessionState {
return {
revision,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return (
p.nx === undefined &&
p.ny === undefined &&
p.sizeN === undefined &&
!p.disposition &&
!p.inactive
);
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement {
return {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
};
}
function withMove(
@@ -26,10 +41,9 @@ function withMove(
ny: number,
): SceneNpcTokensSessionPlacement {
return {
...restOf(prev),
nx,
ny,
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
};
}
@@ -43,11 +57,12 @@ function withoutPlacement(
revision: state.revision + 1,
byPlacementId: rest,
scale: state.scale,
spawned: state.spawned,
};
}
export class SceneNpcTokensSessionStore {
private state: SceneNpcTokensSessionState = emptyState();
private state: SceneNpcTokensSessionState = emptySceneNpcTokensSessionState();
getState(): SceneNpcTokensSessionState {
return this.state;
@@ -56,11 +71,12 @@ export class SceneNpcTokensSessionStore {
reset(): SceneNpcTokensSessionState {
if (
Object.keys(this.state.byPlacementId).length === 0 &&
Object.keys(this.state.spawned).length === 0 &&
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
this.state = emptySceneNpcTokensSessionState(this.state.revision + 1);
return this.state;
}
@@ -69,7 +85,8 @@ export class SceneNpcTokensSessionStore {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return this.state;
const ids = Object.keys(this.state.byPlacementId);
if (ids.length === 0) return this.state;
const spawnIds = Object.keys(this.state.spawned);
if (ids.length === 0 && spawnIds.length === 0) return this.state;
let changed = false;
const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {};
for (const id of ids) {
@@ -83,11 +100,20 @@ export class SceneNpcTokensSessionStore {
byPlacementId[id] = prev;
}
}
const spawned: SceneNpcTokensSessionState['spawned'] = {};
for (const id of spawnIds) {
const prev = this.state.spawned[id];
if (!prev) continue;
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
spawned[id] = { ...prev, nx: p.nx, ny: p.ny };
changed = true;
}
if (!changed) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId,
scale: this.state.scale,
spawned,
};
return this.state;
}
@@ -106,6 +132,51 @@ export class SceneNpcTokensSessionStore {
};
return this.state;
}
case 'spawn': {
const npcId = String(event.npcId ?? '');
const sceneId = String(event.sceneId ?? '');
if (!npcId || !sceneId) 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 id = event.placementId
? asSceneNpcTokenId(String(event.placementId))
: newSceneNpcSessionSpawnId();
const spawn: SceneNpcSessionSpawn = {
id,
sceneId,
npcId: asNpcId(npcId),
nx,
ny,
sizeN: clampSceneNpcTokenSizeN(
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
),
disposition: normalizeNpcDisposition(event.disposition),
};
this.state = {
revision: this.state.revision + 1,
byPlacementId: this.state.byPlacementId,
scale: this.state.scale,
spawned: { ...this.state.spawned, [String(spawn.id)]: spawn },
};
return this.state;
}
case 'despawn': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
if (!(placementId in this.state.spawned) && !(placementId in this.state.byPlacementId)) {
return this.state;
}
const { [placementId]: _s, ...spawned } = this.state.spawned;
const { [placementId]: _p, ...byPlacementId } = this.state.byPlacementId;
this.state = {
revision: this.state.revision + 1,
byPlacementId,
scale: this.state.scale,
spawned,
};
return this.state;
}
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
@@ -113,7 +184,11 @@ export class SceneNpcTokensSessionStore {
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlacementId[placementId];
const spawn = this.state.spawned[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
const spawned = spawn
? { ...this.state.spawned, [placementId]: { ...spawn, nx, ny } }
: this.state.spawned;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
@@ -121,6 +196,7 @@ export class SceneNpcTokensSessionStore {
[placementId]: withMove(prev, nx, ny),
},
scale: this.state.scale,
spawned,
};
return this.state;
}
@@ -129,12 +205,13 @@ export class SceneNpcTokensSessionStore {
if (!placementId) return this.state;
const disposition = normalizeNpcDisposition(event.disposition);
const prev = this.state.byPlacementId[placementId];
if (prev?.disposition === disposition) return this.state;
const spawn = this.state.spawned[placementId];
if (prev?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
return this.state;
}
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...restOf(prev),
disposition,
...(prev?.inactive ? { inactive: true } : {}),
};
this.state = {
revision: this.state.revision + 1,
@@ -143,6 +220,9 @@ export class SceneNpcTokensSessionStore {
[placementId]: next,
},
scale: this.state.scale,
spawned: spawn
? { ...this.state.spawned, [placementId]: { ...spawn, disposition } }
: this.state.spawned,
};
return this.state;
}
@@ -155,6 +235,7 @@ export class SceneNpcTokensSessionStore {
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
@@ -169,6 +250,27 @@ export class SceneNpcTokensSessionStore {
[placementId]: next,
},
scale: this.state.scale,
spawned: this.state.spawned,
};
return this.state;
}
case 'setSize': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const prev = this.state.byPlacementId[placementId];
const spawn = this.state.spawned[placementId];
if (prev?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { ...restOf(prev), sizeN },
},
scale: this.state.scale,
spawned: spawn
? { ...this.state.spawned, [placementId]: { ...spawn, sizeN } }
: this.state.spawned,
};
return this.state;
}
@@ -47,3 +47,15 @@ void test('ScenePlayerTokensSessionStore rotateMapCwSteps', () => {
assert.equal(s.byPlayerId.p1?.nx, 0.9);
assert.equal(s.byPlayerId.p1?.ny, 0.2);
});
void test('ScenePlayerTokensSessionStore setSize updates placement', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
const nxBefore = store.getState().byPlayerId.p1?.nx;
const s1 = store.dispatch({ kind: 'setSize', playerId: 'p1', sizeN: 0.22 });
assert.equal(s1.byPlayerId.p1?.sizeN, 0.22);
assert.equal(s1.byPlayerId.p1?.nx, nxBefore);
const s2 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.4, ny: 0.5 });
assert.equal(s2.byPlayerId.p1?.sizeN, 0.22);
});
@@ -155,6 +155,23 @@ export class ScenePlayerTokensSessionStore {
};
return this.state;
}
case 'setSize': {
const playerId = String(event.playerId ?? '');
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
const prev = this.state.byPlayerId[playerId];
if (!prev) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
if (prev.sizeN === sizeN) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: {
...this.state.byPlayerId,
[playerId]: { ...prev, sizeN },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneTokensSessionStore } from './sceneTokensSessionStore';
void test('SceneTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.2 });
assert.equal(s1.byPlacementId.t1?.sizeN, 0.2);
assert.equal(s1.byPlacementId.t1?.nx, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 't1', nx: 0.3, ny: 0.4 });
assert.equal(s2.byPlacementId.t1?.sizeN, 0.2);
assert.equal(s2.byPlacementId.t1?.nx, 0.3);
const s3 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.01 });
assert.equal(s3.byPlacementId.t1?.sizeN, 0.02); // clamped min
});
void test('SceneTokensSessionStore rotateMapCwSteps keeps sizeN', () => {
const store = new SceneTokensSessionStore();
store.dispatch({ kind: 'move', placementId: 't1', nx: 0.2, ny: 0.1 });
store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.15 });
const s = store.rotateMapCwSteps(1);
assert.equal(s.byPlacementId.t1?.nx, 0.9);
assert.equal(s.byPlacementId.t1?.ny, 0.2);
assert.equal(s.byPlacementId.t1?.sizeN, 0.15);
});
+44 -4
View File
@@ -1,4 +1,9 @@
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
import {
clampSceneTokenSizeN,
type SceneTokensSessionEvent,
type SceneTokensSessionPlacement,
type SceneTokensSessionState,
} from '../../shared/types';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(): SceneTokensSessionState {
@@ -8,6 +13,18 @@ function emptyState(): SceneTokensSessionState {
};
}
function withMove(
prev: SceneTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneTokensSessionPlacement {
return {
nx,
ny,
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
};
}
export class SceneTokensSessionStore {
private state: SceneTokensSessionState = emptyState();
@@ -34,8 +51,12 @@ export class SceneTokensSessionStore {
for (const id of ids) {
const prev = this.state.byPlacementId[id];
if (!prev) continue;
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlacementId[id] = { nx: next.nx, ny: next.ny };
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlacementId[id] = { ...prev, nx: next.nx, ny: next.ny };
} else {
byPlacementId[id] = prev;
}
}
this.state = {
revision: this.state.revision + 1,
@@ -60,7 +81,26 @@ export class SceneTokensSessionStore {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { nx, ny },
[placementId]: withMove(prev, nx, ny),
},
};
return this.state;
}
case 'setSize': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const sizeN = clampSceneTokenSizeN(event.sizeN);
const prev = this.state.byPlacementId[placementId];
if (prev?.sizeN === sizeN) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: {
...(typeof prev?.nx === 'number' ? { nx: prev.nx } : {}),
...(typeof prev?.ny === 'number' ? { ny: prev.ny } : {}),
sizeN,
},
},
};
return this.state;