修复资源画本动画期间布局变化导致的跳变
上一版在布局变化时重启整段动画,导致此前修好的遮挡顺序与加载态全部复现,已回滚该做法 控制器新增 retarget:临时解除场景隐藏后重新测量真实卡片,只把运行中动画的结束帧改成映射到新几何;克隆几何、动画进度与叠加层都不重建 布局变化 effect 在动画期间调用 retarget,空闲时才 settle 补充 resourceBookController 用例:重定向不新建动画,结束帧映射到新目标
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -156,13 +156,18 @@ function clonePresentation(
|
||||
return clone;
|
||||
}
|
||||
|
||||
function capture(root: HTMLElement): Map<string, Snapshot> {
|
||||
function capture(
|
||||
root: HTMLElement,
|
||||
options: { ignoreOverlay?: boolean; ignoreVisibility?: boolean } = {},
|
||||
): Map<string, Snapshot> {
|
||||
const result = new Map<string, Snapshot>();
|
||||
const bounds = root.getBoundingClientRect();
|
||||
const layer = root.querySelector<HTMLElement>(
|
||||
'.game-resource-book-transition-layer',
|
||||
);
|
||||
const moving = layer?.querySelectorAll<HTMLElement>('[data-motion-snapshot]');
|
||||
const moving = options.ignoreOverlay
|
||||
? null
|
||||
: layer?.querySelectorAll<HTMLElement>('[data-motion-snapshot]');
|
||||
const elements = moving?.length
|
||||
? Array.from(moving)
|
||||
: Array.from(
|
||||
@@ -199,7 +204,7 @@ function capture(root: HTMLElement): Map<string, Snapshot> {
|
||||
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<string, Snapshot>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function fixture() {
|
||||
finish(): void;
|
||||
reject(): void;
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
setKeyframes: ReturnType<typeof vi.fn>;
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user