feat(control): spawn session NPCs and resize tokens in preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-18 14:42:06 +08:00
parent 1ab6ffd593
commit 1a9f0f2557
28 changed files with 1446 additions and 89 deletions
+67 -4
View File
@@ -121,19 +121,82 @@
.tile {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
grid-template-columns: auto 1fr;
gap: 4px;
align-items: center;
padding: 8px;
padding: 4px 8px 4px 4px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: #18181b;
cursor: pointer;
text-align: left;
color: inherit;
width: 100%;
}
.tileBody {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
align-items: center;
padding: 4px;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
color: inherit;
min-width: 0;
}
.tileMenuBtn {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text2);
cursor: pointer;
flex-shrink: 0;
}
.tileMenuBtn:hover {
background: #27272a;
color: var(--text1);
}
.menu {
position: fixed;
z-index: 80;
min-width: 160px;
padding: 6px;
border-radius: 10px;
background: #18181b;
border: 1px solid var(--stroke);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.menuItem {
border: 0;
background: transparent;
color: var(--text1);
text-align: left;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
width: 100%;
}
.menuItem:hover:not(:disabled) {
background: #27272a;
}
.menuItem:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tileSelected {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa;
+229 -10
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
@@ -8,8 +9,15 @@ import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import {
listVisibleSceneNpcTokens,
nextSessionNpcSpawnPoint,
} from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import styles from './NpcsApp.module.css';
@@ -54,26 +62,159 @@ function RuntimeNpcTile({
npc,
selected,
accentColor,
mapDragEnabled,
onActivate,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: {
npc: ProjectNpc;
selected: boolean;
accentColor?: string | null;
mapDragEnabled: boolean;
onActivate: () => void;
onAddToMap: () => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
}) {
const { t } = useEditorI18n();
const url = useAssetUrl(npc.avatarAssetId);
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const dragRef = useRef<{
pointerId: number;
x: number;
y: number;
started: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
useEffect(() => {
if (!menu) return;
const onPointerDown = (e: PointerEvent) => {
const tgt = e.target;
if (!(tgt instanceof Node)) return;
if (menuBtnRef.current?.contains(tgt)) return;
if (menuRef.current?.contains(tgt)) return;
setMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey);
};
}, [menu]);
return (
<button
type="button"
<div
className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
onClick={onActivate}
>
<div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tileName}>{npc.name}</div>
</button>
<button
ref={menuBtnRef}
type="button"
className={styles.tileMenuBtn}
aria-label={t('npcs.tileMenu')}
aria-haspopup="menu"
aria-expanded={menu !== null}
title={t('npcs.tileMenu')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const pad = 8;
const menuW = 180;
const menuH = 48;
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.max(pad, Math.min(rect.right + 4, window.innerWidth - menuW - pad));
const y = Math.max(pad, Math.min(rect.top, window.innerHeight - menuH - pad));
setMenu((cur) => (cur ? null : { x, y }));
}}
>
</button>
<button
type="button"
className={styles.tileBody}
onClick={(e) => {
if (!suppressClickRef.current) return;
suppressClickRef.current = false;
e.preventDefault();
e.stopPropagation();
}}
onPointerDown={(e) => {
if (e.button !== 0 || !mapDragEnabled) return;
dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false };
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId || d.started) return;
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return;
d.started = true;
onMapDragBegin(npc.id);
}}
onPointerUp={(e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
dragRef.current = null;
try {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
} catch {
/* ignore */
}
if (d.started) {
suppressClickRef.current = true;
e.preventDefault();
onMapDragEnd();
return;
}
onActivate();
}}
onPointerCancel={() => {
dragRef.current = null;
}}
>
<div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tileName}>{npc.name}</div>
</button>
{menu
? createPortal(
<div
ref={menuRef}
role="menu"
className={styles.menu}
style={{ left: menu.x, top: menu.y }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
disabled={!mapDragEnabled}
title={mapDragEnabled ? t('npcs.addToMapHint') : t('npcs.addToMapNoScene')}
onClick={() => {
setMenu(null);
if (!mapDragEnabled) return;
onAddToMap();
}}
>
{t('npcs.addToMap')}
</button>
</div>,
document.body,
)
: null}
</div>
);
}
@@ -83,14 +224,22 @@ function RuntimeGroupSection({
isExpanded,
onToggleExpanded,
selectedIds,
mapDragEnabled,
onActivate,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedIds: ReadonlySet<NpcId>;
mapDragEnabled: boolean;
onActivate: (id: NpcId) => void;
onAddToMap: (id: NpcId) => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
}) {
const g = node.group;
const expanded = isExpanded(g.id);
@@ -117,7 +266,11 @@ function RuntimeGroupSection({
npc={n}
selected={selectedIds.has(n.id)}
accentColor={g.color}
mapDragEnabled={mapDragEnabled}
onActivate={() => onActivate(n.id)}
onAddToMap={() => onAddToMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
{node.children.map((child) => (
@@ -128,7 +281,11 @@ function RuntimeGroupSection({
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedIds={selectedIds}
mapDragEnabled={mapDragEnabled}
onActivate={onActivate}
onAddToMap={onAddToMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
</div>
@@ -142,6 +299,8 @@ export function NpcsApp() {
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [overlay, overlayApi] = useNpcsOverlayState();
const [npcSession, npcSessionApi] = useSceneNpcTokensSession();
const [mapDrag, mapDragApi] = useNpcMapSpawnDrag();
const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
@@ -160,6 +319,10 @@ export function NpcsApp() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (mapDrag.dragging) {
void mapDragApi.cancel();
return;
}
if (hasActive) {
void overlayApi.dispatch({ kind: 'hide' });
return;
@@ -173,7 +336,7 @@ export function NpcsApp() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [hasActive, overlay?.zoomTool, overlayApi]);
}, [hasActive, mapDrag.dragging, mapDragApi, overlay?.zoomTool, overlayApi]);
const zoomTool = overlay?.zoomTool ?? null;
@@ -254,6 +417,47 @@ export function NpcsApp() {
[overlayApi],
);
const sceneId = session?.currentSceneId ?? null;
const currentScene = sceneId && session?.project ? session.project.scenes[sceneId] : undefined;
const mapDragEnabled = Boolean(sceneId);
const spawnNpcOnMap = useCallback(
(npcId: NpcId) => {
if (!sceneId) return;
const npc = npcs.find((item) => item.id === npcId);
if (!npc) return;
const visible = listVisibleSceneNpcTokens(
currentScene?.npcTokens,
npcSession.spawned,
sceneId,
);
const occupied = visible.map((tok) => {
const override = npcSession.byPlacementId[String(tok.id)];
return { nx: override?.nx ?? tok.nx, ny: override?.ny ?? tok.ny };
});
const point = nextSessionNpcSpawnPoint(occupied);
npcSessionApi.dispatch({
kind: 'spawn',
npcId,
sceneId,
nx: point.nx,
ny: point.ny,
disposition: normalizeNpcDisposition(npc.disposition),
});
},
[currentScene?.npcTokens, npcSession.byPlacementId, npcSession.spawned, npcSessionApi, npcs, sceneId],
);
const onMapDragBegin = useCallback(
(id: NpcId) => {
void mapDragApi.beginDrag(id);
},
[mapDragApi],
);
const onMapDragEnd = useCallback(() => {
void mapDragApi.commit();
}, [mapDragApi]);
return (
<div className={styles.page}>
<div className={styles.toolbar}>
@@ -296,6 +500,9 @@ export function NpcsApp() {
? t('npcs.zoomOutHint')
: t('npcs.zoomIdleHint')}
</div>
<div className={styles.toolbarHint}>
{sceneId ? t('npcs.dragToMapHint') : t('npcs.addToMapNoScene')}
</div>
<div className={styles.toolbarRow}>
<Button
title={t('npcs.closeOverlay')}
@@ -361,7 +568,11 @@ export function NpcsApp() {
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))
) : (
@@ -374,7 +585,11 @@ export function NpcsApp() {
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedIds={selectedIds}
mapDragEnabled={mapDragEnabled}
onActivate={onSelectTile}
onAddToMap={spawnNpcOnMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
{ungrouped.length > 0 || !searching ? (
@@ -386,7 +601,11 @@ export function NpcsApp() {
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
</div>