46bec1a86a
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>
166 lines
5.5 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|
|
}
|