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
+100 -10
View File
@@ -22,6 +22,7 @@ import type {
import {
DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
listVisibleSceneNpcTokens,
NPC_TOKEN_SESSION_SCALE_MAX,
NPC_TOKEN_SESSION_SCALE_MIN,
} from '../../shared/types/appPlayers';
@@ -38,6 +39,7 @@ import {
} from '../shared/playerToken/SceneNpcTokensOverlay';
import { ScenePlayerTokensOverlay } from '../shared/playerToken/ScenePlayerTokensOverlay';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from '../shared/playerToken/useScenePlayerTokensSession';
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
@@ -161,6 +163,7 @@ export function ControlApp() {
const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
const [npcMapSpawnDrag, npcMapSpawnDragApi] = useNpcMapSpawnDrag();
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
@@ -385,6 +388,15 @@ export function ControlApp() {
const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const visibleNpcTokens = useMemo(
() =>
listVisibleSceneNpcTokens(
currentScene?.npcTokens,
sceneNpcTokensSession.spawned,
session?.currentSceneId,
),
[currentScene?.npcTokens, sceneNpcTokensSession.spawned, session?.currentSceneId],
);
const isDarkenScene = Boolean(currentScene?.darkenScene);
const snapNormActive = useCallback(
@@ -415,7 +427,7 @@ export function ControlApp() {
});
}
for (const placement of currentScene?.npcTokens ?? []) {
for (const placement of visibleNpcTokens) {
const key = String(placement.id);
const override = sceneNpcTokensSession?.byPlacementId[key];
const raw = {
@@ -448,8 +460,8 @@ export function ControlApp() {
}
}, [
currentScene?.grid,
currentScene?.npcTokens,
currentScene?.tokens,
visibleNpcTokens,
previewContentRect,
sceneNpcTokensApi,
sceneNpcTokensSession?.byPlacementId,
@@ -1005,7 +1017,7 @@ export function ControlApp() {
const pathLivePoses = useTokenPathLivePoses({
tokens: currentScene?.tokens ?? [],
npcTokens: currentScene?.npcTokens ?? [],
npcTokens: visibleNpcTokens,
pathSession: tokenPathSession,
enabled: Boolean(currentScene),
onMarkDone: (kind, placementId) => {
@@ -1013,7 +1025,7 @@ export function ControlApp() {
const placement =
kind === 'token'
? (currentScene?.tokens ?? []).find((t) => String(t.id) === placementId)
: (currentScene?.npcTokens ?? []).find((t) => String(t.id) === placementId);
: visibleNpcTokens.find((t) => String(t.id) === placementId);
if (!placement?.path) return;
const end = sampleTokenPathAtDistance(placement.path, tokenPathTotalLength(placement.path));
if (!end) return;
@@ -1047,13 +1059,40 @@ export function ControlApp() {
const npcPathDragEnabled = useMemo(() => {
const out: Record<string, boolean> = {};
for (const t of currentScene?.npcTokens ?? []) {
for (const t of visibleNpcTokens) {
const key = String(t.id);
const entry = tokenPathSession?.playback[tokenPathKey('npcToken', key)];
out[key] = !entry || entry.phase === 'stopped';
}
return out;
}, [currentScene?.npcTokens, tokenPathSession?.playback]);
}, [visibleNpcTokens, tokenPathSession?.playback]);
useEffect(() => {
if (!npcMapSpawnDrag.dragging) return;
const toNorm = (clientX: number, clientY: number) => {
const host = previewHostRef.current;
const cr = previewContentRectRef.current;
if (!host || !cr) return null;
const r = host.getBoundingClientRect();
const nx = (clientX - (r.left + cr.x)) / Math.max(1e-6, cr.w);
const ny = (clientY - (r.top + cr.y)) / Math.max(1e-6, cr.h);
if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return null;
return snapNormActive(nx, ny);
};
const onMove = (e: MouseEvent) => {
const p = toNorm(e.clientX, e.clientY);
void npcMapSpawnDragApi.setHover(p ? p.nx : null, p ? p.ny : null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') void npcMapSpawnDragApi.cancel();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('keydown', onKey);
};
}, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]);
useEffect(() => {
if (!markersInteractive) {
@@ -2025,7 +2064,15 @@ export function ControlApp() {
className={styles.previewFrame}
title="Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан"
>
<div ref={previewHostRef} className={styles.previewHost}>
<div
ref={previewHostRef}
className={[
styles.previewHost,
npcMapSpawnDrag.dragging ? styles.previewHostSpawnDrag : '',
]
.filter(Boolean)
.join(' ')}
>
<ControlScenePreview
session={session}
videoRef={previewVideoRef}
@@ -2033,6 +2080,18 @@ export function ControlApp() {
onContentRectChange={setPreviewContentRect}
/>
</div>
{npcMapSpawnDrag.dragging &&
previewContentRect &&
npcMapSpawnDrag.hoverNx != null &&
npcMapSpawnDrag.hoverNy != null ? (
<div
className={styles.npcSpawnGhost}
style={{
left: previewContentRect.x + npcMapSpawnDrag.hoverNx * previewContentRect.w,
top: previewContentRect.y + npcMapSpawnDrag.hoverNy * previewContentRect.h,
}}
/>
) : null}
<>
<SceneGridOverlay grid={currentScene?.grid} viewport={previewContentRect} />
<PixiEffectsOverlay
@@ -2209,7 +2268,7 @@ export function ControlApp() {
{previewContentRect ? (
<TokenPathsOverlay
tokens={currentScene?.tokens ?? []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (currentScene?.npcTokens ?? []) : []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []}
viewport={previewContentRect}
mode="always"
pathSession={tokenPathSession}
@@ -2234,6 +2293,10 @@ export function ControlApp() {
ny: snapped.ny,
});
}}
onResize={(placementId, sizeN) => {
void sceneTokensApi.dispatch({ kind: 'setSize', placementId, sizeN });
}}
resizeTitle={t('control.resizeToken')}
{...(markersInteractive
? {
onContextMenu: (
@@ -2252,7 +2315,7 @@ export function ControlApp() {
) : null}
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
<SceneNpcTokensOverlay
placements={currentScene?.npcTokens ?? []}
placements={visibleNpcTokens}
library={session?.project?.npcs ?? []}
session={sceneNpcTokensSession}
viewport={previewContentRect}
@@ -2270,6 +2333,10 @@ export function ControlApp() {
ny: snapped.ny,
});
}}
onResize={(placementId, sizeN) => {
sceneNpcTokensApi.dispatch({ kind: 'setSize', placementId, sizeN });
}}
resizeTitle={t('control.resizeToken')}
{...(markersInteractive
? {
onContextMenu: (
@@ -2306,6 +2373,10 @@ export function ControlApp() {
ny: snapped.ny,
});
}}
onResize={(playerId, sizeN) => {
scenePlayerTokensApi.dispatch({ kind: 'setSize', playerId, sizeN });
}}
resizeTitle={t('control.resizeToken')}
/>
) : null}
{previewContentRect ? (
@@ -2914,7 +2985,7 @@ export function ControlApp() {
role="menu"
>
{(() => {
const placement = (currentScene?.npcTokens ?? []).find(
const placement = visibleNpcTokens.find(
(item) => String(item.id) === npcSessionCtxMenu.placementId,
);
const npc = placement
@@ -2956,8 +3027,27 @@ export function ControlApp() {
</button>
);
}
const isSessionSpawn = Boolean(
sceneNpcTokensSession.spawned[npcSessionCtxMenu.placementId],
);
return (
<>
{isSessionSpawn ? (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
sceneNpcTokensApi.dispatch({
kind: 'despawn',
placementId: npcSessionCtxMenu.placementId,
});
setNpcSessionCtxMenu(null);
}}
>
{t('npcs.removeFromMap')}
</button>
) : null}
{path && path.points.length >= 2 ? (
<>
<button