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