feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+229
-10
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user