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 acaf0134b..370ecd372 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 @@ -2585,24 +2585,13 @@ export default function ProjectDevelopmentView({ previous.resources !== current.resources || previous.sortMode !== current.sortMode) ) { - // 卡片尺寸要等预览图尺寸到位才算准,通常正好落在动画末尾;直接 settle - // 会让真实场景瞬间接管,看起来就是结束瞬间跳一下。改为从当前叠加层几何 - // 重定向,动画平滑续到新的目标位置。 + // 动画期间布局(卡片尺寸/位置)会随预览图尺寸到位而更新。此时直接 settle + // 会让真实场景瞬间接管,看起来就是结束瞬间跳一下;改为只更新运行中动画的 + // 终点,不重建叠加层、不打断飞行。 const manager = resourceBookManagerRef.current; const controller = resourceBookTransitionControllerRef.current; if (resourceBookState.phase !== 'idle' && manager) { - const retargetToken = controller.begin(manager); - controller.play( - manager, - retargetToken, - () => { - dispatchResourceBook({ - type: 'finish-transition', - token: resourceBookState.token, - }); - }, - resourceBookState.phase === 'returning-main' ? 'target' : 'source', - ); + controller.retarget(manager); } else { controller.settle(); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts index 9af169220..177695627 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts @@ -156,13 +156,18 @@ function clonePresentation( return clone; } -function capture(root: HTMLElement): Map { +function capture( + root: HTMLElement, + options: { ignoreOverlay?: boolean; ignoreVisibility?: boolean } = {}, +): Map { const result = new Map(); const bounds = root.getBoundingClientRect(); const layer = root.querySelector( '.game-resource-book-transition-layer', ); - const moving = layer?.querySelectorAll('[data-motion-snapshot]'); + const moving = options.ignoreOverlay + ? null + : layer?.querySelectorAll('[data-motion-snapshot]'); const elements = moving?.length ? Array.from(moving) : Array.from( @@ -199,7 +204,7 @@ function capture(root: HTMLElement): Map { if (style.visibility === 'hidden' || style.display === 'none') visible = false; } - if (!visible || opacity <= 0) continue; + if (!options.ignoreVisibility && (!visible || opacity <= 0)) continue; const key = element.dataset.motionSnapshot ?? (element.dataset.resourceCardId @@ -230,6 +235,10 @@ export function createResourceBookTransitionController() { let token = 0; let source = new Map(); let animations: Animation[] = []; + let motion = new Map< + string, + { animation: Animation; rect: Snapshot['rect']; start: Keyframe } + >(); let layer: HTMLElement | null = null; let root: HTMLElement | null = null; let completion: (() => void) | null = null; @@ -239,6 +248,7 @@ export function createResourceBookTransitionController() { const clear = () => { animations.forEach((animation) => animation.cancel()); animations = []; + motion = new Map(); layer?.replaceChildren(); root?.removeAttribute('data-book-motion'); observer?.disconnect(); @@ -337,15 +347,16 @@ export function createResourceBookTransitionController() { }); wrapper.append(item.content); layer.append(wrapper); + const startFrame: Keyframe = { + transform: resourceBookFlipTransform( + from?.rect ?? item.rect, + item.rect, + ), + opacity: from?.opacity ?? 0, + }; const animation = wrapper.animate( [ - { - transform: resourceBookFlipTransform( - from?.rect ?? item.rect, - item.rect, - ), - opacity: from?.opacity ?? 0, - }, + startFrame, { transform: 'translate(0px, 0px) scale(1, 1)', opacity: to?.opacity ?? 0, @@ -358,6 +369,11 @@ export function createResourceBookTransitionController() { }, ); animations.push(animation); + motion.set(item.key, { + animation, + rect: item.rect, + start: startFrame, + }); // Attach rejection handling immediately in case a later item fails // before the aggregate completion handler has been installed. void animation.finished.catch(() => undefined); @@ -387,6 +403,34 @@ export function createResourceBookTransitionController() { observer.observe(manager); } }, + /** + * 动画期间布局(卡片尺寸/位置)变化时不要重启动画,只更新运行中动画的终点: + * 临时解除场景隐藏后重新测量真实卡片,再把每条动画的结束帧改成映射到新目标。 + * 克隆自身的几何保持不变,因此不需要重建叠加层,也不会打断正在进行的飞行。 + */ + retarget(manager: HTMLElement) { + if (motion.size === 0) return; + const hadMotion = manager.hasAttribute('data-book-motion'); + manager.removeAttribute('data-book-motion'); + const next = capture(manager, { + ignoreOverlay: true, + ignoreVisibility: true, + }); + if (hadMotion) manager.setAttribute('data-book-motion', 'running'); + if (next.size === 0) return; + for (const [key, entry] of motion) { + const target = next.get(key); + if (!target) continue; + const effect = entry.animation.effect as KeyframeEffect | null; + effect?.setKeyframes([ + entry.start, + { + transform: resourceBookFlipTransform(target.rect, entry.rect), + opacity: target.opacity, + }, + ]); + } + }, dispose: invalidate, }; } diff --git a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts index 933787c9f..7eb65798c 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts @@ -35,6 +35,7 @@ function fixture() { finish(): void; reject(): void; cancel: ReturnType; + setKeyframes: ReturnType; frames: Keyframe[]; }[] = []; vi.spyOn(Element.prototype, 'animate').mockImplementation((frames) => { @@ -45,8 +46,15 @@ function fixture() { reject = () => fail(new Error('canceled')); }); const cancel = vi.fn(reject); - flights.push({ finish, reject, cancel, frames: frames as Keyframe[] }); - return { finished, cancel } as unknown as Animation; + const setKeyframes = vi.fn(); + flights.push({ + finish, + reject, + cancel, + setKeyframes, + frames: frames as Keyframe[], + }); + return { finished, cancel, effect: { setKeyframes } } as unknown as Animation; }); return { root, card, measure, flights }; } @@ -290,6 +298,29 @@ describe('resource book FLIP controller', () => { controller.dispose(); }); + it('retargets a running animation to new geometry without restarting it', () => { + const { root, measure, flights } = setup(); + const controller = createResourceBookTransitionController(); + const token = controller.begin(root); + measure.mockReturnValue(rect(150, 100, 200, 160)); + controller.play(root, token, () => undefined); + expect(flights).toHaveLength(1); + + measure.mockReturnValue(rect(300, 200, 100, 80)); + controller.retarget(root); + + expect(flights).toHaveLength(1); + expect(flights[0]?.setKeyframes).toHaveBeenCalledTimes(1); + const frames = flights[0]?.setKeyframes.mock.calls[0]?.[0] as Keyframe[]; + expect(frames[1]?.transform).toBe( + resourceBookFlipTransform( + rect(300, 200, 100, 80), + rect(150, 100, 200, 160), + ), + ); + controller.dispose(); + }); + it.each(['reduced', 'zero', 'unsupported'])( 'settles without a timer for %s motion', (reason) => {