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