You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
AdLike2048/assets/script/battle/view/BagSlotEntranceAnimator.ts

761 lines
14 KiB

import { Node, Tween, tween, UITransform, v3, Vec3 } from 'cc';
import { mapKeysToArray } from '../../../mx/tools/MembershipSet';
import type { TextGrid } from './TextGrid';
import { BattleAudio } from '../audio/BattleAudio';
/** 槽位间延迟(右→左依次抛物线入场) */
const SLOT_STAGGER = 0.2;
/** 抛物线飞行时长(秒) */
const FLY_DUR = 0.48;
/** 相对按钮再往上拱的像素 */
const APEX_LIFT = 300;
/** 落地弹一下 */
const LAND_SQUASH = 0.1;
const LAND_SETTLE = 0.12;
/** 单格抛物线入场可选参数 */
export interface BagSlotEntranceFlightOpts {
/** 飞行期间挂载层(如 ui_fly),保证在 btnItem 等 UI 之上 */
flightLayer?: Node | null;
/** 落点槽位;与 flightLayer 同时传入时终点为该槽世界坐标 */
homeSlot?: Node | null;
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function hideEntranceTrailSafe(grid: TextGrid): void {
if (grid?.isValid && grid.node?.isValid) grid.hideEntranceTrail();
}
/** 入场进度代理:禁止 tween(node),避免 setData/resetNodeTransform 掐掉 onComplete */
type EntranceMotion = { fly: number; land: number };
/**
* 背包刷新出场:从砚台世界坐标抛物线落入槽位(右→左)。
* 全程只 tween 代理对象;节点变换仅在 onUpdate 写入。
* 每格必达 onDone(播完 / 节点失效 / stopGrid),由 finished 计数触发总 onComplete。
*/
export class BagSlotEntranceAnimator {
private static readonly _motions = new WeakMap<TextGrid, EntranceMotion>();
private static _activeComplete: (() => void) | null = null;
private static readonly _gridDone = new WeakMap<TextGrid, () => void>();
/** 打断上一轮 play 后,旧 gridDone/settle 须失效 */
private static _playGen = 0;
/**
* 该字块是否仍在入场抛物线或落地 squash 中。
* 落地并 snap 回家后为 false,可立即拖动。
*/
static isPlaying(grid: TextGrid | null | undefined): boolean {
if (!grid) return false;
return this._motions.has(grid) || this._gridDone.has(grid);
}
/**
* 背包刷新入场:右→左依次抛物线落入槽位。
* @param onSlotDone 单格落地后回调(可立即开拖 / 刷可合成)
*/
static play(slots: Map<number, TextGrid>, _bagList?: Node | null, onComplete?: () => void, fromWorld?: Readonly<Vec3> | null, onSlotDone?: (grid: TextGrid) => void): void {
// settle=false:开场只清残留 tween,勿触发上一轮 onComplete(否则会 snap/flush 打乱本轮)
this.stopAll(slots, _bagList, false);
const indices = mapKeysToArray(slots).sort((a, b) => b - a);
if (indices.length === 0) {
onComplete?.();
return;
}
const sessionGen = this._playGen;
let finished = 0;
const total = indices.length;
let settled = false;
const settle = (): void => {
if (settled || this._playGen !== sessionGen) return;
settled = true;
if (this._activeComplete === settle) this._activeComplete = null;
onComplete?.();
};
this._activeComplete = settle;
for (let order = 0; order < indices.length; order++) {
const slotIndex = indices[order];
const grid = slots.get(slotIndex);
if (!grid?.node?.isValid) {
finished++;
if (finished >= total) settle();
continue;
}
let fired = false;
const gridDone = (): void => {
if (fired || this._playGen !== sessionGen) return;
fired = true;
this._gridDone.delete(grid);
onSlotDone?.(grid);
finished++;
if (finished >= total) settle();
};
this._gridDone.set(grid, gridDone);
if (fromWorld) {
this._playParabolaFrom(grid, fromWorld, order, gridDone);
} else {
this._playFallback(grid, slotIndex, order, total, gridDone);
}
}
}
/** 单格从世界坐标抛物线落入当前槽位(与砚台出字同轨迹) */
static playSingleFromWorld(
grid: TextGrid,
fromWorld: Readonly<Vec3>,
appearOrder: number,
onDone: () => void,
opts?: BagSlotEntranceFlightOpts,
): void {
if (!grid?.node?.isValid) {
onDone();
return;
}
let fired = false;
const gridDone = (): void => {
if (fired) return;
fired = true;
this._gridDone.delete(grid);
onDone();
};
this._gridDone.set(grid, gridDone);
this._playParabolaFrom(grid, fromWorld, appearOrder, gridDone, opts);
}
private static _playParabolaFrom(
grid: TextGrid,
fromWorld: Readonly<Vec3>,
appearOrder: number,
onDone: () => void,
opts?: BagSlotEntranceFlightOpts,
): void {
const node = grid.node;
this._stopMotion(grid);
const homeSlot = opts?.homeSlot ?? node.parent;
const flightLayer = opts?.flightLayer ?? null;
const spaceNode = flightLayer?.isValid ? flightLayer : homeSlot;
const spaceUt = spaceNode?.getComponent(UITransform);
if (!homeSlot?.isValid || !spaceNode?.isValid || !spaceUt) {
this._snapHome(grid);
onDone();
return;
}
if (flightLayer?.isValid && node.parent !== flightLayer) {
if (!flightLayer.active) {
flightLayer.active = true;
}
node.parent = flightLayer;
}
const start = spaceUt.convertToNodeSpaceAR(new Vec3(fromWorld.x, fromWorld.y, fromWorld.z));
const end = flightLayer?.isValid
? spaceUt.convertToNodeSpaceAR(homeSlot.worldPosition)
: v3(0, 0, 0);
const p0x = start.x;
const p0y = start.y;
const p1x = (start.x + end.x) * 0.5;
const p1y = start.y + APEX_LIFT;
const p2x = end.x;
const p2y = end.y;
const side = start.x >= end.x ? 1 : -1;
node.setPosition(start);
node.angle = -18 * side;
node.setScale(0.2, 0.2, 1);
hideEntranceTrailSafe(grid);
const motion: EntranceMotion = { fly: 0, land: 0 };
this._motions.set(grid, motion);
tween(motion)
.delay(appearOrder * SLOT_STAGGER)
.call(() => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
try {
grid.showEntranceTrail();
} catch (e) {
console.log('[BagSlotEntranceAnimator] showEntranceTrail failed', String(e));
}
BattleAudio.playInkstoneSpawn();
})
.to(FLY_DUR, { fly: 1 }, {
easing: 'quadIn',
onUpdate: () => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
const t = motion.fly;
const u = 1 - t;
node.setPosition(
u * u * p0x + 2 * u * t * p1x + t * t * p2x,
u * u * p0y + 2 * u * t * p1y + t * t * p2y,
0,
);
const s = lerp(0.2, 1, t);
node.setScale(s, s * lerp(1, 0.85, t * t), 1);
node.angle = lerp(-18 * side, 0, t);
},
})
.call(() => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
node.setPosition(end);
node.angle = 0;
node.setScale(1.15, 0.75, 1);
motion.land = 0;
})
.to(LAND_SQUASH, { land: 1 }, {
easing: 'quadOut',
onUpdate: () => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
const t = motion.land;
node.setScale(lerp(1.15, 0.85, t), lerp(0.75, 1.2, t), 1);
},
})
.call(() => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
motion.land = 0;
})
.to(LAND_SETTLE, { land: 1 }, {
easing: 'quadOut',
onUpdate: () => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
const t = motion.land;
node.setScale(lerp(0.85, 1, t), lerp(1.2, 1, t), 1);
},
})
.call(() => {
this._motions.delete(grid);
if (flightLayer?.isValid && homeSlot?.isValid && node.parent !== homeSlot) {
node.parent = homeSlot;
}
this._snapHome(grid);
onDone();
})
.start();
}
private static _playFallback(
grid: TextGrid,
slotIndex: number,
appearOrder: number,
total: number,
onDone: () => void,
): void {
const node = grid.node;
this._stopMotion(grid);
const home = v3(0, 0, 0);
const center = (total - 1) / 2;
const peakY = 90 + APEX_LIFT + Math.abs(slotIndex - center) * 10;
const start = v3(0, peakY, 0);
const p0x = start.x;
const p0y = start.y;
const p1x = 0;
const p1y = start.y;
const p2x = home.x;
const p2y = home.y;
node.setPosition(start);
node.angle = 0;
node.setScale(0.2, 0.2, 1);
hideEntranceTrailSafe(grid);
const motion: EntranceMotion = { fly: 0, land: 0 };
this._motions.set(grid, motion);
tween(motion)
.delay(appearOrder * SLOT_STAGGER)
.call(() => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
try {
grid.showEntranceTrail();
} catch (e) {
console.log('[BagSlotEntranceAnimator] showEntranceTrail failed', String(e));
}
BattleAudio.playInkstoneSpawn();
})
.to(FLY_DUR, { fly: 1 }, {
easing: 'quadIn',
onUpdate: () => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
const t = motion.fly;
const u = 1 - t;
node.setPosition(
u * u * p0x + 2 * u * t * p1x + t * t * p2x,
u * u * p0y + 2 * u * t * p1y + t * t * p2y,
0,
);
const s = lerp(0.2, 1, t);
node.setScale(s, s * lerp(1, 0.85, t * t), 1);
},
})
.call(() => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
node.setPosition(home);
node.setScale(1.15, 0.75, 1);
motion.land = 0;
})
.to(LAND_SQUASH, { land: 1 }, {
easing: 'quadOut',
onUpdate: () => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
const t = motion.land;
node.setScale(lerp(1.15, 0.85, t), lerp(0.75, 1.2, t), 1);
},
})
.call(() => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
motion.land = 0;
})
.to(LAND_SETTLE, { land: 1 }, {
easing: 'quadOut',
onUpdate: () => {
if (!this._assertEntranceAlive(grid, node, onDone)) return;
const t = motion.land;
node.setScale(lerp(0.85, 1, t), lerp(1.2, 1, t), 1);
},
})
.call(() => {
this._motions.delete(grid);
this._snapHome(grid);
onDone();
})
.start();
}
/**
* 入场中节点被回收/销毁:停掉剩余链并计完成,避免 finished 永远凑不齐。
* @returns 仍可继续播
*/
private static _assertEntranceAlive(grid: TextGrid, node: Node, onDone: () => void): boolean {
if (grid.isValid && node.isValid) return true;
this._stopMotion(grid);
hideEntranceTrailSafe(grid);
onDone();
return false;
}
private static _snapHome(grid: TextGrid): void {
hideEntranceTrailSafe(grid);
const node = grid.node;
if (!node?.isValid) return;
node.setPosition(0, 0, 0);
node.angle = 0;
node.setScale(1, 1, 1);
node.active = true;
}
private static _stopMotion(grid: TextGrid): void {
const motion = this._motions.get(grid);
if (motion) {
Tween.stopAllByTarget(motion);
this._motions.delete(grid);
}
}
/**
* 单格停入场。
* @param invokeDone false 时只停 tween(rebuild 换场,由 stopAll 作废 session)
*/
static stopGrid(grid: TextGrid | null | undefined, invokeDone = true): void {
if (!grid) return;
const pendingDone = this._gridDone.get(grid);
if (!invokeDone) this._gridDone.delete(grid);
this._stopMotion(grid);
if (grid.node?.isValid) {
this._snapHome(grid);
}
if (invokeDone) pendingDone?.();
else this._gridDone.delete(grid);
}
/**
* 停止全部入场。
* @param settle true 时触发 onComplete
*/
static stopAll(slots: Map<number, TextGrid>, bagList?: Node | null, settle = true): void {
this._playGen++;
for (const grid of slots.values()) {
this.stopGrid(grid, false);
}
if (bagList?.isValid) {
bagList.setScale(1, 1, 1);
}
const complete = this._activeComplete;
this._activeComplete = null;
if (settle) complete?.();
}
}