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.
213 lines
9.0 KiB
213 lines
9.0 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 读 skeletonJson:骨骼 rotate/translate/scale 轨道上 time>0 的最早关键帧。
|
|
* 用于法杖「挥砍」等出手特效骨(非槽位卸附件)。
|
|
* @param skeleton 骨架
|
|
* @param animName 动画名
|
|
* @param boneSubstr 骨骼名须包含的子串(如「挥砍」);空串匹配全部
|
|
* @returns 秒;无则 null
|
|
*/
|
|
static findFirstBoneKeyframeTime(skeleton: sp.Skeleton, animName: string, boneSubstr: 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);
|
|
type BoneTrack = { rotate?: Array<{ time?: number }>; translate?: Array<{ time?: number }>; scale?: Array<{ time?: number }> };
|
|
const raw = (sd.skeletonJson ?? sd._skeletonJson) as { animations?: Record<string, { bones?: Record<string, BoneTrack> }> } | null;
|
|
const bones = raw?.animations?.[animName]?.bones;
|
|
if (!bones) return null;
|
|
const needle = String(boneSubstr ?? '');
|
|
let best: number | null = null;
|
|
const names = Object.keys(bones);
|
|
for (let i = 0; i < names.length; i++) {
|
|
const bn = names[i];
|
|
if (needle && bn.indexOf(needle) < 0) continue;
|
|
const track = bones[bn];
|
|
if (!track) continue;
|
|
const lanes = [track.rotate, track.translate, track.scale];
|
|
for (let L = 0; L < lanes.length; L++) {
|
|
const keys = lanes[L];
|
|
if (!keys || keys.length === 0) continue;
|
|
for (let k = 0; k < keys.length; k++) {
|
|
const t = Number(keys[k]?.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;
|
|
}
|
|
}
|
|
|