Files
DndGamePlayer/app/main/players/scenePlayerTokensSessionStore.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

166 lines
5.5 KiB
TypeScript

import {
clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
layoutPlayerTokensBottom,
type ScenePlayerTokensSessionEvent,
type ScenePlayerTokensSessionState,
} from '../../shared/types/appPlayers';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(revision = 1): ScenePlayerTokensSessionState {
return {
revision,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
};
}
function normalizePlayerIds(raw: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of raw) {
const s = String(id ?? '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
export class ScenePlayerTokensSessionStore {
private state: ScenePlayerTokensSessionState = emptyState();
getState(): ScenePlayerTokensSessionState {
return this.state;
}
reset(): ScenePlayerTokensSessionState {
if (
this.state.selectedPlayerIds.length === 0 &&
!this.state.visible &&
Object.keys(this.state.byPlayerId).length === 0
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
return this.state;
}
/** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */
clearPlacements(): ScenePlayerTokensSessionState {
if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: false,
byPlayerId: {},
};
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':
return this.reset();
case 'clearPlacements':
return this.clearPlacements();
case 'setSelection': {
const selectedPlayerIds = normalizePlayerIds(event.playerIds);
const same =
selectedPlayerIds.length === this.state.selectedPlayerIds.length &&
selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]);
if (same) return this.state;
this.state = {
revision: this.state.revision + 1,
selectedPlayerIds,
visible: false,
byPlayerId: {},
};
return this.state;
}
case 'setVisible': {
const visible = Boolean(event.visible);
if (this.state.visible === visible) return this.state;
if (visible && this.state.selectedPlayerIds.length === 0) return this.state;
this.state = { ...this.state, revision: this.state.revision + 1, visible };
return this.state;
}
case 'show': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const byPlayerId =
Object.keys(this.state.byPlayerId).length > 0
? this.state.byPlayerId
: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN);
if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: true,
byPlayerId,
};
return this.state;
}
case 'seedBottom': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
if (Object.keys(this.state.byPlayerId).length > 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN),
};
return this.state;
}
case 'move': {
const playerId = String(event.playerId ?? '');
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) 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.byPlayerId[playerId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: {
...this.state.byPlayerId,
[playerId]: { nx, ny, sizeN },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}