import { instantiate, Node, Tween, tween, UIOpacity, UITransform, v3, Vec3, } from 'cc'; import { GBundle } from '../../game/ConfigRes'; /** 沿弧线飞移速度(px/s) */ const MOVE_SPEED_PX = 2000; /** 最短飞行时长(秒) */ const MIN_TOTAL_SEC = 0.14; /** 最长飞行时长(秒) */ const MAX_TOTAL_SEC = 0.34; /** 弧顶抬高(dragLayer 本地像素) */ const LIFT_HEIGHT = 42; /** 影子透明度(与 skill 拖拽一致) */ const ICON_GHOST_OPACITY = 200; /** 落地后淡出时长(秒) */ const FADE_OUT_SEC = 0.12; /** 被动 icon 弧线飞行选项 */ export interface PassiveIconArcFlightOptions { /** 起点世界坐标;缺省为 iconNode 当前 worldPosition */ startWorld?: Readonly | null; /** 飞行时显示 textGrid trail1 拖尾 */ showTrail?: boolean; } /** * 被动 skill icon 影子沿二次贝塞尔弧线飞行(对齐 BagToMapPlaceAnimator 轨迹)。 */ export class PassiveIconArcFlight { /** * 复制 icon 为影子并飞向目标世界坐标,落地淡出后回调。 * @param iconNode 被动槽 icon * @param targetWorld 落点世界坐标 * @param flyLayer 飞行层(ui_fly) * @param onComplete 动画结束 * @param opts 起点 / 拖尾 */ static play( iconNode: Node, targetWorld: Readonly, flyLayer: Node, onComplete?: () => void, opts?: PassiveIconArcFlightOptions, ): Node | null { const finish = (): void => { onComplete?.(); }; if (!iconNode?.isValid || !flyLayer?.isValid) { finish(); return null; } if (!flyLayer.active) { flyLayer.active = true; } const host = new Node('passiveIconArcFlightHost'); host.parent = flyLayer; host.setSiblingIndex(flyLayer.children.length - 1); const ghost = instantiate(iconNode); ghost.name = 'passiveIconArcGhost'; ghost.parent = host; ghost.setPosition(0, 0, 0); ghost.setWorldScale(iconNode.worldScale); const ghostOpacity = ghost.getComponent(UIOpacity) ?? ghost.addComponent(UIOpacity); ghostOpacity.opacity = ICON_GHOST_OPACITY; let trailNode: Node | null = null; if (opts?.showTrail) { trailNode = this._borrowTrail1Node(); if (trailNode?.isValid) { trailNode.parent = host; trailNode.setPosition(0, 0, 0); trailNode.active = true; } } const startWorld = opts?.startWorld ? new Vec3(opts.startWorld.x, opts.startWorld.y, opts.startWorld.z) : iconNode.worldPosition.clone(); host.setWorldPosition(startWorld); const ut = flyLayer.getComponent(UITransform); if (!ut) { this._destroyHost(host); finish(); return null; } const startLocal = ut.convertToNodeSpaceAR(startWorld); const endLocal = ut.convertToNodeSpaceAR(new Vec3(targetWorld.x, targetWorld.y, targetWorld.z)); const control = v3( (startLocal.x + endLocal.x) * 0.5, Math.max(startLocal.y, endLocal.y) + LIFT_HEIGHT, startLocal.z, ); host.parent = flyLayer; host.setPosition(startLocal); const arcLen = Vec3.distance(startLocal, control) + Vec3.distance(control, endLocal); const totalSec = Math.min( MAX_TOTAL_SEC, Math.max(MIN_TOTAL_SEC, arcLen / MOVE_SPEED_PX), ); const proxy = { t: 0 }; tween(proxy) .to(totalSec, { t: 1 }, { easing: 'sineInOut', onUpdate: () => { if (!host.isValid) return; const t = proxy.t; const u = 1 - t; host.setPosition( u * u * startLocal.x + 2 * u * t * control.x + t * t * endLocal.x, u * u * startLocal.y + 2 * u * t * control.y + t * t * endLocal.y, startLocal.z, ); }, }) .call(() => { if (!host.isValid) { finish(); return; } host.setPosition(endLocal); this._fadeOutHost(host, ghostOpacity, trailNode, finish); }) .start(); return host; } /** 中断并销毁飞行 host */ static abort(host: Node | null): void { if (!host?.isValid) return; Tween.stopAllByTarget(host); this._destroyHost(host); } /** 从 textGrid 借 trail1 节点(instantiate 副本) */ private static _borrowTrail1Node(): Node | null { let gridNode: Node | null = null; try { gridNode = gg.res.getNode('prefab/', 'textGrid', GBundle.BattleGame); } catch (e) { console.log('[PassiveIconArcFlight] textGrid 加载失败', String(e)); return null; } if (!gridNode?.isValid) { console.log('[PassiveIconArcFlight] textGrid 缺失'); return null; } const trail = gridNode.getChildByName('trail1'); if (!trail?.isValid) { gg.res.putNode(gridNode); console.log('[PassiveIconArcFlight] trail1 缺失'); return null; } const trailClone = instantiate(trail); gg.res.putNode(gridNode); return trailClone; } /** 落地淡出后销毁 */ private static _fadeOutHost( host: Node, ghostOpacity: UIOpacity, trailNode: Node | null, onDone: () => void, ): void { if (!host.isValid) { onDone(); return; } Tween.stopAllByTarget(ghostOpacity); tween(ghostOpacity) .to(FADE_OUT_SEC, { opacity: 0 }) .call(() => { if (trailNode?.isValid) trailNode.active = false; this._destroyHost(host); onDone(); }) .start(); } /** 销毁 host 及子节点 */ private static _destroyHost(host: Node): void { if (!host?.isValid) return; Tween.stopAllByTarget(host); for (let i = host.children.length - 1; i >= 0; i--) { const child = host.children[i]; if (!child?.isValid) continue; const opacity = child.getComponent(UIOpacity); if (opacity?.isValid) Tween.stopAllByTarget(opacity); child.destroy(); } host.destroy(); } }