interface LoadingNodeState { x: number; y: number; scaleX: number; scaleY: number; opacity: number; } interface LoadingAnimationState { runId: number; cat: LoadingNodeState; blocks: LoadingNodeState[]; } const STATE_KEY = "__loadingCatAnimationState"; /** 统一管理各界面的猫跳 Loading,避免每个业务脚本复制一套动画。 */ export default class LoadingCatAnimation { static play(loading: cc.Node): boolean { if (!loading || !cc.isValid(loading)) { return false; } const cat = loading.getChildByName("cat"); const blocks = [1, 2, 3, 4].map(index => loading.getChildByName(`block${index}`)); if (!cat || blocks.some(block => !block)) { cc.warn("Loading 缺少 cat 或 block1~4,无法播放猫跳动画:", loading.name); loading.active = true; return false; } let state = this.getState(loading); if (!state) { state = { runId: 0, cat: this.capture(cat), blocks: blocks.map(block => this.capture(block)), }; (loading as any)[STATE_KEY] = state; } this.stopTargets(loading, cat, blocks); this.reset(cat, blocks, state); const labelNode = loading.getChildByName("New Label"); const label = labelNode && labelNode.getComponent(cc.Label); if (label) { label.string = "加载中,请稍等喵~"; } loading.active = true; const runId = ++state.runId; this.playCycle(loading, cat, blocks, state, runId); return true; } static stop(loading: cc.Node): void { if (!loading || !cc.isValid(loading)) { return; } const cat = loading.getChildByName("cat"); const blocks = [1, 2, 3, 4] .map(index => loading.getChildByName(`block${index}`)) .filter(block => !!block); const state = this.getState(loading); if (state) { state.runId++; } this.stopTargets(loading, cat, blocks); if (cat && blocks.length === 4 && state) { this.reset(cat, blocks, state); } loading.active = false; } private static playCycle( loading: cc.Node, cat: cc.Node, orderedBlocks: cc.Node[], state: LoadingAnimationState, runId: number, ): void { if (!this.isRunning(loading, state, runId)) { return; } const base = state.cat; const spacing = Math.abs(orderedBlocks[1].x - orderedBlocks[0].x) || 150; const landingBlock = orderedBlocks[2]; const footOffsetY = cat.height * cat.anchorY; const squashScaleY = base.scaleY * 0.86; const stretchScaleY = base.scaleY * 1.10; cc.tween(cat) .delay(0.10) .to(0.09, { y: base.y - 12, scaleX: base.scaleX * 1.18, scaleY: base.scaleY * 0.80, }, { easing: "quadIn" }) .call(() => this.moveBlocks(orderedBlocks, spacing)) .to(0.08, { y: base.y + 26, scaleX: base.scaleX * 0.84, scaleY: base.scaleY * 1.22, }, { easing: "quadOut" }) .to(0.19, { y: base.y + 108, scaleX: base.scaleX * 0.98, scaleY: base.scaleY * 1.03, }, { easing: "quadOut" }) .to(0.19, { y: base.y + 38, scaleX: base.scaleX * 0.94, scaleY: base.scaleY * 1.09, }, { easing: "quadIn" }) .to(0.08, { y: base.y, scaleX: base.scaleX, scaleY: base.scaleY, }, { easing: "quadIn" }) .call(() => { if (this.isRunning(loading, state, runId)) { this.flashBlock(landingBlock); } }) .delay(0.03) .to(0.10, { y: base.y + footOffsetY * (squashScaleY - base.scaleY), scaleX: base.scaleX * 1.12, scaleY: squashScaleY, }, { easing: "quadInOut" }) .to(0.11, { y: base.y + footOffsetY * (stretchScaleY - base.scaleY), scaleX: base.scaleX * 0.94, scaleY: stretchScaleY, }, { easing: "quadOut" }) .to(0.08, { y: base.y, scaleX: base.scaleX, scaleY: base.scaleY, }, { easing: "sineOut" }) .delay(0.05) .call(() => { if (!this.isRunning(loading, state, runId)) { return; } const recycledBlock = orderedBlocks.shift(); const rightmostBlock = orderedBlocks[orderedBlocks.length - 1]; const recycledState = this.getBlockState(recycledBlock, state); recycledBlock.x = rightmostBlock.x + spacing; recycledBlock.y = recycledState.y; recycledBlock.scaleX = recycledState.scaleX; recycledBlock.scaleY = recycledState.scaleY; recycledBlock.opacity = 0; const light = recycledBlock.getChildByName("light"); if (light) { light.opacity = 0; } orderedBlocks.push(recycledBlock); this.playCycle(loading, cat, orderedBlocks, state, runId); }) .start(); } private static moveBlocks(blocks: cc.Node[], spacing: number): void { const lastIndex = blocks.length - 1; blocks.forEach((block, index) => { const tween = cc.tween(block); if (index === lastIndex) { tween.delay(0.06); } tween.to(0.47, { x: block.x - spacing, opacity: index === 0 ? 0 : 255, }, { easing: "sineInOut" }).start(); }); } private static flashBlock(block: cc.Node): void { const light = block && block.getChildByName("light"); if (!light) { return; } cc.Tween.stopAllByTarget(light); light.opacity = 255; light.scaleX = 1.04; light.scaleY = 1.04; cc.tween(light) .to(0.10, { opacity: 220, scaleX: 1.12, scaleY: 1.12 }, { easing: "quadOut" }) .to(0.24, { opacity: 0, scaleX: 1, scaleY: 1 }, { easing: "sineOut" }) .start(); } private static reset(cat: cc.Node, blocks: cc.Node[], state: LoadingAnimationState): void { this.apply(cat, state.cat); blocks.forEach((block, index) => { this.apply(block, state.blocks[index]); const light = block.getChildByName("light"); if (light) { light.opacity = 0; light.scaleX = 1; light.scaleY = 1; } }); } private static stopTargets(loading: cc.Node, cat: cc.Node, blocks: cc.Node[]): void { const targets = [cat, loading.getChildByName("load")]; blocks.forEach(block => { targets.push(block); targets.push(block.getChildByName("light")); }); targets.forEach(target => { if (target) { cc.Tween.stopAllByTarget(target); target.stopAllActions(); } }); } private static capture(node: cc.Node): LoadingNodeState { return { x: node.x, y: node.y, scaleX: node.scaleX, scaleY: node.scaleY, opacity: node.opacity, }; } private static apply(node: cc.Node, state: LoadingNodeState): void { node.x = state.x; node.y = state.y; node.scaleX = state.scaleX; node.scaleY = state.scaleY; node.opacity = state.opacity; } private static getBlockState(block: cc.Node, state: LoadingAnimationState): LoadingNodeState { const index = Number(block.name.replace("block", "")) - 1; return state.blocks[index] || state.blocks[0]; } private static getState(loading: cc.Node): LoadingAnimationState { return (loading as any)[STATE_KEY] || null; } private static isRunning(loading: cc.Node, state: LoadingAnimationState, runId: number): boolean { return state.runId === runId && cc.isValid(loading) && loading.active; } }