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

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