From 1a9f0f255757eca3478f18a07f85c56648c310e1 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Tue, 18 Aug 2026 14:42:06 +0800 Subject: [PATCH] feat(control): spawn session NPCs and resize tokens in preview Co-authored-by: Cursor --- app/main/index.ts | 81 ++++++ .../sceneNpcTokensSessionStore.test.ts | 44 ++++ .../players/sceneNpcTokensSessionStore.ts | 136 ++++++++-- .../scenePlayerTokensSessionStore.test.ts | 12 + .../players/scenePlayerTokensSessionStore.ts | 17 ++ .../tokens/sceneTokensSessionStore.test.ts | 26 ++ app/main/tokens/sceneTokensSessionStore.ts | 48 +++- app/renderer/control/ControlApp.module.css | 16 ++ app/renderer/control/ControlApp.tsx | 110 +++++++- app/renderer/editor/i18n/editorMessages.ts | 18 +- app/renderer/npcs/NpcsApp.module.css | 71 +++++- app/renderer/npcs/NpcsApp.tsx | 239 +++++++++++++++++- app/renderer/shared/PresentationView.tsx | 16 +- .../SceneNpcTokensOverlay.module.css | 19 ++ .../playerToken/SceneNpcTokensOverlay.tsx | 84 +++++- .../playerToken/ScenePlayerTokensOverlay.tsx | 82 +++++- .../playerToken/useLiveDragBroadcast.ts | 47 ++++ .../shared/playerToken/useNpcMapSpawnDrag.ts | 56 ++++ .../playerToken/useSceneNpcTokensSession.ts | 111 +++++++- .../useScenePlayerTokensSession.ts | 12 + .../tokens/SceneTokensOverlay.module.css | 26 +- .../shared/tokens/SceneTokensOverlay.tsx | 104 +++++++- .../shared/tokens/useSceneTokensSession.ts | 21 +- app/shared/ipc/contracts.players.test.ts | 1 + app/shared/ipc/contracts.ts | 31 +++ app/shared/types/appPlayers.ts | 93 +++++++ app/shared/types/appTokens.ts | 12 +- package.json | 2 +- 28 files changed, 1446 insertions(+), 89 deletions(-) create mode 100644 app/main/tokens/sceneTokensSessionStore.test.ts create mode 100644 app/renderer/shared/playerToken/useNpcMapSpawnDrag.ts diff --git a/app/main/index.ts b/app/main/index.ts index 056e7c6..b5ebb6e 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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() }; }); diff --git a/app/main/players/sceneNpcTokensSessionStore.test.ts b/app/main/players/sceneNpcTokensSessionStore.test.ts index 139f310..84122d4 100644 --- a/app/main/players/sceneNpcTokensSessionStore.test.ts +++ b/app/main/players/sceneNpcTokensSessionStore.test.ts @@ -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); +}); diff --git a/app/main/players/sceneNpcTokensSessionStore.ts b/app/main/players/sceneNpcTokensSessionStore.ts index 4ea686e..7698868 100644 --- a/app/main/players/sceneNpcTokensSessionStore.ts +++ b/app/main/players/sceneNpcTokensSessionStore.ts @@ -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; } diff --git a/app/main/players/scenePlayerTokensSessionStore.test.ts b/app/main/players/scenePlayerTokensSessionStore.test.ts index 416e1da..5f7d963 100644 --- a/app/main/players/scenePlayerTokensSessionStore.test.ts +++ b/app/main/players/scenePlayerTokensSessionStore.test.ts @@ -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); +}); diff --git a/app/main/players/scenePlayerTokensSessionStore.ts b/app/main/players/scenePlayerTokensSessionStore.ts index b94beda..91ce1c0 100644 --- a/app/main/players/scenePlayerTokensSessionStore.ts +++ b/app/main/players/scenePlayerTokensSessionStore.ts @@ -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; diff --git a/app/main/tokens/sceneTokensSessionStore.test.ts b/app/main/tokens/sceneTokensSessionStore.test.ts new file mode 100644 index 0000000..4964743 --- /dev/null +++ b/app/main/tokens/sceneTokensSessionStore.test.ts @@ -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); +}); diff --git a/app/main/tokens/sceneTokensSessionStore.ts b/app/main/tokens/sceneTokensSessionStore.ts index fd866bc..6577e9b 100644 --- a/app/main/tokens/sceneTokensSessionStore.ts +++ b/app/main/tokens/sceneTokensSessionStore.ts @@ -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; diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index 173f2d0..049b12a 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -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; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 59d59b9..4dab764 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -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 = {}; - 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+ЛКМ — пан" > -
+
+ {npcMapSpawnDrag.dragging && + previewContentRect && + npcMapSpawnDrag.hoverNx != null && + npcMapSpawnDrag.hoverNy != null ? ( +
+ ) : null} <> { + 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 ? ( { + 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() { ); } + const isSessionSpawn = Boolean( + sceneNpcTokensSession.spawned[npcSessionCtxMenu.placementId], + ); return ( <> + {isSessionSpawn ? ( + + ) : null} {path && path.points.length >= 2 ? ( <> + + + {menu + ? createPortal( +
e.stopPropagation()} + > + +
, + document.body, + ) + : null} +
); } @@ -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; + 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} /> ))}
@@ -142,6 +299,8 @@ export function NpcsApp() { const api = getDndApi(); const [session, setSession] = useState(null); const [overlay, overlayApi] = useNpcsOverlayState(); + const [npcSession, npcSessionApi] = useSceneNpcTokensSession(); + const [mapDrag, mapDragApi] = useNpcMapSpawnDrag(); const [query, setQuery] = useState(''); const [collapsedGroups, setCollapsedGroups] = useState>(() => 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 (
@@ -296,6 +500,9 @@ export function NpcsApp() { ? t('npcs.zoomOutHint') : t('npcs.zoomIdleHint')}
+
+ {sceneId ? t('npcs.dragToMapHint') : t('npcs.addToMapNoScene')} +
diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 4198715..71bd799 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -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 ? ( .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; +} diff --git a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx index c6637f9..4bbabe0 100644 --- a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx +++ b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx @@ -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(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) => { + const d = resizeRef.current; + if (d?.pointerId !== e.pointerId) return; + resizeRef.current = null; + flushResize(d.lastSize); + setLocalSize(null); + }; + return (
+ {canResize ? ( +
{ + 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}
); } @@ -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 | null; @@ -198,7 +263,7 @@ export function SceneNpcTokensOverlay({ const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE; const gridFit = sceneGridTokenFitFactor(grid); return ( -
+
{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 } : {})} /> diff --git a/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx b/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx index 728fe6c..682a594 100644 --- a/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx +++ b/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx @@ -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(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) => { + const d = resizeRef.current; + if (d?.pointerId !== e.pointerId) return; + resizeRef.current = null; + flushResize(d.lastSize); + setLocalSize(null); + }; + return (
+ {canResize ? ( +
{ + 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}
); } @@ -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 ( -
+
{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 } : {})} /> ); diff --git a/app/renderer/shared/playerToken/useLiveDragBroadcast.ts b/app/renderer/shared/playerToken/useLiveDragBroadcast.ts index 990ee47..cb48902 100644 --- a/app/renderer/shared/playerToken/useLiveDragBroadcast.ts +++ b/app/renderer/shared/playerToken/useLiveDragBroadcast.ts @@ -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(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 }; +} diff --git a/app/renderer/shared/playerToken/useNpcMapSpawnDrag.ts b/app/renderer/shared/playerToken/useNpcMapSpawnDrag.ts new file mode 100644 index 0000000..e66db28 --- /dev/null +++ b/app/renderer/shared/playerToken/useNpcMapSpawnDrag.ts @@ -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; + setHover: (nx: number | null, ny: number | null) => Promise; + commit: () => Promise; + cancel: () => Promise; + }, +] { + const api = getDndApi(); + const [state, setState] = useState(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]; +} diff --git a/app/renderer/shared/playerToken/useSceneNpcTokensSession.ts b/app/renderer/shared/playerToken/useSceneNpcTokensSession.ts index 6afa961..34d782c 100644 --- a/app/renderer/shared/playerToken/useSceneNpcTokensSession.ts +++ b/app/renderer/shared/playerToken/useSceneNpcTokensSession.ts @@ -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({ - revision: 0, - byPlacementId: {}, - scale: DEFAULT_NPC_TOKEN_SESSION_SCALE, - }); + const [state, setState] = useState(() => + 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); diff --git a/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts b/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts index 3c3763c..f2e8e7f 100644 --- a/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts +++ b/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts @@ -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; } diff --git a/app/renderer/shared/tokens/SceneTokensOverlay.module.css b/app/renderer/shared/tokens/SceneTokensOverlay.module.css index 1434272..4a464b4 100644 --- a/app/renderer/shared/tokens/SceneTokensOverlay.module.css +++ b/app/renderer/shared/tokens/SceneTokensOverlay.module.css @@ -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; } diff --git a/app/renderer/shared/tokens/SceneTokensOverlay.tsx b/app/renderer/shared/tokens/SceneTokensOverlay.tsx index 920f21e..8b430c0 100644 --- a/app/renderer/shared/tokens/SceneTokensOverlay.tsx +++ b/app/renderer/shared/tokens/SceneTokensOverlay.tsx @@ -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 | null; }; +function layerFromEvent(e: React.PointerEvent): 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(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 (
- {url ? : null} + {url ? ( +
+ +
+ ) : null} + {canResize ? ( +
{ + 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}
); } @@ -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 ( -
+
{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 } : {})} /> diff --git a/app/renderer/shared/tokens/useSceneTokensSession.ts b/app/renderer/shared/tokens/useSceneTokensSession.ts index dff5177..91ace02 100644 --- a/app/renderer/shared/tokens/useSceneTokensSession.ts +++ b/app/renderer/shared/tokens/useSceneTokensSession.ts @@ -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 } : {}), + }, }, }; } diff --git a/app/shared/ipc/contracts.players.test.ts b/app/shared/ipc/contracts.players.test.ts index 9d6b43e..0e4d121 100644 --- a/app/shared/ipc/contracts.players.test.ts +++ b/app/shared/ipc/contracts.players.test.ts @@ -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/); diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 032a35c..6106f2a 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -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; + res: { spawned: boolean }; + }; + [ipcChannels.npcMapSpawn.cancel]: { + req: Record; + res: { ok: true }; + }; + [ipcChannels.npcMapSpawn.getState]: { + req: Record; + res: { state: NpcMapSpawnDragState }; + }; [ipcChannels.scenePlayerTokensSession.getState]: { req: Record; 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; diff --git a/app/shared/types/appPlayers.ts b/app/shared/types/appPlayers.ts index 9e82c31..2e8ba8f 100644 --- a/app/shared/types/appPlayers.ts +++ b/app/shared/types/appPlayers.ts @@ -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; /** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */ scale: number; + spawned: Record; }; 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 | 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; diff --git a/app/shared/types/appTokens.ts b/app/shared/types/appTokens.ts index 79cc2e0..d4959d7 100644 --- a/app/shared/types/appTokens.ts +++ b/app/shared/types/appTokens.ts @@ -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; + /** Смещение позиции/размера на текущую сессию. */ + byPlacementId: Record; }; 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 { diff --git a/package.json b/package.json index 09ea452..9640029 100644 --- a/package.json +++ b/package.json @@ -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",