1a9f0f2557
Co-authored-by: Cursor <cursoragent@cursor.com>
285 lines
9.7 KiB
TypeScript
285 lines
9.7 KiB
TypeScript
import {
|
|
clampNpcTokenSessionScale,
|
|
clampSceneNpcTokenSizeN,
|
|
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
|
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
|
asSceneNpcTokenId,
|
|
emptySceneNpcTokensSessionState,
|
|
newSceneNpcSessionSpawnId,
|
|
type SceneNpcSessionSpawn,
|
|
type SceneNpcTokensSessionEvent,
|
|
type SceneNpcTokensSessionPlacement,
|
|
type SceneNpcTokensSessionState,
|
|
} from '../../shared/types/appPlayers';
|
|
import { asNpcId } from '../../shared/types/ids';
|
|
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
|
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
|
|
|
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
|
return (
|
|
p.nx === undefined &&
|
|
p.ny === undefined &&
|
|
p.sizeN === undefined &&
|
|
!p.disposition &&
|
|
!p.inactive
|
|
);
|
|
}
|
|
|
|
function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement {
|
|
return {
|
|
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
|
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
|
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
|
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
|
...(prev?.inactive ? { inactive: true } : {}),
|
|
};
|
|
}
|
|
|
|
function withMove(
|
|
prev: SceneNpcTokensSessionPlacement | undefined,
|
|
nx: number,
|
|
ny: number,
|
|
): SceneNpcTokensSessionPlacement {
|
|
return {
|
|
...restOf(prev),
|
|
nx,
|
|
ny,
|
|
};
|
|
}
|
|
|
|
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,
|
|
spawned: state.spawned,
|
|
};
|
|
}
|
|
|
|
export class SceneNpcTokensSessionStore {
|
|
private state: SceneNpcTokensSessionState = emptySceneNpcTokensSessionState();
|
|
|
|
getState(): SceneNpcTokensSessionState {
|
|
return this.state;
|
|
}
|
|
|
|
reset(): SceneNpcTokensSessionState {
|
|
if (
|
|
Object.keys(this.state.byPlacementId).length === 0 &&
|
|
Object.keys(this.state.spawned).length === 0 &&
|
|
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
|
|
) {
|
|
return this.state;
|
|
}
|
|
this.state = emptySceneNpcTokensSessionState(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);
|
|
const spawnIds = Object.keys(this.state.spawned);
|
|
if (ids.length === 0 && spawnIds.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;
|
|
}
|
|
}
|
|
const spawned: SceneNpcTokensSessionState['spawned'] = {};
|
|
for (const id of spawnIds) {
|
|
const prev = this.state.spawned[id];
|
|
if (!prev) continue;
|
|
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
|
spawned[id] = { ...prev, nx: p.nx, ny: p.ny };
|
|
changed = true;
|
|
}
|
|
if (!changed) return this.state;
|
|
this.state = {
|
|
revision: this.state.revision + 1,
|
|
byPlacementId,
|
|
scale: this.state.scale,
|
|
spawned,
|
|
};
|
|
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 'spawn': {
|
|
const npcId = String(event.npcId ?? '');
|
|
const sceneId = String(event.sceneId ?? '');
|
|
if (!npcId || !sceneId) 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 id = event.placementId
|
|
? asSceneNpcTokenId(String(event.placementId))
|
|
: newSceneNpcSessionSpawnId();
|
|
const spawn: SceneNpcSessionSpawn = {
|
|
id,
|
|
sceneId,
|
|
npcId: asNpcId(npcId),
|
|
nx,
|
|
ny,
|
|
sizeN: clampSceneNpcTokenSizeN(
|
|
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
|
),
|
|
disposition: normalizeNpcDisposition(event.disposition),
|
|
};
|
|
this.state = {
|
|
revision: this.state.revision + 1,
|
|
byPlacementId: this.state.byPlacementId,
|
|
scale: this.state.scale,
|
|
spawned: { ...this.state.spawned, [String(spawn.id)]: spawn },
|
|
};
|
|
return this.state;
|
|
}
|
|
case 'despawn': {
|
|
const placementId = String(event.placementId ?? '');
|
|
if (!placementId) return this.state;
|
|
if (!(placementId in this.state.spawned) && !(placementId in this.state.byPlacementId)) {
|
|
return this.state;
|
|
}
|
|
const { [placementId]: _s, ...spawned } = this.state.spawned;
|
|
const { [placementId]: _p, ...byPlacementId } = this.state.byPlacementId;
|
|
this.state = {
|
|
revision: this.state.revision + 1,
|
|
byPlacementId,
|
|
scale: this.state.scale,
|
|
spawned,
|
|
};
|
|
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];
|
|
const spawn = this.state.spawned[placementId];
|
|
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
|
const spawned = spawn
|
|
? { ...this.state.spawned, [placementId]: { ...spawn, nx, ny } }
|
|
: this.state.spawned;
|
|
this.state = {
|
|
revision: this.state.revision + 1,
|
|
byPlacementId: {
|
|
...this.state.byPlacementId,
|
|
[placementId]: withMove(prev, nx, ny),
|
|
},
|
|
scale: this.state.scale,
|
|
spawned,
|
|
};
|
|
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];
|
|
const spawn = this.state.spawned[placementId];
|
|
if (prev?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
|
|
return this.state;
|
|
}
|
|
const next: SceneNpcTokensSessionPlacement = {
|
|
...restOf(prev),
|
|
disposition,
|
|
};
|
|
this.state = {
|
|
revision: this.state.revision + 1,
|
|
byPlacementId: {
|
|
...this.state.byPlacementId,
|
|
[placementId]: next,
|
|
},
|
|
scale: this.state.scale,
|
|
spawned: spawn
|
|
? { ...this.state.spawned, [placementId]: { ...spawn, disposition } }
|
|
: this.state.spawned,
|
|
};
|
|
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?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
|
...(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,
|
|
spawned: this.state.spawned,
|
|
};
|
|
return this.state;
|
|
}
|
|
case 'setSize': {
|
|
const placementId = String(event.placementId ?? '');
|
|
if (!placementId) return this.state;
|
|
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
|
const prev = this.state.byPlacementId[placementId];
|
|
const spawn = this.state.spawned[placementId];
|
|
if (prev?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return this.state;
|
|
this.state = {
|
|
revision: this.state.revision + 1,
|
|
byPlacementId: {
|
|
...this.state.byPlacementId,
|
|
[placementId]: { ...restOf(prev), sizeN },
|
|
},
|
|
scale: this.state.scale,
|
|
spawned: spawn
|
|
? { ...this.state.spawned, [placementId]: { ...spawn, sizeN } }
|
|
: this.state.spawned,
|
|
};
|
|
return this.state;
|
|
}
|
|
default: {
|
|
const _exhaustive: never = event;
|
|
void _exhaustive;
|
|
return this.state;
|
|
}
|
|
}
|
|
}
|
|
}
|