import { _decorator, instantiate, Node, sp, UITransform, Vec3 } 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 { SkillTransformDriver, computeAimAngleDeg } from '../skill/SkillTransformDriver'; import { computeArrowBowAimDeg, ARROW_BOW_OFFSET_DEG, } from '../skill/SkillParentAim'; import { BattleAudio } from '../audio/BattleAudio'; import { HeroSkin } from '../view/HeroSkin'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { HeroActor } from './HeroActor'; const { ccclass } = _decorator; const NODE_GLYPH = '动画'; /** battleHero/prefab/skill/英雄弓-攻击 */ const ATK_PREFAB = '英雄弓-攻击'; const ATK_PREFAB_PATH = 'prefab/skill/'; const NODE_DRAW = '拉弓动画'; const NODE_PROJECTILE = '投掷物'; const SPINE_TEXT_IDLE = '待机'; /** 攻效拉弓(英雄弓-攻击 / 拉弓动画) */ const SPINE_SKILL_ATTACK2 = '攻击2'; const PROJECTILE_FLY_SPEED = 1280; /** 拉弓:从休息角平滑拧到瞄准角的时长(秒) */ const AIM_BLEND_SEC = 0.33; /** 连击时快速转向 */ const COMBO_AIM_BLEND_SEC = 0.1; /** 控制点落在拉弓射线上的路程比例;保证出弓切线 = 拉弓角 */ const LAUNCH_CTRL_T = 0.42; /** * 攻击2 拉满放箭时刻(秒 @ timeScale=1)。 * 弓/玄3 拉到最满 = 0.3667;槽位「弓/弓箭」0.4 才卸;整段动画 0.667 才结束。 */ const RELEASE_AT_BASE = 0.3667; /** 拉弓表现上限(秒),须低于 0.2 */ const BOW_ATTACK_MAX_SEC = 0.19; /** 攻击2 定格后无连击则回到文字待机 */ const COMBO_WINDOW_SEC = 1; type BowPhase = 'idle' | 'aiming' | 'engaged' | 'returning'; /** * 弓:HeroActor 壳;字形「弓」只播待机。 * 攻击隐藏字形,加载「英雄弓-攻击」播攻击2;拉满时克隆「投掷物」飞出,攻效定格无箭末帧。 */ @ccclass('Gong') export class Gong extends HeroActor { textAnim: sp.Skeleton = null; skillAnim: sp.Skeleton = null; protected _textNode: Node | null = null; protected _skillParent: Node | null = null; /** 拉弓 Spine(英雄弓-攻击/拉弓动画) */ protected _skillNode: Node | null = null; /** 投掷物模板,仅用于 instantiate */ protected _projectileTemplate: Node | null = null; private _attackSeq = 0; /** 攻击2 播放段代数;作废过期的 complete/hold */ private _drawSeq = 0; private _phase: BowPhase = 'idle'; private _comboTimerActive = false; /** 连击窗口定时代数;递增即作废未触发的 schedulePresentation 回调 */ private _comboTimerSeq = 0; private _pendingCtx: SkillPresentationContext | null = null; private _flyingClones: Node[] = []; detachPooledFx(): void { this._destroyFlyingClones(); this._disposeAtkPrefab(); this._textNode = null; super.detachPooledFx(); } protected onLoad(): void { this._heroSkin = this.node.getComponent(HeroSkin); this._bindNodes(); this.onIdle(); } protected onDisable(): void { this.teardownPresentation(); this.endPresentationTeardown(); } protected onDestroy(): void { this.teardownLifecycle(); } onUpdateActive(active: boolean): void { this._heroSkin = this.node.getComponent(HeroSkin) ?? this._heroSkin; this._bindNodes(); if (!active) { // 失活打断表现;禁止在连击中直接 _applyDefaultIdleState(会字形待机叠拉弓角) if (!this.isPresentationTeardown()) this.abortPresentation(); return; } if (!this.isPresentationBusy()) this.onIdle(); } protected resolveTextNode(): Node | null { const display = this._heroSkin?.displayNode; if (!display?.isValid) return null; return display.getChildByName(NODE_GLYPH) ?? display; } protected resolveTextAnim(): sp.Skeleton | null { return this._heroSkin?.anim ?? null; } protected onBindNodesComplete(): void { this.syncAtkFxSkin(this.skillAnim); this._setProjectileTemplateVisible(false); if (this._phase === 'idle') { if (this._skillParent?.isValid) this._skillParent.active = false; } else { // 换皮/重绑后字形节点可能已换新实例,必须按当前相位重刷显隐 this._showAtkHideGlyph(); } } protected _bindNodes(): void { if (!this.node?.isValid) return; this._heroSkin = this.node.getComponent(HeroSkin) ?? this._heroSkin; this._textNode = this.resolveTextNode(); this.textAnim = this.resolveTextAnim() ?? this._textNode?.getComponent(sp.Skeleton) ?? null; this._ensureAtkPrefab(); this._skillNode = this._skillParent?.getChildByName(NODE_DRAW) ?? this._findDescendant(this._skillParent, NODE_DRAW) ?? null; this._projectileTemplate = this._resolveProjectileTemplate(); this.skillAnim = this._skillNode?.getComponent(sp.Skeleton) ?? null; if (!this._skillParent?.isValid) { console.log('[Gong] 攻效预制体未加载', ATK_PREFAB); } else if (!this._skillNode?.isValid) { console.log('[Gong] 攻效预制体缺少节点', NODE_DRAW); } else if (!this._projectileTemplate?.isValid) { console.log('[Gong] 攻效预制体缺少节点', NODE_PROJECTILE); } this.onBindNodesComplete(); } /** 从 battleHero/prefab/skill 取「英雄弓-攻击」,挂到字形根下(与「动画」同级) */ private _ensureAtkPrefab(): void { if (!this._skillParent?.isValid) { const atk = gg.res.getNode(ATK_PREFAB_PATH, ATK_PREFAB, GBundle.BattleHero); if (!atk?.isValid) { this._skillParent = null; return; } this._skillParent = atk; } this._attachAtkToGlyph(); } /** 攻效挂在英雄展示根上,勿挂进「动画」以免藏字时连弓一起关 */ private _attachAtkToGlyph(): void { const atk = this._skillParent; if (!atk?.isValid) return; const display = this._heroSkin?.displayNode; const parent = display?.isValid ? display : this.node; if (atk.parent !== parent) { atk.parent = parent; } atk.setPosition(0, 0, 0); } /** 回池前拆下攻效,避免嵌进 heroTemplate 池 */ private _disposeAtkPrefab(): void { if (this._skillParent?.isValid) { gg.res.putNode(this._skillParent); } this._skillParent = null; this._skillNode = null; this._projectileTemplate = null; this.skillAnim = null; } /** 投掷物在「英雄弓-攻击」或「拉弓动画」下 */ private _resolveProjectileTemplate(): Node | null { if (!this._skillParent?.isValid) return null; const direct = this._skillParent.getChildByName(NODE_PROJECTILE); if (direct?.isValid) return direct; const underDraw = this._skillNode?.getChildByName(NODE_PROJECTILE) ?? this._findDescendant(this._skillParent, NODE_PROJECTILE); return underDraw?.isValid ? underDraw : null; } /** 深度优先按名查找 */ private _findDescendant(root: Node | null | undefined, name: string): Node | null { if (!root?.isValid) return null; const direct = root.getChildByName(name); if (direct?.isValid) return direct; const children = root.children; for (let i = 0; i < children.length; i++) { const found = this._findDescendant(children[i], name); if (found) return found; } return null; } /** 攻击根是否挂在字形「动画」子树内 */ private _skillNestedUnderText(): boolean { if (!this._textNode?.isValid || !this._skillParent?.isValid) return false; let p: Node | null = this._skillParent.parent; while (p?.isValid) { if (p === this._textNode) return true; p = p.parent; } return false; } onIdle(): void { if (this.isPresentationBusy()) return; this._applyDefaultIdleState(); } /** * 弓攻击全程藏字形;基类伴侣字同步会在 textAnim 上播「攻击→待机」, * 与拉弓叠影/误显字形待机,这里必须空实现。 */ playPartnerAttackVisual(_timeScale = 1): void { } /** 换皮后:先重绑节点,再按相位重刷(连击中仍只显示拉弓) */ onHeroGlyphSkinRefreshed(): void { this._bindNodes(); if (this.isPresentationBusy()) this._showAtkHideGlyph(); } /** 闭环终点:字形显示+待机+角度 0,英雄弓-攻击隐藏+角度 0 */ protected _applyDefaultIdleState(): void { this._drawSeq++; this._phase = 'idle'; this._comboTimerActive = false; this._cancelComboTimer(); this._cancelAimAnimations(); this._setProjectileTemplateVisible(false); if (this._textNode?.isValid) { this._textNode.angle = 0; this._textNode.active = true; } if (this.textAnim?.isValid) this.textAnim.enabled = true; if (this._skillParent?.isValid) { this._skillParent.angle = 0; this._skillParent.active = false; } if (this.textAnim) this.playSpine(this.textAnim, SPINE_TEXT_IDLE); this._restoreAtkTimeScale(); this._heroSkin?.playIdleAnim(); } /** skillId Presenter 入口 */ presentNormAttack(ctx: SkillPresentationContext): void { if (!this.isValid || !this.node?.isValid) { ctx.finish(); return; } this._bindNodes(); this._playAttackBow(ctx); } abortPresentation(): void { this.abortPresentationCast(); this._abortPendingPresentation(); } isPresentationBusy(): boolean { return this._phase !== 'idle'; } /** * 弓普攻:只打 ctx.targets[0](路径最前),箭飞到该目标落地才结算。 * 藏字形「弓」,显「英雄弓-攻击」插值瞄准后播攻击2;拉满发射投掷物。 * 1 秒内连击再播攻击2;窗内无下一箭则插值回休息角切文字待机。 */ private _playAttackBow(ctx: SkillPresentationContext) { if (!ctx.isAlive) { ctx.finish(); return; } const shotTargets = ctx.targets.filter(id => SkillPresentationEnv.isPresentTargetValid(id)); if (shotTargets.length === 0) { ctx.finish(); return; } const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer || !this._projectileTemplate?.isValid) { ctx.setTargets([shotTargets[0]]); ctx.hit(); ctx.finish(); return; } this._supersedePendingCast(ctx); const seq = ++this._attackSeq; this._pendingCtx = ctx; const aspdScale = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(ctx.caster)); const bowAnimTs = Math.max(aspdScale, RELEASE_AT_BASE / BOW_ATTACK_MAX_SEC); let unlocked = false; const unlockCast = () => { if (unlocked) return; unlocked = true; if (this._pendingCtx === ctx) this._pendingCtx = null; if (ctx.isAlive) ctx.finish(); }; let pendingFlights = 0; let visualDone = false; const finishVisual = () => { if (visualDone) return; visualDone = true; unlockCast(); }; const pivotWorld = this._skillParent?.isValid ? this._skillParent.worldPosition.clone() : SkillPresentationEnv.getCasterGridCenter(ctx.caster); const primaryId = shotTargets[0]; const primaryWorld = SkillPresentationEnv.getTargetWorldPos(primaryId); if (!pivotWorld || !primaryWorld) { console.log('[Gong] 普攻缺少施法点或目标点', ctx.caster, primaryId); unlockCast(); finishVisual(); this._beginReturnToRest(); return; } const bowAimDeg = computeArrowBowAimDeg( pivotWorld, primaryWorld, ARROW_BOW_OFFSET_DEG, ) ?? (computeAimAngleDeg(pivotWorld, primaryWorld) ?? 0); // 连击 = 仍处于拉弓交战态(含攻击2 播放中、已拉满定格)。 // 不可用 _comboTimerActive:拉满后、进 hold 前的窗口若按非连击会 setup 定格并打断攻击2。 const isCombo = this._phase === 'engaged'; this._cancelComboTimer(); if (!isCombo) { this._cancelAimAnimations(); } let released = false; const holdEngagedAfterEmptyCast = () => { if (seq !== this._attackSeq || !this.isValid || !ctx.isAlive) return; // 仅攻击2 已起播(engaged)才定格;aiming 阶段走正常瞄准 if (this._phase !== 'engaged') return; this._enterHold(this._drawSeq); }; const launchVolley = () => { if (released || seq !== this._attackSeq) { if (!released) { unlockCast(); finishVisual(); } return; } const destId = SkillPresentationEnv.isPresentTargetValid(primaryId) ? primaryId : 0; const destWorld = destId > 0 ? SkillPresentationEnv.getTargetWorldPos(destId) : null; if (!ctx.isAlive || destId <= 0 || !destWorld) { unlockCast(); finishVisual(); holdEngagedAfterEmptyCast(); return; } released = true; BattleAudio.playWeaponAttack(ctx.caster); this._setProjectileTemplateVisible(false); unlockCast(); pendingFlights = 1; const onOneFlightDone = () => { pendingFlights--; if (pendingFlights <= 0) finishVisual(); }; const launchDeg = computeArrowBowAimDeg( pivotWorld, destWorld, ARROW_BOW_OFFSET_DEG, ) ?? (computeAimAngleDeg(pivotWorld, destWorld) ?? bowAimDeg); const projectileClone = this._cloneProjectile(); if (!projectileClone) { onOneFlightDone(); return; } this._launchProjectile({ ctx, destId, projectileNode: projectileClone, flyLayer, fromWorld: projectileClone.worldPosition.clone(), toWorld: destWorld, launchDeg, onComplete: onOneFlightDone, }); }; const beginDrawAndRelease = () => { if (seq !== this._attackSeq || !this.isValid) return; this._phase = 'engaged'; this.syncAtkFxSkin(this.skillAnim); this._showAtkHideGlyph(); this._setProjectileTemplateVisible(false); const drawSeq = ++this._drawSeq; if (this.skillAnim) { this.skillAnim.clearTracks(); this.skillAnim.setToSetupPose(); this.playSpine( this.skillAnim, SPINE_SKILL_ATTACK2, () => this._onDrawAnimComplete(drawSeq), bowAnimTs, false, ); } else { console.log('[Gong] 攻效缺少拉弓动画', NODE_DRAW); this._enterHold(drawSeq); } const releaseDelay = RELEASE_AT_BASE / bowAnimTs; this.schedulePresentation(() => { if (seq !== this._attackSeq) { if (!released) { unlockCast(); finishVisual(); } return; } if (released) return; launchVolley(); }, releaseDelay); }; this._showAtkHideGlyph(); this._setProjectileTemplateVisible(false); if (!isCombo) { this._phase = 'aiming'; this._holdAtkSetupPose(); } const fromAngle = this._skillParent?.isValid ? this._skillParent.angle : (this._textNode?.angle ?? 0); const blendSec = isCombo ? COMBO_AIM_BLEND_SEC : AIM_BLEND_SEC; this._animateAimPair(fromAngle, bowAimDeg, blendSec, beginDrawAndRelease); } /** 攻击2 播完:定格无箭末帧,开连击窗(过期 complete 不得再进 hold) */ private _onDrawAnimComplete(drawSeq: number): void { if (!this.isValid || this._phase !== 'engaged') return; if (drawSeq !== this._drawSeq) return; this._enterHold(drawSeq); } /** 攻效定格末帧;1 秒内没有下一箭则回文字待机 */ private _enterHold(drawSeq: number): void { if (drawSeq !== this._drawSeq) return; this._phase = 'engaged'; this._comboTimerActive = true; this._showAtkHideGlyph(); this._setProjectileTemplateVisible(false); if (this.skillAnim?.isValid) { BattleSpinePlayback.clearPendingComplete(this.skillAnim); this.skillAnim.timeScale = 0; } this._scheduleComboReturn(); } /** 藏字形「弓」,显「英雄弓-攻击」(投掷物模板保持隐藏,发射时再克隆) */ private _showAtkHideGlyph(): void { this._setTextVisible(false); this._setSkillParentVisible(true); this._setProjectileTemplateVisible(false); } /** 瞄准前攻效停在 setup,避免预制体默认循环播攻击2 */ private _holdAtkSetupPose(): void { if (!this.skillAnim?.isValid) return; BattleSpinePlayback.resetTracks(this.skillAnim); this.skillAnim.setToSetupPose(); this.skillAnim.timeScale = 0; } /** 解除攻效定格 */ private _restoreAtkTimeScale(): void { if (!this.skillAnim?.isValid) return; BattleSpinePlayback.clearPendingComplete(this.skillAnim); this.skillAnim.timeScale = 1; } private _scheduleComboReturn(): void { const seq = ++this._comboTimerSeq; this.schedulePresentation(() => { if (seq !== this._comboTimerSeq) return; if (!this.isValid || this.isPresentationTeardown()) return; this._onComboWindowExpired(); }, COMBO_WINDOW_SEC); } /** 收招窗到点:本窗内没有下一箭则回文字待机 */ private _onComboWindowExpired = (): void => { if (!this.isValid || this.isPresentationTeardown() || this._phase !== 'engaged') return; this._comboTimerActive = false; this._beginReturnToRest(); }; /** 攻效定格拧回休息角后切文字待机 */ private _beginReturnToRest(): void { if (!this.isValid || this._phase === 'idle' || this._phase === 'returning') return; this._phase = 'returning'; this._comboTimerActive = false; this._cancelComboTimer(); this._setProjectileTemplateVisible(false); if (this.skillAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.skillAnim); this._animateAimToRest(AIM_BLEND_SEC, () => { if (!this.isValid) return; // 回正动画期间若已被新普攻抢占(aiming/engaged),不得再掀字形 if (this._phase !== 'returning') return; this._applyDefaultIdleState(); }); } /** * 攻效转到瞄准角(箭发射角与此一致)。 * @param fromDeg 当前角 * @param toDeg 目标角 * @param duration 秒 * @param onComplete 转到后 */ private _animateAimPair(fromDeg: number, toDeg: number, duration: number, onComplete: () => void): void { let pending = 0; let finished = false; const finishOne = () => { pending--; if (pending <= 0 && !finished) { finished = true; onComplete(); } }; for (const node of [this._skillParent]) { if (!node?.isValid) continue; pending++; SkillTransformDriver.cancelNode(node); node.angle = fromDeg; SkillTransformDriver.animate({ node, duration, fromAngle: fromDeg, toAngle: toDeg, easing: SkillTransformDriver.easeSineInOut, onComplete: finishOne, }); } if (pending === 0) onComplete(); } /** * 攻效转回休息角。 * @param duration 秒 * @param onComplete 转到后 */ private _animateAimToRest(duration: number, onComplete: () => void): void { let pending = 0; let finished = false; const finishOne = () => { pending--; if (pending <= 0 && !finished) { finished = true; onComplete(); } }; if (this._skillParent?.isValid) { pending++; const fromDeg = this._skillParent.angle; SkillTransformDriver.cancelNode(this._skillParent); SkillTransformDriver.animate({ node: this._skillParent, duration, fromAngle: fromDeg, toAngle: 0, easing: SkillTransformDriver.easeSineInOut, onComplete: finishOne, }); } if (pending === 0) onComplete(); } private _cancelAimAnimations(): void { if (this._textNode?.isValid) SkillTransformDriver.cancelNode(this._textNode); if (this._skillParent?.isValid) SkillTransformDriver.cancelNode(this._skillParent); } private _cancelComboTimer(): void { this._comboTimerSeq++; } private _setTextVisible(visible: boolean): void { if (!this._textNode?.isValid) return; // 攻击根挂在「动画」下时不可关节点,否则弓一并隐藏 if (this._skillNestedUnderText()) { this._textNode.active = true; if (this.textAnim?.isValid) this.textAnim.enabled = visible; return; } this._textNode.active = visible; if (visible && this.textAnim?.isValid) this.textAnim.enabled = true; } private _setSkillParentVisible(visible: boolean): void { if (this._skillParent?.isValid) { this._skillParent.active = visible; } } private _setProjectileTemplateVisible(visible: boolean): void { if (this._projectileTemplate?.isValid) { this._projectileTemplate.active = visible; } } private _cloneProjectile(): Node | null { if (!this._projectileTemplate?.isValid) return null; const clone = instantiate(this._projectileTemplate); clone.parent = this._projectileTemplate.parent; clone.setPosition(this._projectileTemplate.position); clone.setScale(this._projectileTemplate.scale); clone.angle = this._projectileTemplate.angle; clone.active = true; this._flyingClones.push(clone); return clone; } /** 表现箭抛物线飞向锁定目标,落地再结算该目标 */ private _launchProjectile(opts: { ctx: SkillPresentationContext; destId: EntityId; projectileNode: Node; flyLayer: Node; fromWorld: Vec3; toWorld: Vec3; launchDeg: number; onComplete: () => void; }): void { const { ctx, destId, projectileNode, flyLayer, fromWorld, toWorld, launchDeg, onComplete } = opts; if (!projectileNode.isValid) { onComplete(); return; } flyLayer.active = true; const flyUt = flyLayer.getComponent(UITransform); const worldScale = projectileNode.worldScale.clone(); projectileNode.parent = flyLayer; projectileNode.setWorldScale(worldScale); projectileNode.angle = launchDeg; if (flyUt) { projectileNode.setPosition(flyUt.convertToNodeSpaceAR(fromWorld)); } else { projectileNode.setWorldPosition(fromWorld); } const dist = Vec3.distance(fromWorld, toWorld); const duration = Math.max(0.12, dist / PROJECTILE_FLY_SPEED); const controlWorld = this._buildFlightControl(fromWorld, launchDeg, dist); SkillTransformDriver.animateParabola({ node: projectileNode, duration, fromWorld, toWorld, controlWorld, onComplete: () => this._onFlightComplete(ctx, destId, projectileNode, flyLayer, onComplete), }); } private _buildFlightControl( from: Readonly, launchDeg: number, dist: number, ): Vec3 { const rad = launchDeg * Math.PI / 180; const ctrlLen = Math.max(40, dist * LAUNCH_CTRL_T); return new Vec3( from.x + Math.cos(rad) * ctrlLen, from.y + Math.sin(rad) * ctrlLen, from.z, ); } private _onFlightComplete(ctx: SkillPresentationContext, destId: EntityId, projectileNode: Node, flyLayer: Node, onComplete: () => void): void { this._settleBowLand(ctx, destId); if (projectileNode.isValid) { SkillTransformDriver.cancelNode(projectileNode); projectileNode.destroy(); } if (this.isValid) { const cloneIdx = this._flyingClones.indexOf(projectileNode); if (cloneIdx >= 0) this._flyingClones.splice(cloneIdx, 1); } this._maybeHideFlyLayer(flyLayer); onComplete(); } /** 箭落地:只打锁定目标,已死不结算 */ private _settleBowLand(ctx: SkillPresentationContext, destId: EntityId): void { if (!ctx.isAlive || destId <= 0) return; if (!SkillPresentationEnv.isPresentTargetValid(destId)) return; ctx.setTargets([destId]); ctx.dealDamage(destId); } private _maybeHideFlyLayer(flyLayer: Node | null): void { if (flyLayer?.isValid && flyLayer.children.length === 0) { flyLayer.active = false; } } private _destroyFlyingClones(): void { const flyLayer = SkillPresentationEnv.flyLayer; for (const clone of this._flyingClones) { if (!clone?.isValid) continue; SkillTransformDriver.cancelNode(clone); clone.destroy(); } this._flyingClones.length = 0; this._maybeHideFlyLayer(flyLayer); } /** 新普攻覆盖未 finish 的旧 cast,避免 deferred 施法锁残留 */ private _supersedePendingCast(nextCtx: SkillPresentationContext): void { const prev = this._pendingCtx; if (prev && prev !== nextCtx && prev.isAlive) { prev.cancel('replaced'); } this.unscheduleAllCallbacks(); } private _abortPendingPresentation(): void { this._attackSeq++; this._drawSeq++; this._cancelComboTimer(); this.unscheduleAllCallbacks(); this._comboTimerActive = false; if (this.textAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.textAnim); if (this.skillAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.skillAnim); const ctx = this._pendingCtx; this._pendingCtx = null; this._cancelAimAnimations(); this._destroyFlyingClones(); if (ctx?.isAlive) ctx.cancel('aborted'); if (!this.isPresentationTeardown() && this.isValid) this._applyDefaultIdleState(); } }