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.
558 lines
18 KiB
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);
|
||
|
|
}
|