feat(scene): keep map tokens with preview rotation
When previewRotationDeg changes, remap non-player, NPC and trap markers (plus live session token overrides) so they stay on the art. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,10 @@ import {
|
|||||||
} from '../shared/project/projectZipExtension';
|
} from '../shared/project/projectZipExtension';
|
||||||
import type { Project } from '../shared/types';
|
import type { Project } from '../shared/types';
|
||||||
import { asNpcId } from '../shared/types/ids';
|
import { asNpcId } from '../shared/types/ids';
|
||||||
|
import {
|
||||||
|
asPreviewRotationDeg,
|
||||||
|
previewRotationStepsCw,
|
||||||
|
} from '../shared/types/scenePreviewRotation';
|
||||||
|
|
||||||
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
||||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||||
@@ -678,6 +682,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
|
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
|
||||||
|
const before = projectStore.getOpenProject()?.scenes[sceneId];
|
||||||
const next = await projectStore.updateScene(sceneId, patch);
|
const next = await projectStore.updateScene(sceneId, patch);
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
|
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
|
||||||
@@ -688,6 +693,29 @@ async function main() {
|
|||||||
syncSceneTrapsForProject(project);
|
syncSceneTrapsForProject(project);
|
||||||
emitSceneTrapsState();
|
emitSceneTrapsState();
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
project?.currentSceneId === sceneId &&
|
||||||
|
patch.previewRotationDeg !== undefined &&
|
||||||
|
before
|
||||||
|
) {
|
||||||
|
const steps = previewRotationStepsCw(
|
||||||
|
asPreviewRotationDeg(before.previewRotationDeg),
|
||||||
|
asPreviewRotationDeg(patch.previewRotationDeg),
|
||||||
|
);
|
||||||
|
if (steps !== 0) {
|
||||||
|
sceneTokensSessionStore.rotateMapCwSteps(steps);
|
||||||
|
sceneNpcTokensSessionStore.rotateMapCwSteps(steps);
|
||||||
|
scenePlayerTokensSessionStore.rotateMapCwSteps(steps);
|
||||||
|
emitSceneTokensSessionState();
|
||||||
|
emitSceneNpcTokensSessionState();
|
||||||
|
emitScenePlayerTokensSessionState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (project && project.currentSceneId === sceneId && patch.previewRotationDeg !== undefined) {
|
||||||
|
// Trap ids stay the same; runtime statuses remain valid after coordinate remap in project.
|
||||||
|
syncSceneTrapsForProject(project);
|
||||||
|
emitSceneTrapsState();
|
||||||
|
}
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { scene: next };
|
return { scene: next };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type SceneNpcTokensSessionState,
|
type SceneNpcTokensSessionState,
|
||||||
} from '../../shared/types/appPlayers';
|
} from '../../shared/types/appPlayers';
|
||||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||||
|
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||||
|
|
||||||
function emptyState(revision = 1): SceneNpcTokensSessionState {
|
function emptyState(revision = 1): SceneNpcTokensSessionState {
|
||||||
return {
|
return {
|
||||||
@@ -63,6 +64,34 @@ export class SceneNpcTokensSessionStore {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
|
||||||
|
rotateMapCwSteps(steps: number): SceneNpcTokensSessionState {
|
||||||
|
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;
|
||||||
|
let changed = false;
|
||||||
|
const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const prev = this.state.byPlacementId[id];
|
||||||
|
if (!prev) continue;
|
||||||
|
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
|
||||||
|
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||||
|
byPlacementId[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
byPlacementId[id] = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) return this.state;
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
byPlacementId,
|
||||||
|
scale: this.state.scale,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
|
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case 'clear':
|
case 'clear':
|
||||||
|
|||||||
@@ -37,3 +37,13 @@ void test('ScenePlayerTokensSessionStore move and reset', () => {
|
|||||||
assert.equal(s2.visible, false);
|
assert.equal(s2.visible, false);
|
||||||
assert.deepEqual(s2.byPlayerId, {});
|
assert.deepEqual(s2.byPlayerId, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ScenePlayerTokensSessionStore rotateMapCwSteps', () => {
|
||||||
|
const store = new ScenePlayerTokensSessionStore();
|
||||||
|
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||||
|
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||||
|
store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.2, ny: 0.1 });
|
||||||
|
const s = store.rotateMapCwSteps(1);
|
||||||
|
assert.equal(s.byPlayerId.p1?.nx, 0.9);
|
||||||
|
assert.equal(s.byPlayerId.p1?.ny, 0.2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type ScenePlayerTokensSessionEvent,
|
type ScenePlayerTokensSessionEvent,
|
||||||
type ScenePlayerTokensSessionState,
|
type ScenePlayerTokensSessionState,
|
||||||
} from '../../shared/types/appPlayers';
|
} from '../../shared/types/appPlayers';
|
||||||
|
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||||
|
|
||||||
function emptyState(revision = 1): ScenePlayerTokensSessionState {
|
function emptyState(revision = 1): ScenePlayerTokensSessionState {
|
||||||
return {
|
return {
|
||||||
@@ -60,6 +61,27 @@ export class ScenePlayerTokensSessionStore {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rotate live player tokens with the scene preview (CSS rotate steps). */
|
||||||
|
rotateMapCwSteps(steps: number): ScenePlayerTokensSessionState {
|
||||||
|
const n = ((steps % 4) + 4) % 4;
|
||||||
|
if (n === 0) return this.state;
|
||||||
|
const ids = Object.keys(this.state.byPlayerId);
|
||||||
|
if (ids.length === 0) return this.state;
|
||||||
|
const byPlayerId: ScenePlayerTokensSessionState['byPlayerId'] = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const prev = this.state.byPlayerId[id];
|
||||||
|
if (!prev) continue;
|
||||||
|
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||||
|
byPlayerId[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
byPlayerId,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
|
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case 'clear':
|
case 'clear':
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ import {
|
|||||||
type NpcDisposition,
|
type NpcDisposition,
|
||||||
} from '../../shared/types/npcDisposition';
|
} from '../../shared/types/npcDisposition';
|
||||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||||
|
import {
|
||||||
|
asPreviewRotationDeg,
|
||||||
|
previewRotationStepsCw,
|
||||||
|
rotateMapMarkersByCwSteps,
|
||||||
|
rotateSceneTokensByCwSteps,
|
||||||
|
} from '../../shared/types/scenePreviewRotation';
|
||||||
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
||||||
import type {
|
import type {
|
||||||
AssetId,
|
AssetId,
|
||||||
@@ -787,6 +793,24 @@ export class ZipProjectStore {
|
|||||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keep map markers glued to the art when previewRotationDeg changes (image/video).
|
||||||
|
if (patch.previewRotationDeg !== undefined) {
|
||||||
|
const fromRot = asPreviewRotationDeg(base.previewRotationDeg);
|
||||||
|
const toRot = asPreviewRotationDeg(patch.previewRotationDeg);
|
||||||
|
const steps = previewRotationStepsCw(fromRot, toRot);
|
||||||
|
if (steps !== 0) {
|
||||||
|
if (patch.tokens === undefined) {
|
||||||
|
next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps);
|
||||||
|
}
|
||||||
|
if (patch.npcTokens === undefined) {
|
||||||
|
next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps);
|
||||||
|
}
|
||||||
|
if (patch.traps === undefined) {
|
||||||
|
next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.updateProject((p) => {
|
await this.updateProject((p) => {
|
||||||
const scenes = { ...p.scenes, [sceneId]: next };
|
const scenes = { ...p.scenes, [sceneId]: next };
|
||||||
const sceneListOrder =
|
const sceneListOrder =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
|
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
|
||||||
|
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||||
|
|
||||||
function emptyState(): SceneTokensSessionState {
|
function emptyState(): SceneTokensSessionState {
|
||||||
return {
|
return {
|
||||||
@@ -23,6 +24,26 @@ export class SceneTokensSessionStore {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
|
||||||
|
rotateMapCwSteps(steps: number): SceneTokensSessionState {
|
||||||
|
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 byPlacementId: SceneTokensSessionState['byPlacementId'] = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
byPlacementId,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
|
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case 'clear':
|
case 'clear':
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
} from '../../../shared/types';
|
} from '../../../shared/types';
|
||||||
import { getDndApi } from '../../shared/dndApi';
|
import { getDndApi } from '../../shared/dndApi';
|
||||||
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
|
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
|
||||||
|
import { applyPreviewRotationToSceneMarkers } from '../../../shared/types/scenePreviewRotation';
|
||||||
|
|
||||||
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
||||||
|
|
||||||
@@ -600,6 +601,25 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||||
};
|
};
|
||||||
|
if (patch.previewRotationDeg !== undefined) {
|
||||||
|
const remapped = applyPreviewRotationToSceneMarkers(
|
||||||
|
{
|
||||||
|
previewRotationDeg: scene.previewRotationDeg ?? 0,
|
||||||
|
tokens: scene.tokens ?? [],
|
||||||
|
npcTokens: scene.npcTokens ?? [],
|
||||||
|
traps: scene.traps ?? [],
|
||||||
|
},
|
||||||
|
patch.previewRotationDeg,
|
||||||
|
{
|
||||||
|
tokensProvided: patch.tokens !== undefined,
|
||||||
|
npcTokensProvided: patch.npcTokens !== undefined,
|
||||||
|
trapsProvided: patch.traps !== undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (patch.tokens === undefined) next.tokens = remapped.tokens;
|
||||||
|
if (patch.npcTokens === undefined) next.npcTokens = remapped.npcTokens;
|
||||||
|
if (patch.traps === undefined) next.traps = remapped.traps;
|
||||||
|
}
|
||||||
const scenes = { ...p.scenes, [sceneId]: next };
|
const scenes = { ...p.scenes, [sceneId]: next };
|
||||||
const project: Project = { ...p, scenes };
|
const project: Project = { ...p, scenes };
|
||||||
return { ...s, project };
|
return { ...s, project };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * from './npcs';
|
|||||||
export * from './sceneDarkness';
|
export * from './sceneDarkness';
|
||||||
export * from './sceneGrid';
|
export * from './sceneGrid';
|
||||||
export * from './sceneGridSnap';
|
export * from './sceneGridSnap';
|
||||||
|
export * from './scenePreviewRotation';
|
||||||
export * from './sceneTraps';
|
export * from './sceneTraps';
|
||||||
export * from './sceneView';
|
export * from './sceneView';
|
||||||
export * from './videoPlayback';
|
export * from './videoPlayback';
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyPreviewRotationToSceneMarkers,
|
||||||
|
previewRotationStepsCw,
|
||||||
|
rotateMapNormPointByCwSteps,
|
||||||
|
rotateMapNormPointCw90,
|
||||||
|
rotateSceneTokensByCwSteps,
|
||||||
|
} from './scenePreviewRotation';
|
||||||
|
|
||||||
|
void test('previewRotationStepsCw wraps correctly', () => {
|
||||||
|
assert.equal(previewRotationStepsCw(0, 90), 1);
|
||||||
|
assert.equal(previewRotationStepsCw(90, 180), 1);
|
||||||
|
assert.equal(previewRotationStepsCw(0, 270), 3);
|
||||||
|
assert.equal(previewRotationStepsCw(270, 0), 1);
|
||||||
|
assert.equal(previewRotationStepsCw(90, 90), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('rotateMapNormPointCw90 matches CSS rotate(90deg) y-down', () => {
|
||||||
|
assert.deepEqual(rotateMapNormPointCw90(0, 0), { nx: 1, ny: 0 });
|
||||||
|
assert.deepEqual(rotateMapNormPointCw90(1, 0), { nx: 1, ny: 1 });
|
||||||
|
assert.deepEqual(rotateMapNormPointCw90(1, 1), { nx: 0, ny: 1 });
|
||||||
|
assert.deepEqual(rotateMapNormPointCw90(0, 1), { nx: 0, ny: 0 });
|
||||||
|
assert.deepEqual(rotateMapNormPointCw90(0.25, 0.1), { nx: 0.9, ny: 0.25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('four Cw90 steps return to start', () => {
|
||||||
|
const start = { nx: 0.2, ny: 0.7 };
|
||||||
|
assert.deepEqual(rotateMapNormPointByCwSteps(start.nx, start.ny, 4), start);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('rotateSceneTokensByCwSteps also turns facing', () => {
|
||||||
|
const [tok] = rotateSceneTokensByCwSteps([{ id: 'a', nx: 0.2, ny: 0.1, rotationDeg: 15 }], 1);
|
||||||
|
assert.ok(tok);
|
||||||
|
assert.equal(tok.nx, 0.9);
|
||||||
|
assert.equal(tok.ny, 0.2);
|
||||||
|
assert.equal(tok.rotationDeg, 105);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('applyPreviewRotationToSceneMarkers remaps when rotation changes', () => {
|
||||||
|
const scene = {
|
||||||
|
previewRotationDeg: 0 as const,
|
||||||
|
tokens: [{ id: 't', nx: 0.2, ny: 0.1, rotationDeg: 0 }],
|
||||||
|
npcTokens: [{ id: 'n', nx: 0.5, ny: 0.5 }],
|
||||||
|
traps: [{ id: 'tr', nx: 0, ny: 0 }],
|
||||||
|
};
|
||||||
|
const next = applyPreviewRotationToSceneMarkers(scene, 90);
|
||||||
|
assert.deepEqual(next.tokens[0], { id: 't', nx: 0.9, ny: 0.2, rotationDeg: 90 });
|
||||||
|
assert.deepEqual(next.npcTokens[0], { id: 'n', nx: 0.5, ny: 0.5 });
|
||||||
|
assert.deepEqual(next.traps[0], { id: 'tr', nx: 1, ny: 0 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/** Rotate map-normalized markers with scene previewRotationDeg (CSS rotate, y-down). */
|
||||||
|
|
||||||
|
export type PreviewRotationDeg = 0 | 90 | 180 | 270;
|
||||||
|
|
||||||
|
export function asPreviewRotationDeg(raw: unknown): PreviewRotationDeg {
|
||||||
|
return raw === 90 || raw === 180 || raw === 270 ? raw : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many 90° clockwise steps take `from` to `to` (0..3). */
|
||||||
|
export function previewRotationStepsCw(from: PreviewRotationDeg, to: PreviewRotationDeg): number {
|
||||||
|
return ((((to - from) / 90) % 4) + 4) % 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One 90° clockwise step in image/AABB normalized space (origin top-left, y down).
|
||||||
|
* Matches CSS `transform: rotate(90deg)` of the media around its center.
|
||||||
|
*/
|
||||||
|
export function rotateMapNormPointCw90(nx: number, ny: number): { nx: number; ny: number } {
|
||||||
|
return {
|
||||||
|
nx: clamp01(1 - ny),
|
||||||
|
ny: clamp01(nx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotateMapNormPointByCwSteps(
|
||||||
|
nx: number,
|
||||||
|
ny: number,
|
||||||
|
steps: number,
|
||||||
|
): { nx: number; ny: number } {
|
||||||
|
let x = nx;
|
||||||
|
let y = ny;
|
||||||
|
const n = ((steps % 4) + 4) % 4;
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
const next = rotateMapNormPointCw90(x, y);
|
||||||
|
x = next.nx;
|
||||||
|
y = next.ny;
|
||||||
|
}
|
||||||
|
return { nx: x, ny: y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotateMapMarkersByCwSteps<T extends { nx: number; ny: number }>(
|
||||||
|
items: readonly T[],
|
||||||
|
steps: number,
|
||||||
|
): T[] {
|
||||||
|
const n = ((steps % 4) + 4) % 4;
|
||||||
|
if (n === 0) return items.map((item) => ({ ...item }));
|
||||||
|
return items.map((item) => {
|
||||||
|
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
|
||||||
|
return { ...item, nx: p.nx, ny: p.ny };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-player tokens: move with the map and keep facing relative to the art. */
|
||||||
|
export function rotateSceneTokensByCwSteps<T extends { nx: number; ny: number; rotationDeg: number }>(
|
||||||
|
items: readonly T[],
|
||||||
|
steps: number,
|
||||||
|
): T[] {
|
||||||
|
const n = ((steps % 4) + 4) % 4;
|
||||||
|
if (n === 0) return items.map((item) => ({ ...item }));
|
||||||
|
const delta = n * 90;
|
||||||
|
return items.map((item) => {
|
||||||
|
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
|
||||||
|
return { ...item, nx: p.nx, ny: p.ny, rotationDeg: item.rotationDeg + delta };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply marker remaps when preview rotation changes.
|
||||||
|
* Skips collections explicitly provided in the patch (caller already sent final values).
|
||||||
|
*/
|
||||||
|
export function applyPreviewRotationToSceneMarkers<
|
||||||
|
TScene extends {
|
||||||
|
previewRotationDeg: PreviewRotationDeg;
|
||||||
|
tokens: readonly { nx: number; ny: number; rotationDeg: number }[];
|
||||||
|
npcTokens: readonly { nx: number; ny: number }[];
|
||||||
|
traps: readonly { nx: number; ny: number }[];
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
scene: TScene,
|
||||||
|
nextRotationDeg: PreviewRotationDeg,
|
||||||
|
opts?: {
|
||||||
|
tokensProvided?: boolean;
|
||||||
|
npcTokensProvided?: boolean;
|
||||||
|
trapsProvided?: boolean;
|
||||||
|
},
|
||||||
|
): Pick<TScene, 'tokens' | 'npcTokens' | 'traps'> {
|
||||||
|
const steps = previewRotationStepsCw(scene.previewRotationDeg, nextRotationDeg);
|
||||||
|
return {
|
||||||
|
tokens: opts?.tokensProvided
|
||||||
|
? (scene.tokens as TScene['tokens'])
|
||||||
|
: (rotateSceneTokensByCwSteps(scene.tokens, steps) as TScene['tokens']),
|
||||||
|
npcTokens: opts?.npcTokensProvided
|
||||||
|
? (scene.npcTokens as TScene['npcTokens'])
|
||||||
|
: (rotateMapMarkersByCwSteps(scene.npcTokens, steps) as TScene['npcTokens']),
|
||||||
|
traps: opts?.trapsProvided
|
||||||
|
? (scene.traps as TScene['traps'])
|
||||||
|
: (rotateMapMarkersByCwSteps(scene.traps, steps) as TScene['traps']),
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"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 && 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/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.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": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user