import { Node, sp, Tween } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { GBundle } from '../../game/ConfigRes'; import { BATTLE_HERO_BUFF_PATH } from '../../battle/res/BattleHeroResCollector'; import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback'; import { schedulePresentation } from '../../battle/skill/BattlePresentationClock'; import type { CocosBattleMonsterPresenter } from '../../battle/view/CocosBattleMonsterPresenter'; /** 九霄神雷预制体名 */ const BASE_THUNDER_FX_PREFAB_NAME = '九霄神雷'; /** 九霄神雷 Spine 动画名 */ const BASE_THUNDER_FX_ANIM_NAME = '攻击'; /** 挂载特效后至造成伤害的延迟(逻辑秒) */ const BASE_THUNDER_DAMAGE_DELAY_SEC = 0.2; /** 相邻目标特效起播间隔(逻辑秒) */ const BASE_THUNDER_STAGGER_SEC = 0.2; /** 落雷相对怪物世界坐标 Y 偏移(像素) */ const BASE_THUNDER_WORLD_OFFSET_Y = 50; /** 单目标落雷表现条目 */ type ThunderFxEntry = { /** 特效节点 */ node: Node; /** Spine 组件 */ skel: sp.Skeleton; }; /** * 九霄神雷被动表现:在目标怪物上挂特效播「攻击」,挂载后固定延迟结算伤害。 */ export class BattleGameBaseThunderPresenter { private _monsterPresenter: CocosBattleMonsterPresenter | null = null; /** 落雷特效父节点(地图层;与怪物节点解耦,避免击杀回收打断动画) */ private _fxLayer: Node | null = null; /** 并行调度世代(unbind 时递增以作废待触发回调) */ private _scheduleGen = 0; /** 本轮进行中的特效节点 */ private readonly _activeFx: ThunderFxEntry[] = []; /** 绑定怪物表现层与地图特效层 */ bind(monsterPresenter: CocosBattleMonsterPresenter | null, fxLayer: Node | null | undefined): void { this.unbind(); this._monsterPresenter = monsterPresenter; this._fxLayer = fxLayer?.isValid ? fxLayer : null; } /** 注销并中断全部进行中的表现 */ unbind(): void { this._scheduleGen++; this._abortAllFx(); this._monsterPresenter = null; this._fxLayer = null; } /** * 逐目标播放落雷:各目标 Spine 错开 0.2s 起播,挂载后 0.2s 结算伤害(不等动画播完)。 * @param targets 按进路顺序排列的受击实体 * @param onDamage 单目标伤害回调 * @param onComplete 全部目标伤害已结算完成 */ playStrike( targets: readonly EntityId[], onDamage: (entityId: EntityId) => void, onComplete: () => void, ): void { if (targets.length <= 0) { onComplete(); return; } const scheduleGen = this._scheduleGen; let pending = targets.length; const finishOne = (): void => { pending--; if (pending <= 0) onComplete(); }; for (let i = 0; i < targets.length; i++) { const entityId = targets[i]; const fxDelay = i * BASE_THUNDER_STAGGER_SEC; schedulePresentation(fxDelay, () => { if (scheduleGen !== this._scheduleGen) { finishOne(); return; } this._spawnFxOnMonster(entityId); schedulePresentation(BASE_THUNDER_DAMAGE_DELAY_SEC, () => { if (scheduleGen !== this._scheduleGen) { finishOne(); return; } onDamage(entityId); finishOne(); }); }); } } /** 在怪物世界坐标挂九霄神雷并播攻击动画(挂地图层,击杀不带走特效) */ private _spawnFxOnMonster(entityId: EntityId): void { const presenter = this._monsterPresenter; const fxLayer = this._fxLayer; if (!presenter || !fxLayer?.isValid) { console.log('[BaseThunder] 落雷特效层未绑定'); return; } const hostNode = presenter.getEntityNodeMap().get(entityId); if (!hostNode?.isValid) { console.log('[BaseThunder] 怪物节点缺失,跳过特效', entityId); return; } let fxNode: Node | null = null; try { fxNode = gg.res.getNode(BATTLE_HERO_BUFF_PATH, BASE_THUNDER_FX_PREFAB_NAME, GBundle.BattleHero); } catch (e) { console.log('[BaseThunder] 九霄神雷预制体加载异常', String(e)); return; } if (!fxNode?.isValid) { console.log('[BaseThunder] 九霄神雷预制体缺失', BASE_THUNDER_FX_PREFAB_NAME); return; } const worldPos = hostNode.worldPosition.clone(); worldPos.y += BASE_THUNDER_WORLD_OFFSET_Y; fxNode.parent = fxLayer; fxNode.setWorldPosition(worldPos); fxNode.setSiblingIndex(fxLayer.children.length - 1); fxNode.active = true; const skel = fxNode.getComponent(sp.Skeleton) ?? fxNode.getComponentInChildren(sp.Skeleton); if (!skel?.isValid) { console.log('[BaseThunder] 九霄神雷缺少 sp.Skeleton'); this._releaseFx(fxNode, null); return; } this._activeFx.push({ node: fxNode, skel }); BattleSpinePlayback.play(skel, BASE_THUNDER_FX_ANIM_NAME, { loop: false, onComplete: this._onAnimComplete.bind(this, fxNode, skel), }); } /** 动画播完回收特效节点 */ private _onAnimComplete(fxNode: Node, skel: sp.Skeleton): void { if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel); this._releaseFx(fxNode, skel); } /** 回收单个特效节点 */ private _releaseFx(node: Node, skel: sp.Skeleton | null): void { if (!node?.isValid) return; for (let i = 0; i < this._activeFx.length; i++) { const entry = this._activeFx[i]; if (entry.node === node) { this._activeFx.splice(i, 1); break; } } if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel); Tween.stopAllByTarget(node); if (node['resKey']) gg.res.putNode(node); else node.destroy(); } /** 中断并清理全部表现 */ private _abortAllFx(): void { const pending = this._activeFx.slice(); this._activeFx.length = 0; for (let i = 0; i < pending.length; i++) { this._releaseFx(pending[i].node, pending[i].skel); } } }