
在Hexo博客里放一只明日方舟小人的SpineModel
0. 前言
首先,如果你正好在使用 Hexo 博客,并且正好对明日方舟的小人模型感兴趣,想把模型放到自己的博客里,那么本文可以放心阅读。
对于其他朋友来说,大概也可以参考下边的教程来获得一部分参考。
效果预览:
- 最开始是看到了这个:在你的博客里放一只可爱的Spine Model吧,但是当时没什么鼓捣前端的经验,跟着教程做了一遍仍然没有成功
- 后来在B站上看到了 Ark-Pets 项目,又激起了我的兴趣,于是又去找教程,找到了这个:Blog 添加 2d 模型 | Weakyon Blog,拼尽全力仍无法战胜,被 3.5.51 版本的模型击败了(我也忘了当时具体是怎么回事了)
- 一直到现在才面向 DeepSeek 并结合官方的源码里边藏的 demo 解决问题,终于能把自推放到自己的博客里了,这太酷了
下边记录一下是怎样完成的:
1. Spine 模型
我使用的模型是明日方舟官方的 Spine 模型,来自这个仓库:
需要知道的是,这些模型的文件包含了 skel, atlas, png 三种文件,我下边的代码是基于这三个文件的。尤其是 skel 文件,由于我使用 Spine 导出的 json 无法正确导入,所以干脆就让 DeepSeek 写了一个使用 skel 文件的 Player,所以如果你没有 skel 文件的话,需要在我的代码的基础上进行修改,使用 Spine Runtime 内置的 json 读取函数
2. Spine 引擎
我面向 DeepSeek 写的魔改版 spine-player:
1 | // 全局配置 const spine_model_path = "path/to/spine-models/"; var MODELS = [ // 模型列表 "model_1", "model_2", // 可用模型列表 ]; var DEFAULT_ANIMATION = "Relax"; // 默认动画 var SKIN_NAME = "default"; // 皮肤名称 var PREMULTIPLIED_ALPHA = true; // 是否启用 Premultiplied Alpha(请注意,自《明日方舟》v2.1.41 起,新增的模型在渲染时需要禁用 Premultiplied Alpha,否则可能导致Alpha图层纹理异常。) var NUM_SKELETONS = 1; // 渲染的骨架数量 var SCALE = 0.4; // 缩放比例 var RANDOM_MODEL = true; // 是否启用随机模型 var lastFrameTime = Date.now() / 1000; var canvas, gl, shader, batcher, mvp, assetManager, skeletonRenderer, debugRenderer, shapes; var skeletons = []; var activeSkeleton; // 当前活动的骨架 var isPlayingDefaultAnimation = true; // 是否正在播放默认动画 var availableAnimations = []; // 模型支持的动作列表 var isUninterruptible = false; // 是否正在播放无法被打断的动作 var currentAnimation = DEFAULT_ANIMATION; // 当前正在播放的动作 function init() { canvas = document.getElementById("spine-canvas"); canvas.width = 300; canvas.height = 300; // 初始化 WebGL 上下文 var config = { alpha: true, premultipliedAlpha: PREMULTIPLIED_ALPHA }; gl = canvas.getContext("webgl", config) || canvas.getContext("experimental-webgl", config); if (!gl) { alert('WebGL is unavailable.'); return; } // 创建着色器、批处理器和 MVP 矩阵 shader = spine.webgl.Shader.newTwoColoredTextured(gl); batcher = new spine.webgl.PolygonBatcher(gl); mvp = new spine.webgl.Matrix4(); // 初始化渲染器和调试渲染器 skeletonRenderer = new spine.webgl.SkeletonRenderer(gl); skeletonRenderer.premultipliedAlpha = PREMULTIPLIED_ALPHA; // 设置预乘 Alpha debugRenderer = new spine.webgl.SkeletonDebugRenderer(gl); debugRenderer.drawRegionAttachments = true; debugRenderer.drawBoundingBoxes = true; debugRenderer.drawMeshHull = true; debugRenderer.drawMeshTriangles = true; debugRenderer.drawPaths = true; // 初始化资源管理器 assetManager = new spine.webgl.AssetManager(gl); // 随机选择模型 activeSkeleton = RANDOM_MODEL ? MODELS[Math.floor(Math.random() * MODELS.length)] : MODELS[0]; // 加载资源 assetManager.loadBinary(spine_model_path + activeSkeleton + ".skel"); // 加载 .skel 文件 assetManager.loadText(spine_model_path + activeSkeleton + ".atlas"); assetManager.loadTexture(spine_model_path + activeSkeleton + ".png"); // 添加点击事件监听器 var widget = document.getElementById("spine-widget"); widget.addEventListener("click", onClick); requestAnimationFrame(load); } function onClick() { // 如果正在播放无法被打断的动作,则忽略点击 if (isUninterruptible) return; if (availableAnimations.length > 0) { // 过滤掉当前正在播放的动作(interact 和 special 除外) var availableActions = availableAnimations.filter(anim => anim !== currentAnimation || anim === "interact" || anim === "special" ); // 随机选择一个支持的动作 var randomAnimation = availableActions[Math.floor(Math.random() * availableActions.length)]; // 判断是否需要循环播放 var shouldLoop = ["Sleep", "Sit", "Move"].includes(randomAnimation); // 判断是否是无法被打断的动作 isUninterruptible = ["interact", "special"].includes(randomAnimation); // 切换到点击触发的动画 for (var i = 0; i < skeletons.length; i++) { var state = skeletons[i].state; state.setAnimation(0, randomAnimation, shouldLoop); // 根据 shouldLoop 决定是否循环播放 // 如果不是循环播放的动作,则在播放完成后回到默认动画 if (!shouldLoop) { state.addAnimation(0, DEFAULT_ANIMATION, true, 0); // 播放完成后回到默认动画 } } // 更新当前正在播放的动作 currentAnimation = randomAnimation; isPlayingDefaultAnimation = false; } } function load() { if (assetManager.isLoadingComplete()) { // 加载骨架数据 for (var i = 0; i < NUM_SKELETONS; i++) { var skeletonData = loadSkeleton(activeSkeleton, DEFAULT_ANIMATION, PREMULTIPLIED_ALPHA, SKIN_NAME); skeletons.push(skeletonData); } requestAnimationFrame(render); } else { requestAnimationFrame(load); } } function loadSkeleton(name, initialAnimation, premultipliedAlpha, skin) { if (skin === undefined) skin = "default"; // 加载纹理图集 var atlas = new spine.TextureAtlas(assetManager.get(spine_model_path + name + ".atlas"), function(path) { return assetManager.get(spine_model_path + path); }); // 创建附件加载器 var atlasLoader = new spine.AtlasAttachmentLoader(atlas); // 使用 SkeletonBinary 加载 .skel 文件 var skeletonBinary = new spine.SkeletonBinary(atlasLoader); skeletonBinary.scale = SCALE; // 设置缩放比例 var skeletonData = skeletonBinary.readSkeletonData(assetManager.get(spine_model_path + name + ".skel")); // 获取模型支持的动作列表 availableAnimations = skeletonData.animations.map(anim => anim.name); // 检查默认动画是否存在 if (!availableAnimations.includes(DEFAULT_ANIMATION)) { DEFAULT_ANIMATION = availableAnimations[0]; // 使用第一个动作作为默认动作 } // 创建骨架和动画状态 var skeleton = new spine.Skeleton(skeletonData); skeleton.setSkinByName(skin); skeleton.setToSetupPose(); skeleton.updateWorldTransform(); // 设置模型的初始位置 skeleton.x = 0; // 水平居中 skeleton.y = -100; // 向下偏移 100 像素 var animationStateData = new spine.AnimationStateData(skeleton.data); var animationState = new spine.AnimationState(animationStateData); animationState.setAnimation(0, DEFAULT_ANIMATION, true); // 监听动画完成事件 animationState.addListener({ complete: function(entry) { // 如果当前动画不是循环播放的动作,则回到默认动画 if (!["Sleep", "Sit", "Move"].includes(entry.animation.name)) { isPlayingDefaultAnimation = true; currentAnimation = DEFAULT_ANIMATION; } // 如果当前是无法被打断的动作,则重置标志 if (["interact", "special"].includes(entry.animation.name)) { isUninterruptible = false; } } }); // 返回骨架和动画状态 return { skeleton: skeleton, state: animationState }; } function render() { var now = Date.now() / 1000; var delta = now - lastFrameTime; lastFrameTime = now; // 限制 delta 的最大值,避免跳帧 if (delta > 0.1) delta = 0.1; // 调整画布大小 resize(); // 清除画布 gl.clearColor(0, 0, 0, 0); // 设置背景颜色 gl.clear(gl.COLOR_BUFFER_BIT); // 设置混合模式 gl.enable(gl.BLEND); gl.blendFunc(PREMULTIPLIED_ALPHA ? gl.ONE : gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // 更新并渲染每个骨架 for (var i = 0; i < skeletons.length; i++) { var state = skeletons[i].state; var skeleton = skeletons[i].skeleton; // 更新动画状态 state.update(delta); state.apply(skeleton); skeleton.updateWorldTransform(); // 绑定着色器并设置 MVP 矩阵 shader.bind(); shader.setUniformi(spine.webgl.Shader.SAMPLER, 0); shader.setUniform4x4f(spine.webgl.Shader.MVP_MATRIX, mvp.values); // 渲染骨架 batcher.begin(shader); skeletonRenderer.draw(batcher, skeleton); batcher.end(); shader.unbind(); } requestAnimationFrame(render); } function resize() { var w = canvas.width; var h = canvas.height; if (canvas.width != w || canvas.height != h) { canvas.width = w; canvas.height = h; } // 更新 MVP 矩阵 mvp.ortho2d(-(w / 2) - 20, 0 - 150, w, h); // 这里需要根据模型的动作进行合理修改 gl.viewport(0, 0, w, h); } // 初始化 init(); |
官方的 3.8 版本的 spine-webgl(太长了就不放在这儿了):spine-webgl.js
一开始参考的那篇文章的博主使用的是官方的 3.6.53 的 spine-widget.js,然而我一开始使用的模型是 Ark-Pets 项目里边使用的某个版本的模型,我已经忘了是哪个版本了,可能是 3.8,但是由于我当时没找到 3.8 的 Skeleton Viewer,所以转换成了 3.5.51 版本的,但是当时转换完之后仍然不会搞
最近想起来搞这个的时候,一开始我是没有任何头绪的,甚至都不知道该怎样问 AI,所以我就去看了一眼 Spine Runtime 的源码,发现在 spine-ts 的源码里边有几个 example.html,于是就把这几个 example 丢给 DeepSeek,再经过了许多次修改之后,终于写了一个基于 spine-webgl 的魔改版 spine-player
其实 3.8 版本有官方的 spine-player,但是由于我一开始用的模型是 3.5.51 版本的,并没有对应版本的 spine-player,所以就仍然利用 spine-webgl 搞了
3. Hexo 注入器
相关代码:
1 | hexo.extend.injector.register( 'body_end', // 注入到页面 body 的末尾 ` <script> // 检测是否为移动设备 function isMobileDevice() { // 通过 userAgent 检测常见的移动设备 return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); } // 如果不是移动设备,则加载 Spine 小部件 if (!isMobileDevice()) { const spineWidget = document.createElement("div"); spineWidget.id = "spine-widget"; spineWidget.innerHTML = '<canvas id="spine-canvas"></canvas>'; document.body.appendChild(spineWidget); // 动态加载 Spine 运行时库 const spineScript = document.createElement("script"); spineScript.src = "https://rimrose.top/spine-widget/spine-webgl.js"; spineScript.async = true; spineScript.onload = function() { // Spine 运行时库加载完成后,初始化 Spine 小部件 const canvas = document.getElementById("spine-canvas"); canvas.width = 300; canvas.height = 300; // 初始化 Spine 动画逻辑 const spineLogicScript = document.createElement("script"); spineLogicScript.src = "https://rimrose.top/spine-widget/spine-player.js"; document.body.appendChild(spineLogicScript); }; document.body.appendChild(spineScript); } </script> `, 'default' // 注入到所有页面 ); // 动态加载外置的 CSS 文件(仅在非移动设备时加载) hexo.extend.injector.register( 'head_end', // 注入到页面 head 的末尾 ` <script> // 检测是否为移动设备 function isMobileDevice() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); } // 如果不是移动设备,则加载 CSS 文件 if (!isMobileDevice()) { const spineCSS = document.createElement("link"); spineCSS.rel = "stylesheet"; spineCSS.href = "https://rimrose.top/spine-widget/spine-widget.css"; document.head.appendChild(spineCSS); } </script> `, 'default' // 注入到所有页面 ); |
里边提到的 spine-widget.css:
1 | /* Spine 小部件容器 */ #spine-widget { position: fixed; bottom: 12px; right: 0; width: 250px; height: 250px; background-color: rgba(0,0,0,0); overflow: hidden; cursor: pointer; z-index: 999; } /* 画布 */ #spine-canvas { width: 100%; height: 100%; } |
4. 使用方法
在 Hexo 根目录的 scripts 目录下创建一个名为 spine-widget.js 的文件,并粘贴上述的注入器代码。
在 source 文件夹合适的地方创建一个 spine-widget 文件夹,在里边放上第二步提到的 spine-player.js 和 spine-webgl.js 文件以及第三步提到的 spline-widget.css 文件,可以同时在这个文件夹下创建一个 assets 文件夹,用来放你需要的模型
跟着做到这里就可以了,不过有一点需要注意的是,文件之间的路径会出现一点问题,建议在 hexo deploy 之后将上边出现的路径全部改为链接,比如我的博客里的 spine-widget 文件夹路径是:https://rimrose.top/spine-widget/
,这样可以避免路径问题
参考:
写在后面
Spine 官方是有关于如何使用 Spine Web Player 的教程的:Spine Web Player
但是呢:
好笑吗,我只看到了一个绝望的不会前端的 CV 工程师(逃)
上次鼓捣这个的时间居然是2024-06-11,令人感叹