// ============================================================ // 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; } }); })();