deadhead-space-freight-tycoon/apps/web/scripts/map-art.mjs

97 lines
3.6 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";
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] };
export const BACKGROUND_MAX = 2048;
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 {
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;
}
// Trim transparent padding once, then export each size from the trimmed image.
const trimmed = await sharp(source).trim().png().toBuffer();
const [one, two] = SPRITE_SIZES[asset.kind];
for (const [suffix, size] of [
["1x", one],
["2x", two],
]) {
await sharp(trimmed)
.resize({ width: size, height: size, fit: "inside" })
.png(png)
.toFile(join(outDir, `${id}@${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));
}