在你的博客里放一只可爱的Spine Model吧~

一切的开始 万恶之源 是某Tover说想在他博客上养一只Miku,还想要小的,然后……本着自己的npy能宠一定宠的原则,就去把pjsk资源解包+解密了(具体请自己折腾,版权问题本文不提供现成资源)。

然而没想到解出来发现小Miku的模型是用spine做的,而能找到的绝大多数文章都是在hexo里添加live2d而不是spine,于是开始了漫漫魔改路。得益于之前的魔改把Melody主题的结构摸清楚了,才能知道哪些代码放哪里怎么用hhh(折腾只有一次和无数次?

效果展示:https://c10udlnk.top

image-20211101191625607

参考文章

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(为了显示文本而无声音,将在后续改进中大修,避免此无用操作)。

image-20211102194122693

更改_config.yml

_config.yml最后新增以下部分,具体什么配置改什么都写在注释里了。

其中animations(动画名称)和skin(皮肤)可以用spine对应版本的Skeleton Viewer来看,比如Miku的是3.6.53的spine,那用skeletonViewer-3.6.53.jar可以看到:

image-20211102192727985

_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> ` })

image-20211102194050555

spine引擎

主题文件的源目录source/js/third-party/myspine路径下放置对应spine版本spine-widget.jsspine-skeleton-binary.js

image-20211102195054233

spine-widget.js

比如3.6.53版本的可以在https://github.com/EsotericSoftware/spine-runtimes/blob/3.6.53/spine-ts/build/spine-widget.js里下载。

不同版本的spine改中间的版本号就行。

image-20211102195156304

spine-skeleton-binary.js

这个我死活找不到按版本号分类的,参考文章里给的是3.5.51版的spine-skeleton-binary.js

鉴于这个文件是用来解析skel文件二进制的,逆向手思索了一下,突然反应过来Skeleton Viewer不也是解析skel文件二进制诶,那不如……用jd-gui逆了一下skeletonViewer-3.6.53.jarskeletonViewer-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路径下。

image-20211102201351068

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路径下。

image-20211102201924645

.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"

image-20211102202119076

And...

在参考文章(在博客中添加明日方舟小人 - Stalo Fantasia♪)基础上的improve:

  1. 模型数量扩展,可使用多个模型。
  2. 模型动作展示从顺序切换更改为随机切换,增加互动乐趣。
  3. Idle动作和语音扩展,可用多个动作和语音,并增加到达一定时长随机切换的功能。
  4. 对interact动作进行时间限制,防止某些动作后摇过长。
  5. 调整部分CSS样式。

一些TODO:

  1. 砍语音部分(感觉容易社死,下个版本全盘砍掉)。
  2. 将语音文本独立出来做展示文字,可以多项文本随机切换。

如果想继续魔改的朋友们,把数据通路啥的梳理清楚了就很容易改啦~


__EOF__

本文作者c10udlnk
本文链接https://www.cnblogs.com/c10udlnk/p/15846185.html
关于博主:欢迎关注我的个人博客-> https://c10udlnk.top/
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   c10udlnk  阅读(743)  评论(7编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示