import { Node, sp, UITransform, Vec3 } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { GBundle, GPath } from '../../game/ConfigRes'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { PRES_HIT_AT_BASE } from '../presentation/SkillVisualChannel'; import { schedulePresentation } from './BattlePresentationClock'; import { SkillPresentationEnv } from './SkillPresentationEnv'; import type { SkillPresentationContext } from './SkillPresentationTypes'; /** 预兆动画播完后再停留的逻辑秒 */ const HOLD_AFTER_ANIM_SEC = 1; /** 播完再施加命中(杨戬召唤哮天犬) */ const APPLY_AFTER_ANIM = '召唤哮天犬'; /** 预兆时钟任务种类 */ enum ShowEffectJobKind { /** 延迟后生成 */ Delay = 0, /** 起播后施加效果 */ Apply = 1, /** 停留结束回池 */ Hold = 2, } /** Skill.showeffect 解析结果 */ export interface SkillShowEffectSpec { /** 技能释放后延迟(秒) */ delaySec: number; /** battleHero/prefab/skill 下的预制体名 */ prefabName: string; } /** 带代数的时钟回调,避免过期预约仍生效 */ class ShowEffectSeqJob { /** * @param owner 预兆播放器 * @param seq 预约代数 * @param kind 到期后执行的步骤 */ constructor(private readonly owner: SkillShowEffectPlayer, private readonly seq: number, private readonly kind: ShowEffectJobKind) {} /** 时钟到期 */ readonly fire = (): void => { if (this.kind === ShowEffectJobKind.Hold) this.owner.onHoldJob(this.seq); else if (this.kind === ShowEffectJobKind.Apply) this.owner.onApplyJob(this.seq); else this.owner.onDelayJob(this.seq); }; } /** * Skill.showeffect:释放后延迟播预兆,挂组合两字中间(英雄坐标)。 * 默认动画起播后 PRES_HIT_AT_BASE(0.33s)回调 onApply;`召唤哮天犬` 等播完再施加。动画继续播完再停 1 秒回池。 */ export class SkillShowEffectPlayer { private _seq = 0; private _caster: EntityId = 0; private _prefabName = ''; private _node: Node | null = null; private _animReady = false; /** 预兆起播后命中时刻施加一次 */ private _onApply: (() => void) | null = null; /** 本次预约是否已回调(含失败立即施加) */ private _applied = true; /** * 按本次施法的 showeffect 预约预兆。空配置返回 false,不打断已在播的预兆。 * @param ctx 表现上下文 * @param onApply 预兆起播后命中时刻回调;生成失败也会 warn 后调用,避免效果丢失 * @returns 是否挂了预兆(调用方应推迟命中与额外效果) */ arm(ctx: SkillPresentationContext, onApply: () => void): boolean { const spec = SkillShowEffectPlayer.parse(String(ctx.params.showeffect ?? ''), Number(ctx.params.skillId ?? ctx.baseAtkId) || 0); if (!spec) return false; this.release(); this._applied = false; this._onApply = onApply; this._caster = ctx.caster; this._prefabName = spec.prefabName; if (spec.delaySec <= 0) { this._spawn(); return true; } schedulePresentation(spec.delaySec, new ShowEffectSeqJob(this, this._seq, ShowEffectJobKind.Delay).fire); return true; } /** 当前预兆节点(暂停时需一并冻 Spine) */ get node(): Node | null { return this._node?.isValid ? this._node : null; } /** * 延迟到期:代数仍有效才生成。 * @param seq 预约代数 */ onDelayJob(seq: number): void { if (seq !== this._seq) return; this._spawn(); } /** * 起播后命中时刻:代数仍有效才施加。 * @param seq 预约代数 */ onApplyJob(seq: number): void { if (seq !== this._seq) return; this._fireApply(); } /** * 停留结束:代数仍有效才回池。 * @param seq 预约代数 */ onHoldJob(seq: number): void { if (seq !== this._seq) return; this._recycle(); } /** 取消预约并回池(不触发 onApply) */ release(): void { this._seq++; this._onApply = null; this._applied = true; this._recycle(); this._caster = 0; this._prefabName = ''; this._animReady = false; } /** * 解析 `延迟秒,预制体名`。空串视为未配置。 * @param raw Skill.showeffect * @param skillId 仅用于 warn */ static parse(raw: string, skillId: number): SkillShowEffectSpec | null { const s = String(raw ?? '').trim(); if (!s) return null; const split = s.indexOf(','); if (split <= 0 || split >= s.length - 1) { console.log('[SkillShowEffect] showeffect 须为「延迟秒,预制体名」', skillId); return null; } const delaySec = Number(s.slice(0, split).trim()); const prefabName = s.slice(split + 1).trim(); if (!Number.isFinite(delaySec) || delaySec < 0 || !prefabName) { console.log('[SkillShowEffect] showeffect 无效', skillId); return null; } return { delaySec, prefabName }; } private readonly _onAnimDone = (): void => { if (!this._animReady) return; this._fireApply(); schedulePresentation(HOLD_AFTER_ANIM_SEC, new ShowEffectSeqJob(this, this._seq, ShowEffectJobKind.Hold).fire); }; /** 施加一次;失败路径也走这里,避免效果被吞 */ private _fireApply(): void { if (this._applied) return; this._applied = true; const fn = this._onApply; this._onApply = null; fn?.(); } /** 生成失败:暴露问题并仍施加效果 */ private _failSpawn(reason: string, extra?: unknown): void { if (extra !== undefined) console.log('[SkillShowEffect]', reason, this._prefabName, extra); else console.log('[SkillShowEffect]', reason, this._prefabName); this._fireApply(); } /** 在组合中心生成预兆 */ private _spawn(): void { if (!this._prefabName) { this._failSpawn('预制体名为空'); return; } const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { this._failSpawn('flyLayer 缺失'); return; } const world = this._resolveWorldPos(this._caster); if (!world) { this._failSpawn('无组合中心', this._caster); return; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), this._prefabName, GBundle.BattleHero); } catch (err) { this._failSpawn('getNode 失败', err); return; } if (!node?.isValid) { this._failSpawn('预制体未加载'); return; } this._recycle(); flyLayer.active = true; node.active = true; node.layer = flyLayer.layer; node.parent = flyLayer; const flyUt = flyLayer.getComponent(UITransform); if (flyUt) node.setPosition(flyUt.convertToNodeSpaceAR(world)); else node.setWorldPosition(world); node.setSiblingIndex(flyLayer.children.length - 1); this._node = node; this._animReady = false; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { this._recycle(); this._failSpawn('缺少 Skeleton'); return; } const typed = sk as sp.Skeleton & { defaultAnimation?: string }; const name = String(typed.defaultAnimation || sk.animation || '').trim(); if (!name) { this._recycle(); this._failSpawn('无默认动画'); return; } const ok = BattleSpinePlayback.play(sk, name, { loop: false, onComplete: this._onAnimDone }); this._animReady = ok; if (!ok) { this._recycle(); this._failSpawn('默认动画起播失败', name); return; } if (this._prefabName === APPLY_AFTER_ANIM) return; schedulePresentation(PRES_HIT_AT_BASE, new ShowEffectSeqJob(this, this._seq, ShowEffectJobKind.Apply).fire); } /** * 组合占格中心(两字中间,与技能名飘字同一英雄坐标;单格即该格)。 * @param caster 施法者 */ private _resolveWorldPos(caster: EntityId): Vec3 | null { return SkillPresentationEnv.getCompositionWorldCenter(caster); } /** 回池当前节点 */ private _recycle(): void { const node = this._node; this._node = null; this._animReady = false; if (!node?.isValid) return; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); gg.res.putNode(node); } }