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; } } /** * 读 skeletonJson:槽位附件被卸掉(name=null)的最早时刻,用于拉弓卸箭帧。 * @param skeleton 骨架 * @param animName 动画名 * @param slotSubstr 槽位名须包含的子串(如「箭矢」);空串匹配全部 * @returns 秒;无则 null */ static findFirstAttachmentClearTime(skeleton: sp.Skeleton, animName: string, slotSubstr: string): number | null { if (!skeleton?.isValid || !animName) return null; const sd = skeleton.skeletonData as sp.SkeletonData & { skeletonJson?: unknown; _skeletonJson?: unknown }; if (!sd?.isValid) return null; try { ensureSpineSkeletonDataRuntime(sd); const raw = (sd.skeletonJson ?? sd._skeletonJson) as { animations?: Record }> }> } | null; const anim = raw?.animations?.[animName]; const slots = anim?.slots; if (!slots) return null; const needle = String(slotSubstr ?? ''); let best: number | null = null; const names = Object.keys(slots); for (let i = 0; i < names.length; i++) { const sn = names[i]; if (needle && sn.indexOf(needle) < 0) continue; const keys = slots[sn]?.attachment; if (!keys || keys.length === 0) continue; for (let k = 0; k < keys.length; k++) { const frame = keys[k]; if (!frame || frame.name != null) continue; const t = Number(frame.time); if (!(t > 0)) continue; if (best == null || t < best) best = t; } } return best; } 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; } }