Files
MatchMaster/assets/Script/LoadingCatAnimation.ts
T
2026-08-13 15:28:06 +08:00

255 lines
8.4 KiB
TypeScript

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;
}
}