440 lines
13 KiB
JavaScript
440 lines
13 KiB
JavaScript
// ============================================================
|
||
// SpineMgr —— Spine 动画管理器(独立文件)
|
||
// 集成到 gameabc Canvas 2D 绘制循环,零侵入 gamemain.js
|
||
// 在 index.html 中置于 gameabc.min.js 之后、gamemain.js 之前加载
|
||
// ============================================================
|
||
(function(){
|
||
|
||
var SpineMgr = {
|
||
|
||
// --- 内部状态 ---
|
||
_entries: {}, // {id: EntryObject}
|
||
_assetManager: null,
|
||
_renderer: null, // spine.SkeletonRenderer (Canvas 2D)
|
||
_lastTime: 0, // 上一帧时间戳(ms)
|
||
_inited: false,
|
||
_loadedPaths: {}, // 已请求加载的资源路径,避免重复请求
|
||
_pendingCmds: {}, // {id: [{method, args}]} 等待实例就绪后执行的命令队列
|
||
|
||
// 默认资源根路径(可通过 init 覆盖)
|
||
_basePath: "assets/spine/",
|
||
|
||
// ----------------------------------------------------------
|
||
// init(basePath) 手动初始化(可选)
|
||
// 如不调用,load 时会自动以默认路径 "assets/spine/" 初始化
|
||
// ----------------------------------------------------------
|
||
init: function(basePath) {
|
||
if (!window.spine) {
|
||
logmessage("[SpineMgr] spine-canvas.js 未加载,请检查引用");
|
||
return;
|
||
}
|
||
this._basePath = basePath || this._basePath;
|
||
this._assetManager = new spine.AssetManager(this._basePath);
|
||
this._lastTime = Date.now();
|
||
this._inited = true;
|
||
},
|
||
|
||
// (内部) 确保已初始化
|
||
_ensureInit: function() {
|
||
if (!this._inited) {
|
||
this.init(this._basePath);
|
||
}
|
||
},
|
||
|
||
// (内部) 自动加载:根据 id 约定文件名 id.json / id.atlas
|
||
_autoLoad: function(id) {
|
||
if (this._entries[id]) return;
|
||
this.load(id, id + ".json", id + ".atlas", {});
|
||
},
|
||
|
||
// (内部) 将命令加入等待队列
|
||
_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 后触发隐藏
|
||
};
|
||
},
|
||
|
||
// ----------------------------------------------------------
|
||
// (内部) 资源加载完成后实例化骨骼
|
||
// ----------------------------------------------------------
|
||
_buildEntry: function(entry) {
|
||
var atlas = this._assetManager.require(entry.atlasFile);
|
||
var loader = new spine.AtlasAttachmentLoader(atlas);
|
||
var skelJson = new spine.SkeletonJson(loader);
|
||
skelJson.scale = entry.scale;
|
||
|
||
var skelData = skelJson.readSkeletonData(
|
||
this._assetManager.require(entry.jsonFile)
|
||
);
|
||
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);
|
||
logmessage("[SpineMgr] " + id + " 构建完成");
|
||
this._flushCmds(id);
|
||
} catch(err) {
|
||
logmessage("[SpineMgr] " + id + " 构建失败: " + err.message);
|
||
e.ready = false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
},
|
||
|
||
// ----------------------------------------------------------
|
||
// 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;
|
||
|
||
if (!this._renderer) {
|
||
this._renderer = new spine.SkeletonRenderer(ctx);
|
||
this._renderer.triangleRendering = true; // ★ 启用三角形渲染以支持 Mesh 网格附件
|
||
}
|
||
// 确保 renderer 用的是当前 ctx (gameabc可能重建)
|
||
this._renderer.ctx = ctx;
|
||
|
||
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;
|
||
e.skeleton.updateWorldTransform(spine.Physics.update);
|
||
|
||
ctx.save();
|
||
ctx.translate(e.x, e.y);
|
||
ctx.scale(1, -1); // ★ Spine Y-up → Canvas Y-down
|
||
this._renderer.draw(e.skeleton);
|
||
ctx.restore();
|
||
}
|
||
},
|
||
|
||
// ==========================================================
|
||
// 公开控制 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;
|
||
}
|
||
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) {
|
||
var e = this._entries[id];
|
||
if (!e) {
|
||
this._autoLoad(id);
|
||
this._queueCmd(id, "setScale", [sx, sy]);
|
||
return;
|
||
}
|
||
if (!e.skeleton) {
|
||
this._queueCmd(id, "setScale", [sx, sy]);
|
||
return;
|
||
}
|
||
e.skeleton.scaleX = sx;
|
||
e.skeleton.scaleY = sy !== undefined ? sy : sx;
|
||
},
|
||
|
||
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);
|
||
this.setAnimation(id, animName, false, track);
|
||
var 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);
|
||
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 = {};
|
||
}
|
||
};
|
||
|
||
// 挂载到 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();
|
||
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;
|
||
}
|
||
logmessage("[SpineMgr] 预加载 " + list.length + " 组 Spine 资源" +
|
||
(Object.keys(textData).length > 0 ? "(使用嵌入数据)" : "(使用网络请求)"));
|
||
}
|
||
|
||
// ★ 用 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; }
|
||
});
|
||
|
||
})();
|