Files
erqiwang_youle/client/js/gameabc-framework/spine/SpineMgr.js
T
2026-08-11 22:17:54 +08:00

656 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// SpineMgr —— Spine 动画管理器(独立文件)
// 集成到 gameabc Canvas 2D 绘制循环,零侵入 gamemain.js
// 在 index.html 中置于 gameabc.min.js 之后、gamemain.js 之前加载
// ============================================================
(function(){
// gameabc 的全局 logmessage 默认走 mode 0,是空操作(见 gameabc.min.js 的实现),
// 诊断日志必须直接走 console 才能在浏览器与真机 WebView 里看到。
function spineLog(msg) {
if (typeof console !== "undefined" && console.log) {
console.log(msg);
}
}
function spineWarn(msg) {
if (typeof console !== "undefined" && console.warn) {
console.warn(msg);
}
}
var SpineMgr = {
// --- 内部状态 ---
_entries: {}, // {id: EntryObject}
_assetManager: null,
_backend: null, // 渲染后端(SpineBackendWebGL / SpineBackendCanvas)
_lastTime: 0, // 上一帧时间戳(ms)
_inited: false,
_loadedPaths: {}, // 已请求加载的资源路径,避免重复请求
_pendingCmds: {}, // {id: [{method, args}]} 等待实例就绪后执行的命令队列
_scaleMap: {}, // {spineId: scale} 主流 SkeletonJson.scale 方案,_autoLoad 时注入
_skelDataCache: {}, // {"file.json_scale1": SkeletonData} 同文件共享解析结果,避免重复 readSkeletonData
// 默认资源根路径(可通过 init 覆盖)
_basePath: "assets/spine/",
// ----------------------------------------------------------
// init(basePath) 手动初始化(可选)
// 如不调用,load 时会自动以默认路径 "assets/spine/" 初始化
// ----------------------------------------------------------
init: function(basePath) {
if (!window.spine) {
spineWarn("[SpineMgr] spine 运行时未加载,请检查 index.html 的探测脚本");
return;
}
this._backend = this._selectBackend();
if (!this._backend) {
spineWarn("[SpineMgr] 无可用渲染后端");
return;
}
spineLog("[SpineMgr] 渲染后端: " + this._backend.name);
this._basePath = basePath || this._basePath;
// 挂接 context lost 恢复回调(仅 WebGL 后端有此机制)
var self = this;
if (this._backend.name === "webgl") {
this._backend._onContextRestored = function() {
self._rebuildAfterContextRestored();
};
}
// 后端初始化失败必须显式记录并让 _inited 保持 false,但不能让异常向上抛:
// init 由文件末尾的预加载块经 _ensureInit() 调用,异常会中断整个 IIFE,
// 导致后面的 gameenddraw 拦截根本不安装 —— Spine 从此永不绘制且无迹可循。
// _inited 保持 false 时,各 API 走既有的 if (!this._inited) return 静默路径。
try {
this._assetManager = this._backend.createAssetManager(this._basePath);
} catch (err) {
spineWarn("[SpineMgr] 后端初始化失败: " + err.message);
this._backend = null;
return;
}
this._lastTime = Date.now();
this._inited = true;
},
// (内部) 选定渲染后端。index.html 的探测脚本已决定加载哪套 spine 运行时,
// 此处按运行时实际提供的能力对应上后端实现。
_selectBackend: function() {
if (window.SpineBackendWebGL && SpineBackendWebGL.isAvailable()) {
return SpineBackendWebGL;
}
if (window.SpineBackendCanvas && SpineBackendCanvas.isAvailable()) {
return SpineBackendCanvas;
}
return null;
},
// (内部) 确保已初始化
_ensureInit: function() {
if (!this._inited) {
this.init(this._basePath);
}
},
// ----------------------------------------------------------
// (内部) _preloadAll()
// 按 gameabc_face.spineAssets 清单,把全部 Spine 资源注入当前 AssetManager 并发起加载。
// ⚠️ 必须整批注入,不能只补"当前已存在的 entry":
// 规避 file:// 下 XHR CORS 用的 rawDataUris 是 Downloader 实例私有的
// (AssetManagerBase 构造时默认 new Downloader()),换一个新 AssetManager
// 就等于换了一份空 rawDataUris。若只补已存在的 entry,context 恢复之后
// 首次触发的新特效(如天胡)会走真实 XHR 被 CORS 拦掉,_tryBuild 里 require()
// 抛异常 → ready 恒 false → 每帧重试且永不显示。
// 返回本次注册的资源组数。
// ----------------------------------------------------------
_preloadAll: function() {
var list = gameabc_face.spineAssets;
if (!list || list.length === 0) return 0;
var textData = gameabc_face.spineTextData || {};
for (var i = 0; i < list.length; i++) {
var name = list[i];
var jsonKey = name + ".json";
var atlasKey = name + ".atlas";
// 将嵌入文本数据注册为 rawDataURI,Spine Downloader 会优先从内存读取
if (textData[jsonKey]) {
this._assetManager.setRawDataURI(jsonKey, "data:," + textData[jsonKey]);
}
if (textData[atlasKey]) {
this._assetManager.setRawDataURI(atlasKey, "data:," + textData[atlasKey]);
}
this._assetManager.loadText(jsonKey);
this._loadedPaths[jsonKey] = true;
this._assetManager.loadTextureAtlas(atlasKey);
this._loadedPaths[atlasKey] = true;
}
spineLog("[SpineMgr] 预加载 " + list.length + " 组 Spine 资源" +
(Object.keys(textData).length > 0 ? "(使用嵌入数据)" : "(使用网络请求)"));
return list.length;
},
// (内部) 自动加载:根据 id 约定文件名 id.json / id.atlas
// 主流 SkeletonJson.scale 方案:从 _scaleMap 读取目标缩放,传入 load() 在解析时烘焙
_autoLoad: function(id) {
if (this._entries[id]) return;
var scale = this._scaleMap[id] || 1;
this.load(id, id + ".json", id + ".atlas", { scale: scale });
},
// (内部) 将命令加入等待队列
_queueCmd: function(id, method, args) {
if (!this._pendingCmds[id]) this._pendingCmds[id] = [];
this._pendingCmds[id].push({method: method, args: args});
},
// (内部) 实例就绪后执行队列中的命令
_flushCmds: function(id) {
var cmds = this._pendingCmds[id];
if (!cmds || cmds.length === 0) return;
delete this._pendingCmds[id];
for (var i = 0; i < cmds.length; i++) {
var cmd = cmds[i];
this[cmd.method].apply(this, [id].concat(cmd.args));
}
},
// ----------------------------------------------------------
// load(id, jsonFile, atlasFile, option)
// 加载一组 Spine 资源, id 为自定义标识
// option: {x, y, scale, skin, animation, loop, mixDuration}
// ----------------------------------------------------------
load: function(id, jsonFile, atlasFile, option) {
this._ensureInit();
if (!this._inited) return;
var opt = option || {};
// 只加载尚未请求过的资源(预加载过的会跳过)
if (!this._loadedPaths[jsonFile]) {
this._assetManager.loadText(jsonFile);
this._loadedPaths[jsonFile] = true;
}
if (!this._loadedPaths[atlasFile]) {
this._assetManager.loadTextureAtlas(atlasFile);
this._loadedPaths[atlasFile] = true;
}
this._entries[id] = {
jsonFile: jsonFile,
atlasFile: atlasFile,
skeleton: null,
state: null,
x: opt.x || 0,
y: opt.y || 0,
scale: opt.scale || 1,
skin: opt.skin || "default",
defAnim: opt.animation || null,
defLoop: opt.loop !== undefined ? opt.loop : true,
mixDur: opt.mixDuration || 0.2,
visible: true,
ready: false,
_hideOnComplete: false, // 播放完成后是否自动隐藏
_hideAfterCompletes: 0, // 剩余多少次 complete 后触发隐藏
_buildFailLogged: false // 构建失败日志只记一次(_tryBuild 每帧调用)
};
},
// ----------------------------------------------------------
// ensureEntry(id, jsonFile, atlasFile, option)
// 确保 id 对应的 entry 存在并达到 option 指定的状态。
// entry 生命周期归 SpineMgr 管,调用方无需掌握"不在就 load、在就 setScale"的内部规则,
// 也无需从 id 推导文件名(entryId 与资源文件名不一定同名,如 ting_0 -> ting.json)。
// ----------------------------------------------------------
ensureEntry: function(id, jsonFile, atlasFile, option) {
var e = this._entries[id];
if (!e) {
this.load(id, jsonFile, atlasFile, option);
return;
}
if (option && option.scale !== undefined) {
this.setScale(id, option.scale);
}
},
// ----------------------------------------------------------
// isReady() 后端是否已就绪、能真正播放 Spine
// 探测阶段的 canvas 兜底只覆盖"WebGL 不可用";若探测通过但 GL 初始化失败
// (叠加层 DOM 缺失 / 并发 context 耗尽 / shader 编译失败),此时 canvas 运行时
// 并未加载、回不去,调用方应据此走各自既有的帧动画降级路径,
// 而不是让 Spine 静默失效却以为播成功了。
// ----------------------------------------------------------
isReady: function() {
return !!this._inited;
},
// ----------------------------------------------------------
// (内部) 资源加载完成后实例化骨骼
// 同一 jsonFile+scale 的 SkeletonData 只解析一次,多个 entry 共享
// ----------------------------------------------------------
_buildEntry: function(entry) {
spineLog("[SpineMgr] _buildEntry: " + entry._id + " skelJson.scale=" + entry.scale);
var cacheKey = entry.jsonFile + "_s" + entry.scale;
var skelData = this._skelDataCache[cacheKey];
if (!skelData) {
var atlas = this._assetManager.require(entry.atlasFile);
var loader = new spine.AtlasAttachmentLoader(atlas);
var skelJson = new spine.SkeletonJson(loader);
skelJson.scale = entry.scale;
skelData = skelJson.readSkeletonData(
this._assetManager.require(entry.jsonFile)
);
this._skelDataCache[cacheKey] = skelData;
spineLog("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey);
} else {
spineLog("[SpineMgr] SkeletonData 命中缓存: " + cacheKey);
}
entry.skeleton = new spine.Skeleton(skelData);
// 设置皮肤
if (entry.skin && entry.skin !== "default") {
entry.skeleton.setSkinByName(entry.skin);
}
entry.skeleton.setToSetupPose();
// AnimationState
var stateData = new spine.AnimationStateData(skelData);
stateData.defaultMix = entry.mixDur;
entry.state = new spine.AnimationState(stateData);
// 绑定事件回调 —— 转发到 Spine_Event.js
entry.state.addListener({
complete: function(trackEntry) {
// 自动隐藏:playOnce / playQueue 设置的计数器
if (entry._hideOnComplete && !trackEntry.loop) {
entry._hideAfterCompletes--;
if (entry._hideAfterCompletes <= 0) {
entry.visible = false;
entry._hideOnComplete = false;
}
}
if (gameabc_face.spine_onComplete) {
gameabc_face.spine_onComplete(entry._id, trackEntry.animation.name, trackEntry.trackIndex);
}
},
event: function(trackEntry, event) {
if (gameabc_face.spine_onEvent) {
gameabc_face.spine_onEvent(entry._id, event.data.name, event.intValue, event.floatValue, event.stringValue);
}
}
});
// 默认动画
if (entry.defAnim) {
entry.state.setAnimation(0, entry.defAnim, entry.defLoop);
}
entry.ready = true;
},
// ----------------------------------------------------------
// (内部) 每帧调用:确保所有资源就绪
// ----------------------------------------------------------
_tryBuild: function() {
if (!this._assetManager.isLoadingComplete()) return false;
for (var id in this._entries) {
var e = this._entries[id];
if (!e.ready) {
e._id = id;
try {
this._buildEntry(e);
spineLog("[SpineMgr] " + id + " 构建完成");
this._flushCmds(id);
} catch(err) {
// 重试本身必须保留(资源可能只是还没到齐),但日志只记一次:
// 本方法每帧调用,持续失败时会以约 60 条/秒刷屏并淹没其他日志。
if (!e._buildFailLogged) {
e._buildFailLogged = true;
spineWarn("[SpineMgr] " + id + " 构建失败(仅记一次): " + err.message);
}
e.ready = false;
}
}
}
return true;
},
// ----------------------------------------------------------
// (内部) WebGL context 恢复后重建全部 GPU 资源
// context lost 会让 AssetManager 中缓存的 GLTexture 与 shader 一并作废,
// 必须重新加载资源并重建所有 entry,否则表现为"特效再也不显示"。
// ----------------------------------------------------------
_rebuildAfterContextRestored: function() {
spineLog("[SpineMgr] 重建 Spine 资源(context restored)");
// 记录当前 entry 的声明式状态,重建后按原样恢复
var saved = [];
for (var id in this._entries) {
var e = this._entries[id];
saved.push({
id: id,
jsonFile: e.jsonFile,
atlasFile: e.atlasFile,
x: e.x,
y: e.y,
scale: e.scale,
skin: e.skin,
mixDur: e.mixDur
});
}
// 丢弃全部缓存:SkeletonData 持有已作废的 GLTexture 引用,不能复用
this._entries = {};
this._pendingCmds = {};
this._skelDataCache = {};
this._loadedPaths = {};
// 先释放旧 AssetManager 再换新的:GLTexture 构造时会 context.addRestorable(this),
// 只有 dispose() 才 removeRestorable。本后端刻意复用同一个 ManagedWebGLRenderingContext
// (见 SpineBackendWebGL._ensureGL 注释),若不释放,每恢复一次就把 23 页图集的
// GLTexture 永久留在 restorables 里(解码位图约 137MB)—— 而 context lost 的诱因
// 正是内存压力,泄漏会把 lost/restore 变成越恢复越糟的死循环。
// context 已丢失时 gl.deleteTexture 等按规范是 no-op,安全;仍加 try/catch 防御,
// 释放失败不能挡住后面的重建。
if (this._assetManager) {
try {
this._assetManager.dispose();
} catch (errDispose) {
spineWarn("[SpineMgr] 释放旧 AssetManager 失败(已忽略): " + errDispose.message);
}
this._assetManager = null;
}
// 本方法在 webglcontextrestored 事件回调中执行,异常会逸出到事件循环,
// 故与 init 同样显式捕获:重建失败就停在不可用状态并留下日志。
try {
this._assetManager = this._backend.createAssetManager(this._basePath);
} catch (err) {
spineWarn("[SpineMgr] context 恢复后重建 AssetManager 失败: " + err.message);
this._inited = false;
return;
}
// 整批重注入 rawDataURI 并重新发起加载:新 AssetManager = 新 Downloader = 空 rawDataUris,
// 只补 saved 里的资源会让恢复后首次触发的新特效在 file:// 下被 CORS 拦死(见 _preloadAll 注释)。
// _preloadAll 会把全部资源写进 _loadedPaths,故下面按 saved 重建 entry 时
// load() 的 if (!this._loadedPaths[...]) 判断会正确跳过重复请求。
this._preloadAll();
for (var i = 0; i < saved.length; i++) {
var s = saved[i];
this.load(s.id, s.jsonFile, s.atlasFile, {
x: s.x, y: s.y, scale: s.scale, skin: s.skin, mixDuration: s.mixDur
});
// 重建后默认不可见:正在播的动画已随 context 丢失,
// 强行续播会出现半截动画,交由下一次业务触发重新播放。
this._entries[s.id].visible = false;
}
},
// ----------------------------------------------------------
// updateAndDraw(ctx) 每帧自动调用
// ctx: gameabc_face.dc (Canvas 2D Context)
// ----------------------------------------------------------
updateAndDraw: function(ctx) {
if (!this._inited) return;
this._tryBuild();
var now = Date.now();
var dt = (now - this._lastTime) / 1000;
this._lastTime = now;
if (dt <= 0 || dt > 0.5) dt = 1/30;
// 先推进动画状态:即使本帧无法绘制(后端未就绪 / context lost),
// 动画时间轴与 complete 事件也必须照常推进,否则回调链会卡死。
var visibleList = [];
for (var id in this._entries) {
var e = this._entries[id];
if (!e.ready || !e.visible) continue;
e.state.update(dt);
e.state.apply(e.skeleton);
// 骨骼位置由后端决定:Canvas 后端归零(用 ctx.translate 定位),
// WebGL 后端写入 x/-y(位移并入骨骼,使所有 entry 共用同一 MVP)。
// 必须在 updateWorldTransform 之前设置,否则不生效于本帧。
this._backend.placeEntry(e);
e.skeleton.updateWorldTransform(spine.Physics.update);
visibleList.push(e);
}
if (!this._backend.beginFrame(visibleList.length)) return;
// endFrame 必须配对执行:WebGL 后端的 PolygonBatcher 只在 endFrame 里 end(),
// 若 drawEntry 抛出而漏了 end(),batcher.isDrawing 会永久停在 true,
// 下一帧 begin() 即抛 "PolygonBatch is already drawing" 并冒到 gameenddraw —— Spine 永久黑屏。
try {
for (var i = 0; i < visibleList.length; i++) {
this._backend.drawEntry(visibleList[i]);
}
} finally {
this._backend.endFrame();
}
},
// ==========================================================
// 公开控制 API
// ==========================================================
setAnimation: function(id, animName, loop, track) {
var e = this._entries[id];
if (!e) {
this._autoLoad(id);
this._queueCmd(id, "setAnimation", [animName, loop, track]);
return null;
}
e.visible = true;
if (!e.ready) {
this._queueCmd(id, "setAnimation", [animName, loop, track]);
return null;
}
return e.state.setAnimation(track || 0, animName, loop !== false);
},
addAnimation: function(id, animName, loop, delay, track) {
var e = this._entries[id];
if (!e) {
this._autoLoad(id);
this._queueCmd(id, "addAnimation", [animName, loop, delay, track]);
return null;
}
if (!e.ready) {
this._queueCmd(id, "addAnimation", [animName, loop, delay, track]);
return null;
}
return e.state.addAnimation(track || 0, animName, loop !== false, delay || 0);
},
setPosition: function(id, x, y) {
var e = this._entries[id];
if (!e) {
this._autoLoad(id);
e = this._entries[id];
}
e.x = x; e.y = y;
},
setScale: function(id, sx, sy) {
// 主流方案:scale 通过 SkeletonJson.scale 在 load 时烘焙,运行时 setScale 更新 _scaleMap
// 并在 entry 未就绪时作为 option 重建,保持接口向后兼容
this._scaleMap[id] = sx;
var e = this._entries[id];
if (!e) {
this._autoLoad(id); // 会使用 _scaleMap[id] = sx
return;
}
if (e.scale === sx) return; // 已是目标 scale,无需重建
// 目标 scale 与已烘焙 scale 不同 → 清除旧 entry 并以新 scale 重建。
// 文件名必须取自 entry(load 时记录的权威值):entryId 与资源文件名不一定同名
// (如 entryId=ting_0 对应 ting.json),按 id 推导会去加载不存在的文件。
var savedX = e.x, savedY = e.y;
var savedJson = e.jsonFile, savedAtlas = e.atlasFile;
this.remove(id);
this.load(id, savedJson, savedAtlas, { scale: sx, x: savedX, y: savedY });
},
setFlip: function(id, flipX, flipY) {
var e = this._entries[id];
if (!e) {
this._autoLoad(id);
this._queueCmd(id, "setFlip", [flipX, flipY]);
return;
}
if (!e.skeleton) {
this._queueCmd(id, "setFlip", [flipX, flipY]);
return;
}
e.skeleton.scaleX = Math.abs(e.skeleton.scaleX) * (flipX ? -1 : 1);
e.skeleton.scaleY = Math.abs(e.skeleton.scaleY) * (flipY ? -1 : 1);
},
setVisible: function(id, visible) {
var e = this._entries[id];
if (!e) {
this._autoLoad(id);
e = this._entries[id];
}
e.visible = !!visible;
},
setSkin: function(id, skinName) {
var e = this._entries[id];
if (!e) {
this._autoLoad(id);
this._queueCmd(id, "setSkin", [skinName]);
return;
}
if (!e.ready) {
this._queueCmd(id, "setSkin", [skinName]);
return;
}
e.skeleton.setSkinByName(skinName);
e.skeleton.setSlotsToSetupPose();
},
getAnimations: function(id) {
var e = this._entries[id];
if (!e) { this._autoLoad(id); return []; }
if (!e.ready) return [];
var anims = e.skeleton.data.animations;
var names = [];
for (var i = 0; i < anims.length; i++) names.push(anims[i].name);
return names;
},
getSkins: function(id) {
var e = this._entries[id];
if (!e) { this._autoLoad(id); return []; }
if (!e.ready) return [];
var skins = e.skeleton.data.skins;
var names = [];
for (var i = 0; i < skins.length; i++) names.push(skins[i].name);
return names;
},
playOnce: function(id, animName, track) {
this.setVisible(id, true);
// 重播前重置骨骼到初始姿态,消除上次末帧残留导致的混合过渡(斜向位移)
var e = this._entries[id];
if (e && e.ready) {
e.state.clearTracks();
e.skeleton.setToSetupPose();
}
this.setAnimation(id, animName, false, track);
e = this._entries[id];
if (e) {
e._hideOnComplete = true;
e._hideAfterCompletes = 1;
}
},
playQueue: function(id, animList, hideOnComplete) {
if (!animList || animList.length === 0) return;
this.setVisible(id, true);
// 重播前重置骨骼到初始姿态,消除上次末帧残留导致的混合过渡
var e = this._entries[id];
if (e && e.ready) {
e.state.clearTracks();
e.skeleton.setToSetupPose();
}
this.setAnimation(id, animList[0], false, 0);
for (var i = 1; i < animList.length; i++) {
this.addAnimation(id, animList[i], false, 0, 0);
}
var e = this._entries[id];
if (e) {
e._hideOnComplete = hideOnComplete !== false;
e._hideAfterCompletes = animList.length;
}
},
stop: function(id) {
var e = this._entries[id];
if (!e) return;
e.visible = false;
e._hideOnComplete = false;
e._hideAfterCompletes = 0;
delete this._pendingCmds[id];
if (e.state) {
e.state.clearTracks();
}
if (e.skeleton) {
e.skeleton.setToSetupPose();
}
},
stopAll: function() {
for (var id in this._entries) {
this.stop(id);
}
},
remove: function(id) {
delete this._entries[id];
delete this._pendingCmds[id];
},
removeAll: function() {
this._entries = {};
this._pendingCmds = {};
this._skelDataCache = {};
}
};
// 挂载到 gameabc_face 上
gameabc_face.spineMgr = SpineMgr;
// ★ 自动预加载:读取 gameabc_face.spineAssets 清单,在引擎启动前预加载所有 Spine 资源
// 如果存在 spineTextData(嵌入文本数据),则通过 setRawDataURI 注入,
// 彻底避免 file:// 协议下 XHR CORS 拦截问题
if (gameabc_face.spineAssets && gameabc_face.spineAssets.length > 0) {
SpineMgr._ensureInit();
if (!SpineMgr._inited) {
// 后端未就绪:跳过预加载,但必须继续往下走完 gameenddraw 拦截的安装。
// 此处不能 return —— 预加载块在 IIFE 函数体内,return 会退出整个 IIFE,
// 反而导致拦截装不上,正是本守卫要避免的后果。
// 回归锁:codes/test/node/spineMgr.gameEndDrawGuard.test.js
spineWarn("[SpineMgr] 后端未就绪,跳过 Spine 资源预加载");
} else {
// 与 context 恢复后的重注入共用同一份实现,避免两处清单分叉
SpineMgr._preloadAll();
}
}
// ★ 用 defineProperty 拦截 gameenddraw 赋值
// 无论 gamemain.js 或其他代码如何定义 gameenddraw,
// Spine 渲染都会自动追加在用户逻辑之后
var _userEndDraw = gameabc_face.gameenddraw || null;
var _wrappedEndDraw = function(gameid, spid, times, timelong) {
if (typeof _userEndDraw === "function") {
_userEndDraw(gameid, spid, times, timelong);
}
var ctx = gameabc_face.dc;
if (ctx && SpineMgr._inited) {
SpineMgr.updateAndDraw(ctx);
}
};
Object.defineProperty(gameabc_face, "gameenddraw", {
configurable: true,
get: function() { return _wrappedEndDraw; },
set: function(fn) { _userEndDraw = fn; }
});
})();