diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css
index 3d119aab9..f59614598 100644
--- a/apps/ai-game-creator-shell/src/styles.css
+++ b/apps/ai-game-creator-shell/src/styles.css
@@ -5619,19 +5619,6 @@ iframe.preview-frame {
transition: none;
}
-.game-resource-book-transition-layer {
- position: absolute;
- inset: 0;
- z-index: 35;
- overflow: hidden;
- pointer-events: none;
-}
-
-.game-resource-book-manager[data-book-motion='running']
- .game-resource-book-scene-world {
- opacity: 0;
-}
-
.game-resource-book-scene--child {
pointer-events: auto;
}
@@ -5721,6 +5708,8 @@ iframe.preview-frame {
box-shadow: 0 8px 18px rgb(112 70 52 / 8%);
pointer-events: none;
transform: translateY(0);
+ /* FLIP 反向 transform 围绕节点自身原点缩放。 */
+ transform-origin: 0 0;
transition: none;
}
@@ -5739,6 +5728,8 @@ iframe.preview-frame {
position: absolute;
z-index: 25;
pointer-events: none;
+ /* 卡片包装节点是 0x0,FLIP 反向 transform 围绕它的原点缩放。 */
+ transform-origin: 0 0;
translate: 0 0;
transition: none;
}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx
deleted file mode 100644
index f5ebb6efc..000000000
--- a/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-export function ResourceBookTransitionLayer() {
- return (
-
- );
-}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
index 815a964ab..cc7c5919d 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
@@ -75,22 +75,19 @@ import {
type ResourceBookTransitionController,
} from './resourceBookController';
import {
- groupResourceBookResourcesByCategory,
- groupResourceBookStacks,
- resourceBookChildCardLayout,
- resourceBookOverviewCardLayout,
+ buildResourceBookScenePlan,
type ResourceBookOverviewRect,
- selectResourceBookOverviewCards,
+ resourceBookSceneCardKey,
+ resourceBookSceneCardLayoutSignature,
+ type ResourceBookSceneCardPlan,
+ type ResourceBookSceneCategoryPlan,
+ resourceBookSceneTitleKey,
} from './resourceBookLayout';
import {
initialResourceBookState,
resourceBookReducer,
- resourceBookSceneCategory,
- resourceBookShowsChildCards,
- resourceBookShowsOverviewCards,
type ResourceBookState,
} from './resourceBookModel';
-import { ResourceBookTransitionLayer } from './ResourceBookTransitionLayer';
import {
DEFAULT_RESOURCE_BOOK_VIEWPORT,
ensureResourceBookContentVisible,
@@ -979,15 +976,11 @@ function ResourceBookTitleBar({
}
function ResourceBookScene({
- resources,
- visibleCategoryOrder,
+ plan,
resourceBookState,
overviewRects,
- positions,
- cardSizes,
+ overviewReady,
visibleResourceIds,
- categoryViewCenters,
- cardsReady,
viewport,
mainViewport,
renderCard,
@@ -998,15 +991,11 @@ function ResourceBookScene({
onPointerCancel,
onWheel,
}: {
- resources: ProjectResource[];
- visibleCategoryOrder: readonly ResourceCategory[];
+ plan: ResourceBookSceneCategoryPlan[];
resourceBookState: ResourceBookState;
overviewRects: Map;
- positions: Map;
- cardSizes: Map;
+ overviewReady: boolean;
visibleResourceIds: Set;
- categoryViewCenters: Map;
- cardsReady: boolean;
viewport: CanvasViewport;
mainViewport: CanvasViewport;
renderCard: (
@@ -1028,14 +1017,6 @@ function ResourceBookScene({
onPointerCancel: (event: ReactPointerEvent) => void;
onWheel: (event: ReactWheelEvent) => void;
}) {
- const resourcesByCategory = groupResourceBookResourcesByCategory(resources);
- const sceneCategory = resourceBookSceneCategory(resourceBookState);
- const showOverviewCards = resourceBookShowsOverviewCards(resourceBookState);
- const showChildCards = resourceBookShowsChildCards(resourceBookState);
- const overviewReady = visibleCategoryOrder.every((category) => {
- const rect = overviewRects.get(category);
- return Boolean(rect && rect.width > 0 && rect.height > 0);
- });
const safeViewport = normalizeResourceBookViewport(viewport);
const safeMainViewport = normalizeResourceBookViewport(mainViewport);
const sceneViewport =
@@ -1059,29 +1040,25 @@ function ResourceBookScene({
} as CSSProperties
}
>
- {visibleCategoryOrder.map((category) => {
- const rect = overviewRects.get(category);
- const categoryResources = resourcesByCategory.get(category) ?? [];
- const stacks = groupResourceBookStacks(categoryResources);
- const active = showChildCards && sceneCategory === category;
- const renderTransitionCards = showOverviewCards;
- const renderCards = cardsReady && (renderTransitionCards || active);
+ {plan.map((group) => {
+ const active = group.titlebarActive;
+ const rect = overviewRects.get(group.category);
const titleLayout = active
? { left: 0, top: 0, width: '100%', opacity: 1 }
: {
left: rect?.left ?? 0,
top: rect?.top ?? 0,
width: `${rect?.width ?? 280}px`,
- opacity: renderTransitionCards ? 1 : 0,
+ opacity: group.titlebarOpacity,
};
const titlebarTransform = active
? `translate(${-safeViewport.x / safeViewport.scale}px, ${-safeViewport.y / safeViewport.scale}px) scale(${1 / safeViewport.scale})`
: undefined;
return (
-
+
- {renderCards &&
- stacks.flatMap(([_type, items], stackColumn) => {
- const expanded = active;
- // 总览只铺前几张,避免一个类型素材变多时把整摞堆到同一处;
- // 数量由栏目标题栏的“N 项”表达,子画布仍然渲染全部资源。
- const stackItems = expanded
- ? items
- : selectResourceBookOverviewCards({
- resources: items,
- visibleResourceIds,
- positions,
- cardSizes,
- viewCenter: categoryViewCenters.get(category),
- });
- return stackItems.map((resource, stackIndex) => {
- const position = positions.get(resource.id);
- const size = cardSizes.get(resource.id) ?? {
- width: 180,
- height: 128,
- };
- const cardVisible =
- visibleResourceIds.has(resource.id) &&
- (renderTransitionCards || expanded);
- const layout = expanded
- ? resourceBookChildCardLayout({
- position,
- size,
- })
- : resourceBookOverviewCardLayout({
- rect,
- stackIndex,
- stackColumn,
- });
- return (
-
- {renderCard(resource, layout)}
-
- );
- });
- })}
+ {group.cards.map((card) => {
+ const expanded = card.presentation === 'child';
+ const cardVisible =
+ visibleResourceIds.has(card.resource.id) &&
+ (expanded ||
+ (card.presentation === 'overview' &&
+ resourceBookState.view === 'main'));
+ return (
+
+ {renderCard(card.resource, card.layout)}
+
+ );
+ })}
);
})}
@@ -1210,6 +1160,9 @@ export default function ProjectDevelopmentView({
createResourceBookTransitionController(),
);
const resourceBookManagerRef = useRef(null);
+ const resourceBookPlanKeysRef = useRef(
+ new Map(),
+ );
const resourceBookThumbnailElementsRef = useRef(
new Map(),
);
@@ -2434,6 +2387,121 @@ export default function ProjectDevelopmentView({
};
}, []);
+ // 搜索/排序导致的卸载:保留一帧,让同一套 FLIP 把卡片淡出后再撤下。
+ const [resourceBookExitingCards, setResourceBookExitingCards] = useState<
+ ResourceBookSceneCardPlan[]
+ >([]);
+ const resourceBookExitingTimerRef = useRef(null);
+ const resourceBookScenePlan = useMemo(
+ () =>
+ buildResourceBookScenePlan({
+ state: resourceBookState,
+ visibleCategoryOrder,
+ resourcesByCategory: projectResourcesByCategory,
+ overviewRects: resourceBookOverviewRects,
+ positions: resourcePositionById,
+ cardSizes: resourceCardSizeByResourceId,
+ visibleResourceIds,
+ categoryViewCenters: resourceBookCategoryViewCenters,
+ cardsReady:
+ sortMode !== 'dependency' || resourceGraphInitializationReady,
+ exitingCategory:
+ resourceBookState.phase === 'returning-main'
+ ? activePageCategory
+ : null,
+ exitingCards: resourceBookExitingCards,
+ }),
+ [
+ activePageCategory,
+ projectResourcesByCategory,
+ resourceBookCategoryViewCenters,
+ resourceBookExitingCards,
+ resourceBookOverviewRects,
+ resourceBookState,
+ resourceCardSizeByResourceId,
+ resourceGraphInitializationReady,
+ resourcePositionById,
+ sortMode,
+ visibleResourceIds,
+ ],
+ );
+ const resourceBookOverviewReady = useMemo(
+ () =>
+ visibleCategoryOrder.every((category) => {
+ const rect = resourceBookOverviewRects.get(category);
+ return Boolean(rect && rect.width > 0 && rect.height > 0);
+ }),
+ [resourceBookOverviewRects],
+ );
+ const resourceBookMotionSignatures = useMemo(() => {
+ const signatures = new Map();
+ for (const group of resourceBookScenePlan) {
+ const rect = resourceBookOverviewRects.get(group.category);
+ signatures.set(
+ resourceBookSceneTitleKey(group.category),
+ group.titlebarActive
+ ? 'active'
+ : `inactive:${rect?.left ?? 0},${rect?.top ?? 0},${rect?.width ?? 0}`,
+ );
+ for (const card of group.cards) {
+ signatures.set(card.key, resourceBookSceneCardLayoutSignature(card));
+ }
+ }
+ return signatures;
+ }, [resourceBookOverviewRects, resourceBookScenePlan]);
+ // 拖拽中的卡片只跟指针走,不参与布局 FLIP。
+ const resourceBookFrozenMotionKeys = useMemo(() => {
+ const keys = new Set();
+ if (resourceCardDragPreview) {
+ keys.add(resourceBookSceneCardKey(resourceCardDragPreview.resourceId));
+ }
+ return keys;
+ }, [resourceCardDragPreview]);
+ useLayoutEffect(() => {
+ const previousKeys = resourceBookPlanKeysRef.current;
+ const currentKeys = new Map();
+ for (const group of resourceBookScenePlan) {
+ for (const card of group.cards) {
+ // 只跟踪"当前看得见"的卡片:转场期间正在淡出的节点不需要再补一次淡出。
+ const visible =
+ visibleResourceIds.has(card.resource.id) &&
+ (card.presentation === 'child' || resourceBookState.view === 'main');
+ if (card.presentation !== 'exiting' && visible) {
+ currentKeys.set(card.key, card);
+ }
+ }
+ }
+ resourceBookPlanKeysRef.current = currentKeys;
+ const removed = Array.from(previousKeys)
+ .filter(([key]) => !currentKeys.has(key))
+ .map(([, card]) => card);
+ if (removed.length === 0) return;
+ if (resourceBookExitingTimerRef.current !== null) {
+ window.clearTimeout(resourceBookExitingTimerRef.current);
+ resourceBookExitingTimerRef.current = null;
+ }
+ const remaining =
+ resourceBookTransitionControllerRef.current.motionRemainingMs();
+ if (remaining <= 0) {
+ setResourceBookExitingCards([]);
+ return;
+ }
+ setResourceBookExitingCards(removed);
+ resourceBookExitingTimerRef.current = window.setTimeout(() => {
+ resourceBookExitingTimerRef.current = null;
+ setResourceBookExitingCards([]);
+ }, remaining + 80);
+ }, [resourceBookScenePlan, resourceBookState.view, visibleResourceIds]);
+ useEffect(
+ () => () => {
+ if (resourceBookExitingTimerRef.current !== null) {
+ window.clearTimeout(resourceBookExitingTimerRef.current);
+ resourceBookExitingTimerRef.current = null;
+ }
+ },
+ [],
+ );
+
useLayoutEffect(() => {
const manager = resourceBookManagerRef.current;
if (resourceBookState.phase === 'idle') return;
@@ -2454,62 +2522,21 @@ export default function ProjectDevelopmentView({
token: resourceBookState.token,
});
},
- // 进入时重叠在总览摞上,沿用来源顺序;返回时重叠在摞上,沿用目标顺序。
- resourceBookState.phase === 'returning-main' ? 'target' : 'source',
);
}, [resourceBookState.token, resourceBookState.phase, mode]);
- const motionLayoutRef = useRef({
- token: resourceBookState.token,
- viewport: activeResourceCanvasViewport,
- overview: resourceBookOverviewRects,
- positions: resourcePositionById,
- sizes: resourceCardSizeByResourceId,
- resources: canvasResources,
- sortMode,
- });
+ // 每次布局提交后同步一次:布局签名变化(含预览图尺寸晚到)从"此刻像素"重基。
useLayoutEffect(() => {
- const current = {
- token: resourceBookState.token,
- viewport: activeResourceCanvasViewport,
- overview: resourceBookOverviewRects,
- positions: resourcePositionById,
- sizes: resourceCardSizeByResourceId,
- resources: canvasResources,
- sortMode,
- };
- const previous = motionLayoutRef.current;
- motionLayoutRef.current = current;
- if (
- previous.token === current.token &&
- (previous.viewport !== current.viewport ||
- previous.overview !== current.overview ||
- previous.positions !== current.positions ||
- previous.sizes !== current.sizes ||
- previous.resources !== current.resources ||
- previous.sortMode !== current.sortMode)
- ) {
- // 动画期间布局(卡片尺寸/位置)会随预览图尺寸到位而更新。此时直接 settle
- // 会让真实场景瞬间接管,看起来就是结束瞬间跳一下;改为只更新运行中动画的
- // 终点,不重建叠加层、不打断飞行。
- const manager = resourceBookManagerRef.current;
- const controller = resourceBookTransitionControllerRef.current;
- if (resourceBookState.phase !== 'idle' && manager) {
- controller.retarget(manager);
- } else {
- controller.settle();
- }
- }
- }, [
- resourceBookState.token,
- resourceBookState.phase,
- activeResourceCanvasViewport,
- resourceBookOverviewRects,
- resourcePositionById,
- resourceCardSizeByResourceId,
- canvasResources,
- sortMode,
- ]);
+ const manager = resourceBookManagerRef.current;
+ if (!manager || mode !== 'resources') return;
+ // 主画布首帧还在测量时不建动画,避免卡片从临时位置飞向真实位置。
+ if (resourceBookState.view === 'main' && !resourceBookOverviewReady) return;
+ resourceBookTransitionControllerRef.current.sync(
+ manager,
+ resourceBookMotionSignatures,
+ resourceBookFrozenMotionKeys,
+ );
+ });
const openResourceBookChild = useCallback(
(category: ResourceCategory) => {
@@ -5142,7 +5169,8 @@ export default function ProjectDevelopmentView({
data-resource-book-transition={resourceBookState.phase}
onPointerDownCapture={(event) => {
if (
- event.currentTarget.hasAttribute('data-book-motion') &&
+ event.currentTarget.dataset.resourceBookTransition !==
+ 'idle' &&
(event.target as Element).closest('.game-resource-card')
) {
event.preventDefault();
@@ -5152,18 +5180,11 @@ export default function ProjectDevelopmentView({
}}
>
-