import { sp } from 'cc'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { getBattleTimeScale, schedulePresentation } from '../skill/BattlePresentationClock'; /** 战斗 Spine 播放参数 */ export interface BattleSpinePlayOptions { /** 轨道索引 */ track?: number; /** 是否循环 */ loop?: boolean; /** 相对 timeScale(战斗倍速在内部叠加) */ timeScale?: number; /** 非循环播完回调 */ onComplete?: () => void; /** 资源确实没有该动画时静默失败(部分字形无「待机」) */ optional?: boolean; } /** * 战斗 Spine 统一播放:禁止 setCompleteListener 做结束回调; * 按动画时长 + 战斗逻辑时钟调度 onComplete(随局内倍速)。 */ export class BattleSpinePlayback { private static _pendingGen = new WeakMap(); /** userTimeScale × 战斗倍速(与 anim.timeScale 一致) */ static resolveCombinedTimeScale(userTimeScale = 1): number { return Math.max(0.01, userTimeScale) * Math.max(0.01, getBattleTimeScale()); } /** 读取 Spine 动画原始时长(秒,未除 userTimeScale)。0 时长姿态轨仍算存在。 */ static getAnimDuration(skeleton: sp.Skeleton, animName: string): number | null { if (!skeleton?.isValid || !animName) return null; const sd = skeleton.skeletonData; if (!sd?.isValid) return null; try { ensureSpineSkeletonDataRuntime(sd); const fromComp = typeof skeleton.findAnimation === 'function' ? skeleton.findAnimation(animName) : null; const runtime = sd.getRuntimeData?.(true) ?? sd.getRuntimeData?.() ?? null; const anim = fromComp ?? runtime?.findAnimation?.(animName) ?? null; if (!anim) return null; const duration = Number(anim.duration); if (!Number.isFinite(duration) || duration < 0) return 0; return duration; } catch { return null; } } /** * 战斗逻辑时钟下的播完延迟(秒)。 * anim.timeScale 已含战斗倍速时,delay = duration / userTimeScale。 */ static resolveLogicalCompleteDelay(skeleton: sp.Skeleton, animName: string, userTimeScale = 1): number | null { const duration = BattleSpinePlayback.getAnimDuration(skeleton, animName); if (duration == null) return null; return duration / Math.max(0.01, userTimeScale); } /** 取消 pending 完成回调(并清除 Spine complete 监听) */ static clearPendingComplete(skeleton: sp.Skeleton | null | undefined): void { if (!skeleton?.isValid) return; skeleton.setCompleteListener(null); const next = (BattleSpinePlayback._pendingGen.get(skeleton) ?? 0) + 1; BattleSpinePlayback._pendingGen.set(skeleton, next); } /** 缓存模式安全重置轨道 */ static resetTracks(skeleton: sp.Skeleton | null | undefined): void { if (!skeleton?.isValid) return; skeleton.setCompleteListener(null); const cached = typeof skeleton.isAnimationCached === 'function' && skeleton.isAnimationCached(); if (!cached) { skeleton.clearTracks(); } else { skeleton.setToSetupPose(); } } /** * 播放 Spine;非 loop 时用战斗时钟调度 onComplete。 * @returns 是否成功起播 */ static play(skeleton: sp.Skeleton, animName: string, options: BattleSpinePlayOptions = {}): boolean { const track = options.track ?? 0; const loop = options.loop ?? false; const userTimeScale = options.timeScale ?? 1; const onComplete = options.onComplete; if (!skeleton?.isValid) { onComplete?.(); return false; } const name = String(animName ?? '').trim(); if (!name) { console.log('[BattleSpinePlayback] 动画名为空'); onComplete?.(); return false; } const duration = BattleSpinePlayback.getAnimDuration(skeleton, name); if (duration == null) { if (!options.optional) { console.log('[BattleSpinePlayback] 动画不存在', name, skeleton.node?.name); } onComplete?.(); return false; } BattleSpinePlayback.clearPendingComplete(skeleton); skeleton.timeScale = BattleSpinePlayback.resolveCombinedTimeScale(userTimeScale); const entry = skeleton.setAnimation(track, name, loop); if (!entry) { onComplete?.(); return false; } if (!loop && onComplete) { const gen = BattleSpinePlayback._pendingGen.get(skeleton) ?? 0; const delay = duration / Math.max(0.01, userTimeScale); schedulePresentation(delay, () => { const current = skeleton ? (BattleSpinePlayback._pendingGen.get(skeleton) ?? 0) : -1; if (current !== gen) return; onComplete(); }); } return true; } }