// 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; }