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) => 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; targetWorld: Readonly; 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, target: Readonly): 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): 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): 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, 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; } private _pushHead(pos: Readonly): 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; }