feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 scene’s 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 scene’s 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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user