mirror of
https://github.com/Terncode/pixel.horse.git
synced 2026-09-24 21:55:52 +02:00
493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { times } from 'lodash';
|
|
import { Sprite, Rect } from './types';
|
|
import { findByIndex } from './common';
|
|
import { removeItem } from '../common/utils';
|
|
import { createExtCanvas, saveCanvas } from './canvas-utils';
|
|
|
|
interface ExtSprite extends Sprite {
|
|
index?: number;
|
|
layers?: number;
|
|
duplicateOf?: ExtSprite;
|
|
overlays?: ExtSprite[];
|
|
overlayedOn?: ExtSprite;
|
|
data?: ImageData;
|
|
}
|
|
|
|
function isIdenticalSprite(a: ExtSprite | undefined, b: ExtSprite | undefined): boolean {
|
|
return !!(a && b && a.data && b.data && isIdenticalData(a.data, b.data));
|
|
}
|
|
|
|
function isIdenticalData(a: ImageData, b: ImageData) {
|
|
if (a.width !== b.width || a.height !== b.height)
|
|
return false;
|
|
|
|
const length = (a.width * a.height * 4) | 0;
|
|
const adat = a.data;
|
|
const bdat = b.data;
|
|
|
|
for (let i = 0; i < length; i = (i + 1) | 0) {
|
|
if (adat[i] !== bdat[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isIdenticalChannel(a: Sprite | undefined, b: Sprite | undefined, channel: number) {
|
|
if (!a || !b || a.w !== b.w || a.h !== b.h)
|
|
return false;
|
|
|
|
const adata = a.image.getContext('2d')!.getImageData(a.ox, a.oy, a.w, a.h);
|
|
const bdata = b.image.getContext('2d')!.getImageData(b.ox, b.oy, b.w, b.h);
|
|
const length = (adata.width * adata.height * 4) | 0;
|
|
const adat = adata.data;
|
|
const bdat = bdata.data;
|
|
|
|
for (let i = channel | 0; i < length; i = (i + 4) | 0) {
|
|
if (adat[i] !== bdat[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function trimImageData(data: ImageData): Rect {
|
|
const width = data.width | 0;
|
|
const imageData = data.data;
|
|
|
|
let top = 0;
|
|
let left = 0;
|
|
let right = data.width | 0;
|
|
let bottom = data.height | 0;
|
|
|
|
function isEmpty(x: number, y: number) {
|
|
return imageData[((getIndex(x, y, width) << 2) + 3) | 0] === 0;
|
|
}
|
|
|
|
function isRowEmpty(y: number) {
|
|
for (let x = left | 0; x < right; x = (x + 1) | 0) {
|
|
if (!isEmpty(x | 0, y | 0)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isColEmpty(x: number) {
|
|
for (let y = top | 0; y < bottom; y = (y + 1) | 0) {
|
|
if (!isEmpty(x | 0, y | 0)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
while (bottom > top && isRowEmpty(bottom - 1))
|
|
bottom--;
|
|
while (right > left && isColEmpty(right - 1))
|
|
right--;
|
|
while (top < bottom && isRowEmpty(top))
|
|
top++;
|
|
while (left < right && isColEmpty(left))
|
|
left++;
|
|
|
|
return { y: top, x: left, w: right - left, h: bottom - top };
|
|
}
|
|
|
|
export function getSpriteRect(canvas: HTMLCanvasElement, x: number, y: number, w: number, h: number): Rect {
|
|
const data = canvas.getContext('2d')!.getImageData(x, y, w, h);
|
|
const rect = trimImageData(data);
|
|
return { x: x + rect.x, y: y + rect.y, w: rect.w, h: rect.h };
|
|
}
|
|
|
|
export function imageToSprite(image: HTMLCanvasElement, index: number): ExtSprite {
|
|
const { w, h, x, y } = getSpriteRect(image, 0, 0, image.width, image.height);
|
|
return { image, index, w, h, x: 0, y: 0, ox: x, oy: y };
|
|
}
|
|
|
|
function getIndex(x: number, y: number, outputWidth: number) {
|
|
return ((x | 0) + (((y | 0) * outputWidth) | 0)) | 0;
|
|
}
|
|
|
|
function isEmpty(x: number, y: number, w: number, h: number, outputWidth: number, taken: Uint8Array) {
|
|
outputWidth = outputWidth | 0;
|
|
|
|
if (((x + w) | 0) > outputWidth || ((y + h) | 0) > outputWidth) {
|
|
return false;
|
|
}
|
|
|
|
for (let iy = 0; iy < h; iy++) {
|
|
for (let ix = 0; ix < w; ix++) {
|
|
if (taken[getIndex((ix + x) | 0, (iy + y) | 0, outputWidth)] !== 0) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
interface Line {
|
|
start: number;
|
|
length: number;
|
|
}
|
|
|
|
interface Taken {
|
|
lines: Line[][];
|
|
data: Uint8Array; // TODO: remove completely, just use lines
|
|
}
|
|
|
|
function getFirstFree(outputWidth: number, width: number, height: number, { data, lines }: Taken) {
|
|
const maxY = (outputWidth - height) | 0;
|
|
|
|
for (let y = 0; y < maxY; y = (y + 1) | 0) {
|
|
const spans = lines[y];
|
|
|
|
for (let i = 0; i < spans.length; i++) {
|
|
const span = spans[i];
|
|
const start = span.start | 0;
|
|
const end = (start + span.length - width) | 0;
|
|
|
|
for (let x = start; x < end; x = (x + 1) | 0) {
|
|
if (isEmpty(x, y, width, height, outputWidth, data)) {
|
|
return { x, y, layer: 0 };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(`Cannot find free space for (${width}, ${height}) [getFirstFree]`);
|
|
}
|
|
|
|
function getFirstFreePacked(outputWidth: number, width: number, height: number, takens: Taken[]) {
|
|
const maxY = outputWidth - height;
|
|
|
|
for (let layer = 0; layer < takens.length; layer = (layer + 1) | 0) {
|
|
const { data, lines } = takens[layer];
|
|
|
|
for (let y = 0; y < maxY; y = (y + 1) | 0) {
|
|
const spans = lines[y];
|
|
|
|
for (let i = 0; i < spans.length; i++) {
|
|
const span = spans[i];
|
|
const start = span.start | 0;
|
|
const end = (start + span.length - width) | 0;
|
|
|
|
for (let x = start | 0; x < end; x = (x + 1) | 0) {
|
|
if (isEmpty(x, y, width, height, outputWidth, data)) {
|
|
return { x, y, layer };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(`Cannot find free space for (${width}, ${height}) [getFirstFreePacked]`);
|
|
}
|
|
|
|
function positionSprite(sprite: ExtSprite, outputWidth: number, taken: Taken[], pack: boolean) {
|
|
const layers = sprite.layers || 1;
|
|
const { x, y, layer } = (pack && layers === 1) ?
|
|
getFirstFreePacked(outputWidth, sprite.w, sprite.h, taken) :
|
|
getFirstFree(outputWidth, sprite.w, sprite.h, taken[0]);
|
|
|
|
sprite.x = x;
|
|
sprite.y = y;
|
|
sprite.layer = layer;
|
|
|
|
const w = sprite.w;
|
|
const right = x + w;
|
|
|
|
for (let il = 0; il < layers; il++) {
|
|
const { data, lines } = taken[il + layer];
|
|
|
|
for (let iy = 0; iy < sprite.h; iy++) {
|
|
const yy = y + iy;
|
|
const spans = lines[yy];
|
|
|
|
for (let ix = 0; ix < sprite.w; ix++) {
|
|
const xx = x + ix;
|
|
data[getIndex(xx, yy, outputWidth)] = 1;
|
|
}
|
|
|
|
for (let i = 0; i < spans.length; i++) {
|
|
const span = spans[i];
|
|
const start = span.start;
|
|
const length = span.length;
|
|
const end = start + length;
|
|
|
|
if (start >= right) // right of span
|
|
break;
|
|
|
|
if (end <= right) // left of span
|
|
continue;
|
|
|
|
if (start === x) {
|
|
if (length === w) { // entire span
|
|
spans.splice(i, 1);
|
|
} else { // at the start of span
|
|
span.start += w;
|
|
span.length -= w;
|
|
}
|
|
} else {
|
|
if (end === right) { // at the end of span
|
|
span.length -= w;
|
|
} else { // in the middle of span
|
|
span.length = x - start;
|
|
spans.splice(i + 1, 0, { start: right, length: end - right });
|
|
}
|
|
}
|
|
|
|
// let start = span.start;
|
|
// let lineMoved = false;
|
|
|
|
// for (let ix = 0; ix < sprite.w; ix++) {
|
|
// const xx = x + ix;
|
|
// data[getIndex(xx, yy, outputWidth)] = 1;
|
|
|
|
// if (xx === start) {
|
|
// start++;
|
|
// lineMoved = true;
|
|
// }
|
|
// }
|
|
|
|
// if (lineMoved) {
|
|
// for (let x = start; x < outputWidth && data[getIndex(x, yy, outputWidth)] !== 0; x++) {
|
|
// start++;
|
|
// }
|
|
|
|
// span.start = start;
|
|
// span.length = outputWidth - start;
|
|
// }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function hasShading(s: Sprite) {
|
|
const data = s.image.getContext('2d')!.getImageData(s.ox, s.oy, s.w, s.h);
|
|
|
|
for (let y = 0; y < data.height; y++) {
|
|
for (let x = 0; x < data.width; x++) {
|
|
if (data.data[(x + y * data.width) * 4 + 1] !== 0xff) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function getSpriteImageData(s: ExtSprite): ImageData | undefined {
|
|
if (!s.w || !s.h)
|
|
return undefined;
|
|
|
|
const context = s.image.getContext('2d')!;
|
|
const { width, height, data } = context.getImageData(s.ox, s.oy, s.w, s.h);
|
|
return { width, height, data };
|
|
}
|
|
|
|
export function createSpriteSheet(name: string, images: ExtSprite[], log: boolean, size: number, bg?: string, pack = false) {
|
|
const maxLayers = 4;
|
|
const sprites = images.slice();
|
|
|
|
let outputWidth = size;
|
|
let maxY = 0;
|
|
let areaTaken = 0;
|
|
let deduplicated = 0;
|
|
let layered = 0;
|
|
let tooBig = sprites.find(s => s.w >= outputWidth);
|
|
|
|
while (tooBig) {
|
|
throw new Error(`Sprite too large (${tooBig.w}x${tooBig.h}) in ${name} (${size}x${size})`);
|
|
}
|
|
|
|
sprites
|
|
.forEach(s => s.data = getSpriteImageData(s));
|
|
|
|
sprites
|
|
.forEach((s, i) => {
|
|
if (s.w && s.h) {
|
|
for (let j = 0; j < i; j++) {
|
|
if (!s.duplicateOf && isIdenticalSprite(sprites[j], s)) {
|
|
s.duplicateOf = sprites[j];
|
|
deduplicated++;
|
|
//console.log('duplicate', sprite.name, '==', sprites[j].name);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
sprites
|
|
.filter(s => !s.duplicateOf && s.w && s.h)
|
|
.forEach(s => {
|
|
s.shade = !pack || hasShading(s);
|
|
s.layers = s.shade ? 2 : 1;
|
|
});
|
|
|
|
if (pack) {
|
|
sprites
|
|
.filter(s => !s.duplicateOf && s.shade)
|
|
.reduce((pool, sprite) => {
|
|
const match = pool.find(d => isIdenticalChannel(sprite, d, 1));
|
|
|
|
if (match) {
|
|
match.overlays = match.overlays || [];
|
|
match.overlays.push(sprite);
|
|
sprite.layer = match.layers!;
|
|
sprite.overlayedOn = match;
|
|
match.layers = match.layers! + 1;
|
|
|
|
if (match.layers >= maxLayers) {
|
|
removeItem(pool, match);
|
|
}
|
|
|
|
layered++;
|
|
} else {
|
|
pool.push(sprite);
|
|
}
|
|
|
|
return pool;
|
|
}, [] as ExtSprite[]);
|
|
}
|
|
|
|
sprites.sort((a, b) => ((b.layers || 1) - (a.layers || 1)) || ((b.h * 1024 + b.w) - (a.h * 1024 + a.w)));
|
|
|
|
maxY = 0;
|
|
areaTaken = 0;
|
|
|
|
const taken: Taken[] = times(maxLayers, () => ({
|
|
lines: times(outputWidth, () => [{ start: 0, length: outputWidth }]),
|
|
data: new Uint8Array(outputWidth * outputWidth),
|
|
}));
|
|
|
|
sprites
|
|
.filter(s => !s.duplicateOf && !s.overlayedOn)
|
|
.forEach(s => {
|
|
try {
|
|
positionSprite(s, outputWidth, taken, pack);
|
|
maxY = Math.max(maxY, s.y + s.h);
|
|
areaTaken += s.w * s.h;
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
});
|
|
|
|
if (maxY > outputWidth) {
|
|
throw new Error(`Exceeded sprite sheet size for ${name} (${size}x${size})`);
|
|
}
|
|
|
|
if (log) {
|
|
const efficiency = (areaTaken * 100 / outputWidth / maxY).toFixed();
|
|
|
|
console.log(
|
|
`[sprites] [${name}] Packed ${sprites.length} sprites into ${outputWidth} x ${maxY} sheet, `
|
|
+ `${efficiency}% efficiency, ${deduplicated} deduplicated, ${layered} layered`);
|
|
}
|
|
|
|
sprites
|
|
.filter(s => s.overlayedOn)
|
|
.forEach(s => {
|
|
s.x = s.overlayedOn!.x;
|
|
s.y = s.overlayedOn!.y;
|
|
});
|
|
|
|
sprites
|
|
.filter(s => s.duplicateOf)
|
|
.forEach(s => {
|
|
s.x = s.duplicateOf!.x;
|
|
s.y = s.duplicateOf!.y;
|
|
s.layer = s.duplicateOf!.layer;
|
|
s.shade = s.duplicateOf!.shade;
|
|
});
|
|
|
|
const image = createExtCanvas(outputWidth, outputWidth, 'sprite sheet image');
|
|
const alpha = createExtCanvas(outputWidth, outputWidth, 'sprite sheet alpha');
|
|
const context = image.getContext('2d')!;
|
|
const alphaContext = alpha.getContext('2d')!;
|
|
|
|
if (bg) {
|
|
context.fillStyle = bg;
|
|
context.fillRect(0, 0, outputWidth, outputWidth);
|
|
alphaContext.fillStyle = bg;
|
|
alphaContext.fillRect(0, 0, outputWidth, outputWidth);
|
|
}
|
|
|
|
if (pack) {
|
|
const data = context.getImageData(0, 0, image.width, image.height);
|
|
const alphaData = alphaContext.getImageData(0, 0, image.width, image.height);
|
|
|
|
sprites
|
|
.filter(s => !s.duplicateOf && s.w && s.h)
|
|
.forEach(s => {
|
|
if (s.layer === 3) {
|
|
drawChannel(s.image, alphaData, 0, 0, s.ox, s.oy, s.x, s.y, s.w, s.h);
|
|
} else {
|
|
drawChannel(s.image, data, 0, s.layer || 0, s.ox, s.oy, s.x, s.y, s.w, s.h);
|
|
}
|
|
|
|
if (s.shade) {
|
|
drawChannel(s.image, data, 1, 1, s.ox, s.oy, s.x, s.y, s.w, s.h);
|
|
}
|
|
});
|
|
|
|
context.putImageData(data, 0, 0);
|
|
alphaContext.putImageData(alphaData, 0, 0);
|
|
} else {
|
|
sprites
|
|
.filter(s => !s.duplicateOf && s.w && s.h)
|
|
.forEach(s => context.drawImage(s.image, s.ox, s.oy, s.w, s.h, s.x, s.y, s.w, s.h));
|
|
}
|
|
|
|
return {
|
|
sprites: images.map((_, index) => findByIndex(sprites, index) || null),
|
|
image,
|
|
alpha,
|
|
};
|
|
}
|
|
|
|
function drawChannel(
|
|
src: HTMLCanvasElement, dst: ImageData, srcChannel: number, dstChannel: number,
|
|
sx: number, sy: number, dx: number, dy: number, w: number, h: number
|
|
) {
|
|
const srcData = src.getContext('2d')!.getImageData(sx, sy, w, h);
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
dst.data[((x + dx) + (y + dy) * dst.width) * 4 + dstChannel] =
|
|
srcData.data[(x + y * srcData.width) * 4 + srcChannel];
|
|
}
|
|
}
|
|
}
|
|
|
|
export function saveSpriteSheet(filePath: string, canvas: HTMLCanvasElement) {
|
|
saveCanvas(filePath, canvas);
|
|
return path.basename(filePath);
|
|
}
|
|
|
|
export function saveSpriteSheetAsBinary(filePath: string, canvas: HTMLCanvasElement) {
|
|
const context = canvas.getContext('2d')!;
|
|
const data = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
fs.writeFileSync(filePath, Buffer.from(data.data.buffer));
|
|
}
|
|
|
|
export function saveCanvasAsRaw(filePath: string, canvas: HTMLCanvasElement) {
|
|
const buffer = Buffer.alloc(4 + 4 + 4 + 4 * canvas.width * canvas.height);
|
|
buffer.writeUInt8('R'.charCodeAt(0), 0);
|
|
buffer.writeUInt8('A'.charCodeAt(0), 1);
|
|
buffer.writeUInt8('W'.charCodeAt(0), 2);
|
|
buffer.writeUInt8(' '.charCodeAt(0), 3);
|
|
buffer.writeUInt32LE(canvas.width, 4);
|
|
buffer.writeUInt32LE(canvas.height, 8);
|
|
const data = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height);
|
|
buffer.set(data.data, 12);
|
|
fs.writeFileSync(filePath, buffer);
|
|
}
|