89 lines
3.3 KiB
JavaScript
89 lines
3.3 KiB
JavaScript
// Splits a sheet of separate sprites (e.g. the belt's rocks, CR-016) into one
|
|
// image per sprite, by connected regions of visible pixels. Read-only on the source.
|
|
|
|
/** Pixels at least this opaque belong to a sprite; fainter glow is dropped. */
|
|
export const MIN_ALPHA = 16;
|
|
/** Regions smaller than this share of the sheet are stray specks, not sprites. */
|
|
export const MIN_AREA_SHARE = 0.002;
|
|
|
|
/**
|
|
* Labels the connected regions (4-neighbour) of pixels with alpha ≥ MIN_ALPHA.
|
|
* Returns the label per pixel (0 = none) and each kept region's bounding box,
|
|
* in reading order (by row of their top edge, then left to right).
|
|
*/
|
|
export function findRegions(alpha, width, height) {
|
|
const labels = new Int32Array(width * height);
|
|
const minArea = Math.max(1, Math.floor(width * height * MIN_AREA_SHARE));
|
|
const regions = [];
|
|
let next = 1;
|
|
for (let start = 0; start < width * height; start++) {
|
|
if (labels[start] !== 0 || alpha[start] < MIN_ALPHA) continue;
|
|
const label = next++;
|
|
let area = 0;
|
|
let [left, top, right, bottom] = [width, height, -1, -1];
|
|
const stack = [start];
|
|
labels[start] = label;
|
|
while (stack.length > 0) {
|
|
const p = stack.pop();
|
|
area++;
|
|
const x = p % width;
|
|
const y = (p - x) / width;
|
|
if (x < left) left = x;
|
|
if (x > right) right = x;
|
|
if (y < top) top = y;
|
|
if (y > bottom) bottom = y;
|
|
for (const q of [p - 1, p + 1, p - width, p + width]) {
|
|
if (q < 0 || q >= width * height) continue;
|
|
if ((q === p - 1 || q === p + 1) && Math.floor(q / width) !== y) continue;
|
|
if (labels[q] === 0 && alpha[q] >= MIN_ALPHA) {
|
|
labels[q] = label;
|
|
stack.push(q);
|
|
}
|
|
}
|
|
}
|
|
if (area >= minArea) {
|
|
regions.push({ label, left, top, width: right - left + 1, height: bottom - top + 1, area });
|
|
}
|
|
}
|
|
// Reading order: rows of sprites (tops within half the smallest sprite's height), then x.
|
|
const rowTolerance = Math.max(1, Math.min(...regions.map((r) => r.height)) / 2);
|
|
regions.sort((a, b) =>
|
|
Math.abs(a.top - b.top) <= rowTolerance ? a.left - b.left : a.top - b.top,
|
|
);
|
|
return { labels, regions };
|
|
}
|
|
|
|
/**
|
|
* Cuts each sprite out of the sheet as its own trimmed PNG buffer (RGBA): the
|
|
* region's bounding box, with every pixel that isn't part of that region made
|
|
* fully transparent (so a neighbour's glow never comes along).
|
|
*/
|
|
export async function sliceSheet(sharp, source) {
|
|
const { data, info } = await sharp(source)
|
|
.ensureAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
const { width, height } = info;
|
|
const alpha = new Uint8Array(width * height);
|
|
for (let i = 0; i < width * height; i++) alpha[i] = data[i * 4 + 3];
|
|
const { labels, regions } = findRegions(alpha, width, height);
|
|
const pieces = [];
|
|
for (const region of regions) {
|
|
const out = Buffer.alloc(region.width * region.height * 4);
|
|
for (let y = 0; y < region.height; y++) {
|
|
for (let x = 0; x < region.width; x++) {
|
|
const src = (region.top + y) * width + region.left + x;
|
|
const dst = (y * region.width + x) * 4;
|
|
if (labels[src] !== region.label) continue;
|
|
data.copy(out, dst, src * 4, src * 4 + 4);
|
|
}
|
|
}
|
|
pieces.push(
|
|
await sharp(out, { raw: { width: region.width, height: region.height, channels: 4 } })
|
|
.png()
|
|
.toBuffer(),
|
|
);
|
|
}
|
|
return pieces;
|
|
}
|