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(); private static _activeComplete: (() => void) | null = null; private static readonly _gridDone = new WeakMap 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, _bagList?: Node | null, onComplete?: () => void, fromWorld?: Readonly | 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, 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, 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, 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?.(); } }