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/skill/SkillTransformDriver.ts

558 lines
18 KiB

3 weeks ago
import { director, Director, game, Node, UITransform, Vec3 } from 'cc';
type EasingFn = (t: number) => number;
export interface SkillTransformAnimOpts {
node: Node;
duration: number;
fromPos?: Readonly<Vec3>;
toPos?: Readonly<Vec3>;
fromScale?: Readonly<Vec3>;
toScale?: Readonly<Vec3>;
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<Vec3>;
/** 长半径(像素,沿 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<Vec3>;
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<Vec3>;
toWorld: Readonly<Vec3>;
arcHeight?: number;
controlWorld?: Readonly<Vec3>;
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<Vec3>,
ctrl: Readonly<Vec3>,
to: Readonly<Vec3>,
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<Vec3>): 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<Vec3>): void {
_setNodeWorldPos(node, world);
}
/** pivot→target 朝向角(度) */
export function computeAimAngleDeg(
pivotWorld: Readonly<Vec3>,
targetWorld: Readonly<Vec3>,
): 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<Vec3>,
reachPx: number,
baseReach: number,
): Vec3 {
const factor = computeReachScaleFactor(reachPx, baseReach);
return new Vec3(initScale.x * factor, initScale.y, initScale.z);
}