在你的博客里放一只可爱的Spine Model吧~
一切的开始 万恶之源 是某Tover说想在他博客上养一只Miku,还想要小的,然后……本着自己的npy能宠一定宠的原则,就去把pjsk资源解包+解密了(具体请自己折腾,版权问题本文不提供现成资源)。
然而没想到解出来发现小Miku的模型是用spine做的,而能找到的绝大多数文章都是在hexo里添加live2d而不是spine,于是开始了漫漫魔改路。得益于之前的魔改把Melody主题的结构摸清楚了,才能知道哪些代码放哪里怎么用hhh(折腾只有一次和无数次?
效果展示:https://c10udlnk.top
参考文章
- 解密方法出处(脚本用python读文件写就很简单了hhh):【世界计划 多彩舞台】关于Project SEKAI游戏资源的提取 - 哔哩哔哩
- 在hexo中添加spine模型:在博客中添加明日方舟小人 - Stalo Fantasia♪
Before all
一个spine模型应该有以下几个必备文件:
xxx.skel
:骨架xxx.png
:贴图xxx.atlas
:纹理图集
在主题文件的源目录中(例如Melody主题的路径是blog/node_modules/hexo-theme-melody
,Butterfly的则是在Blog/themes/butterfly
中)新增一个名为spine_models
的文件夹,并将以上文件放于同一子目录中(下图示例是sd_21miku_normal_r
文件夹)。
在spine_models
文件夹下新建一个随机长度的空语音default.mp3
(为了显示文本而无声音,将在后续改进中大修,避免此无用操作)。
更改_config.yml
在_config.yml
最后新增以下部分,具体什么配置改什么都写在注释里了。
其中animations
(动画名称)和skin
(皮肤)可以用spine对应版本的Skeleton Viewer来看,比如Miku的是3.6.53的spine,那用skeletonViewer-3.6.53.jar
可以看到:
在_config.yml
中增加的代码:
myspine:
enable: true #是否启用
spineDir: "/spine_models/" #spine模型存放的主目录,上面说的新建的spine_models
models:
- name: "sd_21miku_normal_r/" #模型存放子文件夹名(末尾一定要带斜杠)
skin: "default" #使用的皮肤
atlas: "sekai_atlas.atlas" #atlas文件的url
skeleton: "sekai_atlas.skel" #skeleton文件的url
- name: "model_example/" #可增加多个模型
skin: "model_example.skin"
atlas: "model_example.atlas"
skeleton: "model_example.skel"
styles:
widget: #展示窗口的样式,可以自行添加其他需要设置的CSS属性
width: "200px"
height: "200px"
voiceText: #语音文字的样式,可以自行添加其他需要设置的CSS属性
color: "#e6e6e6"
behaviors:
start: #模型刚出现时触发的行为
animation: "w_emu_run01_f" #动画名称
voice: "" #音频的url,为空默认是default.mp3
text: "Ohhhhh好耶" #语音文字
idle: #模型被闲置时触发的行为
maxMinutes: 1 #最大闲置时间,不够时间将循环播放当前动画,时间到达后将随机替换一个闲置动画
animations:
- name: "m_cool_idle01_f" #动画名称,可增加多个动画
loop: false #语音结束前是否循环播放
- name: "m_cool_joy01_f"
loop: false
voices:
- voice: "" #同上,可增加多个语音
text: "不买立省百分百" #同上
interact: #模型被点击时触发的行为
maxPlaySec: 3 #单个动画的最长播放时间,超过将直接停止播放
animations:
- name: "w_normal_joy01_b" #同上
loop: false #同上
- name: "m_cool_angry01_f"
loop: false
voices:
- voice: "" #同上
text: "既见未来,为何不buy" #同上
使用Hexo自带的注入器
将spine相关的html使用Hexo Injector注入到网页的body部分,将以下代码保存为myspine-injector.js
,然后放在主题文件的源目录的scripts
目录下。
hexo.extend.injector.register('body_end', function () {
const {
enable,
spineDir,
models,
styles,
behaviors
} = hexo.config.myspine;
if (!enable) {
return null;
}
return `
<div class="myspine-spine-widget"></div>
<script src="/js/third-party/myspine/spine-widget.js"></script>
<script src="/js/third-party/myspine/spine-skeleton-binary.js"></script>
<script src="/js/third-party/myspine/myspine.js"></script>
<link type="text/css" href="/css/_third-party/myspine.css"></link>
<script>
new MySpine({
spineDir: "${spineDir}",
models: ${JSON.stringify(models)},
styles: ${JSON.stringify(styles)},
behaviors: ${JSON.stringify(behaviors)}
});
</script>
`
})
spine引擎
在主题文件的源目录的source/js/third-party/myspine
路径下放置对应spine版本的spine-widget.js
和spine-skeleton-binary.js
。
spine-widget.js
比如3.6.53版本的可以在https://github.com/EsotericSoftware/spine-runtimes/blob/3.6.53/spine-ts/build/spine-widget.js里下载。
不同版本的spine改中间的版本号就行。
spine-skeleton-binary.js
这个我死活找不到按版本号分类的,参考文章里给的是3.5.51版的spine-skeleton-binary.js
。
鉴于这个文件是用来解析skel文件二进制的,逆向手思索了一下,突然反应过来Skeleton Viewer不也是解析skel文件二进制诶,那不如……用jd-gui逆了一下skeletonViewer-3.6.53.jar
和skeletonViewer-3.5.51.jar
,对比发现3.6.53多了点东西,在原js的基础上加了对应的解析就可了。
更改后3.6.53版本的spine-skeleton-binary.js
:
(function (spine) {
let BlendMode = ["normal", "additive", "multiply", "screen"];
let AttachmentType = ["region", "boundingbox", "mesh", "linkedmesh", "path", "point", "clipping"];
let TransformMode = ["normal", "onlyTranslation", "noRotationOrReflection", "noScale", "noScaleOrReflection"];
let PositionMode = ["fixed", "percent"];
let SpacingMode = ["length", "fixed", "percent"];
let RotateMode = ["tangent", "chain", "chainScale"];
spine.SkeletonJsonConverter = (function () {
function SkeletonJsonConverter(data, scale = 1) {
this.data = data;
this.scale = scale;
this.nextNum = 0;
this.chars = null;
this.json = {};
}
SkeletonJsonConverter.prototype.convertToJson = function () {
this.json.skeleton = {};
let skeleton = this.json.skeleton;
skeleton.hash = this.readString();
if (skeleton.hash.length == 0x0) skeleton.hash = null;
skeleton.spine = this.readString();
if (skeleton.spine.length == 0x0) skeleton.spine = null;
skeleton.width = this.readFloat();
skeleton.height = this.readFloat();
let nonessential = this.readBoolean();
if (nonessential) {
skeleton.fps = this.readFloat();
skeleton.images = this.readString();
if (skeleton.images.length == 0x0) skeleton.images = null;
}
this.json.bones = new Array(this.readInt(!![]));
let bones = this.json.bones;
for (let i = 0x0; i < bones.length; i++) {
let boneData = {};
boneData.name = this.readString();
boneData.parent = null;
if (i != 0x0) {
const nonessential = this.readInt(!![]);
boneData.parent = bones[nonessential].name;
}
boneData.rotation = this.readFloat();
boneData["x"] = this.readFloat() * this.scale;
boneData["y"] = this.readFloat() * this.scale;
boneData.scaleX = this.readFloat();
boneData.scaleY = this.readFloat();
boneData.shearX = this.readFloat();
boneData.shearY = this.readFloat();
boneData.length = this.readFloat() * this.scale;
boneData.transform = TransformMode[this.readInt(!![])];
if (nonessential) {
boneData.color = this.readColor();
}
bones[i] = boneData;
}
this.json.slots = new Array(this.readInt(!![]));
let slots = this.json.slots;
for (let i = 0x0; i < slots.length; i++) {
let slotData = {};
slotData.name = this.readString();
const boneData = this.json.bones[this.readInt(!![])];
slotData.bone = boneData.name;
slotData.color = this.readColor();
slotData.darkColor = this.readColor();
slotData.attachment = this.readString();
slotData.blend = BlendMode[this.readInt(!![])];
slots[i] = slotData;
}
this.json["ik"] = new Array(this.readInt(!![]));
let ik = this.json["ik"];
for (let i = 0x0; i < ik.length; i++) {
let ikConstraints = {};
ikConstraints.name = this.readString();
ikConstraints.order = this.readInt(!![]);
ikConstraints.bones = new Array(this.readInt(!![]));
for (let j = 0x0; j < ikConstraints.bones.length; j++) {
ikConstraints.bones[j] = this.json.bones[this.readInt(!![])].name;
}
ikConstraints.target = this.json.bones[this.readInt(!![])].name;
ikConstraints.mix = this.readFloat();
ikConstraints.bendPositive = this.readByte() != 0xff;
ik[i] = ikConstraints;
}
this.json.transform = new Array(this.readInt(!![]));
let transform = this.json.transform;
for (let i = 0x0; i < transform.length; i++) {
let transformData = {};
transformData.name = this.readString();
transformData.order = this.readInt(!![]);
const bones = new Array(this.readInt(!![]));
for (let j = 0x0, len = bones.length; j < len; j++) {
bones[j] = this.json.bones[this.readInt(!![])].name;
}
transformData.bones = bones;
transformData.target = this.json.bones[this.readInt(!![])].name;
transformData.rotation = this.readFloat();
transformData["x"] = this.readFloat() * this.scale;
transformData["y"] = this.readFloat() * this.scale;
transformData.scaleX = this.readFloat();
transformData.scaleY = this.readFloat();
transformData.shearY = this.readFloat();
transformData.rotateMix = this.readFloat();
transformData.translateMix = this.readFloat();
transformData.scaleMix = this.readFloat();
transformData.shearMix = this.readFloat();
transform[i] = transformData;
}
this.json.path = new Array(this.readInt(!![]));
let path = this.json.path;
for (let i = 0x0; i < path.length; i++) {
let pathData = {};
pathData.name = this.readString();
pathData.order = this.readInt(!![]);
pathData.bones = new Array(this.readInt(!![]));
for (let j = 0x0, len = pathData.bones.length; j < len; j++) {
pathData.bones[j] = this.json.bones[this.readInt(!![])].name;
}
pathData.target = this.json.slots[this.readInt(!![])].name;
pathData.positionMode = PositionMode[this.readInt(!![])];
pathData.spacingMode = SpacingMode[this.readInt(!![])];
pathData.rotateMode = RotateMode[this.readInt(!![])];
pathData.rotation = this.readFloat();
pathData.position = this.readFloat();
if (pathData.positionMode == "fixed") {
pathData.position *= this.scale;
}
pathData.spacing = this.readFloat();
if (pathData.spacingMode == "length" || pathData.spacingMode == "fixed") {
pathData.spacing *= this.scale;
}
pathData.rotateMix = this.readFloat();
pathData.translateMix = this.readFloat();
path[i] = pathData;
}
this.json.skins = {};
this.json.skinsName = new Array();
let skins = this.json.skins;
let skinData = this.readSkin("default", nonessential);
if (skinData != null) {
skins.default = skinData;
this.json.skinsName.push("default");
}
for (let i = 0x0, len = this.readInt(!![]); i < len; i++) {
let skinName = this.readString();
let skin = this.readSkin(skinName, nonessential);
skins[skinName] = skin;
this.json.skinsName.push(skinName);
}
this.json.events = [];
this.json.eventsName = [];
let events = this.json.events;
for (let i = 0x0, len = this.readInt(!![]); i < len; i++) {
let eventName = this.readString();
let event = {};
event.int = this.readInt(![]);
event.float = this.readFloat();
event.string = this.readString();
events[eventName] = event;
this.json.eventsName[i] = eventName;
}
this.json.animations = {};
let animations = this.json.animations;
for (let i = 0x0, len = this.readInt(!![]); i < len; i++) {
let animationName = this.readString();
let animation = this.readAnimation(animationName);
animations[animationName] = animation;
}
};
SkeletonJsonConverter.prototype.readByte = function () {
return this.nextNum < this.data.length ? this.data[this.nextNum++] : null;
};
SkeletonJsonConverter.prototype.readBoolean = function () {
return this.readByte() != 0x0;
};
SkeletonJsonConverter.prototype.readShort = function () {
return (this.readByte() << 0x8) | this.readByte();
};
SkeletonJsonConverter.prototype.readInt = function (optimizePositive) {
if (typeof optimizePositive === "undefined") {
return (
(this.readByte() << 0x18) |
(this.readByte() << 0x10) |
(this.readByte() << 0x8) |
this.readByte()
);
}
let b = this.readByte();
let result = b & 0x7f;
if ((b & 0x80) != 0x0) {
b = this.readByte();
result |= (b & 0x7f) << 0x7;
if ((b & 0x80) != 0x0) {
b = this.readByte();
result |= (b & 0x7f) << 0xe;
if ((b & 0x80) != 0x0) {
b = this.readByte();
result |= (b & 0x7f) << 0x15;
if ((b & 0x80) != 0x0) {
b = this.readByte();
result |= (b & 0x7f) << 0x1c;
}
}
}
}
return optimizePositive ? result : (result >> 0x1) ^ -(result & 0x1);
};
SkeletonJsonConverter.prototype.bytes2Float32 = function (bytes) {
let sign = bytes & 0x80000000 ? -0x1 : 0x1;
let exponent = ((bytes >> 0x17) & 0xff) - 0x7f;
let significand = bytes & ~(-0x1 << 0x17);
if (exponent == 0x80)
return sign * (significand ? Number.NaN : Number.POSITIVE_INFINITY);
if (exponent == -0x7f) {
if (significand == 0x0) return sign * 0x0;
exponent = -0x7e;
significand /= 0x1 << 0x16;
} else significand = (significand | (0x1 << 0x17)) / (0x1 << 0x17);
return sign * significand * Math.pow(0x2, exponent);
};
SkeletonJsonConverter.prototype.readFloat = function () {
return this.bytes2Float32(
(this.readByte() << 0x18) +
(this.readByte() << 0x10) +
(this.readByte() << 0x8) +
(this.readByte() << 0x0)
);
};
SkeletonJsonConverter.prototype.readVertices = function (vertexCount) {
let verticesLength = vertexCount << 0x1;
if (!this.readBoolean()) {
return this.readFloatArray(verticesLength, this.scale);
}
let bonesArray = new Array();
for (let i = 0x0; i < vertexCount; i++) {
let dataLength = this.readInt(!![]);
bonesArray.push(dataLength);
for (let j = 0x0; j < dataLength; j++) {
bonesArray.push(this.readInt(!![]));
bonesArray.push(this.readFloat() * this.scale);
bonesArray.push(this.readFloat() * this.scale);
bonesArray.push(this.readFloat());
}
}
return bonesArray;
};
SkeletonJsonConverter.prototype.readFloatArray = function (length, scale) {
let array = new Array(length);
if (scale == 0x1) {
for (let i = 0x0; i < length; i++) {
array[i] = this.readFloat();
}
} else {
for (let i = 0x0; i < length; i++) {
array[i] = this.readFloat() * scale;
}
}
return array;
};
SkeletonJsonConverter.prototype.readShortArray = function () {
let n = this.readInt(!![]);
let array = new Array(n);
for (let i = 0x0; i < n; i++) {
array[i] = this.readShort();
}
return array;
};
SkeletonJsonConverter.prototype.readIntArray = function () {
let n = this.readInt(!![]);
let array = new Array(n);
for (let i = 0x0; i < n; i++) array[i] = this.readInt(!![]);
return array;
};
SkeletonJsonConverter.prototype.readHex = function () {
let hex = this.readByte().toString(0x10);
return hex.length == 0x2 ? hex : "0" + hex;
};
SkeletonJsonConverter.prototype.readColor = function () {
return this.readHex() + this.readHex() + this.readHex() + this.readHex();
};
SkeletonJsonConverter.prototype.readString = function () {
let charCount = this.readInt(this, !![]);
switch (charCount) {
case 0x0:
return null;
case 0x1:
return "";
}
charCount--;
this.chars = "";
let charIndex = 0x0;
for (let i = 0x0; i < charCount;) {
charIndex = this.readByte();
switch (charIndex >> 0x4) {
case 0xc:
case 0xd:
this.chars += String.fromCharCode(
((charIndex & 0x1f) << 0x6) | (this.readByte() & 0x3f)
);
i += 0x2;
break;
case 0xe:
this.chars += String.fromCharCode(
((charIndex & 0xf) << 0xc) |
((this.readByte() & 0x3f) << 0x6) |
(this.readByte() & 0x3f)
);
i += 0x3;
break;
default:
this.chars += String.fromCharCode(charIndex);
i++;
}
}
return this.chars;
};
SkeletonJsonConverter.prototype.readSkin = function (slotIndex, nonessential) {
let slotCount = this.readInt(!![]);
if (slotCount == 0x0) return null;
let skin = {};
for (let i = 0x0; i < slotCount; i++) {
const slotIndex = this.readInt(!![]);
const slot = {};
for (let j = 0x0, n = this.readInt(!![]); j < n; j++) {
let name = this.readString();
let attachment = this.readAttachment(name, nonessential);
if (attachment != null) {
slot[name] = attachment;
}
}
skin[this.json.slots[slotIndex].name] = slot;
}
return skin;
};
SkeletonJsonConverter.prototype.readAttachment = function (attachmentName, nonessential) {
let scale = this.scale;
let name = this.readString();
if (name == null) name = attachmentName;
let path,
n,
region = {},
box = {},
mesh = {},
linkdeMesh = {};
let array;
let point = {};
let clipping = {};
switch (AttachmentType[this.readByte()]) {
case "region":
path = this.readString();
if (path == null) path = name;
region.type = "region";
region.name = name;
region.path = path.trim();
region.rotation = this.readFloat();
region["x"] = this.readFloat() * scale;
region["y"] = this.readFloat() * scale;
region.scaleX = this.readFloat();
region.scaleY = this.readFloat();
region.width = this.readFloat() * scale;
region.height = this.readFloat() * scale;
region.color = this.readColor();
return region;
case "boundingbox":
box.type = "boundingbox";
box.name = name;
n = this.readInt(!![]);
box.vertexCount = n;
box.vertices = this.readVertices(n);
if (this.nonessential) {
box.color = this.readColor();
}
return box;
case "mesh":
path = this.readString();
if (path == null) path = name;
mesh.type = "mesh";
mesh.name = name;
mesh.path = path;
mesh.color = this.readColor();
n = this.readInt(!![]);
mesh.uvs = this.readFloatArray(n << 0x1, 0x1);
mesh.triangles = this.readShortArray();
mesh.vertices = this.readVertices(n);
mesh.hull = this.readInt(!![]) << 0x1;
if (nonessential) {
mesh.edges = this.readShortArray();
mesh.width = this.readFloat() * scale;
mesh.height = this.readFloat() * scale;
}
return mesh;
case "linkedmesh":
path = this.readString();
if (path == null) path = name;
linkdeMesh.type = "linkedmesh";
linkdeMesh.name = name;
linkdeMesh.path = path;
linkdeMesh.color = this.readColor();
linkdeMesh.skin = this.readString();
linkdeMesh.parent = this.readString();
linkdeMesh.deform = this.readBoolean();
if (nonessential) {
linkdeMesh.width = this.readFloat() * scale;
linkdeMesh.height = this.readFloat() * scale;
}
return linkdeMesh;
case "path":
path = {};
path.type = "path";
path.name = name;
path.closed = this.readBoolean();
path.constantSpeed = this.readBoolean();
n = this.readInt(!![]);
path.vertexCount = n;
path.vertices = this.readVertices(n);
array = array = new Array(n / 0x3);
for (let i = 0x0; i < array.length; i++) {
array[i] = this.readFloat() * scale;
}
path.lengths = array;
if (nonessential) {
path.color = this.readColor();
}
return path;
case "point":
point.type = "point";
point.name = name;
point.rotation = this.readFloat();
point["x"] = this.readFloat() * scale;
point["y"] = this.readFloat() * scale;
if (nonessential) {
path.color = this.readColor();
}
return point;
case "clipping":
clipping.type = "clipping";
clipping.name = name;
clipping.end = this.readInt(!![]);
n = this.readInt(!![]);
clipping.vertexCount = n;
clipping.vertices = this.readVertices(n);
if (nonessential) {
clipping.color = this.readColor();
}
return clipping;
}
return null;
};
SkeletonJsonConverter.prototype.readCurve = function (frameIndex, timeline) {
let cx1, cy1, cx2, cy2;
switch (this.readByte()) {
case 0x0:
timeline[frameIndex].curve = "linear";
break;
case 0x1:
timeline[frameIndex].curve = "stepped";
break;
case 0x2:
cx1 = this.readFloat();
cy1 = this.readFloat();
cx2 = this.readFloat();
cy2 = this.readFloat();
timeline[frameIndex].curve = [cx1, cy1, cx2, cy2];
}
};
SkeletonJsonConverter.prototype.readAnimation = function (name) {
let animation = {};
let scale = this.scale;
let duration = 0x0;
let slots = {};
for (let i = 0x0, nn = this.readInt(!![]); i < nn; i++) {
let slotIndex = this.readInt(!![]);
let slotMap = {};
let timeCount = this.readInt(!![]);
for (let ii = 0x0; ii < timeCount; ii++) {
let timelineType = this.readByte();
let frameCount = this.readInt(!![]);
let timeline;
switch (timelineType) {
case 0x0:
timeline = new Array(frameCount);
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
let time = this.readFloat();
let color = this.readString();
timeline[frameIndex] = {};
timeline[frameIndex].time = time;
timeline[frameIndex].name = color;
}
slotMap.attachment = timeline;
duration = Math.max(duration, timeline[frameCount - 0x1].time);
break;
case 0x1:
timeline = new Array(frameCount);
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
const time = this.readFloat();
const color = this.readColor();
timeline[frameIndex] = {};
timeline[frameIndex].time = time;
timeline[frameIndex].color = color;
if (frameIndex < frameCount - 0x1) {
this.readCurve(frameIndex, timeline); //let curve
}
}
slotMap.color = timeline;
duration = Math.max(duration, timeline[frameCount - 0x1].time);
break;
case 0x2:
timeline = new Array(frameCount);
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
const time = this.readFloat();
let lightColor = this.readColor();
let darkColor = this.readColor();
timeline[frameIndex] = {};
timeline[frameIndex].time = time;
timeline[frameIndex].light = lightColor;
timeline[frameIndex].dark = darkColor;
if (frameIndex < frameCount - 0x1) {
this.readCurve(frameIndex, timeline); // let curve
}
}
slotMap.twoColor = timeline;
duration = Math.max(duration, timeline[frameCount - 0x1].time);
break;
}
}
slots[this.json.slots[slotIndex].name] = slotMap;
}
animation.slots = slots;
let bones = {};
for (let i = 0x0, n = this.readInt(!![]); i < n; i++) {
let boneIndex = this.readInt(!![]);
let boneMap = {};
for (let ii = 0x0, nn = this.readInt(!![]); ii < nn; ii++) {
const timelineType = this.readByte();
const frameCount = this.readInt(!![]);
let timeline;
let timelineScale = 0x1;
switch (timelineType) {
case 0x0:
timeline = new Array(frameCount);
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
const time = this.readFloat();
const tlangle = this.readFloat();
timeline[frameIndex] = {};
timeline[frameIndex].time = time;
timeline[frameIndex].angle = tlangle;
if (frameIndex < frameCount - 0x1) {
this.readCurve(frameIndex, timeline);
}
}
boneMap.rotate = timeline;
duration = Math.max(duration, timeline[frameCount - 0x1].time);
break;
case 0x1:
case 0x2:
case 0x3:
timeline = new Array(frameCount);
if (timelineType == 0x1) {
timelineScale = scale;
}
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
let tltime = this.readFloat();
let tlx = this.readFloat();
let tly = this.readFloat();
timeline[frameIndex] = {};
timeline[frameIndex].time = tltime;
timeline[frameIndex]["x"] = tlx * timelineScale;
timeline[frameIndex]["y"] = tly * timelineScale;
if (frameIndex < frameCount - 0x1) {
this.readCurve(frameIndex, timeline);
}
}
if (timelineType == 0x1) {
boneMap.translate = timeline;
} else if (timelineType == 0x2) {
boneMap.scale = timeline;
} else {
boneMap.shear = timeline;
}
duration = Math.max(duration, timeline[frameCount - 0x1].time);
break;
}
}
bones[this.json.bones[boneIndex].name] = boneMap;
}
animation.bones = bones;
let ik = {};
for (let i = 0x0, nn = this.readInt(!![]); i < nn; i++) {
const ikIndex = this.readInt(!![]);
const frameCount = this.readInt(!![]);
const timeline = new Array(frameCount);
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
const time = this.readFloat();
const mix = this.readFloat();
const bendPositive = this.readByte() != 0xff;
timeline[frameIndex] = {};
timeline[frameIndex].time = time;
timeline[frameIndex].mix = mix;
timeline[frameIndex].bendPositive = bendPositive;
if (frameIndex < frameCount - 0x1) {
this.readCurve(frameIndex, timeline);
}
}
ik[this.json["ik"][ikIndex].name] = timeline;
duration = Math.max(duration, timeline[frameCount - 0x1].time);
}
animation["ik"] = ik;
let ffd = {};
for (let i = 0x0, nn = this.readInt(!![]); i < nn; i++) {
const slotIndex = this.readInt(!![]);
const frameCount = this.readInt(!![]);
const timeline = new Array(frameCount);
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
timeline[frameIndex] = {};
timeline[frameIndex].time = this.readFloat();
timeline[frameIndex].rotateMix = this.readFloat();
timeline[frameIndex].translateMix = this.readFloat();
timeline[frameIndex].scaleMix = this.readFloat();
timeline[frameIndex].shearMix = this.readFloat();
if (frameIndex < frameCount - 0x1) {
this.readCurve(frameIndex, timeline);
}
}
ffd[this.json.transform[slotIndex].name] = timeline;
duration = Math.max(duration, timeline[frameCount - 0x1].time);
}
animation.transform = ffd;
let path = {};
for (let i = 0x0, n = this.readInt(!![]); i < n; i++) {
let pathOrder = this.readInt(!![]);
let pathData = this.json.path[pathOrder];
let data = {};
for (let ii = 0x0, nn = this.readInt(!![]); ii < nn; ii++) {
const timelineType = this.readByte();
const frameCount = this.readInt(!![]);
let timeline, time, timelineScale;
switch (timelineType) {
case 0x0:
case 0x1:
timeline = new Array(frameCount);
timelineScale = 0x1;
if (timelineType == 0x1) {
if (
pathData.spacingMode == "length" ||
pathData.spacingMode == "fixed"
) {
timelineScale = this.scale;
}
} else {
if (pathData.positionMode == "fixed") {
timelineScale = this.scale;
}
}
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
time = this.readFloat();
let _0xebc795 = this.readFloat();
timeline[frameIndex] = {};
timeline[frameIndex].time = time;
if (timelineType == 0x0) {
timeline[frameIndex].position = _0xebc795 * timelineScale;
} else {
timeline[frameIndex].spacing = _0xebc795 * timelineScale;
}
if (frameIndex < frameCount - 0x1)
this.readCurve(frameIndex, timeline);
}
if (timelineType == 0x0) {
data.position = timeline;
} else {
data.spacing = timeline;
}
duration = Math.max(duration, timeline[frameCount - 0x1].time);
break;
case 0x2:
timeline = new Array(frameCount);
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
time = this.readFloat();
let _0x47f0f0 = this.readFloat();
let _0x1bb1d7 = this.readFloat();
timeline[frameIndex] = {};
timeline[frameIndex].time = time;
timeline[frameIndex].rotateMix = _0x47f0f0;
timeline[frameIndex].translateMix = _0x1bb1d7;
if (frameIndex < frameCount - 0x1)
this.readCurve(frameIndex, timeline);
}
data.mix = timeline;
duration = Math.max(duration, timeline[frameCount - 0x1].time);
break;
}
}
path[this.json.path[pathOrder].name] = data;
}
animation.paths = path;
// 变量名可能不对
let deform = {};
for (let i = 0x0, n = this.readInt(!![]); i < n; i++) {
let index = this.readInt(!![]);
let skinName = this.json.skinsName[index];
let deformData = {};
for (let ii = 0x0, nn = this.readInt(!![]); ii < nn; ii++) {
const slotIndex = this.readInt(!![]);
const slot = this.json.slots[slotIndex];
const attachment = {};
for (let iii = 0x0, nnn = this.readInt(!![]); iii < nnn; iii++) {
const name = this.readString();
const frameCount = this.readInt(!![]);
const timeline = new Array(frameCount);
for (let frameIndex = 0x0; frameIndex < frameCount; frameIndex++) {
const time = this.readFloat();
const end = this.readInt(!![]);
timeline[frameIndex] = {};
timeline[frameIndex].time = time;
if (end != 0x0) {
let vertices = new Array(end);
let start = this.readInt(!![]);
if (this.scale == 0x1) {
for (let i = 0x0; i < end; i++) {
vertices[i] = this.readFloat();
}
} else {
for (let i = 0x0; i < end; i++) {
vertices[i] = this.readFloat() * this.scale;
}
}
timeline[frameIndex].offset = start;
timeline[frameIndex].vertices = vertices;
}
if (frameIndex < frameCount - 0x1)
this.readCurve(frameIndex, timeline);
}
attachment[name] = timeline;
duration = Math.max(duration, timeline[frameCount - 0x1].time);
}
deformData[slot.name] = attachment;
}
deform[skinName] = deformData;
}
animation.deform = deform;
let drawOrderCount = this.readInt(!![]);
if (drawOrderCount > 0x0) {
let drawOrders = new Array(drawOrderCount);
for (let i = 0x0; i < drawOrderCount; i++) {
const drawOrderMap = {};
const time = this.readFloat();
const offsetCount = this.readInt(!![]);
const offsets = new Array(offsetCount);
for (let ii = 0x0; ii < offsetCount; ii++) {
const offsetMap = {};
const slotIndex = this.readInt(!![]);
offsetMap.slot = this.json.slots[slotIndex].name;
let dooffset = this.readInt(!![]);
offsetMap.offset = dooffset;
offsets[ii] = offsetMap;
}
drawOrderMap.offsets = offsets;
drawOrderMap.time = time;
drawOrders[i] = drawOrderMap;
}
duration = Math.max(duration, drawOrders[drawOrderCount - 0x1].time);
animation.drawOrder = drawOrders;
}
let eventCount = this.readInt(!![]);
if (eventCount > 0x0) {
let events = new Array(eventCount);
for (let i = 0x0; i < eventCount; i++) {
const time = this.readFloat();
const name = this.json.eventsName[this.readInt(!![])];
const eventData = this.json.events[name];
const e = {};
e.name = name;
e.int = this.readInt(![]);
e.float = this.readFloat();
e.string = this.readBoolean() ? this.readString() : eventData.string;
e.time = time;
events[i] = e;
}
duration = Math.max(duration, events[eventCount - 0x1].time);
animation.events = events;
}
return animation;
};
return SkeletonJsonConverter;
})();
})(spine);
控制spine代码
这部分是我们可以用来控制具体行为的代码,随便魔改hhh。
myspine.js
将以下代码保存为myspine.js
,放在主题文件的源目录的source/js/third-party/myspine
路径下。
function MySpine(t) {
this.config = t,
this.model = t.models[Number.parseInt(Math.random()*t.models.length)],
this.urlPrefix = t.spineDir + this.model.name,
this.skin = this.model.skin,
this.skeleton = this.urlPrefix + this.model.skeleton,
this.atlas = this.urlPrefix + this.model.atlas,
this.widget = null,
this.widgetContainer = document.querySelector(".myspine-spine-widget"),
this.voiceText = document.createElement("div"),
this.voicePlayer = new Audio,
this.triggerEvents = ["mousedown", "touchstart", "scroll"],
this.animationQueue = new Array,
this.isPlayingVoice = !1,
this.lastInteractTime = Date.now(),
this.localX = 0,
this.localY = 0,
this.load()
}
MySpine.downloadBinary = function(t, e, i) {
var n = new XMLHttpRequest;
n.open("GET", t, !0),
n.responseType = "arraybuffer",
n.onload = function() {
200 == n.status ? e(new Uint8Array(n.response)) : i(n.status, n.responseText)
}
,
n.onerror = function() {
i(n.status, n.responseText)
}
,
n.send()
}
,
MySpine.prototype = {
load: function() {
let i = this.config;
MySpine.downloadBinary(this.skeleton, t=>{
function e(t, e) {
for (var i in e)
t.style.setProperty(i, e[i])
}
e(this.widgetContainer, i.styles.widget),
e(this.voiceText, i.styles.voiceText);
t = new spine.SkeletonJsonConverter(t,1);
t.convertToJson(),
new spine.SpineWidget(this.widgetContainer,{
animation: this.getAnimationList("start")[0].name,
skin: this.skin,
atlas: this.atlas,
jsonContent: t.json,
backgroundColor: "#00000000",
loop: !1,
success: this.spineWidgetSuccessCallback.bind(this)
})
}
, function(t, e) {
console.error(`Couldn't download skeleton ${path}: status ${t}, ${e}.`)
})
},
spineWidgetSuccessCallback: function(t) {
var e = ()=>{
this.triggerEvents.forEach(t=>window.removeEventListener(t, e)),
this.triggerEvents.forEach(t=>window.addEventListener(t, this.changeIdleAnimation.bind(this))),
this.initVoiceComponents(),
this.initWidgetActions(),
this.initDragging(),
this.widget.play(),
this.playVoice(this.getVoice("start")),
this.widgetContainer.style.display = "block"
}
;
this.widget = t,
this.widget.pause(),
this.widgetContainer.style.display = "none",
this.triggerEvents.forEach(t=>window.addEventListener(t, e))
},
initVoiceComponents: function() {
this.voiceText.setAttribute("class", "myspine-voice-text"),
this.widgetContainer.appendChild(this.voiceText),
this.voiceText.style.opacity = 0,
this.voicePlayer.addEventListener("timeupdate", ()=>{
this.voiceText.scrollTo({
left: 0,
top: this.voiceText.offsetHeight * (this.voicePlayer.currentTime / this.voicePlayer.duration),
behavior: "smooth"
})
}
),
this.voicePlayer.addEventListener("ended", ()=>{
this.voiceText.style.opacity = 0,
this.isPlayingVoice = !1
}
)
},
initWidgetActions: function() {
this.widget.canvas.onclick = this.interact.bind(this),
this.widget.state.addListener({
complete: t=>{
(this.isPlayingVoice && t.loop) || this.isIdle() ? this.playRandAnimation({
name: t.animation.name,
loop: !0
}) : this.playRandAnimation(this.getAnimationList("idle"))
}
})
},
initDragging: function() {
function i(t) {
var e = document.documentElement.scrollLeft
, i = document.documentElement.scrollTop;
return t.targetTouches ? (e += t.targetTouches[0].clientX,
i += t.targetTouches[0].clientY) : t.clientX && t.clientY && (e += t.clientX,
i += t.clientY),
{
x: e,
y: i
}
}
function e(t) {
t.cancelable && t.preventDefault()
}
var n = (t,e)=>{
t = Math.max(0, t),
e = Math.max(0, e),
t = Math.min(document.body.clientWidth - this.widgetContainer.clientWidth, t),
e = Math.min(document.body.clientHeight - this.widgetContainer.clientHeight, e),
this.widgetContainer.style.left = t + "px",
this.widgetContainer.style.top = e + "px"
}
, o = t=>{
var {x: e, y: t} = i(t);
this.localX = e - this.widgetContainer.offsetLeft,
this.localY = t - this.widgetContainer.offsetTop
}
, s = t=>{
var {x: e, y: t} = i(t);
n(e - this.localX, t - this.localY),
window.getSelection ? window.getSelection().removeAllRanges() : document.selection.empty()
}
, t = {
passive: !0
}
, a = {
passive: !1
};
this.widgetContainer.addEventListener("mousedown", t=>{
o(t),
document.addEventListener("mousemove", s)
}
),
this.widgetContainer.addEventListener("touchstart", t=>{
o(t),
document.addEventListener("touchmove", e, a)
}
, t),
this.widgetContainer.addEventListener("touchmove", s, t),
document.addEventListener("mouseup", ()=>document.removeEventListener("mousemove", s)),
this.widgetContainer.addEventListener("touchend", ()=>document.removeEventListener("touchmove", e)),
window.addEventListener("resize", ()=>{
let t = this.widgetContainer.style;
var e, i;
t.left && t.top && (e = Number.parseInt(t.left.substring(0, t.left.length - 2)),
i = Number.parseInt(t.top.substring(0, t.top.length - 2)),
n(e, i))
}
)
},
interact: function() {
this.isPlayingVoice || 0 < this.animationQueue.length || !this.isIdle() ? console.warn("互动过于频繁!") : (this.lastInteractTime = Date.now(),
this.playRandAnimation(this.getAnimationList("interact")),
this.playVoice(this.getVoice("interact")))
},
getUrl: function(t) {
return this.urlPrefix + t
},
getAnimationList: function(t) {
var e = this.config.behaviors[t];
return "start" == t? [{
name: e.animation,
loop: !1
}] : e.animations.slice()
},
getVoice: function(t) {
var e = this.config.behaviors[t];
return "start" == t? {
voice: e.voice,
text: e.text
} : e.voices[Math.floor(Math.random() * e.voices.length)]
},
playRandAnimation: function(t) {
if (Array.isArray(t)) {
e = t[Number.parseInt(Math.random()*t.length)];
this.widget.state.setAnimation(0, e.name, e.loop);
} else {
this.widget.state.setAnimation(0, t.name, t.loop);
}
},
playVoice: function(t) {
voiceUrl = this.getUrl(t.voice);
// 如果为空则播放五秒的空语音,仅用来显示文本框
if (voiceUrl == this.urlPrefix) {
voiceUrl = this.config.spineDir + "default.mp3";
}
t && (this.isPlayingVoice = !0,
this.voicePlayer.src = voiceUrl,
this.voicePlayer.load(),
this.voicePlayer.play().then(()=>{
this.voiceText.innerHTML = t.text,
this.voiceText.scrollTo(0, 0),
this.voiceText.style.opacity = 1
}
, t=>{
this.isPlayingVoice = !1,
console.error(`无法播放音频,因为:${t}`)
}
))
},
isIdle: function() {
var e = this.widget.state.tracks[0].animation;
for (const v of this.getAnimationList("idle")) {
if (v.name == e.name) {
return !0;
}
}
return !1;
},
isInteract: function() {
var e = this.widget.state.tracks[0].animation;
for (const v of this.getAnimationList("interact")) {
if (v.name == e.name) {
return !0;
}
}
return !1;
},
changeIdleAnimation: function() {
var t = Date.now()
, e = t - this.lastInteractTime;
if ((this.isIdle() && e/1e3/60 >= this.config.behaviors.idle.maxMinutes) ||
(this.isInteract() && e/1e3 >= this.config.behaviors.interact.maxPlaySec)) {
this.lastInteractTime = t,
this.playRandAnimation(this.getAnimationList("idle"))
}
}
};
myspine.css
将以下代码保存为myspine.css
,放在主题文件的源目录的source/css/_third-party
路径下。
.myspine-spine-widget {
position: fixed;
width: 200px;
height: 200px;
left: 10px;
bottom: 10px;
z-index: 999;
}
.myspine-voice-text {
width: 100%;
height: 60px;
position: absolute;
margin-top: -70px;
padding: 5px 5px 5px 5px;
line-height: 1.5;
color: #fff;
background-color: #14151685;
box-shadow: 0 3px 15px 2px #141516;
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
overflow-x: hidden;
overflow-y: scroll;
transition: opacity .3s ease-in-out,height .5s ease-in-out;
}
/*
.myspine-voice-text::before {
content: 'VOICE TEXT';
display: block;
color: #37b2ff;
font-weight: 700;
}*/
.myspine-voice-text {
scrollbar-width: none;
-ms-overflow-style: none;
}
.myspine-voice-text::-webkit-scrollbar {
display: none;
}
增加调用
这一部分应该是Melody独有的,不知道别的主题有没有,不import的话css导不进去(
css调用
在主题文件的源目录的source/css/index.styl
中加一句:
@import "_third-party/myspine.css"
And...
在参考文章(在博客中添加明日方舟小人 - Stalo Fantasia♪)基础上的improve:
- 模型数量扩展,可使用多个模型。
- 模型动作展示从顺序切换更改为随机切换,增加互动乐趣。
- Idle动作和语音扩展,可用多个动作和语音,并增加到达一定时长随机切换的功能。
- 对interact动作进行时间限制,防止某些动作后摇过长。
- 调整部分CSS样式。
一些TODO:
- 砍语音部分(
感觉容易社死,下个版本全盘砍掉)。 - 将语音文本独立出来做展示文字,可以多项文本随机切换。
如果想继续魔改的朋友们,把数据通路啥的梳理清楚了就很容易改啦~