分享一个精灵图生成和拆分的实现

概述

精灵图(Sprite)是一种将多个小图像合并到单个图像文件中的技术,广泛应用于网页开发、游戏开发和UI设计中。在MapboxGL中,跟之配套的还有一个json文件用来记录图标的大小和位置。本文分享基于Node和sharp库实现精灵图的合并与拆分。

实现效果


代码实现

将拆分和合并封装成了一个方法,实现代码如下:

js 复制代码
const sharp = require("sharp");
const fs = require("fs").promises;
const path = require("path");

// 二叉树节点类
class Node {
  constructor(x, y, width, height) {
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
    this.used = false;
    this.right = null;
    this.down = null;
  }

  // 查找可以放置图片的节点
  find(width, height) {
    // 如果当前节点已被使用,在子节点中查找
    if (this.used) {
      return this.right?.find(width, height) || this.down?.find(width, height);
    }

    // 检查图片是否适合当前节点
    if (width <= this.width && height <= this.height) {
      return this;
    }

    return null;
  }

  // 分割节点
  split(width, height) {
    this.used = true;

    // 创建右侧节点
    this.right = new Node(this.x + width, this.y, this.width - width, height);

    // 创建底部节点
    this.down = new Node(
      this.x,
      this.y + height,
      this.width,
      this.height - height
    );

    return this;
  }
}

class SpriteManager {
  constructor() {
    this.metadata = {
      sprites: {},
      width: 0,
      height: 0,
    };
  }

  /**
   * 将多个图片合并成一个精灵图
   * @param {string} inputDir 输入图片目录
   * @param {string} outputImage 输出精灵图路径
   * @param {string} outputJson 输出JSON文件路径
   */
  async createSprite(inputDir, outputImage, outputJson) {
    const start = Date.now();
    try {
      // 读取目录下所有图片
      const files = await fs.readdir(inputDir);
      const images = files.filter((file) => /\.(png|jpg|jpeg)$/i.test(file));

      // 并行处理图片元数据
      const imageMetadata = await Promise.all(
        images.map(async (file) => {
          const imagePath = path.join(inputDir, file);
          const image = sharp(imagePath);
          const metadata = await image.metadata();
          const name = file.split(".")[0];

          // 预处理图片 - 统一转换为PNG格式并缓存
          const buffer = await image.png().toBuffer();

          return {
            name,
            width: metadata.width,
            height: metadata.height,
            buffer,
          };
        })
      );

      // 按面积从大到小排序
      imageMetadata.sort((a, b) => b.width * b.height - a.width * a.height);

      // 计算初始画布大小
      const totalArea = imageMetadata.reduce(
        (sum, img) => sum + img.width * img.height,
        0
      );
      const estimatedSide = Math.ceil(Math.sqrt(totalArea * 1.1));

      // 创建根节点
      let root = new Node(0, 0, estimatedSide, estimatedSide);
      let maxWidth = 0;
      let maxHeight = 0;

      // 使用二叉树算法放置图片
      for (const img of imageMetadata) {
        // 查找合适的节点
        let node = root.find(img.width, img.height);

        // 如果找不到合适的节点,扩展画布
        if (!node) {
          // 创建新的更大的根节点
          const newRoot = new Node(0, 0, root.width * 1.5, root.height * 1.5);
          newRoot.used = true;
          newRoot.down = root;
          root = newRoot;
          node = root.find(img.width, img.height);
        }

        // 分割节点并记录位置
        if (node) {
          const position = node.split(img.width, img.height);
          this.metadata.sprites[img.name] = {
            x: position.x,
            y: position.y,
            width: img.width,
            height: img.height,
          };

          // 更新最大尺寸
          maxWidth = Math.max(maxWidth, position.x + img.width);
          maxHeight = Math.max(maxHeight, position.y + img.height);
        }
      }

      // 更新最终画布尺寸
      this.metadata.width = maxWidth;
      this.metadata.height = maxHeight;

      // 创建并合成图片
      const composite = sharp({
        create: {
          width: this.metadata.width,
          height: this.metadata.height,
          channels: 4,
          background: { r: 0, g: 0, b: 0, alpha: 0 },
        },
      });

      // 一次性合成所有图片
      const compositeOperations = imageMetadata.map((img) => ({
        input: img.buffer,
        left: this.metadata.sprites[img.name].x,
        top: this.metadata.sprites[img.name].y,
      }));

      await composite
        .composite(compositeOperations)
        .png({ quality: 100 })
        .toFile(outputImage);

      // 保存JSON文件
      await fs.writeFile(outputJson, JSON.stringify(this.metadata.sprites));
      
      const end = Date.now();
      console.log("精灵图创建完成, 耗时" + (end - start) / 1000 + "s");
    } catch (error) {
      throw new Error(`创建精灵图失败: ${error.message}`);
    }
  }

  /**
   * 从精灵图中提取单个图片
   * @param {string} spriteImage 精灵图路径
   * @param {string} jsonFile JSON文件路径
   * @param {string} outputDir 输出目录
   */
  async extractSprites(spriteImage, jsonFile, outputDir) {
    // 读取JSON文件
    const metadata = JSON.parse(await fs.readFile(jsonFile, "utf-8"));

    // 确保输出目录存在
    await fs.mkdir(outputDir, { recursive: true });

    // 提取每个图片
    for (const [filename, info] of Object.entries(metadata)) {
      const iconPath = path.join(outputDir, filename + ".png");
      sharp(spriteImage)
        .extract({
          left: info.x,
          top: info.y,
          width: info.width,
          height: info.height,
        }) // 裁剪区域
        .toFile(iconPath)
        .then((_info) => {
          console.log("Image cropped successfully:", _info);
        })
        .catch((error) => {
          console.log(iconPath, info);
          console.error("Error processing image:", error);
        });
    }
  }
}

module.exports = SpriteManager;

调用代码如下:

js 复制代码
// 引用
const SpriteManager = require("./sprite/sprite");

const spriteManager = new SpriteManager();

// 创建精灵图
spriteManager.createSprite(
  "./sprite/icons", // 输入图片目录
  "./sprite/sprite.png", // 输出精灵图路径
  "./sprite/sprite.json" // 输出JSON文件路径
);
// 拆分精灵图
// spriteManager.extractSprites(
//   "./sprite/sprite.png", // 精灵图路径
//   "./sprite/sprite.json", // JSON文件路径
//   "./sprite/icons" // 输出目录
// );
相关推荐
Flamesky2 个月前
unity assetbundle 加载图集的所有sprite图片
unity·sprite
棉猴7 个月前
Pygame中Sprite类实现多帧动画3-3
pygame·sprite·精灵·多帧动画
棉猴1 年前
Pygame中将鼠标形状设置为图片2-1
pygame·sprite·鼠标显示图片·精灵类
棉猴1 年前
Pygame中将鼠标形状设置为图片2-2
pygame·sprite·精灵·鼠标显示图片
棉猴2 年前
Pygame中Sprite的使用方法6-6
pygame·group·碰撞检测·sprite·spritecollide