概述
精灵图(Sprite)是一种将多个小图像合并到单个图像文件中的技术,广泛应用于网页开发、游戏开发和UI设计中。在MapboxGL中,跟之配套的还有一个json文件用来记录图标的大小和位置。本文分享基于Node和sharp
库实现精灵图的合并与拆分。
实现效果
代码实现
将拆分和合并封装成了一个方法,实现代码如下:
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;
调用代码如下:
// 引用
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" // 输出目录
// );