删除hook,框架改动

This commit is contained in:
2026-08-19 08:14:48 +08:00
parent 96971f3072
commit 2ce936e7ec
51 changed files with 20002 additions and 4793 deletions
@@ -0,0 +1,69 @@
// ============================================================
// SpineBackendCanvas —— Canvas 2D 渲染后端
// 由 SpineMgr 在 WebGL 不可用时使用。逻辑迁自 SpineMgr.updateAndDraw,行为不变。
//
// 已知性能特征:开启 triangleRendering 后,spine-canvas 对每个三角形都执行一次
// clip() 加一次整张图集的 drawImage。mesh 密集的骨架(如 qixingshisanlan 2784
// 三角形)在 iOS WKWebView 上会因 clip 叠加非轴对齐变换失去 GPU 快路径而卡顿。
// 故本后端仅作回退,主路径见 SpineBackendWebGL。
// ============================================================
(function(){
var SpineBackendCanvas = {
name: "canvas",
_renderer: null,
_ctx: null,
// 是否可用:spine 运行时须提供 Canvas 2D 的 SkeletonRenderer
isAvailable: function() {
return !!(window.spine && window.spine.SkeletonRenderer && !window.spine.SceneRenderer);
},
createAssetManager: function(basePath) {
return new spine.AssetManager(basePath);
},
// Canvas 后端:骨骼位置归零,实际位置由 drawEntry 的 ctx.translate 控制。
// 由 SpineMgr 在 updateWorldTransform 之前调用 —— 之后再改 skeleton.x/y 不会重算
// 世界变换,本帧不生效。
placeEntry: function(entry) {
entry.skeleton.x = 0;
entry.skeleton.y = 0;
},
// 每帧开始:取 gameabc 的绘制上下文
beginFrame: function() {
var ctx = gameabc_face.dc;
if (!ctx) return false;
this._ctx = ctx;
if (!this._renderer) {
this._renderer = new spine.SkeletonRenderer(ctx);
this._renderer.triangleRendering = true; // ★ 启用三角形渲染以支持 Mesh 网格附件
}
// 确保 renderer 用的是当前 ctx (gameabc可能重建)
this._renderer.ctx = ctx;
return true;
},
drawEntry: function(entry) {
var ctx = this._ctx;
ctx.save();
ctx.translate(entry.x, entry.y);
ctx.scale(1, -1); // ★ Spine Y-up → Canvas Y-down(scale 已烘焙进 SkeletonJson.scale)
this._renderer.draw(entry.skeleton);
ctx.restore();
},
endFrame: function() {
this._ctx = null;
}
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SpineBackendCanvas;
}
window.SpineBackendCanvas = SpineBackendCanvas;
})();
@@ -0,0 +1,315 @@
// ============================================================
// SpineBackendWebGL —— WebGL 渲染后端(主路径)
//
// 在主画布之上叠加一张透明 canvas 绘制 Spine。相比 Canvas 2D 后端,mesh 密集的
// 骨架从"每三角形一次 clip + 整图 drawImage"变为一次 draw call。
//
// 坐标标定:不逆向混淆的 gameabc.min.js,而是每帧从 ctx.getTransform() 读取
// 权威变换矩阵,交由 SpineOverlayTransform 换算成 MVP。
//
// 真机实测确认的完整链路(gameabc_face.dc 属于一张离屏画布,并非 DOM 中可见的
// #canvas,两者是完全不同的两个对象):
// Spine 坐标 --ctx.getTransform()--> 离屏画布像素(gameabc_face.dc.canvas,
// 实测 backing 1280x720,不在 DOM,getBoundingClientRect() 恒为 0)
// --gameabc 内部 blit--> 显示画布 #canvas(DOM 中可见,实测 backing 1912x880)
// --CSS--> 屏幕(实测 rect 956x440)
//
// 因此本文件区分两个几何来源,各司其职、不可合并成一个:
// - NDC 分母(buildMvp 的 canvasW/canvasH):取 gameabc_face.dc.canvas 的
// width/height —— Spine 坐标是喂给 dc 的,分母必须与其同源。
// - 叠加层的定位/CSS 尺寸/drawing buffer:取显示画布 #canvas 的
// getBoundingClientRect() 与 width/height(backing) —— 叠加层要盖在屏幕上
// 可见的画面上,且 drawing buffer 尺寸须与主画布有效分辨率一致。
// 历史教训:曾把两者当成同一个对象 —— 用 #canvas 的尺寸做 NDC 分母则缩放错
// (X/Y 倍数不同、宽高比歪);后又改成全部从 dc.canvas 取,结果叠加层去对齐
// 离屏画布拿到的 rect(0,0,0,0),_syncSize 每帧提前 return,特效完全不显示。
// ============================================================
(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 SpineBackendWebGL = {
name: "webgl",
_canvas: null,
_gl: null,
_context: null, // spine.ManagedWebGLRenderingContext
_shader: null,
_batcher: null,
_renderer: null,
_mvp: null, // 复用的 16 元素数组,避免每帧分配
_loggedCanvasIdentity: false, // 权威画布诊断日志只打一次,避免每帧刷屏
_contextLost: false,
_hadVisible: false, // 上一帧是否有可见 entry(用于空闲帧跳过)
_assetManager: null,
_lossHandlersBound: false, // context lost 监听只绑一次
// 是否可用:spine 运行时须是 webgl 版(提供 SceneRenderer 等 GL 专有类)
isAvailable: function() {
return !!(window.spine && window.spine.SceneRenderer && window.SpineOverlayTransform);
},
// (内部) 惰性初始化 GL 资源。失败返回 false,SpineMgr 侧不再绘制。
_ensureGL: function() {
if (this._gl) return true;
this._canvas = document.getElementById("spine_gl");
if (!this._canvas) {
spineWarn("[SpineBackendWebGL] 缺少 spine_gl 元素");
return false;
}
var gl = null;
try {
// alpha:true 让叠加层透出下方主画布;premultipliedAlpha 交由 spine 按图集 pma 标记处理
var opts = { alpha: true, premultipliedAlpha: true, antialias: true, depth: false, stencil: false };
gl = this._canvas.getContext("webgl", opts) || this._canvas.getContext("experimental-webgl", opts);
} catch (err) {
spineWarn("[SpineBackendWebGL] getContext 异常: " + err.message);
return false;
}
if (!gl) {
spineWarn("[SpineBackendWebGL] 无法取得 WebGL context");
return false;
}
this._gl = gl;
this._bindContextLossHandlers();
// context 恢复时复用同一个 ManagedWebGLRenderingContext:
// canvas.getContext() 恢复后返回的是同一个 WebGLRenderingContext 对象,
// 重新 new 会让新纹理注册到新 managed context,而 _assetManager 仍挂在旧的上,
// 造成新旧分叉。shader / batcher / renderer 持有失效的 GPU 资源,则必须重建。
if (!this._context) {
this._context = new spine.ManagedWebGLRenderingContext(gl);
}
// 复用 _context 的代价:Shader / Mesh 构造时都会 context.addRestorable(this),
// 只有 dispose() 才 removeRestorable。每次 context 恢复都会重走本段,
// 旧对象若不释放就永久堆在 restorables 里(旧 shader 程序 + batcher 顶点/索引缓冲),
// 恢复次数越多占用越大。context 已丢失时 gl.deleteShader/deleteBuffer 按规范是 no-op,
// 安全;仍加 try/catch 防御,释放失败不能挡住重建。
// (SkeletonRenderer 不持有 GL 资源、无 dispose,不需处理。)
if (this._shader) {
try {
this._shader.dispose();
} catch (errShader) {
spineWarn("[SpineBackendWebGL] 释放旧 shader 失败(已忽略): " + errShader.message);
}
this._shader = null;
}
if (this._batcher) {
try {
this._batcher.dispose();
} catch (errBatcher) {
spineWarn("[SpineBackendWebGL] 释放旧 batcher 失败(已忽略): " + errBatcher.message);
}
this._batcher = null;
}
this._shader = spine.Shader.newTwoColoredTextured(this._context);
this._batcher = new spine.PolygonBatcher(this._context);
// 中和 blend 模式:spine-canvas 的 drawTriangles 只跟踪 slot.data.blendMode 却从不应用
// (该文件全文无 globalCompositeOperation),即迁移前所有槽都是按 source-over 画的。
// spine-webgl 的 SkeletonRenderer 会真的调 batcher.setBlendMode(),在"清成透明黑"的
// 叠加层上,screen 槽会输出非预乘颜色导致过亮/白边,multiply 槽会因 srcRgb=DST_COLOR
// 而 RGB 恒 0 变成纯黑剪影(本项目 start 有 9 个 screen 槽,而 game_start 每局必播)。
// additive 在叠加层上恰好等价于 source-over,无差异。
// 本次迁移只求性能、不引入视觉变化,故在此中和,保持 begin() 设定的 Normal 混合
// (SkeletonRenderer.premultipliedAlpha 默认 false,与 PolygonBatcher 构造时的
// SRC_ALPHA / ONE_MINUS_SRC_ALPHA 默认值一致,即"未中和时 Normal 槽会设成的那一组")。
// 是否放开真实 blend,留作将来一次独立的、经美术确认的改动。
// ⚠️ 必须紧跟每一次 new PolygonBatcher —— context 恢复会重建 batcher,写在别处即失效。
this._batcher.setBlendMode = function() {};
this._renderer = new spine.SkeletonRenderer(this._context);
this._mvp = new Array(16);
gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
return true;
},
// (内部) WebGL context lost / restored。
// iOS 在内存压力或切后台返回时会真实触发;不处理的表现是"特效突然全部消失且不再恢复"。
// ⚠️ 只能绑一次:context restored 后 _gl 被置 null,_ensureGL 会重新走一遍,
// 若不加此闩,每恢复一次就多挂一对监听器,重复触发且泄漏。
_bindContextLossHandlers: function() {
if (this._lossHandlersBound) return;
this._lossHandlersBound = true;
var self = this;
this._canvas.addEventListener("webglcontextlost", function(ev){
ev.preventDefault(); // 必须 preventDefault,否则不会触发 restored
self._contextLost = true;
spineWarn("[SpineBackendWebGL] context lost");
}, false);
this._canvas.addEventListener("webglcontextrestored", function(){
spineLog("[SpineBackendWebGL] context restored,重建 GL 资源与纹理");
self._contextLost = false;
self._gl = null; // 迫使 _ensureGL 重建 shader/batcher/renderer
self._hadVisible = false; // 复位空闲帧状态机,避免恢复后多发一次 clear
if (self._onContextRestored) self._onContextRestored();
}, false);
},
// 由 SpineMgr 注入的纹理重建回调(Task 7 接入)
_onContextRestored: null,
createAssetManager: function(basePath) {
if (!this._ensureGL()) {
// GL 不可用时不静默降级成"看似成功",直接抛出让 SpineMgr.init 失败,
// 由 index.html 的探测脚本保证不会走到这里。
throw new Error("[SpineBackendWebGL] GL 初始化失败,无法创建 AssetManager");
}
this._assetManager = new spine.AssetManager(this._context, basePath);
return this._assetManager;
},
// WebGL 后端:位移并入骨骼,使所有 entry 共用同一 MVP,可批量绘制。
// y 取负是因为 MVP 中含 Spine Y-up → 画布 Y-down 的翻转(见 SpineOverlayTransform 推导)。
placeEntry: function(entry) {
entry.skeleton.x = entry.x;
entry.skeleton.y = -entry.y;
},
beginFrame: function(visibleCount) {
if (this._contextLost) return false;
if (!this._ensureGL()) return false;
// 绘制缓冲画布(NDC 分母来源):Spine 坐标是喂给 dc 的,分母必须与其同源,
// 只能每帧从 dc 派生(createAssetManager 阶段 dc 可能还不存在,故不能在
// _ensureGL 里固定取一次)。数据源唯一,取不到即报错跳帧,禁止 || 兜底。
var ctx = gameabc_face.dc;
if (!ctx) return false;
var drawCanvas = ctx.canvas;
if (!drawCanvas) {
spineWarn("[SpineBackendWebGL] gameabc_face.dc.canvas 不存在(非标准 CanvasRenderingContext2D 实现),本帧跳过");
return false;
}
// 显示画布(叠加层定位/CSS 尺寸/drawing buffer 来源):index.html 中
// <div id="ifastgame"><canvas id="canvas"> 是 gameabc 平台约定的固定
// 显示画布契约,并非按 id 猜——但仍须校验存在性/在 DOM/rect 有效,
// 任一项不满足都不能静默继续绘制。
var displayCanvas = (typeof document !== "undefined" && document.getElementById) ? document.getElementById("canvas") : null;
if (!displayCanvas) {
spineWarn("[SpineBackendWebGL] 找不到显示画布 document.getElementById(\"canvas\"),本帧跳过");
return false;
}
if (!displayCanvas.parentNode) {
spineWarn("[SpineBackendWebGL] 显示画布 #canvas 不在 DOM 中,本帧跳过");
return false;
}
var displayRect = displayCanvas.getBoundingClientRect();
if (!displayRect || displayRect.width <= 0 || displayRect.height <= 0) {
spineWarn("[SpineBackendWebGL] 显示画布 #canvas 的 rect 尺寸无效,本帧跳过");
return false;
}
this._logCanvasIdentityOnce(drawCanvas, displayCanvas, displayRect);
// 空闲帧跳过:麻将大部分时间无特效在播,不能因叠加层存在就每帧空 clear 全屏。
// 仅在从"有"变"无"的那一帧 clear 一次收尾。
if (visibleCount === 0) {
if (!this._hadVisible) return false;
this._hadVisible = false;
this._syncSize(displayCanvas);
this._gl.clearColor(0, 0, 0, 0);
this._gl.clear(this._gl.COLOR_BUFFER_BIT);
return false;
}
this._hadVisible = true;
if (!this._syncSize(displayCanvas)) return false;
var m = this._readTransform(ctx);
SpineOverlayTransform.buildMvp(m, drawCanvas.width, drawCanvas.height, this._mvp);
var gl = this._gl;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
this._shader.bind();
this._shader.setUniformi(spine.Shader.SAMPLER, 0);
this._shader.setUniform4x4f(spine.Shader.MVP_MATRIX, this._mvp);
this._batcher.begin(this._shader);
return true;
},
// (内部) 读取 gameabc 绘制上下文的权威变换矩阵。
// getTransform() 自 iOS Safari 11 起支持,远低于本项目已验证的 WebView 基线;
// 取不到时退化为单位矩阵(此时叠加层按绘制缓冲画布 dc.canvas 的 backing 尺寸直接映射)。
_readTransform: function(ctx) {
if (typeof ctx.getTransform === "function") {
var t = ctx.getTransform();
return { a: t.a, b: t.b, c: t.c, d: t.d, e: t.e, f: t.f };
}
return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
},
// (内部) 一次性诊断日志:同时记录两个几何来源的身份与数值,便于后续核对
// ——绘制缓冲画布(dc.canvas,NDC 分母)与显示画布(#canvas,叠加层定位/尺寸)
// 是否为同一个对象、各自的 backing 与显示画布的 rect。
_logCanvasIdentityOnce: function(drawCanvas, displayCanvas, displayRect) {
if (this._loggedCanvasIdentity) return;
this._loggedCanvasIdentity = true;
var drawIdDesc = drawCanvas.id ? ("#" + drawCanvas.id) : "(无 id)";
spineLog("[SpineBackendWebGL] 绘制缓冲画布(gameabc_face.dc.canvas) = " + drawIdDesc +
",backing=" + drawCanvas.width + "x" + drawCanvas.height +
";显示画布(#canvas) backing=" + displayCanvas.width + "x" + displayCanvas.height +
",rect=(" + displayRect.left + "," + displayRect.top + "," + displayRect.width + "," + displayRect.height + ")" +
",两者是否同一对象=" + (drawCanvas === displayCanvas));
},
// (内部) 每帧把叠加层的位置与尺寸镜像到显示画布(#canvas)的实际显示区域。
// drawing buffer 尺寸直接取显示画布的 backing(width/height),不再用
// rect × devicePixelRatio 计算——与主画布有效分辨率一致即可,用 DPR 会开出
// 比主画布 backing 还大的 buffer,白白多占显存。
_syncSize: function(displayCanvas) {
var rect = displayCanvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
var host = this._canvas.parentNode;
host.style.left = rect.left + "px";
host.style.top = rect.top + "px";
this._canvas.style.width = rect.width + "px";
this._canvas.style.height = rect.height + "px";
var bw = displayCanvas.width;
var bh = displayCanvas.height;
if (bw <= 0 || bh <= 0) return false;
if (this._canvas.width !== bw || this._canvas.height !== bh) {
this._canvas.width = bw;
this._canvas.height = bh;
}
this._gl.viewport(0, 0, bw, bh);
return true;
},
drawEntry: function(entry) {
this._renderer.draw(this._batcher, entry.skeleton);
},
endFrame: function() {
this._batcher.end();
this._shader.unbind();
}
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SpineBackendWebGL;
}
window.SpineBackendWebGL = SpineBackendWebGL;
})();
+233 -45
View File
@@ -5,12 +5,25 @@
// ============================================================
(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,
_renderer: null, // spine.SkeletonRenderer (Canvas 2D)
_backend: null, // 渲染后端(SpineBackendWebGL / SpineBackendCanvas)
_lastTime: 0, // 上一帧时间戳(ms)
_inited: false,
_loadedPaths: {}, // 已请求加载的资源路径,避免重复请求
@@ -27,15 +40,50 @@ var SpineMgr = {
// ----------------------------------------------------------
init: function(basePath) {
if (!window.spine) {
logmessage("[SpineMgr] spine-canvas.js 未加载,请检查引用");
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;
this._assetManager = new spine.AssetManager(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) {
@@ -43,6 +91,42 @@ var SpineMgr = {
}
},
// ----------------------------------------------------------
// (内部) _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) {
@@ -101,16 +185,45 @@ var SpineMgr = {
visible: true,
ready: false,
_hideOnComplete: false, // 播放完成后是否自动隐藏
_hideAfterCompletes: 0 // 剩余多少次 complete 后触发隐藏
_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) {
logmessage("[SpineMgr] _buildEntry: " + entry._id + " skelJson.scale=" + entry.scale);
spineLog("[SpineMgr] _buildEntry: " + entry._id + " skelJson.scale=" + entry.scale);
var cacheKey = entry.jsonFile + "_s" + entry.scale;
var skelData = this._skelDataCache[cacheKey];
if (!skelData) {
@@ -122,9 +235,9 @@ var SpineMgr = {
this._assetManager.require(entry.jsonFile)
);
this._skelDataCache[cacheKey] = skelData;
logmessage("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey);
spineLog("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey);
} else {
logmessage("[SpineMgr] SkeletonData 命中缓存: " + cacheKey);
spineLog("[SpineMgr] SkeletonData 命中缓存: " + cacheKey);
}
entry.skeleton = new spine.Skeleton(skelData);
@@ -179,10 +292,15 @@ var SpineMgr = {
e._id = id;
try {
this._buildEntry(e);
logmessage("[SpineMgr] " + id + " 构建完成");
spineLog("[SpineMgr] " + id + " 构建完成");
this._flushCmds(id);
} catch(err) {
logmessage("[SpineMgr] " + id + " 构建失败: " + err.message);
// 重试本身必须保留(资源可能只是还没到齐),但日志只记一次:
// 本方法每帧调用,持续失败时会以约 60 条/秒刷屏并淹没其他日志。
if (!e._buildFailLogged) {
e._buildFailLogged = true;
spineWarn("[SpineMgr] " + id + " 构建失败(仅记一次): " + err.message);
}
e.ready = false;
}
}
@@ -190,6 +308,79 @@ var SpineMgr = {
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)
@@ -203,29 +394,33 @@ var SpineMgr = {
this._lastTime = now;
if (dt <= 0 || dt > 0.5) dt = 1/30;
if (!this._renderer) {
this._renderer = new spine.SkeletonRenderer(ctx);
this._renderer.triangleRendering = true; // ★ 启用三角形渲染以支持 Mesh 网格附件
}
// 确保 renderer 用的是当前 ctx (gameabc可能重建)
this._renderer.ctx = ctx;
// 先推进动画状态:即使本帧无法绘制(后端未就绪 / 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);
// 骨骼位置归零,由 ctx.translate 控制实际位置
e.skeleton.x = 0;
e.skeleton.y = 0;
// 骨骼位置由后端决定:Canvas 后端归零(用 ctx.translate 定位),
// WebGL 后端写入 x/-y(位移并入骨骼,使所有 entry 共用同一 MVP)。
// 必须在 updateWorldTransform 之前设置,否则不生效于本帧。
this._backend.placeEntry(e);
e.skeleton.updateWorldTransform(spine.Physics.update);
visibleList.push(e);
}
ctx.save();
ctx.translate(e.x, e.y);
ctx.scale(1, -1); // ★ Spine Y-up → Canvas Y-down(scale 已烘焙进 SkeletonJson.scale)
this._renderer.draw(e.skeleton);
ctx.restore();
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();
}
},
@@ -281,10 +476,13 @@ var SpineMgr = {
return;
}
if (e.scale === sx) return; // 已是目标 scale,无需重建
// 目标 scale 与已烘焙 scale 不同 → 清除旧 entry,下次 _autoLoad 将以新 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, id + ".json", id + ".atlas", { scale: sx, x: savedX, y: savedY });
this.load(id, savedJson, savedAtlas, { scale: sx, x: savedX, y: savedY });
},
setFlip: function(id, flipX, flipY) {
@@ -423,26 +621,16 @@ gameabc_face.spineMgr = SpineMgr;
// 彻底避免 file:// 协议下 XHR CORS 拦截问题
if (gameabc_face.spineAssets && gameabc_face.spineAssets.length > 0) {
SpineMgr._ensureInit();
var list = gameabc_face.spineAssets;
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]) {
SpineMgr._assetManager.setRawDataURI(jsonKey, "data:," + textData[jsonKey]);
}
if (textData[atlasKey]) {
SpineMgr._assetManager.setRawDataURI(atlasKey, "data:," + textData[atlasKey]);
}
SpineMgr._assetManager.loadText(jsonKey);
SpineMgr._loadedPaths[jsonKey] = true;
SpineMgr._assetManager.loadTextureAtlas(atlasKey);
SpineMgr._loadedPaths[atlasKey] = true;
if (!SpineMgr._inited) {
// 后端未就绪:跳过预加载,但必须继续往下走完 gameenddraw 拦截的安装。
// 此处不能 return —— 预加载块在 IIFE 函数体内,return 会退出整个 IIFE,
// 反而导致拦截装不上,正是本守卫要避免的后果。
// 回归锁:codes/test/node/spineMgr.gameEndDrawGuard.test.js
spineWarn("[SpineMgr] 后端未就绪,跳过 Spine 资源预加载");
} else {
// 与 context 恢复后的重注入共用同一份实现,避免两处清单分叉
SpineMgr._preloadAll();
}
logmessage("[SpineMgr] 预加载 " + list.length + " 组 Spine 资源" +
(Object.keys(textData).length > 0 ? "(使用嵌入数据)" : "(使用网络请求)"));
}
// ★ 用 defineProperty 拦截 gameenddraw 赋值
@@ -0,0 +1,88 @@
// ============================================================
// SpineOverlayTransform —— WebGL 叠加层坐标标定(纯函数)
//
// 职责:把 gameabc 主画布的权威变换矩阵换算成 WebGL 的 MVP 矩阵。
// 无副作用、无 DOM 依赖,可独立单测。
//
// 为什么不逆向 gameabc.min.js:那是混淆代码(变量名全打乱),读不出它内部如何
// 映射坐标。而 Spine 绘制发生在 gameenddraw(gameabc 绘制流程末尾),此刻 ctx
// 上已带着 gameabc 设好的全局变换 —— 直接向 ctx 要矩阵即可,权威、且 gameabc
// 后续改缩放/响应旋转时自动跟随。
//
// ⚠️ 真机实测确认:Spine 实际画在 gameabc_face.dc.canvas 这张离屏画布上
// (backing 约 1280x720,不在 DOM),gameabc 再把它 blit 到 DOM 中可见的显示
// 画布 #canvas(backing 约 1912x880),最后经 CSS 缩放到屏幕。本函数的
// canvasW/canvasH 必须传绘制缓冲画布(dc.canvas)的 backing 尺寸,因为
// Spine 坐标正是喂给这张画布的,分母须与其同源;叠加层自身的定位/CSS 尺寸/
// drawing buffer 则是另一个来源(显示画布 #canvas),由调用方
// SpineBackendWebGL 负责,不在本函数职责内。
//
// 推导(Spine 世界坐标 Y-up,画布 Y-down):
// 位移由调用方经 skeleton.x = e.x; skeleton.y = -e.y 承担,本矩阵只处理
// scale(1,-1) 与投影,等价于 Canvas 后端的 ctx.translate(e.x,e.y)+ctx.scale(1,-1)。
//
// 1) Spine 世界 → 逻辑坐标: Lx = vx, Ly = -vy
// 2) 逻辑 → 画布像素: Px = a*Lx + c*Ly + e, Py = b*Lx + d*Ly + f
// 3) 画布像素 → NDC: x = 2*Px/W - 1, y = 1 - 2*Py/H
//
// 合并:
// x_ndc = (2a/W)*vx + (-2c/W)*vy + (2e/W - 1)
// y_ndc = (-2b/H)*vx + ( 2d/H)*vy + (1 - 2f/H)
//
// 注意:叠加层 drawing buffer 尺寸在推导中被约掉(gl.viewport 覆盖整个 buffer,
// NDC 自动铺满),故本矩阵只依赖 ctx 变换与绘制缓冲画布 backing 尺寸。因此所有
// entry 共用同一个 MVP,可一次 batcher.begin/end 批量绘制。
// ============================================================
(function(){
var SpineOverlayTransform = {
// ----------------------------------------------------------
// buildMvp(m, canvasW, canvasH, out)
// m : {a,b,c,d,e,f} —— ctx.getTransform() 的六元素
// canvasW : 绘制缓冲画布(gameabc_face.dc.canvas)的 backing 宽(实测约 1280)
// canvasH : 绘制缓冲画布(gameabc_face.dc.canvas)的 backing 高(实测约 720)
// out : 可选,长度 16 的复用数组(避免每帧分配)
// 返回列主序 4x4 矩阵
// ----------------------------------------------------------
buildMvp: function(m, canvasW, canvasH, out) {
var r = out || new Array(16);
var sx = 2 / canvasW;
var sy = 2 / canvasH;
// 第 0 列:vx 的系数
r[0] = sx * m.a;
r[1] = -sy * m.b;
r[2] = 0;
r[3] = 0;
// 第 1 列:vy 的系数
r[4] = -sx * m.c;
r[5] = sy * m.d;
r[6] = 0;
r[7] = 0;
// 第 2 列:z 保持
r[8] = 0;
r[9] = 0;
r[10] = 1;
r[11] = 0;
// 第 3 列:平移
r[12] = sx * m.e - 1;
r[13] = 1 - sy * m.f;
r[14] = 0;
r[15] = 1;
return r;
}
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SpineOverlayTransform;
}
// 友乐运行时有 module 无 require 且共享全局作用域,须无条件暴露全局名
window.SpineOverlayTransform = SpineOverlayTransform;
})();