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:
@@ -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 });
|
||||
});
|
||||
@@ -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']),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user