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 cd9e26062..c95c5342d 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 @@ -107,6 +107,7 @@ import { import { ResourceBookTransitionLayer } from './ResourceBookTransitionLayer'; import { DEFAULT_RESOURCE_BOOK_VIEWPORT, + ensureResourceBookContentVisible, keepResourceBookViewportAtReadableScale, normalizeResourceBookViewport, panResourceBookViewport, @@ -118,6 +119,8 @@ import { createResourceCanvasCardSizeByResourceId, fitResourceCanvasViewportToContent, normalizeInfiniteResourceCanvasViewport, + RESOURCE_CANVAS_CARD_HEIGHT, + RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_DRAG_THRESHOLD, RESOURCE_CANVAS_FIT_PADDING, RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, @@ -2549,7 +2552,7 @@ export default function ProjectDevelopmentView({ resourcePositionsByCategory.get(category) ?? [], resourceCardSizeByResourceId, ); - const categoryViewport = resourceCanvasFitKeysRef.current.has( + const fittedCategoryViewport = resourceCanvasFitKeysRef.current.has( categoryFitKey, ) ? resourceCanvasViewportTargetsRef.current[sortMode][category] @@ -2564,6 +2567,24 @@ export default function ProjectDevelopmentView({ canvasSize: fitCanvasSize, padding: RESOURCE_CANVAS_FIT_PADDING, }); + // 入场动画要落在真实卡片上;视口停在空白处时,先把最近的卡片带进画面。 + const categoryViewport = ensureResourceBookContentVisible({ + viewport: fittedCategoryViewport, + cards: (resourcePositionsByCategory.get(category) ?? []).map( + (position) => ({ + x: position.x, + y: position.y, + width: + resourceCardSizeByResourceId.get(position.resourceId)?.width ?? + RESOURCE_CANVAS_CARD_WIDTH, + height: + resourceCardSizeByResourceId.get(position.resourceId)?.height ?? + RESOURCE_CANVAS_CARD_HEIGHT, + }), + ), + canvasSize: fitCanvasSize, + padding: RESOURCE_CANVAS_FIT_PADDING, + }); // Opening a child explicitly establishes the initial viewport for this // sort/category pair. Mark that fit as settled so the paged-canvas diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookViewport.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookViewport.ts index 2e9125196..e09d96910 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookViewport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookViewport.ts @@ -57,6 +57,77 @@ export function normalizeResourceBookViewport( }; } +/** + * 入场/返回动画要求目标卡片落在可视区域内,否则 FLIP 找不到终点,整段动画 + * 会退化成淡入淡出。视口若停在空白处,就把离画面中心最近的卡片平移到中心, + * 保持当前缩放不变,避免顺手改变用户的缩放预期。 + */ +export function ensureResourceBookContentVisible({ + viewport, + cards, + canvasSize, + padding = 16, +}: { + viewport: CanvasViewport; + cards: readonly { + x: number; + y: number; + width: number; + height: number; + }[]; + canvasSize: { width: number; height: number }; + padding?: number; +}): CanvasViewport { + const current = normalizeResourceBookViewport(viewport); + if ( + cards.length === 0 || + !Number.isFinite(canvasSize.width) || + !Number.isFinite(canvasSize.height) || + canvasSize.width <= 0 || + canvasSize.height <= 0 + ) { + return current; + } + const inset = Math.max(0, padding); + const viewportRect = { + left: inset, + top: inset, + right: Math.max(inset + 1, canvasSize.width - inset), + bottom: Math.max(inset + 1, canvasSize.height - inset), + }; + const screenRects = cards.map((card) => ({ + card, + left: current.x + card.x * current.scale, + top: current.y + card.y * current.scale, + width: Math.max(1, card.width * current.scale), + height: Math.max(1, card.height * current.scale), + })); + const intersectsViewport = (rect: (typeof screenRects)[number]) => + rect.left < viewportRect.right && + rect.left + rect.width > viewportRect.left && + rect.top < viewportRect.bottom && + rect.top + rect.height > viewportRect.top; + if (screenRects.some(intersectsViewport)) { + return current; + } + const centerX = canvasSize.width / 2; + const centerY = canvasSize.height / 2; + const nearest = screenRects.reduce((closest, candidate) => { + const closestDistance = + Math.abs(closest.left + closest.width / 2 - centerX) + + Math.abs(closest.top + closest.height / 2 - centerY); + const candidateDistance = + Math.abs(candidate.left + candidate.width / 2 - centerX) + + Math.abs(candidate.top + candidate.height / 2 - centerY); + return candidateDistance < closestDistance ? candidate : closest; + }); + return { + scale: current.scale, + x: centerX - (nearest.card.x + nearest.card.width / 2) * current.scale, + y: centerY - (nearest.card.y + nearest.card.height / 2) * current.scale, + }; +} + export function zoomResourceBookViewport( viewport: CanvasViewport, factor: number, diff --git a/apps/ai-game-creator-shell/tests/resourceBookViewport.test.ts b/apps/ai-game-creator-shell/tests/resourceBookViewport.test.ts index 591b30479..0a2710e23 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookViewport.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookViewport.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + ensureResourceBookContentVisible, keepResourceBookViewportAtReadableScale, panResourceBookViewport, } from '../src/view/project-development/resourceBookViewport'; @@ -29,4 +30,41 @@ describe('resource book viewport', () => { ), ).toEqual({ x: -4_000, y: 3_000, scale: 1 }); }); + + it('keeps a viewport that already shows a card', () => { + const viewport = { x: -40, y: -20, scale: 1 }; + expect( + ensureResourceBookContentVisible({ + viewport, + cards: [{ x: 100, y: 100, width: 180, height: 128 }], + canvasSize: { width: 900, height: 640 }, + padding: 16, + }), + ).toEqual(viewport); + }); + + it('centers the nearest card when the viewport would show nothing', () => { + expect( + ensureResourceBookContentVisible({ + viewport: { x: 0, y: 0, scale: 1 }, + cards: [ + { x: 4_000, y: 4_000, width: 180, height: 128 }, + { x: 2_000, y: 2_000, width: 180, height: 128 }, + ], + canvasSize: { width: 900, height: 640 }, + padding: 16, + }), + ).toEqual({ x: -1_640, y: -1_744, scale: 1 }); + }); + + it('leaves an empty category viewport untouched', () => { + const viewport = { x: 12, y: 34, scale: 1.5 }; + expect( + ensureResourceBookContentVisible({ + viewport, + cards: [], + canvasSize: { width: 900, height: 640 }, + }), + ).toEqual(viewport); + }); });