import { _decorator, Component, director, Node, sp, tween, Tween, UIOpacity, UITransform, Vec3, type ISchedulable, } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { applyGlyphSpineSkin, GLYPH_ACTIVE_SKIN_BUCKETS, GLYPH_SKIN_IDLE, GLYPH_SKIN_QUALITY, resolveActiveGlyphSkin, } from '../view/GlyphSpineSkin'; import type { SkillPresentationContext } from './SkillPresentationTypes'; import { getBattleTimeScale, schedulePresentation as schedulePresentationClock } from './BattlePresentationClock'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { SkillTransformDriver } from './SkillTransformDriver'; import { SkillPresenterRouter } from '../presentation/SkillPresenterRouter'; import { getAllySkillExtraHost } from '../presentation/AllySkillExtraHost'; import { getFaXiangHost } from '../presentation/FaXiangHost'; import { SkillVisualChannel } from '../presentation/SkillVisualChannel'; import { getGroundCircleHost, logicPosOf, pulseHostileCircle } from '../presentation/ThrownCircleAtkPresenter'; import { getStaffSmashHost } from '../presentation/StaffSmashPresenter'; import { SkillPresentationEnv } from './SkillPresentationEnv'; import { SkillShowEffectPlayer } from './SkillShowEffect'; import { AttrId } from 'db://assets/mx/module/battle/ecs/Components'; export type { SkillPresentationContext, SkillAttackFlowSnapshot } from './SkillPresentationTypes'; const { ccclass } = _decorator; /** 美术资源:品质固定 1,激活皮仅 皮肤字1_3 / 1_4 / 1_5 三档 */ const COMPOSE_TEXT_SKIN_QUALITY = GLYPH_SKIN_QUALITY; const COMPOSE_TEXT_SKIN_IDLE = GLYPH_SKIN_IDLE; const COMPOSE_ACTIVE_SKIN_BUCKETS = GLYPH_ACTIVE_SKIN_BUCKETS; /** atk_type=4 弓箭待落地结算 */ interface BowLandJob { /** 落地任务 id */ id: number; /** 施法上下文(finish 后仍可 dealDamage,不可 cancel) */ ctx: SkillPresentationContext; /** 锁定目标 */ target: EntityId; } @ccclass('Skill') export class Skill extends Component { /**是否激活战斗状态,默认false */ isActive: boolean = false; /** 字形/武器视觉通道(表现 Router 用) */ readonly visualChannel = new SkillVisualChannel(); /** Skill.showeffect 预兆 */ private readonly _showEffect = new SkillShowEffectPlayer(); /** 有预兆:命中与额外效果等预兆起播后 0.33s 再施加 */ private _showEffectGate = false; /** 多段已登记、等预兆命中时刻再起跳 */ private _presMultiHitArmed = false; /** atk_type=10 开战锁定的单体快照 */ private readonly _presSmashPool: EntityId[] = []; /** 预兆已施加(或无预兆) */ private _showEffectApplied = true; /** 英雄动画已结束,仍等预兆施加后再 finish */ private _presAnimWaitForShow = false; /** 回收/销毁中:不再恢复待机表现(字形已回池) */ protected _presentationTeardown = false; private _presSeq = 0; private _presCtx: SkillPresentationContext | null = null; private _presFinished = false; /** 由武器/字形动画 complete 收招的序号;0=冲锋等长表现,旧动画不得收招 */ private _presAnimSeq = 0; /** 本次播控动画已结束(多段 hit 不得早于动画 finish) */ private _presAnimComplete = false; /** 字形动画播完不收招,等攻效播完再收招(环形刀光等) */ private _presHoldUntilFxComplete = false; /** atk_type=4 弓:当前施法伤害等箭落地,动画结束不得 drain */ private _presBowLand = false; /** 弓箭落地任务自增 id */ private _bowLandSeq = 0; /** 当前施法对应的落地 id;0=无 */ private _presBowLandId = 0; /** 在飞箭待落地结算(支持震天弓等长飞距与下一击重叠) */ private readonly _bowLandJobs: BowLandJob[] = []; /** HeroActor 回池或节点销毁前调用 */ beginPresentationTeardown(): void { this._presentationTeardown = true; } protected endPresentationTeardown(): void { this._presentationTeardown = false; } protected isPresentationTeardown(): boolean { return this._presentationTeardown; } /** 回池/禁用时:标记 teardown 并打断表现,避免 onIdle 触回池字形 */ protected teardownPresentation(): void { this.beginPresentationTeardown(); this._showEffect.release(); this.abortPresentation(); } /** 节点销毁:打断表现并拆下池化 FX */ protected teardownLifecycle(): void { this.teardownPresentation(); this.detachPooledFx(); } /** abort 后恢复待机(teardown 期间跳过) */ protected idleAfterAbortUnlessTeardown(): void { if (!this.isPresentationTeardown() && this.isValid) this.onIdle(); } /** 更新激活状态 */ onSetActiveAtk(isActive: boolean) { this.isActive = isActive; if (!this._presentationTeardown) { this.onUpdateActive(isActive); } } /** 更新激活状态 */ onUpdateActive(active: boolean) { } /** HeroSkin 刷新字形皮后(升级 / refresh);子类按需隐藏字块自带武器等 */ onHeroGlyphSkinRefreshed(): void { } /** 播放闲置动画 */ onIdle() { } /** 普攻:默认走 SkillPresenterRouter(按 Skill.atk_type) */ onAttack(ctx: SkillPresentationContext) { this.dispatchSkillPresentation(ctx); } /** 英雄主动:与普攻同一 Router */ onSkill(ctx: SkillPresentationContext) { this.dispatchSkillPresentation(ctx); } /** 武器主动:与普攻同一 Router */ onWeaponSkill(ctx: SkillPresentationContext) { this.dispatchSkillPresentation(ctx); } /** 分发到表现 Router(三槽共用);类型 3/4/6 在 atk_type/skillId 表现之后叠加 */ dispatchSkillPresentation(ctx: SkillPresentationContext): void { this._showEffectGate = this._showEffect.arm(ctx, this._onShowEffectApplyBound); this._showEffectApplied = !this._showEffectGate; this._presAnimWaitForShow = false; SkillPresenterRouter.instance.dispatch(ctx, this); if (!this._showEffectGate) this._applyExtraOnCast(ctx); } /** 预兆命中时刻:施加额外效果并 drain 命中 */ private readonly _onShowEffectApplyBound = (): void => { this._applyShowEffectGatedEffects(); }; /** * 英雄/武器主动额外 3/4/6。 * @param ctx 表现上下文 */ private _applyExtraOnCast(ctx: SkillPresentationContext): void { getAllySkillExtraHost().onSkillCast(ctx); getFaXiangHost().onSkillCast(ctx, this); } /** 预兆命中时刻:额外效果 + 逻辑命中;若英雄动画已完则收招 */ private _applyShowEffectGatedEffects(): void { if (!this.isValid || this.isPresentationTeardown() || this._showEffectApplied) return; this._showEffectApplied = true; const ctx = this._presCtx; if (ctx?.isAlive) { this._applyExtraOnCast(ctx); if (this._presMultiHitArmed && this._presMultiHitCtx) { this._presMultiHitArmed = false; this.schedulePresentation(this._onPresMultiHitTick, 0); } else if (!this._presMultiHitCtx) { while (ctx.isAlive && ctx.hit()) { /* drain deferred DealDamage / atk_buff */ } } } if (this._presAnimWaitForShow && !this._presMultiHitCtx) { this._presAnimWaitForShow = false; this.finishPresentationCast(this._presSeq, false); } } /** * 仅推进逻辑已锁定的 deferred 伤害(无动画)。 * Presenter 回退 / 子类需要时调用。 */ drainLogicPresentation(ctx: SkillPresentationContext): void { this._defaultLogicPresentation(ctx); } /** * 默认表现:只推进逻辑已锁定的 deferred 伤害,不通过碰撞算目标。 * 子类播完动画后应同样 hit/finish,勿用 SkillCollisionHit 作为唯一伤害源。 */ protected _defaultLogicPresentation(ctx: SkillPresentationContext): void { if (!ctx.isAlive) return; while (ctx.isAlive && ctx.hit()) { /* drain deferred DealDamage */ } if (ctx.isAlive) ctx.finish(); } /** 开始一次 Router 播控,返回序号 */ beginPresentationCast(ctx: SkillPresentationContext): number { const prev = this._presCtx; if (prev?.isAlive && prev !== ctx && !this._hasBowLandJob(prev)) { prev.cancel('superseded'); } this._presFinished = false; this._presAnimComplete = false; this._presHoldUntilFxComplete = false; this._presBowLand = false; this._presBowLandId = 0; this._presCtx = ctx; const seq = ++this._presSeq; this._presAnimSeq = seq; return seq; } /** * 字形动画播完不收招,等攻效(Spine)播完由攻效侧 markStaffSmashAnimsComplete 收招。 * 环形刀光(棍 200005)等「攻效播完才发射」的表现使用。 */ holdPresentationUntilFxComplete(): void { this._presHoldUntilFxComplete = true; } /** 冲锋等:本次播控不由武器/字形动画 complete 收招 */ disarmAnimComplete(): void { this._presAnimSeq = 0; } /** 当前播控序号是否仍有效 */ isPresentationCast(seq: number): boolean { return seq === this._presSeq && !this._presFinished; } /** 多段范围技(14 / 16 / 20):不门闩预兆,伤害出手即起跳 */ private _isMultiSegRangeAtk(): boolean { const ctx = this._presCtx; if (!ctx) return false; const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0); return head === 14 || head === 16 || head === 20; } /** * 是否有该 ctx 的待落地弓箭(长飞距时下一击不得 cancel)。 * @param ctx 施法上下文 */ private _hasBowLandJob(ctx: SkillPresentationContext): boolean { for (let i = 0; i < this._bowLandJobs.length; i++) { if (this._bowLandJobs[i].ctx === ctx) return true; } return false; } /** * atk_type=4 弓:登记落地任务。伤害只在落地 dealDamage;标记保持到收招,动画结束不 drain。 * @param ctx 施法上下文 * @param target 锁定目标 * @returns 落地任务 id,交给飞箭 */ armBowProjectileLand(ctx: SkillPresentationContext, target: EntityId): number { const id = ++this._bowLandSeq; this._bowLandJobs.push({ id, ctx, target }); this._presBowLand = true; this._presBowLandId = id; return id; } /** * 弓箭落地:按 landId 结算对应任务;目标已死不结算。 * @param target 飞箭带回的锁定目标 * @param landId 落地任务 id */ readonly onBowProjectileLandBound = (target: EntityId, landId = 0): void => { const job = this._takeBowLandJob(landId, target); if (!job) return; // 落地只清任务 id;_presBowLand 保持到收招,避免近距箭先落地后动画结束再 drain 第二段 if (this._presBowLandId === job.id) this._presBowLandId = 0; const dest = target > 0 ? target : job.target; if (!job.ctx.isAlive || dest <= 0) return; if (!SkillPresentationEnv.isPresentTargetValid(dest)) return; job.ctx.setTargets([dest]); job.ctx.dealDamage(dest); }; /** * 取出并移除落地任务。 * @param landId 优先按 id;≤0 时按目标 FIFO * @param target 飞箭目标 */ private _takeBowLandJob(landId: number, target: EntityId): BowLandJob | null { if (landId > 0) { for (let i = 0; i < this._bowLandJobs.length; i++) { if (this._bowLandJobs[i].id !== landId) continue; const job = this._bowLandJobs[i]; this._bowLandJobs.splice(i, 1); return job; } } for (let i = 0; i < this._bowLandJobs.length; i++) { if (target > 0 && this._bowLandJobs[i].target !== target) continue; const job = this._bowLandJobs[i]; this._bowLandJobs.splice(i, 1); return job; } return null; } /** 打断时作废全部待落地弓箭结算 */ private _cancelBowLandJobs(): void { for (let i = 0; i < this._bowLandJobs.length; i++) { const ctx = this._bowLandJobs[i].ctx; if (ctx.isAlive) ctx.cancel('bow_land_aborted'); } this._bowLandJobs.length = 0; this._presBowLand = false; this._presBowLandId = 0; } /** 延迟后只结算 hit,等动画播完再 finish(避免普攻重播掐断当前动作) */ schedulePresentationCast(seq: number, delayLogicalSec: number): void { if (this._showEffectGate && !this._showEffectApplied) return; this._presCastAwaitSeq = seq; this.schedulePresentation(this._onPresCastHitTick, delayLogicalSec); } /** 多段 hit:按 hitIntervalSec 逐段推进 deferred 切片(atk_type=5 / 10 / 14 / 16) */ schedulePresentationMultiHit(seq: number, ctx: SkillPresentationContext, firstDelayLogicalSec: number): void { this._presMultiHitSeq = seq; this._presMultiHitCtx = ctx; this._presMultiHitIndex = 0; this._presSmashPool.length = 0; const atkHead = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0); if (atkHead === 10) { const locked = ctx.targets; for (let i = 0; i < locked.length; i++) this._presSmashPool.push(locked[i]); } if (atkHead !== 14 && atkHead !== 16 && atkHead !== 20 && this._showEffectGate && !this._showEffectApplied) { this._presMultiHitArmed = true; return; } this._presMultiHitArmed = false; this.schedulePresentation(this._onPresMultiHitTick, firstDelayLogicalSec); } /** * 先登记 12/13/17 弹道形态(须在 playCastAnim 之前),避免动画同步完播提前 finish。 * @param seq 播控序号 * @param shape 12 / 13 / 17 * @returns 是否登记成功 */ prepareBowShapeLaunch(seq: number, shape: number): boolean { if (seq !== this._presSeq || this._presFinished) return false; this._presCastAwaitSeq = seq; this._presBowShape = shape; this._scatterLaunched = false; this._presAnimWait = false; this._volleyLeft = 0; if (shape === 13) { const ctx = this._presCtx; if (!ctx || !this._setupVolley(ctx)) { this._presBowShape = 0; return false; } } return true; } /** * atk_type 12/13/17:出手时刻发射弹道,不 drain deferred。 * 若尚未 prepare,会先登记形态。 * @param seq 播控序号 * @param delayLogicalSec 首次出手延迟 * @param shape 12 / 13 / 17 */ scheduleBowShapeLaunch(seq: number, delayLogicalSec: number, shape: number): void { if (this._presBowShape !== shape || this._presCastAwaitSeq !== seq) { if (!this.prepareBowShapeLaunch(seq, shape)) { this.finishPresentationCast(seq, false); return; } } if (this._presFinished || seq !== this._presSeq) return; this.schedulePresentation(this._onBowShapeTick, delayLogicalSec); } /** * 登记 atk_type=19 冰锥发射参数(须在延迟出手前)。 * @param seq 播控序号 * @param count 最多发射数 * @param scale 伤害倍率 * @param buff1 atk_buff1 * @param buff2 atk_buff2 * @param targets 锁定目标 */ prepareIceConeLaunch(seq: number, count: number, scale: number, buff1: string, buff2: string, targets: readonly EntityId[]): void { if (seq !== this._presSeq || this._presFinished) return; this._iceConeAwaitSeq = seq; this._iceConeCount = count; this._iceConeScale = scale; this._iceConeBuff1 = buff1; this._iceConeBuff2 = buff2; this._iceConeTargets.length = 0; for (let i = 0; i < targets.length; i++) this._iceConeTargets.push(targets[i]); } /** * atk_type=19:对齐武器关键帧后发射冰锥。 * @param seq 播控序号 * @param delayLogicalSec 出手延迟 */ scheduleIceConeLaunch(seq: number, delayLogicalSec: number): void { if (this._iceConeAwaitSeq !== seq || this._presFinished || seq !== this._presSeq) return; this.schedulePresentation(this._onIceConeLaunchDue, delayLogicalSec); } /** 冰锥配表动画结束:收招并结束播控 */ readonly onIceConeAnimCompleteBound = (): void => { this.visualChannel.endHoldCast(); if (this._iceConeAwaitSeq > 0 && this.isPresentationCast(this._iceConeAwaitSeq)) { this.finishPresentationCast(this._iceConeAwaitSeq, false); } }; /** 结算当前 Router 播控 */ finishPresentationCast(seq: number, drainHits: boolean): void { if (this._presFinished || seq !== this._presSeq) return; this._presFinished = true; const ctx = this._presCtx; this._presCtx = null; this._presSmashPool.length = 0; this._iceConeAwaitSeq = 0; this._iceConeCount = 0; this._iceConeTargets.length = 0; if (ctx?.isAlive) { if (drainHits) { while (ctx.isAlive && ctx.hit()) { /* drain */ } } ctx.finish(); } if (this.isValid && !this.isPresentationBusy()) this.onIdle(); } /** * 连砸独立实例全部播完。未打完段则等命中;已打完则收招。 * @param seq 播控序号 */ markStaffSmashAnimsComplete(seq: number): void { if (this._presFinished || seq !== this._presSeq) return; this._presAnimComplete = true; if (this._presMultiHitCtx) return; this.finishPresentationCast(seq, false); } /** 动画播完:解锁施法,允许下一击 */ readonly onPresentationAnimCompleteBound = (): void => { if (this._presAnimSeq <= 0 || this._presAnimSeq !== this._presSeq) return; this._presAnimComplete = true; if (this._presMultiHitCtx) return; // 环形刀光等:等攻效播完再收招发射,字形动画播完不 finish if (this._presHoldUntilFxComplete) return; // 连发/散射:动画结束绝不提前 finish;只标记等待,射完再收招 if (this._presBowShape === 13) { this._presAnimWait = true; if (this._volleyLeft <= 0) this._finishBowShape(); return; } if ((this._presBowShape === 12 || this._presBowShape === 17) && !this._scatterLaunched) { this._presAnimWait = true; return; } if (this._showEffectGate && !this._showEffectApplied && !this._isMultiSegRangeAtk()) { this._presAnimWaitForShow = true; return; } const drain = this._presBowShape !== 12 && this._presBowShape !== 13 && !this._presBowLand; this._presBowShape = 0; this.finishPresentationCast(this._presSeq, drain); }; private _presCastAwaitSeq = 0; private _presMultiHitSeq = 0; private _presMultiHitIndex = 0; private _presMultiHitCtx: SkillPresentationContext | null = null; /** atk_type=14 当前脉冲用的表现上下文 */ private _presCirclePulseCtx: SkillPresentationContext | null = null; /** atk_type=14 当前脉冲伤害倍率 */ private _presCirclePulseScale = 0; /** 12 散射 / 13 连发 */ private _presBowShape = 0; /** 散射是否已出手 */ private _scatterLaunched = false; /** 连发剩余发数 */ private _volleyLeft = 0; /** 连发已出手序号(从 0) */ private _volleyIndex = 0; /** 连发间隔秒 */ private _volleyInterval = 0; /** 连发锁定目标 */ private _volleyTarget: EntityId = 0; /** 连发伤害倍率 */ private _volleyScale = 0; /** 连发瞄准点(目标死后仍用最后坐标射完) */ private readonly _volleyAim = new Vec3(); /** 是否已有有效瞄准点 */ private _volleyHasAim = false; /** 动画已结束、等待弹道出手完再 finish */ private _presAnimWait = false; /** 冰锥播控序号 */ private _iceConeAwaitSeq = 0; /** 冰锥最多发射数 */ private _iceConeCount = 0; /** 冰锥伤害倍率 */ private _iceConeScale = 0; /** 冰锥 atk_buff1 */ private _iceConeBuff1 = ''; /** 冰锥 atk_buff2 */ private _iceConeBuff2 = ''; /** 冰锥锁定目标 */ private readonly _iceConeTargets: EntityId[] = []; private _onPresCastHitTick = (): void => { if (this._presFinished || this._presCastAwaitSeq !== this._presSeq) return; const ctx = this._presCtx; if (!ctx?.isAlive) return; while (ctx.isAlive && ctx.hit()) { /* drain deferred DealDamage */ } }; private _onPresMultiHitTick = (): void => { const seq = this._presMultiHitSeq; const ctx = this._presMultiHitCtx; if (!ctx || !this.isPresentationCast(seq) || !ctx.isAlive) { this._finishMultiHitCast(seq, ctx); return; } const atkHead = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0); if (atkHead === 10 && !this._prepareSequentialSmash(ctx)) { this._finishMultiHitCast(seq, ctx, true); return; } if (atkHead === 14) this._pulseCasterCircleHit(ctx); else ctx.hit(); const hitCount = Math.max(1, Math.floor(Number(ctx.params.hitCount) || 1)); const next = this._presMultiHitIndex + 1; if (next >= hitCount) { this._finishMultiHitCast(seq, ctx); return; } this._presMultiHitIndex = next; const interval = Math.max(0, Number(ctx.params.hitIntervalSec) || 0); this.schedulePresentation(this._onPresMultiHitTick, interval); }; /** * atk_type=14:以施法者为圆心、射程为半径,本段重新索敌并结算。 * @param ctx 表现上下文 */ private _pulseCasterCircleHit(ctx: SkillPresentationContext): void { const session = SkillPresentationEnv.getBattleSession(); if (!session || !session.world.isAlive(ctx.caster)) return; const center = logicPosOf(session, ctx.caster); if (!center) { console.log('[Skill] atk_type=14 无释放点', ctx.params.skillId); return; } let radius = Math.max(0, Number(ctx.params.stageRangePx) || 0); if (!(radius > 0)) { radius = Math.max(0, session.attributes.getAttr(ctx.caster, AttrId.Range) || 0); } if (!(radius > 0)) { console.log('[Skill] atk_type=14 无有效射程', ctx.params.skillId); return; } const wan = Number(ctx.params.damageWan); if (!Number.isFinite(wan)) { console.log('[Skill] atk_type=14 伤害万分比无效', ctx.params.skillId, ctx.params.atkPara); return; } this._presCirclePulseCtx = ctx; this._presCirclePulseScale = wan / 10000; pulseHostileCircle(session, ctx.caster, center.x, center.y, radius, this._onCasterCircleHit); this._presCirclePulseCtx = null; } private readonly _onCasterCircleHit = (id: EntityId): void => { const ctx = this._presCirclePulseCtx; if (!ctx?.isAlive) return; ctx.dealDamage(id, { scale: this._presCirclePulseScale, damageType: 'caster_circle' }); }; /** * 多段命中结束:atk_type=14 先停循环武器动画再收招。 * @param seq 播控序号 * @param ctx 表现上下文(可空) * @param stopSmash 是否停连砸生成 */ private _finishMultiHitCast(seq: number, ctx: SkillPresentationContext | null, stopSmash = false): void { const atkHead = ctx ? Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0) : 0; this._presMultiHitCtx = null; if (stopSmash && ctx) getStaffSmashHost().stopSpawning(ctx.caster); if (atkHead === 14) this.visualChannel.endHoldCast(); if (!this.isPresentationCast(seq)) return; if (this._presAnimSeq === seq && !this._presAnimComplete) return; this.finishPresentationCast(seq, false); } /** * atk_type=10:本段只打一个存活单体。无存活目标返回 false。 * @param ctx 表现上下文 */ private _prepareSequentialSmash(ctx: SkillPresentationContext): boolean { const target = this._pickSmashTarget(this._presMultiHitIndex); if (!(target > 0)) return false; ctx.setTargets([target]); return true; } /** * 从开战快照按砸次取存活单体,不足则循环。 * @param smashIndex 第几次砸(从 0) */ private _pickSmashTarget(smashIndex: number): EntityId { const pool = this._presSmashPool; const n = pool.length; if (n <= 0) return 0; const start = smashIndex % n; for (let k = 0; k < n; k++) { const id = pool[(start + k) % n]; if (SkillPresentationEnv.isPresentTargetValid(id) && !SkillPresentationEnv.isSpawnProtected(id) && !SkillPresentationEnv.isBajiaoBlowRetreating(id) && !SkillPresentationEnv.isYanCaught(id)) { return id; } } return 0; } private readonly _onBowShapeTick = (): void => { if (this._presFinished || this._presCastAwaitSeq !== this._presSeq) return; const ctx = this._presCtx; if (!ctx?.isAlive) return; if (this._presBowShape === 12 || this._presBowShape === 17) { this._doScatter(ctx); return; } if (this._presBowShape === 13) this._doVolleyShot(ctx); }; /** * 解析连发参数(数量、间隔、伤害均来自 atk_para 配表)。 * @param ctx 表现上下文 * @returns 是否有效 */ private _setupVolley(ctx: SkillPresentationContext): boolean { const raw = String(ctx.params.atkPara ?? ''); const parts = raw.split(',').map((s) => Number(String(s).trim())); const count = Math.floor(parts[0] || 0); const interval = Number(parts[1]); const wan = Number(parts[2]); const target = ctx.targets[0] ?? 0; if (!(count > 0) || !Number.isFinite(interval) || !(interval >= 0) || !(wan > 0) || !(target > 0)) { console.log('[Skill] atk_type=13 参数无效', target); return false; } this._volleyLeft = count; this._volleyIndex = 0; this._volleyInterval = interval; this._volleyTarget = target; this._volleyScale = wan / 10000; this._volleyHasAim = false; const aim = SkillPresentationEnv.getTargetWorldPos(target); if (aim) { this._volleyAim.set(aim); this._volleyHasAim = true; } return true; } /** * 散射出手(12 穿透 / 17 不穿透)。 * @param ctx 表现上下文 */ private _doScatter(ctx: SkillPresentationContext): void { const parts = String(ctx.params.atkPara ?? '').split(',').map(Number); const count = Math.floor(parts[0] || 0); const stepDeg = parts[1]; const wan = parts[2]; const target = ctx.targets[0] ?? 0; const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0); const pierce = head !== 17; const rangePx = SkillPresentationEnv.getCasterAttr(ctx.caster, AttrId.Range); if (!(count > 0) || !(stepDeg > 0) || !(wan > 0) || !(rangePx > 0) || !(target > 0)) { console.log('[Skill] atk_type=12/17 参数无效', ctx.params.atkPara, rangePx, target); } else { this.visualChannel.launchScatterArrows(ctx.caster, ctx.abilityId, target, count, stepDeg, rangePx, wan / 10000, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? ''), pierce); } this._scatterLaunched = true; if (this._presAnimWait) this._finishBowShape(); } /** * 连发一箭;配表发数打满,目标死后仍飞向最后瞄准点,落地不结算。 * @param ctx 表现上下文 */ private _doVolleyShot(ctx: SkillPresentationContext): void { const liveAim = SkillPresentationEnv.getTargetWorldPos(this._volleyTarget); if (liveAim) { this._volleyAim.set(liveAim); this._volleyHasAim = true; } if (!this._volleyHasAim) { console.log('[Skill] atk_type=13 无瞄准点', this._volleyTarget); this._finishBowShape(); return; } const ok = this.visualChannel.launchVolleyShot(ctx.caster, ctx.abilityId, this._volleyTarget, this._volleyScale, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? ''), this._volleyIndex, this._volleyAim); if (!ok) { this._finishBowShape(); return; } this._volleyIndex += 1; this._volleyLeft -= 1; if (this._volleyLeft > 0) { this.schedulePresentation(this._onBowShapeTick, this._volleyInterval); return; } if (this._presAnimWait) this._finishBowShape(); } /** 结束 12/13 播控(不 drain);连发解除 holdCasting */ private _finishBowShape(): void { this._volleyLeft = 0; this._presBowShape = 0; this.visualChannel.endHoldCast(); this.finishPresentationCast(this._presSeq, false); } /** 冰锥关键帧出手 */ private readonly _onIceConeLaunchDue = (): void => { if (this._presFinished || this._iceConeAwaitSeq !== this._presSeq) return; const ctx = this._presCtx; if (!ctx?.isAlive) return; let launched = 0; for (let i = 0; i < this._iceConeTargets.length && launched < this._iceConeCount; i++) { const tid = this._iceConeTargets[i]; if (!(tid > 0)) continue; if (this.visualChannel.launchIceCone(ctx.caster, ctx.abilityId, tid, this._iceConeScale, this._iceConeBuff1, this._iceConeBuff2)) { launched += 1; } } if (launched <= 0) { console.log('[Skill] atk_type=19 无有效发射', ctx.params.skillId); } }; /** 打断 Router 播控 */ protected abortPresentationCast(): void { ++this._presSeq; this._presFinished = true; this._presAnimSeq = 0; this._presAnimComplete = false; this._presMultiHitCtx = null; this._presMultiHitArmed = false; this._presSmashPool.length = 0; this._presBowShape = 0; this._volleyLeft = 0; this._scatterLaunched = false; this._presAnimWait = false; this._presAnimWaitForShow = false; this._showEffectGate = false; this._showEffectApplied = true; this._iceConeAwaitSeq = 0; this._iceConeCount = 0; this._iceConeTargets.length = 0; this._cancelBowLandJobs(); const ctx = this._presCtx; this._presCtx = null; if (ctx != null) { getStaffSmashHost().abortCaster(ctx.caster); getGroundCircleHost().abortCaster(ctx.caster); this.visualChannel.endHoldCast(); } if (ctx?.isAlive) ctx.cancel('aborted'); } /** * 地图字形 Spine 皮肤(五行字等): * - 非激活 → 普通字 * - 激活 → 皮肤字1_3(1–3 级)/ 皮肤字1_4(4 级)/ 皮肤字1_5(5 级) */ applyComposeTextSkin(active: boolean, level: number): void { const anim = this.getComposeTextAnim(); if (!anim?.isValid || !anim.skeletonData) return; ensureSpineSkeletonDataRuntime(anim.skeletonData); const lv = Math.max(1, Math.floor(level)); const skinName = active ? resolveActiveComposeSkin(lv) : COMPOSE_TEXT_SKIN_IDLE; if (!applyComposeSpineSkin(anim, skinName)) return; const cur = anim.getCurrent?.(0); const rawName = cur?.animation?.name; const animName = (typeof rawName === 'string' && rawName.length > 0 && rawName !== '') ? rawName : '待机'; const loop = cur ? !!cur.loop : true; if (!BattleSpinePlayback.play(anim, animName, { loop, optional: true })) { BattleSpinePlayback.play(anim, '待机', { loop: true, optional: true }); } } /** @deprecated 请用 applyComposeTextSkin;保留空实现兼容旧调用 */ onSetSkinByQuality(_quality: number) { } /** * 伴生字纯表现:只播文本「攻击」再回待机,不碰 hit/finish。 * 由 SkillController 在主战字 onAttack/onSkill 时同步调用。 */ playPartnerAttackVisual(timeScale = 1): void { const anim = this.getComposeTextAnim(); if (!anim?.isValid) return; const combined = this.combinePresentationTimeScale(timeScale); this.playSpine(anim, '攻击', () => { if (anim?.isValid) { BattleSpinePlayback.play(anim, '待机', { loop: true, optional: true }); } }, combined, false); } /** 局内战斗倍速(与 BattleRuntime.TimeScale 一致) */ protected getBattleTimeScale(): number { return getBattleTimeScale(); } /** 攻速系数 × 战斗倍速,供 Spine / 位移表现 */ protected combinePresentationTimeScale(attackSpeedScale = 1): number { return Math.max(0.01, attackSpeedScale) * Math.max(0.01, this.getBattleTimeScale()); } /** * 战斗逻辑时间延迟(随 2 倍速等比缩短;走 BattleClock,不用 Cocos scheduleOnce 墙钟)。 * 参数顺序与 scheduleOnce 一致:(回调, 延迟秒)。 */ protected schedulePresentation(fn: () => void, delayLogicalSec: number): void { if (delayLogicalSec <= 0) { fn(); return; } schedulePresentationClock(delayLogicalSec, fn); } /** * 持续型 VFX:逻辑秒 hold 后淡出并销毁(禁止 tween.delay 墙钟)。 */ protected scheduleOpacityLifetime( node: Node, holdLogicalSec: number, fadeLogicalSec: number, onBeforeDestroy?: () => void, ): void { if (!node?.isValid) return; const uo = node.getComponent(UIOpacity) ?? node.addComponent(UIOpacity); Tween.stopAllByTarget(uo); Tween.stopAllByTarget(node); uo.opacity = 255; const holdSec = Math.max(0.05, holdLogicalSec); this.schedulePresentation(() => { if (!uo?.isValid || !node?.isValid) return; const fadeDur = fadeLogicalSec / Math.max(0.01, this.getBattleTimeScale()); tween(uo).to(fadeDur, { opacity: 0 }).start(); }, holdSec); this.schedulePresentation(() => { onBeforeDestroy?.(); if (node?.isValid) { Tween.stopAllByTarget(uo); node.destroy(); } }, holdSec + fadeLogicalSec); } /** flyLayer 内直线飞行(SkillTransformDriver,随战斗倍速) */ protected animateLinearFlyInLayer( flyLayer: Node, fly: Node, fromWorld: Readonly, toWorld: Readonly, durationLogicalSec: number, onComplete: () => void, angleOpts?: { fromAngle?: number; toAngle?: number }, ): void { const flyUt = flyLayer.getComponent(UITransform); const startLocal = flyUt ? flyUt.convertToNodeSpaceAR(new Vec3(fromWorld.x, fromWorld.y, fromWorld.z)) : fly.position.clone(); const endLocal = flyUt ? flyUt.convertToNodeSpaceAR(new Vec3(toWorld.x, toWorld.y, toWorld.z)) : startLocal.clone(); fly.setPosition(startLocal); const fromAngle = angleOpts?.fromAngle ?? fly.angle; const toAngle = angleOpts?.toAngle ?? fromAngle; SkillTransformDriver.cancelNode(fly); SkillTransformDriver.animate({ node: fly, duration: durationLogicalSec, fromPos: startLocal, toPos: endLocal, fromAngle, toAngle, easing: SkillTransformDriver.easeLinear, onComplete, }); } bindEntity(_entityId: EntityId): void { } /** 回池前拆下攻技节点,避免嵌进 heroTemplate 池 */ detachPooledFx(): void { } /** 移动/拆塔等打断未结束的表现(子类 override 时须调 super 或 abortPresentationCast) */ abortPresentation(): void { this.abortPresentationCast(); } /** Router 播控进行中 */ isPresentationBusy(): boolean { return this._presCtx != null && !this._presFinished; } /** 挂到 flyLayer 等、不在 this.node 下的表现节点;暂停时一并冻 Spine */ protected collectPresentationFxNodes(out: Node[]): void { const fx = this._showEffect.node; if (fx) out.push(fx); const caster = this._presCtx?.caster; if (caster != null) getStaffSmashHost().collectNodes(out, caster); } /** 局内 UI 暂停:冻结 Spine 与 schedule,恢复后从当前帧续播 */ freezePresentation(): void { if (!this.isValid) return; director.getScheduler().pauseTarget(this as unknown as ISchedulable); const frozen = new Map(); const roots: Node[] = [this.node]; this.collectPresentationFxNodes(roots); for (const root of roots) { if (!root?.isValid) continue; for (const sk of root.getComponentsInChildren(sp.Skeleton)) { if (!sk?.isValid || frozen.has(sk)) continue; frozen.set(sk, sk.timeScale); sk.timeScale = 0; } } this._frozenSpineTimeScales = frozen; } /** 恢复 freezePresentation 冻结的表现 */ unfreezePresentation(): void { if (!this.isValid) return; director.getScheduler().resumeTarget(this as unknown as ISchedulable); if (!this._frozenSpineTimeScales) return; for (const [sk, ts] of this._frozenSpineTimeScales) { if (sk?.isValid) sk.timeScale = ts; } this._frozenSpineTimeScales = null; } private _frozenSpineTimeScales: Map | null = null; /** 组合字文本 Spine:优先子类 textAnim,否则按常见节点路径查找 */ protected getComposeTextAnim(): sp.Skeleton | null { const owned = (this as Skill & { textAnim?: sp.Skeleton | null }).textAnim; if (owned?.isValid) return owned; return findComposeTextAnim(this.node); } /** * 播放 Spine;非 loop 时播完触发 callback。 * 动画不存在时立即 callback,避免表现层等不到 complete → presentation 永久 locked。 */ protected playSpine( anim: sp.Skeleton, name: string, callback: Function = null, timeScale = 1, loop = true, ): boolean { return playSkillSpine(anim, name, callback as (() => void) | null, timeScale, loop); } } /** * 播放 Spine(供 Skill 子类外的表现服务调用)。 * 非 loop 时播完触发 callback;动画不存在时立即 callback。 */ export function playSkillSpine( anim: sp.Skeleton, name: string, callback: (() => void) | null = null, timeScale = 1, loop = true, ): boolean { return BattleSpinePlayback.play(anim, name, { loop, timeScale, onComplete: callback ?? undefined, }); } function resolveActiveComposeSkin(level: number): string { return resolveActiveGlyphSkin(level, COMPOSE_TEXT_SKIN_QUALITY); } function applyComposeSpineSkin(anim: sp.Skeleton, preferred: string): boolean { const extras = preferred === COMPOSE_TEXT_SKIN_IDLE ? [] : [ ...COMPOSE_ACTIVE_SKIN_BUCKETS .map((b) => `皮肤字${COMPOSE_TEXT_SKIN_QUALITY}_${b}`) .filter((name) => name !== preferred), COMPOSE_TEXT_SKIN_IDLE, ]; return applyGlyphSpineSkin(anim, preferred, extras); } /** 查找字块文本 Spine(排除 atkParent/skillParent 下的武器) */ function findComposeTextAnim(root: Node | null): sp.Skeleton | null { if (!root?.isValid) return null; const paths = [ 'anim/text', 'text', 'anim/沙', 'anim/僧', 'anim/悟', 'anim/空', 'anim/八', 'anim/戒', 'anim/白', 'anim/龙', 'anim/马', 'anim/阎', 'anim/王', 'anim/哪', 'anim/吒', 'anim/嫦', 'anim/娥', 'anim/杨', 'anim/戬', 'anim/轮', 'anim/火', ]; for (const p of paths) { const sk = root.find(p)?.getComponent(sp.Skeleton); if (sk) return sk; } const anim = root.getChildByName('anim'); if (!anim?.isValid) return null; // 僧等:字 Spine 挂在 anim/skill,且无武器父节点 if (!anim.getChildByName('skillParent') && !anim.getChildByName('atkParent')) { return anim.getChildByName('skill')?.getComponent(sp.Skeleton) ?? null; } return null; }