import { director, Director, game, Node, UITransform, Vec3 } from 'cc'; type EasingFn = (t: number) => number; export interface SkillTransformAnimOpts { node: Node; duration: number; fromPos?: Readonly; toPos?: Readonly; fromScale?: Readonly; toScale?: Readonly; fromAngle?: number; toAngle?: number; /** 默认 true:走最短弧;多圈旋转须设 false */ useShortestAngle?: boolean; easing?: EasingFn; /** 每帧回调(rawT 0~1),用于爪拉怪等须跟位移同步的逻辑 */ onStep?: (rawT: number) => void; onComplete?: (node?: Node) => void; } interface ActiveTransformAnim { node: Node; duration: number; elapsed: number; fromPos: Vec3 | null; toPos: Vec3 | null; fromScale: Vec3 | null; toScale: Vec3 | null; fromAngle: number | null; toAngle: number | null; useShortestAngle: boolean; easing: EasingFn; onStep?: (rawT: number) => void; onComplete?: (node?: Node) => void; } const _active: ActiveTransformAnim[] = []; const _tmpPos = new Vec3(); const _tmpScale = new Vec3(); let _hooked = false; interface ActiveParabolaAnim { node: Node; duration: number; elapsed: number; fromWorld: Vec3; toWorld: Vec3; /** 二次贝塞尔控制点;缺省时由 arcHeight 在弦中点上方生成 */ ctrlWorld: Vec3; /** 追踪时每帧刷新终点,并按弧高重算控制点 */ arcHeight: number; /** 追踪落点;返回 null 则沿用上次终点 */ resolveToWorld?: (node: Node) => Vec3 | null; /** 是否把节点朝向切成飞行切线 */ faceTangent: boolean; /** 进度曲线;缺省 smoothstep */ easing: EasingFn; onComplete?: (node: Node) => void; } const _parabolaActive: ActiveParabolaAnim[] = []; interface ActiveSmoothOrbitAnim { node: Node; anchorWorld: Vec3; radiusPx: number; angle: number; angularSpeed: number; wobbleAmp: number; wobbleHarmonic: number; wobblePhase: number; } const _orbitActive: ActiveSmoothOrbitAnim[] = []; /** 有限时长轴对齐椭圆绕一圈 */ interface ActiveEllipseAnim { /** 运动节点 */ node: Node; /** 椭圆中心(世界) */ centerWorld: Vec3; /** 长半径(沿 X) */ radiusX: number; /** 短半径(沿 Y) */ radiusY: number; /** 绕一圈时长(逻辑秒) */ duration: number; /** 已过逻辑秒 */ elapsed: number; /** 起始角(弧度) */ startAngle: number; /** 1=逆时针 -1=顺时针 */ direction: number; /** 绕完 */ onComplete?: (node: Node) => void; } const _ellipseActive: ActiveEllipseAnim[] = []; const _worldPos = new Vec3(); const _nextWorldPos = new Vec3(); /** 有限时长椭圆绕一圈(世界坐标,匀角速) */ export interface SkillEllipseOnceOpts { /** 运动节点 */ node: Node; /** 椭圆中心(世界) */ centerWorld: Readonly; /** 长半径(像素,沿 X) */ radiusX: number; /** 短半径(像素,沿 Y) */ radiusY: number; /** 绕一圈时长(逻辑秒,与 Spine 时长对齐) */ duration: number; /** 起始角(弧度);缺省由节点当前世界坐标相对中心推算 */ startAngleRad?: number; /** 1=逆时针 -1=顺时针;缺省 -1(从左侧先上绕) */ direction?: number; /** 绕完 */ onComplete?: (node: Node) => void; } /** 普攻/技能节点位移、缩放、旋转:update 插值,不用 tween */ export const SkillTransformDriver = { easeLinear(t: number): number { return t; }, easeSmoothstep(t: number): number { return t * t * (3 - 2 * t); }, easeSineInOut(t: number): number { return -(Math.cos(Math.PI * t) - 1) / 2; }, animate(opts: SkillTransformAnimOpts): void { const node = opts.node; if (!node?.isValid) { opts.onComplete?.(node); return; } _ensureHook(); let fromAngle = opts.fromAngle ?? null; let toAngle = opts.toAngle ?? null; const useShortestAngle = opts.useShortestAngle !== false; if (fromAngle != null && toAngle != null && useShortestAngle) { toAngle = fromAngle + shortestAngleDeltaDeg(fromAngle, toAngle); } _active.push({ node, duration: Math.max(0, opts.duration), elapsed: 0, fromPos: opts.fromPos ? opts.fromPos.clone() : null, toPos: opts.toPos ? opts.toPos.clone() : null, fromScale: opts.fromScale ? opts.fromScale.clone() : null, toScale: opts.toScale ? opts.toScale.clone() : null, fromAngle, toAngle, useShortestAngle, easing: opts.easing ?? SkillTransformDriver.easeSmoothstep, onStep: opts.onStep, onComplete: opts.onComplete, }); }, cancelNode(node: Node): void { for (let i = _active.length - 1; i >= 0; i--) { if (_active[i].node === node) _active.splice(i, 1); } for (let i = _parabolaActive.length - 1; i >= 0; i--) { if (_parabolaActive[i].node === node) _parabolaActive.splice(i, 1); } for (let i = _orbitActive.length - 1; i >= 0; i--) { if (_orbitActive[i].node === node) _orbitActive.splice(i, 1); } for (let i = _ellipseActive.length - 1; i >= 0; i--) { if (_ellipseActive[i].node === node) _ellipseActive.splice(i, 1); } }, clear(): void { _active.length = 0; _parabolaActive.length = 0; _orbitActive.length = 0; _ellipseActive.length = 0; }, /** * 落点周围平滑闭合曲线游走(匀速角速度,无折线转角停顿)。 * 轨迹:极坐标花瓣曲线 r(θ)=R·(1+k·sin(nθ)),k 较小时仍近似匀速。 */ animateSmoothOrbit(opts: { node: Node; anchorWorld: Readonly; radiusPx: number; /** 绕一圈所需秒数 */ loopDurationSec?: number; /** 1=逆时针 -1=顺时针 */ direction?: number; /** 半径起伏(0~0.3),轨迹仍为平滑曲线 */ wobbleAmp?: number; }): void { const node = opts.node; if (!node?.isValid) return; const loopDurationSec = Math.max(0.2, opts.loopDurationSec ?? 5); const direction = opts.direction ?? (Math.random() < 0.5 ? 1 : -1); _ensureHook(); _orbitActive.push({ node, anchorWorld: opts.anchorWorld.clone(), radiusPx: Math.max(1, opts.radiusPx), angle: Math.random() * Math.PI * 2, angularSpeed: (2 * Math.PI / loopDurationSec) * direction, wobbleAmp: Math.min(0.3, Math.max(0, opts.wobbleAmp ?? 0.14)), wobbleHarmonic: 3, wobblePhase: Math.random() * Math.PI * 2, }); _sampleSmoothOrbitWorld(_orbitActive[_orbitActive.length - 1], _worldPos); _setNodeWorldPos(node, _worldPos); }, /** * 轴对齐椭圆绕中心一圈后停止(匀角速,跟战斗倍速)。 * @param opts 椭圆参数 */ animateEllipseOnce(opts: SkillEllipseOnceOpts): void { const node = opts.node; if (!node?.isValid) { opts.onComplete?.(node); return; } const duration = opts.duration; if (!(duration > 0) || !(opts.radiusX > 0) || !(opts.radiusY > 0)) { console.log('[SkillTransformDriver] 椭圆参数无效', duration, opts.radiusX, opts.radiusY); opts.onComplete?.(node); return; } const cx = opts.centerWorld.x; const cy = opts.centerWorld.y; let startAngle = opts.startAngleRad; if (startAngle == null || !Number.isFinite(startAngle)) { const wp = node.worldPosition; startAngle = Math.atan2((wp.y - cy) / opts.radiusY, (wp.x - cx) / opts.radiusX); } const direction = opts.direction === 1 ? 1 : -1; _ensureHook(); const rec: ActiveEllipseAnim = { node, centerWorld: opts.centerWorld.clone(), radiusX: opts.radiusX, radiusY: opts.radiusY, duration, elapsed: 0, startAngle, direction, onComplete: opts.onComplete, }; _ellipseActive.push(rec); _sampleEllipseWorld(rec, 0, _worldPos); _setNodeWorldPos(node, _worldPos); }, /** * 世界坐标抛物线飞行(二次贝塞尔)。 * - 传 controlWorld:按该控制点飞(下方目标可把控制点抬高,先上扬再落) * - 只传 arcHeight:弦中点上方弧高(兼容旧用法) * - 传 resolveToWorld:每帧追踪移动目标,弧线随终点更新 */ animateParabola(opts: { node: Node; duration: number; fromWorld: Readonly; toWorld: Readonly; arcHeight?: number; controlWorld?: Readonly; resolveToWorld?: (node: Node) => Vec3 | null; /** 默认朝向飞行切线 */ faceTangent?: boolean; /** 进度曲线;缺省 smoothstep(起步慢)。立即飞出传 easeLinear */ easing?: EasingFn; onComplete?: (node: Node) => void; }): void { const node = opts.node; if (!node?.isValid) { opts.onComplete?.(node); return; } const from = opts.fromWorld; const to = opts.toWorld; const arcHeight = opts.arcHeight ?? 0; const ctrl = opts.controlWorld ? opts.controlWorld.clone() : new Vec3( (from.x + to.x) * 0.5, (from.y + to.y) * 0.5 + arcHeight, (from.z + to.z) * 0.5, ); _ensureHook(); const rec: ActiveParabolaAnim = { node, duration: Math.max(0.05, opts.duration), elapsed: 0, fromWorld: from.clone(), toWorld: to.clone(), ctrlWorld: ctrl, arcHeight, resolveToWorld: opts.resolveToWorld, faceTangent: opts.faceTangent !== false, easing: opts.easing ?? SkillTransformDriver.easeSmoothstep, onComplete: opts.onComplete, }; _parabolaActive.push(rec); _applyParabolaFacing(rec, 0); _setNodeWorldPos(node, from); }, /** * 立刻推进一帧位移(飞剑出生当帧就要动,不能等 AFTER_UPDATE)。 * @param dtSec 逻辑秒;缺省 1/60×倍速 */ stepNow(dtSec?: number): void { if (_active.length === 0 && _parabolaActive.length === 0 && _orbitActive.length === 0 && _ellipseActive.length === 0) return; const battle = gg.game?.CurentBattle as { TimeScale?: number } | null | undefined; const scale = battle?.TimeScale ?? 1; if (scale <= 0) return; const dt = dtSec != null && dtSec > 0 ? dtSec : (1 / 60) * scale; _stepTransformAnims(dt); }, }; function _transformDriverAfterUpdate(): void { if (_active.length === 0 && _parabolaActive.length === 0 && _orbitActive.length === 0 && _ellipseActive.length === 0) return; const battle = gg.game?.CurentBattle as { TimeScale?: number } | null | undefined; const scale = battle?.TimeScale ?? 1; if (scale <= 0) return; _stepTransformAnims((game.deltaTime || 1 / 60) * scale); } function _ensureHook(): void { if (_hooked) return; _hooked = true; director.on(Director.EVENT_AFTER_UPDATE, _transformDriverAfterUpdate); } function _stepTransformAnims(dt: number): void { _tickLinearAnims(dt); _tickParabolaAnims(dt); _tickOrbitAnims(dt); _tickEllipseAnims(dt); } function _sampleSmoothOrbitWorld(anim: ActiveSmoothOrbitAnim, out: Vec3): void { const wobble = 1 + anim.wobbleAmp * Math.sin(anim.wobbleHarmonic * anim.angle + anim.wobblePhase); const r = anim.radiusPx * wobble; out.x = anim.anchorWorld.x + r * Math.cos(anim.angle); out.y = anim.anchorWorld.y + r * Math.sin(anim.angle); out.z = anim.anchorWorld.z; } function _tickOrbitAnims(dt: number): void { for (let i = _orbitActive.length - 1; i >= 0; i--) { const anim = _orbitActive[i]; const node = anim.node; if (!node?.isValid) { _orbitActive.splice(i, 1); continue; } anim.angle += anim.angularSpeed * dt; _sampleSmoothOrbitWorld(anim, _worldPos); _setNodeWorldPos(node, _worldPos); } } function _sampleEllipseWorld(anim: ActiveEllipseAnim, rawT: number, out: Vec3): void { const angle = anim.startAngle + anim.direction * Math.PI * 2 * rawT; out.x = anim.centerWorld.x + anim.radiusX * Math.cos(angle); out.y = anim.centerWorld.y + anim.radiusY * Math.sin(angle); out.z = anim.centerWorld.z; } function _tickEllipseAnims(dt: number): void { for (let i = _ellipseActive.length - 1; i >= 0; i--) { const anim = _ellipseActive[i]; const node = anim.node; if (!node?.isValid) { const cb = anim.onComplete; _ellipseActive.splice(i, 1); cb?.(node); continue; } anim.elapsed += dt; const rawT = anim.duration > 0 ? Math.min(1, anim.elapsed / anim.duration) : 1; _sampleEllipseWorld(anim, rawT, _worldPos); _setNodeWorldPos(node, _worldPos); if (rawT >= 1) { const cb = anim.onComplete; const finished = anim.node; _ellipseActive.splice(i, 1); cb?.(finished); } } } function _tickLinearAnims(dt: number): void { for (let i = _active.length - 1; i >= 0; i--) { const anim = _active[i]; const node = anim.node; if (!node?.isValid) { _active.splice(i, 1); continue; } anim.elapsed += dt; const rawT = anim.duration > 0 ? Math.min(1, anim.elapsed / anim.duration) : 1; const t = anim.easing(rawT); if (anim.fromPos && anim.toPos) { Vec3.lerp(_tmpPos, anim.fromPos, anim.toPos, t); node.setPosition(_tmpPos); } if (anim.fromScale && anim.toScale) { Vec3.lerp(_tmpScale, anim.fromScale, anim.toScale, t); node.setScale(_tmpScale); } if (anim.fromAngle != null && anim.toAngle != null) { node.angle = anim.fromAngle + (anim.toAngle - anim.fromAngle) * t; } anim.onStep?.(rawT); if (rawT >= 1) { const cb = anim.onComplete; _active.splice(i, 1); cb?.(node); } } } function _tickParabolaAnims(dt: number): void { for (let i = _parabolaActive.length - 1; i >= 0; i--) { const anim = _parabolaActive[i]; const node = anim.node; if (!node?.isValid) { const cb = anim.onComplete; _parabolaActive.splice(i, 1); cb?.(node); continue; } if (anim.resolveToWorld) { const live = anim.resolveToWorld(node); if (live) { anim.toWorld.set(live); anim.ctrlWorld.set( (anim.fromWorld.x + live.x) * 0.5, (anim.fromWorld.y + live.y) * 0.5 + anim.arcHeight, (anim.fromWorld.z + live.z) * 0.5, ); } } anim.elapsed += dt; const rawT = anim.duration > 0 ? Math.min(1, anim.elapsed / anim.duration) : 1; const t = anim.easing(rawT); _sampleBezierWorld(anim.fromWorld, anim.ctrlWorld, anim.toWorld, t, _worldPos); _setNodeWorldPos(node, _worldPos); if (rawT < 1) _applyParabolaFacing(anim, rawT); if (rawT >= 1) { const cb = anim.onComplete; const finished = anim.node; _parabolaActive.splice(i, 1); cb?.(finished); } } } /** * 按贝塞尔切线转节点朝向。 * @param anim 抛物线记录 * @param rawT 未缓动进度 0~1 */ function _applyParabolaFacing(anim: ActiveParabolaAnim, rawT: number): void { if (!anim.faceTangent || !anim.node?.isValid) return; const t = anim.easing(rawT); _sampleBezierWorld(anim.fromWorld, anim.ctrlWorld, anim.toWorld, t, _worldPos); const tNext = Math.min(1, rawT + 0.02); _sampleBezierWorld(anim.fromWorld, anim.ctrlWorld, anim.toWorld, anim.easing(tNext), _nextWorldPos); const dx = _nextWorldPos.x - _worldPos.x; const dy = _nextWorldPos.y - _worldPos.y; if (Math.abs(dx) > 1e-3 || Math.abs(dy) > 1e-3) { anim.node.angle = Math.atan2(dy, dx) * 180 / Math.PI; } } /** 二次贝塞尔:B(t)=(1-t)²P0 + 2(1-t)t P1 + t²P2 */ function _sampleBezierWorld( from: Readonly, ctrl: Readonly, to: Readonly, t: number, out: Vec3, ): void { const u = 1 - t; const uu = u * u; const tt = t * t; const ut2 = 2 * u * t; out.x = uu * from.x + ut2 * ctrl.x + tt * to.x; out.y = uu * from.y + ut2 * ctrl.y + tt * to.y; out.z = uu * from.z + ut2 * ctrl.z + tt * to.z; } function _setNodeWorldPos(node: Node, world: Readonly): void { const parent = node.parent; const ui = parent?.getComponent(UITransform); if (ui) { ui.convertToNodeSpaceAR(world as Vec3, _tmpPos); node.setPosition(_tmpPos); } else { node.setWorldPosition(world); } } /** 将节点摆到指定世界坐标(UI 父节点下走 convertToNodeSpaceAR) */ export function setNodeWorldPos(node: Node, world: Readonly): void { _setNodeWorldPos(node, world); } /** pivot→target 朝向角(度) */ export function computeAimAngleDeg( pivotWorld: Readonly, targetWorld: Readonly, ): number | null { const dx = targetWorld.x - pivotWorld.x; const dy = targetWorld.y - pivotWorld.y; if (Math.abs(dx) < 1e-4 && Math.abs(dy) < 1e-4) return null; return Math.atan2(dy, dx) * 180 / Math.PI; } /** from→to 最短转角增量(度),落在 (-180, 180] */ export function shortestAngleDeltaDeg(fromDeg: number, toDeg: number): number { let d = toDeg - fromDeg; d = ((d + 180) % 360 + 360) % 360 - 180; return d; } /** skillParent 沿 X 轴拉伸倍数 */ export function computeReachScaleFactor(reachPx: number, baseReach: number): number { if (baseReach <= 1e-4) return 1; return Math.max(0.1, reachPx / baseReach); } export function buildReachScale( initScale: Readonly, reachPx: number, baseReach: number, ): Vec3 { const factor = computeReachScaleFactor(reachPx, baseReach); return new Vec3(initScale.x * factor, initScale.y, initScale.z); }