微信小程序 - 龙骨图集拆分

发布时间:2023年12月18日

注意

只支持了JSON版本

目录结构

在这里插入图片描述

演示动画

Spine播放器1.5.0_PC端

Spine播放器1.5.1_移动端

废话一下

这是 SpinePlayer 2D骨骼动画播放器 - 微信小程序版 工具箱中的一个功能。
功能很简单,80% 的代码都在处理交互逻辑,以及移动端PC端兼容方面的问题。

业务逻辑

  1. 读取JSON文件和PNG图片。
  2. 解析JSON得到所有小图在图集(PNG图片)中的 x,y 坐标和高宽。
  3. 将PNG图集绘制到2d画布,然后使用 canvasToTempFilePath 逐个截取区域,保存为小图。
  4. 最后将所有小图打包为 zip 供用户保存即可。

注意点

  1. 为了保证截取图片的清晰度,画布尺寸需要用图片大小乘以设备像素比
  2. 图片填充完整,再截图。否则会空白。所以会成两步操作比较稳妥。当然也可以自己控制延时自动调用,一气呵成。
  3. 因为 2d 画布不便于直接显示,所以使用一个 image 组件来实现预览。
    3.1. 方法是将 PNG 读取为 base64 赋给 image 组件 <image src="{{textureBase64}}" />
    3.2. 读取 PNGbase64 就这句 fs.readFileSync(‘临时图片路径’, 'base64') 当然用的时候还要拼接一下头,详情看源码吧。

龙骨JSON图集结构

可以看出结构非常简单,直接读 SubTexture 数组,遍历它进行截取就可以了。

{
  "imagePath": "body_tex.png",
  "width": 1024,
  "SubTexture": [
    {
      "height": 472,
      "y": 1,
      "width": 295,
      "name": "body/a_arm_L",
      "x": 720
    }
    略。。。
  ],
  "name": "body",
  "height": 1024
}

源码分享

dragonbones-split.js

// packageTools/pages/dragonbones-split/dragonbones-split.js
const fileUtil = require('../../../utils/fileUtil.js');
const imgUtil = require('./imgUtil.js');
const pixelRatio = wx.getSystemInfoSync().pixelRatio; // 设备像素比
let canvas, ctx;
let globalData = getApp().globalData;
let dbsplit = globalData.PATH.dbsplit;
Page({
  /**
   * 页面的初始数据
   */
  data: {
    canvasWidth: 300,                     // 画布宽
    canvasHeight: 150,                    // 画布高
    texture: {},                          // 龙骨图集PNG图片信息 { path, width, height, orientation, type }
    textureBase64: '',                    // 龙骨图集PNG图片的 Base64 编码
    subTextureList:[],                    // 龙骨图集JSON数据。包含拆分出的小图地址 tempFilePath
    shareZipFile: { name: '', path: ''},  // 最终生成的ZIP
    jsonValue: '',                        // 文本框内容(PC 端用于获取 JSON)
    // parseInt('0011', 2) === 3
    status: 0,                            // 工作状态:0000 初始,0001 有图,0010 有JSON,0100 已拆图,1000 已ZIP
    isPC: false,
  },
  /**
   * 生命周期函数--监听页面加载
   */
  onLoad(options) {
    // 获取画布
    wx.createSelectorQuery()
    .select('#myCanvas') // 在 WXML 中填入的 id
    .fields({ node: true, size: true })
    .exec((res) => {
        canvas = res[0].node;           // Canvas 对象
        ctx = canvas.getContext('2d');  // 渲染上下文
    });
    // 创建工作目录
    fileUtil.mkdir(dbsplit); 
    this.setData({ isPC: globalData.systemInfo.isPC });
    // 清理场地
    fileUtil.clearDirSync(globalData.PATH.dbsplit);
  },
  /**
   * 粘贴龙骨图集 JSON 的文本框发生变化
   */
  onChange(event) {
    try {
      if(event.detail.value === undefined){
        return;
      }
      let json = imgUtil.parseJSON(event.detail.value);
      this.data.shareZipFile = { name: `${json.name}.zip`, path: `${dbsplit}/${json.name}.zip`}; // zip路径打包时用
      this.setData({ 
        jsonValue: event.detail.value,
        status: 2,
        subTextureList: json.SubTexture,
      });
    } catch (err) {
      console.log(err);
      this.setData({ 
        jsonValue: '',
        status: 1, // this.data.status & 13, // parseInt('1101', 2)
        subTextureList: [],
      });
      wx.showToast({ title: 'JSON格式有误', icon: 'error' })
    }
  },

  /**
   * 选择龙骨图集PNG、JSON
   */
  async choosePNGJSON(e){
    console.log('选择龙骨图集PNG、JSON');
    wx.showLoading({ title: '选择图集' });
    imgUtil.chooseAtlas({ count: 2})
    .then(res => {
      if(res.length != 2){
        wx.showToast({ title: '文件数量异常!', icon: 'error' });
        return;
      }
      let texture, json;
      if(res[0].type === 'png'){
        [texture, json] = res;
      }else{
        [json, texture] = res;
      }
      wx.showLoading({ title: '解析图集' });
      this.data.texture = texture; // 更新图集PNG的相关信息。点击预览时会用到它的 path
      this.data.shareZipFile = { name: `${json.name}.zip`, path: `${dbsplit}/${json.name}.zip`}; // zip路径打包时用
      // 图集PNG填充画布
      this.fillCanvasWithImage(texture).then(()=>{
        // 填充完成后,在下一个时间片段更新数据
        this.setData({
          textureBase64: imgUtil.imageToBase64(texture.path, texture.type), // 更新 image 组件 src
          subTextureList: json.SubTexture,                                  // 更新页面上的 subTexture 列表
          status: 2,                                                        // 已选图,JSON完成解析
        },()=>{
          wx.hideLoading();
        });
      })
    }).catch(err => {
      console.log(err);
      wx.showToast({ title: '图集选择失败', icon: 'error' });
      this.setData(
        {
          textureBase64: '',              // 更新 image 组件 src
          subTextureList: [],             // 更新页面上的 subTexture 列表
          status: 0,
        }
      );
    }).finally(()=>{
      wx.hideLoading() 
    })
  },

  /**
   * 选择龙骨图集PNG文件
   */
  async choosePNG(e){
    console.log('选择龙骨图集PNG文件');
    let whereFrom = globalData.systemInfo.isPC ? 'media' : 'message';
    let promises = imgUtil.chooseImg({ count: 1, whereFrom }); // media message
    await promises.then(res => {
      let texture = res[0];
      console.log(texture);
      this.setData({ 
        texture , 
        textureBase64: imgUtil.imageToBase64(texture.path, texture.type),
        status: 1,
        // 重选图片后,清空已选的JSON
        jsonValue: '',        // 清除 JSON 数据
        subTextureList: []    // 清除 解析后的 JSON 数据
      }, res => {
        this.fillCanvasWithImage(texture); // 填充画布
      });
    }).catch(err => {
      console.log(err);
      wx.showToast({ title: '选择图片失败', icon: 'error' });
      this.setData({ 
        texture: {} , 
        textureBase64: '',
        status: 0, // this.data.status & 14,
      });
    });
  },
  
  /**
   * 将图片绘制到画布
   */
  fillCanvasWithImage(imageInfo){
    let { path, width, height, orientation, type } = imageInfo;
    // 按图片大小更新画布尺寸
    canvas.width = width * pixelRatio;
    canvas.height = height * pixelRatio;
    ctx.scale(pixelRatio, pixelRatio);
    return new Promise((resolve, reject)=>{
      // 更新画布 渲染宽高。完成后,绘制图片到画布
      this.setData({ canvasWidth: width, canvasHeight: height}, res=> {
        const image = canvas.createImage(); // 创建图片对象
        image.onload = () => ctx.drawImage(image, 0, 0); // 图片加载完成,在回调中将其绘制到画布
        image.src = path; // 设置图片 src
        resolve();
      });
    });
  },

  /**
   * 解析JSON并拆分图集
   */
  async parseJsonAndSplitIMG(e){
    console.log('解析JSON并拆分图集');
    if(this.data.status < 1){
      wx.showToast({ title: '请选择图片', icon: 'error'});
      return;
    }
    if(this.data.status < 2){
      wx.showToast({ title: '请提供JSON', icon: 'error'});
      return;
    }
    this.splitIMG();
  },
  
  /**
   * 拆分龙骨图集PNG文件
   */
  async splitIMG(e){
    console.log('拆分龙骨图集PNG文件');
    let pArr = this.data.subTextureList.map(subTexture => {
      return new Promise((resolve, reject)=> {
        let { x, y, width, height, } = subTexture;
        wx.canvasToTempFilePath({
          x, y, width, height, canvas,
          destWidth: width,
          destHeight: height,
          fileType: 'png',
          success: res => {
            console.log(res.tempFilePath);
            subTexture.tempFilePath = res.tempFilePath;
            resolve(subTexture);
          },
          fail: reject
        });
      });
    });
    Promise.all(pArr).then(async res => {
      await this.creatZip(res);
      this.setData({ status: 3, }); // 更新状态,完成拆图
    }).catch(err => {
      this.setData({ status: 2, });
    });
  },
  /**
   * 将拆好的小图打包为 ZIP
   */
  async creatZip(subTextureList){
    console.log('将拆好的小图打包为 ZIP');
    try {
      // 图片列表
      let fileList = subTextureList.map(subTexture => ({ name: subTexture.name, path: subTexture.tempFilePath}));
      // 创建压缩包
      await fileUtil.zip(fileList, this.data.shareZipFile.path, progress => { 
        wx.showLoading({ title: progress.msg, });
        if(progress.percent == 100){
          setTimeout(wx.hideLoading, 200);
        }
      });
      // 更新状态
      console.log(this.data.shareZipFile.path);
      this.setData({subTextureList});
    } catch (err) {
      console.error(err)
      wx.showToast({ icon: 'error', title: '打包失败' });
      this.setData({shareZipFile: {}});
    } finally {
      wx.hideLoading();
    }
  },

  /**
   * 将拆分后的文件打包导出
   */
  saveIMG(e){
    console.log('将拆分后的文件打包导出');
    console.log(this.data.subTextureList);
    if(this.data.status < 3){
      wx.showToast({ title: '尚未拆图', icon: 'error' });
      return;
    }
    // 如果是电脑端,否则是手机端
    if(globalData.systemInfo.platform == 'windows' 
    || globalData.systemInfo.platform == 'mac'
    // || globalData.systemInfo.platform == 'devtools'
    ){
      wx.saveFileToDisk({
        filePath: this.data.shareZipFile.path,
        success: console.log,
        fail: console.error
      });
    } else {
      wx.shareFileMessage({
        filePath: this.data.shareZipFile.path,
        fileName: this.data.shareZipFile.name,
        success: console.log,
        fail: console.error,
        complete: console.log
      });
    }
  },
  async previewTexture(e){
    if(!!this.data.texture.path == false && globalData.systemInfo.isPC){
      await this.choosePNG();
      return;
    }
    wx.previewImage({
      urls: [this.data.texture.path],
      success: (res) => {},
      fail: (res) => {},
      complete: (res) => {},
    })
  },
  previewSubTexture(e){
    if(this.data.status < 3){
      wx.showToast({ title: '尚未拆分', icon:"error" });
      return;
    }
    wx.previewImage({
      urls: this.data.subTextureList.map(obj => obj.tempFilePath),
      current: e.currentTarget.dataset.texturePath,
      showmenu: true,
      success: (res) => {},
      fail: (res) => {},
      complete: (res) => {},
    })
  }
})

dragonbones-split.json

{
  "usingComponents": {}
}

dragonbones-split.wxml

<!--packageTools/pages/dragonbones-split/dragonbones-split.wxml-->
<!-- 大家好我是笨笨,笨笨的笨,笨笨的笨,谢谢!https://blog.csdn.net/jx520/ -->
<view class="main-container bg-60" >
  <view class="top-container poem-container poem-h bg-24" style="min-height: 200rpx;">
    <view></view>
    <view>无根翡翠顺江涛, 有尾鱼虾逆水潮。</view>
    <view>行宿天涯本无路, 去留飘渺也逍遥。</view>
  </view>
  <view class="top-container scroll-y home-top" disabled>
    <image class="texture chessboard" src="{{textureBase64}}" bind:tap="previewTexture"/>
    <view wx:for="{{subTextureList}}" wx:key="name"
      bindtap="previewSubTexture" data-texture-name="{{item.name}}" data-texture-path="{{item.tempFilePath}}"
      class="sub-texture-list {{status < 3 ? 'disabled' : 'splited'}}">
      <view class="sub-texture-row"> 
        <view>{{item.name}}</view>
        <view>{{item.width}} x {{item.height}}</view>
      </view>
    </view>
    <view class="sub-texture-list" wx:if="{{isPC}}">
      <textarea class="json-area" auto-height	maxlength="-1" placeholder="请在此处粘贴龙骨图集的 JSON 内容" value="{{jsonValue}}"
        bindblur="onChange" bindlinechange="onChange" bindconfirm="onChange" bindinput="onChange" />
    </view>
    <canvas id="myCanvas" type="2d" class="canvas2d" style="width: {{canvasWidth}}px; height: {{canvasHeight}}px;" />
  </view>
  <view class="bottom-container">
      <!-- 移动端 -->
      <view wx:if="{{isPC == false}}">
        <view class="but-row">
          <view class="button button-large" bindtap="choosePNGJSON">
            <view>选择龙骨PNGJSON</view>
          </view>
          <view class="button button-large {{status < 2 ? 'disabled' : ''}}" bindtap="parseJsonAndSplitIMG">
            <view>拆分图集</view>
          </view>
        </view>
        <view class="but-row">
          <view class="button button-large {{status < 3 ? 'disabled' : ''}}" bindtap="saveIMG">
            <view>转发到聊天</view>
          </view>
        </view>
      </view>
      <!-- PC-->
      <view wx:if="{{isPC}}">
        <view class="but-row">
          <view class="button button-large" bindtap="choosePNG">
            <view wx:if="{{textureBase64 === ''}}">选择图片</view>
            <view wx:if="{{textureBase64 != ''}}">重选图片</view>
          </view>
          <view class="button button-large {{status < 2 ? 'disabled' : ''}}" bindtap="parseJsonAndSplitIMG">
            <view>拆分图集</view>
          </view>
        </view>
        <view class="but-row">
          <view class="button button-large {{status < 3 ? 'disabled' : ''}}" bindtap="saveIMG">
            <view>保存</view>
          </view>
        </view>
      </view>
      <view class="gb-img"></view>
  </view>
</view>

dragonbones-split.wxss

/* packageTools/pages/dragonbones-split/dragonbones-split.wxss */
@import '/common/wxss/player-page-common.wxss';

.button{
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  margin: 10rpx;
  padding: 10rpx;
  color: rgb(236, 236, 236);
  
  box-sizing: border-box;
}
.button-large{
  display: flex;
  justify-content: center;
  text-align: center;
  font-size: large;
  width: auto;
  height: 100%;
  max-height: 50%;
  box-sizing: border-box;
  flex-grow: 1;
  border-radius: 10px;
  background-color: rgb(102, 102, 102);
  border-top: 1px solid rgb(112, 112, 112);
  border-bottom: 2px solid rgb(90, 90, 90);
}

.but-row {
  display: flex;
}
.sub-texture-row {
  display: flex;
  align-items: center;
  padding: 0 20rpx;
  border-bottom: rgb(102, 102, 102) solid 1px;
  box-sizing: border-box;
}
.sub-texture-row>view {
  margin: 4px;
}
.sub-texture-row :nth-child(1) {
  width: 70%;
  word-wrap: break-word;
  border-right: #666 solid 1px;
}
.sub-texture-row :nth-child(2) {
  margin-left: 5px;
  width: 30%;
  height: 100%;
}

.texture {
  padding: 6px;
  width: 100%;
  border: rgb(63, 63, 63) solid 2px;
  box-sizing: border-box;
}
.canvas2d {
  position: absolute;
  right: 100vw;
}
.sub-texture-list {
  display: flex;
  flex-direction: column;
  padding: 5rpx 20rpx;
}
.json-area {
  background-color: #000;
  border-radius: 10rpx;
  width: 100%;
  padding: 20rpx;
  box-sizing: border-box;
  font-size: x-small;
  min-height: 430rpx;
}

.splited {
  color: chartreuse;
}

imgUtil.js

const fs = wx.getFileSystemManager();

/**
 * 选择图集(PNG和JSON一对)
 * @param {object} options 
 */
function chooseAtlas(_options = {}){
  const defaultOptions = { count: 2 };
  let options = { ...defaultOptions, ..._options };
  // 选择 PNG、JSON
  let promise = wx.chooseMessageFile(options)
  .then(res => res.tempFiles) 
  .then(tempFiles => {
    return tempFiles.map(tempFilePath => {
      if(tempFilePath.type === 'image'){ // 图片
        return wx.getImageInfo({ src: tempFilePath.path })
        .then(res => {
          let { path, width, height, orientation, type } =  res;
          let imageInfo = { path, width, height, orientation, type };
          return imageInfo;
        });
      }else if(tempFilePath.type === 'file' && tempFilePath.path.toLowerCase().endsWith('.json')){ // JSON
        return parseJSON(fs.readFileSync(tempFilePath.path, 'utf-8'));
      }else{
        return null;
      }
    }).filter(obj => obj != null);
  }).catch(err=> {
    console.log(err);
    return [];
  });
  // 全部完成再返回
  return promise.then(promiseArr => {
    return Promise.all(promiseArr).then(res => res);
  })
}

/**
 * 选择图片
 * @param {object} options 
 */
function chooseImg(_options = {}){
  const defaultOptions = {
    count: 9, mediaType: ['image'],
    whereFrom: 'message' , // 从何处选取。合法值:message media
  };
  let options = { ...defaultOptions, ..._options };
  let promise;
  // 根据参数中给的选择方式,调用对应方法。
  switch (options.whereFrom) {
    case 'media':
      promise = wx.chooseMedia(options)
        .then(res => res.tempFiles.map( data => data.tempFilePath) )
        .catch(err=> {
          console.log(err);
          return [];
        });
      break;
    default:
      promise = wx.chooseMessageFile(options)
        .then(res => res.tempFiles.map( data => data.path) )
        .catch(err=> {
          console.log(err);
          return [];
        });
      break;
  }
  // 对选择的图片,获取信息。构建好对象返回
  return promise.then(tempFiles => {
    return tempFiles.map(tempFilePath => {
      return wx.getImageInfo({ src: tempFilePath })
      .then(res => {
        let { path, width, height, orientation, type } =  res;
        let imageInfo = { path, width, height, orientation, type };
        return imageInfo;
      });
    });
  }).then(promiseArr => { // 全部完成再返回
    return Promise.all(promiseArr).then(res => res);
  });
}

/**
 * 从 tempFilePath 以 base64 格式读取文件内容
 * @param {*} tempFilePath 
 * @param {*} type 图片类型是提前通过 getImageInfo 获取的
 */
function imageToBase64(tempFilePath, type) {
  let data = fs.readFileSync(tempFilePath, 'base64');
  return `data:image/${type};base64,${data}`;
}

/**
 * 解析龙骨图集的JSON
 * @param {string} dbJsonTxt 龙骨图集的JSON
 */
function parseJSON(dbJsonTxt){
  // 解析JSON
  let json = JSON.parse(dbJsonTxt);
  // 从 SubTexture 中取出图片名
  // 判断是否有重复,如果重复就用完整路径名,否则:就直接用图片名
  let arr = json.SubTexture.map(st => st.name.substr(st.name.lastIndexOf('/')+1));
  // { "x": 2, "y": 2, "width": 554, "height": 140, "name": "weapon_hand_r"}
  arr = json.SubTexture.map(subTexture => {
    if(arr.length !== new Set(arr).size){
      subTexture.name = `${subTexture.name.replace(/\//g, '-')}.png`;
    }else{
      subTexture.name = `${subTexture.name.substr(subTexture.name.lastIndexOf('/')+1)}.png`;
    }
    return subTexture;
  });
  console.log(arr);
  json.SubTexture = arr;
  json.type = 'json';
  return json;
}

// /**
//  * 选择JSON。(PC端的弹窗竟然不支持输入功能。此方法没用上)
//  * @param {object} options 
//  */
// function chooseJSON(_options = {}){
//   const defaultOptions = {
//     count: 1,
//     whereFrom: 'modal' , // 从何处选取。合法值: modal message
//   };
//   let options = { ...defaultOptions, ..._options };
//   let promise;
//   // 根据参数中给的选择方式,调用对应方法。
//   switch (options.whereFrom) {
//     case 'modal':
//       promise = wx.showModal({
//           title: '龙骨图集JSON',
//           placeholderText: '请输入龙骨图集JSON内容',
//           confirmText: '解析',
//           editable: true,
//         }).then(res => {
//           if (res.confirm && res.errMsg === "showModal:ok" ) {
//             console.log(res.content);
//             return res.content;
//           } else if (res.cancel) {
//             console.log('用户点击取消')
//             return Promise.reject();
//           }
//         });
//       break;
//     default:
//       promise = wx.chooseMessageFile(options)
//         .then(res => res.tempFiles.map( data => fs.readFileSync(data.path, 'utf-8')))
//         .catch(err=> {
//           console.log(err);
//           return '';
//         });
//       break;
//   }
//   return promise;
// }

module.exports = {
  chooseAtlas,
  chooseImg,
  imageToBase64,
  parseJSON,
}

参考资料

笑虾:微信小程序 - 创建 ZIP 压缩包
笑虾:微信小程序 - 文件工具类 fileUtil.js

文章来源:https://blog.csdn.net/jx520/article/details/135021602
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。