Files
DndGamePlayer/app/main/npcs/npcsOverlayStore.ts
T
Ivan Fontosh 1829191410 feat(npcs): show multiple character overlays in session playback
Toggle NPCs independently from the characters window, keep one shared dim/close-all, and scroll descriptions for all selected characters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:23:19 +08:00

178 lines
5.3 KiB
TypeScript

import {
clampNpcsLayout,
DEFAULT_NPCS_OVERLAY_LAYOUT,
type NpcId,
type NpcsOverlayEvent,
type NpcsOverlayLayout,
type NpcsOverlayState,
type NpcsZoomTool,
zoomNpcsLayoutAt,
} from '../../shared/types';
function emptyState(): NpcsOverlayState {
return {
revision: 1,
activeNpcIds: [],
layouts: {},
focusNpcId: null,
zoomTool: null,
};
}
function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
}
export class NpcsOverlayStore {
private state: NpcsOverlayState = emptyState();
getState(): NpcsOverlayState {
return this.state;
}
clear(): NpcsOverlayState {
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeNpcIds: [],
layouts: {},
focusNpcId: null,
zoomTool: null,
};
return this.state;
}
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
case 'show': {
if (this.state.activeNpcIds.includes(event.npcId)) {
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: event.npcId,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
...this.state.layouts,
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'toggle': {
if (this.state.activeNpcIds.includes(event.npcId)) {
const activeNpcIds = this.state.activeNpcIds.filter((id) => id !== event.npcId);
const layouts = { ...this.state.layouts };
delete layouts[event.npcId];
const focusNpcId =
this.state.focusNpcId === event.npcId
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
: this.state.focusNpcId;
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
...this.state.layouts,
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'layout.set': {
if (!this.state.activeNpcIds.includes(event.npcId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: event.npcId,
layouts: {
...this.state.layouts,
[event.npcId]: clampNpcsLayout(event.layout),
},
};
return this.state;
}
case 'zoomTool.set': {
const tool: NpcsZoomTool = event.tool;
this.state = {
...this.state,
revision: this.state.revision + 1,
zoomTool: tool,
};
return this.state;
}
case 'zoomAt': {
if (this.state.activeNpcIds.length === 0 || !this.state.zoomTool) return this.state;
const targetId =
(event.npcId && this.state.activeNpcIds.includes(event.npcId) ? event.npcId : null) ??
this.state.focusNpcId ??
this.state.activeNpcIds[this.state.activeNpcIds.length - 1] ??
null;
if (!targetId) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: NpcsOverlayLayout = zoomNpcsLayoutAt(
layoutFor(this.state, targetId),
event.nx,
event.ny,
factor,
);
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: targetId,
layouts: {
...this.state.layouts,
[targetId]: layout,
},
};
return this.state;
}
default:
return this.state;
}
}
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state;
if (activeNpcIds.length === 0) return this.clear();
const layouts: Record<string, NpcsOverlayLayout> = {};
for (const id of activeNpcIds) {
const layout = this.state.layouts[id];
if (layout) layouts[id] = layout;
}
const focusNpcId =
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
? this.state.focusNpcId
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
}