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;
@@ -363,6 +363,22 @@
inset: 0;
}
.previewHostSpawnDrag {
cursor: copy;
}
.npcSpawnGhost {
position: absolute;
z-index: 12;
width: 28px;
height: 28px;
border-radius: 999px;
transform: translate(-50%, -50%);
pointer-events: none;
border: 2px dashed rgba(96, 165, 250, 0.95);
background: rgba(96, 165, 250, 0.2);
}
.brushCursor {
position: absolute;
z-index: 2;
+100 -10
View File
@@ -22,6 +22,7 @@ import type {
import {
DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
listVisibleSceneNpcTokens,
NPC_TOKEN_SESSION_SCALE_MAX,
NPC_TOKEN_SESSION_SCALE_MIN,
} from '../../shared/types/appPlayers';
@@ -38,6 +39,7 @@ import {
} from '../shared/playerToken/SceneNpcTokensOverlay';
import { ScenePlayerTokensOverlay } from '../shared/playerToken/ScenePlayerTokensOverlay';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from '../shared/playerToken/useScenePlayerTokensSession';
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
@@ -161,6 +163,7 @@ export function ControlApp() {
const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
const [npcMapSpawnDrag, npcMapSpawnDragApi] = useNpcMapSpawnDrag();
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
@@ -385,6 +388,15 @@ export function ControlApp() {
const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const visibleNpcTokens = useMemo(
() =>
listVisibleSceneNpcTokens(
currentScene?.npcTokens,
sceneNpcTokensSession.spawned,
session?.currentSceneId,
),
[currentScene?.npcTokens, sceneNpcTokensSession.spawned, session?.currentSceneId],
);
const isDarkenScene = Boolean(currentScene?.darkenScene);
const snapNormActive = useCallback(
@@ -415,7 +427,7 @@ export function ControlApp() {
});
}
for (const placement of currentScene?.npcTokens ?? []) {
for (const placement of visibleNpcTokens) {
const key = String(placement.id);
const override = sceneNpcTokensSession?.byPlacementId[key];
const raw = {
@@ -448,8 +460,8 @@ export function ControlApp() {
}
}, [
currentScene?.grid,
currentScene?.npcTokens,
currentScene?.tokens,
visibleNpcTokens,
previewContentRect,
sceneNpcTokensApi,
sceneNpcTokensSession?.byPlacementId,
@@ -1005,7 +1017,7 @@ export function ControlApp() {
const pathLivePoses = useTokenPathLivePoses({
tokens: currentScene?.tokens ?? [],
npcTokens: currentScene?.npcTokens ?? [],
npcTokens: visibleNpcTokens,
pathSession: tokenPathSession,
enabled: Boolean(currentScene),
onMarkDone: (kind, placementId) => {
@@ -1013,7 +1025,7 @@ export function ControlApp() {
const placement =
kind === 'token'
? (currentScene?.tokens ?? []).find((t) => String(t.id) === placementId)
: (currentScene?.npcTokens ?? []).find((t) => String(t.id) === placementId);
: visibleNpcTokens.find((t) => String(t.id) === placementId);
if (!placement?.path) return;
const end = sampleTokenPathAtDistance(placement.path, tokenPathTotalLength(placement.path));
if (!end) return;
@@ -1047,13 +1059,40 @@ export function ControlApp() {
const npcPathDragEnabled = useMemo(() => {
const out: Record<string, boolean> = {};
for (const t of currentScene?.npcTokens ?? []) {
for (const t of visibleNpcTokens) {
const key = String(t.id);
const entry = tokenPathSession?.playback[tokenPathKey('npcToken', key)];
out[key] = !entry || entry.phase === 'stopped';
}
return out;
}, [currentScene?.npcTokens, tokenPathSession?.playback]);
}, [visibleNpcTokens, tokenPathSession?.playback]);
useEffect(() => {
if (!npcMapSpawnDrag.dragging) return;
const toNorm = (clientX: number, clientY: number) => {
const host = previewHostRef.current;
const cr = previewContentRectRef.current;
if (!host || !cr) return null;
const r = host.getBoundingClientRect();
const nx = (clientX - (r.left + cr.x)) / Math.max(1e-6, cr.w);
const ny = (clientY - (r.top + cr.y)) / Math.max(1e-6, cr.h);
if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return null;
return snapNormActive(nx, ny);
};
const onMove = (e: MouseEvent) => {
const p = toNorm(e.clientX, e.clientY);
void npcMapSpawnDragApi.setHover(p ? p.nx : null, p ? p.ny : null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') void npcMapSpawnDragApi.cancel();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('keydown', onKey);
};
}, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]);
useEffect(() => {
if (!markersInteractive) {
@@ -2025,7 +2064,15 @@ export function ControlApp() {
className={styles.previewFrame}
title="Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан"
>
<div ref={previewHostRef} className={styles.previewHost}>
<div
ref={previewHostRef}
className={[
styles.previewHost,
npcMapSpawnDrag.dragging ? styles.previewHostSpawnDrag : '',
]
.filter(Boolean)
.join(' ')}
>
<ControlScenePreview
session={session}
videoRef={previewVideoRef}
@@ -2033,6 +2080,18 @@ export function ControlApp() {
onContentRectChange={setPreviewContentRect}
/>
</div>
{npcMapSpawnDrag.dragging &&
previewContentRect &&
npcMapSpawnDrag.hoverNx != null &&
npcMapSpawnDrag.hoverNy != null ? (
<div
className={styles.npcSpawnGhost}
style={{
left: previewContentRect.x + npcMapSpawnDrag.hoverNx * previewContentRect.w,
top: previewContentRect.y + npcMapSpawnDrag.hoverNy * previewContentRect.h,
}}
/>
) : null}
<>
<SceneGridOverlay grid={currentScene?.grid} viewport={previewContentRect} />
<PixiEffectsOverlay
@@ -2209,7 +2268,7 @@ export function ControlApp() {
{previewContentRect ? (
<TokenPathsOverlay
tokens={currentScene?.tokens ?? []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (currentScene?.npcTokens ?? []) : []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []}
viewport={previewContentRect}
mode="always"
pathSession={tokenPathSession}
@@ -2234,6 +2293,10 @@ export function ControlApp() {
ny: snapped.ny,
});
}}
onResize={(placementId, sizeN) => {
void sceneTokensApi.dispatch({ kind: 'setSize', placementId, sizeN });
}}
resizeTitle={t('control.resizeToken')}
{...(markersInteractive
? {
onContextMenu: (
@@ -2252,7 +2315,7 @@ export function ControlApp() {
) : null}
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
<SceneNpcTokensOverlay
placements={currentScene?.npcTokens ?? []}
placements={visibleNpcTokens}
library={session?.project?.npcs ?? []}
session={sceneNpcTokensSession}
viewport={previewContentRect}
@@ -2270,6 +2333,10 @@ export function ControlApp() {
ny: snapped.ny,
});
}}
onResize={(placementId, sizeN) => {
sceneNpcTokensApi.dispatch({ kind: 'setSize', placementId, sizeN });
}}
resizeTitle={t('control.resizeToken')}
{...(markersInteractive
? {
onContextMenu: (
@@ -2306,6 +2373,10 @@ export function ControlApp() {
ny: snapped.ny,
});
}}
onResize={(playerId, sizeN) => {
scenePlayerTokensApi.dispatch({ kind: 'setSize', playerId, sizeN });
}}
resizeTitle={t('control.resizeToken')}
/>
) : null}
{previewContentRect ? (
@@ -2914,7 +2985,7 @@ export function ControlApp() {
role="menu"
>
{(() => {
const placement = (currentScene?.npcTokens ?? []).find(
const placement = visibleNpcTokens.find(
(item) => String(item.id) === npcSessionCtxMenu.placementId,
);
const npc = placement
@@ -2956,8 +3027,27 @@ export function ControlApp() {
</button>
);
}
const isSessionSpawn = Boolean(
sceneNpcTokensSession.spawned[npcSessionCtxMenu.placementId],
);
return (
<>
{isSessionSpawn ? (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
sceneNpcTokensApi.dispatch({
kind: 'despawn',
placementId: npcSessionCtxMenu.placementId,
});
setNpcSessionCtxMenu(null);
}}
>
{t('npcs.removeFromMap')}
</button>
) : null}
{path && path.points.length >= 2 ? (
<>
<button
+16 -2
View File
@@ -219,7 +219,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.controlPanel.title': 'Пульт управления',
'help.section.controlPanel.body':
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка НПС (цветная иконка человека) открывает окно персонажей — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены и токены НПС тоже видны на предпросмотре и их можно двигать до конца сессии; ПКМ по токену НПС — активировать / открыть информацию / сменить тип / сделать неактивным (см. «НПС»). На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размеры игр. токенов» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка НПС (цветная иконка человека) открывает окно персонажей — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии; ПКМ по токену НПС — активировать / открыть информацию / сменить тип / сделать неактивным (см. «НПС»). На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размеры игр. токенов» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
'help.section.transitions.title': 'Переходы между сценами',
'help.section.transitions.body':
@@ -514,6 +514,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
'npcs.zoomOutHint': 'Кликните по аватару в предпросмотре пульта, чтобы уменьшить.',
'npcs.zoomIdleHint': 'Выберите лупу, затем кликните по аватару в предпросмотре пульта.',
'npcs.addToMap': 'На карту',
'npcs.addToMapHint': 'Поставить токен на текущую сцену до конца сессии. Можно несколько раз.',
'npcs.addToMapNoScene': 'Нет активной сцены — сначала запустите игру.',
'npcs.dragToMapHint':
'Перетащите плитку на предпросмотр пульта или нажмите «На карту». Токен только на сессию.',
'npcs.removeFromMap': 'Убрать с карты',
'npcs.ungrouped': 'Без группы',
'npcs.addGroup': 'Новая группа',
'npcs.editGroup': 'Изменить группу',
@@ -624,6 +630,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.noActiveScene': 'Нет активной сцены.',
'control.screenPreview': 'Предпросмотр экрана',
'control.npcTokenScale': 'Размеры игр. токенов',
'control.resizeToken': 'Изменить размер',
'control.snapTokensToGrid': 'Привязка токенов к сетке',
'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется',
'control.stopPresentation': 'Выключить',
@@ -839,7 +846,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.controlPanel.title': 'Control panel',
'help.section.controlPanel.body':
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scenes formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player tokens and NPC tokens are also visible on the preview and can be moved until the session ends; right-click an NPC token to activate / open information / change type / make inactive (see NPCs). On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Play token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scenes formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends; right-click an NPC token to activate / open information / change type / make inactive (see NPCs). On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Play token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
'help.section.transitions.title': 'Scene transitions',
'help.section.transitions.body':
@@ -1137,6 +1144,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
'npcs.zoomOutHint': 'Click the avatar on the control preview to zoom out.',
'npcs.zoomIdleHint': 'Pick a magnifier, then click the avatar on the control preview.',
'npcs.addToMap': 'Add to map',
'npcs.addToMapHint': 'Place a token on the current scene until the session ends. You can add it more than once.',
'npcs.addToMapNoScene': 'No active scene — start the game first.',
'npcs.dragToMapHint':
'Drag a tile onto the control preview or press Add to map. Session-only token.',
'npcs.removeFromMap': 'Remove from map',
'npcs.ungrouped': 'Ungrouped',
'npcs.addGroup': 'New group',
'npcs.editGroup': 'Edit group',
@@ -1246,6 +1259,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.noActiveScene': 'No active scene.',
'control.screenPreview': 'Screen preview',
'control.npcTokenScale': 'Play token size',
'control.resizeToken': 'Resize',
'control.snapTokensToGrid': 'Snap tokens to grid',
'control.snapTokensToGridNoGrid': 'Grid is off on this scene — snap has no effect',
'control.stopPresentation': 'Turn off',
+67 -4
View File
@@ -121,19 +121,82 @@
.tile {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
grid-template-columns: auto 1fr;
gap: 4px;
align-items: center;
padding: 8px;
padding: 4px 8px 4px 4px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: #18181b;
cursor: pointer;
text-align: left;
color: inherit;
width: 100%;
}
.tileBody {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
align-items: center;
padding: 4px;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
color: inherit;
min-width: 0;
}
.tileMenuBtn {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text2);
cursor: pointer;
flex-shrink: 0;
}
.tileMenuBtn:hover {
background: #27272a;
color: var(--text1);
}
.menu {
position: fixed;
z-index: 80;
min-width: 160px;
padding: 6px;
border-radius: 10px;
background: #18181b;
border: 1px solid var(--stroke);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.menuItem {
border: 0;
background: transparent;
color: var(--text1);
text-align: left;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
width: 100%;
}
.menuItem:hover:not(:disabled) {
background: #27272a;
}
.menuItem:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tileSelected {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa;
+229 -10
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
@@ -8,8 +9,15 @@ import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import {
listVisibleSceneNpcTokens,
nextSessionNpcSpawnPoint,
} from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import styles from './NpcsApp.module.css';
@@ -54,26 +62,159 @@ function RuntimeNpcTile({
npc,
selected,
accentColor,
mapDragEnabled,
onActivate,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: {
npc: ProjectNpc;
selected: boolean;
accentColor?: string | null;
mapDragEnabled: boolean;
onActivate: () => void;
onAddToMap: () => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
}) {
const { t } = useEditorI18n();
const url = useAssetUrl(npc.avatarAssetId);
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const dragRef = useRef<{
pointerId: number;
x: number;
y: number;
started: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
useEffect(() => {
if (!menu) return;
const onPointerDown = (e: PointerEvent) => {
const tgt = e.target;
if (!(tgt instanceof Node)) return;
if (menuBtnRef.current?.contains(tgt)) return;
if (menuRef.current?.contains(tgt)) return;
setMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey);
};
}, [menu]);
return (
<button
type="button"
<div
className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
onClick={onActivate}
>
<div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tileName}>{npc.name}</div>
</button>
<button
ref={menuBtnRef}
type="button"
className={styles.tileMenuBtn}
aria-label={t('npcs.tileMenu')}
aria-haspopup="menu"
aria-expanded={menu !== null}
title={t('npcs.tileMenu')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const pad = 8;
const menuW = 180;
const menuH = 48;
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.max(pad, Math.min(rect.right + 4, window.innerWidth - menuW - pad));
const y = Math.max(pad, Math.min(rect.top, window.innerHeight - menuH - pad));
setMenu((cur) => (cur ? null : { x, y }));
}}
>
</button>
<button
type="button"
className={styles.tileBody}
onClick={(e) => {
if (!suppressClickRef.current) return;
suppressClickRef.current = false;
e.preventDefault();
e.stopPropagation();
}}
onPointerDown={(e) => {
if (e.button !== 0 || !mapDragEnabled) return;
dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false };
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId || d.started) return;
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return;
d.started = true;
onMapDragBegin(npc.id);
}}
onPointerUp={(e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
dragRef.current = null;
try {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
} catch {
/* ignore */
}
if (d.started) {
suppressClickRef.current = true;
e.preventDefault();
onMapDragEnd();
return;
}
onActivate();
}}
onPointerCancel={() => {
dragRef.current = null;
}}
>
<div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tileName}>{npc.name}</div>
</button>
{menu
? createPortal(
<div
ref={menuRef}
role="menu"
className={styles.menu}
style={{ left: menu.x, top: menu.y }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
disabled={!mapDragEnabled}
title={mapDragEnabled ? t('npcs.addToMapHint') : t('npcs.addToMapNoScene')}
onClick={() => {
setMenu(null);
if (!mapDragEnabled) return;
onAddToMap();
}}
>
{t('npcs.addToMap')}
</button>
</div>,
document.body,
)
: null}
</div>
);
}
@@ -83,14 +224,22 @@ function RuntimeGroupSection({
isExpanded,
onToggleExpanded,
selectedIds,
mapDragEnabled,
onActivate,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedIds: ReadonlySet<NpcId>;
mapDragEnabled: boolean;
onActivate: (id: NpcId) => void;
onAddToMap: (id: NpcId) => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
}) {
const g = node.group;
const expanded = isExpanded(g.id);
@@ -117,7 +266,11 @@ function RuntimeGroupSection({
npc={n}
selected={selectedIds.has(n.id)}
accentColor={g.color}
mapDragEnabled={mapDragEnabled}
onActivate={() => onActivate(n.id)}
onAddToMap={() => onAddToMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
{node.children.map((child) => (
@@ -128,7 +281,11 @@ function RuntimeGroupSection({
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedIds={selectedIds}
mapDragEnabled={mapDragEnabled}
onActivate={onActivate}
onAddToMap={onAddToMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
</div>
@@ -142,6 +299,8 @@ export function NpcsApp() {
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [overlay, overlayApi] = useNpcsOverlayState();
const [npcSession, npcSessionApi] = useSceneNpcTokensSession();
const [mapDrag, mapDragApi] = useNpcMapSpawnDrag();
const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
@@ -160,6 +319,10 @@ export function NpcsApp() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (mapDrag.dragging) {
void mapDragApi.cancel();
return;
}
if (hasActive) {
void overlayApi.dispatch({ kind: 'hide' });
return;
@@ -173,7 +336,7 @@ export function NpcsApp() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [hasActive, overlay?.zoomTool, overlayApi]);
}, [hasActive, mapDrag.dragging, mapDragApi, overlay?.zoomTool, overlayApi]);
const zoomTool = overlay?.zoomTool ?? null;
@@ -254,6 +417,47 @@ export function NpcsApp() {
[overlayApi],
);
const sceneId = session?.currentSceneId ?? null;
const currentScene = sceneId && session?.project ? session.project.scenes[sceneId] : undefined;
const mapDragEnabled = Boolean(sceneId);
const spawnNpcOnMap = useCallback(
(npcId: NpcId) => {
if (!sceneId) return;
const npc = npcs.find((item) => item.id === npcId);
if (!npc) return;
const visible = listVisibleSceneNpcTokens(
currentScene?.npcTokens,
npcSession.spawned,
sceneId,
);
const occupied = visible.map((tok) => {
const override = npcSession.byPlacementId[String(tok.id)];
return { nx: override?.nx ?? tok.nx, ny: override?.ny ?? tok.ny };
});
const point = nextSessionNpcSpawnPoint(occupied);
npcSessionApi.dispatch({
kind: 'spawn',
npcId,
sceneId,
nx: point.nx,
ny: point.ny,
disposition: normalizeNpcDisposition(npc.disposition),
});
},
[currentScene?.npcTokens, npcSession.byPlacementId, npcSession.spawned, npcSessionApi, npcs, sceneId],
);
const onMapDragBegin = useCallback(
(id: NpcId) => {
void mapDragApi.beginDrag(id);
},
[mapDragApi],
);
const onMapDragEnd = useCallback(() => {
void mapDragApi.commit();
}, [mapDragApi]);
return (
<div className={styles.page}>
<div className={styles.toolbar}>
@@ -296,6 +500,9 @@ export function NpcsApp() {
? t('npcs.zoomOutHint')
: t('npcs.zoomIdleHint')}
</div>
<div className={styles.toolbarHint}>
{sceneId ? t('npcs.dragToMapHint') : t('npcs.addToMapNoScene')}
</div>
<div className={styles.toolbarRow}>
<Button
title={t('npcs.closeOverlay')}
@@ -361,7 +568,11 @@ export function NpcsApp() {
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))
) : (
@@ -374,7 +585,11 @@ export function NpcsApp() {
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedIds={selectedIds}
mapDragEnabled={mapDragEnabled}
onActivate={onSelectTile}
onAddToMap={spawnNpcOnMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
{ungrouped.length > 0 || !searching ? (
@@ -386,7 +601,11 @@ export function NpcsApp() {
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
</div>
+12 -4
View File
@@ -36,7 +36,10 @@ import { ContainedVideo } from './ContainedVideo';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
import {
DEFAULT_NPC_TOKEN_SESSION_SCALE,
listVisibleSceneNpcTokens,
} from '../../shared/types/appPlayers';
export type PresentationViewProps = {
session: SessionState | null;
@@ -73,9 +76,14 @@ export function PresentationView({
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const project = session?.project;
const visibleNpcTokens = listVisibleSceneNpcTokens(
scene?.npcTokens,
sceneNpcTokensSession.spawned,
session?.currentSceneId,
);
const pathLivePoses = useTokenPathLivePoses({
tokens: scene?.tokens ?? [],
npcTokens: scene?.npcTokens ?? [],
npcTokens: visibleNpcTokens,
pathSession: tokenPathSession,
enabled: Boolean(scene),
onMarkDone: (kind, placementId) => {
@@ -211,7 +219,7 @@ export function PresentationView({
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
<TokenPathsOverlay
tokens={scene.tokens ?? []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (scene.npcTokens ?? []) : []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []}
viewport={contentRect}
mode="presentation"
pathSession={tokenPathSession}
@@ -230,7 +238,7 @@ export function PresentationView({
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
contentRect ? (
<SceneNpcTokensOverlay
placements={scene.npcTokens ?? []}
placements={visibleNpcTokens}
library={project?.npcs ?? []}
session={sceneNpcTokensSession}
viewport={contentRect}
@@ -17,6 +17,10 @@
pointer-events: none;
}
.token > .resizeHandle {
pointer-events: auto;
}
.editable {
pointer-events: auto;
cursor: grab;
@@ -25,3 +29,18 @@
.editable:active {
cursor: grabbing;
}
.resizeHandle {
position: absolute;
right: -5px;
bottom: -5px;
width: 12px;
height: 12px;
border-radius: 2px;
background: #f5c542;
border: 1px solid #000;
cursor: nwse-resize;
touch-action: none;
pointer-events: auto;
z-index: 2;
}
@@ -7,7 +7,7 @@ import type {
SceneNpcToken,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import { clampSceneNpcTokenSizeN, DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
@@ -16,7 +16,7 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
@@ -35,6 +35,7 @@ function NpcSprite({
npc,
nx,
ny,
sizeN,
viewport,
editable,
dragEnabled,
@@ -43,6 +44,8 @@ function NpcSprite({
disposition,
inactive,
onMove,
onResize,
resizeTitle,
onContextMenu,
snapNorm,
}: {
@@ -50,6 +53,7 @@ function NpcSprite({
npc: ProjectNpc;
nx: number;
ny: number;
sizeN: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
@@ -58,11 +62,14 @@ function NpcSprite({
disposition: NpcDisposition;
inactive: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const [localSize, setLocalSize] = useState<number | null>(null);
const dragRef = useRef<{
pointerId: number;
startNx: number;
@@ -72,15 +79,26 @@ function NpcSprite({
lastNx: number;
lastNy: number;
} | null>(null);
const resizeRef = useRef<{
pointerId: number;
startSize: number;
startDist: number;
lastSize: number;
} | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
onResize,
String(placement.id),
);
const pos = localPos ?? { nx, ny };
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
const sizePx = Math.max(16, shownSizeN * gridFit * displayScale * minDim);
const canDrag = editable && dragEnabled && onMove !== undefined;
const canResize = editable && dragEnabled && onResize !== undefined;
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
const rect = host?.getBoundingClientRect();
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
const rect = (host ?? e.currentTarget.parentElement)?.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))),
@@ -95,6 +113,14 @@ function NpcSprite({
setLocalPos(null);
};
const endResize = (e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (d?.pointerId !== e.pointerId) return;
resizeRef.current = null;
flushResize(d.lastSize);
setLocalSize(null);
};
return (
<div
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
@@ -104,7 +130,7 @@ function NpcSprite({
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
pointerEvents: canDrag || onContextMenu ? 'auto' : undefined,
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
}}
onContextMenu={
onContextMenu
@@ -163,6 +189,41 @@ function NpcSprite({
sizePx={sizePx}
inactive={inactive}
/>
{canResize ? (
<div
className={styles.resizeHandle}
title={resizeTitle}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
resizeRef.current = {
pointerId: e.pointerId,
startSize: shownSizeN,
startDist: dist,
lastSize: shownSizeN,
};
setLocalSize(shownSizeN);
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = resizeRef.current;
if (d?.pointerId !== e.pointerId) return;
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
const next = clampSceneNpcTokenSizeN(d.startSize * (dist / d.startDist));
d.lastSize = next;
setLocalSize(next);
scheduleResize(next);
}}
onPointerUp={endResize}
onPointerCancel={endResize}
/>
) : null}
</div>
);
}
@@ -175,6 +236,8 @@ export function SceneNpcTokensOverlay({
grid = null,
editable = false,
onMove,
onResize,
resizeTitle,
onContextMenu,
snapNorm,
poseOverrides = null,
@@ -188,6 +251,8 @@ export function SceneNpcTokensOverlay({
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
@@ -198,7 +263,7 @@ export function SceneNpcTokensOverlay({
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
const gridFit = sceneGridTokenFitFactor(grid);
return (
<div className={styles.layer}>
<div className={styles.layer} data-token-layer="1">
{placements.map((placement) => {
const npc = byId.get(placement.npcId);
if (!npc) return null;
@@ -215,6 +280,7 @@ export function SceneNpcTokensOverlay({
npc={npc}
nx={pose?.nx ?? override?.nx ?? placement.nx}
ny={pose?.ny ?? override?.ny ?? placement.ny}
sizeN={override?.sizeN ?? placement.sizeN}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
@@ -223,6 +289,8 @@ export function SceneNpcTokensOverlay({
disposition={disposition}
inactive={inactive}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
@@ -5,12 +5,15 @@ import type {
SceneGrid,
ScenePlayerTokensSessionState,
} from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import {
clampSceneNpcTokenSizeN,
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 { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
@@ -25,6 +28,8 @@ function PlayerSprite({
displayScale,
gridFit,
onMove,
onResize,
resizeTitle,
snapNorm,
}: {
player: AppPlayer;
@@ -36,10 +41,13 @@ function PlayerSprite({
displayScale: number;
gridFit: number;
onMove?: (playerId: string, nx: number, ny: number) => void;
onResize?: (playerId: string, sizeN: number) => void;
resizeTitle?: string;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = usePlayerImageUrl(player.id);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const [localSize, setLocalSize] = useState<number | null>(null);
const dragRef = useRef<{
pointerId: number;
startNx: number;
@@ -49,14 +57,25 @@ function PlayerSprite({
lastNx: number;
lastNy: number;
} | null>(null);
const resizeRef = useRef<{
pointerId: number;
startSize: number;
startDist: number;
lastSize: number;
} | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
onResize,
String(player.id),
);
const pos = localPos ?? { nx, ny };
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim);
const sizePx = Math.max(16, shownSizeN * gridFit * displayScale * minDim);
const canResize = editable && onResize !== undefined;
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
const rect = host?.getBoundingClientRect();
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
const rect = (host ?? e.currentTarget.parentElement)?.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))),
@@ -71,6 +90,14 @@ function PlayerSprite({
setLocalPos(null);
};
const endResize = (e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (d?.pointerId !== e.pointerId) return;
resizeRef.current = null;
flushResize(d.lastSize);
setLocalSize(null);
};
return (
<div
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
@@ -128,6 +155,41 @@ function PlayerSprite({
imageScale={player.imageScale}
sizePx={sizePx}
/>
{canResize ? (
<div
className={styles.resizeHandle}
title={resizeTitle}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
resizeRef.current = {
pointerId: e.pointerId,
startSize: shownSizeN,
startDist: dist,
lastSize: shownSizeN,
};
setLocalSize(shownSizeN);
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = resizeRef.current;
if (d?.pointerId !== e.pointerId) return;
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
const next = clampSceneNpcTokenSizeN(d.startSize * (dist / d.startDist));
d.lastSize = next;
setLocalSize(next);
scheduleResize(next);
}}
onPointerUp={endResize}
onPointerCancel={endResize}
/>
) : null}
</div>
);
}
@@ -140,6 +202,8 @@ export function ScenePlayerTokensOverlay({
grid = null,
editable = false,
onMove,
onResize,
resizeTitle,
snapNorm,
}: {
library: readonly AppPlayer[];
@@ -150,13 +214,15 @@ export function ScenePlayerTokensOverlay({
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (playerId: string, nx: number, ny: number) => void;
onResize?: (playerId: string, sizeN: number) => void;
resizeTitle?: string;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
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}>
<div className={styles.layer} data-token-layer="1">
{session.selectedPlayerIds.map((playerId) => {
const player = byId.get(playerId);
const placement = session.byPlayerId[playerId];
@@ -173,6 +239,8 @@ export function ScenePlayerTokensOverlay({
displayScale={displayScale}
gridFit={gridFit}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
);
@@ -46,3 +46,50 @@ export function useLiveDragBroadcast(
return { schedule, flush };
}
/** Во время resize шлёт onResize не чаще одного раза за кадр. */
export function useLiveResizeBroadcast(
onResize: ((id: string, sizeN: number) => void) | undefined,
id: string,
): {
schedule: (sizeN: number) => void;
flush: (sizeN: number) => void;
} {
const rafRef = useRef(0);
const pendingRef = useRef<number | null>(null);
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const idRef = useRef(id);
idRef.current = id;
useEffect(
() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
},
[],
);
const schedule = useCallback((sizeN: number) => {
if (!onResizeRef.current) return;
pendingRef.current = sizeN;
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
const pending = pendingRef.current;
const cb = onResizeRef.current;
if (pending === null || !cb) return;
cb(idRef.current, pending);
});
}, []);
const flush = useCallback((sizeN: number) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}
pendingRef.current = null;
onResizeRef.current?.(idRef.current, sizeN);
}, []);
return { schedule, flush };
}
@@ -0,0 +1,56 @@
import { useEffect, useMemo, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { NpcMapSpawnDragState } from '../../../shared/types';
import { asNpcId } from '../../../shared/types/ids';
import { getDndApi } from '../dndApi';
const EMPTY: NpcMapSpawnDragState = {
dragging: false,
npcId: null,
hoverNx: null,
hoverNy: null,
};
export function useNpcMapSpawnDrag(): [
NpcMapSpawnDragState,
{
beginDrag: (npcId: string) => Promise<void>;
setHover: (nx: number | null, ny: number | null) => Promise<void>;
commit: () => Promise<boolean>;
cancel: () => Promise<void>;
},
] {
const api = getDndApi();
const [state, setState] = useState<NpcMapSpawnDragState>(EMPTY);
useEffect(() => {
void api.invoke(ipcChannels.npcMapSpawn.getState, {}).then(({ state: s }) => {
setState(s);
});
return api.on(ipcChannels.npcMapSpawn.stateChanged, ({ state: s }) => {
setState(s);
});
}, [api]);
const apiWrap = useMemo(
() => ({
beginDrag: async (npcId: string) => {
await api.invoke(ipcChannels.npcMapSpawn.beginDrag, { npcId: asNpcId(npcId) });
},
setHover: async (nx: number | null, ny: number | null) => {
await api.invoke(ipcChannels.npcMapSpawn.setHover, { nx, ny });
},
commit: async () => {
const res = await api.invoke(ipcChannels.npcMapSpawn.commit, {});
return Boolean(res.spawned);
},
cancel: async () => {
await api.invoke(ipcChannels.npcMapSpawn.cancel, {});
},
}),
[api],
);
return [state, apiWrap];
}
@@ -2,14 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
SceneNpcSessionSpawn,
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionPlacement,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import {
asSceneNpcTokenId,
clampNpcTokenSessionScale,
clampSceneNpcTokenSizeN,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
emptySceneNpcTokensSessionState,
newSceneNpcSessionSpawnId,
} from '../../../shared/types/appPlayers';
import { asNpcId } from '../../../shared/types/ids';
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
import { getDndApi } from '../dndApi';
@@ -17,35 +24,89 @@ function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionStat
return {
...state,
scale: clampNpcTokenSessionScale(state.scale),
spawned: state.spawned ?? {},
};
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
return (
p.nx === undefined &&
p.ny === undefined &&
p.sizeN === 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 applyEvent(
prev: SceneNpcTokensSessionState,
event: SceneNpcTokensSessionEvent,
): SceneNpcTokensSessionState {
const spawned = prev.spawned ?? {};
if (event.kind === 'clear') {
return {
revision: prev.revision + 1,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
return emptySceneNpcTokensSessionState(prev.revision + 1);
}
if (event.kind === 'setScale') {
const scale = clampNpcTokenSessionScale(event.scale);
if (prev.scale === scale) return prev;
return { ...prev, revision: prev.revision + 1, scale };
return { ...prev, revision: prev.revision + 1, scale, spawned };
}
if (event.kind === 'spawn') {
const npcId = String(event.npcId ?? '');
const sceneId = String(event.sceneId ?? '');
if (!npcId || !sceneId) return prev;
const id = event.placementId
? asSceneNpcTokenId(String(event.placementId))
: newSceneNpcSessionSpawnId();
const spawn: SceneNpcSessionSpawn = {
id,
sceneId,
npcId: asNpcId(npcId),
nx: Math.max(0, Math.min(1, event.nx)),
ny: Math.max(0, Math.min(1, event.ny)),
sizeN: clampSceneNpcTokenSizeN(
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
),
disposition: normalizeNpcDisposition(event.disposition),
};
return {
...prev,
revision: prev.revision + 1,
spawned: { ...spawned, [String(id)]: spawn },
};
}
if (event.kind === 'despawn') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
if (!(placementId in spawned) && !(placementId in prev.byPlacementId)) return prev;
const { [placementId]: _s, ...nextSpawned } = spawned;
const { [placementId]: _p, ...byPlacementId } = prev.byPlacementId;
return {
...prev,
revision: prev.revision + 1,
byPlacementId,
spawned: nextSpawned,
};
}
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;
const spawn = spawned[placementId];
if (cur?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
return prev;
}
return {
revision: prev.revision + 1,
byPlacementId: {
@@ -53,11 +114,13 @@ function applyEvent(
[placementId]: {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
disposition,
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, disposition } } : spawned,
};
}
if (event.kind === 'setInactive') {
@@ -69,6 +132,7 @@ function applyEvent(
const next: SceneNpcTokensSessionPlacement = {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
@@ -78,6 +142,7 @@ function applyEvent(
revision: prev.revision + 1,
byPlacementId: rest,
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned,
};
}
return {
@@ -87,12 +152,31 @@ function applyEvent(
[placementId]: next,
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned,
};
}
if (event.kind === 'setSize') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const cur = prev.byPlacementId[placementId];
const spawn = spawned[placementId];
if (cur?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return prev;
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: { ...restOf(cur), sizeN },
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, sizeN } } : spawned,
};
}
// move
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const cur = prev.byPlacementId[placementId];
const spawn = spawned[placementId];
return {
revision: prev.revision + 1,
byPlacementId: {
@@ -100,11 +184,13 @@ function applyEvent(
[placementId]: {
nx: event.nx,
ny: event.ny,
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, nx: event.nx, ny: event.ny } } : spawned,
};
}
@@ -113,17 +199,14 @@ export function useSceneNpcTokensSession(): [
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
] {
const api = getDndApi();
const [state, setState] = useState<SceneNpcTokensSessionState>({
revision: 0,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
});
const [state, setState] = useState<SceneNpcTokensSessionState>(() =>
emptySceneNpcTokensSessionState(0),
);
const localRevRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.sceneNpcTokensSession.getState, {}).then((res) => {
const next = withScale(res.state);
// Не затираем более свежий optimistic/broadcast стейт устаревшим getState.
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
@@ -55,6 +55,18 @@ function applyEvent(
},
};
}
if (event.kind === 'setSize') {
const prevPlacement = prev.byPlayerId[event.playerId];
if (!prevPlacement) return prev;
return {
...prev,
revision: prev.revision + 1,
byPlayerId: {
...prev.byPlayerId,
[event.playerId]: { ...prevPlacement, sizeN: event.sizeN },
},
};
}
return prev;
}
@@ -11,7 +11,7 @@
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.2);
overflow: hidden;
overflow: visible;
pointer-events: none;
touch-action: none;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
@@ -31,10 +31,34 @@
cursor: context-menu;
}
.tokenFace {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 8px;
pointer-events: none;
}
.tokenImg {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
border-radius: 8px;
}
.resizeHandle {
position: absolute;
right: -5px;
bottom: -5px;
width: 12px;
height: 12px;
border-radius: 2px;
background: #f5c542;
border: 1px solid #000;
cursor: nwse-resize;
touch-action: none;
pointer-events: auto;
z-index: 2;
}
@@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
import { useTokenImageUrl } from './useTokenImageUrl';
import styles from './SceneTokensOverlay.module.css';
@@ -20,6 +22,8 @@ type Props = {
viewport: Viewport | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
/** Snap во время drag (пульт, привязка к сетке). */
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
@@ -29,26 +33,36 @@ type Props = {
dragEnabledById?: Record<string, boolean> | null;
};
function layerFromEvent(e: React.PointerEvent<HTMLElement>): HTMLElement | null {
return e.currentTarget.closest('[data-token-layer="1"]');
}
function TokenSprite({
placement,
nx,
ny,
sizeN,
rotationDeg,
viewport,
editable,
dragEnabled,
onMove,
onResize,
resizeTitle,
snapNorm,
onContextMenu,
}: {
placement: SceneToken;
nx: number;
ny: number;
sizeN: number;
rotationDeg: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
}) {
@@ -62,14 +76,30 @@ function TokenSprite({
lastNx: number;
lastNy: number;
} | null>(null);
const resizeRef = useRef<{
pointerId: number;
startSize: number;
startDist: number;
lastSize: number;
} | null>(null);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const [localSize, setLocalSize] = useState<number | null>(null);
const frameRef = useRef(0);
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
onResize,
String(placement.id),
);
useEffect(() => {
if (dragRef.current) return;
setLocalPos(null);
}, [nx, ny]);
useEffect(() => {
if (resizeRef.current) return;
setLocalSize(null);
}, [sizeN]);
useEffect(() => {
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
@@ -78,13 +108,15 @@ function TokenSprite({
const posNx = localPos?.nx ?? nx;
const posNy = localPos?.ny ?? ny;
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * minDim);
const sizePx = Math.max(16, shownSizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const canDrag = editable && dragEnabled && Boolean(onMove);
const interactive = canDrag || Boolean(onContextMenu);
const canResize = editable && dragEnabled && Boolean(onResize);
const interactive = canDrag || canResize || Boolean(onContextMenu);
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
@@ -113,6 +145,19 @@ function TokenSprite({
}
};
const endResize = (el: HTMLDivElement, pointerId: number) => {
const d = resizeRef.current;
if (!d || d.pointerId !== pointerId) return;
resizeRef.current = null;
flushResize(d.lastSize);
setLocalSize(null);
try {
if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
};
return (
<div
className={[
@@ -189,7 +234,52 @@ function TokenSprite({
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
}}
>
{url ? <img className={styles.tokenImg} src={url} alt="" draggable={false} /> : null}
{url ? (
<div className={styles.tokenFace}>
<img className={styles.tokenImg} src={url} alt="" draggable={false} />
</div>
) : null}
{canResize ? (
<div
className={styles.resizeHandle}
title={resizeTitle}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const host = layerFromEvent(e);
if (!host) return;
const p = hostToNorm(e.clientX, e.clientY, host);
const dist = Math.max(1e-4, Math.hypot(p.x - posNx, p.y - posNy));
resizeRef.current = {
pointerId: e.pointerId,
startSize: shownSizeN,
startDist: dist,
lastSize: shownSizeN,
};
setLocalSize(shownSizeN);
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = resizeRef.current;
if (!d || d.pointerId !== e.pointerId) return;
const host = layerFromEvent(e);
if (!host) return;
const p = hostToNorm(e.clientX, e.clientY, host);
const dist = Math.max(1e-4, Math.hypot(p.x - posNx, p.y - posNy));
const next = clampSceneTokenSizeN(d.startSize * (dist / d.startDist));
d.lastSize = next;
setLocalSize(next);
scheduleResize(next);
}}
onPointerUp={(e) => {
endResize(e.currentTarget, e.pointerId);
}}
onPointerCancel={(e) => {
endResize(e.currentTarget, e.pointerId);
}}
/>
) : null}
</div>
);
}
@@ -201,6 +291,8 @@ export function SceneTokensOverlay({
viewport,
editable = false,
onMove,
onResize,
resizeTitle,
snapNorm,
onContextMenu,
poseOverrides = null,
@@ -210,7 +302,7 @@ export function SceneTokensOverlay({
const known = new Set(library.map((t) => t.id));
return (
<div className={styles.layer}>
<div className={styles.layer} data-token-layer="1">
{placements
.filter((p) => known.has(p.tokenId))
.map((placement) => {
@@ -219,6 +311,7 @@ export function SceneTokensOverlay({
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
const nx = pose?.nx ?? override?.nx ?? placement.nx;
const ny = pose?.ny ?? override?.ny ?? placement.ny;
const sizeN = override?.sizeN ?? placement.sizeN;
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (
@@ -227,11 +320,14 @@ export function SceneTokensOverlay({
placement={placement}
nx={nx}
ny={ny}
sizeN={sizeN}
rotationDeg={rotationDeg}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}
{...(snapNorm ? { snapNorm } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
@@ -13,11 +13,30 @@ function applyEvent(
return { revision: base.revision + 1, byPlacementId: {} };
}
const placementId = String(event.placementId ?? '');
if (!placementId) return base;
const cur = base.byPlacementId[placementId];
if (event.kind === 'setSize') {
return {
revision: base.revision + 1,
byPlacementId: {
...base.byPlacementId,
[placementId]: {
...(typeof cur?.nx === 'number' ? { nx: cur.nx } : {}),
...(typeof cur?.ny === 'number' ? { ny: cur.ny } : {}),
sizeN: event.sizeN,
},
},
};
}
return {
revision: base.revision + 1,
byPlacementId: {
...base.byPlacementId,
[placementId]: { nx: event.nx, ny: event.ny },
[placementId]: {
nx: event.nx,
ny: event.ny,
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
},
},
};
}
+1
View File
@@ -13,6 +13,7 @@ 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, /npcMapSpawn:\s*\{/);
assert.match(src, /scenePlayerTokensSession:\s*\{/);
assert.match(src, /tokenGridSnap:\s*\{/);
assert.match(src, /tokenGridSnap\.setEnabled/);
+31
View File
@@ -30,6 +30,7 @@ import type {
SceneGrid,
SceneId,
SceneNpcToken,
NpcMapSpawnDragState,
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionState,
ScenePlayerTokensSessionEvent,
@@ -233,6 +234,14 @@ export const ipcChannels = {
dispatch: 'sceneNpcTokensSession.dispatch',
stateChanged: 'sceneNpcTokensSession.stateChanged',
},
npcMapSpawn: {
beginDrag: 'npcMapSpawn.beginDrag',
setHover: 'npcMapSpawn.setHover',
commit: 'npcMapSpawn.commit',
cancel: 'npcMapSpawn.cancel',
getState: 'npcMapSpawn.getState',
stateChanged: 'npcMapSpawn.stateChanged',
},
scenePlayerTokensSession: {
getState: 'scenePlayerTokensSession.getState',
dispatch: 'scenePlayerTokensSession.dispatch',
@@ -319,6 +328,7 @@ export type IpcEventMap = {
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
@@ -875,6 +885,26 @@ export type IpcInvokeMap = {
req: { event: SceneNpcTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.npcMapSpawn.beginDrag]: {
req: { npcId: NpcId };
res: { ok: true };
};
[ipcChannels.npcMapSpawn.setHover]: {
req: { nx: number | null; ny: number | null };
res: { ok: true };
};
[ipcChannels.npcMapSpawn.commit]: {
req: Record<string, never>;
res: { spawned: boolean };
};
[ipcChannels.npcMapSpawn.cancel]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.npcMapSpawn.getState]: {
req: Record<string, never>;
res: { state: NpcMapSpawnDragState };
};
[ipcChannels.scenePlayerTokensSession.getState]: {
req: Record<string, never>;
res: { state: ScenePlayerTokensSessionState };
@@ -929,6 +959,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>;
+93
View File
@@ -68,26 +68,59 @@ export type SceneNpcTokensSessionPlacement = {
/** Есть после move; без move позиция берётся из сцены. */
nx?: number;
ny?: number;
/** Session-only override of map diameter. */
sizeN?: number;
/** Session-only override типа экземпляра. */
disposition?: NpcDisposition;
/** Session-only: серая рамка и grayscale. */
inactive?: boolean;
};
/** Подкрепление: токен только на сессию, не пишется в scene.npcTokens. */
export type SceneNpcSessionSpawn = {
id: SceneNpcTokenId;
sceneId: string;
npcId: NpcId;
nx: number;
ny: number;
sizeN: number;
disposition: NpcDisposition;
};
export type SceneNpcTokensSessionState = {
revision: number;
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
/** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
scale: number;
spawned: Record<string, SceneNpcSessionSpawn>;
};
export type SceneNpcTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'setSize'; placementId: string; sizeN: number }
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
| { kind: 'setInactive'; placementId: string; inactive: boolean }
| { kind: 'setScale'; scale: number }
| {
kind: 'spawn';
npcId: string;
sceneId: string;
nx: number;
ny: number;
sizeN?: number;
disposition?: NpcDisposition;
placementId?: string;
}
| { kind: 'despawn'; placementId: string }
| { kind: 'clear' };
export type NpcMapSpawnDragState = {
dragging: boolean;
npcId: string | null;
hoverNx: number | null;
hoverNy: number | null;
};
/** Session-only токены живых игроков на карте во время показа. */
export type ScenePlayerTokenPlacement = { nx: number; ny: number; sizeN: number };
@@ -104,6 +137,7 @@ export type ScenePlayerTokensSessionEvent =
| { kind: 'show'; sizeN: number }
| { kind: 'seedBottom'; sizeN: number }
| { kind: 'move'; playerId: string; nx: number; ny: number }
| { kind: 'setSize'; playerId: string; sizeN: number }
| { kind: 'clearPlacements' }
| { kind: 'clear' };
@@ -156,6 +190,65 @@ export function clampSceneNpcTokenSizeN(sizeN: number): number {
return Math.max(SCENE_NPC_TOKEN_SIZE_MIN, Math.min(SCENE_NPC_TOKEN_SIZE_MAX, sizeN));
}
export function emptySceneNpcTokensSessionState(revision = 1): SceneNpcTokensSessionState {
return {
revision,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned: {},
};
}
export function newSceneNpcSessionSpawnId(): SceneNpcTokenId {
return asSceneNpcTokenId(
`snpc_sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
);
}
export function nextSessionNpcSpawnPoint(
occupied: readonly { nx: number; ny: number }[],
): { nx: number; ny: number } {
const tryPoint = (nx: number, ny: number) => {
const p = {
nx: Math.max(0.05, Math.min(0.95, nx)),
ny: Math.max(0.05, Math.min(0.95, ny)),
};
return occupied.some((o) => Math.hypot(o.nx - p.nx, o.ny - p.ny) < 0.045) ? null : p;
};
const first = tryPoint(0.5, 0.5);
if (first) return first;
for (let i = 1; i <= 16; i += 1) {
const r = 0.05 * i;
for (let k = 0; k < 8; k += 1) {
const a = (k / 8) * Math.PI * 2 + i * 0.35;
const hit = tryPoint(0.5 + Math.cos(a) * r, 0.5 + Math.sin(a) * r);
if (hit) return hit;
}
}
return { nx: 0.5, ny: 0.5 };
}
export function listVisibleSceneNpcTokens(
sceneTokens: readonly SceneNpcToken[] | undefined,
spawned: Record<string, SceneNpcSessionSpawn> | undefined,
sceneId: string | null | undefined,
): SceneNpcToken[] {
const out = [...(sceneTokens ?? [])];
if (!sceneId || !spawned) return out;
for (const s of Object.values(spawned)) {
if (s.sceneId !== sceneId) continue;
out.push({
id: asSceneNpcTokenId(s.id),
npcId: asNpcId(s.npcId),
nx: s.nx,
ny: s.ny,
sizeN: s.sizeN,
disposition: s.disposition,
});
}
return out;
}
export function normalizeAppPlayerTeam(raw: unknown): AppPlayerTeam | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<AppPlayerTeam>;
+10 -2
View File
@@ -33,14 +33,22 @@ export const DEFAULT_SCENE_TOKEN_SIZE_N = 0.08;
export const SCENE_TOKEN_SIZE_MIN = 0.02;
export const SCENE_TOKEN_SIZE_MAX = 0.45;
export type SceneTokensSessionPlacement = {
nx?: number;
ny?: number;
/** Session-only override of map diameter. */
sizeN?: number;
};
export type SceneTokensSessionState = {
revision: number;
/** Смещение позиции на текущую сессию (только nx/ny). */
byPlacementId: Record<string, { nx: number; ny: number }>;
/** Смещение позиции/размера на текущую сессию. */
byPlacementId: Record<string, SceneTokensSessionPlacement>;
};
export type SceneTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'setSize'; placementId: string; sizeN: number }
| { kind: 'clear' };
export function clampSceneTokenSizeN(sizeN: number): number {
+1 -1
View File
@@ -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/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/renderer/shared/videoSceneMapParity.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 app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.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/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.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/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/renderer/shared/videoSceneMapParity.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 app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.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/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
"format": "prettier . --check",
"format:write": "prettier . --write",
"postinstall": "patch-package",