#!/usr/bin/env node /** * 将 build/assets/level-prefabs 拆为: * - shell: config.json + index.js(进 assets_all 首屏) * - 每 15 关一包: assets/level-prefabs/import/.../*.json * * 输出 levels-manifest.json 供 loader 按 levelId 按需下载(同包关卡共享 bundle)。 * LEVELS_PER_PACK 可覆盖每包关卡数,默认 15。 */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const { formatBytes } = require('./package-optimize'); const DEFAULT_LEVELS_PER_PACK = 15; function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); } function hashFileMd5(filePath) { return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex'); } function zipDir(srcDir, outFile) { const absOut = path.resolve(outFile); mkdirp(path.dirname(absOut)); if (fs.existsSync(absOut)) fs.unlinkSync(absOut); execSync(`cd "${path.resolve(srcDir)}" && zip -0 -q -r "${absOut}" .`, { stdio: 'pipe' }); } function walkJsonFiles(dir, out = []) { if (!fs.existsSync(dir)) return out; for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, ent.name); if (ent.isDirectory()) walkJsonFiles(p, out); else if (ent.name.endsWith('.json')) out.push(p); } return out; } function levelsPerPack() { const n = parseInt(process.env.LEVELS_PER_PACK || String(DEFAULT_LEVELS_PER_PACK), 10); return Number.isFinite(n) && n > 0 ? n : DEFAULT_LEVELS_PER_PACK; } /** 扫描 import/*.json,从预制体名 Level{id} 建立 levelId → 相对路径 */ function indexImportFiles(levelPrefabsDir) { const importRoot = path.join(levelPrefabsDir, 'import'); const byLevelId = new Map(); for (const abs of walkJsonFiles(importRoot)) { const rel = path.relative(levelPrefabsDir, abs).split(path.sep).join('/'); const txt = fs.readFileSync(abs, 'utf8'); const m = /"Level(\d+)"/.exec(txt); if (!m) continue; const levelId = m[1]; if (byLevelId.has(levelId)) { console.warn(`>>> 警告: Level${levelId} 重复 import,保留 ${byLevelId.get(levelId)}`); continue; } byLevelId.set(levelId, rel); } return byLevelId; } /** 从 config.json 校验 path ↔ uuid */ function readConfigLevels(levelPrefabsDir) { const cfg = JSON.parse(fs.readFileSync(path.join(levelPrefabsDir, 'config.json'), 'utf8')); const out = new Map(); for (const [idx, entry] of Object.entries(cfg.paths || {})) { const m = /^level-prefabs\/Level(\d+)$/.exec(entry[0]); if (!m) continue; out.set(m[1], { path: entry[0], pathIndex: idx, uuid: (cfg.uuids || [])[+idx], }); } return out; } /** * @param {string} levelPrefabsDir Cocos 构建产物 assets/level-prefabs * @param {string} webglDir 输出 WebGL/*.bundle * @param {string} manifestOutPath 输出 levels-manifest.json */ function splitLevelBundles(levelPrefabsDir, webglDir, manifestOutPath, packTmpDir) { if (!fs.existsSync(path.join(levelPrefabsDir, 'config.json'))) { throw new Error(`缺少 level-prefabs/config.json: ${levelPrefabsDir}`); } const packSize = levelsPerPack(); const configLevels = readConfigLevels(levelPrefabsDir); const importByLevel = indexImportFiles(levelPrefabsDir); mkdirp(webglDir); const stageBase = packTmpDir || path.join(webglDir, '..', '.level-pack-tmp'); mkdirp(stageBase); const ready = []; const missing = []; for (const [levelId, meta] of configLevels) { const importRel = importByLevel.get(levelId); if (!importRel) { missing.push(levelId); continue; } ready.push({ levelId, meta, importRel }); } ready.sort((a, b) => Number(a.levelId) - Number(b.levelId)); const manifest = { version: 2, packSize, shell: ['assets/level-prefabs/config.json', 'assets/level-prefabs/index.js'], levels: {}, }; let packed = 0; let packs = 0; let totalBytes = 0; for (let i = 0; i < ready.length; i += packSize) { const group = ready.slice(i, i + packSize); const firstId = group[0].levelId; const lastId = group[group.length - 1].levelId; const stageRoot = path.join(stageBase, `pack-${firstId}-${lastId}`); const assetRoot = path.join(stageRoot, 'assets', 'level-prefabs'); for (const item of group) { mkdirp(path.dirname(path.join(assetRoot, item.importRel))); fs.copyFileSync( path.join(levelPrefabsDir, item.importRel), path.join(assetRoot, item.importRel), ); } const zipTmp = path.join(stageBase, `pack-${firstId}-${lastId}.zip`); zipDir(stageRoot, zipTmp); const hash = hashFileMd5(zipTmp); const bundleName = `defaultlocalgroup_levels_${firstId}_${lastId}_${hash}.bundle`; fs.copyFileSync(zipTmp, path.join(webglDir, bundleName)); const size = fs.statSync(zipTmp).size; totalBytes += size; packs += 1; for (const item of group) { manifest.levels[item.levelId] = { bundle: bundleName, uuid: item.meta.uuid, }; packed += 1; } fs.rmSync(stageRoot, { recursive: true, force: true }); fs.unlinkSync(zipTmp); } fs.rmSync(stageBase, { recursive: true, force: true }); fs.writeFileSync(manifestOutPath, JSON.stringify(manifest), 'utf8'); console.log(`>>> 关卡分包: ${packed} 关 / ${packs} 包 (${packSize} 关/包), 合计 ${formatBytes(totalBytes)}, 均 ${formatBytes(Math.round(totalBytes / Math.max(packs, 1)))}/包`); if (missing.length) { console.warn(`>>> 警告: ${missing.length} 关在 config 中无 import 文件 (例: ${missing.slice(0, 5).join(', ')})`); } return { manifest, packed, packs, packSize, missing, totalBytes }; } module.exports = { DEFAULT_LEVELS_PER_PACK, splitLevelBundles, indexImportFiles, readConfigLevels, }; if (require.main === module) { const levelDir = path.resolve(process.argv[2]); const webgl = path.resolve(process.argv[3]); const manifest = path.resolve(process.argv[4] || path.join(path.dirname(webgl), 'levels-manifest.json')); splitLevelBundles(levelDir, webgl, manifest); }