Files
DndGamePlayer/app/main/players/sceneNpcTokensSessionStore.ts
T
Ivan Fontosh 46bec1a86a 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>
2026-08-11 12:29:20 +08:00

183 lines
5.9 KiB
TypeScript

import {
clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
type SceneNpcTokensSessionEvent,
type SceneNpcTokensSessionPlacement,
type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers';
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.disposition && !p.inactive;
}
function withMove(
prev: SceneNpcTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneNpcTokensSessionPlacement {
return {
nx,
ny,
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
};
}
function withoutPlacement(
state: SceneNpcTokensSessionState,
placementId: string,
): SceneNpcTokensSessionState {
if (!(placementId in state.byPlacementId)) return state;
const { [placementId]: _removed, ...rest } = state.byPlacementId;
return {
revision: state.revision + 1,
byPlacementId: rest,
scale: state.scale,
};
}
export class SceneNpcTokensSessionStore {
private state: SceneNpcTokensSessionState = emptyState();
getState(): SceneNpcTokensSessionState {
return this.state;
}
reset(): SceneNpcTokensSessionState {
if (
Object.keys(this.state.byPlacementId).length === 0 &&
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
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':
return this.reset();
case 'setScale': {
const scale = clampNpcTokenSessionScale(event.scale);
if (this.state.scale === scale) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
scale,
};
return this.state;
}
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) 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 prev = this.state.byPlacementId[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: withMove(prev, nx, ny),
},
scale: this.state.scale,
};
return this.state;
}
case 'setDisposition': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const disposition = normalizeNpcDisposition(event.disposition);
const prev = this.state.byPlacementId[placementId];
if (prev?.disposition === disposition) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
disposition,
...(prev?.inactive ? { inactive: true } : {}),
};
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
};
return this.state;
}
case 'setInactive': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const inactive = Boolean(event.inactive);
const prev = this.state.byPlacementId[placementId];
if (Boolean(prev?.inactive) === inactive) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId);
return this.state;
}
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}