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:
Ivan Fontosh
2026-08-11 12:29:20 +08:00
parent 7362a36fe5
commit 46bec1a86a
11 changed files with 312 additions and 1 deletions
@@ -6,6 +6,7 @@ import {
type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(revision = 1): SceneNpcTokensSessionState {
return {
@@ -63,6 +64,34 @@ export class SceneNpcTokensSessionStore {
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 {
switch (event.kind) {
case 'clear':
@@ -37,3 +37,13 @@ void test('ScenePlayerTokensSessionStore move and reset', () => {
assert.equal(s2.visible, false);
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 ScenePlayerTokensSessionState,
} from '../../shared/types/appPlayers';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(revision = 1): ScenePlayerTokensSessionState {
return {
@@ -60,6 +61,27 @@ export class ScenePlayerTokensSessionStore {
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 {
switch (event.kind) {
case 'clear':