// 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 `@1x.png` and `@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)); }