import { _decorator, Node, sp } from 'cc'; import type { EntityId } from 'db://assets/mx/module/battle'; import { GBundle } from '../../game/ConfigRes'; import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { computeAimAngleDeg, SkillTransformDriver } from '../skill/SkillTransformDriver'; import { worldPointAtAimAngle } from '../skill/SkillParentAim'; import { PRES_HIT_AT_BASE } from '../presentation/SkillVisualChannel'; import { HeroSkin } from '../view/HeroSkin'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { HeroActor } from './HeroActor'; const { ccclass } = _decorator; /** battleHero/prefab/skill/英雄枪-攻击 */ const ATK_PREFAB = '英雄枪-攻击'; const ATK_PREFAB_PATH = 'prefab/skill/'; /** 攻效节点名 */ const NODE_ANIM = 'anim'; /** 攻效攻击段 */ const SPINE_ATK_FX = '攻击2'; /** 攻效定格后:1 秒内没有下一刀则回文字待机 */ const HOLD_WINDOW_SEC = 1; /** 攻击2 完成回调丢失后的收招宽限(逻辑秒) */ const ATK_COMPLETE_SLACK_SEC = 0.05; /** 预制体默认朝向(无目标时 fallback) */ const ATK_REST_ANGLE = 0; /** 瞄准 / 收招插值秒(与武器枪一致) */ const AIM_BLEND_SEC = 0.2; /** 连击瞄准更短,减少从末帧拧到新目标的等待 */ const COMBO_AIM_BLEND_SEC = 0.1; type QiangPhase = 'idle' | 'aiming' | 'attacking' | 'engaged' | 'returning'; /** * 枪:HeroActor 壳;普攻/技能共用「英雄枪-攻击」(播攻击2、瞄准目标)。 * 攻击2 播完定格末帧,开 1 秒连击窗(与弓一致);窗内无下一刀再插值回文字待机。 * 能否出手只由逻辑层 tryCast 决定。 */ @ccclass('Qiang') export class Qiang extends HeroActor { private _atkRoot: Node | null = null; private _atkAnim: sp.Skeleton | null = null; private _phase: QiangPhase = 'idle'; /** 连击窗计时序号 */ private _holdTimerSeq = 0; /** 攻击2 收招监视序号 */ private _atkWatchSeq = 0; /** 瞄准/收招插值世代 */ private _aimSeq = 0; /** 瞄准中的施法 ctx:beginPresentationCast 前若被 abort,必须 finish,否则逻辑锁永久卡住 */ private _aimingCtx: SkillPresentationContext | null = null; detachPooledFx(): void { this._disposeAtk(); super.detachPooledFx(); } onIdle(): void { // 攻击/连击定格中:禁止字形收招把 1 秒窗踩掉 if (this._phase === 'attacking' || this._phase === 'engaged') return; // idle / aiming / returning:都要能收干净(拖动 abort、插值被取消) this._forceIdleVisual(); } abortPresentation(): void { this._forceIdleVisual(); super.abortPresentation(); if (this.isValid && this.isActive) super.onIdle(); } isPresentationBusy(): boolean { return this._phase !== 'idle' || super.isPresentationBusy(); } /** 立刻收攻效并回字形待机(拖动打断 / 插值被取消时的兜底) */ private _forceIdleVisual(): void { this._cancelHoldTimer(); this._cancelAtkWatch(); this._cancelAimTween(); const aiming = this._aimingCtx; this._aimingCtx = null; this._phase = 'idle'; this._hideAtk(); // 瞄准中的 ctx 从未进 _presCtx,这里必须结算,否则 OnTick 永远等表现结束 → 不再攻击 if (aiming?.isAlive) aiming.finish(); this.abortPresentationCast(); if (this.isValid) super.onIdle(); } /** skillId Presenter:普攻与技能同一套攻效 */ presentNormAttack(ctx: SkillPresentationContext): void { this._presentQiangFx(ctx); } /** 主动技能与普攻共用预制体(预留入口) */ presentSkill(ctx: SkillPresentationContext): void { this._presentQiangFx(ctx); } private _presentQiangFx(ctx: SkillPresentationContext): void { if (!ctx.isAlive || !this.isValid || !this.node?.isValid) { ctx.finish(); return; } // 连击:攻效仍在末帧定格,禁止 setToSetupPose(会弹到 setup,像翻到枪字另一侧) const isCombo = this._phase === 'engaged'; this._cancelHoldTimer(); this._cancelAtkWatch(); this._cancelAimTween(); this._bindChannel(); this._ensureAtk(); this.syncAtkFxSkin(this._atkAnim); this._phase = 'aiming'; this._aimingCtx = ctx; this._showAtk(); if (!isCombo && this._atkAnim?.isValid) { // 首刀/非连击:瞄准期间停在 setup,避免预制体默认动画抢播 BattleSpinePlayback.resetTracks(this._atkAnim); this._atkAnim.setToSetupPose(); this._atkAnim.timeScale = 0; } const aspd = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(ctx.caster)); const aimSec = isCombo ? COMBO_AIM_BLEND_SEC : AIM_BLEND_SEC; this._aimAtkRoot(ctx.targets[0] ?? null, aimSec, aspd, () => { this._aimingCtx = null; if (!this.isValid || this._phase !== 'aiming') { // 瞄准被 abort/force-idle 抢走:ctx 已在 _forceIdleVisual 结算 return; } this._showAtk(); this._phase = 'attacking'; const seq = this.beginPresentationCast(ctx); const castAspd = this.visualChannel.playCastAnim({ caster: ctx.caster, animation: String(ctx.params.animation ?? ''), followWeapon: false, primaryTarget: ctx.targets[0] ?? null, onAnimComplete: this.onPresentationAnimCompleteBound, }); if (this._atkAnim?.isValid) { BattleSpinePlayback.resetTracks(this._atkAnim); this.playSpine(this._atkAnim, SPINE_ATK_FX, this._onAtkFxCompleteBound, castAspd, false); const dur = BattleSpinePlayback.getAnimDuration(this._atkAnim, SPINE_ATK_FX); if (dur != null) { this._scheduleAtkWatch(dur / Math.max(0.01, castAspd) + ATK_COMPLETE_SLACK_SEC); } } else { console.log('[Qiang] 攻效预制体未加载', ATK_PREFAB); this._enterHold(); } this.schedulePresentationCast(seq, PRES_HIT_AT_BASE / Math.max(0.01, castAspd)); }); } /** 攻击2 播完:定格末帧并开 1 秒连击窗(与弓一致;勿因字形已收招而跳过) */ private readonly _onAtkFxCompleteBound = (): void => { if (!this.isValid || this._phase !== 'attacking') return; this._cancelAtkWatch(); this._enterHold(); }; /** 攻击2 完成回调未到时强制走同一收招 */ private _onAtkWatchExpired(seq: number): void { if (seq !== this._atkWatchSeq) return; if (!this.isValid || this.isPresentationTeardown() || this._phase !== 'attacking') return; console.warn('[Qiang] 攻击2 完成回调未到,强制收招'); this._onAtkFxCompleteBound(); } /** 攻击2 停在末帧(攻效「攻击待机」时长为 0,再播会把后续攻击2 播不出来) */ private _enterHold(): void { this._phase = 'engaged'; if (this._atkAnim?.isValid) { this._showAtk(); BattleSpinePlayback.clearPendingComplete(this._atkAnim); this._atkAnim.timeScale = 0; } this._scheduleHoldReturn(); } /** 1 秒后收招;下一刀会作废本次序号 */ private _scheduleHoldReturn(): void { const seq = ++this._holdTimerSeq; this.schedulePresentation(() => { if (seq !== this._holdTimerSeq) return; if (!this.isValid || this.isPresentationTeardown()) return; this._onHoldWindowExpired(); }, HOLD_WINDOW_SEC); } /** * 攻击2 应在 delay 内结束;超时仍 attacking 则强制收招。 * @param delaySec 逻辑秒 */ private _scheduleAtkWatch(delaySec: number): void { const seq = ++this._atkWatchSeq; this.schedulePresentation(() => { if (seq !== this._atkWatchSeq) return; this._onAtkWatchExpired(seq); }, delaySec); } /** 收招窗到点:本窗内没有下一刀则回文字待机 */ private _onHoldWindowExpired(): void { if (!this.isValid || this.isPresentationTeardown() || this._phase !== 'engaged') return; this._restoreFullIdle(); } /** 藏攻效,字形回待机;攻效仍显示时先插值拧回休息角 */ private _restoreFullIdle(): void { this._cancelHoldTimer(); this._cancelAtkWatch(); if (this._phase === 'idle' || this._phase === 'returning') return; const atk = this._atkRoot; if (!atk?.isValid || !atk.active) { this._forceIdleVisual(); return; } this._phase = 'returning'; this._cancelAimTween(); const seq = ++this._aimSeq; SkillTransformDriver.animate({ node: atk, duration: AIM_BLEND_SEC, fromAngle: atk.angle, toAngle: ATK_REST_ANGLE, easing: SkillTransformDriver.easeSineInOut, onComplete: () => { if (!this.isValid || seq !== this._aimSeq) return; if (this._phase !== 'returning') return; this._phase = 'idle'; this._hideAtk(); super.onIdle(); }, }); // 拖动/节点失效时 SkillTransformDriver 丢弃动画且不回调 → 必须兜底,否则字形永久不显示 this.schedulePresentation(() => { if (!this.isValid || seq !== this._aimSeq) return; if (this._phase !== 'returning') return; this._forceIdleVisual(); }, AIM_BLEND_SEC + 0.05); } /** 作废未触发的连击窗计时 */ private _cancelHoldTimer(): void { this._holdTimerSeq++; } /** 作废未触发的攻击2 收招监视 */ private _cancelAtkWatch(): void { this._atkWatchSeq++; } private _cancelAimTween(): void { this._aimSeq++; if (this._atkRoot?.isValid) SkillTransformDriver.cancelNode(this._atkRoot); } /** * 旋转「英雄枪-攻击」根节点插值朝向主目标。 * @param primaryTarget 主目标 * @param aimSec 插值时长(秒 @ 攻速=1) * @param aspd 攻速系数 * @param onReady 转到瞄准角后起播攻击2 */ private _aimAtkRoot(primaryTarget: EntityId | null, aimSec: number, aspd: number, onReady: () => void): void { const atk = this._atkRoot; if (!atk?.isValid) { onReady(); return; } let targetWorld = primaryTarget != null ? SkillPresentationEnv.getTargetWorldPos(primaryTarget) : null; const pivotWorld = atk.worldPosition.clone(); if (!targetWorld) { targetWorld = worldPointAtAimAngle(pivotWorld, ATK_REST_ANGLE, 200); } const aim = computeAimAngleDeg(pivotWorld, targetWorld); if (aim == null) { onReady(); return; } this._cancelAimTween(); const seq = ++this._aimSeq; SkillTransformDriver.animate({ node: atk, duration: Math.max(0.01, aimSec / aspd), fromAngle: atk.angle, toAngle: aim, easing: SkillTransformDriver.easeSineInOut, onComplete: () => { if (seq !== this._aimSeq) return; onReady(); }, }); } private _ensureAtk(): void { if (!this._atkRoot?.isValid) { const atk = gg.res.getNode(ATK_PREFAB_PATH, ATK_PREFAB, GBundle.BattleHero); if (!atk?.isValid) { this._atkRoot = null; this._atkAnim = null; return; } this._atkRoot = atk; } this._attachAtk(); const animNode = this._atkRoot.getChildByName(NODE_ANIM); this._atkAnim = animNode?.getComponent(sp.Skeleton) ?? null; if (!this._atkAnim?.isValid) { console.log('[Qiang] 攻效缺少节点', NODE_ANIM); } } /** 挂在英雄展示根上,与「动画」同级 */ private _attachAtk(): void { const atk = this._atkRoot; if (!atk?.isValid) return; if (!this._heroSkin) { this._heroSkin = this.node.getComponent(HeroSkin); } const display = this._heroSkin?.displayNode; const parent = display?.isValid ? display : this.node; if (atk.parent !== parent) { atk.parent = parent; } atk.setPosition(0, 0, 0); } private _showAtk(): void { if (this._atkRoot?.isValid) this._atkRoot.active = true; } private _hideAtk(): void { if (this._atkAnim?.isValid) { BattleSpinePlayback.clearPendingComplete(this._atkAnim); this._atkAnim.timeScale = 1; } if (this._atkRoot?.isValid) { this._atkRoot.active = false; this._atkRoot.angle = ATK_REST_ANGLE; } } private _disposeAtk(): void { this._cancelHoldTimer(); this._cancelAtkWatch(); this._cancelAimTween(); const aiming = this._aimingCtx; this._aimingCtx = null; if (aiming?.isAlive) aiming.finish(); this._phase = 'idle'; this._hideAtk(); if (this._atkRoot?.isValid) { gg.res.putNode(this._atkRoot); } this._atkRoot = null; this._atkAnim = null; } }