模板工程升级修改

This commit is contained in:
2026-08-11 22:17:54 +08:00
parent 047561675b
commit 3b7c103387
38 changed files with 16607 additions and 1205 deletions
+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 赋值