You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
AdLike2048/assets/script/battle/presentation/BattleSpinePlayback.ts

170 lines
6.9 KiB

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<sp.Skeleton, number>();
/** 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<string, { slots?: Record<string, { attachment?: Array<{ time?: number; name?: string | null }> }> }> } | 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;
}
}