/** * 资源画本转场:对真实元素做 FLIP,不再克隆快照。 * * 总览与子画布之间,同一张卡一直是同一个 DOM 节点(同一个 React key、同一个父节点, * 只是布局函数从 resourceBookOverviewCardLayout 换成 resourceBookChildCardLayout)。 * 因此转场只需要:状态提交前记录 First 矩形,提交后测 Last 矩形,给同一个节点加反向 * transform 再过渡回 identity。动画结束没有"克隆 → 真实节点"的交接,也就没有交接瞬间 * 的跳变。 * * 预览图尺寸是异步到达的,卡片尺寸/位置会在动画之后(甚至动画结束之后)再变一次。 * 所有布局变化都走同一台机器:sync() 比较每个节点的布局签名,变化时从"当前视觉矩形" * 重新加反向 transform(可中断重基),所以二次变化是动画而不是瞬移。 */ export const RESOURCE_BOOK_MOTION_DURATION = 420; // 重基时给剩余时长设下限,避免 0ms 跳变。 export const RESOURCE_BOOK_MOTION_MIN_DURATION = 80; const EASING = 'cubic-bezier(0.2, 0.78, 0.2, 1)'; const RECT_EPSILON = 0.5; const SCALE_EPSILON = 0.001; const NONE_TRANSFORM = 'none'; const CARD_HOST_SELECTOR = '.game-resource-book-scene-card'; const CARD_BOX_SELECTOR = '.game-resource-card'; const TITLE_HOST_SELECTOR = '.game-resource-book-scene-titlebar'; const WORLD_SELECTOR = '.game-resource-book-scene-world'; export type ResourceBookMotionRect = { left: number; top: number; width: number; height: number; }; export type ResourceBookMotionOrigin = { left: number; top: number; }; export type ResourceBookMotionSignatures = ReadonlyMap; type MotionSnapshotEntry = { rect: ResourceBookMotionRect; opacity: number; }; type MotionNode = { key: string; host: HTMLElement; box: HTMLElement; }; type MotionRecord = { key: string; host: HTMLElement; box: HTMLElement; signature: string; targetOpacity: number; cleanRect: ResourceBookMotionRect | null; animation: Animation | null; epoch: number; finishAt: number; }; type FlipValues = { translateX: number; translateY: number; scaleX: number; scaleY: number; }; const EMPTY_SIGNATURES: ResourceBookMotionSignatures = new Map(); function formatNumber(value: number) { const rounded = Math.round(value * 1000) / 1000; return Object.is(rounded, -0) ? 0 : rounded; } export function resourceBookMotionRect(rect: { left: number; top: number; width: number; height: number; }): ResourceBookMotionRect { return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, }; } export function resourceBookValidMotionRect( rect: ResourceBookMotionRect | null | undefined, ): rect is ResourceBookMotionRect { return Boolean( rect && Number.isFinite(rect.left) && Number.isFinite(rect.top) && Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0, ); } /** * FLIP 反向 transform。transform-origin 固定 0 0,缩放围绕宿主节点自身的原点进行, * 所以位置需要按原点偏移换算;translate 的数值单位是宿主节点所在坐标系(world 局部 * 空间),屏幕位移要先除以 world 的缩放。 */ export function resourceBookFlipValues( first: ResourceBookMotionRect, last: ResourceBookMotionRect, origin: ResourceBookMotionOrigin = { left: last.left, top: last.top }, worldScale = 1, ): FlipValues { const scaleX = first.width / last.width; const scaleY = first.height / last.height; const scale = worldScale > 0 ? worldScale : 1; return { translateX: (first.left - origin.left - scaleX * (last.left - origin.left)) / scale, translateY: (first.top - origin.top - scaleY * (last.top - origin.top)) / scale, scaleX, scaleY, }; } export function resourceBookFlipTransform( first: ResourceBookMotionRect, last: ResourceBookMotionRect, origin: ResourceBookMotionOrigin = { left: last.left, top: last.top }, worldScale = 1, ): string { const values = resourceBookFlipValues(first, last, origin, worldScale); return `translate(${formatNumber(values.translateX)}px, ${formatNumber( values.translateY, )}px) scale(${formatNumber(values.scaleX)}, ${formatNumber(values.scaleY)})`; } function resourceBookFlipIsIdentity(values: FlipValues) { return ( Math.abs(values.translateX) < RECT_EPSILON && Math.abs(values.translateY) < RECT_EPSILON && Math.abs(values.scaleX - 1) < SCALE_EPSILON && Math.abs(values.scaleY - 1) < SCALE_EPSILON ); } function readRect(element: Element): ResourceBookMotionRect { return resourceBookMotionRect(element.getBoundingClientRect()); } function readTargetOpacity(host: HTMLElement) { const raw = host.style.opacity; if (!raw) return 1; const value = Number.parseFloat(raw); return Number.isFinite(value) ? value : 1; } function readVisualOpacity(host: HTMLElement, fallback: number) { const value = Number.parseFloat(getComputedStyle(host).opacity); return Number.isFinite(value) ? value : fallback; } /** * 宿主节点自身原点的屏幕位置。卡片包装节点是 0x0,原点就是它的矩形左上角;标题栏是 * 有尺寸的节点,激活时还带一层抵消 world 的内联 transform,需要临时摘掉再量。 */ function readOrigin(host: HTMLElement): ResourceBookMotionOrigin { const base = host.style.transform; if (!base) { const rect = host.getBoundingClientRect(); return { left: rect.left, top: rect.top }; } host.style.transform = ''; const rect = host.getBoundingClientRect(); host.style.transform = base; return { left: rect.left, top: rect.top }; } type WorldTransform = { scale: number; origin: ResourceBookMotionOrigin; }; /** * world 变换(平移 + 缩放,transform-origin 0 0)。宿主节点的几何基准统一存成 * world 局部坐标,平移和缩放就不会把"上一次的干净位置"变成过期的屏幕坐标。 */ function readWorld(root: HTMLElement): WorldTransform { const world = root.querySelector(WORLD_SELECTOR); if (!world) return { scale: 1, origin: { left: 0, top: 0 } }; const rect = world.getBoundingClientRect(); const transform = getComputedStyle(world).transform; const match = /^matrix(?:3d)?\(([^)]+)\)$/.exec(transform); const parsed = match ? Number.parseFloat(match[1]!.split(',')[0] ?? '') : Number.NaN; const scale = Number.isFinite(parsed) && parsed > 0 ? parsed : 1; return { scale, origin: { left: rect.left, top: rect.top } }; } function toWorldRect( rect: ResourceBookMotionRect, world: WorldTransform, ): ResourceBookMotionRect { return { left: (rect.left - world.origin.left) / world.scale, top: (rect.top - world.origin.top) / world.scale, width: rect.width / world.scale, height: rect.height / world.scale, }; } function fromWorldRect( rect: ResourceBookMotionRect, world: WorldTransform, ): ResourceBookMotionRect { return { left: world.origin.left + rect.left * world.scale, top: world.origin.top + rect.top * world.scale, width: rect.width * world.scale, height: rect.height * world.scale, }; } function collectNodes(root: HTMLElement): MotionNode[] { const nodes: MotionNode[] = []; for (const host of root.querySelectorAll(CARD_HOST_SELECTOR)) { const box = host.querySelector(CARD_BOX_SELECTOR); const resourceId = box?.dataset.resourceCardId; if (box && resourceId) { nodes.push({ key: `card:${resourceId}`, host, box }); } } for (const host of root.querySelectorAll(TITLE_HOST_SELECTOR)) { const category = host.dataset.resourceBookCategory; if (category) { nodes.push({ key: `title:${category}`, host, box: host }); } } return nodes; } function supportsMotion(host: Element) { return typeof (host as HTMLElement).animate === 'function'; } function prefersReducedMotion() { return ( window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true ); } export type ResourceBookTransitionController = ReturnType< typeof createResourceBookTransitionController >; export function createResourceBookTransitionController() { let token = 0; const records = new Map(); let snapshot: Map | null = null; let completion: (() => void) | null = null; let phasePending = false; let phaseStartedAt = 0; let lastManagerWidth: number | null = null; let lastManagerHeight: number | null = null; const cancelRecord = (record: MotionRecord) => { const animation = record.animation; record.animation = null; record.finishAt = 0; if (animation) { // 取消会让 finished reject;完成判定只认当前 epoch 的动画。 record.epoch += 1; animation.cancel(); } }; const maybeFinish = () => { if (!phasePending) return; for (const record of records.values()) { if (record.animation) return; } const done = completion; completion = null; phasePending = false; phaseStartedAt = 0; done?.(); }; const settle = () => { records.forEach(cancelRecord); const done = completion; completion = null; phasePending = false; phaseStartedAt = 0; done?.(); }; const invalidate = () => { token += 1; records.forEach(cancelRecord); snapshot = null; completion = null; phasePending = false; phaseStartedAt = 0; lastManagerWidth = null; lastManagerHeight = null; return token; }; const startMotion = ( record: MotionRecord, spec: { first: ResourceBookMotionRect | null; hold: boolean; startOpacity: number; endOpacity: number; duration: number; worldScale: number; }, ) => { if (!supportsMotion(record.host)) return false; const origin = readOrigin(record.host); const last = readRect(record.box); const base = record.host.style.transform; const baseTransform = base === '' ? NONE_TRANSFORM : base; const first = resourceBookValidMotionRect(spec.first) ? spec.first : null; const values = first && resourceBookValidMotionRect(last) ? resourceBookFlipValues(first, last, origin, spec.worldScale) : null; const flip = values && !resourceBookFlipIsIdentity(values) ? resourceBookFlipTransform(first!, last, origin, spec.worldScale) : null; if (!flip && Math.abs(spec.startOpacity - spec.endOpacity) < 0.001) { return false; } const startTransform = flip ? base === '' ? flip : `${flip} ${base}` : baseTransform; // 淡出把节点钉在 First 位置,不要一边淡出一边飞到新位置。 const endTransform = spec.hold || !flip ? startTransform : baseTransform; const frames: Keyframe[] = [ { transform: startTransform, opacity: spec.startOpacity }, { transform: endTransform, opacity: spec.endOpacity }, ]; let animation: Animation; try { animation = record.host.animate(frames, { duration: spec.duration, easing: EASING, fill: 'both', }); } catch { return false; } record.epoch += 1; const epoch = record.epoch; record.animation = animation; record.finishAt = performance.now() + spec.duration; const finish = () => { if (record.epoch !== epoch) return; const current = record.animation; record.animation = null; record.finishAt = 0; current?.cancel(); maybeFinish(); }; // 立即挂上拒绝处理,避免后续节点创建失败时留下未处理的 Promise。 void animation.finished.then(finish, () => undefined); return true; }; const syncNodes = ( manager: HTMLElement, signatures: ResourceBookMotionSignatures, options: { snapshot?: Map | null; frozenKeys?: ReadonlySet; forceMountFadeIn?: boolean; duration?: number; } = {}, ) => { const world = readWorld(manager); const managerRect = manager.getBoundingClientRect(); // 容器尺寸变化(窗口 resize、面板折叠)不做动画,只刷新记录。 const sizeChanged = lastManagerWidth !== null && lastManagerHeight !== null && (managerRect.width !== lastManagerWidth || managerRect.height !== lastManagerHeight); lastManagerWidth = managerRect.width; lastManagerHeight = managerRect.height; const suppressed = sizeChanged || prefersReducedMotion(); const duration = options.duration ?? (phasePending ? Math.max( RESOURCE_BOOK_MOTION_MIN_DURATION, phaseStartedAt + RESOURCE_BOOK_MOTION_DURATION - performance.now(), ) : RESOURCE_BOOK_MOTION_DURATION); const liveKeys = new Set(); for (const node of collectNodes(manager)) { liveKeys.add(node.key); const signature = signatures.get(node.key) ?? ''; const targetOpacity = readTargetOpacity(node.host); const existing = records.get(node.key) ?? null; const remounted = existing !== null && existing.host !== node.host; const record = existing ?? ({ key: node.key, host: node.host, box: node.box, signature: '', targetOpacity: 1, cleanRect: null, animation: null, epoch: 0, finishAt: 0, } satisfies MotionRecord); if (!existing) records.set(node.key, record); record.host = node.host; record.box = node.box; if (options.frozenKeys?.has(node.key)) { // 拖拽中的卡片只跟指针走:不参与布局 FLIP,但保持基准几何最新, // 落位提交后不会把卡片拉回拖拽前的位置。 cancelRecord(record); record.signature = signature; record.targetOpacity = targetOpacity; record.cleanRect = toWorldRect(readRect(node.box), world); continue; } const snapshotEntry = options.snapshot?.get(node.key) ?? null; const previousSignature = record.signature; const previousTargetOpacity = record.targetOpacity; const hadAnimation = record.animation !== null; const signatureChanged = previousSignature !== '' && previousSignature !== signature; const opacityChanged = previousTargetOpacity !== targetOpacity; // 已经渲染过、但节点被重新挂载:补一次淡入,避免"凭空出现"。 const mountFade = (options.forceMountFadeIn === true && snapshotEntry === null) || (remounted && record.cleanRect !== null); const wantMotion = Boolean(snapshotEntry) || signatureChanged || opacityChanged || (mountFade && targetOpacity > 0 && !hadAnimation); if (!wantMotion) { record.signature = signature; record.targetOpacity = targetOpacity; if (!hadAnimation) { record.cleanRect = toWorldRect(readRect(node.box), world); } continue; } const visual = readRect(node.box); const previousCleanRect = record.cleanRect; const startOpacity = hadAnimation ? readVisualOpacity(node.host, previousTargetOpacity) : mountFade ? 0 : previousTargetOpacity; cancelRecord(record); record.signature = signature; record.targetOpacity = targetOpacity; record.cleanRect = toWorldRect(readRect(node.box), world); if (suppressed) continue; // 转场用 begin 记录的 First;动画中途重基用"此刻像素";空闲布局变化用上一次的干净矩形。 const first = snapshotEntry?.rect ?? (hadAnimation ? visual : previousCleanRect ? fromWorldRect(previousCleanRect, world) : visual); startMotion(record, { first, hold: targetOpacity < 1, startOpacity: snapshotEntry && !hadAnimation ? snapshotEntry.opacity : startOpacity, endOpacity: targetOpacity, duration, worldScale: world.scale, }); } for (const record of records.values()) { if (!liveKeys.has(record.key) && !record.host.isConnected) { cancelRecord(record); } } // 保留已卸载节点的记录用于重新挂载时的淡入;数量过多时回收。 if (records.size > 512) { for (const [key, record] of records) { if (!record.host.isConnected) records.delete(key); } } }; return { /** * 状态提交前记录 First:真实元素的屏幕矩形和当前不透明度。 * * 只记录当前可见的节点。不可见节点(内联 opacity 0)的屏幕矩形是"看不见的位置": * 非活动栏目的标题栏在子画布态就属于这种情况——它的布局坐标还是总览矩形,但被 * 子画布 world 变换推到了屏幕外。若把它当 First,返回总览时标题栏会从屏幕外飞 * 进来。没有 First 的节点只走淡入,位置保持不动。 */ begin(manager: HTMLElement | null) { const captured = new Map(); if (manager) { for (const node of collectNodes(manager)) { const opacity = readVisualOpacity( node.host, readTargetOpacity(node.host), ); if (opacity <= 0.001) continue; const rect = readRect(node.box); if (!resourceBookValidMotionRect(rect)) continue; captured.set(node.key, { rect, opacity }); } } invalidate(); snapshot = captured; return token; }, invalidate, settle, isCurrent: (expected: number) => token === expected, /** 目标 DOM 提交后播放转场:用 begin 记录的 First 反算反向 transform。 */ play( manager: HTMLElement | null, expected: number, done: () => void, signatures: ResourceBookMotionSignatures = EMPTY_SIGNATURES, frozenKeys?: ReadonlySet, ) { if (token !== expected) return; const first = snapshot; snapshot = null; completion = done; phasePending = true; phaseStartedAt = performance.now(); if ( !manager || !first?.size || prefersReducedMotion() || !supportsMotion(manager) ) { settle(); return; } syncNodes(manager, signatures, { snapshot: first, frozenKeys, forceMountFadeIn: true, duration: RESOURCE_BOOK_MOTION_DURATION, }); maybeFinish(); }, /** * 每次布局提交后调用。比较布局签名,变化时从当前视觉矩形重基: * 取消旧动画 → 重新测量 → 按"此刻像素 → 新 Last"加反向 transform 再过渡回 identity。 */ sync( manager: HTMLElement | null, signatures: ResourceBookMotionSignatures = EMPTY_SIGNATURES, frozenKeys?: ReadonlySet, ) { if (!manager) return; syncNodes(manager, signatures, { frozenKeys }); maybeFinish(); }, isAnimating() { for (const record of records.values()) { if (record.animation) return true; } return false; }, /** 运行中动画的最长剩余时长;用于决定淡出节点何时从 DOM 撤下。 */ motionRemainingMs() { const now = performance.now(); let remaining = 0; for (const record of records.values()) { if (!record.animation) continue; remaining = Math.max(remaining, Math.max(0, record.finishAt - now)); } return remaining; }, dispose() { return invalidate(); }, }; }