137 lines
5.2 KiB
JavaScript
137 lines
5.2 KiB
JavaScript
// Generates optimized map art from the user's source images (CR-003, decision 0.5.37).
|
|
//
|
|
// node scripts/map-art.mjs [outDir] regenerate (default public/assets/map-gen)
|
|
// node scripts/map-art.mjs --if-available as above, but if sharp is not installed,
|
|
// only check the committed output exists
|
|
//
|
|
// Sources in the manifest's `sourceDir` are only ever read.
|
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { sliceSheet } from "./slice.mjs";
|
|
|
|
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const manifest = JSON.parse(readFileSync(join(webRoot, "src/map/art/manifest.json"), "utf8"));
|
|
|
|
/** Long-side sizes in px for the 1x and 2x variants of each sprite kind. */
|
|
export const SPRITE_SIZES = {
|
|
body: [256, 512],
|
|
badge: [256, 512],
|
|
ship: [128, 256],
|
|
// Each rock sliced from a sheet (CR-016 §5).
|
|
rocks: [64, 128],
|
|
};
|
|
export const BACKGROUND_MAX = 2048;
|
|
/** A seamless strip (the belt's haze, CR-016 §5) is kept within this size. */
|
|
export const STRIP_MAX = { width: 4096, height: 256 };
|
|
export const DEFAULT_OUT_DIR = join(webRoot, "public/assets/map-gen");
|
|
|
|
/** Every file the script writes for the manifest, relative to the output folder. */
|
|
export function expectedOutputs() {
|
|
const outputs = [];
|
|
for (const [id, asset] of Object.entries(manifest.assets)) {
|
|
if (asset.file === null) continue;
|
|
if (asset.kind === "background") outputs.push({ id, file: `${id}.webp`, kind: asset.kind });
|
|
else if (asset.kind === "strip") outputs.push({ id, file: `${id}.png`, kind: asset.kind });
|
|
else if (asset.kind === "rocks") {
|
|
const [one, two] = SPRITE_SIZES.rocks;
|
|
for (let i = 1; i <= asset.pieces; i++) {
|
|
outputs.push({ id, file: `${id}-${i}@1x.png`, kind: asset.kind, longSide: one });
|
|
outputs.push({ id, file: `${id}-${i}@2x.png`, kind: asset.kind, longSide: two });
|
|
}
|
|
} else {
|
|
const [one, two] = SPRITE_SIZES[asset.kind];
|
|
outputs.push({ id, file: `${id}@1x.png`, kind: asset.kind, longSide: one });
|
|
outputs.push({ id, file: `${id}@2x.png`, kind: asset.kind, longSide: two });
|
|
}
|
|
}
|
|
return outputs;
|
|
}
|
|
|
|
const png = { compressionLevel: 9, adaptiveFiltering: false, palette: false };
|
|
|
|
async function generate(sharp, outDir) {
|
|
mkdirSync(outDir, { recursive: true });
|
|
for (const [id, asset] of Object.entries(manifest.assets)) {
|
|
if (asset.file === null) {
|
|
console.warn(`map-art: ${id} has no source file; the map will use its placeholder`);
|
|
continue;
|
|
}
|
|
const source = join(webRoot, manifest.sourceDir, asset.file);
|
|
if (asset.kind === "background") {
|
|
await sharp(source)
|
|
.resize({
|
|
width: BACKGROUND_MAX,
|
|
height: BACKGROUND_MAX,
|
|
fit: "inside",
|
|
withoutEnlargement: true,
|
|
})
|
|
.webp({ quality: 80, effort: 6 })
|
|
.toFile(join(outDir, `${id}.webp`));
|
|
continue;
|
|
}
|
|
if (asset.kind === "strip") {
|
|
await sharp(source)
|
|
.resize({ ...STRIP_MAX, fit: "inside", withoutEnlargement: true })
|
|
.png(png)
|
|
.toFile(join(outDir, `${id}.png`));
|
|
continue;
|
|
}
|
|
if (asset.kind === "rocks") {
|
|
// A sheet of separate rocks: one sprite per rock (CR-016 §5).
|
|
const pieces = await sliceSheet(sharp, source);
|
|
if (pieces.length !== asset.pieces) {
|
|
throw new Error(
|
|
`map-art: ${asset.file} has ${pieces.length} separate sprites; the manifest says ${asset.pieces}`,
|
|
);
|
|
}
|
|
for (const [i, piece] of pieces.entries()) {
|
|
await writeSizes(sharp, piece, SPRITE_SIZES.rocks, join(outDir, `${id}-${i + 1}`));
|
|
}
|
|
continue;
|
|
}
|
|
// Trim transparent padding once, then export each size from the trimmed image.
|
|
const trimmed = await sharp(source).trim().png().toBuffer();
|
|
await writeSizes(sharp, trimmed, SPRITE_SIZES[asset.kind], join(outDir, id));
|
|
}
|
|
}
|
|
|
|
/** Writes `<base>@1x.png` and `<base>@2x.png` with the given long sides. */
|
|
async function writeSizes(sharp, image, [one, two], base) {
|
|
for (const [suffix, size] of [
|
|
["1x", one],
|
|
["2x", two],
|
|
]) {
|
|
await sharp(image)
|
|
.resize({ width: size, height: size, fit: "inside" })
|
|
.png(png)
|
|
.toFile(`${base}@${suffix}.png`);
|
|
}
|
|
}
|
|
|
|
async function main(args) {
|
|
const ifAvailable = args.includes("--if-available");
|
|
const outDir = resolve(args.find((a) => !a.startsWith("--")) ?? DEFAULT_OUT_DIR);
|
|
let sharp;
|
|
try {
|
|
sharp = (await import("sharp")).default;
|
|
} catch (error) {
|
|
if (!ifAvailable) throw error;
|
|
const missing = expectedOutputs().filter((o) => !existsSync(join(outDir, o.file)));
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`map-art: sharp unavailable and output missing: ${missing.map((o) => o.file)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
console.warn("map-art: sharp unavailable; using the committed output");
|
|
return;
|
|
}
|
|
await generate(sharp, outDir);
|
|
console.log(`map-art: wrote ${expectedOutputs().length} files to ${outDir}`);
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
await main(process.argv.slice(2));
|
|
}
|