feat(npcs): inspect cards, Show overlay, and optional external link

Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-19 10:42:57 +08:00
parent 1a9f0f2557
commit 0f4bb76b41
35 changed files with 1189 additions and 259 deletions
+85 -6
View File
@@ -16,6 +16,8 @@ function emptyState(): NpcsOverlayState {
layouts: {},
focusNpcId: null,
zoomTool: null,
inspectNpcIds: [],
inspectFocusNpcId: null,
};
}
@@ -23,6 +25,15 @@ function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
}
function addInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
if (ids.includes(npcId)) return ids;
return [...ids, npcId];
}
function removeInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
return ids.filter((id) => id !== npcId);
}
export class NpcsOverlayStore {
private state: NpcsOverlayState = emptyState();
@@ -30,11 +41,29 @@ export class NpcsOverlayStore {
return this.state;
}
/** Полный сброс (закрытие проекта / конец сессии). */
clear(): NpcsOverlayState {
const s = this.state;
if (
s.activeNpcIds.length === 0 &&
s.zoomTool === null &&
s.inspectNpcIds.length === 0 &&
s.inspectFocusNpcId === null
) {
return s;
}
this.state = emptyState();
this.state.revision = s.revision + 1;
return this.state;
}
/** Снять аватары с экрана, карточки описания оставить. */
hideAvatars(): NpcsOverlayState {
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [],
layouts: {},
@@ -47,7 +76,7 @@ export class NpcsOverlayStore {
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
return this.hideAvatars();
case 'show': {
if (this.state.activeNpcIds.includes(event.npcId)) {
this.state = {
@@ -58,6 +87,7 @@ export class NpcsOverlayStore {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
@@ -65,7 +95,6 @@ export class NpcsOverlayStore {
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
@@ -79,6 +108,7 @@ export class NpcsOverlayStore {
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
: this.state.focusNpcId;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds,
layouts,
@@ -88,6 +118,7 @@ export class NpcsOverlayStore {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
@@ -95,7 +126,38 @@ export class NpcsOverlayStore {
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'inspect.toggle': {
if (this.state.inspectNpcIds.includes(event.npcId)) {
const inspectNpcIds = removeInspect(this.state.inspectNpcIds, event.npcId);
const inspectFocusNpcId =
this.state.inspectFocusNpcId === event.npcId
? (inspectNpcIds[inspectNpcIds.length - 1] ?? null)
: this.state.inspectFocusNpcId;
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds,
inspectFocusNpcId,
};
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
inspectFocusNpcId: event.npcId,
};
return this.state;
}
case 'inspect.show': {
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
inspectFocusNpcId: event.npcId,
};
return this.state;
}
@@ -157,8 +219,19 @@ export class NpcsOverlayStore {
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 inspectNpcIds = this.state.inspectNpcIds.filter((id) => npcIds.has(id));
const sameActive = activeNpcIds.length === this.state.activeNpcIds.length;
const sameInspect = inspectNpcIds.length === this.state.inspectNpcIds.length;
if (sameActive && sameInspect) return this.state;
if (activeNpcIds.length === 0 && inspectNpcIds.length === 0) {
const zoomTool = this.state.zoomTool;
this.state = {
...emptyState(),
revision: this.state.revision + 1,
zoomTool,
};
return this.state;
}
const layouts: Record<string, NpcsOverlayLayout> = {};
for (const id of activeNpcIds) {
const layout = this.state.layouts[id];
@@ -168,12 +241,18 @@ export class NpcsOverlayStore {
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
? this.state.focusNpcId
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
const inspectFocusNpcId =
this.state.inspectFocusNpcId && inspectNpcIds.includes(this.state.inspectFocusNpcId)
? this.state.inspectFocusNpcId
: (inspectNpcIds[inspectNpcIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: this.state.zoomTool,
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
inspectNpcIds,
inspectFocusNpcId,
};
return this.state;
}