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.
475 lines
16 KiB
475 lines
16 KiB
import {
|
|
director,
|
|
Director,
|
|
game,
|
|
Node,
|
|
Rect,
|
|
Size,
|
|
Sprite,
|
|
SpriteFrame,
|
|
UIOpacity,
|
|
UITransform,
|
|
Vec3,
|
|
} from 'cc';
|
|
import { setNodeWorldPos } from './SkillTransformDriver';
|
|
|
|
/** 龙字处向上盘旋上升 */
|
|
const RISE_DURATION = 0.44;
|
|
/** 盘旋移向目标上方 */
|
|
const TRANSIT_DURATION = 0.48;
|
|
/** 俯冲砸敌 */
|
|
const STRIKE_DURATION = 0.18;
|
|
/** 相对龙字向上盘旋净高度(像素) */
|
|
const RISE_HEIGHT_PX = 180;
|
|
const HOVER_ABOVE_TARGET_PX = 100;
|
|
const RISE_LAPS = 1.1;
|
|
/** 略减圈数,降低极限曲率 */
|
|
const TRANSIT_LAPS = 0.85;
|
|
const RISE_RADIUS_START = 14;
|
|
const RISE_RADIUS_END = 46;
|
|
const TRANSIT_RADIUS = 52;
|
|
const LANE_SPREAD_PX = 44;
|
|
const HISTORY_MAX = 320;
|
|
const SLICE_SPACING = 6.5;
|
|
const SLICE_COUNT = 60;
|
|
/** 片宽相对间距放大,急弯露缝减小 */
|
|
const SLICE_WIDTH_OVERLAP = 1.3;
|
|
const TANGENT_DELTA = 6;
|
|
const MIN_HEAD_STEP_PX = 0.25;
|
|
/** 片角平滑:约 18 帧追到目标(60fps) */
|
|
const ANGLE_SMOOTH = 12;
|
|
/** 单帧最大转角(度),急弯顺过来而非硬折 */
|
|
const ANGLE_MAX_DEG_PER_SEC = 540;
|
|
/** 俯冲贝塞尔沿出口切线伸出长度 */
|
|
const STRIKE_TANGENT_CTRL_PX = 70;
|
|
|
|
const _headPos = new Vec3();
|
|
const _posA = new Vec3();
|
|
const _posB = new Vec3();
|
|
const _hoverCenter = new Vec3();
|
|
const _riseEnd = new Vec3();
|
|
const _transitCenter = new Vec3();
|
|
const _strikeCtrl = new Vec3();
|
|
|
|
export type DragonFlyComplete = (impactWorld: Readonly<Vec3>) => void;
|
|
|
|
/**
|
|
* 墨龙三阶段:龙字向上盘旋上升 → 移向目标上方盘旋 → 俯冲攻击。
|
|
* 阶段切线 C1 衔接 + 片角平滑,避免转向卡顿与切片断裂。
|
|
*/
|
|
export class DragonPathFlyPresenter {
|
|
|
|
private readonly _root: Node;
|
|
private readonly _slices: Node[] = [];
|
|
private readonly _sliceAngles: number[] = [];
|
|
private readonly _history: Vec3[] = [];
|
|
private readonly _originWorld = new Vec3();
|
|
private readonly _targetWorld = new Vec3();
|
|
private readonly _strikeFrom = new Vec3();
|
|
private readonly _strikeTo = new Vec3();
|
|
/** 阶段出口切线(单位向量),供下一阶段 C1 衔接 */
|
|
private readonly _exitDir = new Vec3(1, 0, 0);
|
|
|
|
private _alive = true;
|
|
private _phase: 'rise' | 'transit' | 'strike' = 'rise';
|
|
private _elapsed = 0;
|
|
private _riseStartAngle = 0;
|
|
private _transitStartAngle = 0;
|
|
private _laneOffsetX = 0;
|
|
private _resolveTargetWorld: (() => Vec3 | null) | null = null;
|
|
private _onComplete: DragonFlyComplete | null = null;
|
|
|
|
private constructor(root: Node, slices: Node[]) {
|
|
this._root = root;
|
|
this._slices.push(...slices);
|
|
for (let i = 0; i < slices.length; i++) {
|
|
this._sliceAngles.push(slices[i].angle);
|
|
}
|
|
}
|
|
|
|
static start(opts: {
|
|
flyLayer: Node;
|
|
textureTemplate: Node;
|
|
originWorld: Readonly<Vec3>;
|
|
targetWorld: Readonly<Vec3>;
|
|
laneIndex?: number;
|
|
/** 俯冲阶段每帧刷新落点(追踪移动中的敌人) */
|
|
resolveTargetWorld?: () => Vec3 | null;
|
|
onComplete: DragonFlyComplete;
|
|
}): DragonPathFlyPresenter | null {
|
|
const layer = opts.flyLayer;
|
|
const template = opts.textureTemplate;
|
|
if (!layer?.isValid || !template?.isValid) {
|
|
opts.onComplete(opts.targetWorld);
|
|
return null;
|
|
}
|
|
|
|
const srcSprite = template.getComponent(Sprite);
|
|
const srcFrame = srcSprite?.spriteFrame;
|
|
if (!srcFrame?.texture) {
|
|
opts.onComplete(opts.targetWorld);
|
|
return null;
|
|
}
|
|
|
|
layer.active = true;
|
|
const root = new Node('dragon_fly');
|
|
root.parent = layer;
|
|
root.layer = layer.layer;
|
|
root.addComponent(UITransform);
|
|
|
|
const slices = _createDragonSlices(root, srcFrame, template.layer);
|
|
if (slices.length === 0) {
|
|
root.destroy();
|
|
opts.onComplete(opts.targetWorld);
|
|
return null;
|
|
}
|
|
|
|
const presenter = new DragonPathFlyPresenter(root, slices);
|
|
presenter._onComplete = opts.onComplete;
|
|
presenter._originWorld.set(opts.originWorld);
|
|
presenter._targetWorld.set(opts.targetWorld);
|
|
presenter._laneOffsetX = (opts.laneIndex ?? 0) * LANE_SPREAD_PX;
|
|
presenter._resolveTargetWorld = opts.resolveTargetWorld ?? null;
|
|
presenter._setupPath(opts.originWorld, opts.targetWorld);
|
|
presenter._pushHead(opts.originWorld);
|
|
presenter._updateBody(1 / 60);
|
|
|
|
_registerFly(presenter);
|
|
return presenter;
|
|
}
|
|
|
|
dispose(): void {
|
|
if (!this._alive) return;
|
|
this._alive = false;
|
|
_unregisterFly(this);
|
|
if (this._root?.isValid) this._root.destroy();
|
|
this._slices.length = 0;
|
|
this._sliceAngles.length = 0;
|
|
this._history.length = 0;
|
|
this._onComplete = null;
|
|
}
|
|
|
|
private _setupPath(origin: Readonly<Vec3>, target: Readonly<Vec3>): void {
|
|
this._syncHoverFromTarget(target);
|
|
this._riseStartAngle = -Math.PI * 0.5;
|
|
// 初始盘旋角占位;rise→transit 时按出口切线重算
|
|
const dx = _hoverCenter.x - origin.x;
|
|
const dy = _hoverCenter.y - origin.y;
|
|
this._transitStartAngle = Math.atan2(dy, dx);
|
|
this._exitDir.set(0, 1, 0);
|
|
}
|
|
|
|
private _syncTargetWorld(fallback?: Readonly<Vec3>): boolean {
|
|
const live = this._resolveTargetWorld?.();
|
|
if (live) {
|
|
this._targetWorld.set(live);
|
|
return true;
|
|
}
|
|
if (fallback) {
|
|
this._targetWorld.set(fallback);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private _syncHoverFromTarget(fallback?: Readonly<Vec3>): void {
|
|
this._syncTargetWorld(fallback);
|
|
_hoverCenter.set(
|
|
this._targetWorld.x + this._laneOffsetX,
|
|
this._targetWorld.y + HOVER_ABOVE_TARGET_PX,
|
|
this._targetWorld.z,
|
|
);
|
|
}
|
|
|
|
/** 沿出口切线布置俯冲控制点,避免砸落瞬间掉头 */
|
|
private _refreshStrikeCtrl(): void {
|
|
const tx = this._exitDir.x;
|
|
const ty = this._exitDir.y;
|
|
_strikeCtrl.set(
|
|
this._strikeFrom.x + tx * STRIKE_TANGENT_CTRL_PX
|
|
+ (this._strikeTo.x - this._strikeFrom.x) * 0.25,
|
|
this._strikeFrom.y + ty * STRIKE_TANGENT_CTRL_PX
|
|
+ (this._strikeTo.y - this._strikeFrom.y) * 0.15,
|
|
0,
|
|
);
|
|
}
|
|
|
|
/** 从历史头两点刷新出口切线 */
|
|
private _captureExitDir(): void {
|
|
if (this._history.length < 2) return;
|
|
const a = this._history[0];
|
|
const b = this._history[1];
|
|
const dx = a.x - b.x;
|
|
const dy = a.y - b.y;
|
|
const len = Math.sqrt(dx * dx + dy * dy);
|
|
if (len < 1e-4) return;
|
|
this._exitDir.set(dx / len, dy / len, 0);
|
|
}
|
|
|
|
/** 每帧推进飞龙;由模块级 afterUpdate 调用(避免 Set/私有方法在抖音压缩后丢引用) */
|
|
stepFly(dt: number): void {
|
|
if (!this._alive || !this._root?.isValid) {
|
|
this.dispose();
|
|
return;
|
|
}
|
|
|
|
if (this._phase === 'rise') {
|
|
this._elapsed += dt;
|
|
const rawT = Math.min(1, this._elapsed / RISE_DURATION);
|
|
const ease = 1 - Math.pow(1 - rawT, 2.2);
|
|
const angle = this._riseStartAngle + ease * Math.PI * 2 * RISE_LAPS;
|
|
const radius = RISE_RADIUS_START + (RISE_RADIUS_END - RISE_RADIUS_START) * ease;
|
|
const origin = this._originWorld;
|
|
const riseY = origin.y + ease * RISE_HEIGHT_PX;
|
|
_headPos.set(
|
|
origin.x + Math.cos(angle) * radius + this._laneOffsetX * ease * 0.2,
|
|
riseY + Math.sin(angle) * radius * 0.15,
|
|
origin.z,
|
|
);
|
|
this._pushHead(_headPos);
|
|
this._updateBody(dt);
|
|
if (rawT >= 1) {
|
|
this._captureExitDir();
|
|
_riseEnd.set(_headPos);
|
|
_riseEnd.y = Math.max(_riseEnd.y, origin.y + RISE_HEIGHT_PX);
|
|
// 半径从 0 长大:初始扩张方向对齐出口切线,位置/切线连续
|
|
this._transitStartAngle = Math.atan2(this._exitDir.y, this._exitDir.x);
|
|
this._phase = 'transit';
|
|
this._elapsed = 0;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this._phase === 'transit') {
|
|
this._elapsed += dt;
|
|
const rawT = Math.min(1, this._elapsed / TRANSIT_DURATION);
|
|
const ease = rawT * rawT * (3 - 2 * rawT);
|
|
this._syncHoverFromTarget();
|
|
_transitCenter.set(
|
|
_riseEnd.x + (_hoverCenter.x - _riseEnd.x) * ease,
|
|
_riseEnd.y + (_hoverCenter.y - _riseEnd.y) * ease,
|
|
_hoverCenter.z,
|
|
);
|
|
// 半径从 0 平滑长大,消除 rise 结束瞬间外甩
|
|
const radiusGrow = ease * ease * (3 - 2 * ease);
|
|
const angle = this._transitStartAngle + ease * Math.PI * 2 * TRANSIT_LAPS;
|
|
const radius = TRANSIT_RADIUS * radiusGrow * (0.85 + 0.15 * Math.sin(ease * Math.PI));
|
|
_headPos.set(
|
|
_transitCenter.x + Math.cos(angle) * radius,
|
|
_transitCenter.y + Math.sin(angle) * radius * 0.65,
|
|
_transitCenter.z,
|
|
);
|
|
this._pushHead(_headPos);
|
|
this._updateBody(dt);
|
|
if (rawT >= 1) {
|
|
this._captureExitDir();
|
|
this._strikeFrom.set(_headPos);
|
|
this._syncTargetWorld();
|
|
this._strikeTo.set(this._targetWorld);
|
|
this._refreshStrikeCtrl();
|
|
this._phase = 'strike';
|
|
this._elapsed = 0;
|
|
}
|
|
return;
|
|
}
|
|
|
|
this._elapsed += dt;
|
|
const rawT = Math.min(1, this._elapsed / STRIKE_DURATION);
|
|
const t = rawT * rawT * rawT;
|
|
this._syncTargetWorld();
|
|
this._strikeTo.set(this._targetWorld);
|
|
// 追踪目标时保持出口切线控制点,避免每帧乱跳
|
|
this._refreshStrikeCtrl();
|
|
this._sampleBezier(this._strikeFrom, _strikeCtrl, this._strikeTo, t, _headPos);
|
|
this._pushHead(_headPos);
|
|
this._updateBody(dt);
|
|
|
|
if (rawT >= 1) {
|
|
const cb = this._onComplete;
|
|
const impact = this._targetWorld.clone();
|
|
this.dispose();
|
|
cb?.(impact);
|
|
}
|
|
}
|
|
|
|
private _updateBody(dt: number): void {
|
|
const sliceCount = this._slices.length;
|
|
const maxStep = ANGLE_MAX_DEG_PER_SEC * Math.max(dt, 1 / 120);
|
|
const smoothK = 1 - Math.exp(-ANGLE_SMOOTH * Math.max(dt, 1 / 120));
|
|
|
|
for (let i = 0; i < sliceCount; i++) {
|
|
const slice = this._slices[i];
|
|
if (!slice?.isValid) continue;
|
|
|
|
const dist = i * SLICE_SPACING;
|
|
if (!this._sampleHistory(dist, _posA)) continue;
|
|
setNodeWorldPos(slice, _posA);
|
|
|
|
const t = sliceCount > 1 ? i / (sliceCount - 1) : 0;
|
|
const scale = 1.14 - t * 0.28;
|
|
slice.setScale(scale, scale, 1);
|
|
const uo = slice.getComponent(UIOpacity);
|
|
if (uo) uo.opacity = Math.round(255 - t * 180);
|
|
|
|
let targetDeg = this._sliceAngles[i] ?? slice.angle;
|
|
if (this._sampleHistory(dist + TANGENT_DELTA, _posB)) {
|
|
const dx = _posA.x - _posB.x;
|
|
const dy = _posA.y - _posB.y;
|
|
if (dx * dx + dy * dy > 1e-6) {
|
|
targetDeg = Math.atan2(dy, dx) * 180 / Math.PI;
|
|
}
|
|
}
|
|
|
|
const cur = this._sliceAngles[i] ?? slice.angle;
|
|
let delta = _shortestAngleDelta(cur, targetDeg);
|
|
delta = Math.max(-maxStep, Math.min(maxStep, delta * smoothK));
|
|
const next = cur + delta;
|
|
this._sliceAngles[i] = next;
|
|
slice.angle = next;
|
|
}
|
|
}
|
|
|
|
private _sampleBezier(
|
|
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;
|
|
}
|
|
|
|
private _pushHead(pos: Readonly<Vec3>): void {
|
|
if (this._history.length > 0) {
|
|
const last = this._history[0];
|
|
const dx = pos.x - last.x;
|
|
const dy = pos.y - last.y;
|
|
if (dx * dx + dy * dy < MIN_HEAD_STEP_PX * MIN_HEAD_STEP_PX) return;
|
|
}
|
|
this._history.unshift(pos.clone());
|
|
if (this._history.length > HISTORY_MAX) {
|
|
this._history.length = HISTORY_MAX;
|
|
}
|
|
}
|
|
|
|
private _sampleHistory(dist: number, out: Vec3): boolean {
|
|
const hist = this._history;
|
|
if (hist.length === 0) return false;
|
|
if (dist <= 0) {
|
|
out.set(hist[0]);
|
|
return true;
|
|
}
|
|
let acc = 0;
|
|
for (let i = 0; i < hist.length - 1; i++) {
|
|
const a = hist[i];
|
|
const b = hist[i + 1];
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const segLen = Math.sqrt(dx * dx + dy * dy);
|
|
if (acc + segLen >= dist) {
|
|
const local = segLen > 1e-4 ? (dist - acc) / segLen : 0;
|
|
out.x = a.x + dx * local;
|
|
out.y = a.y + dy * local;
|
|
out.z = a.z;
|
|
return true;
|
|
}
|
|
acc += segLen;
|
|
}
|
|
out.set(hist[hist.length - 1]);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** 与 SkillCollisionHit / SkillTransformDriver 一致:Array + 具名 afterUpdate,兼容抖音打包 */
|
|
const _activeFlies: DragonPathFlyPresenter[] = [];
|
|
let _flyHooked = false;
|
|
|
|
function _dragonFlyAfterUpdate(): void {
|
|
if (_activeFlies.length === 0) return;
|
|
const battle = gg.game?.CurentBattle as { TimeScale?: number } | null | undefined;
|
|
const scale = battle?.TimeScale ?? 1;
|
|
if (scale <= 0) return;
|
|
const dt = (game.deltaTime || 1 / 60) * scale;
|
|
for (let i = _activeFlies.length - 1; i >= 0; i--) {
|
|
_activeFlies[i].stepFly(dt);
|
|
}
|
|
}
|
|
|
|
function _ensureFlyHook(): void {
|
|
if (_flyHooked) return;
|
|
_flyHooked = true;
|
|
director.on(Director.EVENT_AFTER_UPDATE, _dragonFlyAfterUpdate);
|
|
}
|
|
|
|
function _registerFly(p: DragonPathFlyPresenter): void {
|
|
_ensureFlyHook();
|
|
if (_activeFlies.indexOf(p) < 0) {
|
|
_activeFlies.push(p);
|
|
}
|
|
}
|
|
|
|
function _unregisterFly(p: DragonPathFlyPresenter): void {
|
|
const idx = _activeFlies.indexOf(p);
|
|
if (idx >= 0) _activeFlies.splice(idx, 1);
|
|
}
|
|
|
|
/** 最短角差(度),结果落在 (-180, 180] */
|
|
function _shortestAngleDelta(fromDeg: number, toDeg: number): number {
|
|
let d = (toDeg - fromDeg) % 360;
|
|
if (d > 180) d -= 360;
|
|
if (d <= -180) d += 360;
|
|
return d;
|
|
}
|
|
|
|
function _createDragonSlices(
|
|
root: Node,
|
|
srcFrame: SpriteFrame,
|
|
layer: number,
|
|
): Node[] {
|
|
const tex = srcFrame.texture;
|
|
if (!tex) return [];
|
|
|
|
const rect = srcFrame.rect;
|
|
const origW = srcFrame.originalSize.width || rect.width;
|
|
const origH = srcFrame.originalSize.height || rect.height;
|
|
const texSliceW = origW / SLICE_COUNT;
|
|
// 显示宽度 ≥ 间距×重叠,盖住急弯缝隙
|
|
const displayW = Math.max(texSliceW, SLICE_SPACING * SLICE_WIDTH_OVERLAP);
|
|
const slices: Node[] = [];
|
|
|
|
for (let i = SLICE_COUNT - 1; i >= 0; i--) {
|
|
const node = new Node(`dragon_slice_${i}`);
|
|
node.parent = root;
|
|
node.layer = layer;
|
|
|
|
const ut = node.addComponent(UITransform);
|
|
ut.setContentSize(displayW, origH);
|
|
ut.setAnchorPoint(0.5, 0.5);
|
|
|
|
const sp = node.addComponent(Sprite);
|
|
const sf = new SpriteFrame();
|
|
sf.texture = tex;
|
|
const x = rect.x + rect.width - (i + 1) * (rect.width / SLICE_COUNT);
|
|
sf.rect = new Rect(x, rect.y, rect.width / SLICE_COUNT, rect.height);
|
|
sf.originalSize = new Size(displayW, origH);
|
|
sf.offset = srcFrame.offset;
|
|
sf.rotated = srcFrame.rotated;
|
|
sp.spriteFrame = sf;
|
|
sp.sizeMode = Sprite.SizeMode.CUSTOM;
|
|
|
|
const uo = node.addComponent(UIOpacity);
|
|
const t = (SLICE_COUNT - 1 - i) / Math.max(1, SLICE_COUNT - 1);
|
|
uo.opacity = Math.round(255 - t * 180);
|
|
|
|
slices.push(node);
|
|
}
|
|
|
|
slices.reverse();
|
|
return slices;
|
|
}
|
|
|