188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
// @vitest-environment node
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import sharp from "sharp";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { artManifest, pieceIds } from "../src/map/art/manifest";
|
|
// @ts-expect-error: a plain .mjs build script, tested directly.
|
|
import { MIN_ALPHA, sliceSheet } from "../scripts/slice.mjs";
|
|
|
|
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const script = join(webRoot, "scripts/map-art.mjs");
|
|
sharp.cache(false);
|
|
/** Reads through a buffer so sharp never holds the temp files open (Windows). */
|
|
const image = (path: string) => sharp(readFileSync(path));
|
|
const SIZES = {
|
|
body: [256, 512],
|
|
badge: [256, 512],
|
|
ship: [128, 256],
|
|
rocks: [64, 128],
|
|
} as const;
|
|
|
|
const run = (outDir: string) =>
|
|
execFileSync(process.execPath, [script, outDir], { cwd: webRoot, stdio: "pipe" });
|
|
|
|
describe("map art optimization script (CR-003)", () => {
|
|
let first: string;
|
|
let second: string;
|
|
beforeAll(() => {
|
|
first = mkdtempSync(join(tmpdir(), "map-art-a-"));
|
|
second = mkdtempSync(join(tmpdir(), "map-art-b-"));
|
|
run(first);
|
|
run(second);
|
|
}, 120_000);
|
|
afterAll(() => {
|
|
rmSync(first, { recursive: true, force: true });
|
|
rmSync(second, { recursive: true, force: true });
|
|
});
|
|
|
|
const present = Object.entries(artManifest.assets).filter(([, a]) => a.file !== null);
|
|
|
|
it("writes every expected file at the right size", async () => {
|
|
for (const [id, asset] of present) {
|
|
if (asset.kind === "background") {
|
|
const meta = await image(join(first, `${id}.webp`)).metadata();
|
|
expect(meta.format).toBe("webp");
|
|
expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(2048);
|
|
continue;
|
|
}
|
|
if (asset.kind === "strip") {
|
|
const meta = await image(join(first, `${id}.png`)).metadata();
|
|
expect(meta.width!).toBeLessThanOrEqual(4096);
|
|
expect(meta.height!).toBeLessThanOrEqual(256);
|
|
continue;
|
|
}
|
|
// A sliced sheet writes one sprite per piece (CR-016).
|
|
const bases = asset.kind === "rocks" ? pieceIds(id, asset) : [id];
|
|
const [one, two] = SIZES[asset.kind];
|
|
for (const base of bases) {
|
|
for (const [suffix, longSide] of [
|
|
["1x", one],
|
|
["2x", two],
|
|
] as const) {
|
|
const meta = await image(join(first, `${base}@${suffix}.png`)).metadata();
|
|
expect(Math.max(meta.width!, meta.height!), `${base}@${suffix}`).toBe(longSide);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("produces identical files when run twice", () => {
|
|
const files = readdirSync(first).sort();
|
|
expect(readdirSync(second).sort()).toEqual(files);
|
|
for (const file of files) {
|
|
expect(readFileSync(join(second, file)).equals(readFileSync(join(first, file))), file).toBe(
|
|
true,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("matches the committed output pixel for pixel", async () => {
|
|
// Pixels, not bytes: PNG compression may differ between platforms (CI runs Linux).
|
|
const committed = join(webRoot, "public/assets/map-gen");
|
|
for (const file of readdirSync(first).filter((f) => f.endsWith(".png"))) {
|
|
const [fresh, stored] = await Promise.all([
|
|
image(join(first, file)).raw().toBuffer(),
|
|
image(join(committed, file)).raw().toBuffer(),
|
|
]);
|
|
expect(stored.equals(fresh), file).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("keeps the background under 1 MB", () => {
|
|
expect(statSync(join(first, "bg.webp")).size).toBeLessThan(1024 * 1024);
|
|
});
|
|
|
|
it("has source sprites with transparent backgrounds", async () => {
|
|
for (const [id, asset] of present) {
|
|
if (asset.kind === "background") continue;
|
|
const { data, info } = await image(join(webRoot, artManifest.sourceDir, asset.file!))
|
|
.ensureAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
const alpha = (x: number, y: number) => data[(y * info.width + x) * info.channels + 3];
|
|
const corners = [
|
|
alpha(0, 0),
|
|
alpha(info.width - 1, 0),
|
|
alpha(0, info.height - 1),
|
|
alpha(info.width - 1, info.height - 1),
|
|
];
|
|
expect(
|
|
// Alpha up to 2/255 is invisible (ship-lighter.png has a stray alpha-1 corner pixel).
|
|
corners.every((a) => a! <= 2),
|
|
`${asset.file} (${id}): background isn't transparent`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
/** A transparent sheet with an opaque square at each `[x, y, size]`, plus faint glow and specks. */
|
|
async function sheet(squares: [number, number, number][]): Promise<Buffer> {
|
|
const width = 200;
|
|
const height = 120;
|
|
const data = Buffer.alloc(width * height * 4);
|
|
const paint = (x: number, y: number, a: number) => {
|
|
const i = (y * width + x) * 4;
|
|
data[i] = 180;
|
|
data[i + 1] = 160;
|
|
data[i + 2] = 140;
|
|
data[i + 3] = Math.max(data[i + 3]!, a);
|
|
};
|
|
for (const [left, top, size] of squares) {
|
|
// A faint halo bridging toward the neighbours, below the slicing threshold.
|
|
for (let y = top - 6; y < top + size + 6; y++) {
|
|
for (let x = left - 6; x < left + size + 6; x++) {
|
|
if (x >= 0 && y >= 0 && x < width && y < height) paint(x, y, MIN_ALPHA - 6);
|
|
}
|
|
}
|
|
for (let y = top; y < top + size; y++)
|
|
for (let x = left; x < left + size; x++) paint(x, y, 255);
|
|
}
|
|
// A stray speck: far too small to be a sprite.
|
|
paint(195, 115, 255);
|
|
return sharp(data, { raw: { width, height, channels: 4 } })
|
|
.png()
|
|
.toBuffer();
|
|
}
|
|
|
|
describe("slicing a sprite sheet (Step 16.5)", () => {
|
|
it("cuts three separate rocks into exactly three trimmed sprites", async () => {
|
|
const pieces: Buffer[] = await sliceSheet(
|
|
sharp,
|
|
await sheet([
|
|
[10, 10, 30],
|
|
[80, 20, 20],
|
|
[140, 60, 40],
|
|
]),
|
|
);
|
|
expect(pieces).toHaveLength(3);
|
|
const sizes = await Promise.all(pieces.map((piece) => sharp(piece).metadata()));
|
|
// Reading order (top row left to right), each trimmed to its own square.
|
|
expect(sizes.map((m) => [m.width, m.height])).toEqual([
|
|
[30, 30],
|
|
[20, 20],
|
|
[40, 40],
|
|
]);
|
|
});
|
|
|
|
it("keeps a single rock as one sprite", async () => {
|
|
expect(await sliceSheet(sharp, await sheet([[60, 30, 50]]))).toHaveLength(1);
|
|
});
|
|
|
|
it("drops a neighbour's glow from each sprite", async () => {
|
|
const [piece] = await sliceSheet(
|
|
sharp,
|
|
await sheet([
|
|
[10, 10, 30],
|
|
[44, 10, 30],
|
|
]),
|
|
);
|
|
const { data } = await sharp(piece).raw().toBuffer({ resolveWithObject: true });
|
|
// Every pixel is either the rock (opaque) or cleared, never the faint halo.
|
|
for (let i = 3; i < data.length; i += 4) expect([0, 255]).toContain(data[i]);
|
|
});
|
|
});
|