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
+1
View File
@@ -10,6 +10,7 @@ export * from './npcs';
export * from './sceneDarkness';
export * from './sceneGrid';
export * from './sceneGridSnap';
export * from './scenePreviewRotation';
export * from './sceneTraps';
export * from './sceneView';
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 });
});
+104
View File
@@ -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']),
};
}