|
| 1 | +import { appendFileSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; |
| 2 | +import { basename, extname, join, relative, sep } from "node:path"; |
| 3 | + |
| 4 | +const root = process.cwd(); |
| 5 | +const modelsDir = join(root, "models"); |
| 6 | +const configPath = join(root, "sprite-sheet-helper.generated.json"); |
| 7 | +const supportedExtensions = new Set([".fbx", ".glb", ".gltf", ".obj"]); |
| 8 | + |
| 9 | +function envString(name, fallback) { |
| 10 | + const value = process.env[name]; |
| 11 | + return value === undefined || value === "" ? fallback : value; |
| 12 | +} |
| 13 | + |
| 14 | +function envNumber(name, fallback) { |
| 15 | + const value = envString(name, String(fallback)); |
| 16 | + const parsed = Number(value); |
| 17 | + if (!Number.isFinite(parsed)) { |
| 18 | + throw new Error(`${name} must be a number. Received: ${value}`); |
| 19 | + } |
| 20 | + return parsed; |
| 21 | +} |
| 22 | + |
| 23 | +function envInteger(name, fallback) { |
| 24 | + const value = envNumber(name, fallback); |
| 25 | + if (!Number.isInteger(value)) { |
| 26 | + throw new Error(`${name} must be an integer. Received: ${value}`); |
| 27 | + } |
| 28 | + return value; |
| 29 | +} |
| 30 | + |
| 31 | +function envBoolean(name, fallback) { |
| 32 | + const value = envString(name, fallback ? "true" : "false").toLowerCase(); |
| 33 | + if (["true", "1", "yes", "on"].includes(value)) return true; |
| 34 | + if (["false", "0", "no", "off"].includes(value)) return false; |
| 35 | + throw new Error(`${name} must be true or false. Received: ${value}`); |
| 36 | +} |
| 37 | + |
| 38 | +function toRepoPath(path) { |
| 39 | + return relative(root, path).split(sep).join("/"); |
| 40 | +} |
| 41 | + |
| 42 | +function toModelPath(path) { |
| 43 | + return relative(modelsDir, path).split(sep).join("/"); |
| 44 | +} |
| 45 | + |
| 46 | +function slugify(path, usedIds) { |
| 47 | + const withoutExtension = toModelPath(path).replace(/\.[^.]+$/, ""); |
| 48 | + const base = |
| 49 | + withoutExtension |
| 50 | + .toLowerCase() |
| 51 | + .replace(/[^a-z0-9]+/g, "-") |
| 52 | + .replace(/^-+|-+$/g, "") || basename(path, extname(path)).toLowerCase(); |
| 53 | + |
| 54 | + let id = base; |
| 55 | + let suffix = 2; |
| 56 | + while (usedIds.has(id)) { |
| 57 | + id = `${base}-${suffix}`; |
| 58 | + suffix += 1; |
| 59 | + } |
| 60 | + usedIds.add(id); |
| 61 | + return id; |
| 62 | +} |
| 63 | + |
| 64 | +function walkModels(dir) { |
| 65 | + let entries; |
| 66 | + try { |
| 67 | + entries = readdirSync(dir, { withFileTypes: true }); |
| 68 | + } catch (error) { |
| 69 | + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { |
| 70 | + return []; |
| 71 | + } |
| 72 | + throw error; |
| 73 | + } |
| 74 | + |
| 75 | + const files = []; |
| 76 | + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { |
| 77 | + const path = join(dir, entry.name); |
| 78 | + if (entry.isDirectory()) { |
| 79 | + files.push(...walkModels(path)); |
| 80 | + continue; |
| 81 | + } |
| 82 | + if (!entry.isFile()) continue; |
| 83 | + if (supportedExtensions.has(extname(entry.name).toLowerCase())) { |
| 84 | + files.push(path); |
| 85 | + } |
| 86 | + } |
| 87 | + return files; |
| 88 | +} |
| 89 | + |
| 90 | +const modelFiles = walkModels(modelsDir); |
| 91 | +const usedIds = new Set(); |
| 92 | +const jobs = modelFiles.map((path) => { |
| 93 | + const id = slugify(path, usedIds); |
| 94 | + return { |
| 95 | + id, |
| 96 | + input: toRepoPath(path), |
| 97 | + output: `dist/sprites/${id}`, |
| 98 | + }; |
| 99 | +}); |
| 100 | + |
| 101 | +const config = { |
| 102 | + defaults: { |
| 103 | + format: envString("SPRITE_FORMAT", "spritesheet"), |
| 104 | + workflow: envString("SPRITE_WORKFLOW", "topdown-4dir"), |
| 105 | + frames: envInteger("SPRITE_FRAMES", 4), |
| 106 | + fps: envInteger("SPRITE_FPS", 10), |
| 107 | + width: envInteger("SPRITE_WIDTH", 64), |
| 108 | + height: envInteger("SPRITE_HEIGHT", 64), |
| 109 | + normalMap: envBoolean("SPRITE_NORMAL_MAP", true), |
| 110 | + atlasLayout: envString("SPRITE_ATLAS_LAYOUT", "rows"), |
| 111 | + atlasPadding: envInteger("SPRITE_ATLAS_PADDING", 0), |
| 112 | + atlasBleed: envInteger("SPRITE_ATLAS_BLEED", 0), |
| 113 | + atlasScale: envNumber("SPRITE_ATLAS_SCALE", 1), |
| 114 | + maxAtlasSize: envInteger("SPRITE_MAX_ATLAS_SIZE", 2048), |
| 115 | + multiPage: envBoolean("SPRITE_MULTI_PAGE", false), |
| 116 | + }, |
| 117 | + jobs, |
| 118 | +}; |
| 119 | + |
| 120 | +mkdirSync("dist", { recursive: true }); |
| 121 | +writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); |
| 122 | + |
| 123 | +if (process.env.GITHUB_OUTPUT) { |
| 124 | + appendFileSync(process.env.GITHUB_OUTPUT, `has-jobs=${jobs.length > 0}\n`); |
| 125 | + appendFileSync(process.env.GITHUB_OUTPUT, `job-count=${jobs.length}\n`); |
| 126 | +} |
| 127 | + |
| 128 | +console.log(`Found ${jobs.length} model${jobs.length === 1 ? "" : "s"}.`); |
| 129 | +for (const job of jobs) { |
| 130 | + console.log(`- ${job.id}: ${job.input} -> ${job.output}`); |
| 131 | +} |
0 commit comments